내부 밸런스 비교 CLI와 조건별 대조 실험을 추가한다

This commit is contained in:
2026-09-29 10:55:43 +00:00
parent b060bc83bf
commit 4b00c1052d
7 changed files with 990 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
// Internal experiment design. No router, database, server seed or gameplay mutation.
export const DESIGN_VERSION = 'balance-lab-v2';
export const BUILDS = {
martial: { leadership: 90, strength: 90, intel: 30 },
scholar: { leadership: 90, strength: 30, intel: 90 },
command: { leadership: 150, strength: 30, intel: 30 },
hybrid: { leadership: 30, strength: 90, intel: 90 },
};
export const BASE_UNITS = [1100, 1200, 1300, 1400, 1500];
export const nativeBuild = (unit) =>
Math.floor(unit / 100) === 14 ? 'scholar' : Math.floor(unit / 100) === 15 ? 'command' : 'martial';
export function general(id, unit, build, crew = 7000) {
return {
no: id,
name: `fixture-${id}`,
nation: id,
city: id,
turntime: '2000-01-01 00:00:00',
personal: null,
special: 'None',
special2: null,
crew,
crewtype: unit,
atmos: 100,
train: 100,
...BUILDS[build],
intel_exp: 0,
book: null,
strength_exp: 0,
weapon: null,
injury: 0,
leadership_exp: 0,
horse: null,
item: null,
explevel: 0,
experience: 1000,
dedication: 1000,
officer_level: 1,
officer_city: 0,
gold: 100000,
rice: 100000,
dex1: 1000,
dex2: 1000,
dex3: 1000,
dex4: 1000,
dex5: 1000,
defence_train: 0,
recent_war: null,
warnum: 0,
killnum: 0,
killcrew: 0,
};
}
function city(id, wall) {
return {
city: id,
nation: id,
supply: 1,
name: `fixture-city-${id}`,
pop: 50000,
agri: 1000,
comm: 1000,
secu: 1000,
def: wall,
wall,
trust: 100,
level: 5,
pop_max: 100000,
agri_max: 2000,
comm_max: 2000,
secu_max: 2000,
def_max: Math.max(2000, wall),
wall_max: Math.max(2000, wall),
dead: 0,
state: 0,
conflict: '{}',
};
}
function nation(id, type = 'che_중립') {
return {
type,
tech: 3000,
level: 1,
capital: id,
nation: id,
name: `fixture-nation-${id}`,
gold: 100000,
rice: 100000,
gennum: 10,
};
}
export function makePayload(unitSet, cell, seed, treatment = true) {
const { build, opponentBuild = build, unit, opponent, mode, role, budget = 'equal-crew' } = cell;
if (!BUILDS[build] || !BUILDS[opponentBuild]) throw new Error('Unknown build');
const lookup = (id) => {
const found = unitSet.crewTypes.find((entry) => entry.id === id);
if (!found) throw new Error(`Unknown unit ${id}`);
return found;
};
const count = (id, buildKey) =>
budget === 'capacity'
? BUILDS[buildKey].leadership * 100
: budget === 'equal-gold'
? Math.min(BUILDS[buildKey].leadership * 100, Math.floor((7000 * 9) / lookup(id).cost))
: Math.min(7000, BUILDS[buildKey].leadership * 100);
const subject = general(role === 'attack' ? 1 : 2, unit, build, count(unit, build));
const other = general(role === 'attack' ? 2 : 1, opponent, opponentBuild, count(opponent, opponentBuild));
if (treatment) Object.assign(subject, cell.generalPatch ?? {});
if (cell.condition === 'depleted') {
subject.crew = Math.floor(subject.crew * 0.5);
subject.train = 70;
subject.atmos = 70;
}
const attack = role === 'attack' ? subject : other;
const defend = role === 'attack' ? other : subject;
const payload = {
action: 'battle',
repeatCnt: 1,
seed,
year: 200,
month: 1,
attackerGeneral: attack,
attackerCity: city(1, 1000),
attackerNation: nation(1),
defenderGenerals:
mode === 'siege'
? []
: mode === 'line'
? [defend, { ...defend, no: 3, name: 'fixture-3' }, { ...defend, no: 4, name: 'fixture-4' }]
: [defend],
defenderCity: city(2, mode === 'field' ? 0 : (cell.wall ?? 1000)),
defenderNation: nation(2),
unitSet,
scenarioEffect: null,
config: {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
maxGeneralStat: 255,
statUpgradeLimit: 30,
maxTechLevel: 12,
castleCrewTypeId: 1000,
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
},
time: { year: 200, month: 1, startYear: 180 },
};
if (treatment && cell.nationTrait) {
payload[role === 'attack' ? 'attackerNation' : 'defenderNation'].type = cell.nationTrait;
}
if (mode === 'siege' && role !== 'attack') throw new Error('Siege subject must attack');
return payload;
}
export function summarize(values) {
if (!values.length || values.some((value) => !Number.isFinite(value))) throw new Error('Invalid samples');
const n = values.length;
const mean = values.reduce((a, b) => a + b, 0) / n;
const variance = n > 1 ? values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1) : null;
// Normal-approximation interval, descriptive screening only; never a multiple-testing gate.
const half = variance === null ? null : 1.96 * Math.sqrt(variance / n);
const sorted = values.toSorted((a, b) => a - b);
return {
n,
mean,
sd: variance === null ? null : Math.sqrt(variance),
ci95: half === null ? null : [mean - half, mean + half],
p10: sorted[Math.floor((n - 1) * 0.1)],
p50: sorted[Math.floor((n - 1) * 0.5)],
p90: sorted[Math.floor((n - 1) * 0.9)],
min: sorted[0],
max: sorted[n - 1],
};
}
export function observe(outcome, payload, role) {
const subjectId = role === 'attack' ? 1 : 2;
const report = outcome.reports.find((entry) => entry.type === 'general' && entry.id === subjectId);
if (!report) throw new Error('Subject report missing');
const ownStart = role === 'attack' ? payload.attackerGeneral : payload.defenderGenerals[0];
const ownEnd = role === 'attack' ? outcome.attacker : outcome.defenders[0];
const enemyReports = outcome.reports.filter((entry) => entry.type === 'general' && entry.id !== subjectId);
const fieldKills = enemyReports.reduce((sum, entry) => sum + entry.dead, 0);
const wall = outcome.reports.find((entry) => entry.type === 'city');
const enemyStart =
role === 'attack'
? payload.defenderGenerals.reduce((sum, entry) => sum + entry.crew, 0)
: payload.attackerGeneral.crew;
const enemyEnd =
role === 'attack' ? outcome.defenders.reduce((sum, entry) => sum + entry.crew, 0) : outcome.attacker.crew;
return {
// Fractional loss difference is bounded and comparable across troop budgets, unlike kill/death ratios.
exchange: enemyStart ? fieldKills / enemyStart - report.dead / ownStart.crew : null,
fieldKills,
dead: report.dead,
remaining: ownEnd.crew,
rice: ownStart.rice - ownEnd.rice,
goldGain: ownEnd.gold - ownStart.gold,
experienceGain: ownEnd.experience - ownStart.experience,
dedicationGain: ownEnd.dedication - ownStart.dedication,
injury: ownEnd.injury - ownStart.injury,
phase: report.phase ?? 0,
wallDamage: role === 'attack' ? (wall?.dead ?? 0) : 0,
breakthrough: Number(outcome.conquered),
annihilated: Number(ownEnd.crew === 0),
enemyAnnihilated: enemyStart ? Number(enemyEnd === 0) : null,
};
}
export function buildPlan(catalog, suite) {
const cells = [];
const add = (cell) => cells.push({ id: String(cells.length).padStart(5, '0'), ...cell });
if (suite === 'families' || suite === 'tiers') {
const candidates =
suite === 'families'
? catalog.units.filter((unit) => BASE_UNITS.includes(unit.id))
: catalog.units.filter((unit) =>
unit.requirements.some((req) => req.type === 'ReqTech' && req.tech === 3000)
);
for (const unit of candidates)
for (const enemy of candidates) {
for (const budget of ['equal-crew', 'equal-gold', 'capacity'])
for (const role of ['attack', 'defend']) {
add({
category: suite,
key: String(unit.id),
unit: unit.id,
opponent: enemy.id,
build: nativeBuild(unit.id),
opponentBuild: nativeBuild(enemy.id),
budget,
role,
mode: 'field',
});
}
}
} else if (suite === 'interactions') {
const pairs = [
['che_공성', 'che_공성_묵자', 1501],
['che_공성', 'che_행동_서촉지형도', 1501],
['che_반계', 'che_서적_07_사마법', 1400],
['che_반계', 'che_서적_12_산해경', 1400],
['che_격노', 'che_격노_구정신단경', 1100],
['che_집중', 'che_환술_논어집해', 1400],
['che_환술', 'che_집중_전국책', 1400],
['che_척사', 'che_척사_오악진형도', 1100],
['che_위압', 'che_위압_조목삭', 1100],
['che_징병', 'che_징병_낙주', 1501],
];
for (const [trait, key, unit] of pairs) {
const item = catalog.items.find((entry) => entry.key === key);
if (!item) throw new Error(`Missing interaction item ${key}`);
for (const mode of ['field', 'siege', 'line'])
for (const condition of ['ready', 'depleted']) {
for (const wall of mode === 'siege' ? [1000, 5000] : [1000])
add({
category: 'interaction',
key: `${trait}+${key}`,
unit,
build: nativeBuild(unit),
opponent: 1400,
opponentBuild: 'scholar',
role: 'attack',
mode,
condition,
paired: true,
wall,
generalPatch: { special2: trait, [item.slot]: key },
factorialPatches: [{ special2: trait }, { [item.slot]: key }],
});
}
}
} else if (suite === 'units') {
for (const unit of catalog.units)
for (const build of Object.keys(BUILDS)) {
for (const budget of ['equal-crew', 'equal-gold']) {
for (const opponent of BASE_UNITS)
for (const role of ['attack', 'defend']) {
add({
category: 'unit',
key: String(unit.id),
unit: unit.id,
build,
budget,
opponent,
role,
mode: 'field',
});
}
for (const wall of [1000, 5000])
add({
category: 'unit',
key: String(unit.id),
unit: unit.id,
build,
budget,
opponent: 1100,
role: 'attack',
mode: 'siege',
wall,
});
}
}
} else {
const treatments =
suite === 'items'
? catalog.items.map((item) => ({
category: 'item',
key: item.key,
generalPatch: { [item.slot]: item.key },
}))
: suite === 'traits'
? catalog.war.map((trait) => ({
category: 'war',
key: trait.key,
generalPatch: { special2: trait.key },
}))
: suite === 'nations'
? catalog.nations.map((trait) => ({
category: 'nation',
key: trait.key,
nationTrait: trait.key,
}))
: null;
if (!treatments) throw new Error(`Unknown suite ${suite}`);
for (const treatment of treatments)
for (const [build, unit] of [
['martial', 1100],
['martial', 1200],
['martial', 1300],
['scholar', 1400],
['command', 1501],
]) {
for (const opponent of [1100, 1200, 1300, 1400, 1500])
for (const role of ['attack', 'defend']) {
add({
...treatment,
unit,
build,
opponent,
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
});
}
for (const wall of [1000, 5000])
add({
...treatment,
unit,
build,
opponent: 1100,
role: 'attack',
mode: 'siege',
wall,
paired: true,
});
}
}
return cells;
}