밸런스 실험에 모집 경제와 조건부 효과 대조를 보강한다
This commit is contained in:
@@ -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이라는 음성 대조도 포함한다.
|
||||||
|
|||||||
@@ -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) }));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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));
|
||||||
|
|||||||
Reference in New Issue
Block a user