내부 밸런스 비교 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
+3
View File
@@ -35,6 +35,9 @@
"check:legacy:scenario": "SAMMO_REQUIRE_REF_SOURCE=1 pnpm --filter @sammo-ts/game-engine test monthlyCatalogCoverage.test.ts scenarioLoader.test.ts scenarioComposition.test.ts", "check:legacy:scenario": "SAMMO_REQUIRE_REF_SOURCE=1 pnpm --filter @sammo-ts/game-engine test monthlyCatalogCoverage.test.ts scenarioLoader.test.ts scenarioComposition.test.ts",
"check:architecture": "node tools/check-package-boundaries.mjs && node tools/check-game-clock-participants.mjs", "check:architecture": "node tools/check-package-boundaries.mjs && node tools/check-game-clock-participants.mjs",
"check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs", "check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs",
"balance:run": "node tools/balance-lab/run.mjs",
"balance:analyze": "node tools/balance-lab/analyze.mjs",
"test:balance": "pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && node --test tools/balance-lab/*.test.mjs",
"test:architecture": "node --test tools/check-package-boundaries.test.mjs", "test:architecture": "node --test tools/check-package-boundaries.test.mjs",
"test:image-sync": "node --test tools/sync-image-repository.test.mjs", "test:image-sync": "node --test tools/sync-image-repository.test.mjs",
"test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json", "test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json",
+107
View File
@@ -0,0 +1,107 @@
# 내부 밸런스 실험실
공개 API/화면과 연결하지 않는 Node CLI다. 실제 `processBattleSimJob` →
`resolveWarBattle`의 관찰 callback으로 측정하며 DB/Redis/운영 seed를 사용하지 않는다.
게임 수치나 알고리즘은 바꾸지 않는다. Ref 동일성 test를 대체하지 않는다.
```sh
fnm use 24
node --test tools/balance-lab/*.test.mjs
node tools/balance-lab/run.mjs --suite units --samples 64 --out test-results/balance-lab/units-64
node tools/balance-lab/run.mjs --suite items --samples 32 --out test-results/balance-lab/items-32
node tools/balance-lab/run.mjs --suite traits --samples 64 --out test-results/balance-lab/traits-64
node tools/balance-lab/run.mjs --suite nations --samples 32 --out test-results/balance-lab/nations-32
```
`pnpm` 실행 파일이 PATH에 있어야 한다. 최초 환경에서 필요하면 `corepack enable`.
CLI는 common/logic을 먼저 build하여 오래된 dist를 측정하지 않는다. 출력 경로는
매 실행 새 경로를 지정한다. 기존 manifest가 있으면 중단하여 이전 증거를 보존한다.
`--plan`은 build/catalog/설계만 출력한다. `--match 1100/martial`처럼 key/build/mode/role/budget
부분 문자열로 후속 정밀 실험을 좁힐 수 있다. `--seed`는 실험용 문자열이다.
표본수는 cell별 독립 seed 수이며 공수와 treatment/control은 같은 seed를 사용한다.
## 비교 계약
- 병종군: 기본 5종과 모든 병종의 상대 행렬. 보→궁→기→보의 유리 방향을
공격/수비 각각 확인한다. 같은 seed의 양 역할은 독립 표본 두 개로 세지 않는다.
- 병종: CHE 33종, 통무/통지/통솔/무지 4개 총합 210 배분, 기본 5종 상대,
공수 역할과 성벽 단독 전투. 기술 요구량과 지역 제한은 catalog에 보존한다.
- 유니크: 등록된 모든 unique 모듈을 슬롯에 장착하고 같은 슬롯이 빈 대조군과
짝지어 차이를 낸다. 모든 등록 모듈의 기계적 효과 조사이며 특정 시나리오의
획득 가능성이나 희소성·경매가격을 뜻하지 않는다.
- 전투특기: 20종을 무특기와 비교한다. 기본 보궁기/귀병/충차에서 각각 관측한다.
- 국가성향: 중립 대조 전투 외에 실제 모듈의 내정 score/cost/success, 세입,
인구 증가·손실과 전략 delay/globalDelay를 probe한다. 전투에서 차이가 없다고
성향이 없다고 판정하지 않는다.
`equal-crew`는 7천 명 **상한**이며 원 통솔×100을 넘지 않는다. `equal-gold`는
중립 징병의 병종 기본 금 계수로 비용을 맞추되 같은 통솔 상한을 적용한다.
실제 징병에 필요한 군량·민심·주민·명령 턴, 장비/특기의 징병 할인은 이 정규화에
포함되지 않는다. `unit.rice`는 전투 군량 계수이며 징병 군량 단가로 합산하지 않는다.
원 능력치 총합은 같아도 역할별로 불리한 배분이 존재한다. 배분을 합친 한 줄 순위를
제품 밸런스 결론으로 쓰지 않는다.
야전 주지표 `exchange`는 `상대 병력 손실률 - 자기 병력 손실률`이다. 성벽 피해는
포함하지 않고 공성에서는 `null`이다. 생존·전멸·성벽 피해·군량·부상·phase를 같이
보며 phase 종료를 임의 승리로 바꾸지 않는다. 무피해 승리의 무한 교환비도 만들지 않는다.
`samples.jsonl`은 cell/index/seed, treatment/control/차이를 보존한다. `summary.json`은
cell별 평균, 표준편차, 정규근사 95% 구간과 10/50/90 분위수를 제공한다. 낮은 n,
확률 0/1 근처와 희귀 발동에서는 정규구간이 정확하지 않다. 수천 cell의 구간을
다중검정 보정 없는 확정 유의성으로 쓰지 않는다. 같은 seed여도 효과가 RNG 소비
경로를 바꿀 수 있으며 개별 전투의 RNG tape가 동일하다는 뜻은 아니다.
## 판정 절차
1. 모든 후보를 저표본 탐색하고 결과를 보기 전에 실용 차이 기준을 정한다.
초기 검토 기준은 야전 손실률 차이 0.10, 동일 역할 공성 피해 +25%다.
이는 밸런스 허용치 확정이 아니라 추가 조사 후보를 정하는 기준이다.
2. 우위 후보를 별도 seed 256회 이상으로 재검사한다. 상성 역전 여부, 공수/배분/
병력 비용 조건에서의 일관성, 하위 10% 성과와 불리한 상대를 확인한다.
3. 3등급끼리 직접 교전, 동급 내 Pareto 지배, 지역/획득 제약, 특기×유니크
상호작용, 소모·연속 교전, 초중후반 기술/훈련/숙련 변화로 검증한다.
4. 게시판·과거 로그는 가설과 상황 분포의 근거로 쓴다. 버전·스탯·장비·턴 앞뒤
상태가 없는 로그만으로 원인 효과나 메타 점유율을 계산하지 않는다.
5. 국가성향은 수입·내정 턴·전략 빈도·군사 역할을 다목적으로 비교한다.
나라 크기/유저 활성도/외교의 효과를 성향 효과로 오인하지 않는다.
## 증거와 현재 제한
manifest는 Git HEAD/dirty 상태, common/logic source·병종 resource·실험 도구 SHA-256,
Node, 설계 버전, seed, cell/전투 수와 완료 여부를 기록한다. 실행 도중 source 변경이
있으면 완료하지 않는다. `completed: false`인 디렉터리는 완성 결과가 아니다.
raw sample/빌드 산출물은 Git에 넣지 않고 상위 보고서에 경로·해시와 집계를 남긴다.
현재 v2는 단일/연속 상대 전투와 성향 hook의 비교 기반이다. 획득 pool·경제 수명주기·스노우볼,
능력치 증가로 늘어난 실제 징병 상한, 실제 시즌 메타 비중은 아직 측정하지 않는다.
위 판정 절차의 후속 검증을 끝내기 전에는 전체 밸런스 평가 완료로 보고하지 않는다.
## 추가 비교와 해석 출력
```sh
node tools/balance-lab/run.mjs --suite families --samples 256 --out test-results/balance-lab/families-256
node tools/balance-lab/run.mjs --suite tiers --samples 128 --out test-results/balance-lab/tiers-128
node tools/balance-lab/run.mjs --suite interactions --samples 256 --out test-results/balance-lab/interactions-256
node tools/balance-lab/analyze.mjs test-results/balance-lab/families-256
```
`families`는 보궁기에 통무, 귀병에 통지, 차병에 통솔 배분을 적용하고 서로 다른
배분의 상대와 싸운다. 원 통솔×100까지 병력을 채우는 `capacity` 조건도 포함한다.
`tiers`는 요구 기술 3000인 모든 병종의 직접 대진을 같은 방식으로 계산한다.
따라서 단순히 약한 기본병을 잘 잡는 후보만 고르는 문제를 보완한다.
`interactions`는 공성/반계/격노/집중/환술/척사/위압/징병의 10개 특기·아이템
조합에서 `둘 다 - 특기만 - 아이템만 + 둘 다 없음`을 같은 seed로 측정한다.
이는 선택한 지표에서의 통계적 상호작용이지 trigger 중첩 버그의 판정은 아니다.
준비 완료와 병력 절반·훈사70 상태, 야전/수비장수 3명/공성을 분리한다.
공성에는 성방·성벽 1000과 5000의 두 조건을 둔다. 5000은 상한에 가려지는
효과를 보기 위한 내구도 스트레스 조건이며 현재 운영 도시의 대표값이라는
의미가 아니다. `wallDamage`는 도시 전투 HP 피해이며 HP는 성방×10이다.
원 `city.wall` 필드 감소량과 같은 단위가 아니다. 성벽을 모두 제거해 피해량이
같더라도 사상자·phase·군량 차이를 함께 확인해야 한다.
`screen.md`와 `screen.json`은 조건별 행렬과 후보를 정리한다. cell 평균들의
최솟값/최댓값은 확률 분포의 분위수나 신뢰구간이 아니다. 원 표본의 분위수와
구간은 `summary.json`에 따로 있다. 손실률 평균만으로 전쟁 승리나 국가 생존을
예측하지 않는다. 도구 수정 후에는 새 출력 경로에 재실행한다.
+114
View File
@@ -0,0 +1,114 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const directory = path.resolve(root, process.argv[2] ?? 'test-results/balance-lab/units');
const read = async (file) => JSON.parse(await readFile(path.join(directory, file), 'utf8'));
const manifest = await read('manifest.json');
if (!manifest.completed) throw new Error('Cannot analyze an incomplete experiment');
const rows = await read('summary.json');
const catalog = await read('catalog.json');
const names = new Map([
...catalog.units.map((entry) => [String(entry.id), entry.name]),
...[...catalog.items, ...catalog.war, ...catalog.nations].map((entry) => [entry.key, entry.name]),
]);
const name = (key) => names.get(key) ?? key;
const mean = (numbers) => numbers.reduce((sum, x) => sum + x, 0) / numbers.length;
const percent = (number) => (number * 100).toFixed(1);
const lines = [
`# ${manifest.suite}: ${manifest.design}`,
'',
`- Source: ${manifest.commit}, SHA-256 ${manifest.sourceHash}`,
`- ${manifest.cells} cells × ${manifest.samples} seeds; ${manifest.battles} battles.`,
'- Synthetic scenario screen, not live win rate. Cell intervals are descriptive; no multiplicity correction.',
'- Exchange = enemy loss fraction minus own loss fraction; numbers below are percentage points.',
'- Opponents and attack/defence cells have equal weight. These are designed scenario means, not independent samples.',
'',
];
const groups = new Map();
for (const row of rows.filter((entry) => entry.mode === 'field')) {
const key = JSON.stringify([row.key, row.build, row.budget ?? 'equal-crew']);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
}
const ranking = [...groups.values()]
.map((group) => {
const paired = Boolean(group[0].delta);
const scores = group.map((row) => (paired ? row.delta : row.treatment).exchange.mean);
return {
key: group[0].key,
build: group[0].build,
budget: group[0].budget ?? 'equal-crew',
effect: paired ? 'vs-empty-control' : 'absolute-exchange',
cells: group.length,
mean: mean(scores),
worstCell: Math.min(...scores),
bestCell: Math.max(...scores),
aboveScreen: scores.filter((score) => score >= 0.1).length / scores.length,
belowScreen: scores.filter((score) => score <= -0.1).length / scores.length,
};
})
.sort((a, b) => b.mean - a.mean);
lines.push(
'## Field screen',
'',
'| Candidate | Build | Budget | Mean | Worst cell | Best cell | Cells >=10 pp |',
'| --- | --- | --- | ---: | ---: | ---: | ---: |'
);
for (const row of ranking)
lines.push(
`| ${name(row.key)} | ${row.build} | ${row.budget} | ${percent(row.mean)} | ${percent(row.worstCell)} | ${percent(row.bestCell)} | ${percent(row.aboveScreen)}% |`
);
if (['units', 'families', 'tiers'].includes(manifest.suite)) {
lines.push('', '## Matchup matrices (attack/defence mean)', '');
const strata = [...new Set(rows.filter((row) => row.mode === 'field').map((row) => `${row.build}/${row.budget}`))];
for (const stratum of strata) {
const subset = rows.filter((row) => row.mode === 'field' && `${row.build}/${row.budget}` === stratum);
const candidates = [...new Set(subset.map((row) => row.key))];
const opponents = [...new Set(subset.map((row) => row.opponent))];
lines.push(
`### ${stratum}`,
'',
`| Candidate | ${opponents.map((id) => name(String(id))).join(' | ')} |`,
`| --- | ${opponents.map(() => '---:').join(' | ')} |`
);
for (const candidate of candidates)
lines.push(
`| ${name(candidate)} | ${opponents.map((opponent) => percent(mean(subset.filter((row) => row.key === candidate && row.opponent === opponent).map((row) => row.treatment.exchange.mean)))).join(' | ')} |`
);
lines.push('');
}
}
lines.push(
'',
'## Siege (do not combine with field exchange)',
'',
'| Candidate | Build | Budget / city defence | Wall damage | Change vs control | Rice | Own dead |',
'| --- | --- | --- | ---: | ---: | ---: | ---: |'
);
for (const row of rows
.filter((entry) => entry.mode === 'siege')
.sort((a, b) => b.treatment.wallDamage.mean - a.treatment.wallDamage.mean)) {
lines.push(
`| ${name(row.key)} | ${row.build}${row.condition ? '/' + row.condition : ''} | ${row.budget ?? 'equal-crew'}/${row.wall ?? 1000} | ${row.treatment.wallDamage.mean.toFixed(0)} | ${row.delta ? row.delta.wallDamage.mean.toFixed(0) : 'n/a'} | ${row.treatment.rice.mean.toFixed(0)} | ${row.treatment.dead.mean.toFixed(0)} |`
);
}
if (manifest.suite === 'interactions') {
lines.push(
'',
'## Factorial interactions',
'',
'Synergy = both - trait alone - item alone + neither.',
'',
'| Pair | Mode | Condition | Exchange delta | Exchange synergy | Wall delta | Wall synergy |',
'| --- | --- | --- | ---: | ---: | ---: | ---: |'
);
for (const row of rows)
lines.push(
`| ${row.key} | ${row.mode} | ${row.condition}/city-${row.wall ?? 1000} | ${row.delta.exchange ? percent(row.delta.exchange.mean) : 'n/a'} | ${row.synergy.exchange ? percent(row.synergy.exchange.mean) : 'n/a'} | ${row.delta.wallDamage.mean.toFixed(0)} | ${row.synergy.wallDamage.mean.toFixed(0)} |`
);
}
await writeFile(path.join(directory, 'screen.json'), JSON.stringify(ranking, null, 2));
await writeFile(path.join(directory, 'screen.md'), lines.join('\n') + '\n');
console.log(JSON.stringify({ directory, groups: ranking.length, largest: ranking.slice(0, 5) }));
+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;
}
+108
View File
@@ -0,0 +1,108 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { BUILDS, makePayload, buildPlan, summarize, observe } from './design.mjs';
const unitSet = JSON.parse(
await readFile(new URL('../../resources/unitset/unitset_che.json', import.meta.url), 'utf8')
);
const cell = {
unit: 1100,
opponent: 1200,
build: 'martial',
role: 'attack',
mode: 'field',
generalPatch: { weapon: 'test-item' },
};
test('allocation preserves total stat budget and caps troops by leadership', () => {
for (const [build, stats] of Object.entries(BUILDS)) {
assert.equal(
Object.values(stats).reduce((a, b) => a + b),
210
);
const payload = makePayload(unitSet, { ...cell, build }, 's');
assert.ok(payload.attackerGeneral.crew <= stats.leadership * 100);
}
});
test('treatment changes only the subject and paired control removes it in both roles', () => {
for (const role of ['attack', 'defend']) {
const treatment = makePayload(unitSet, { ...cell, role, nationTrait: 'che_병가' }, 's');
const control = makePayload(unitSet, { ...cell, role, nationTrait: 'che_병가' }, 's', false);
const own = role === 'attack' ? treatment.attackerGeneral : treatment.defenderGenerals[0];
const enemy = role === 'attack' ? treatment.defenderGenerals[0] : treatment.attackerGeneral;
assert.equal(own.crewtype, cell.unit);
assert.equal(enemy.crewtype, cell.opponent);
assert.equal(own.weapon, 'test-item');
assert.equal(enemy.weapon, null);
own.weapon = null;
treatment[role === 'attack' ? 'attackerNation' : 'defenderNation'].type = 'che_중립';
assert.deepEqual(treatment, control);
}
});
test('field and siege are separate and economic budget respects expensive units', () => {
assert.equal(makePayload(unitSet, cell, 's').defenderCity.wall, 0);
const siege = makePayload(unitSet, { ...cell, mode: 'siege' }, 's');
assert.equal(siege.defenderGenerals.length, 0);
assert.equal(siege.defenderCity.wall, 1000);
const expensive = makePayload(unitSet, { ...cell, unit: 1503, budget: 'equal-gold' }, 's');
assert.ok(expensive.attackerGeneral.crew < 7000);
assert.throws(() => makePayload(unitSet, { ...cell, mode: 'siege', role: 'defend' }, 's'));
});
test('observer excludes wall damage from field exchange and preserves unknown siege exchange', () => {
const payload = makePayload(unitSet, cell, 's');
const outcome = {
attacker: { crew: 6000, rice: 99900, injury: 0 },
defenders: [{ crew: 5000 }],
conquered: false,
reports: [
{ id: 1, type: 'general', dead: 1000, killed: 9000 },
{ id: 2, type: 'general', dead: 2000 },
{ id: null, type: 'city', dead: 7000 },
],
};
assert.equal(observe(outcome, payload, 'attack').fieldKills, 2000);
assert.equal(observe(outcome, payload, 'attack').exchange, 1000 / 7000);
const siege = makePayload(unitSet, { ...cell, mode: 'siege' }, 's');
assert.equal(observe({ ...outcome, defenders: [] }, siege, 'attack').exchange, null);
});
test('statistics expose n, interval and tails; invalid samples fail closed', () => {
assert.deepEqual(summarize([2, 2]).ci95, [2, 2]);
assert.equal(summarize([1, 2, 3]).mean, 2);
assert.equal(summarize([1]).ci95, null);
assert.throws(() => summarize([]));
assert.throws(() => summarize([NaN]));
});
test('design covers every registered unit, both budgets, four builds, roles and siege', () => {
const catalog = { units: unitSet.crewTypes.filter((unit) => unit.armType > 0) };
const plan = buildPlan(catalog, 'units');
assert.equal(plan.length, catalog.units.length * 4 * 2 * 12);
assert.equal(new Set(plan.map((cell) => cell.id)).size, plan.length);
assert.throws(() => buildPlan(catalog, 'no-such-suite'));
});
test('native family and tier comparisons use matched total stats and directly compare tier-three units', () => {
const catalog = { units: unitSet.crewTypes.filter((unit) => unit.armType > 0) };
const families = buildPlan(catalog, 'families');
assert.equal(families.length, 5 * 5 * 3 * 2);
const pair = families.find((row) => row.unit === 1400 && row.opponent === 1100 && row.role === 'attack');
const payload = makePayload(unitSet, pair, 's');
assert.equal(payload.attackerGeneral.intel, 90);
assert.equal(payload.defenderGenerals[0].strength, 90);
const capacity = makePayload(unitSet, { ...pair, unit: 1500, build: 'command', budget: 'capacity' }, 's');
assert.equal(capacity.attackerGeneral.crew, 15000);
const tiers = buildPlan(catalog, 'tiers');
assert.ok(tiers.some((row) => row.unit === 1502 && row.opponent === 1503));
assert.ok(tiers.every((row) => ![1100, 1306].includes(row.unit)));
});
test('depleted line encounters preserve defender ownership and vary only subject readiness', () => {
const payload = makePayload(unitSet, { ...cell, condition: 'depleted', mode: 'line' }, 's');
assert.equal(payload.attackerGeneral.crew, 3500);
assert.equal(payload.attackerGeneral.train, 70);
assert.deepEqual(
payload.defenderGenerals.map((row) => row.no),
[2, 3, 4]
);
assert.ok(payload.defenderGenerals.every((row) => row.nation === 2 && row.city === 2 && row.train === 100));
});
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { processBattleSimJob, parseUnitSetDefinition } from '../../packages/logic/dist/index.js';
import { makePayload, observe } from './design.mjs';
const units = parseUnitSetDefinition(
JSON.parse(await readFile(new URL('../../resources/unitset/unitset_che.json', import.meta.url), 'utf8'))
);
const cell = { unit: 1100, opponent: 1200, build: 'martial', role: 'attack', mode: 'field' };
function run(payload, role = 'attack') {
let observation;
processBattleSimJob(payload, {
onBattleResolved: (outcome) => {
observation = observe(outcome, payload, role);
},
});
assert.ok(observation);
assert.ok(Object.values(observation).every((value) => value === null || Number.isFinite(value)));
return observation;
}
test('actual engine is deterministic and leaves the input fixture immutable', () => {
const payload = makePayload(units, cell, 'balance-test');
const saved = structuredClone(payload);
assert.deepEqual(run(payload), run(payload));
assert.deepEqual(payload, saved);
});
test('neutral nation control is exactly equal, not just statistically similar', () => {
const paired = { ...cell, nationTrait: 'che_중립' };
assert.deepEqual(run(makePayload(units, paired, 's')), run(makePayload(units, paired, 's', false)));
});
test('same-unit mirror results reverse exchange and retain zero wall damage', () => {
const mirror = { ...cell, opponent: 1100 };
const attack = run(makePayload(units, mirror, 's'));
const defend = run(makePayload(units, { ...mirror, role: 'defend' }, 's'), 'defend');
assert.ok(Math.abs(attack.exchange + defend.exchange) < 1e-12);
assert.equal(attack.wallDamage, 0);
});
test('real siege item is equipped and changes wall damage in the production action stack', () => {
const siege = { ...cell, unit: 1501, build: 'command', mode: 'siege', generalPatch: { item: 'che_공성_묵자' } };
const control = run(makePayload(units, { ...siege, wall: 5000 }, 'balance-test', false));
const treatment = run(makePayload(units, { ...siege, wall: 5000 }, 'balance-test'));
assert.equal(treatment.exchange, null);
assert.ok(treatment.wallDamage > control.wallDamage);
});
+247
View File
@@ -0,0 +1,247 @@
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { mkdir, readFile, writeFile, readdir, open } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { parseArgs } from 'node:util';
import { DESIGN_VERSION, BUILDS, buildPlan, makePayload, observe, summarize } from './design.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const { values } = parseArgs({
options: {
suite: { type: 'string', default: 'units' },
samples: { type: 'string', default: '64' },
seed: { type: 'string', default: 'balance-2026-09-v1' },
out: { type: 'string' },
match: { type: 'string' },
plan: { type: 'boolean', default: false },
},
});
const samples = Number(values.samples);
if (!Number.isSafeInteger(samples) || samples < 2 || samples > 10000) throw new Error('--samples must be 2..10000');
if (!['units', 'items', 'traits', 'nations', 'families', 'tiers', 'interactions'].includes(values.suite))
throw new Error('Unknown suite');
const out = path.resolve(root, values.out ?? `test-results/balance-lab/${values.suite}`);
// Build before importing; a previous branch's dist must never become measurement evidence.
for (const pkg of ['common', 'logic']) {
execFileSync('pnpm', ['--filter', `@sammo-ts/${pkg}`, 'build'], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
}
const logic = await import('../../packages/logic/dist/index.js');
const unitSet = logic.parseUnitSetDefinition(
JSON.parse(await readFile(path.join(root, 'resources/unitset/unitset_che.json'), 'utf8'))
);
const allItems = await logic.loadItemModules([...logic.ITEM_KEYS]);
const war = await logic.loadWarTraitModules([...logic.WAR_TRAIT_KEYS]);
const nations = await logic.loadNationTraitModules([...logic.NATION_TRAIT_KEYS]);
const spec = (entry) => ({
key: entry.key,
name: entry.name,
info: entry.info,
slot: entry.slot,
unique: entry.unique,
cost: entry.cost,
selection: entry.selection,
hooks: Object.keys(entry).filter(
(key) => typeof entry[key] === 'function' && !['getName', 'getInfo'].includes(key)
),
events: Object.keys(entry.eventHandlers ?? {}),
});
const catalog = {
scope: 'CHE unitset + all registered unique modules; scenario acquisition eligibility is NOT asserted',
units: unitSet.crewTypes.filter((unit) => unit.armType > 0),
items: allItems.filter((item) => item.unique).map(spec),
war: war.map(spec),
nations: nations.map(spec),
};
let cells = buildPlan(catalog, values.suite);
if (values.match)
cells = cells.filter((cell) =>
`${cell.key}/${cell.build}/${cell.mode}/${cell.role}/${cell.budget ?? ''}`.includes(values.match)
);
if (!cells.length) throw new Error('No experiment cells selected');
async function hashTree(directories) {
const hash = createHash('sha256');
const walk = async (relative) => {
for (const entry of (await readdir(path.join(root, relative), { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
)) {
const child = path.join(relative, entry.name);
if (entry.isDirectory()) await walk(child);
else if (entry.isFile()) hash.update(child).update(await readFile(path.join(root, child)));
}
};
for (const dir of directories) await walk(dir);
return hash.digest('hex');
}
const sourceHash = await hashTree([
'packages/common/src',
'packages/logic/src',
'resources/unitset',
'tools/balance-lab',
]);
const manifest = {
design: DESIGN_VERSION,
suite: values.suite,
seed: values.seed,
samples,
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(),
dirty: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim(),
sourceHash,
node: process.version,
builds: BUILDS,
cells: cells.length,
battles: cells.reduce((sum, cell) => sum + samples * (cell.factorialPatches ? 4 : cell.paired ? 2 : 1), 0),
protocol: {
seedPairing: 'same seed within cell and treatment/control; RNG consumption may diverge',
inference:
'per-cell descriptive normal CI; correlated cells and multiple tests prevent global significance claims',
economics: 'equal-gold uses base crew cost only, capped by leadership; not total lifetime cost',
outcome: 'fraction enemy troops lost minus fraction own troops lost; siege uses wall damage separately',
environment: 'synthetic CHE, tech 3000, year 200, no scenario effect; no production data or mutation',
},
completed: false,
};
await mkdir(out, { recursive: true });
// Never silently truncate a previous run (including a partially completed run).
const claim = await open(path.join(out, 'manifest.json'), 'wx');
await claim.writeFile(JSON.stringify(manifest, null, 2));
await claim.close();
await writeFile(path.join(out, 'catalog.json'), JSON.stringify(catalog, null, 2));
await writeFile(path.join(out, 'plan.json'), JSON.stringify(cells, null, 2));
if (values.plan) {
console.log(JSON.stringify({ out, ...manifest }));
process.exit(0);
}
// Nation coefficients are observed through the actual module hooks. Fail on newly context-dependent
// hooks rather than silently inventing a partial domain context for them.
const noContext = new Proxy(
{},
{
get: (_, key) => {
throw new Error(`Nation probe needs context.${String(key)}`);
},
}
);
const nationProfiles = nations.map((module) => {
const domestic = {};
for (const action of ['농업', '상업', '기술', '치안', '민심', '인구', '수비', '성벽', '계략', '징병', '모병']) {
domestic[action] = Object.fromEntries(
['score', 'cost', 'success'].map((kind) => {
const base = kind === 'success' ? 0.5 : 100;
return [kind, module.onCalcDomestic?.(noContext, action, kind, base) ?? base];
})
);
}
return {
key: module.key,
domestic,
income: Object.fromEntries(
['gold', 'rice', 'pop'].map((kind) => [kind, module.onCalcNationalIncome?.(noContext, kind, 100) ?? 100])
),
populationLoss: module.onCalcNationalIncome?.(noContext, 'pop', -100) ?? -100,
strategy: Object.fromEntries(
['의병모집', '급습', '수몰'].map((action) => [
action,
Object.fromEntries(
['delay', 'globalDelay'].map((kind) => [
kind,
[12, 24, 36].map((base) => ({
base,
actual: module.onCalcStrategic?.(noContext, action, kind, base) ?? base,
})),
])
),
])
),
};
});
await writeFile(path.join(out, 'nation-profiles.json'), JSON.stringify(nationProfiles, null, 2));
const raw = await open(path.join(out, 'samples.jsonl'), 'wx');
const summary = [];
const started = Date.now();
try {
for (const cell of cells) {
const rows = [];
for (let index = 0; index < samples; index++) {
// Omit candidate key and role: common random numbers across candidate and role strata.
const seed = JSON.stringify([
values.seed,
cell.build,
cell.opponent,
cell.mode,
cell.budget ?? 'equal-crew',
cell.condition ?? 'ready',
index,
]);
const run = (treatment, patch) => {
const payload = makePayload(unitSet, patch ? { ...cell, generalPatch: patch } : cell, seed, treatment);
let result;
logic.processBattleSimJob(payload, {
onBattleResolved: (battle) => {
result = observe(battle, payload, cell.role);
},
});
if (!result) throw new Error('Battle did not execute');
return result;
};
const treatment = run(true);
const control = cell.paired ? run(false) : null;
const delta = control
? Object.fromEntries(
Object.entries(treatment).map(([key, value]) => [
key,
value === null ? null : value - control[key],
])
)
: null;
const singles = cell.factorialPatches?.map((patch) => run(true, patch));
const synergy = singles
? Object.fromEntries(
Object.entries(treatment).map(([key, value]) => [
key,
value === null ? null : value - singles[0][key] - singles[1][key] + control[key],
])
)
: null;
const row = { cell: cell.id, index, seed, treatment, control, delta, singles, synergy };
rows.push(row);
await raw.write(JSON.stringify(row) + '\n');
}
const aggregate = (field) =>
Object.fromEntries(
Object.keys(rows[0][field]).map((metric) => {
const values = rows.map((row) => row[field][metric]);
return [metric, values[0] === null ? null : summarize(values)];
})
);
summary.push({
...cell,
treatment: aggregate('treatment'),
control: cell.paired ? aggregate('control') : null,
delta: cell.paired ? aggregate('delta') : null,
synergy: cell.factorialPatches ? aggregate('synergy') : null,
});
if (summary.length % 25 === 0)
console.error(
`${values.suite}: ${summary.length}/${cells.length} cells, ${Math.round((Date.now() - started) / 1000)}s`
);
}
} finally {
await raw.close();
}
const finalHash = await hashTree([
'packages/common/src',
'packages/logic/src',
'resources/unitset',
'tools/balance-lab',
]);
if (finalHash !== sourceHash) throw new Error('Source changed during run; result is not complete');
await writeFile(path.join(out, 'summary.json'), JSON.stringify(summary, null, 2));
manifest.completed = true;
manifest.elapsedSeconds = (Date.now() - started) / 1000;
await writeFile(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2));
console.log(
JSON.stringify({ out, cells: cells.length, battles: manifest.battles, elapsedSeconds: manifest.elapsedSeconds })
);