857 lines
43 KiB
JavaScript
857 lines
43 KiB
JavaScript
#!/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}`);
|
|
|
|
// 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('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('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)' : ''}.`);
|