밸런스 실험에 모집 경제와 조건부 효과 대조를 보강한다
This commit is contained in:
@@ -72,9 +72,9 @@ Node, 설계 버전, seed, cell/전투 수와 완료 여부를 기록한다. 실
|
||||
있으면 완료하지 않는다. `completed: false`인 디렉터리는 완성 결과가 아니다.
|
||||
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`에 따로 있다. 손실률 평균만으로 전쟁 승리나 국가 생존을
|
||||
예측하지 않는다. 도구 수정 후에는 새 출력 경로에 재실행한다.
|
||||
|
||||
## 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이라는 음성 대조도 포함한다.
|
||||
|
||||
@@ -28,7 +28,16 @@ const lines = [
|
||||
];
|
||||
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']);
|
||||
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, []);
|
||||
groups.get(key).push(row);
|
||||
}
|
||||
@@ -39,8 +48,13 @@ const ranking = [...groups.values()]
|
||||
return {
|
||||
key: group[0].key,
|
||||
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',
|
||||
effect: paired ? 'vs-empty-control' : 'absolute-exchange',
|
||||
effect: paired ? 'vs-specified-control' : 'absolute-exchange',
|
||||
cells: group.length,
|
||||
mean: mean(scores),
|
||||
worstCell: Math.min(...scores),
|
||||
@@ -58,7 +72,7 @@ lines.push(
|
||||
);
|
||||
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)}% |`
|
||||
`| ${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)) {
|
||||
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)} |`
|
||||
);
|
||||
}
|
||||
if (manifest.suite === 'interactions') {
|
||||
if (rows.some((row) => row.synergy)) {
|
||||
lines.push(
|
||||
'',
|
||||
'## Factorial interactions',
|
||||
@@ -104,11 +118,26 @@ if (manifest.suite === 'interactions') {
|
||||
'| 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(
|
||||
`| ${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.md'), lines.join('\n') + '\n');
|
||||
console.log(JSON.stringify({ directory, groups: ranking.length, largest: ranking.slice(0, 5) }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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 = {
|
||||
martial: { leadership: 90, strength: 90, intel: 30 },
|
||||
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);
|
||||
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 ?? {});
|
||||
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') {
|
||||
subject.crew = Math.floor(subject.crew * 0.5);
|
||||
subject.train = 70;
|
||||
@@ -153,6 +160,8 @@ export function makePayload(unitSet, cell, seed, treatment = true) {
|
||||
if (treatment && 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');
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -5,6 +5,8 @@ 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';
|
||||
import { FOLLOWUP_SUITES, buildFollowupPlan } from './followup.mjs';
|
||||
import { economicTools, nationPlans, lifecycleProfiles, strategyPlans } from './economy.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const { values } = parseArgs({
|
||||
@@ -19,7 +21,11 @@ const { values } = parseArgs({
|
||||
});
|
||||
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))
|
||||
if (
|
||||
!['units', 'items', 'traits', 'nations', 'families', 'tiers', 'interactions', ...FOLLOWUP_SUITES].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.
|
||||
@@ -27,12 +33,17 @@ 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 common = await import('../../packages/common/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 { 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) => ({
|
||||
key: entry.key,
|
||||
name: entry.name,
|
||||
@@ -53,7 +64,9 @@ const catalog = {
|
||||
war: war.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)
|
||||
cells = cells.filter((cell) =>
|
||||
`${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',
|
||||
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',
|
||||
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',
|
||||
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,
|
||||
};
|
||||
@@ -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-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 summary = [];
|
||||
@@ -177,6 +220,7 @@ try {
|
||||
]);
|
||||
const run = (treatment, patch) => {
|
||||
const payload = makePayload(unitSet, patch ? { ...cell, generalPatch: patch } : cell, seed, treatment);
|
||||
const recruited = cell.budget === 'recruited' ? economics.recruit(payload, cell) : null;
|
||||
let result;
|
||||
logic.processBattleSimJob(payload, {
|
||||
onBattleResolved: (battle) => {
|
||||
@@ -184,6 +228,7 @@ try {
|
||||
},
|
||||
});
|
||||
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;
|
||||
};
|
||||
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');
|
||||
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.elapsedSeconds = (Date.now() - started) / 1000;
|
||||
await writeFile(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
Reference in New Issue
Block a user