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

109 lines
5.3 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { BUILDS, makePayload, buildPlan, summarize, observe } from './design.mjs';
const unitSet = JSON.parse(
await readFile(new URL('../../resources/unitset/unitset_che.json', import.meta.url), 'utf8')
);
const cell = {
unit: 1100,
opponent: 1200,
build: 'martial',
role: 'attack',
mode: 'field',
generalPatch: { weapon: 'test-item' },
};
test('allocation preserves total stat budget and caps troops by leadership', () => {
for (const [build, stats] of Object.entries(BUILDS)) {
assert.equal(
Object.values(stats).reduce((a, b) => a + b),
210
);
const payload = makePayload(unitSet, { ...cell, build }, 's');
assert.ok(payload.attackerGeneral.crew <= stats.leadership * 100);
}
});
test('treatment changes only the subject and paired control removes it in both roles', () => {
for (const role of ['attack', 'defend']) {
const treatment = makePayload(unitSet, { ...cell, role, nationTrait: 'che_병가' }, 's');
const control = makePayload(unitSet, { ...cell, role, nationTrait: 'che_병가' }, 's', false);
const own = role === 'attack' ? treatment.attackerGeneral : treatment.defenderGenerals[0];
const enemy = role === 'attack' ? treatment.defenderGenerals[0] : treatment.attackerGeneral;
assert.equal(own.crewtype, cell.unit);
assert.equal(enemy.crewtype, cell.opponent);
assert.equal(own.weapon, 'test-item');
assert.equal(enemy.weapon, null);
own.weapon = null;
treatment[role === 'attack' ? 'attackerNation' : 'defenderNation'].type = 'che_중립';
assert.deepEqual(treatment, control);
}
});
test('field and siege are separate and economic budget respects expensive units', () => {
assert.equal(makePayload(unitSet, cell, 's').defenderCity.wall, 0);
const siege = makePayload(unitSet, { ...cell, mode: 'siege' }, 's');
assert.equal(siege.defenderGenerals.length, 0);
assert.equal(siege.defenderCity.wall, 1000);
const expensive = makePayload(unitSet, { ...cell, unit: 1503, budget: 'equal-gold' }, 's');
assert.ok(expensive.attackerGeneral.crew < 7000);
assert.throws(() => makePayload(unitSet, { ...cell, mode: 'siege', role: 'defend' }, 's'));
});
test('observer excludes wall damage from field exchange and preserves unknown siege exchange', () => {
const payload = makePayload(unitSet, cell, 's');
const outcome = {
attacker: { crew: 6000, rice: 99900, injury: 0 },
defenders: [{ crew: 5000 }],
conquered: false,
reports: [
{ id: 1, type: 'general', dead: 1000, killed: 9000 },
{ id: 2, type: 'general', dead: 2000 },
{ id: null, type: 'city', dead: 7000 },
],
};
assert.equal(observe(outcome, payload, 'attack').fieldKills, 2000);
assert.equal(observe(outcome, payload, 'attack').exchange, 1000 / 7000);
const siege = makePayload(unitSet, { ...cell, mode: 'siege' }, 's');
assert.equal(observe({ ...outcome, defenders: [] }, siege, 'attack').exchange, null);
});
test('statistics expose n, interval and tails; invalid samples fail closed', () => {
assert.deepEqual(summarize([2, 2]).ci95, [2, 2]);
assert.equal(summarize([1, 2, 3]).mean, 2);
assert.equal(summarize([1]).ci95, null);
assert.throws(() => summarize([]));
assert.throws(() => summarize([NaN]));
});
test('design covers every registered unit, both budgets, four builds, roles and siege', () => {
const catalog = { units: unitSet.crewTypes.filter((unit) => unit.armType > 0) };
const plan = buildPlan(catalog, 'units');
assert.equal(plan.length, catalog.units.length * 4 * 2 * 12);
assert.equal(new Set(plan.map((cell) => cell.id)).size, plan.length);
assert.throws(() => buildPlan(catalog, 'no-such-suite'));
});
test('native family and tier comparisons use matched total stats and directly compare tier-three units', () => {
const catalog = { units: unitSet.crewTypes.filter((unit) => unit.armType > 0) };
const families = buildPlan(catalog, 'families');
assert.equal(families.length, 5 * 5 * 3 * 2);
const pair = families.find((row) => row.unit === 1400 && row.opponent === 1100 && row.role === 'attack');
const payload = makePayload(unitSet, pair, 's');
assert.equal(payload.attackerGeneral.intel, 90);
assert.equal(payload.defenderGenerals[0].strength, 90);
const capacity = makePayload(unitSet, { ...pair, unit: 1500, build: 'command', budget: 'capacity' }, 's');
assert.equal(capacity.attackerGeneral.crew, 15000);
const tiers = buildPlan(catalog, 'tiers');
assert.ok(tiers.some((row) => row.unit === 1502 && row.opponent === 1503));
assert.ok(tiers.every((row) => ![1100, 1306].includes(row.unit)));
});
test('depleted line encounters preserve defender ownership and vary only subject readiness', () => {
const payload = makePayload(unitSet, { ...cell, condition: 'depleted', mode: 'line' }, 's');
assert.equal(payload.attackerGeneral.crew, 3500);
assert.equal(payload.attackerGeneral.train, 70);
assert.deepEqual(
payload.defenderGenerals.map((row) => row.no),
[2, 3, 4]
);
assert.ok(payload.defenderGenerals.every((row) => row.nation === 2 && row.city === 2 && row.train === 100));
});