diff --git a/tools/balance-lab/README.md b/tools/balance-lab/README.md index 88828d8b..d782f3bf 100644 --- a/tools/balance-lab/README.md +++ b/tools/balance-lab/README.md @@ -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이라는 음성 대조도 포함한다. diff --git a/tools/balance-lab/analyze.mjs b/tools/balance-lab/analyze.mjs index 6ad31b98..4d79b961 100644 --- a/tools/balance-lab/analyze.mjs +++ b/tools/balance-lab/analyze.mjs @@ -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) })); diff --git a/tools/balance-lab/design.mjs b/tools/balance-lab/design.mjs index 564da997..bba01cdd 100644 --- a/tools/balance-lab/design.mjs +++ b/tools/balance-lab/design.mjs @@ -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; } diff --git a/tools/balance-lab/economy.mjs b/tools/balance-lab/economy.mjs new file mode 100644 index 00000000..b039af30 --- /dev/null +++ b/tools/balance-lab/economy.mjs @@ -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, + }; + }) + ) + ); +} diff --git a/tools/balance-lab/followup.mjs b/tools/balance-lab/followup.mjs new file mode 100644 index 00000000..84bd1b4c --- /dev/null +++ b/tools/balance-lab/followup.mjs @@ -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; +} diff --git a/tools/balance-lab/followup.test.mjs b/tools/balance-lab/followup.test.mjs new file mode 100644 index 00000000..afc8143e --- /dev/null +++ b/tools/balance-lab/followup.test.mjs @@ -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]); +}); diff --git a/tools/balance-lab/run.mjs b/tools/balance-lab/run.mjs index 456ba97e..f46ad1a1 100644 --- a/tools/balance-lab/run.mjs +++ b/tools/balance-lab/run.mjs @@ -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));