// 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, unlockedUnits: m.unlockedUnits, 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, unlockedUnits: m.unlockedUnits, 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.`); }