밸런스 실험에 모집 경제와 조건부 효과 대조를 보강한다

This commit is contained in:
2026-09-29 11:26:00 +00:00
parent 4b00c1052d
commit df6c9269fa
7 changed files with 888 additions and 14 deletions
+74 -3
View File
@@ -72,9 +72,9 @@ Node, 설계 버전, seed, cell/전투 수와 완료 여부를 기록한다. 실
있으면 완료하지 않는다. `completed: false`인 디렉터리는 완성 결과가 아니다. 있으면 완료하지 않는다. `completed: false`인 디렉터리는 완성 결과가 아니다.
raw sample/빌드 산출물은 Git에 넣지 않고 상위 보고서에 경로·해시와 집계를 남긴다. raw sample/빌드 산출물은 Git에 넣지 않고 상위 보고서에 경로·해시와 집계를 남긴다.
현재 v2는 단일/연속 상대 전투와 성향 hook의 비교 기반이다. 획득 pool·경제 수명주기·스노우볼, v2의 전투 탐색에 v3의 실제 모집·조건부 발동·비전투 효과와 v3.1의 잔존 병력 대조를
능력치 증가로 늘어난 실제 징병 상한, 실제 시즌 메타 비중은 아직 측정하지 않는다. 추가했다. 최초 비교 연구의 범위는 아래 suite와 관측에 한정한다. 획득 pool의 희소성,
위 판정 절차의 후속 검증을 끝내기 전에는 전체 밸런스 평가 완료로 보고하지 않는다. 다년 경제 스노우볼, 실제 시즌 메타 비중과 최적 전략은 이 도구만으로 확정하지 않는다.
## 추가 비교와 해석 출력 ## 추가 비교와 해석 출력
@@ -105,3 +105,74 @@ node tools/balance-lab/analyze.mjs test-results/balance-lab/families-256
최솟값/최댓값은 확률 분포의 분위수나 신뢰구간이 아니다. 원 표본의 분위수와 최솟값/최댓값은 확률 분포의 분위수나 신뢰구간이 아니다. 원 표본의 분위수와
구간은 `summary.json`에 따로 있다. 손실률 평균만으로 전쟁 승리나 국가 생존을 구간은 `summary.json`에 따로 있다. 손실률 평균만으로 전쟁 승리나 국가 생존을
예측하지 않는다. 도구 수정 후에는 새 출력 경로에 재실행한다. 예측하지 않는다. 도구 수정 후에는 새 출력 경로에 재실행한다.
## v3 조건부 효과·모집·운영 가치
```sh
node tools/balance-lab/run.mjs --suite conditional --samples 128 --seed balance-confirm-v3 --out test-results/balance-lab/conditional-v3
node tools/balance-lab/run.mjs --suite premium --samples 256 --seed balance-confirm-v3 --out test-results/balance-lab/premium-v3
node tools/balance-lab/run.mjs --suite sensitivity --samples 256 --seed balance-confirm-v3 --out test-results/balance-lab/sensitivity-v3
node tools/balance-lab/run.mjs --suite recruit --samples 128 --seed balance-confirm-v3 --out test-results/balance-lab/recruit-v3
```
- `conditional`: 모든 특기20/유니크100을 근위병·천귀병에 추가 대조하고, 척사와
오악진형도는 기본/지역/도시 병종 대진의 factorial 효과도 확인한다. 척사는 NPC
여부가 아니라 상대 병종의 지역/도시 조건으로 발동한다.
- `premium`: 탐색 상위5종을 독립 seed로 재검사한다. 말은 일반 +6 흑색마,
도구는 빈 슬롯/매번 소비하는 청주를 각각 대조군으로 둔다. 청주 가격1000은
매 전투 반복 비용이고, 흑색마의 상점가격은 21000/치안6000 요구다. 전투당
소모품 대조를 영구 아이템의 구매가격으로 오인하지 않는다.
- `sensitivity`: 183/200/220년, 기술1000/3000/6000, 숙련0/10000/50000,
훈사80/100/110, 초기 대비 능력치0.8/1/1.4배의 조합에서 기본5종을 재비교한다.
각각은 묶음 stress condition이다. 어느 한 변수의 인과효과를 분리한 실험은 아니다.
- `recruit`: 실제 `che_징병.CommandResolver`로 금1500/5000, 쌀5000,
모집 가능 인구20000 내에서 가능한 병력·통솔 상한·훈련·사기·지출을 계산한다.
quote만 재사용하고 DB나 실제 명령 mutation은 실행하지 않는다. 기본 병종/충차의
기술 조건은 충족한다. 도시 이동·모병 명령·복합 성향/관직/유산은 이 fixture 범위 밖이다.
충원 직후와 훈사100까지 훈련한 상태를 나누고 후자의 추가 훈련 턴을 공짜라고
평가하지 않는다. 그 두 상태는 서로 다른 조건이다.
모든 v3 실행에 아래 비전투 관측도 저장한다.
- `economic-profiles.json`: 모든 unique/전투특기의 실제 일반 action pipeline에서
능력치·명성·공헌·숙련, 내정 비용/성과/성공, 계략 방어, 전략 지연을 probe한다.
기본값과 변화값을 같이 저장하며 병종별 모집 할인은 `recruit`로 따로 측정한다.
- `lifecycle-profiles.json`: 실제 판매 event와 턴 전 치료 trigger를 512 seed로
실행한다. 도기의 연차별 개인/국가 보상, 의술 아이템/특기의 자신·아군·적군
치료를 분리한다. 새로운 미지원 event가 등록되면 오류로 중단한다.
- `nation-plans.json`: 실제 국가 hook 계수에 12턴 상한과 600/1200/2400 자원을
적용한 고정 업무 계획 모델이다. 개발/수성/회복의 자원 병목과 턴 병목을 분리한다.
월간 국가 경제나 NPC·외교·국가 생존을 모사한 출력이 아니다.
- `strategy-plans.json`: 실제 의병모집 resolver의 국가별/장수수별 쿨다운을
평만지장도 유무로 비교한다. 240 cooldown 단위 안에서 같은 전략을 반복하는
자원 무제한 상한이며 의병 수·실제 달력·다른 전략 교차 사용을 포함하지 않는다.
v3 화면용 Markdown/JSON 집계는 병종·대조군·예산·준비 상태를 다른 그룹으로 유지한다.
초기 v2 화면 집계와 그룹 수가 달라질 수 있지만 원 cell/seed 자료는 보존된다.
조건을 확인하지 못한 효과는 0점이나 밸런스 통과로 자동 변환하지 않는다.
## v3.1 잔존 병력 대조
```sh
node tools/balance-lab/run.mjs --suite attrition --samples 256 --seed balance-attrition-v3.1 --out test-results/balance-lab/attrition-v3.1
node tools/balance-lab/analyze.mjs test-results/balance-lab/attrition-v3.1
```
옥란/흑색마와 상편/빈슬롯을 실제 모집 상한까지 채운 뒤, 평가 대상의 잔존 병력만
100%/50%/25%로 줄이고 상대는 만충원을 유지한다. 상대 보병·궁병·기병·천귀병,
공수 양 역할과 대상 기본4종/충차를 구분한다.
금100000·쌀5000·인구20000으로 이 대상의 모집 상한을 충족한다. 전체 모집 대금은
이미 지불한 상태이고 `recruit_crew`는 모집 인원, `recruit_remainingBeforeBattle`은
전투 직전 인원이다. 훈사100을 유지하며 이전 손실을 만든 전투·부상·군량 소모는
재현하지 않는 상태 통제 실험이다. 따라서 실제 연전 전체 효율로 부르지 않는다.
## v3.2 상대 특기 대응
```sh
node tools/balance-lab/run.mjs --suite counter --samples 256 --seed balance-counter-v3.2 --out test-results/balance-lab/counter-v3.2
node tools/balance-lab/analyze.mjs test-results/balance-lab/counter-v3.2
```
박혁론/빈슬롯을 상대 무특기·반계·격노로 분리한다. 기본4종/충차로 보병·기병·
귀병·천귀병과 양 공수에서 싸운다. 상대 특기만 양 대조군에 똑같이 적용하며,
상대가 억제할 능력이 없을 때 효과0이라는 음성 대조도 포함한다.
+34 -5
View File
@@ -28,7 +28,16 @@ const lines = [
]; ];
const groups = new Map(); const groups = new Map();
for (const row of rows.filter((entry) => entry.mode === 'field')) { for (const row of rows.filter((entry) => entry.mode === 'field')) {
const key = JSON.stringify([row.key, row.build, row.budget ?? 'equal-crew']); const key = JSON.stringify([
row.key,
row.build,
row.unit,
row.budget ?? 'equal-crew',
row.baseline,
row.condition,
row.wallet,
row.quality,
]);
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row); groups.get(key).push(row);
} }
@@ -39,8 +48,13 @@ const ranking = [...groups.values()]
return { return {
key: group[0].key, key: group[0].key,
build: group[0].build, build: group[0].build,
unit: group[0].unit,
baseline: group[0].baseline ?? 'empty',
condition: group[0].condition ?? 'ready',
wallet: group[0].wallet ?? null,
quality: group[0].quality ?? null,
budget: group[0].budget ?? 'equal-crew', budget: group[0].budget ?? 'equal-crew',
effect: paired ? 'vs-empty-control' : 'absolute-exchange', effect: paired ? 'vs-specified-control' : 'absolute-exchange',
cells: group.length, cells: group.length,
mean: mean(scores), mean: mean(scores),
worstCell: Math.min(...scores), worstCell: Math.min(...scores),
@@ -58,7 +72,7 @@ lines.push(
); );
for (const row of ranking) for (const row of ranking)
lines.push( lines.push(
`| ${name(row.key)} | ${row.build} | ${row.budget} | ${percent(row.mean)} | ${percent(row.worstCell)} | ${percent(row.bestCell)} | ${percent(row.aboveScreen)}% |` `| ${name(row.key)} | ${row.build}/${name(String(row.unit))} | ${row.budget}/${row.baseline}/${row.condition}${row.wallet ? '/' + row.wallet + '/' + row.quality : ''} | ${percent(row.mean)} | ${percent(row.worstCell)} | ${percent(row.bestCell)} | ${percent(row.aboveScreen)}% |`
); );
if (['units', 'families', 'tiers'].includes(manifest.suite)) { if (['units', 'families', 'tiers'].includes(manifest.suite)) {
lines.push('', '## Matchup matrices (attack/defence mean)', ''); lines.push('', '## Matchup matrices (attack/defence mean)', '');
@@ -94,7 +108,7 @@ for (const row of rows
`| ${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)} |` `| ${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') { if (rows.some((row) => row.synergy)) {
lines.push( lines.push(
'', '',
'## Factorial interactions', '## Factorial interactions',
@@ -104,11 +118,26 @@ if (manifest.suite === 'interactions') {
'| Pair | Mode | Condition | Exchange delta | Exchange synergy | Wall delta | Wall synergy |', '| Pair | Mode | Condition | Exchange delta | Exchange synergy | Wall delta | Wall synergy |',
'| --- | --- | --- | ---: | ---: | ---: | ---: |' '| --- | --- | --- | ---: | ---: | ---: | ---: |'
); );
for (const row of rows) for (const row of rows.filter((row) => row.synergy))
lines.push( 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)} |` `| ${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)} |`
); );
} }
if (['recruit', 'attrition'].includes(manifest.suite)) {
lines.push(
'',
'## Recruitment (actual production resolver)',
'',
'| Candidate | Unit | Wallet / quality | Subject troops | Control troops | Gold spent | Population drawn | Initial train | Exchange delta |',
'| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |'
);
for (const group of groups.values()) {
const row = group[0];
lines.push(
`| ${name(row.key)} | ${name(String(row.unit))} | ${row.wallet}/${row.quality}/${row.condition ?? 'ready'} | ${row.treatment.recruit_crew.mean} | ${row.control.recruit_crew.mean} | ${row.treatment.recruit_gold.mean} | ${row.treatment.recruit_population.mean} | ${row.treatment.recruit_train.mean} | ${percent(mean(group.map((row) => row.delta.exchange.mean)))} |`
);
}
}
await writeFile(path.join(directory, 'screen.json'), JSON.stringify(ranking, null, 2)); await writeFile(path.join(directory, 'screen.json'), JSON.stringify(ranking, null, 2));
await writeFile(path.join(directory, 'screen.md'), lines.join('\n') + '\n'); await writeFile(path.join(directory, 'screen.md'), lines.join('\n') + '\n');
console.log(JSON.stringify({ directory, groups: ranking.length, largest: ranking.slice(0, 5) })); console.log(JSON.stringify({ directory, groups: ranking.length, largest: ranking.slice(0, 5) }));
+11 -2
View File
@@ -1,5 +1,5 @@
// Internal experiment design. No router, database, server seed or gameplay mutation. // Internal experiment design. No router, database, server seed or gameplay mutation.
export const DESIGN_VERSION = 'balance-lab-v2'; export const DESIGN_VERSION = 'balance-lab-v3.2';
export const BUILDS = { export const BUILDS = {
martial: { leadership: 90, strength: 90, intel: 30 }, martial: { leadership: 90, strength: 90, intel: 30 },
scholar: { leadership: 90, strength: 30, intel: 90 }, scholar: { leadership: 90, strength: 30, intel: 90 },
@@ -109,7 +109,14 @@ export function makePayload(unitSet, cell, seed, treatment = true) {
: Math.min(7000, BUILDS[buildKey].leadership * 100); : Math.min(7000, BUILDS[buildKey].leadership * 100);
const subject = general(role === 'attack' ? 1 : 2, unit, build, count(unit, build)); const subject = general(role === 'attack' ? 1 : 2, unit, build, count(unit, build));
const other = general(role === 'attack' ? 2 : 1, opponent, opponentBuild, count(opponent, opponentBuild)); const other = general(role === 'attack' ? 2 : 1, opponent, opponentBuild, count(opponent, opponentBuild));
if (treatment) Object.assign(subject, cell.generalPatch ?? {}); Object.assign(subject, cell.commonPatch ?? {}, treatment ? (cell.generalPatch ?? {}) : (cell.controlPatch ?? {}));
Object.assign(other, cell.opponentPatch ?? {});
for (const participant of [subject, other]) {
if (cell.readiness !== undefined) participant.train = participant.atmos = cell.readiness;
if (cell.dex !== undefined) for (let family = 1; family <= 5; family++) participant[`dex${family}`] = cell.dex;
if (cell.statScale !== undefined)
for (const stat of ['leadership', 'strength', 'intel']) participant[stat] *= cell.statScale;
}
if (cell.condition === 'depleted') { if (cell.condition === 'depleted') {
subject.crew = Math.floor(subject.crew * 0.5); subject.crew = Math.floor(subject.crew * 0.5);
subject.train = 70; subject.train = 70;
@@ -153,6 +160,8 @@ export function makePayload(unitSet, cell, seed, treatment = true) {
if (treatment && cell.nationTrait) { if (treatment && cell.nationTrait) {
payload[role === 'attack' ? 'attackerNation' : 'defenderNation'].type = cell.nationTrait; payload[role === 'attack' ? 'attackerNation' : 'defenderNation'].type = cell.nationTrait;
} }
payload.year = payload.time.year = cell.year ?? 200;
payload.attackerNation.tech = payload.defenderNation.tech = cell.tech ?? 3000;
if (mode === 'siege' && role !== 'attack') throw new Error('Siege subject must attack'); if (mode === 'siege' && role !== 'attack') throw new Error('Siege subject must attack');
return payload; return payload;
} }
+315
View File
@@ -0,0 +1,315 @@
// Calculation-only use of the production recruitment resolver. No world mutation or DB access.
export function domainContext(payload, nation, time) {
return {
time,
maxTechLevel: 12,
nation: {
id: nation.nation,
typeCode: nation.type,
gold: nation.gold,
rice: nation.rice,
meta: { tech: nation.tech },
},
general: {
id: payload.no,
name: payload.name,
nationId: payload.nation,
cityId: payload.city,
troopId: 0,
stats: { leadership: payload.leadership, strength: payload.strength, intelligence: payload.intel },
role: {
specialWar: payload.special2,
specialDomestic: payload.special,
personality: payload.personal,
items: { horse: payload.horse, weapon: payload.weapon, book: payload.book, item: payload.item },
},
crew: 0,
crewTypeId: payload.crewtype,
injury: payload.injury,
gold: payload.gold,
rice: payload.rice,
train: payload.train,
atmos: payload.atmos,
experience: payload.experience,
dedication: payload.dedication,
officerLevel: payload.officer_level,
age: 20,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {},
},
};
}
export function economicTools(logic, CommandResolver, items, war, nations) {
const itemMap = new Map(items.map((item) => [item.key, item]));
const warMap = new Map(war.map((trait) => [trait.key, trait]));
const nationMap = new Map(nations.map((trait) => [trait.key, trait]));
const modules = (payload, nation) =>
[
nationMap.get(nation.type),
warMap.get(payload.special2),
...['horse', 'weapon', 'book', 'item'].map((slot) => itemMap.get(payload[slot])),
].filter(Boolean);
function quote(payload, nation, time, unit, { gold = 5000, rice = 5000, population = 20000 } = {}) {
const context = domainContext(payload, nation, time);
const resolver = new CommandResolver(modules(payload, nation), {});
const capacity = resolver.resolveFullLeadership(context) * 100;
// Search the actual rounded resolver costs; population constraint uses the trait-adjusted draw.
let low = 99,
high = Math.floor(resolver.resolveLeadership(context) * 100);
const feasible = (amount) => {
const cost = resolver.getCost(context, unit.id, amount, unit);
return (
cost.gold <= gold &&
cost.rice <= rice &&
resolver.getRecruitPopulation(context, cost.applied) <= population
);
};
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (feasible(middle)) low = middle;
else high = middle - 1;
}
if (low < 100) return { capacity, crew: 0, gold: 0, rice: 0, population: 0, train: 0, atmos: 0 };
const cost = resolver.getCost(context, unit.id, low, unit);
return {
capacity,
crew: cost.applied,
gold: cost.gold,
rice: cost.rice,
population: resolver.getRecruitPopulation(context, cost.applied),
train: resolver.getTrain(context, unit),
atmos: resolver.getAtmos(context, unit),
};
}
function recruit(payload, cell) {
const reports = new Map();
for (const [general, nation] of [
[payload.attackerGeneral, payload.attackerNation],
...payload.defenderGenerals.map((general) => [general, payload.defenderNation]),
]) {
const unit = payload.unitSet.crewTypes.find((entry) => entry.id === general.crewtype);
const result = quote(general, nation, payload.time, unit, {
gold: cell.wallet,
rice: 5000,
population: 20000,
});
if (!result.crew) throw new Error('Recruitment budget cannot field the minimum 100 troops');
general.crew = result.crew;
general.gold = cell.wallet - result.gold;
general.rice = 5000 - result.rice;
general.train = cell.quality === 'fresh' ? result.train : 100;
general.atmos = cell.quality === 'fresh' ? result.atmos : 100;
reports.set(general.no, result);
}
const subject = cell.role === 'attack' ? payload.attackerGeneral : payload.defenderGenerals[0];
const report = reports.get(subject.no);
if (cell.crewFraction !== undefined) {
if (!(cell.crewFraction > 0 && cell.crewFraction <= 1)) throw new Error('Invalid remaining troop fraction');
// Prior attrition is a controlled snapshot, not a simulated earlier battle. Recruitment was paid in full.
subject.crew = Math.floor(subject.crew * cell.crewFraction);
report.remainingBeforeBattle = subject.crew;
}
return report;
}
function profile(payload, nation, time, module) {
const context = domainContext(payload, nation, time);
const pipeline = new logic.GeneralActionPipeline([module]);
const output = {
key: module.key,
stats: {},
statBaselines: {},
opposeStats: {},
domestic: {},
strategies: {},
events: Object.keys(module.eventHandlers ?? {}),
};
for (const stat of [
'leadership',
'strength',
'intelligence',
'experience',
'dedication',
'addDex',
'injuryProb',
'sabotageAttack',
'sabotageDefence',
]) {
const baseline = stat === 'injuryProb' ? 0.2 : 100;
output.statBaselines[stat] = baseline;
output.stats[stat] = pipeline.onCalcStat(context, stat, baseline);
output.opposeStats[stat] = pipeline.onCalcOpposeStat(context, stat, baseline);
}
for (const action of [
'농업',
'상업',
'기술',
'수비',
'성벽',
'치안',
'민심',
'인구',
'조달',
'계략',
'징병',
'모병',
'징집인구',
]) {
output.domestic[action] = Object.fromEntries(
['score', 'cost', 'rice', 'success', 'fail', 'train', 'atmos'].map((kind) => {
const baseline = ['success', 'fail'].includes(kind) ? 0.2 : 100;
return [
kind,
{ baseline, value: pipeline.onCalcDomestic(context, action, kind, baseline, { armType: 1 }) },
];
})
);
}
for (const action of ['의병모집', '급습', '수몰'])
output.strategies[action] = Object.fromEntries(
['delay', 'globalDelay'].map((kind) => [kind, pipeline.onCalcStrategic(context, action, kind, 100)])
);
return output;
}
return { quote, recruit, profile };
}
export function lifecycleProfiles(logic, common, payload, modules, seed, samples = 512) {
const rows = [];
for (const module of modules) {
const events = Object.keys(module.eventHandlers ?? {});
if (events.some((event) => event !== 'item.sold')) throw new Error(`Uncovered lifecycle event: ${module.key}`);
if (events.includes('item.sold'))
for (const year of [180, 200, 220]) {
let personal = 0,
national = 0,
gold = 0;
for (let index = 0; index < samples; index++) {
const context = domainContext(payload.attackerGeneral, payload.attackerNation, {
year,
month: 1,
startYear: 180,
});
const beforePersonal = context.general.gold + context.general.rice;
const beforeNational = context.nation.gold + context.nation.rice;
context.rng = new common.RandUtil(common.LiteHashDRBG.build(`${seed}/sale/${index}`));
logic.dispatchGeneralActionEventHandlers(
module.eventHandlers,
context,
logic.createGeneralActionEvent('item.sold', { itemKey: module.key, slot: module.slot })
);
personal += context.general.gold + context.general.rice - beforePersonal;
national += context.nation.gold + context.nation.rice - beforeNational;
gold += Number(context.general.gold > payload.attackerGeneral.gold);
}
rows.push({
key: module.key,
kind: 'sale-bonus',
year,
samples,
personalGain: personal / samples,
nationGain: national / samples,
goldFraction: gold / samples,
});
}
if (module.getPreTurnExecuteTriggerList) {
const totals = [0, 0, 0];
for (let index = 0; index < samples; index++) {
const context = domainContext(payload.attackerGeneral, payload.attackerNation, payload.time);
const patients = [
context.general,
{ ...structuredClone(context.general), id: 2 },
{ ...structuredClone(context.general), id: 3, nationId: 2 },
];
for (const general of patients) general.injury = 30;
context.worldView = { listGenerals: () => patients };
context.rng = new common.RandUtil(common.LiteHashDRBG.build(`${seed}/heal/${index}`));
const triggerContext = logic.createGeneralTriggerContext(context);
module.getPreTurnExecuteTriggerList(context)?.fire(triggerContext, {});
patients.forEach((patient, index) => {
totals[index] += Number(patient.injury === 0);
});
}
rows.push({
key: module.key,
kind: 'pre-turn-heal',
samples,
self: totals[0] / samples,
ally: totals[1] / samples,
enemy: totals[2] / samples,
});
}
}
return rows;
}
export function strategyPlans(VolunteerResolver, payload, nations, items) {
const item = items.find((entry) => entry.key === 'che_전략_평만지장도');
if (!item) throw new Error('Missing strategy item');
return nations.flatMap((nation) =>
[false, true].flatMap((equipped) =>
[10, 30, 60].map((generals) => {
const context = domainContext(
payload.attackerGeneral,
{ ...payload.attackerNation, type: nation.key },
payload.time
);
const resolver = new VolunteerResolver([nation, ...(equipped ? [item] : [])], {
initialNationGenLimit: 10,
});
const delay = resolver.getPostDelay(context, generals);
const globalDelay = resolver.getGlobalDelay(context);
const horizon = 240;
return {
key: nation.key,
equipped,
generals,
delay,
globalDelay,
horizon,
// Only one repeated strategy, resources/volunteer limit unconstrained. Not all-strategy throughput.
usesWithResources: 1 + Math.floor((horizon - 1) / Math.max(delay, globalDelay)),
};
})
)
);
}
// Controlled planning model, not the monthly national simulation: fixed starting budget and
// turn cap expose resource vs labour bottlenecks while retaining real nation-hook coefficients.
export function nationPlans(profiles) {
const mixes = {
development: ['농업', '상업', '기술'],
defence: ['수비', '성벽', '치안'],
recovery: ['민심', '인구', '치안'],
};
return profiles.flatMap((profile) =>
Object.entries(mixes).flatMap(([role, actions]) =>
[600, 1200, 2400].map((budget) => {
let remaining = budget,
score = 0,
turns = 0;
while (turns < 12) {
const action = actions[turns % actions.length];
const metric = profile.domestic[action];
if (remaining + 1e-8 < metric.cost) break;
remaining -= metric.cost;
score += metric.score;
turns++;
}
return {
key: profile.key,
role,
budget,
turns,
score,
spent: budget - remaining,
goldIncomePer1000: profile.income.gold * 10,
riceIncomePer1000: profile.income.rice * 10,
populationGainPer1000: profile.income.pop * 10,
};
})
)
);
}
+186
View File
@@ -0,0 +1,186 @@
import { BASE_UNITS, nativeBuild } from './design.mjs';
export const FOLLOWUP_SUITES = ['conditional', 'premium', 'sensitivity', 'recruit', 'attrition', 'counter'];
export function buildFollowupPlan(catalog, suite) {
const cells = [];
const add = (cell) => cells.push({ id: String(cells.length).padStart(5, '0'), ...cell });
const subjects = [1100, 1200, 1300, 1400, 1501];
if (suite === 'conditional') {
// Same effect registry, but opponents meet region/city conditions absent from basic-family screens.
const candidates = [
...catalog.war.map((trait) => ({ key: trait.key, generalPatch: { special2: trait.key } })),
...catalog.items.map((item) => ({ key: item.key, generalPatch: { [item.slot]: item.key } })),
];
for (const candidate of candidates)
for (const unit of subjects)
for (const opponent of [1104, 1407]) {
for (const role of ['attack', 'defend'])
add({
...candidate,
category: suite,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
});
}
for (const opponent of [1100, 1101, 1104, 1407])
for (const role of ['attack', 'defend']) {
add({
category: suite,
key: 'che_척사+che_척사_오악진형도',
unit: 1100,
build: 'martial',
opponent,
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
generalPatch: { special2: 'che_척사', item: 'che_척사_오악진형도' },
factorialPatches: [{ special2: 'che_척사' }, { item: 'che_척사_오악진형도' }],
});
}
} else if (suite === 'premium') {
const keys = [
'che_명마_12_옥란백용구',
'che_명마_07_백상',
'che_불굴_상편',
'che_위압_조목삭',
'che_회피_태평요술',
];
for (const key of keys) {
const item = catalog.items.find((entry) => entry.key === key);
if (!item) throw new Error(`Unknown premium candidate ${key}`);
// Horse: persistent buyable +6 horse. Tool: empty slot and recurring consumable are separate controls.
const controls = item.slot === 'horse' ? ['che_명마_06_흑색마'] : [null, 'che_훈련_청주'];
for (const control of controls)
for (const unit of subjects)
for (const opponent of [1100, 1200, 1300, 1400, 1104, 1407]) {
for (const role of ['attack', 'defend'])
add({
category: suite,
key,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
baseline: control ?? 'empty',
generalPatch: { [item.slot]: key },
controlPatch: { [item.slot]: control },
});
}
}
} else if (suite === 'counter') {
for (const special2 of [null, 'che_반계', 'che_격노'])
for (const unit of subjects)
for (const opponent of [1100, 1300, 1400, 1407])
for (const role of ['attack', 'defend'])
add({
category: suite,
key: 'che_진압_박혁론',
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
condition: special2 ?? 'no-opponent-trait',
opponentPatch: { special2 },
generalPatch: { item: 'che_진압_박혁론' },
});
} else if (suite === 'attrition') {
for (const key of ['che_명마_12_옥란백용구', 'che_불굴_상편']) {
const item = catalog.items.find((entry) => entry.key === key);
if (!item) throw new Error(`Unknown attrition candidate ${key}`);
const control = item.slot === 'horse' ? 'che_명마_06_흑색마' : null;
for (const unit of subjects)
for (const crewFraction of [1, 0.5, 0.25])
for (const opponent of [1100, 1200, 1300, 1407])
for (const role of ['attack', 'defend'])
add({
category: suite,
key,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
paired: true,
budget: 'recruited',
wallet: 100000,
quality: 'trained',
crewFraction,
condition: `remaining-${crewFraction}`,
baseline: control ?? 'empty',
generalPatch: { [item.slot]: key },
controlPatch: { [item.slot]: control },
});
}
} else if (suite === 'sensitivity') {
for (const [condition, patch] of Object.entries({
early: { year: 183, tech: 1000, dex: 0, readiness: 80, statScale: 0.8 },
trained: { year: 200, tech: 3000, dex: 10000, readiness: 100 },
late: { year: 220, tech: 6000, dex: 50000, readiness: 110, statScale: 1.4 },
}))
for (const unit of BASE_UNITS)
for (const opponent of BASE_UNITS) {
for (const role of ['attack', 'defend'])
add({
category: suite,
key: String(unit),
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role,
mode: 'field',
condition,
...patch,
});
}
} else if (suite === 'recruit') {
const variants = [
{ key: 'che_징병', generalPatch: { special2: 'che_징병' } },
{ key: 'che_징병_낙주', generalPatch: { item: 'che_징병_낙주' } },
{
key: 'che_명마_12_옥란백용구',
generalPatch: { horse: 'che_명마_12_옥란백용구' },
controlPatch: { horse: 'che_명마_06_흑색마' },
},
...['보병', '궁병', '기병'].map((name) => ({
key: `che_${name}`,
generalPatch: { special2: `che_${name}` },
})),
];
for (const candidate of variants)
for (const unit of subjects)
for (const wallet of [1500, 5000]) {
for (const quality of ['fresh', 'trained'])
for (const opponent of [1100, 1300, 1400]) {
add({
...candidate,
category: suite,
unit,
opponent,
build: nativeBuild(unit),
opponentBuild: nativeBuild(opponent),
role: 'attack',
mode: 'field',
paired: true,
budget: 'recruited',
wallet,
quality,
});
}
}
} else throw new Error(`Unknown follow-up suite ${suite}`);
return cells;
}
+199
View File
@@ -0,0 +1,199 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import * as logic from '../../packages/logic/dist/index.js';
import * as common from '../../packages/common/dist/index.js';
import { CommandResolver } from '../../packages/logic/dist/src/actions/turn/general/che_징병.js';
import { CommandResolver as VolunteerResolver } from '../../packages/logic/dist/src/actions/turn/nation/che_의병모집.js';
import { economicTools, lifecycleProfiles, nationPlans, strategyPlans } from './economy.mjs';
import { makePayload, observe } from './design.mjs';
import { buildFollowupPlan } from './followup.mjs';
const unitSet = logic.parseUnitSetDefinition(
JSON.parse(await readFile(new URL('../../resources/unitset/unitset_che.json', import.meta.url), 'utf8'))
);
const items = 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 economics = economicTools(logic, CommandResolver, items, war, nations);
const cell = { unit: 1100, opponent: 1100, build: 'martial', role: 'attack', mode: 'field' };
const catalog = { items: items.filter((item) => item.unique), war, nations };
test('conditional design schedules every loaded unique and war trait against both regional opponents', () => {
const plan = buildFollowupPlan(catalog, 'conditional');
for (const module of [...catalog.items, ...catalog.war]) {
const rows = plan.filter((row) => row.key === module.key);
assert.equal(rows.length, 20);
assert.deepEqual([...new Set(rows.map((row) => row.opponent))].sort(), [1104, 1407]);
}
});
test('premium control equips the same slot on the subject in both roles', () => {
const plan = buildFollowupPlan(catalog, 'premium');
for (const cell of plan) {
const payload = makePayload(unitSet, cell, 'test', false);
const subject = cell.role === 'attack' ? payload.attackerGeneral : payload.defenderGenerals[0];
for (const [slot, key] of Object.entries(cell.controlPatch)) assert.equal(subject[slot], key);
}
assert.ok(plan.some((row) => row.baseline === 'che_명마_06_흑색마'));
assert.ok(plan.some((row) => row.baseline === 'che_훈련_청주'));
});
test('regional effect is inactive on basic troops and changes the real regional battle', () => {
const run = (opponent, treatment) => {
const payload = makePayload(
unitSet,
{ ...cell, opponent, generalPatch: { special2: 'che_척사' } },
'regional-test',
treatment
);
let result;
logic.processBattleSimJob(payload, {
onBattleResolved: (outcome) => {
result = observe(outcome, payload, 'attack');
},
});
return result;
};
assert.deepEqual(run(1100, true), run(1100, false));
assert.ok(run(1104, true).exchange > run(1104, false).exchange);
});
test('production recruitment caps money and increases capacity/readiness without population for recruiting trait', () => {
const payload = makePayload(unitSet, cell, 'test');
const unit = unitSet.crewTypes.find((entry) => entry.id === 1100);
const base = economics.quote(payload.attackerGeneral, payload.attackerNation, payload.time, unit);
const trait = economics.quote(
{ ...payload.attackerGeneral, special2: 'che_징병' },
payload.attackerNation,
payload.time,
unit
);
assert.equal(base.capacity, 9000);
assert.equal(trait.capacity, 11200); // Production integer stat truncation, not 11250.
assert.equal(base.train, 40);
assert.equal(trait.train, 70);
assert.equal(base.population, base.crew);
assert.equal(trait.population, 0);
assert.equal(trait.crew, 11200);
const poor = economics.quote(payload.attackerGeneral, payload.attackerNation, payload.time, unit, { gold: 100 });
assert.ok(poor.gold <= 100 && poor.crew < base.crew);
const empty = economics.quote(payload.attackerGeneral, payload.attackerNation, payload.time, unit, { gold: 0 });
assert.equal(empty.crew, 0);
});
test('recruited battle inputs pay quote costs and separate fresh from trained troops', () => {
const design = {
...cell,
budget: 'recruited',
wallet: 1500,
quality: 'fresh',
generalPatch: { special2: 'che_징병' },
};
const payload = makePayload(unitSet, design, 'test');
const cost = economics.recruit(payload, design);
assert.equal(payload.attackerGeneral.gold + cost.gold, 1500);
assert.equal(payload.attackerGeneral.rice + cost.rice, 5000);
assert.equal(payload.attackerGeneral.crew, cost.crew);
assert.equal(payload.attackerGeneral.train, 70);
assert.equal(payload.defenderGenerals[0].train, 40);
});
test('lifecycle probes observe sale resources and healing allies and enemies', () => {
const payload = makePayload(unitSet, cell, 'test');
const rows = lifecycleProfiles(
logic,
common,
payload,
items.filter((item) => ['che_보물_도기', 'che_의술_청낭서'].includes(item.key)),
'lifecycle-test',
128
);
const sale = rows.find((row) => row.kind === 'sale-bonus' && row.year === 200);
assert.equal(sale.personalGain, 30000);
assert.equal(sale.nationGain, 30000);
const heal = rows.find((row) => row.kind === 'pre-turn-heal');
assert.equal(heal.self, 1);
assert.ok(heal.ally > 0 && heal.ally < 1 && heal.enemy > 0 && heal.enemy < 1);
});
test('national planning exposes labour cap and individual strategy cooldown bottleneck', () => {
const profile = {
key: 'neutral',
domestic: Object.fromEntries(
['농업', '상업', '기술', '수비', '성벽', '치안', '민심', '인구'].map((action) => [
action,
{ score: 100, cost: 100 },
])
),
income: { gold: 100, rice: 100, pop: 100 },
};
const plans = nationPlans([profile]).filter((row) => row.role === 'development');
assert.deepEqual(
plans.map((row) => row.turns),
[6, 12, 12]
);
const payload = makePayload(unitSet, cell, 'test');
const strategy = strategyPlans(VolunteerResolver, payload, nations, items);
const neutral = strategy.find((row) => row.key === 'che_중립' && row.generals === 10 && !row.equipped);
const diplomat = strategy.find((row) => row.key === 'che_종횡가' && row.generals === 10 && !row.equipped);
assert.equal(neutral.delay, 100);
assert.equal(diplomat.delay, 75);
assert.equal(diplomat.usesWithResources, 4);
assert.equal(neutral.usesWithResources, 3);
});
test('attrition starts at the same capacity fraction, preserves full payment, and leaves opponents full', () => {
const plan = buildFollowupPlan(catalog, 'attrition');
assert.equal(plan.length, 240);
for (const role of ['attack', 'defend']) {
const cell = plan.find(
(row) =>
row.key === 'che_명마_12_옥란백용구' &&
row.unit === 1501 &&
row.crewFraction === 0.25 &&
row.role === role
);
const payload = makePayload(unitSet, cell, 'attrition-test');
const report = economics.recruit(payload, cell);
const subject = role === 'attack' ? payload.attackerGeneral : payload.defenderGenerals[0];
const opponent = role === 'attack' ? payload.defenderGenerals[0] : payload.attackerGeneral;
assert.equal(report.crew, 16200);
assert.equal(subject.crew, 4050);
assert.equal(report.remainingBeforeBattle, 4050);
assert.equal(opponent.crew, 9000);
assert.equal(subject.gold + report.gold, cell.wallet);
assert.equal(subject.train, 100);
}
});
test('counter design patches only the opposing trait and suppression is a true no-op without it', () => {
const plan = buildFollowupPlan(catalog, 'counter');
assert.equal(plan.length, 120);
for (const role of ['attack', 'defend']) {
const cell = plan.find(
(row) => row.unit === 1400 && row.opponent === 1400 && row.role === role && row.condition === 'che_격노'
);
const payload = makePayload(unitSet, cell, 'counter-test');
const subject = role === 'attack' ? payload.attackerGeneral : payload.defenderGenerals[0];
const opponent = role === 'attack' ? payload.defenderGenerals[0] : payload.attackerGeneral;
assert.equal(subject.item, 'che_진압_박혁론');
assert.equal(subject.special2, null);
assert.equal(opponent.special2, 'che_격노');
}
const control = plan.find(
(row) =>
row.unit === 1400 && row.opponent === 1400 && row.role === 'attack' && row.condition === 'no-opponent-trait'
);
const outcomes = [true, false].map((treatment) => {
const payload = makePayload(unitSet, control, 'counter-test', treatment);
let result;
logic.processBattleSimJob(payload, {
onBattleResolved: (outcome) => {
result = observe(outcome, payload, 'attack');
},
});
return result;
});
assert.deepEqual(outcomes[0], outcomes[1]);
});
+69 -4
View File
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { parseArgs } from 'node:util'; import { parseArgs } from 'node:util';
import { DESIGN_VERSION, BUILDS, buildPlan, makePayload, observe, summarize } from './design.mjs'; import { DESIGN_VERSION, BUILDS, buildPlan, makePayload, observe, summarize } from './design.mjs';
import { FOLLOWUP_SUITES, buildFollowupPlan } from './followup.mjs';
import { economicTools, nationPlans, lifecycleProfiles, strategyPlans } from './economy.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url)); const root = fileURLToPath(new URL('../../', import.meta.url));
const { values } = parseArgs({ const { values } = parseArgs({
@@ -19,7 +21,11 @@ const { values } = parseArgs({
}); });
const samples = Number(values.samples); const samples = Number(values.samples);
if (!Number.isSafeInteger(samples) || samples < 2 || samples > 10000) throw new Error('--samples must be 2..10000'); 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)) if (
!['units', 'items', 'traits', 'nations', 'families', 'tiers', 'interactions', ...FOLLOWUP_SUITES].includes(
values.suite
)
)
throw new Error('Unknown suite'); throw new Error('Unknown suite');
const out = path.resolve(root, values.out ?? `test-results/balance-lab/${values.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. // Build before importing; a previous branch's dist must never become measurement evidence.
@@ -27,12 +33,17 @@ for (const pkg of ['common', 'logic']) {
execFileSync('pnpm', ['--filter', `@sammo-ts/${pkg}`, 'build'], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] }); execFileSync('pnpm', ['--filter', `@sammo-ts/${pkg}`, 'build'], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
} }
const logic = await import('../../packages/logic/dist/index.js'); const logic = await import('../../packages/logic/dist/index.js');
const common = await import('../../packages/common/dist/index.js');
const unitSet = logic.parseUnitSetDefinition( const unitSet = logic.parseUnitSetDefinition(
JSON.parse(await readFile(path.join(root, 'resources/unitset/unitset_che.json'), 'utf8')) JSON.parse(await readFile(path.join(root, 'resources/unitset/unitset_che.json'), 'utf8'))
); );
const allItems = await logic.loadItemModules([...logic.ITEM_KEYS]); const allItems = await logic.loadItemModules([...logic.ITEM_KEYS]);
const war = await logic.loadWarTraitModules([...logic.WAR_TRAIT_KEYS]); const war = await logic.loadWarTraitModules([...logic.WAR_TRAIT_KEYS]);
const nations = await logic.loadNationTraitModules([...logic.NATION_TRAIT_KEYS]); const nations = await logic.loadNationTraitModules([...logic.NATION_TRAIT_KEYS]);
const { CommandResolver } = await import('../../packages/logic/dist/src/actions/turn/general/che_징병.js');
const { CommandResolver: VolunteerResolver } =
await import('../../packages/logic/dist/src/actions/turn/nation/che_의병모집.js');
const economics = economicTools(logic, CommandResolver, allItems, war, nations);
const spec = (entry) => ({ const spec = (entry) => ({
key: entry.key, key: entry.key,
name: entry.name, name: entry.name,
@@ -53,7 +64,9 @@ const catalog = {
war: war.map(spec), war: war.map(spec),
nations: nations.map(spec), nations: nations.map(spec),
}; };
let cells = buildPlan(catalog, values.suite); let cells = FOLLOWUP_SUITES.includes(values.suite)
? buildFollowupPlan(catalog, values.suite)
: buildPlan(catalog, values.suite);
if (values.match) if (values.match)
cells = cells.filter((cell) => cells = cells.filter((cell) =>
`${cell.key}/${cell.build}/${cell.mode}/${cell.role}/${cell.budget ?? ''}`.includes(values.match) `${cell.key}/${cell.build}/${cell.mode}/${cell.role}/${cell.budget ?? ''}`.includes(values.match)
@@ -95,9 +108,11 @@ const manifest = {
seedPairing: 'same seed within cell and treatment/control; RNG consumption may diverge', seedPairing: 'same seed within cell and treatment/control; RNG consumption may diverge',
inference: inference:
'per-cell descriptive normal CI; correlated cells and multiple tests prevent global significance claims', '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', economics:
'equal-gold uses base crew cost; recruited uses production CommandResolver costs/population/train/capacity; neither is lifetime cost',
outcome: 'fraction enemy troops lost minus fraction own troops lost; siege uses wall damage separately', 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', environment:
'synthetic CHE defaults tech 3000/year 200; cell overrides are recorded in plan; no production data or mutation',
}, },
completed: false, completed: false,
}; };
@@ -157,6 +172,34 @@ const nationProfiles = nations.map((module) => {
}; };
}); });
await writeFile(path.join(out, 'nation-profiles.json'), JSON.stringify(nationProfiles, null, 2)); await writeFile(path.join(out, 'nation-profiles.json'), JSON.stringify(nationProfiles, null, 2));
await writeFile(path.join(out, 'nation-plans.json'), JSON.stringify(nationPlans(nationProfiles), null, 2));
const probePayload = makePayload(
unitSet,
{ unit: 1100, opponent: 1100, build: 'martial', role: 'attack', mode: 'field' },
'economic-probe'
);
const economicProfiles = [...allItems.filter((item) => item.unique), ...war].map((module) =>
economics.profile(probePayload.attackerGeneral, probePayload.attackerNation, probePayload.time, module)
);
await writeFile(path.join(out, 'economic-profiles.json'), JSON.stringify(economicProfiles, null, 2));
await writeFile(
path.join(out, 'lifecycle-profiles.json'),
JSON.stringify(
lifecycleProfiles(
logic,
common,
probePayload,
[...allItems.filter((item) => item.unique), ...war],
values.seed
),
null,
2
)
);
await writeFile(
path.join(out, 'strategy-plans.json'),
JSON.stringify(strategyPlans(VolunteerResolver, probePayload, nations, allItems), null, 2)
);
const raw = await open(path.join(out, 'samples.jsonl'), 'wx'); const raw = await open(path.join(out, 'samples.jsonl'), 'wx');
const summary = []; const summary = [];
@@ -177,6 +220,7 @@ try {
]); ]);
const run = (treatment, patch) => { const run = (treatment, patch) => {
const payload = makePayload(unitSet, patch ? { ...cell, generalPatch: patch } : cell, seed, treatment); const payload = makePayload(unitSet, patch ? { ...cell, generalPatch: patch } : cell, seed, treatment);
const recruited = cell.budget === 'recruited' ? economics.recruit(payload, cell) : null;
let result; let result;
logic.processBattleSimJob(payload, { logic.processBattleSimJob(payload, {
onBattleResolved: (battle) => { onBattleResolved: (battle) => {
@@ -184,6 +228,7 @@ try {
}, },
}); });
if (!result) throw new Error('Battle did not execute'); if (!result) throw new Error('Battle did not execute');
if (recruited) for (const [key, value] of Object.entries(recruited)) result[`recruit_${key}`] = value;
return result; return result;
}; };
const treatment = run(true); const treatment = run(true);
@@ -239,6 +284,26 @@ const finalHash = await hashTree([
]); ]);
if (finalHash !== sourceHash) throw new Error('Source changed during run; result is not complete'); 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)); await writeFile(path.join(out, 'summary.json'), JSON.stringify(summary, null, 2));
const coverage = [...catalog.items, ...catalog.war].map((entry) => {
const evaluated = summary.filter((row) => row.key === entry.key);
const changed = evaluated.some(
(row) => row.delta && Object.values(row.delta).some((metric) => metric && Math.abs(metric.mean) > 1e-10)
);
return {
key: entry.key,
cells: evaluated.length,
hooks: entry.hooks,
events: entry.events,
battleObservation: !evaluated.length
? 'not-scheduled'
: changed
? 'difference-observed'
: 'no-difference-in-tested-conditions',
economicProfile: true,
limitation: 'No observed difference is not a balance pass or proof of worthlessness',
};
});
await writeFile(path.join(out, 'coverage.json'), JSON.stringify(coverage, null, 2));
manifest.completed = true; manifest.completed = true;
manifest.elapsedSeconds = (Date.now() - started) / 1000; manifest.elapsedSeconds = (Date.now() - started) / 1000;
await writeFile(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2)); await writeFile(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2));