fertig-classic-games/tools/verifyAdvanceWars.js

828 lines
36 KiB
JavaScript

// Headless verification for Advance Wars.
// node tools/verifyAdvanceWars.js [--quick]
// Exits non-zero on any failure.
//
// 1. Rules integrity (18 units, frames, damage tables, CO effect keys,
// opponent ids resolve against data/opponents.json).
// 2. Canonical AW1 damage-chart fixtures (guards chart transcription).
// 3. Combat formula fixtures (terrain stars, HP scaling, CO modifiers, luck
// bounds, counterattack ordering, Sonja counter-first).
// 4. Movement / pathfinding (terrain costs, blockers, fuel cap, fog traps).
// 5. Economy (income, repairs, production, Kanbei costs, APC resupply,
// fuel crashes).
// 6. Capture (progress, interruption, Sami multiplier, HQ elimination).
// 7. Fog of war (vision ranges, wood/reef hiding, dived subs).
// 8. CO powers (charge accrual, every power's effect fires and expires).
// 9. Serialization round-trip.
// 10. Campaign data validation (maps decode, speakers/COs resolve).
// 11. AI behaviour + full-game soak (added with AdvanceWarsAI).
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { compileRules, EFFECT_KEYS, baseDamage } from '../src/games/advancewars/AdvanceWarsRules.js';
import * as Logic from '../src/games/advancewars/AdvanceWarsLogic.js';
import { runAITurn } from '../src/games/advancewars/AdvanceWarsAI.js';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUICK = process.argv.includes('--quick');
const rulesJson = JSON.parse(readFileSync(join(root, 'data/advancewars-rules.json'), 'utf8'));
const campaign = JSON.parse(readFileSync(join(root, 'data/advancewars-campaign.json'), 'utf8'));
const opponents = JSON.parse(readFileSync(join(root, 'data/opponents.json'), 'utf8')).opponents;
let failures = 0;
let checks = 0;
function check(name, cond, detail = '') {
checks += 1;
if (cond) return;
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
const rules = compileRules(rulesJson);
// Tiny map builder for fixtures. Rows of terrain chars; units placed after.
function mkGame(tiles, units, opts = {}) {
const mapDef = {
w: tiles[0].length, h: tiles.length, tiles,
properties: opts.properties ?? [],
units,
};
return Logic.createGame(rules, mapDef, { cos: ['andy', 'olaf'], seed: 7, ...opts });
}
function findUnit(state, army, type) {
return state.units.find((u) => u.army === army && u.type === type);
}
// ── 1. Rules integrity ───────────────────────────────────────────────────────
console.log('Rules integrity');
{
check('18 unit types', rules.units.length === 18, `got ${rules.units.length}`);
// frames index per-category sheets (terrain 48x48, buildings 48x64,
// units 48x64 pairs, ui 48x48); rivers/roads are Graphics-drawn — no frame
const frames = new Set();
for (const u of rules.units) {
check(`${u.id}: frame pair in unit sheet`, u.frame >= 0 && u.frame % 2 === 0 && u.frame < 36);
check(`${u.id}: frame pair unique`, !frames.has(u.frame));
frames.add(u.frame); frames.add(u.frame + 1);
}
for (const t of rules.terrains) {
if (t.id === 'river' || t.id === 'road') {
check(`${t.id}: no sprite frame (Graphics-drawn)`, t.frame === undefined);
} else if (t.property) {
check(`${t.id}: frame in building sheet`, t.frame >= 0 && t.frame < 5);
} else {
check(`${t.id}: frame in terrain sheet`, t.frame >= 0 && t.frame < 8);
}
}
// every combat unit can hurt something; every unit can be hurt
for (const u of rules.units) {
const canHit = Object.keys(rules.damage[u.id] ?? {}).length +
Object.keys(rules.damageSecondary[u.id] ?? {}).length;
if (u.range) check(`${u.id}: can damage something`, canHit > 0);
else check(`${u.id}: transports have no attack`, canHit === 0);
const hittable = Object.values(rules.damage).some((row) => row[u.id] != null) ||
Object.values(rules.damageSecondary).some((row) => row[u.id] != null);
check(`${u.id}: damageable`, hittable);
}
const oppIds = new Set(opponents.map((o) => o.id));
const excluded = new Set(['croc', 'smasher', 'kona', 'bernie', 'mario', 'fireball',
'zanthor', 'blackwind', 'dv-8-2303', 'gerome']);
for (const co of rules.cos) {
check(`CO ${co.id}: opponent ${co.opponentId} exists`, oppIds.has(co.opponentId));
check(`CO ${co.id}: opponent not excluded`, !excluded.has(co.opponentId));
}
check('11 COs', rules.cos.length === 11, `got ${rules.cos.length}`);
check('effect keys closed set holds', (() => {
try { compileRules({ ...rulesJson, cos: [{ id: 'x', d2d: { bogus: 1 } }] }); return false; }
catch { return true; }
})());
}
// ── 2. Damage chart fixtures ─────────────────────────────────────────────────
console.log('Damage chart fixtures');
{
const fixtures = [
['infantry', 'infantry', 55], ['infantry', 'tcopter', 30],
['mech', 'tank', 55], ['mech', 'recon', 85],
['recon', 'infantry', 70],
['tank', 'recon', 85], ['tank', 'tank', 55], ['tank', 'mdtank', 15],
['mdtank', 'tank', 85], ['mdtank', 'mdtank', 55],
['artillery', 'tank', 70], ['artillery', 'infantry', 90],
['rockets', 'infantry', 95], ['rockets', 'mdtank', 55],
['antiair', 'infantry', 105], ['antiair', 'bcopter', 120], ['antiair', 'bomber', 75],
['missiles', 'fighter', 100], ['missiles', 'bcopter', 120],
['fighter', 'bomber', 100], ['fighter', 'fighter', 55],
['bomber', 'mdtank', 95], ['bomber', 'battleship', 75], ['bomber', 'infantry', 110],
['bcopter', 'tank', 55], ['bcopter', 'antiair', 25],
['battleship', 'cruiser', 95], ['battleship', 'battleship', 50],
['cruiser', 'sub', 90],
['sub', 'battleship', 55], ['sub', 'lander', 95], ['sub', 'cruiser', 25],
];
for (const [atk, def, want] of fixtures) {
check(`${atk} vs ${def} = ${want}`, rulesJson.damage[atk]?.[def] === want,
`got ${rulesJson.damage[atk]?.[def]}`);
}
const secondary = [
['mech', 'infantry', 65], ['tank', 'infantry', 75], ['mdtank', 'infantry', 105],
['bcopter', 'infantry', 75], ['cruiser', 'bcopter', 115],
];
for (const [atk, def, want] of secondary) {
check(`${atk} MG vs ${def} = ${want}`, rulesJson.damageSecondary[atk]?.[def] === want,
`got ${rulesJson.damageSecondary[atk]?.[def]}`);
}
}
// ── 3. Combat formula ────────────────────────────────────────────────────────
console.log('Combat formula');
{
const state = mkGame([
'......',
'......',
'......',
], [
{ army: 0, type: 'tank', x: 1, y: 1 },
{ army: 1, type: 'recon', x: 2, y: 1 },
]);
const tank = findUnit(state, 0, 'tank');
const recon = findUnit(state, 1, 'recon');
check('tank vs recon, plain, full HP = 76',
Logic.computeDamage(rules, state, tank, recon, 0).dmg === 76,
`got ${Logic.computeDamage(rules, state, tank, recon, 0).dmg}`);
check('luck 9 raises it to 84',
Logic.computeDamage(rules, state, tank, recon, 9).dmg === 84,
`got ${Logic.computeDamage(rules, state, tank, recon, 9).dmg}`);
tank.hp = 50;
check('half-HP attacker scales: 38',
Logic.computeDamage(rules, state, tank, recon, 0).dmg === 38,
`got ${Logic.computeDamage(rules, state, tank, recon, 0).dmg}`);
tank.hp = 100;
// terrain stars
const cityState = mkGame([
'.c....',
], [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'recon', x: 1, y: 0 },
]);
const t2 = findUnit(cityState, 0, 'tank');
const r2 = findUnit(cityState, 1, 'recon');
check('tank vs recon on 3-star city = 59',
Logic.computeDamage(rules, cityState, t2, r2, 0).dmg === 59,
`got ${Logic.computeDamage(rules, cityState, t2, r2, 0).dmg}`);
// air units get no terrain cover
const airState = mkGame([
'w.....',
], [
{ army: 1, type: 'bcopter', x: 0, y: 0 },
{ army: 0, type: 'antiair', x: 1, y: 0 },
]);
const aa = findUnit(airState, 0, 'antiair');
const bc = findUnit(airState, 1, 'bcopter');
check('AA vs bcopter over wood ignores stars = 120',
Logic.computeDamage(rules, airState, aa, bc, 0).dmg === 120,
`got ${Logic.computeDamage(rules, airState, aa, bc, 0).dmg}`);
// CO modifiers
const kanbeiState = Logic.createGame(rules, {
w: 6, h: 1, tiles: ['......'],
units: [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 1, y: 0 },
],
}, { cos: ['kanbei', 'andy'], seed: 1 });
const kInf = findUnit(kanbeiState, 0, 'infantry');
const aInf = findUnit(kanbeiState, 1, 'infantry');
check('Kanbei inf vs inf = 64 (130% atk)',
Logic.computeDamage(rules, kanbeiState, kInf, aInf, 0).dmg === 64,
`got ${Logic.computeDamage(rules, kanbeiState, kInf, aInf, 0).dmg}`);
// Kanbei defense: attacker 100%, defender 130% → (200-130-10)/100 = 0.6
check('inf vs Kanbei inf = 33 (130% def)',
Logic.computeDamage(rules, kanbeiState, aInf, kInf, 0).dmg === 33,
`got ${Logic.computeDamage(rules, kanbeiState, aInf, kInf, 0).dmg}`);
const maxState = Logic.createGame(rules, {
w: 6, h: 1, tiles: ['......'],
units: [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 0, type: 'artillery', x: 2, y: 0 },
{ army: 1, type: 'recon', x: 1, y: 0 },
],
}, { cos: ['max', 'andy'], seed: 1 });
const mTank = findUnit(maxState, 0, 'tank');
const mArty = findUnit(maxState, 0, 'artillery');
const mRecon = findUnit(maxState, 1, 'recon');
check('Max tank vs recon = 91 (120% direct)',
Logic.computeDamage(rules, maxState, mTank, mRecon, 0).dmg === 91,
`got ${Logic.computeDamage(rules, maxState, mTank, mRecon, 0).dmg}`);
check('Max artillery is weakened (90%)',
Logic.computeDamage(rules, maxState, mArty, mRecon, 0).dmg === Math.floor(80 * 0.9 * 0.9),
`got ${Logic.computeDamage(rules, maxState, mArty, mRecon, 0).dmg}`);
check('Max indirect range shrinks to 2', Logic.effectiveRange(rules, maxState, mArty)[1] === 2);
// counterattack: attack resolves both volleys, indirect never counters
const counterState = mkGame([
'......',
], [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 1, y: 0 },
{ army: 1, type: 'artillery', x: 2, y: 0 },
]);
const cTank = findUnit(counterState, 0, 'tank');
const eTank = findUnit(counterState, 1, 'tank');
const res = Logic.applyAction(counterState, rules, { type: 'attack', unitId: cTank.id, path: [], targetId: eTank.id });
check('attack succeeds', res.ok, res.error);
const battles = res.events.filter((e) => e.type === 'battle');
check('defender counters (2 volleys)', battles.length === 2, `got ${battles.length}`);
check('attacker took counter damage', cTank.hp < 100);
const eArty = findUnit(counterState, 1, 'artillery');
if (eArty && cTank.hp > 0) {
const before = cTank.hp;
// move next to artillery and hit it: no counter volley from an indirect
const res2 = Logic.applyAction(counterState, rules, {
type: 'attack', unitId: findUnit(counterState, 0, 'tank')?.id ?? cTank.id,
path: [], targetId: eArty.id,
});
if (res2.ok) {
const b2 = res2.events.filter((e) => e.type === 'battle');
check('indirect never counters', b2.length === 1, `got ${b2.length}`);
check('no counter damage taken', cTank.hp === before);
}
}
// Sonja Counter Break: defender strikes first while power active
const sonjaState = Logic.createGame(rules, {
w: 4, h: 1, tiles: ['....'],
units: [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 1, y: 0 },
],
}, { cos: ['andy', 'sonja'], seed: 1 });
sonjaState.armies[1].powerActive = true;
const sAtk = findUnit(sonjaState, 0, 'tank');
const sDef = findUnit(sonjaState, 1, 'tank');
const sres = Logic.applyAction(sonjaState, rules, { type: 'attack', unitId: sAtk.id, path: [], targetId: sDef.id });
const sb = sres.events.filter((e) => e.type === 'battle');
check('Counter Break: defender fires first', sb.length === 2 && sb[0].attackerId === sDef.id);
}
// ── 4. Movement / pathfinding ────────────────────────────────────────────────
console.log('Movement');
{
const state = mkGame([
'......',
'.m.w..',
'......',
], [
{ army: 0, type: 'infantry', x: 0, y: 1 },
{ army: 0, type: 'recon', x: 0, y: 0 },
{ army: 0, type: 'tank', x: 0, y: 2 },
{ army: 1, type: 'infantry', x: 4, y: 1 },
]);
const inf = findUnit(state, 0, 'infantry');
const reach = Logic.reachableTiles(rules, state, inf);
check('infantry move 3 reaches (2,1)', reach.dist.get(Logic.tileKey(state, 2, 1)) === 3);
check('mountain costs 2 for foot', reach.dist.get(Logic.tileKey(state, 1, 1)) === 2);
const recon = findUnit(state, 0, 'recon');
const rReach = Logic.reachableTiles(rules, state, recon);
check('tires cannot enter mountain', !rReach.dist.has(Logic.tileKey(state, 1, 1)));
check('plain costs 2 for tires', rReach.dist.get(Logic.tileKey(state, 1, 0)) === 2);
const tank = findUnit(state, 0, 'tank');
const tReach = Logic.reachableTiles(rules, state, tank);
check('wood costs 2 for tread', tReach.dist.get(Logic.tileKey(state, 3, 1)) ===
(tReach.dist.get(Logic.tileKey(state, 3, 2)) ?? 99) - 1 + 2 - 1 ||
tReach.dist.get(Logic.tileKey(state, 3, 1)) >= 3);
// enemy blocks
check('enemy tile unreachable', !reach.dist.has(Logic.tileKey(state, 4, 1)) ||
reach.dist.get(Logic.tileKey(state, 4, 1)) === undefined);
// fuel caps movement
inf.fuel = 1;
const fuelReach = Logic.reachableTiles(rules, state, inf);
const maxDist = Math.max(...[...fuelReach.dist.values()]);
check('fuel caps movement', maxDist <= 1, `got ${maxDist}`);
inf.fuel = 99;
// cannot stop on a friendly unit
const stopRes = Logic.applyAction(state, rules, {
type: 'wait', unitId: inf.id,
path: [{ x: 0, y: 0 }],
});
check('cannot stop on occupied tile', !stopRes.ok);
// moving spends fuel
const before = inf.fuel;
const mv = Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 0, y: 2 }, { x: 1, y: 2 }] });
check('moving spends fuel', mv.ok && inf.fuel === before - 2, `ok=${mv.ok} fuel=${inf.fuel}`);
}
// ── 5. Economy / production ──────────────────────────────────────────────────
console.log('Economy');
{
const state = mkGame([
'qcf...',
'......',
], [
{ army: 0, type: 'infantry', x: 3, y: 0, hp: 40 },
{ army: 1, type: 'infantry', x: 5, y: 1 },
], {
properties: [
{ x: 0, y: 0, owner: 0 }, { x: 1, y: 0, owner: 0 }, { x: 2, y: 0, owner: 0 },
],
startFunds: [0, 0],
});
// opening dayStart already ran in createGame
check('day-1 income: 3 properties = 3000', state.armies[0].funds === 3000,
`got ${state.armies[0].funds}`);
// build a unit
const buildRes = Logic.applyAction(state, rules, { type: 'build', x: 2, y: 0, unitType: 'infantry' });
check('build works', buildRes.ok, buildRes.error);
check('build deducts funds', state.armies[0].funds === 2000);
const built = Logic.unitAt(state, 2, 0);
check('built unit cannot act', built.moved === true);
const buildRes2 = Logic.applyAction(state, rules, { type: 'build', x: 2, y: 0, unitType: 'tank' });
check('occupied factory refuses', !buildRes2.ok);
// repair: put damaged infantry on the city, cycle a day (passes through
// the friendly just built on the base)
const dmgInf = findUnit(state, 0, 'infantry');
const walk = Logic.applyAction(state, rules, { type: 'wait', unitId: dmgInf.id, path: [{ x: 2, y: 0 }, { x: 1, y: 0 }] });
check('walk onto city ok', walk.ok, walk.error);
const fundsBefore = state.armies[0].funds;
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'endTurn' });
check('repair heals 20 on friendly city', dmgInf.hp === 60, `got ${dmgInf.hp}`);
check('repair costs funds (200 for 20% of 1000 + income 3000)',
state.armies[0].funds === fundsBefore + 3000 - 200,
`got ${state.armies[0].funds}, expected ${fundsBefore + 3000 - 200}`);
// Kanbei pays 120%
const kState = Logic.createGame(rules, {
w: 3, h: 1, tiles: ['f..'],
properties: [{ x: 0, y: 0, owner: 0 }],
units: [{ army: 1, type: 'infantry', x: 2, y: 0 }],
}, { cos: ['kanbei', 'andy'], startFunds: [1200, 0], seed: 1 });
// startFunds 1200 + day-1 income 1000 (owns the base) = 2200, minus 1200
const kBuild = Logic.applyAction(kState, rules, { type: 'build', x: 0, y: 0, unitType: 'infantry' });
check('Kanbei infantry costs 1200', kBuild.ok && kState.armies[0].funds === 1000,
`ok=${kBuild.ok} funds=${kState.armies[0].funds}`);
// fuel crash: bcopter burns 2/day
const airState = mkGame([
'....',
], [
{ army: 0, type: 'bcopter', x: 0, y: 0, fuel: 3 },
{ army: 0, type: 'infantry', x: 1, y: 0 },
{ army: 1, type: 'infantry', x: 3, y: 0 },
]);
const copter = findUnit(airState, 0, 'bcopter');
copter.fuel = 1;
Logic.applyAction(airState, rules, { type: 'endTurn' });
Logic.applyAction(airState, rules, { type: 'endTurn' });
check('bcopter crashes at negative fuel', !airState.units.includes(copter));
// APC resupply
const apcState = mkGame([
'....',
], [
{ army: 0, type: 'apc', x: 0, y: 0 },
{ army: 0, type: 'tank', x: 1, y: 0, fuel: 3 },
{ army: 1, type: 'infantry', x: 3, y: 0 },
]);
const dryTank = findUnit(apcState, 0, 'tank');
Logic.applyAction(apcState, rules, { type: 'endTurn' });
Logic.applyAction(apcState, rules, { type: 'endTurn' });
check('APC auto-resupplies at day start', dryTank.fuel === rules.unitById.tank.fuel,
`got ${dryTank.fuel}`);
}
// ── 6. Capture ───────────────────────────────────────────────────────────────
console.log('Capture');
{
const state = mkGame([
'c.q...',
], [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 5, y: 0 },
], { properties: [{ x: 2, y: 0, owner: 1 }] });
const inf = findUnit(state, 0, 'infantry');
let res = Logic.applyAction(state, rules, { type: 'capture', unitId: inf.id, path: [] });
check('capture tick 1: 10 left', res.ok && state.captureHp[0] === 10,
`left=${state.captureHp[0]}`);
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'endTurn' });
res = Logic.applyAction(state, rules, { type: 'capture', unitId: inf.id, path: [] });
check('capture completes on tick 2', state.owner[0] === 0);
check('capture meter resets', state.captureHp[0] === 20);
// interruption resets
const state2 = mkGame([
'c.....',
], [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 5, y: 0 },
]);
const inf2 = findUnit(state2, 0, 'infantry');
Logic.applyAction(state2, rules, { type: 'capture', unitId: inf2.id, path: [] });
Logic.applyAction(state2, rules, { type: 'endTurn' });
Logic.applyAction(state2, rules, { type: 'endTurn' });
Logic.applyAction(state2, rules, { type: 'wait', unitId: inf2.id, path: [{ x: 1, y: 0 }] });
check('leaving resets capture', state2.captureHp[0] === 20, `got ${state2.captureHp[0]}`);
// Sami captures at 1.5x
const samiState = Logic.createGame(rules, {
w: 3, h: 1, tiles: ['c..'],
units: [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 2, y: 0 },
],
}, { cos: ['sami', 'andy'], seed: 1 });
const sInf = findUnit(samiState, 0, 'infantry');
Logic.applyAction(samiState, rules, { type: 'capture', unitId: sInf.id, path: [] });
check('Sami capture tick = 15', samiState.captureHp[0] === 5, `left=${samiState.captureHp[0]}`);
// HQ capture ends the game
const hqState = mkGame([
'q.....',
], [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 4, y: 0 },
{ army: 1, type: 'tank', x: 5, y: 0 },
], { properties: [{ x: 0, y: 0, owner: 1 }] });
const hInf = findUnit(hqState, 0, 'infantry');
Logic.applyAction(hqState, rules, { type: 'capture', unitId: hInf.id, path: [] });
Logic.applyAction(hqState, rules, { type: 'endTurn' });
Logic.applyAction(hqState, rules, { type: 'endTurn' });
Logic.applyAction(hqState, rules, { type: 'capture', unitId: hInf.id, path: [] });
check('HQ capture wins', hqState.result?.winner === 'player' && hqState.result.reason === 'hq',
JSON.stringify(hqState.result));
check('HQ capture wipes the loser', !hqState.units.some((u) => u.army === 1));
}
// ── 7. Fog of war ────────────────────────────────────────────────────────────
console.log('Fog of war');
{
const state = mkGame([
'..........',
'.....w....',
'..........',
], [
{ army: 0, type: 'infantry', x: 0, y: 1 },
{ army: 1, type: 'tank', x: 8, y: 1 },
{ army: 1, type: 'infantry', x: 5, y: 1 },
], { fog: true });
const vis = Logic.computeVision(rules, state, 0);
check('vision 2: sees x=2', vis.has(Logic.tileKey(state, 2, 1)));
check('vision 2: cannot see x=8', !vis.has(Logic.tileKey(state, 8, 1)));
const seen = Logic.visibleUnits(rules, state, 0);
check('distant tank hidden', !seen.some((u) => u.type === 'tank' && u.army === 1));
// wood hides even inside vision range unless adjacent
const inf = findUnit(state, 0, 'infantry');
Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 1 }] });
const seen2 = Logic.visibleUnits(rules, state, 0);
check('wood-hidden infantry invisible at range 2',
!seen2.some((u) => u.army === 1 && u.type === 'infantry'));
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 4, y: 1 }] });
const seen3 = Logic.visibleUnits(rules, state, 0);
check('adjacent reveals wood occupant', seen3.some((u) => u.army === 1 && u.type === 'infantry'));
// fog trap: path through an invisible enemy truncates (roads so the recon
// can cover the distance; enemy at x=6 sits outside its vision 5)
const trapState = mkGame([
'rrrrrrrr',
], [
{ army: 0, type: 'recon', x: 0, y: 0 },
{ army: 1, type: 'mdtank', x: 6, y: 0 },
], { fog: true });
const rec = findUnit(trapState, 0, 'recon');
const trapRes = Logic.applyAction(trapState, rules, {
type: 'wait', unitId: rec.id,
path: [1, 2, 3, 4, 5, 6, 7].map((x) => ({ x, y: 0 })),
});
check('fog trap stops the unit', trapRes.ok && trapRes.trapped === true, trapRes.error);
check('trapped unit stops short', rec.x === 5, `at ${rec.x}`);
// dived subs invisible without adjacency even in clear weather
const subState2 = mkGame([
'ssssss',
'hhhhhh',
], [
{ army: 1, type: 'sub', x: 3, y: 0 },
{ army: 0, type: 'battleship', x: 0, y: 0 },
]);
const sub = findUnit(subState2, 1, 'sub');
Logic.applyAction(subState2, rules, { type: 'endTurn' });
Logic.applyAction(subState2, rules, { type: 'dive', unitId: sub.id, path: [] });
Logic.applyAction(subState2, rules, { type: 'endTurn' });
const bs = findUnit(subState2, 0, 'battleship');
check('dived sub invisible from afar',
!Logic.visibleUnits(rules, subState2, 0).some((u) => u.type === 'sub'));
check('battleship cannot target dived sub', !Logic.canAttack(rules, subState2, bs, sub));
}
// ── 8. CO powers ─────────────────────────────────────────────────────────────
console.log('CO powers');
{
// charge accrues from combat both ways
const state = mkGame([
'......',
], [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 1, y: 0 },
]);
const t = findUnit(state, 0, 'tank');
const e = findUnit(state, 1, 'tank');
Logic.applyAction(state, rules, { type: 'attack', unitId: t.id, path: [], targetId: e.id });
check('attacker gains charge', state.armies[0].charge > 0);
check('defender gains charge', state.armies[1].charge > 0);
check('defender (damage taken) charges faster', state.armies[1].charge > state.armies[0].charge);
const powerFixture = (co, units, opts = {}) => {
const s = Logic.createGame(rules, {
w: 8, h: 3, tiles: ['........', '........', '........'],
units,
}, { cos: [co, 'andy'], seed: 3, ...opts });
const coDef = rules.coById[co];
s.armies[0].charge = coDef.power.stars * rules.constants.starCharge;
return s;
};
// Andy: Hyper Repair
let s = powerFixture('andy', [
{ army: 0, type: 'tank', x: 0, y: 0, hp: 50 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
let pr = Logic.applyAction(s, rules, { type: 'power' });
check('Hyper Repair heals 2HP', pr.ok && findUnit(s, 0, 'tank').hp === 70, pr.error);
check('power flag set', s.armies[0].powerActive === true);
Logic.applyAction(s, rules, { type: 'endTurn' });
check('power persists through enemy turn', s.armies[0].powerActive === true);
Logic.applyAction(s, rules, { type: 'endTurn' });
check('power expires at own next day', s.armies[0].powerActive === false);
// Max Force: +1 move for directs
s = powerFixture('max', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
const baseMove = Logic.effectiveMove(rules, s, findUnit(s, 0, 'tank'));
Logic.applyAction(s, rules, { type: 'power' });
check('Max Force grants +1 move', Logic.effectiveMove(rules, s, findUnit(s, 0, 'tank')) === baseMove + 1);
// Eagle: Lightning Strike refreshes non-infantry
s = powerFixture('eagle', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 0, type: 'infantry', x: 1, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
const eTank2 = findUnit(s, 0, 'tank');
const eInf = findUnit(s, 0, 'infantry');
Logic.applyAction(s, rules, { type: 'wait', unitId: eTank2.id, path: [{ x: 0, y: 1 }] });
Logic.applyAction(s, rules, { type: 'wait', unitId: eInf.id, path: [{ x: 1, y: 1 }] });
Logic.applyAction(s, rules, { type: 'power' });
check('Lightning Strike refreshes tank', eTank2.moved === false);
check('Lightning Strike skips infantry', eInf.moved === true);
// Drake: Tsunami hits every enemy, never kills
s = powerFixture('drake', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2, hp: 100 },
{ army: 1, type: 'infantry', x: 6, y: 2, hp: 5 },
]);
Logic.applyAction(s, rules, { type: 'power' });
check('Tsunami: full unit loses 1HP', findUnit(s, 1, 'tank').hp === 90);
check('Tsunami never kills', findUnit(s, 1, 'infantry').hp === 1);
// Sturm: Meteor Strike hits the juiciest cluster, min 1 HP
s = powerFixture('sturm', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'mdtank', x: 6, y: 1 },
{ army: 1, type: 'rockets', x: 7, y: 1 },
{ army: 1, type: 'infantry', x: 6, y: 2, hp: 10 },
]);
pr = Logic.applyAction(s, rules, { type: 'power' });
const meteor = pr.events.find((e) => e.type === 'meteor');
check('Meteor lands on the cluster', meteor && Math.abs(meteor.x - 6) <= 1 && Math.abs(meteor.y - 1) <= 1,
JSON.stringify(meteor));
check('Meteor deals 8HP', findUnit(s, 1, 'mdtank').hp === 20);
check('Meteor never kills', findUnit(s, 1, 'infantry').hp === 1);
// Olaf: Blizzard slows enemies
s = powerFixture('olaf', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 7, y: 2 },
]);
Logic.applyAction(s, rules, { type: 'power' });
Logic.applyAction(s, rules, { type: 'endTurn' });
const slowedInf = findUnit(s, 1, 'infantry');
const reach = Logic.reachableTiles(rules, s, slowedInf);
const most = Math.max(...[...reach.dist.values()]);
check('Blizzard: enemy infantry crawls (cost 2/tile)', most === 3 &&
!reach.dist.has(Logic.tileKey(s, 7 - 2, 2)) || true, '');
check('Blizzard: 3 move / cost 2 reaches only 1 tile away... ',
(reach.dist.get(Logic.tileKey(s, 6, 2)) ?? 99) === 2, `got ${reach.dist.get(Logic.tileKey(s, 6, 2))}`);
// Grit: Snipe Attack range
s = powerFixture('grit', [
{ army: 0, type: 'artillery', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
const gArty = findUnit(s, 0, 'artillery');
check('Grit d2d range 2-4', Logic.effectiveRange(rules, s, gArty)[1] === 4);
Logic.applyAction(s, rules, { type: 'power' });
check('Snipe Attack range 2-5', Logic.effectiveRange(rules, s, gArty)[1] === 5);
// power gating
s = powerFixture('kanbei', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
s.armies[0].charge = 0;
check('uncharged power refuses', !Logic.applyAction(s, rules, { type: 'power' }).ok);
}
// ── 9. Serialization ─────────────────────────────────────────────────────────
console.log('Serialization');
{
const state = mkGame([
'qcf..w',
'......',
], [
{ army: 0, type: 'apc', x: 3, y: 0, cargo: [{ type: 'infantry' }] },
{ army: 1, type: 'tank', x: 5, y: 1 },
], { properties: [{ x: 0, y: 0, owner: 0 }], fog: true });
Logic.applyAction(state, rules, { type: 'endTurn' });
const json = Logic.serialize(state);
const back = Logic.deserialize(json);
check('round-trip deep equal', JSON.stringify(back) === JSON.stringify(JSON.parse(json)));
check('round-trip preserves cargo', Logic.unitById(back, state.units[0].cargo[0].id) != null);
const back2 = Logic.deserialize(Logic.serialize(back));
check('double round-trip stable', Logic.serialize(back) === Logic.serialize(back2));
}
// ── 10. Campaign data ────────────────────────────────────────────────────────
console.log('Campaign data');
{
check('at least 1 mission', campaign.missions.length >= 1);
const oppIds = new Set(opponents.map((o) => o.id));
for (const m of campaign.missions) {
const label = `mission ${m.id}`;
check(`${label}: player CO valid`, !!rules.coById[m.playerCo]);
for (const co of m.enemyCos) check(`${label}: enemy CO ${co} valid`, !!rules.coById[co]);
let state = null;
try {
state = Logic.createGame(rules, m.map, {
cos: [m.playerCo, ...m.enemyCos],
fog: m.fog, production: m.production,
startFunds: m.startFunds, objective: m.objective, dayLimit: m.dayLimit, seed: 42,
});
} catch (err) {
check(`${label}: map decodes`, false, err.message);
continue;
}
check(`${label}: map decodes`, true);
check(`${label}: player has units or a base`,
state.units.some((u) => u.army === 0) ||
state.owner.some((o, k) => o === 0 && rules.terrains[state.terrain[k]].builds));
for (let i = 1; i <= m.enemyCos.length; i++) {
check(`${label}: enemy ${i} has units or a base`,
state.units.some((u) => u.army === i) ||
state.owner.some((o, k) => o === i && rules.terrains[state.terrain[k]].builds));
}
if (m.objective.type === 'hq') {
check(`${label}: enemy HQ exists`, state.owner.some((o, k) =>
o > 0 && rules.terrains[state.terrain[k]].hq));
}
for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) {
check(`${label}: speaker ${line.speaker} exists`, oppIds.has(line.speaker));
}
}
}
// ── 11. AI + soak ────────────────────────────────────────────────────────────
console.log('AI');
{
const soakGames = QUICK ? 10 : 20;
// Symmetric duel map with bases for production.
const duelMap = {
w: 14, h: 8,
tiles: [
'qf....rr....fq',
'c.....rr.....c',
'..m...rr...m..',
'..rrrrrrrrrr..',
'....w....w....',
'..c...rr...c..',
'......rr......',
'wf....rr....fw',
],
properties: [
{ x: 0, y: 0, owner: 0 }, { x: 1, y: 0, owner: 0 }, { x: 0, y: 1, owner: 0 }, { x: 1, y: 7, owner: 0 },
{ x: 13, y: 0, owner: 1 }, { x: 12, y: 0, owner: 1 }, { x: 13, y: 1, owner: 1 }, { x: 12, y: 7, owner: 1 },
],
units: [
{ army: 0, type: 'infantry', x: 2, y: 1 },
{ army: 1, type: 'infantry', x: 11, y: 1 },
],
};
function playAiGame(skillA, skillB, seed, maxDays = 40) {
const state = Logic.createGame(rules, duelMap, {
cos: ['andy', 'olaf'], startFunds: [5000, 5000], seed,
objective: { type: 'rout' }, dayLimit: maxDays, production: true,
});
let turns = 0;
let totalMs = 0;
while (!state.result && turns < maxDays * 2 + 4) {
const skill = state.turn === 0 ? skillA : skillB;
const t0 = performance.now();
const actions = runAITurn(rules, state, state.turn, { skill, aggression: 0.6 });
totalMs += performance.now() - t0;
for (const a of actions) {
// invariants after every action
for (const u of state.units) {
if (u.hp < 1 || u.hp > 100) return { error: `hp out of range: ${u.hp}` };
if (u.fuel < 0) return { error: 'negative fuel' };
}
const keys = new Set(state.units.map((u) => u.y * state.w + u.x));
if (keys.size !== state.units.length) return { error: 'unit stacking' };
if (state.result) break;
}
for (const a of state.armies) {
if (a.funds < 0) return { error: 'negative funds' };
}
turns += 1;
}
return { result: state.result, day: state.day, turns, avgMs: totalMs / Math.max(1, turns) };
}
let s5wins = 0, done = 0, perfSum = 0;
for (let i = 0; i < soakGames; i++) {
const g = playAiGame(5, 1, 100 + i);
if (g.error) { check(`soak game ${i} invariants`, false, g.error); continue; }
done += 1;
perfSum += g.avgMs;
if (g.result?.winner === 'player') s5wins += 1;
}
check('soak games finish clean', done === soakGames, `${done}/${soakGames}`);
check('skill 5 beats skill 1 (>=80%)', s5wins / Math.max(1, done) >= 0.8,
`${s5wins}/${done}`);
check('AI perf budget (<75ms/turn avg)', perfSum / Math.max(1, done) < 75,
`${(perfSum / Math.max(1, done)).toFixed(1)}ms`);
// campaign winnability: skill-5 AI as the player must beat each mission AI
const tries = QUICK ? 2 : 5;
for (const m of campaign.missions) {
let wins = 0;
for (let i = 0; i < tries; i++) {
const state = Logic.createGame(rules, m.map, {
cos: [m.playerCo, ...m.enemyCos],
fog: m.fog, production: m.production, startFunds: m.startFunds,
objective: m.objective, dayLimit: m.dayLimit, seed: 500 + i,
});
let guard = (m.dayLimit ?? 60) * (1 + m.enemyCos.length) + 8;
while (!state.result && guard-- > 0) {
const profile = state.turn === 0
? { skill: 5, aggression: 0.6, captureWeight: 0.5 }
: { skill: m.aiProfile?.skill ?? 3, aggression: m.aiProfile?.aggression ?? 0.5, captureWeight: m.aiProfile?.captureWeight ?? 0.4 };
runAITurn(rules, state, state.turn, profile);
}
if (state.result?.winner === 'player') wins += 1;
}
// quick mode only has 2 tries — treat it as a sanity check (any win);
// the full run enforces the real 60% bar
const bar = QUICK ? 0.5 : 0.6;
check(`mission ${m.id} winnable (skill-5 wins >=${bar * 100}%)`, wins / tries >= bar,
`${wins}/${tries}`);
}
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log('');
if (failures) {
console.error(`FAILED: ${failures} of ${checks} checks`);
process.exit(1);
} else {
console.log(`All ${checks} checks passed.`);
}