Files
core2026/tools/balance-lab/followup.test.mjs
T

200 lines
9.1 KiB
JavaScript

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]);
});