#!/usr/bin/env node // Verifies the Total Annihilation engine, data and AI end to end. // // node tools/verifyTotalAnnihilation.js [--quick] // // Everything here runs headless: TARules/TALogic/TANav/TAMapGen/TAAI import no Phaser. The // campaign section is the payoff of routing every order through TALogic.issueOrder — the AI // plays the HUMAN side, so each mission gets an automated winnability assertion rather than a // promise that it is probably beatable. import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { compileRules, armorMul } from '../src/games/totalannihilation/TARules.js'; import { generateMap, decodeMap } from '../src/games/totalannihilation/TAMapGen.js'; import * as L from '../src/games/totalannihilation/TALogic.js'; import { runAI } from '../src/games/totalannihilation/TAAI.js'; import { ensureSheets } from '../src/games/totalannihilation/TAArt.js'; import TAWorldView, { DEPTHS } from '../src/games/totalannihilation/TAWorldView.js'; import TAFx from '../src/games/totalannihilation/TAFx.js'; import { makeStubScene } from './lib/taStubScene.js'; import { createNav, findPath, clearanceFor, segmentClear, tileOk, worldToTileX, worldToTileY, } from '../src/games/totalannihilation/TANav.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const QUICK = process.argv.includes('--quick'); const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); const artJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-artwork.json'), 'utf8')); const campaign = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), 'utf8')); const opponents = JSON.parse(readFileSync(join(ROOT, 'data/opponents.json'), 'utf8')); const rules = compileRules(rulesJson); const HZ = rules.constants.tickHz; let pass = 0; const failures = []; function check(name, cond, detail) { if (cond) { pass++; return true; } failures.push(detail ? `${name} — ${detail}` : name); return false; } function section(title) { console.log(`\n── ${title}`); } // --------------------------------------------------------------------------- section('1. Rules integrity'); // --------------------------------------------------------------------------- { const ids = new Set(); for (const d of [...rules.units, ...rules.buildings]) { check(`unique def id ${d.id}`, !ids.has(d.id), 'duplicate'); ids.add(d.id); } for (const d of [...rules.units, ...rules.buildings]) { check(`${d.id} costs are positive`, (d.cost?.mass ?? 0) >= 0 && (d.cost?.energy ?? 0) >= 0); check(`${d.id} has hp`, d.hp > 0); for (const w of d.weaponDefs ?? []) { for (const a of rules.armorClasses) { check(`${w.id} vs ${a}`, Number.isFinite(armorMul(w, a)), 'missing armorMul'); } } } for (const u of rules.units) { if (!u.buildTime) continue; const makers = rules.buildings.filter((b) => (b.builds ?? []).includes(u.id)); check(`${u.id} is buildable by some factory`, makers.length > 0); } // Every producible thing must be reachable from the Commander's tech tree, or the player // can see it in the rules and never build it. const commander = rules.unitById.commander; const reach = new Set(commander.builds ?? []); for (let i = 0; i < 4; i++) { for (const id of [...reach]) for (const n of rules.defById[id].builds ?? []) reach.add(n); } for (const d of [...rules.units, ...rules.buildings]) { if (d.id === 'commander') continue; check(`${d.id} reachable from the Commander`, reach.has(d.id)); } for (const c of rules.commanders) { check(`commander ${c.id} maps to a real opponent`, (opponents.opponents ?? []).some((o) => o.id === c.opponentId), c.opponentId); check(`commander ${c.id} army exists`, !!rules.armyById[c.armyId]); } // The D-Gun must stay manual — auto-firing it turns every Commander into a base turret // that deletes one attacker per reload, which makes assaulting anything suicide. check('D-Gun is manual-fire', rules.weaponById.dgun.manual === true); check('Commander has a non-manual weapon', (commander.weaponDefs ?? []).some((w) => !w.manual), 'would be defenceless'); } // --------------------------------------------------------------------------- section('2. Artwork manifest'); // --------------------------------------------------------------------------- { const sheets = artJson.sheets ?? {}; check('artwork declares sheets', Object.keys(sheets).length > 0); for (const [name, s] of Object.entries(sheets)) { check(`sheet ${name} has a key`, !!s.key); check(`sheet ${name} has frame size`, s.frameWidth > 0 && s.frameHeight > 0); check(`sheet ${name} has a kind`, ['unit', 'structure', 'terrain', 'icon'].includes(s.kind), s.kind); } for (const a of rules.armies) { check(`army ${a.id} unit sheet declared`, !!sheets[a.unitSheet], a.unitSheet); check(`army ${a.id} structure sheet declared`, !!sheets[a.structureSheet], a.structureSheet); } for (const [id, t] of Object.entries(artJson.themes ?? {})) { check(`theme ${id} sheet declared`, !!sheets[t.sheet], t.sheet); for (const terr of rules.terrain) { check(`theme ${id} has a colour for ${terr.id}`, !!t.palette?.[terr.id]); } } // terrainFrames drives the procedural terrain sheet's size. It also carries a `_comment` // key, and one non-numeric value in there turns the frame count into NaN and the painted // sheet into a zero-height canvas — which fails in the browser as an opaque WebGL error, // so it gets caught here instead. const tf = Object.entries(artJson.terrainFrames ?? {}).filter(([k]) => !k.startsWith('_')); check('terrainFrames declares frames', tf.length > 0); for (const [name, v] of tf) { check(`terrainFrames.${name} is a frame index`, Number.isInteger(v) && v >= 0, String(v)); } for (const terr of rules.terrain) { check(`terrain "${terr.id}" has a frame in terrainFrames`, tf.some(([name]) => name === terr.id) || Number.isInteger(terr.frame)); } const maxTerrainFrame = Math.max(...tf.map(([, v]) => v)) + 1; for (const [name, sheet] of Object.entries(sheets)) { if (sheet.kind !== 'terrain') continue; const cols = sheet.cols ?? 8; check(`${name} layout is fully numeric`, [sheet.frameWidth, sheet.frameHeight, cols].every((v) => Number.isFinite(v) && v > 0)); check(`${name} holds every terrain frame`, maxTerrainFrame <= cols * Math.ceil(maxTerrainFrame / cols)); } // Frame indices must exist within each sheet's declared capacity. for (const d of [...rules.units, ...rules.buildings]) { for (const a of rules.armies) { const sheet = sheets[d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet]; const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8); check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap); if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap); } } } // --------------------------------------------------------------------------- section('2b. Procedural art actually paints'); // --------------------------------------------------------------------------- { // TAArt imports no Phaser, so the painters can be run right here against a stub canvas. // Checking the artwork JSON alone is not enough: the terrain sheet's frame count is DERIVED // from that JSON, and a bad derivation produced a zero-height texture that only failed in // the browser, as a WebGL error pointing nowhere near the cause. Running the painters also // catches NaN geometry, which paints nothing at all and is otherwise invisible until a // player sees a blank tank. const bad = []; const num = (where, ...vals) => { for (const v of vals) if (typeof v === 'number' && !Number.isFinite(v)) bad.push(where); }; const mkCtx = () => new Proxy({}, { get: (_t, prop) => { if (prop === 'save' || prop === 'restore' || prop === 'beginPath' || prop === 'closePath' || prop === 'fill' || prop === 'stroke') return () => {}; if (typeof prop === 'string') return (...args) => num(prop, ...args); return () => {}; }, set: (_t, prop, value) => { num(String(prop), value); return true; }, }); const made = new Map(); const scene = { textures: { exists: (k) => made.has(k), remove: (k) => made.delete(k), createCanvas(key, w, h) { if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { bad.push(`createCanvas(${key}, ${w}, ${h})`); } const frames = []; const tex = { width: w, height: h, frames, getContext: () => mkCtx(), refresh() {}, add(f, _s, fx, fy, fw, fh) { num(`add(${key})`, fx, fy, fw, fh); if (fx + fw > w || fy + fh > h) bad.push(`frame ${f} outside ${key}`); frames.push(f); }, }; made.set(key, tex); return tex; }, }, }; const { keys, procedural } = ensureSheets(scene, rules, artJson); for (const name of Object.keys(artJson.sheets ?? {})) { check(`sheet ${name} resolves to a texture key`, !!keys[name]); const tex = made.get(keys[name]); check(`sheet ${name} painted a non-empty canvas`, !!tex && tex.width > 0 && tex.height > 0, tex ? `${tex.width}x${tex.height}` : 'no texture'); check(`sheet ${name} registered frames`, !!tex && tex.frames.length > 0); } check('every sheet fell back to a painted stand-in', procedural.length === Object.keys(artJson.sheets ?? {}).length, `${procedural.length} procedural`); check('no painter emitted a non-finite value', bad.length === 0, [...new Set(bad)].slice(0, 5).join(', ')); // Every frame a def references must have been registered by the painter that owns it. for (const d of [...rules.units, ...rules.buildings]) { for (const a of rules.armies) { const sheetName = d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet; const tex = made.get(keys[sheetName]); check(`${d.id} frame ${d.frame} was painted on ${sheetName}`, tex?.frames.includes(d.frame)); if (d.turretFrame != null) { check(`${d.id} turret frame was painted`, tex?.frames.includes(d.turretFrame)); } if (d.buildFrame != null) { check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame)); } } } for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) { if (sheet.kind !== 'terrain') continue; const tex = made.get(keys[name]); for (const terr of rules.terrain) { check(`terrain ${terr.id} frame ${terr.frame} painted on ${name}`, tex?.frames.includes(terr.frame)); } } } // --------------------------------------------------------------------------- section('2c. View layering and first frame'); // --------------------------------------------------------------------------- { // TAWorldView and TAFx import no Phaser either, so the real render path runs here against // a stub scene. This section exists because two bugs got past every data-level check and // straight into the browser: the fog sheet drew UNDER the terrain (a Phaser Container // renders in insertion order and never sorts by depth on its own), and the camera walked // off the map before the first frame. Both are invisible to any test that only inspects // simulation state. const map = generateMap(rules, { seed: 123456, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 }); const st = L.createMatch(rules, { seed: 123456, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] }); const scene = makeStubScene(); let view = null; try { view = new TAWorldView(scene, rules, artJson, st, 0); const fx = new TAFx(scene, view.worldRoot, DEPTHS); view.setFogEnabled(true); const start = st.starts.find((x) => x.army === 0); view.centerOn(start.x * st.tileSize, start.y * st.tileSize); const out = view.render(0); check('render() returns fx payloads', !!out && Array.isArray(out.projectiles) && Array.isArray(out.nanoLinks)); const order = view.worldRoot.list; const lastChunk = Math.max(-1, ...order.map((o, i) => (o.type === 'renderTexture' ? i : -1))); check('terrain chunks were painted', lastChunk >= 0); check('fog draws ON TOP of terrain', order.indexOf(view.fogImg) > lastChunk, `fog at ${order.indexOf(view.fogImg)}, last chunk at ${lastChunk}`); check('fx layers are in the world container', order.includes(fx.gUnder) && order.includes(fx.gOver)); const own = st.entities.find((e) => e.army === 0); const foe = st.entities.find((e) => e.army === 1); const ownSprite = view.sprites.get(own.id); check('the player\'s starting unit has a sprite', !!ownSprite); check('own unit draws on top of terrain', order.indexOf(ownSprite.img) > lastChunk); check('own unit is visible through fog', ownSprite.img.visible); check('the enemy is hidden at match start', !view.visibleToPlayer(foe)); // The Commander must be ON SCREEN when the match opens — an off-screen start reads to a // player as "I have no units" and there is nothing they can do about it. const cam = scene.cameras.main.worldView; const onScreen = own.x >= cam.x && own.x <= cam.x + cam.width && own.y >= cam.y && own.y <= cam.y + cam.height; check('the opening camera frames the player\'s Commander', onScreen, `unit ${own.x.toFixed(0)},${own.y.toFixed(0)} vs view ${cam.x.toFixed(0)},${cam.y.toFixed(0)} ${cam.width}x${cam.height}`); // The queue overlay is the only feedback a player gets that a CTRL-queued order // registered at all, so assert it actually strokes something for a queued unit and // nothing at all when the selection is empty. const cmdr = st.entities.find((e) => e.army === 0); const tsz = st.tileSize; L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y } }); L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y + 3 * tsz }, queue: true }); L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'attackMove', x: cmdr.x, y: cmdr.y + 3 * tsz }, queue: true }); view.selection = new Set([cmdr.id]); view.render(0); check('the order-queue overlay draws for a queued unit', view.gOrders.ops > 0, `${view.gOrders.ops} ops`); view.selection = new Set(); view.render(0); check('the order-queue overlay draws nothing with no selection', view.gOrders.ops === 0); L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'stop' } }); // Wheel zoom must keep the world point under the cursor fixed, at any cursor position — // including well away from the screen centre, which is where a centre-anchored zoom (or a // correction computed from a stale camera matrix) visibly drifts. for (const [fx2, fy2] of [[960, 540], [320, 220], [1700, 900], [40, 1040]]) { for (const dir of [1, 1, -1, -1, -1, 1]) { const beforePt = view.worldPoint(fx2, fy2); const beforeZoom = view.zoom; view.zoomBy(dir, fx2, fy2); if (view.zoom === beforeZoom) continue; // already at the end of the ladder const afterPt = view.worldPoint(fx2, fy2); const drift = Math.hypot(afterPt.x - beforePt.x, afterPt.y - beforePt.y); check(`zoom at (${fx2},${fy2}) keeps the cursor's world point fixed`, drift < 0.5, `drifted ${drift.toFixed(1)}px`); } } // Depth bands must stay in the intended order. check('bars draw above actors', DEPTHS.bars > DEPTHS.actor); check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor)); check('selection draws below actors', DEPTHS.selection < DEPTHS.actor); } catch (e) { check('the view layer builds and renders a frame', false, e.message); } } // --------------------------------------------------------------------------- section('2d. Container hitbox lint'); // --------------------------------------------------------------------------- { // TAHud/TAScreens import Phaser directly, so they cannot be constructed here. This is a // source lint instead, guarding one specific mistake that is invisible until a human tries // to click something: a Phaser Container's origin is hard-coded to 0.5, so setInteractive() // centres the hit area on the container's position. A child drawn from a top-left origin // then sits half a button down-right of the region that actually receives the click. const files = ['src/games/totalannihilation/TAHud.js', 'src/games/totalannihilation/TAScreens.js']; for (const rel of files) { const src = readFileSync(join(ROOT, rel), 'utf8'); const lines = src.split('\n'); let offenders = 0; lines.forEach((line, i) => { if (!/add\.container\(/.test(line)) return; // Look at the block this container is built in. const block = lines.slice(i, i + 26).join('\n'); if (!/setInteractive\(/.test(block)) return; if (/setOrigin\(\s*0\s*,\s*0\s*\)/.test(block)) offenders++; }); check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`, offenders === 0, `${offenders} block(s)`); } } // --------------------------------------------------------------------------- section('3. Economy fixtures'); // --------------------------------------------------------------------------- { const map = generateMap(rules, { seed: 7, size: 'small', symmetry: 'mirror-x' }); const st = L.createMatch(rules, { seed: 7, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const a = st.armies[0]; a.mass = 0; a.energy = 0; for (let i = 0; i < HZ; i++) L.tick(st, rules); check('idle army banks its commander income', a.energy > 0 && a.mass > 0); // Building an Energy Generator takes exactly buildTime seconds at nominal build power, // given enough stored resources that nothing stalls. const st2 = L.createMatch(rules, { seed: 8, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const b = st2.armies[0]; b.mass = 99999; b.energy = 99999; b.massCap = 99999; b.energyCap = 99999; const cmd = st2.entities.find((e) => e.army === 0); const gen = rules.buildingById.energygen; let tx = worldToTileX(st2.nav, cmd.x) + 2, ty = worldToTileY(st2.nav, cmd.y); while (!L.canPlaceAt(st2, rules, tx, ty, gen) && tx < st2.w - 4) tx++; const r = L.issueOrder(st2, rules, { army: 0, unitIds: [cmd.id], order: { type: 'build', defId: 'energygen', tx, ty } }); check('build order accepted', r.ok, r.error); let ticks = 0; while (ticks < 200 * HZ) { b.mass = 99999; b.energy = 99999; // hold the economy open so only build power matters L.tick(st2, rules); ticks++; const site = st2.entities.find((e) => e.defId === 'energygen'); if (site && !site.site) break; } const secs = ticks / HZ; const expected = gen.buildTime * (rules.constants.buildPowerNominal / rules.unitById.commander.buildPower); check('unstalled build takes buildTime seconds', Math.abs(secs - expected) <= expected * 0.35 + 2, `${secs.toFixed(1)}s vs ${expected}s`); // Stall factor: halve the available mass flow and construction must slow, not stop. const st3 = L.createMatch(rules, { seed: 9, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const c = st3.armies[0]; c.mass = 0; c.energy = 0; L.tick(st3, rules); check('stall factors stay in [0,1]', c.stallM >= 0 && c.stallM <= 1 && c.stallE >= 0 && c.stallE <= 1); check('buildEff is the binding factor', Math.abs(c.buildEff - Math.min(c.stallE, c.stallM)) < 1e-9); // Storage is capped and the overflow is discarded rather than silently banked. const st4 = L.createMatch(rules, { seed: 10, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const d = st4.armies[0]; d.energy = d.energyCap; d.mass = d.massCap; for (let i = 0; i < HZ * 5; i++) L.tick(st4, rules); check('stored energy never exceeds cap', d.energy <= d.energyCap + 1e-6); check('stored mass never exceeds cap', d.mass <= d.massCap + 1e-6); // A Mass Generator on a metal patch must out-yield one on plain ground. const mgen = rules.buildingById.massgen; check('mass generator has a terrain multiplier', !!mgen.terrainMultiplier); const multTerrain = rules.terrain.find((t) => t[mgen.terrainMultiplier] > 1); check('some terrain carries that multiplier', !!multTerrain); } // --------------------------------------------------------------------------- section('4. Pathfinding'); // --------------------------------------------------------------------------- { const ts = rules.constants.tileSize; const mk = (rows) => { const w = rows[0].length, h = rows.length; const terrain = new Uint8Array(w * h); const wall = rules.terrainByCh['^'].index, open = rules.terrainByCh['.'].index; for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open; return createNav(rules, { w, h, terrain }); }; const nav = mk([ '..........', '..######..', '..#....#..', '..#....#..', '..######..', '..........', ]); const mc = 'tread'; check('tread is a declared movement class', mc in rules.moveClasses); const at = (nav2, x, y) => y * nav2.w + x; const p = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 9, 5)); check('A* finds a route around an obstacle', !!p && p.length > 0); const inside = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 4, 3)); check('sealed region is unreachable', !inside); // Clearance: a wide unit must refuse a one-tile gap but accept a wide corridor. const narrow = mk([ '####.####', '####.####', '####.####', ]); const wide = mk([ '##.....##', '##.....##', '##.....##', ]); const big = clearanceFor(rules.sizeClasses.large.radius, ts); check('large clearance requirement is > 1 tile', big > 1); check('large unit refuses a 1-wide corridor', !findPath(narrow, mc, big, 4, at(narrow, 4, 2))); check('large unit accepts a wide corridor', !!findPath(wide, mc, big, at(wide, 3, 0), at(wide, 5, 2))); // String-pulling must never shortcut through a blocked tile. const blocked = mk(['...', '.#.', '...']); check('segmentClear rejects a line through a wall', !segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 2.5 * ts)); check('segmentClear accepts a clear line', segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts)); } // --------------------------------------------------------------------------- section('4b. Order queueing (CTRL)'); // --------------------------------------------------------------------------- { // Holding CTRL passes `queue: true` to issueOrder. The engine contract that has to hold: // a queued order APPENDS and leaves the current one running, an unqueued one REPLACES the // lot, and the unit then works through them in order. const map = generateMap(rules, { seed: 61, size: 'small', symmetry: 'mirror-x' }); const st = L.createMatch(rules, { seed: 61, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const cmd = st.entities.find((e) => e.army === 0); const ts = st.tileSize; const pt = (dx, dy) => ({ x: cmd.x + dx * ts, y: cmd.y + dy * ts }); const a = pt(3, 0), b = pt(3, 3), c = pt(0, 3); L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } }); check('first order replaces an empty queue', cmd.orders.length === 1); L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true }); L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true }); check('queued orders append', cmd.orders.length === 3, `${cmd.orders.length}`); check('the queue keeps its issue order', Math.abs(cmd.orders[0].x - a.x) < 1 && Math.abs(cmd.orders[2].x - c.x) < 1); // An unqueued order wipes the queue — the standard "plain click cancels everything" rule. L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } }); check('an unqueued order clears the queue', cmd.orders.length === 1, `${cmd.orders.length}`); // Run a three-leg queue and confirm it is actually consumed in sequence. L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true }); L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true }); const seen = [cmd.orders.length]; for (let i = 0; i < 240 * HZ && cmd.orders.length; i++) { L.tick(st, rules); if (cmd.orders.length !== seen[seen.length - 1]) seen.push(cmd.orders.length); } check('a queued route is consumed one leg at a time', seen.join(',') === '3,2,1,0', seen.join(',')); check('the unit ends up at the final waypoint', Math.hypot(cmd.x - c.x, cmd.y - c.y) < ts * 2, `${Math.hypot(cmd.x - c.x, cmd.y - c.y).toFixed(0)}px away`); // Queued BUILD orders: each places its site immediately (so the player sees the ghosts) // while the builder works through them one at a time. const st2 = L.createMatch(rules, { seed: 62, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const b2 = st2.armies[0]; b2.mass = 99999; b2.energy = 99999; b2.massCap = 99999; b2.energyCap = 99999; const builder = st2.entities.find((e) => e.army === 0); const gen = rules.buildingById.energygen; // Candidate spots must clear each other by the building's own footprint, or siting the // first one blocks the second and the "queue" under test never forms. const spots = []; const step = gen.footprint.w + 1; for (let d = 2; d < 30 && spots.length < 3; d += step) { const tx = worldToTileX(st2.nav, builder.x) + d; const ty = worldToTileY(st2.nav, builder.y); if (L.canPlaceAt(st2, rules, tx, ty, gen).ok) spots.push({ tx, ty }); } check('found room for three queued generators', spots.length === 3, `${spots.length}`); let queuedOk = 0; spots.forEach((sp, i) => { const r = L.issueOrder(st2, rules, { army: 0, unitIds: [builder.id], order: { type: 'build', defId: 'energygen', tx: sp.tx, ty: sp.ty }, queue: i > 0, }); if (r.ok) queuedOk++; }); check('three build orders queue onto one builder', queuedOk === 3, `${queuedOk}`); check('every queued building is sited straight away', st2.entities.filter((e) => e.defId === 'energygen').length === 3); check('the builder holds all three in its queue', builder.orders.length === 3, `${builder.orders.length}`); for (let i = 0; i < 400 * HZ && builder.orders.length; i++) { b2.mass = 99999; b2.energy = 99999; L.tick(st2, rules); } const finished = st2.entities.filter((e) => e.defId === 'energygen' && !e.site && !e.dead).length; check('a queued build list completes', finished === 3, `${finished}/3 built`); } // --------------------------------------------------------------------------- section('5. Movement, separation and size classes'); // --------------------------------------------------------------------------- { const map = generateMap(rules, { seed: 21, size: 'small', symmetry: 'mirror-x' }); const st = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); // Find a genuinely open tile with open neighbours — the middle of a generated map is as // likely to be a lake as a field, and units shoved out of water prove nothing. const openTile = (() => { for (let y = 3; y < map.h - 3; y++) { for (let x = 3; x < map.w - 3; x++) { let ok = true; for (let dy = -2; dy <= 2 && ok; dy++) { for (let dx = -2; dx <= 2 && ok; dx++) { if (!tileOk(st.nav, 'foot', 1, x + dx, y + dy)) ok = false; } } if (ok) return { x, y }; } } return { x: (map.w / 2) | 0, y: (map.h / 2) | 0 }; })(); const cx = openTile.x * st.tileSize + st.tileSize / 2; const cy = openTile.y * st.tileSize + st.tileSize / 2; check('found open ground for the packing fixture', tileOk(st.nav, 'foot', 1, openTile.x, openTile.y)); // The size-class requirement, asserted: three smalls share one tile, two mediums cannot. const packed = (defId, n) => { const s2 = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const made = []; for (let i = 0; i < n; i++) { const u = L.spawnUnit(s2, rules, 0, defId, cx + (i - n / 2) * 6, cy + (i % 2) * 6); if (u) made.push(u); } for (let i = 0; i < 60; i++) L.tick(s2, rules); const ts = s2.tileSize; return made.filter((u) => Math.abs(u.x - cx) <= ts / 2 && Math.abs(u.y - cy) <= ts / 2).length; }; // The size-class contract is a statement about collision radii, so assert it there — and // then confirm the emergent behaviour matches, which is the part a player actually sees. const ts0 = rules.constants.tileSize; const small = rules.sizeClasses.small, medium = rules.sizeClasses.medium, large = rules.sizeClasses.large; check('small units are under half a tile wide', small.radius * 2 <= ts0 / 2, `${small.radius * 2}px`); check('medium units are about one tile wide', medium.radius * 2 > ts0 / 2 && medium.radius * 2 <= ts0, `${medium.radius * 2}px`); check('large units span more than one tile', large.radius * 2 > ts0, `${large.radius * 2}px`); check('large units claim a multi-tile footprint', (large.footprint ?? 1) > 1); check('3 small units settle inside one tile', packed('infantry', 3) >= 3, `${packed('infantry', 3)}`); check('3 medium units cannot settle inside one tile', packed('tank', 3) < 3, `${packed('tank', 3)}`); // Settled units must not overlap. for (let i = 0; i < 12; i++) L.spawnUnit(st, rules, 0, 'tank', cx + (i % 4) * 30, cy + Math.floor(i / 4) * 30); for (let i = 0; i < 120; i++) L.tick(st, rules); const mine = st.entities.filter((e) => !e.dead && e.army === 0 && e.defId === 'tank'); let worst = 0; for (let i = 0; i < mine.length; i++) { for (let j = i + 1; j < mine.length; j++) { const overlap = (mine[i].radius + mine[j].radius) - Math.hypot(mine[i].x - mine[j].x, mine[i].y - mine[j].y); worst = Math.max(worst, overlap); } } check('settled units barely overlap', worst < 12, `worst overlap ${worst.toFixed(1)}px`); check('all positions finite', st.entities.every((e) => Number.isFinite(e.x) && Number.isFinite(e.y))); } // --------------------------------------------------------------------------- section('6. Combat'); // --------------------------------------------------------------------------- { const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders const combatRules = compileRules(raw); const map = generateMap(combatRules, { seed: 31, size: 'small', symmetry: 'mirror-x' }); const cx = (map.w / 2) * combatRules.constants.tileSize, cy = (map.h / 2) * combatRules.constants.tileSize; const duel = (aId, na, bId, nb, sec = 150) => { const st = L.createMatch(combatRules, { seed: 31, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); for (const e of st.entities) e.dead = true; st.entities = []; const line = (army, id, n, dx) => { for (let i = 0; i < n; i++) { L.spawnUnit(st, combatRules, army, id, cx + dx - Math.sign(dx) * Math.floor(i / 10) * 40, cy - 180 + (i % 10) * 40); } }; line(0, aId, na, -240); line(1, bId, nb, 240); st.over = null; for (const a of st.armies) a.alive = true; const ids = (army) => st.entities.filter((e) => e.army === army && !e.dead).map((e) => e.id); L.issueOrder(st, combatRules, { army: 0, unitIds: ids(0), order: { type: 'attackMove', x: cx + 240, y: cy } }); L.issueOrder(st, combatRules, { army: 1, unitIds: ids(1), order: { type: 'attackMove', x: cx - 240, y: cy } }); let t = 0; for (; t < sec * HZ; t++) { L.tick(st, combatRules); const a0 = st.entities.some((e) => !e.dead && e.army === 0); const a1 = st.entities.some((e) => !e.dead && e.army === 1); if (!a0 || !a1) break; } const left = (a) => st.entities.filter((e) => !e.dead && e.army === a).length; const hp = (a) => st.entities.filter((e) => !e.dead && e.army === a).reduce((s, e) => s + e.hp, 0); return { a: left(0), b: left(1), hpA: hp(0), hpB: hp(1), secs: t / HZ }; }; const cost = (id) => combatRules.unitById[id].cost.mass + combatRules.unitById[id].cost.energy / 4; const equal = (a, b, n = 8) => { const nb = Math.max(1, Math.round(n * cost(a) / cost(b))); const r = duel(a, n, b, nb); // Score on surviving fraction of the force each side paid for, so a slow grind that the // tanks are clearly winning counts as a win even if the clock runs out first. r.fracA = r.a / n; r.fracB = r.b / nb; return r; }; // Ballistic shells travel 35px per 20Hz tick, so a point-sampled hit test tunnels straight // through its target. Tanks doing real damage is the observable proof the sweep works. const tvi = equal('tank', 'infantry'); check('tanks beat equal-cost infantry', tvi.fracA > tvi.fracB, `${tvi.a} tanks (${(tvi.fracA * 100) | 0}%) v ${tvi.b} infantry (${(tvi.fracB * 100) | 0}%)`); const rvs = equal('rockettank', 'sniper'); check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB, `${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`); // Rockets out-range and out-damage armour; that is what makes the roster a triangle // rather than a ladder where one unit is simply the answer. const rvt = equal('rockettank', 'tank'); check('rocket tanks beat equal-cost tanks', rvt.fracA > rvt.fracB, `${rvt.a} (${(rvt.fracA * 100) | 0}%) v ${rvt.b} (${(rvt.fracB * 100) | 0}%)`); // Engagements must take long enough for reinforcement and composition to matter. When // fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip. check('an even engagement is not instant', tvi.secs > 5, `${tvi.secs.toFixed(1)}s`); // Damage fixtures. const st = L.createMatch(combatRules, { seed: 32, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const victim = L.spawnUnit(st, combatRules, 1, 'tank', cx, cy); const before = victim.hp; L.applyDamage(st, combatRules, victim, 100, { army: 0, id: 0 }); check('applyDamage subtracts exactly', Math.abs((before - victim.hp) - 100) < 1e-9); const rifle = combatRules.weaponById.rifle; check('rifle is strong vs infantry, weak vs medium', armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3); const rocket = combatRules.weaponById.rocketpod; check('rockets favour armour over infantry', armorMul(rocket, 'heavy') > armorMul(rocket, 'infantry') * 3); // Commander death explosion. const dth = combatRules.unitById.commander.deathExplosion; check('commander has a death explosion', dth && dth.radius > 200 && dth.damage > 500); } // --------------------------------------------------------------------------- section('6b. Commander self-repair'); // --------------------------------------------------------------------------- { const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); raw.constants.eliminateWhenUnrecoverable = false; // a lone Commander must not end the match const hr = compileRules(raw); const map = generateMap(hr, { seed: 71, size: 'small', symmetry: 'mirror-x' }); const cdef = hr.unitById.commander; check('the Commander declares self-repair', !!cdef.selfHeal); check('only the Commander regenerates by default', hr.units.filter((u) => u.selfHeal).length === 1); const fresh = () => { const st = L.createMatch(hr, { seed: 71, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); st.over = null; for (const a of st.armies) a.alive = true; return st; }; const run = (st, secs) => { for (let i = 0; i < secs * HZ; i++) L.tick(st, hr); }; // Rate: about a third of max HP per minute. const st = fresh(); const cmd = st.entities.find((e) => e.army === 0); cmd.hp = cmd.maxHp * 0.2; const startHp = cmd.hp; run(st, 60); const gained = (cmd.hp - startHp) / cmd.maxHp; check('regenerates ~33% of max HP per minute', Math.abs(gained - 0.33) < 0.02, `${(gained * 100).toFixed(1)}% in 60s`); // Damage pauses regeneration for the full 30 seconds, then it resumes. const st2 = fresh(); const c2 = st2.entities.find((e) => e.army === 0); c2.hp = c2.maxHp * 0.5; L.applyDamage(st2, hr, c2, 100, { army: 1, id: 0 }); const afterHit = c2.hp; run(st2, 25); check('no regeneration within 30s of taking damage', c2.hp === afterHit, `healed ${(c2.hp - afterHit).toFixed(1)} in 25s`); run(st2, 10); // now 35s since the hit check('regeneration resumes after the pause', c2.hp > afterHit, `healed ${(c2.hp - afterHit).toFixed(1)} by 35s`); // Every fresh hit restarts the clock, so sustained fire suppresses it entirely. const st3 = fresh(); const c3 = st3.entities.find((e) => e.army === 0); c3.hp = c3.maxHp * 0.5; let expected = c3.hp; for (let s2 = 0; s2 < 60; s2++) { L.applyDamage(st3, hr, c3, 10, { army: 1, id: 0 }); expected -= 10; run(st3, 1); } check('being hit every second suppresses regeneration entirely', Math.abs(c3.hp - expected) < 1e-6, `${c3.hp.toFixed(1)} vs ${expected.toFixed(1)}`); // Never overheals, and a unit with no selfHeal never recovers at all. const st4 = fresh(); const c4 = st4.entities.find((e) => e.army === 0); c4.hp = c4.maxHp - 5; run(st4, 120); check('regeneration stops at full health', c4.hp === c4.maxHp, `${c4.hp}/${c4.maxHp}`); const st5 = fresh(); const tank = L.spawnUnit(st5, hr, 0, 'tank', c4.x + 200, c4.y); tank.hp = tank.maxHp * 0.5; const tankHp = tank.hp; run(st5, 60); check('units without selfHeal do not regenerate', tank.hp === tankHp, `${tank.hp} vs ${tankHp}`); // The pause must survive a save/load, or reloading mid-fight grants a free heal. const st6 = fresh(); const c6 = st6.entities.find((e) => e.army === 0); c6.hp = c6.maxHp * 0.5; L.applyDamage(st6, hr, c6, 50, { army: 1, id: 0 }); const back = L.deserialize(hr, L.serialize(st6)); const c6b = back.entities.find((e) => e.army === 0 && e.defId === 'commander'); check('the damage clock round-trips through a save', c6b.lastDamagedTick === c6.lastDamagedTick, `${c6b.lastDamagedTick} vs ${c6.lastDamagedTick}`); const hpAfterLoad = c6b.hp; for (let i = 0; i < 25 * HZ; i++) L.tick(back, hr); check('a reload does not hand back a free heal', c6b.hp === hpAfterLoad); } // --------------------------------------------------------------------------- section('6c. Repair'); // --------------------------------------------------------------------------- { const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); raw.constants.eliminateWhenUnrecoverable = false; // Self-repair would confound the cost measurements below, so it is off for this fixture. for (const u of raw.units) delete u.selfHeal; const rr = compileRules(raw); const map = generateMap(rr, { seed: 81, size: 'small', symmetry: 'mirror-x' }); const setup = (defId, hpFrac) => { const st = L.createMatch(rr, { seed: 81, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); st.over = null; for (const a of st.armies) a.alive = true; const builder = st.entities.find((e) => e.army === 0); const patient = L.spawnUnit(st, rr, 0, defId, builder.x + 80, builder.y); patient.hp = patient.maxHp * hpFrac; const army = st.armies[0]; army.mass = 99999; army.energy = 99999; army.massCap = 99999; army.energyCap = 99999; // Silence the builder's own income, so a drop in stored resources IS the repair bill and // nothing else. Measuring the net change instead would net off the Commander's output. builder.produceE = 0; builder.produceM = 0; return { st, builder, patient, army }; }; // A damaged friendly is a legal repair target; a healthy one is not. { const { st, builder, patient } = setup('tank', 0.5); const ok = L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } }); check('repair order accepted on a damaged friendly', ok.ok, ok.error); patient.hp = patient.maxHp; const full = L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } }); check('repair is refused at full health', !full.ok, full.error); const foe = st.entities.find((e) => e.army === 1); const enemy = L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: foe.id } }); check('repair is refused on an enemy', !enemy.ok, enemy.error); } // Cost and duration are both proportional to the damage healed. for (const [defId, frac] of [['tank', 0.5], ['tank', 0.25], ['rockettank', 0.5]]) { const { st, builder, patient, army } = setup(defId, frac); const def = rr.unitById[defId]; const missing = 1 - frac; const m0 = army.mass, e0 = army.energy; L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } }); let ticks = 0; const cap = 400 * HZ; while (ticks < cap && patient.hp < patient.maxHp) { L.tick(st, rr); ticks++; } check(`${defId} at ${frac * 100}% is repaired to full`, patient.hp >= patient.maxHp - 1e-6, `${patient.hp.toFixed(0)}/${patient.maxHp}`); const spentM = m0 - army.mass, spentE = e0 - army.energy; const wantM = def.cost.mass * missing, wantE = def.cost.energy * missing; check(`${defId} repair mass cost is ~${(missing * 100) | 0}% of build cost`, Math.abs(spentM - wantM) < wantM * 0.06 + 1, `${spentM.toFixed(0)} vs ${wantM.toFixed(0)}`); check(`${defId} repair energy cost is ~${(missing * 100) | 0}% of build cost`, Math.abs(spentE - wantE) < wantE * 0.06 + 1, `${spentE.toFixed(0)} vs ${wantE.toFixed(0)}`); // Time: same build power, so healing X% takes X% of the build time. const bp = rr.unitById.commander.buildPower / rr.constants.buildPowerNominal; const wantSecs = (def.buildTime * missing) / bp; const gotSecs = ticks / HZ; check(`${defId} repair takes ~${(missing * 100) | 0}% of build time`, Math.abs(gotSecs - wantSecs) < wantSecs * 0.25 + 1.5, `${gotSecs.toFixed(1)}s vs ${wantSecs.toFixed(1)}s`); } // Buildings repair too. { const { st, builder, army } = setup('tank', 0.99); const gen = rr.buildingById.energygen; let site = null; for (let d = 2; d < 20 && !site; d++) { const tx = worldToTileX(st.nav, builder.x) + d, ty = worldToTileY(st.nav, builder.y); if (L.canPlaceAt(st, rr, tx, ty, gen).ok) site = L.placeBuilding(st, rr, 0, 'energygen', tx, ty); } check('found room for a building to repair', !!site); site.site = false; site.progress = 1; site.hp = gen.hp * 0.4; const before = site.hp; L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: site.id } }); for (let i = 0; i < 200 * HZ && site.hp < site.maxHp; i++) { army.mass = 99999; army.energy = 99999; L.tick(st, rr); } check('a damaged building can be repaired', site.hp > before && site.hp >= site.maxHp - 1e-6, `${site.hp.toFixed(0)}/${site.maxHp}`); } // The order ends by itself once the patient is whole, and an unqueued order breaks it. { const { st, builder, patient } = setup('tank', 0.5); L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } }); for (let i = 0; i < 400 * HZ && builder.orders.length; i++) L.tick(st, rr); check('the repair order clears itself when done', builder.orders.length === 0); check('the builder releases its build target', builder.buildTargetId === 0); } { const { st, builder, patient } = setup('tank', 0.5); L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } }); for (let i = 0; i < 3 * HZ; i++) L.tick(st, rr); const mid = patient.hp; check('repair is under way', mid > patient.maxHp * 0.5); // A plain (unqueued) order must break the heal outright. L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'move', x: builder.x + 600, y: builder.y } }); check('a new unqueued order replaces the repair', builder.orders.length === 1 && builder.orders[0].type === 'move'); for (let i = 0; i < 5 * HZ; i++) L.tick(st, rr); check('the interrupted patient stops healing', Math.abs(patient.hp - mid) < 1e-6, `${patient.hp.toFixed(1)} vs ${mid.toFixed(1)}`); } // A QUEUED order must not break it — the repair stays at the head of the queue. { const { st, builder, patient } = setup('tank', 0.5); L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } }); for (let i = 0; i < 2 * HZ; i++) L.tick(st, rr); L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'move', x: builder.x + 600, y: builder.y }, queue: true }); const mid = patient.hp; for (let i = 0; i < 3 * HZ; i++) L.tick(st, rr); check('a queued order leaves the repair running', patient.hp > mid, `${patient.hp.toFixed(1)} vs ${mid.toFixed(1)}`); check('the queued order is still waiting behind it', builder.orders.length === 2 && builder.orders[0].type === 'repair'); } // A damaged factory being repaired must not have its production accelerated. { const { st, builder, army } = setup('tank', 0.99); const plant = L.placeBuilding(st, rr, 0, 'vehicleplant', worldToTileX(st.nav, builder.x) + 6, worldToTileY(st.nav, builder.y)); check('placed a factory for the mixing test', !!plant); if (plant) { plant.site = false; plant.progress = 1; plant.hp = rr.buildingById.vehicleplant.hp * 0.5; L.issueOrder(st, rr, { army: 0, order: { type: 'factoryEnqueue', factoryId: plant.id, defId: 'tank', count: 1 } }); L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: plant.id } }); let t = 0; for (; t < 30 * HZ; t++) { army.mass = 99999; army.energy = 99999; L.tick(st, rr); } const bp = rr.buildingById.vehicleplant.buildPower / rr.constants.buildPowerNominal; const expected = Math.min(1, (t / HZ) * bp / rr.unitById.tank.buildTime); check('repairing a factory does not speed up its production', Math.abs(plant.jobProgress - expected) < 0.08 || plant.jobProgress < expected + 0.08, `progress ${plant.jobProgress.toFixed(2)} vs expected ${expected.toFixed(2)}`); } } } // --------------------------------------------------------------------------- section('7. Fog of war'); // --------------------------------------------------------------------------- { const map = generateMap(rules, { seed: 41, size: 'small', symmetry: 'mirror-x' }); const st = L.createMatch(rules, { seed: 41, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const mine = st.entities.find((e) => e.army === 0); const theirs = st.entities.find((e) => e.army === 1); L.computeVision(st, rules); check('own units are always visible', L.isVisibleTo(st, 0, mine)); check('a distant enemy starts hidden', !L.isVisibleTo(st, 0, theirs)); // Explored is sticky; visible is not. const cell = st.tileSize * 2; const vx = Math.floor(mine.x / cell), vy = Math.floor(mine.y / cell); const idx = vy * st.visW + vx; check('own tile is explored', st.armies[0].explored[idx] === 1); L.spawnUnit(st, rules, 0, 'jeep', theirs.x, theirs.y); L.computeVision(st, rules); check('scouting reveals the enemy', L.isVisibleTo(st, 0, theirs)); // The AI must be fog-limited too — it may not target something it cannot see. const st2 = L.createMatch(rules, { seed: 42, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); for (let i = 0; i < HZ * 3; i++) { runAI(rules, st2, 0, { skill: 5 }); L.tick(st2, rules); } const mem = st2.aiMem?.[0]; const cheated = (mem?.knownEnemies ?? []).filter((k) => { const e = L.entityById(st2, k.id); return e && !L.isVisibleTo(st2, 0, e) && k.tick === st2.tick; }); check('AI never records an unseen enemy as seen-now', cheated.length === 0, `${cheated.length} leaks`); } // --------------------------------------------------------------------------- section('8. Map generation'); // --------------------------------------------------------------------------- { const seeds = QUICK ? 24 : 120; let symMismatch = 0, badStarts = 0, poorMetal = 0, unreachable = 0; const sizes = Object.keys(rules.skirmish.sizes); for (let i = 0; i < seeds; i++) { const size = sizes[i % sizes.length]; const sym = rules.skirmish.symmetries[i % rules.skirmish.symmetries.length]; const map = generateMap(rules, { seed: 5000 + i * 13, size, symmetry: sym, armies: 2 }); if (map.starts.length !== 2) { badStarts++; continue; } if (sym === 'mirror-x') { for (let y = 0; y < map.h; y++) { for (let x = 0; x < map.w; x++) { if (map.terrain[y * map.w + x] !== map.terrain[y * map.w + (map.w - 1 - x)]) { symMismatch++; y = map.h; break; } } } } // Every start needs metal within reach or its economy can never leave the ground. const mgen = rules.buildingById.massgen; for (const s of map.starts) { let spots = 0; const R = rules.skirmish.gen.metalSpotRadiusTiles; for (let y = Math.max(0, s.y - R); y < Math.min(map.h, s.y + R); y++) { for (let x = Math.max(0, s.x - R); x < Math.min(map.w, s.x + R); x++) { if (rules.terrain[map.terrain[y * map.w + x]][mgen.terrainMultiplier] > 1) spots++; } } if (spots < 4) poorMetal++; } // And the two starts must be mutually reachable by a medium ground unit. const nav = createNav(rules, map); const clr = clearanceFor(rules.sizeClasses.medium.radius, rules.constants.tileSize); const path = findPath(nav, 'tread', clr, map.starts[0].y * map.w + map.starts[0].x, map.starts[1].y * map.w + map.starts[1].x); if (!path) unreachable++; } check('every generated map places both starts', badStarts === 0, `${badStarts} bad`); check('mirror-x maps are exactly symmetric', symMismatch === 0, `${symMismatch} mismatches`); check('every start has metal nearby', poorMetal === 0, `${poorMetal} starved starts`); check('starts are mutually reachable', unreachable === 0, `${unreachable} unreachable`); } // --------------------------------------------------------------------------- section('9. Campaign data'); // --------------------------------------------------------------------------- { const missions = campaign.missions ?? []; check('campaign has missions', missions.length > 0); const OBJECTIVES = new Set(['destroyAll', 'destroyCommander', 'survive', 'holdArea', 'reachArea', 'protect', 'buildCount']); missions.forEach((m, i) => { let map = null; try { map = decodeMap(rules, m.map); } catch (e) { /* reported below */ } check(`${m.id} terrain decodes`, !!map); if (!map) return; check(`${m.id} declares both starts`, (map.starts ?? []).length >= 2); check(`${m.id} theme exists`, !!artJson.themes?.[m.theme], m.theme); check(`${m.id} player army exists`, !!rules.armyById[m.playerArmy]); check(`${m.id} player commander exists`, !!rules.commanderById[m.playerCommander]); check(`${m.id} objective is a known type`, OBJECTIVES.has(m.objective?.type), m.objective?.type); for (const e of m.enemies ?? []) { check(`${m.id} enemy commander exists`, !!rules.commanderById[e.commander], e.commander); const sk = e.aiProfile?.skill; check(`${m.id} enemy skill is 1-5`, sk >= 1 && sk <= 5, String(sk)); } for (const b of map.buildings ?? []) { check(`${m.id} prebuilt ${b.type} exists`, !!rules.buildingById[b.type], b.type); check(`${m.id} prebuilt ${b.type} is on the map`, b.tx >= 0 && b.ty >= 0 && b.tx < map.w && b.ty < map.h); } for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) { check(`${m.id} speaker ${line.speaker} exists`, (opponents.opponents ?? []).some((o) => o.id === line.speaker), line.speaker); } // Missions must get harder, or the unlock order is meaningless. if (i > 0) { const prev = missions[i - 1].enemies?.[0]?.aiProfile?.skill ?? 0; check(`${m.id} is no easier than the one before`, (m.enemies?.[0]?.aiProfile?.skill ?? 0) >= prev); } }); } // --------------------------------------------------------------------------- section('10. Serialization and determinism'); // --------------------------------------------------------------------------- { const map = generateMap(rules, { seed: 51, size: 'small', symmetry: 'mirror-x' }); const st = L.createMatch(rules, { seed: 51, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); for (let i = 0; i < HZ * 20; i++) { runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); L.tick(st, rules); } const blob = L.serialize(st); const back = L.deserialize(rules, blob); check('serialize round-trips to the same hash', L.hashState(back) === L.hashState(st)); check('serialized save is a sane size', blob.length < 400000, `${blob.length} bytes`); const run = () => { const m2 = generateMap(rules, { seed: 52, size: 'small', symmetry: 'mirror-x' }); const s2 = L.createMatch(rules, { seed: 52, map: m2, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); for (let i = 0; i < HZ * 30; i++) { runAI(rules, s2, 0, { skill: 4 }); runAI(rules, s2, 1, { skill: 2 }); L.tick(s2, rules); } return L.hashState(s2); }; check('same seed replays identically', run() === run()); // The step() harness must produce the same result as driving tick() directly. const m3 = generateMap(rules, { seed: 53, size: 'small', symmetry: 'mirror-x' }); const byTick = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); for (let i = 0; i < 100; i++) L.tick(byTick, rules); const byStep = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); for (let i = 0; i < 100; i++) L.step(byStep, rules, rules.stepMs, 4); check('step() and tick() agree', L.hashState(byStep) === L.hashState(byTick)); } // --------------------------------------------------------------------------- section('11. Skirmish soak'); // --------------------------------------------------------------------------- { const games = QUICK ? 8 : 24; let decided = 0, negative = 0, nonFinite = 0, overCap = 0, peakUnits = 0; let totalTickMs = 0, ticks = 0, worstTick = 0; for (let g = 0; g < games; g++) { const map = generateMap(rules, { seed: 6000 + g * 17, size: g % 2 ? 'medium' : 'small', symmetry: 'mirror-x' }); const st = L.createMatch(rules, { seed: 6000 + g, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const cap = rules.constants.unitCapPerArmy; while (!st.over && st.tick < 1200 * HZ) { runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); const t0 = process.hrtime.bigint(); L.tick(st, rules); const ms = Number(process.hrtime.bigint() - t0) / 1e6; totalTickMs += ms; ticks++; worstTick = Math.max(worstTick, ms); for (const a of st.armies) if (a.mass < -1e-6 || a.energy < -1e-6) negative++; if (st.tick % 200 === 0) { for (const e of st.entities) if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) nonFinite++; for (let i = 0; i < st.armies.length; i++) { const n = st.entities.filter((e) => !e.dead && e.army === i && !e.isBuilding).length; peakUnits = Math.max(peakUnits, n); if (n > cap) overCap++; } } } if (st.over && st.over.winner >= 0) decided++; } const avg = totalTickMs / Math.max(1, ticks); console.log(` ${decided}/${games} decided · peak ${peakUnits} units/army · tick avg ${avg.toFixed(2)}ms worst ${worstTick.toFixed(1)}ms`); check('most games reach a decision', decided >= Math.ceil(games * 0.85), `${decided}/${games}`); check('resources never go negative', negative === 0, `${negative}`); check('positions stay finite', nonFinite === 0, `${nonFinite}`); check('unit cap is respected', overCap === 0, `${overCap} breaches`); // Average is the real budget signal at 20Hz (50ms per tick). The worst case is allowed // more headroom because one slow tick — a burst of path requests, or a GC landing on it — // costs a single dropped frame, not a stall; it is printed above either way. check('average tick is well inside the 20Hz budget', avg < 8, `avg ${avg.toFixed(2)}ms`); check('no tick blows the frame budget outright', worstTick < 120, `worst ${worstTick.toFixed(1)}ms`); } // --------------------------------------------------------------------------- section('12. AI skill ladder'); // --------------------------------------------------------------------------- { const pairs = QUICK ? 6 : 16; // Each seed is played BOTH ways round so that side advantage — turn order, spawn corner, // commander profile — cancels exactly instead of being assumed away. Measured over 32 // mirrored pairs the ladder runs 5v1 ≈ 84%, 3v1 ≈ 81%, 5v3 ≈ 59%; the bars below sit far // enough below those to be stable at this sample size. const ladder = (sa, sb) => { let win = 0, dec = 0; for (let i = 0; i < pairs; i++) { for (const flip of [false, true]) { const map = generateMap(rules, { seed: 7000 + i * 7, size: 'small', symmetry: 'mirror-x' }); const st = L.createMatch(rules, { seed: 7000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); const p0 = flip ? sb : sa, p1 = flip ? sa : sb; while (!st.over && st.tick < 1200 * HZ) { runAI(rules, st, 0, { skill: p0 }); runAI(rules, st, 1, { skill: p1 }); L.tick(st, rules); } if (st.over && st.over.winner >= 0) { dec++; if (st.over.winner === (flip ? 1 : 0)) win++; } } } return dec ? win / dec : 0; }; const hi = ladder(5, 1); console.log(` skill 5 vs skill 1: ${(hi * 100).toFixed(1)}%`); check('skill 5 beats skill 1 decisively', hi >= 0.72, `${(hi * 100).toFixed(1)}%`); if (!QUICK) { const mid = ladder(4, 2); console.log(` skill 4 vs skill 2: ${(mid * 100).toFixed(1)}%`); check('skill 4 beats skill 2', mid >= 0.52, `${(mid * 100).toFixed(1)}%`); } } // --------------------------------------------------------------------------- section('13. Campaign winnability'); // --------------------------------------------------------------------------- { // A skill-5 bot plays the PLAYER's side through the same issueOrder API a human uses. This // is the entire reason the order API is shared, and it is the only way to know a mission is // actually beatable rather than merely plausible. const runs = QUICK ? 2 : 4; for (const m of campaign.missions ?? []) { let wins = 0, played = 0; for (let r = 0; r < runs; r++) { const map = decodeMap(rules, m.map); const st = L.createMatch(rules, { seed: (m.seed ?? 1) + r * 101, map, armies: [ { armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true }, ...(m.enemies ?? []).map((e) => ({ armyId: e.army, commanderId: e.commander, aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null, })), ], }); (m.startResources ?? []).forEach((res, i) => { if (!st.armies[i]) return; st.armies[i].mass = res.mass ?? st.armies[i].mass; st.armies[i].energy = res.energy ?? st.armies[i].energy; }); while (!st.over && st.tick < 1500 * HZ) { runAI(rules, st, 0, { skill: 5 }); for (let i = 1; i < st.armies.length; i++) { const e = m.enemies?.[i - 1]; runAI(rules, st, i, { skill: e?.aiProfile?.skill ?? 3, ...(e?.aiProfile ?? {}) }); } L.tick(st, rules); } played++; if (st.over && st.over.winner === 0) wins++; } const rate = played ? wins / played : 0; console.log(` ${m.id} ${m.name.padEnd(16)} bot wins ${wins}/${played}`); check(`${m.id} is winnable by a skill-5 bot`, rate >= 0.5, `${wins}/${played}`); } } // --------------------------------------------------------------------------- console.log(`\n${'─'.repeat(60)}`); if (failures.length) { console.log(`FAILED — ${pass} checks passed, ${failures.length} failed:`); for (const f of failures) console.log(` ✗ ${f}`); process.exit(1); } console.log(`OK — all ${pass} checks passed${QUICK ? ' (quick)' : ''}.`);