2592 lines
131 KiB
JavaScript
2592 lines
131 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];
|
|
// `rows` is declared per sheet and measured off the real PNG, so this is the actual
|
|
// number of frames the texture holds. Defaulting it would let a def point at a frame the
|
|
// image simply does not contain — which Phaser renders as the whole spritesheet.
|
|
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`);
|
|
}
|
|
}
|
|
|
|
// The Commander-death cascade blows units up one at a time while the simulation sits
|
|
// frozen on state.over, so the view has to be able to stop drawing something that is still
|
|
// very much alive in state. Everything drawn for that entity has to go, not just its hull.
|
|
{
|
|
const victim = st.entities.find((e) => !e.isBuilding && !e.site);
|
|
view.selection.add(victim.id);
|
|
view.render(0);
|
|
check('a live entity draws before it is vaporised', view.sprites.has(victim.id));
|
|
view.vaporised.add(victim.id);
|
|
view.render(0);
|
|
check('a vaporised entity stops being drawn', !view.sprites.has(victim.id));
|
|
view.vaporised.clear();
|
|
view.selection.clear();
|
|
}
|
|
|
|
// 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('2e. Altitude rendering');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// Height is SIMULATION state (`e.liftFrac`) because it decides whether a unit may fire. This
|
|
// section only checks that the renderer turns that number into the right pixels — the flight
|
|
// behaviour itself is §6g, where it can be tested headlessly like everything else.
|
|
check('the Hover Constructor hovers without being an air unit',
|
|
!!rules.unitById.hoverconstructor.flight && rules.unitById.hoverconstructor.isAir === false,
|
|
'the visual and the simulation domain must stay separate');
|
|
for (const u of rules.units) {
|
|
if (!u.isAir) continue;
|
|
check(`${u.id} declares a flight block`, !!u.flight);
|
|
}
|
|
|
|
const map = generateMap(rules, { seed: 4242, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
|
|
const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
|
|
const scene = makeStubScene();
|
|
try {
|
|
const view = new TAWorldView(scene, rules, artJson, st, 0);
|
|
view.setFogEnabled(false);
|
|
const def = rules.unitById.fighter;
|
|
const start = st.starts.find((x) => x.army === 0);
|
|
const e = L.spawnUnit(st, rules, 0, 'fighter', start.x * st.tileSize, start.y * st.tileSize);
|
|
const run = (sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, rules); };
|
|
|
|
run(0.2);
|
|
view.render(0);
|
|
let s = view.sprites.get(e.id);
|
|
check('a grounded aircraft renders on the ground', Math.abs(s.img.y - e.y) < 0.01,
|
|
`body ${(s.img.y - e.y).toFixed(2)}px off the ground`);
|
|
check('a grounded aircraft casts no visible shadow', s.shadow.alpha < 0.001,
|
|
`alpha ${s.shadow.alpha.toFixed(3)}`);
|
|
|
|
L.issueOrder(st, rules, { army: 0, unitIds: [e.id], order: { type: 'move', x: e.x + 2000, y: e.y } });
|
|
run(2.0);
|
|
// Rendered at alpha 1 — an airborne unit is in motion now, so anything comparing sprite
|
|
// positions against the entity's CURRENT x/y has to sample the end of the tick, not a
|
|
// point interpolated back toward where it was.
|
|
view.render(1);
|
|
s = view.sprites.get(e.id);
|
|
const lift = e.y - s.img.y;
|
|
check('a flying aircraft is drawn at its full hover height',
|
|
Math.abs(lift - def.flight.height) < 0.5, `lifted ${lift.toFixed(1)} of ${def.flight.height}px`);
|
|
check('the shadow stays pinned to the true ground position',
|
|
Math.abs(s.shadow.x - e.x) < 0.01 && Math.abs(s.shadow.y - e.y) < 0.01);
|
|
check('the shadow separates from the body by the hover height',
|
|
Math.abs((s.shadow.y - s.img.y) - def.flight.height) < 0.5);
|
|
check('the airborne shadow is 50% transparent', Math.abs(s.shadow.alpha - 0.5) < 0.001,
|
|
`alpha ${s.shadow.alpha.toFixed(3)}`);
|
|
check('the shadow tightens as the unit climbs', s.shadow.scaleX < s.img.scaleX);
|
|
// Lifting the sprite must not re-sort it against its neighbours, or an aircraft would pop
|
|
// in front of things as it took off.
|
|
check('depth is taken from the ground position, not the lifted sprite',
|
|
Math.abs(s.img.depth - (DEPTHS.air + (e.y / st.worldH) * 10 + 0.05)) < 1e-6);
|
|
|
|
// Altitude is interpolated between ticks exactly like position, so a climb is smooth at
|
|
// any framerate rather than stepping 20 times a second. Position is frozen here so the
|
|
// only thing differing between the two renders is the altitude.
|
|
e.px = e.x; e.py = e.y;
|
|
e.pliftFrac = 0; e.liftFrac = 1;
|
|
view.render(0);
|
|
const low = e.y - view.sprites.get(e.id).img.y;
|
|
view.render(1);
|
|
const high = e.y - view.sprites.get(e.id).img.y;
|
|
check('altitude interpolates between sim ticks', low < high - 1,
|
|
`alpha 0 gave ${low.toFixed(1)}px, alpha 1 gave ${high.toFixed(1)}px`);
|
|
view.destroy();
|
|
} catch (err) {
|
|
check('the altitude renderer runs', false, err.message);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
section('2f. Factory production dials');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// Two pies on a working factory: top-LEFT is the whole queue, top-RIGHT is the unit on the
|
|
// bench. Read back off the stub's recorded slice geometry, because "a graphics call
|
|
// happened" proves nothing — the arc has to match the progress it claims to show.
|
|
const map = generateMap(rules, { seed: 5150, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
|
|
const st = L.createMatch(rules, { seed: 5150, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
|
|
const scene = makeStubScene();
|
|
const TAU = Math.PI * 2;
|
|
|
|
try {
|
|
const view = new TAWorldView(scene, rules, artJson, st, 0);
|
|
view.setFogEnabled(false);
|
|
const plant = rules.buildingById.vehicleplant;
|
|
|
|
/** Drop a finished factory for `army` somewhere legal near its start. */
|
|
const factory = (army) => {
|
|
const s = st.starts.find((x) => x.army === army) ?? st.starts[0];
|
|
for (let r = 2; r < 16; r++) {
|
|
for (let a = 0; a < 24; a++) {
|
|
const ang = (a / 24) * TAU;
|
|
const tx = Math.round(s.x + Math.cos(ang) * r), ty = Math.round(s.y + Math.sin(ang) * r);
|
|
if (!L.canPlaceAt(st, rules, tx, ty, plant).ok) continue;
|
|
const b = L.placeBuilding(st, rules, army, 'vehicleplant', tx, ty);
|
|
b.site = false; b.progress = 1; b.hp = plant.hp;
|
|
return b;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const f = factory(0);
|
|
check('the dial fixture placed a factory', !!f);
|
|
|
|
const slices = () => view.gPies.slices;
|
|
|
|
view.render(0, 16.7);
|
|
check('an idle factory draws no dials', slices().length === 0, `${slices().length} slice(s)`);
|
|
|
|
// Queue 4 tanks (80s of work). A batch always starts before any work is done on it, so
|
|
// the dials are sampled in that order here too.
|
|
L.issueOrder(st, rules, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 4 } });
|
|
view.render(0, 16.7);
|
|
check('a fresh queue starts both dials empty', slices().length === 0, `${slices().length} slice(s)`);
|
|
|
|
f.jobProgress = 0.5;
|
|
view.render(0, 16.7);
|
|
let sl = slices();
|
|
check('a working factory draws two dials', sl.length === 2, `${sl.length} slice(s)`);
|
|
|
|
if (sl.length === 2) {
|
|
const left = sl.find((p) => p.x < f.x), right = sl.find((p) => p.x > f.x);
|
|
check('the dials sit on the top-left and top-right corners', !!left && !!right);
|
|
// Both must land inside the footprint, or they'd float over neighbouring buildings.
|
|
const inside = [left, right].every((p) => p
|
|
&& Math.abs(p.x - f.x) < plant.halfW && Math.abs(p.y - f.y) < plant.halfH
|
|
&& p.y < f.y);
|
|
check('both dials sit inside the top half of the footprint', inside);
|
|
|
|
const sweep = (p) => (p.endAngle - p.startAngle) / TAU;
|
|
check('the right dial tracks the unit on the bench',
|
|
Math.abs(sweep(right) - 0.5) < 0.01, `${(sweep(right) * 100).toFixed(0)}% vs 50%`);
|
|
// 4 tanks queued, half of the first one done => 0.5/4 of the batch.
|
|
check('the left dial tracks the whole queue',
|
|
Math.abs(sweep(left) - 0.125) < 0.01, `${(sweep(left) * 100).toFixed(1)}% vs 12.5%`);
|
|
}
|
|
|
|
// The important one. The queue array SHRINKS as units pop out of it, so a dial computed
|
|
// from "done / still queued" would snap back to zero on every completion. After one tank
|
|
// of four finishes, the batch dial must read a quarter, not nothing.
|
|
f.queue = [{ defId: 'tank', count: 3 }];
|
|
f.jobProgress = 0;
|
|
view.render(0, 16.7);
|
|
sl = slices();
|
|
const left2 = sl.find((p) => p.x < f.x);
|
|
check('the queue dial does not reset when a unit completes',
|
|
!!left2 && Math.abs((left2.endAngle - left2.startAngle) / TAU - 0.25) < 0.01,
|
|
left2 ? `${(((left2.endAngle - left2.startAngle) / TAU) * 100).toFixed(1)}% vs 25%` : 'no wedge drawn');
|
|
|
|
// Queueing 4 more mid-run must ENLARGE the batch without discarding the tank already
|
|
// built: 20s done against a 160s total is 12.5%, not 0% and not still 25%.
|
|
L.issueOrder(st, rules, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 4 } });
|
|
view.render(0, 16.7);
|
|
const left3 = slices().find((p) => p.x < f.x);
|
|
const frac3 = left3 ? (left3.endAngle - left3.startAngle) / TAU : 0;
|
|
check('adding to the queue keeps the work already done', Math.abs(frac3 - 0.125) < 0.01,
|
|
`${(frac3 * 100).toFixed(1)}% vs 12.5%`);
|
|
|
|
// Emptying the queue forgets the batch, so the next run starts from zero.
|
|
f.queue = [];
|
|
view.render(0, 16.7);
|
|
check('an emptied queue clears the dials', slices().length === 0);
|
|
f.queue = [{ defId: 'tank', count: 2 }];
|
|
view.render(0, 16.7);
|
|
check('a fresh batch restarts the queue dial from empty', slices().length === 0,
|
|
'the previous batch should be forgotten');
|
|
f.jobProgress = 0.5;
|
|
view.render(0, 16.7);
|
|
const left4 = slices().find((p) => p.x < f.x);
|
|
check('the fresh batch then measures against its own total',
|
|
Math.abs(((left4?.endAngle ?? 0) - (left4?.startAngle ?? 0)) / TAU - 0.25) < 0.01,
|
|
left4 ? `${((((left4.endAngle - left4.startAngle) / TAU)) * 100).toFixed(1)}% vs 25%` : 'no wedge');
|
|
|
|
// Enemy production is not the player's business — no other part of the HUD leaks it.
|
|
const foe = factory(1);
|
|
if (foe) {
|
|
L.issueOrder(st, rules, { army: 1, order: { type: 'factoryEnqueue', factoryId: foe.id, defId: 'tank', count: 4 } });
|
|
foe.jobProgress = 0.5;
|
|
f.queue = [];
|
|
view.render(0, 16.7);
|
|
check('enemy factories show no dials', slices().length === 0, `${slices().length} slice(s)`);
|
|
}
|
|
view.destroy();
|
|
} catch (err) {
|
|
check('the production dials render', false, err.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)`);
|
|
}
|
|
|
|
const hudSrc = readFileSync(join(ROOT, 'src/games/totalannihilation/TAHud.js'), 'utf8');
|
|
const gameSrc = readFileSync(join(ROOT, 'src/games/totalannihilation/TotalAnnihilationGame.js'), 'utf8');
|
|
|
|
// The build menu is two rows deep and no more — a third would run off the bottom of the bar.
|
|
// Adding a build option to a unit is otherwise a pure JSON edit, so nothing else would catch
|
|
// an overflow: giving the Commander an Airfield once pushed it to 7 options and off the end
|
|
// of the single row the grid had back then.
|
|
const cols = Number(/const GRID_COLS = (\d+)/.exec(hudSrc)?.[1]);
|
|
const gridRows = Number(/const GRID_ROWS = (\d+)/.exec(hudSrc)?.[1]);
|
|
check('the HUD declares a build-grid size', cols > 0 && gridRows > 0);
|
|
for (const d of [...rules.units, ...rules.buildings]) {
|
|
const n = (d.builds ?? []).length;
|
|
if (!n) continue;
|
|
check(`${d.id}'s build options fit the grid`, n <= cols * gridRows,
|
|
`${n} options vs ${cols}x${gridRows}`);
|
|
}
|
|
|
|
// ---- command bar ----
|
|
// The order buttons are the only way most of these verbs are discoverable now that the hint
|
|
// string is gone, so a button naming an order the engine does not implement — or a grid that
|
|
// silently drops its last row off the panel — is a dead end the player cannot route around.
|
|
const cmdBlock = /const COMMANDS = \[([\s\S]*?)\n\];/.exec(hudSrc)?.[1] ?? '';
|
|
const cmdIds = [...cmdBlock.matchAll(/\{ id: '(\w+)'/g)].map((m) => m[1]);
|
|
const cmdCols = Number(/const CMD_COLS = (\d+)/.exec(hudSrc)?.[1]);
|
|
check('the HUD declares command buttons', cmdIds.length > 0);
|
|
check('the command grid fits the bar', cmdIds.length <= cmdCols * 2,
|
|
`${cmdIds.length} buttons in ${cmdCols}x2`);
|
|
// Every id must be something issueOrder actually accepts, or the button does nothing.
|
|
const engineOrders = new Set([
|
|
...(/const ORDER_TYPES = new Set\(\[([\s\S]*?)\]\)/.exec(
|
|
readFileSync(join(ROOT, 'src/games/totalannihilation/TALogic.js'), 'utf8'))?.[1] ?? '')
|
|
.split(',').map((t) => t.trim().replace(/'/g, '')).filter(Boolean),
|
|
'factoryEnqueue', 'factoryCancel', 'setRally',
|
|
]);
|
|
for (const id of cmdIds) {
|
|
check(`command "${id}" is an order the engine implements`, engineOrders.has(id));
|
|
}
|
|
for (const id of ['move', 'attack', 'attackMove', 'patrol', 'guard', 'stop', 'hold']) {
|
|
check(`the bar offers ${id}`, cmdIds.includes(id));
|
|
}
|
|
for (const id of ['repair', 'assist', 'setRally']) {
|
|
check(`the bar offers ${id} for builders and factories`, cmdIds.includes(id));
|
|
}
|
|
// Reclaim has no order, no wreck entity and no payout — it must not appear as a button until
|
|
// it does, or it is a control that silently fails.
|
|
check('no button promises an unimplemented verb',
|
|
!cmdIds.some((id) => !engineOrders.has(id)), cmdIds.filter((id) => !engineOrders.has(id)).join(','));
|
|
// A prompt per targeted command, so an armed button always says what it wants.
|
|
const prompts = /const COMMAND_PROMPTS = \{([\s\S]*?)\n\};/.exec(gameSrc)?.[1] ?? '';
|
|
for (const id of cmdIds) {
|
|
// Instant commands (Stop, Hold) take effect on the button press and never wait for a click,
|
|
// so they have nothing to prompt for.
|
|
if (new RegExp(`\\{ id: '${id}'[^}]*instant: true`).test(cmdBlock)) continue;
|
|
check(`command "${id}" has a prompt`, new RegExp(`\\b${id}:`).test(prompts));
|
|
}
|
|
// The old hint string is gone; nothing should still be trying to set it.
|
|
check('the hint line is fully removed', !/this\.hint\b/.test(hudSrc));
|
|
check('every command icon has a frame',
|
|
cmdIds.every((id) => id === 'setRally' ? rules.commandIcons.rally != null : rules.commandIcons[id] != null),
|
|
Object.keys(rules.commandIcons).join(','));
|
|
|
|
// ---- Commander death sequence ----
|
|
// A Phaser scene cannot be built here, so this is a source lint over the four things that
|
|
// would each break the cue silently rather than loudly.
|
|
// Anchored on the method definition, not the call site — `\n _name(` only matches a class
|
|
// member at this indent, where `_name(` alone finds `this._startDeathSequence(...)` first
|
|
// and lints the wrong function body entirely.
|
|
const seq = /\n {2}_startDeathSequence\([\s\S]*?\n {2}\}/.exec(gameSrc)?.[0] ?? '';
|
|
check('the Commander death sequence exists', seq.length > 0);
|
|
// Timed off the sample rather than a magic number, so re-cutting the audio retimes the cue.
|
|
check('the cascade is timed to the nuclear sample', /_sfxDurationMs\('sfx-ta-nuclear'/.test(seq),
|
|
'a hardcoded duration would drift from the audio');
|
|
// Only what the player can see is blown up — an off-screen cascade is wasted work and
|
|
// reveals an army's layout through fog.
|
|
check('the cascade only consumes visible entities', /visibleToPlayer/.test(seq));
|
|
// The result screen has to wait, which is the whole point of the request.
|
|
check('the result screen waits for the sequence',
|
|
/if \(st\.over && !this\._deathSeq\) this\._finish/.test(gameSrc),
|
|
'the victory window would cover its own explosion');
|
|
// It must fire on elimination, not merely on a Commander dying — under the annihilation rule
|
|
// an army fights on without one, and detonating its whole force would be a lie.
|
|
check('the cascade fires on elimination, not on the death alone',
|
|
/reason === 'commanderLost'|lost && this\._commanderDeath/.test(gameSrc));
|
|
// The sample it times against has to actually be loaded for this game.
|
|
const manifest = readFileSync(join(ROOT, 'src/data/assetManifest.js'), 'utf8');
|
|
const taBlock = /totalannihilation: \[([\s\S]*?)\n \],/.exec(manifest)?.[1] ?? '';
|
|
check('the nuclear sample is in the Total Annihilation manifest', /'nuclear'/.test(taBlock),
|
|
'the cue would silently fall back to its default length');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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('4c. Patrol routes');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// A patrol carries a `route`: a flat circuit whose first point is where the unit stood when
|
|
// the order was given. Ctrl-clicking more points EXTENDS that circuit rather than queueing a
|
|
// second patrol behind the first — which would never run, since a patrol never completes.
|
|
const map = generateMap(rules, { seed: 909, size: 'small', symmetry: 'mirror-x' });
|
|
const fresh = () => {
|
|
const st = L.createMatch(rules, { seed: 909, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
|
const s = st.starts.find((x) => x.army === 0);
|
|
return { st, e: L.spawnUnit(st, rules, 0, 'tank', s.x * st.tileSize, s.y * st.tileSize) };
|
|
};
|
|
const patrol = (st, e, x, y, queue) => L.issueOrder(st, rules, {
|
|
army: 0, unitIds: [e.id], order: { type: 'patrol', x, y }, queue,
|
|
});
|
|
|
|
{
|
|
// One click is still the classic there-and-back: a two-point circuit, home included.
|
|
const { st, e } = fresh();
|
|
const x0 = e.x, y0 = e.y;
|
|
patrol(st, e, x0 + 400, y0, false);
|
|
const o = e.orders[0];
|
|
check('a single patrol click builds a two-point circuit',
|
|
o?.type === 'patrol' && o.route?.length === 4, `route ${JSON.stringify(o?.route)}`);
|
|
check('the circuit starts where the unit stood',
|
|
Math.abs(o.route[0] - x0) < 1e-6 && Math.abs(o.route[1] - y0) < 1e-6);
|
|
check('and heads for the clicked point first', o.leg === 1);
|
|
}
|
|
|
|
{
|
|
// Ctrl-clicking three more points extends the one order to a five-point circuit.
|
|
const { st, e } = fresh();
|
|
const x0 = e.x, y0 = e.y;
|
|
patrol(st, e, x0 + 300, y0, false);
|
|
patrol(st, e, x0 + 300, y0 + 300, true);
|
|
patrol(st, e, x0, y0 + 300, true);
|
|
patrol(st, e, x0 - 300, y0, true);
|
|
check('ctrl-clicking extends one patrol order', e.orders.length === 1,
|
|
`${e.orders.length} orders`);
|
|
check('every clicked point joins the route', e.orders[0].route.length === 10,
|
|
`${e.orders[0].route.length / 2} points`);
|
|
}
|
|
|
|
{
|
|
// A non-queued patrol replaces the route rather than growing it forever.
|
|
const { st, e } = fresh();
|
|
patrol(st, e, e.x + 300, e.y, false);
|
|
patrol(st, e, e.x + 300, e.y + 300, true);
|
|
patrol(st, e, e.x - 200, e.y, false);
|
|
check('an unqueued patrol starts a fresh route',
|
|
e.orders.length === 1 && e.orders[0].route.length === 4,
|
|
`${e.orders.length} orders, ${e.orders[0].route.length / 2} points`);
|
|
}
|
|
|
|
{
|
|
// The circuit is actually flown: over a long run the unit must reach every corner, and the
|
|
// order must never complete.
|
|
const { st, e } = fresh();
|
|
const x0 = e.x, y0 = e.y, R = 260;
|
|
patrol(st, e, x0 + R, y0, false);
|
|
patrol(st, e, x0 + R, y0 + R, true);
|
|
patrol(st, e, x0, y0 + R, true);
|
|
const route = e.orders[0].route.slice();
|
|
const n = route.length >> 1;
|
|
const visited = new Array(n).fill(false);
|
|
const legsSeen = new Set();
|
|
for (let i = 0; i < 240 * HZ; i++) {
|
|
L.tick(st, rules);
|
|
if (e.dead || !e.orders.length) break;
|
|
legsSeen.add(e.orders[0].leg);
|
|
for (let k = 0; k < n; k++) {
|
|
if (Math.hypot(e.x - route[k * 2], e.y - route[k * 2 + 1]) < st.tileSize) visited[k] = true;
|
|
}
|
|
}
|
|
check('a patrol order never completes', e.orders.length === 1 && e.orders[0].type === 'patrol');
|
|
check('the unit visits every point on the route', visited.every(Boolean),
|
|
`reached ${visited.filter(Boolean).length} of ${n}`);
|
|
check('and cycles through every leg', legsSeen.size === n, `${legsSeen.size} of ${n} legs`);
|
|
}
|
|
|
|
{
|
|
// Orders written by a save from before routes existed keep working untouched.
|
|
const { st, e } = fresh();
|
|
const legacy = { type: 'patrol', sx: e.x + 200, sy: e.y, fromX: e.x, fromY: e.y, leg: 0 };
|
|
e.orders.push(legacy);
|
|
const a = L.patrolWaypoint(legacy);
|
|
legacy.leg = 1;
|
|
const b = L.patrolWaypoint(legacy);
|
|
check('a legacy two-point patrol still resolves both ends',
|
|
Math.abs(a.x - (e.x + 200)) < 1e-6 && Math.abs(b.x - e.x) < 1e-6,
|
|
`${JSON.stringify(a)} / ${JSON.stringify(b)}`);
|
|
}
|
|
|
|
{
|
|
// Aircraft run the same routes; they just never stop at the corners.
|
|
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
|
raw.constants.eliminateWhenUnrecoverable = false;
|
|
const ar = compileRules(raw);
|
|
const W = 64, H = 64, TS = ar.constants.tileSize;
|
|
const px = (t) => t * TS + TS / 2;
|
|
const st = L.createMatch(ar, {
|
|
seed: 5, victory: 'annihilation',
|
|
map: { w: W, h: H, terrain: new Uint8Array(W * H).fill(ar.terrainById.ground.index), starts: [], theme: 'grasslands' },
|
|
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
|
});
|
|
L.spawnUnit(st, ar, 0, 'infantry', px(1), px(1));
|
|
L.spawnUnit(st, ar, 1, 'infantry', px(W - 2), px(H - 2));
|
|
st.over = null;
|
|
for (const a of st.armies) a.alive = true;
|
|
const f = L.spawnUnit(st, ar, 0, 'fighter', px(30), px(30));
|
|
const pts = [[px(40), px(30)], [px(40), px(40)], [px(30), px(40)]];
|
|
pts.forEach(([x, y], i) => L.issueOrder(st, ar, {
|
|
army: 0, unitIds: [f.id], order: { type: 'patrol', x, y }, queue: i > 0,
|
|
}));
|
|
const route = f.orders[0].route.slice();
|
|
const n = route.length >> 1;
|
|
const visited = new Array(n).fill(false);
|
|
for (let i = 0; i < 180 * HZ; i++) {
|
|
L.tick(st, ar);
|
|
if (f.dead || !f.orders.length) break;
|
|
for (let k = 0; k < n; k++) {
|
|
if (Math.hypot(f.x - route[k * 2], f.y - route[k * 2 + 1]) < TS * 1.5) visited[k] = true;
|
|
}
|
|
}
|
|
check('an aircraft flies a multi-point patrol route', visited.every(Boolean),
|
|
`reached ${visited.filter(Boolean).length} of ${n}`);
|
|
check('and stays airborne doing it', f.liftFrac === 1);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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) => {
|
|
// Annihilation rules: these fixtures throw the Commanders away, and the default rule would
|
|
// call the match over before the first shot.
|
|
const st = L.createMatch(combatRules, {
|
|
seed: 31, map, victory: 'annihilation', 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 are the answer to buildings and air, not to armour — a straight fight against a
|
|
// Tank is one the Rocket Tank should lose, which is what makes the roster a triangle
|
|
// (Tank counters Rocket Tank, Rocket Tank counters Sniper/structures) rather than a ladder
|
|
// where one unit is simply the answer.
|
|
const rvt = equal('rockettank', 'tank');
|
|
check('tanks beat equal-cost rocket tanks', rvt.fracB > rvt.fracA,
|
|
`${rvt.b} tanks (${(rvt.fracB * 100) | 0}%) v ${rvt.a} rocket tanks (${(rvt.fracA * 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 fortifications over vehicle armour',
|
|
armorMul(rocket, 'fortification') > armorMul(rocket, 'heavy') * 3);
|
|
check('rockets are weak against vehicle armour',
|
|
armorMul(rocket, 'light') < 1 && armorMul(rocket, 'medium') < 1 && armorMul(rocket, 'heavy') < 1,
|
|
`light ${armorMul(rocket, 'light')}, medium ${armorMul(rocket, 'medium')}, heavy ${armorMul(rocket, 'heavy')}`);
|
|
|
|
// 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}`);
|
|
|
|
// Well outside any builder's repair range, so this isolates passive selfHeal regen from the
|
|
// auto-heal nanolathe mechanic (an idle builder now auto-repairs damaged allies in range —
|
|
// a real, separate source of healing, not a regression of this one).
|
|
const st5 = fresh();
|
|
const tank = L.spawnUnit(st5, hr, 0, 'tank', c4.x + 2000, 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('6c2. Guarding a structure');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// A builder told to guard a structure works on it: damage first, then whatever it is
|
|
// producing. Both halves are measured against a control that has no guard, because "the
|
|
// factory eventually finished" proves nothing on its own.
|
|
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
|
raw.constants.eliminateWhenUnrecoverable = false;
|
|
const gr = compileRules(raw);
|
|
const map = generateMap(gr, { seed: 606, size: 'small', symmetry: 'mirror-x' });
|
|
const plant = gr.buildingById.vehicleplant;
|
|
|
|
/** A finished Vehicle Plant with the Commander parked next to it (or not). */
|
|
const rig = (withGuard, damage = 0) => {
|
|
const st = L.createMatch(gr, { seed: 606, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
|
const cmd = st.entities.find((e) => e.defId === 'commander' && e.army === 0);
|
|
let f = null;
|
|
for (let r = 3; r < 16 && !f; r++) {
|
|
for (let a = 0; a < 24 && !f; a++) {
|
|
const ang = (a / 24) * Math.PI * 2;
|
|
const tx = Math.round(cmd.x / st.tileSize + Math.cos(ang) * r);
|
|
const ty = Math.round(cmd.y / st.tileSize + Math.sin(ang) * r);
|
|
if (L.canPlaceAt(st, gr, tx, ty, plant).ok) {
|
|
f = L.placeBuilding(st, gr, 0, 'vehicleplant', tx, ty);
|
|
f.site = false; f.progress = 1; f.hp = plant.hp;
|
|
}
|
|
}
|
|
}
|
|
if (f && damage) f.hp = Math.max(1, plant.hp - damage);
|
|
if (f && withGuard) {
|
|
L.issueOrder(st, gr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: f.id } });
|
|
}
|
|
return { st, cmd, f };
|
|
};
|
|
const run = (st, sec, onTick) => {
|
|
for (let i = 0; i < sec * HZ; i++) {
|
|
st.armies[0].mass = 9999; st.armies[0].energy = 9999;
|
|
L.tick(st, gr);
|
|
if (onTick && onTick() === false) return i / HZ;
|
|
}
|
|
return sec;
|
|
};
|
|
|
|
{
|
|
// Repair. The guard has to close on the plant and mend it without any further order.
|
|
const { st, cmd, f } = rig(true, 3000);
|
|
check('the guard rig placed a plant', !!f);
|
|
const before = f.hp;
|
|
run(st, 60, () => f.hp < f.maxHp);
|
|
check('a guarding builder repairs the structure', f.hp > before + 500,
|
|
`${before.toFixed(0)} -> ${f.hp.toFixed(0)} of ${f.maxHp}`);
|
|
check('and holds a nanolathe link while doing it',
|
|
cmd.buildTargetId === f.id || f.hp >= f.maxHp);
|
|
}
|
|
|
|
{
|
|
// Production assist, measured as time-to-first-tank against an unguarded control.
|
|
const timeToTank = (withGuard) => {
|
|
const { st, f } = rig(withGuard);
|
|
L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } });
|
|
let made = 0;
|
|
const t = run(st, 120, () => {
|
|
made = st.entities.filter((e) => !e.dead && e.defId === 'tank' && e.army === 0).length;
|
|
return made === 0;
|
|
});
|
|
return made ? t : Infinity;
|
|
};
|
|
const solo = timeToTank(false);
|
|
const helped = timeToTank(true);
|
|
check('an unguarded plant builds its tank', Number.isFinite(solo), `${solo}s`);
|
|
// Commander buildPower 100 on top of the plant's 100 should roughly halve it; the bar is
|
|
// set well short of that so a tuning change to either number doesn't make this brittle.
|
|
check('a guarding builder speeds the factory up', helped < solo * 0.8,
|
|
`${helped.toFixed(1)}s guarded vs ${solo.toFixed(1)}s alone`);
|
|
}
|
|
|
|
{
|
|
// Priority. While the plant is hurt the guard mends it INSTEAD of pushing the queue, so a
|
|
// damaged-and-busy factory heals before its output accelerates.
|
|
const { st, f } = rig(true, 3000);
|
|
L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } });
|
|
let sawRepairFirst = true;
|
|
for (let i = 0; i < 6 * HZ; i++) {
|
|
st.armies[0].mass = 9999; st.armies[0].energy = 9999;
|
|
L.tick(st, gr);
|
|
// Any tick where the plant is still damaged must show repair power, not extra build power.
|
|
if (f.hp < f.maxHp && f._power > (gr.buildingById.vehicleplant.buildPower ?? 0)) sawRepairFirst = false;
|
|
}
|
|
check('damage is mended before production is helped', sawRepairFirst,
|
|
'the guard pushed the queue while the factory was still hurt');
|
|
}
|
|
|
|
{
|
|
// An intact, idle building needs nothing — the guard must not sit there billing the
|
|
// economy for work that does not exist.
|
|
const { st, cmd, f } = rig(true);
|
|
run(st, 20);
|
|
check('guarding an idle intact building draws no build power',
|
|
cmd.buildTargetId === 0 && st.armies[0].mDrain < 1e-6,
|
|
`link ${cmd.buildTargetId}, drain ${st.armies[0].mDrain.toFixed(2)}`);
|
|
// ...and picks the work up on its own the moment there is some.
|
|
L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } });
|
|
run(st, 3);
|
|
check('and starts assisting as soon as the factory has a job', cmd.buildTargetId === f.id);
|
|
}
|
|
|
|
{
|
|
// Guarding a mobile unit is unchanged: escort only, no nanolathe.
|
|
const { st, cmd } = rig(false);
|
|
const tank = L.spawnUnit(st, gr, 0, 'tank', cmd.x + 200, cmd.y);
|
|
tank.hp = tank.maxHp * 0.5;
|
|
L.issueOrder(st, gr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: tank.id } });
|
|
run(st, 10);
|
|
check('guarding a mobile unit still just escorts it', cmd.buildTargetId === 0,
|
|
'buildings are the only guard target that implies work');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
section('6d. Victory conditions');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
check('the shipped default is Commander kill', rules.constants.victoryDefault === 'commander');
|
|
check('both victory modes are offered', !!rules.victoryModeById.commander && !!rules.victoryModeById.annihilation);
|
|
check('every victory mode has a label and a description',
|
|
rules.victoryModes.every((v) => v.label && v.desc));
|
|
check('exactly one unit is flagged as a Commander', rules.commanderUnits.length === 1,
|
|
rules.commanderUnits.map((u) => u.id).join(', '));
|
|
|
|
const map = generateMap(rules, { seed: 91, size: 'small', symmetry: 'mirror-x' });
|
|
const fresh = (victory) => L.createMatch(rules, {
|
|
seed: 91, map, victory, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }],
|
|
});
|
|
const commanderOf = (st, army) => st.entities.find((e) => !e.dead && e.army === army
|
|
&& rules.unitById[e.defId]?.isCommander);
|
|
// Give the army something that would keep it alive under the annihilation rule, so the two
|
|
// modes are told apart by the Commander alone and not by an empty base.
|
|
const propUp = (st, army) => {
|
|
const cmd = commanderOf(st, army);
|
|
const cx = worldToTileX(st.nav, cmd.x), cy = worldToTileY(st.nav, cmd.y);
|
|
// Started well clear of the Commander's death explosion — the fixture is about the rule,
|
|
// not about whether the blast happens to level the factory too.
|
|
for (let d = 6; d < 24; d++) {
|
|
for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1], [-1, 1], [1, -1]]) {
|
|
const tx = cx + dx * d, ty = cy + dy * d;
|
|
if (!L.canPlaceAt(st, rules, tx, ty, rules.buildingById.vehicleplant).ok) continue;
|
|
const b = L.placeBuilding(st, rules, army, 'vehicleplant', tx, ty);
|
|
b.site = false; b.progress = 1; b.hp = rules.buildingById.vehicleplant.hp;
|
|
return b;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
{
|
|
const st = fresh();
|
|
check('a new match defaults to the Commander rule', st.victory === 'commander');
|
|
check('both armies are marked as having fielded a Commander',
|
|
st.armies.every((a) => a.hadCommander));
|
|
const plant = propUp(st, 1);
|
|
check('the enemy has a factory it could rebuild from', !!plant);
|
|
const foe = commanderOf(st, 1);
|
|
L.applyDamage(st, rules, foe, foe.maxHp * 10, { army: 0, id: 0 });
|
|
L.tick(st, rules);
|
|
check('killing the enemy Commander ends the match', !!st.over);
|
|
check('the survivor wins', st.over?.winner === 0, `winner ${st.over?.winner}`);
|
|
check('the loss is reported as a lost Commander',
|
|
st.events.some((e) => e.t === 'armyEliminated' && e.army === 1 && e.reason === 'commanderLost'));
|
|
}
|
|
|
|
{
|
|
const st = fresh('annihilation');
|
|
const plant = propUp(st, 1);
|
|
const foe = commanderOf(st, 1);
|
|
L.applyDamage(st, rules, foe, foe.maxHp * 10, { army: 0, id: 0 });
|
|
for (let i = 0; i < 5 * HZ; i++) L.tick(st, rules);
|
|
check('annihilation keeps the match alive while a factory stands', !st.over && !!plant);
|
|
check('the bereaved army is still in it', st.armies[1].alive);
|
|
// ...and it still ends once that army can no longer produce anything. The leftover tank is
|
|
// what makes this an UNRECOVERABLE elimination rather than a plain wipe.
|
|
L.spawnUnit(st, rules, 1, 'tank', plant.x + 200, plant.y);
|
|
L.applyDamage(st, rules, plant, plant.maxHp * 10, { army: 0, id: 0 });
|
|
L.tick(st, rules);
|
|
check('annihilation ends when an army can no longer build', !!st.over);
|
|
check('that elimination reads as unrecoverable',
|
|
st.events.some((e) => e.t === 'armyEliminated' && e.army === 1 && e.reason === 'unrecoverable'));
|
|
}
|
|
|
|
// An army that never had a Commander (a garrison-only scenario) must not lose on tick one.
|
|
{
|
|
const st = L.createMatch(rules, {
|
|
seed: 91, map: { ...map, starts: [] }, armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
|
});
|
|
check('a Commander-less army is not eliminated by the Commander rule',
|
|
st.armies.every((a) => !a.hadCommander));
|
|
const before = st.over;
|
|
L.tick(st, rules);
|
|
check('an empty scenario still ends by wipe, not by Commander', !before && !!st.over
|
|
&& st.events.some((e) => e.t === 'armyEliminated' && e.reason === 'wiped'));
|
|
}
|
|
|
|
// The chosen rule has to survive a save, or a reload silently changes the match.
|
|
{
|
|
const st = fresh('annihilation');
|
|
for (let i = 0; i < 3 * HZ; i++) L.tick(st, rules);
|
|
const back = L.deserialize(rules, L.serialize(st));
|
|
check('victory mode round-trips through a save', back.victory === 'annihilation');
|
|
check('hadCommander round-trips through a save',
|
|
back.armies.every((a, i) => a.hadCommander === st.armies[i].hadCommander));
|
|
check('the save still hashes identically', L.hashState(back) === L.hashState(st));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
section('6e. Defensive structures');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
|
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures are a tower and a target
|
|
const dr = compileRules(raw);
|
|
const map = generateMap(dr, { seed: 101, size: 'small', symmetry: 'mirror-x' });
|
|
const tower = dr.buildingById.lasertower;
|
|
const launcher = dr.buildingById.missilelauncher;
|
|
|
|
check('the Commander can build the Laser Tower', dr.unitById.commander.builds.includes('lasertower'));
|
|
check('the Commander can build the Missile Launcher', dr.unitById.commander.builds.includes('missilelauncher'));
|
|
check('both defences are armed', tower.weaponDefs.length > 0 && launcher.weaponDefs.length > 0);
|
|
// Neither builds anything, so a base of nothing but towers is still an army that can never
|
|
// produce again — the elimination rule must not treat a turret as a factory.
|
|
check('defences are not factories', !(tower.builds ?? []).length && !(launcher.builds ?? []).length);
|
|
check('the Missile Launcher outranges every mobile unit',
|
|
dr.units.every((u) => u.maxRange < launcher.maxRange),
|
|
`${launcher.maxRange} vs best unit ${Math.max(...dr.units.map((u) => u.maxRange))}`);
|
|
check('the Missile Launcher has a close-in dead zone',
|
|
launcher.weaponDefs[0].minRange > 0 && launcher.weaponDefs[0].minRange < launcher.maxRange);
|
|
check('the Laser Tower is the cheaper of the two',
|
|
tower.cost.mass < launcher.cost.mass && tower.cost.energy < launcher.cost.energy);
|
|
|
|
// Place a defence for army 0 on open ground and put one enemy next to it.
|
|
const fixture = (defId, dx, dy) => {
|
|
const st = L.createMatch(dr, {
|
|
seed: 101, map, victory: 'annihilation', armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
|
});
|
|
const cmd = st.entities.find((e) => e.army === 0);
|
|
const def = dr.buildingById[defId];
|
|
const cx = worldToTileX(st.nav, cmd.x), cy = worldToTileY(st.nav, cmd.y);
|
|
let built = null;
|
|
for (let d = 4; d < 20 && !built; d++) {
|
|
for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const tx = cx + ox * d, ty = cy + oy * d;
|
|
if (!L.canPlaceAt(st, dr, tx, ty, def).ok) continue;
|
|
built = L.placeBuilding(st, dr, 0, defId, tx, ty);
|
|
built.site = false; built.progress = 1; built.hp = def.hp;
|
|
break;
|
|
}
|
|
}
|
|
if (!built) return null;
|
|
const foe = L.spawnUnit(st, dr, 1, 'tank', built.x + dx, built.y + dy);
|
|
return { st, built, foe };
|
|
};
|
|
const run = (f, secs) => { for (let i = 0; i < secs * HZ; i++) L.tick(f.st, dr); };
|
|
|
|
// A building's heading is fixed at 0, so a tower that had to face its target could only ever
|
|
// shoot due east. Every compass point is checked because that bug looks fine from one side.
|
|
for (const [name, dx, dy] of [['east', 220, 0], ['west', -220, 0], ['north', 0, -220], ['south', 0, 220]]) {
|
|
const f = fixture('lasertower', dx, dy);
|
|
check(`found ground for a Laser Tower (${name})`, !!f);
|
|
if (!f) continue;
|
|
const hp0 = f.foe.hp;
|
|
run(f, 8);
|
|
check(`the Laser Tower engages an enemy to the ${name}`, f.foe.hp < hp0,
|
|
`${f.foe.hp.toFixed(0)}/${hp0}`);
|
|
}
|
|
|
|
// The Missile Launcher reaches far but cannot defend itself: inside its minimum range it
|
|
// holds fire, which is what makes it need the Laser Tower next to it.
|
|
{
|
|
const far = fixture('missilelauncher', 520, 0);
|
|
check('found ground for a Missile Launcher', !!far);
|
|
if (far) {
|
|
const hp0 = far.foe.hp;
|
|
run(far, 12);
|
|
check('the Missile Launcher hits a target beyond tank range', far.foe.hp < hp0,
|
|
`${far.foe.hp.toFixed(0)}/${hp0}`);
|
|
}
|
|
const close = fixture('missilelauncher', 120, 0);
|
|
if (close) {
|
|
const hp0 = close.foe.hp;
|
|
run(close, 12);
|
|
check('the Missile Launcher holds fire inside its dead zone', close.foe.hp === hp0,
|
|
`${close.foe.hp.toFixed(0)}/${hp0}`);
|
|
}
|
|
}
|
|
|
|
// The 1x1 footprint is new — nothing else in the roster is one tile — so it goes through the
|
|
// real order path: site it, build it, and have it end up on the nav grid as a solid.
|
|
{
|
|
const st = L.createMatch(dr, {
|
|
seed: 102, map, victory: 'annihilation', armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
|
});
|
|
const cmd = st.entities.find((e) => e.army === 0);
|
|
st.armies[0].mass = 9999; st.armies[0].energy = 9999;
|
|
st.armies[0].massCap = 9999; st.armies[0].energyCap = 9999;
|
|
const cx = worldToTileX(st.nav, cmd.x), cy = worldToTileY(st.nav, cmd.y);
|
|
let spot = null;
|
|
for (let d = 2; d < 20 && !spot; d++) {
|
|
const tx = cx + d, ty = cy;
|
|
if (L.canPlaceAt(st, dr, tx, ty, tower).ok) spot = { tx, ty };
|
|
}
|
|
check('found a tile for a 1x1 tower', !!spot);
|
|
if (spot) {
|
|
const r = L.issueOrder(st, dr, {
|
|
army: 0, unitIds: [cmd.id], order: { type: 'build', defId: 'lasertower', ...spot },
|
|
});
|
|
check('a Laser Tower build order is accepted', r.ok, r.error);
|
|
let done = null;
|
|
for (let i = 0; i < 120 * HZ && !done; i++) {
|
|
st.armies[0].mass = 9999; st.armies[0].energy = 9999;
|
|
L.tick(st, dr);
|
|
done = st.entities.find((e) => e.defId === 'lasertower' && !e.site && !e.dead);
|
|
}
|
|
check('the Commander finishes the tower', !!done);
|
|
check('a finished 1x1 tower blocks its own tile',
|
|
!!done && !L.canPlaceAt(st, dr, spot.tx, spot.ty, tower).ok);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
section('6f. Air domain and hover');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// Air is the one thing in this game that is not simply "another unit with different numbers":
|
|
// it opts out of the nav grid, out of ground collision, and out of every weapon that doesn't
|
|
// explicitly list the air domain. Each of those three is a separate place the sim could quietly
|
|
// fall back to ground behaviour, so each gets its own fixture.
|
|
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
|
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders
|
|
const ar = compileRules(raw);
|
|
const TS = ar.constants.tileSize;
|
|
|
|
// ---- data ----
|
|
check('the Fighter is an air unit', ar.unitById.fighter?.isAir === true);
|
|
check('the Bomber is an air unit', ar.unitById.bomber?.isAir === true);
|
|
check('the Hover Constructor stays on the ground', ar.unitById.hoverconstructor?.isAir === false);
|
|
check('the Airfield builds fighter, bomber and hover constructor',
|
|
['fighter', 'bomber', 'hoverconstructor'].every((id) => (ar.buildingById.airfield?.builds ?? []).includes(id)));
|
|
check('the Commander can build an Airfield', (ar.unitById.commander.builds ?? []).includes('airfield'));
|
|
const waterIdx = ar.terrainById.water.index;
|
|
const cliffIdx = ar.terrainById.cliff.index;
|
|
check('hover crosses water', ar.moveClasses.hover.costByTerrainIndex[waterIdx] > 0);
|
|
check('treads do not cross water', ar.moveClasses.tread.costByTerrainIndex[waterIdx] == null);
|
|
check('air crosses water and cliffs',
|
|
ar.moveClasses.air.costByTerrainIndex[waterIdx] > 0 && ar.moveClasses.air.costByTerrainIndex[cliffIdx] > 0);
|
|
// Without this a ground-only army has literally no answer to a Bomber, which is not a
|
|
// difficulty setting — it's an auto-win for whoever builds an Airfield first.
|
|
const groundAA = ar.units.filter((u) => !u.isAir && (u.weaponDefs ?? []).some((w) => w.targetsAir));
|
|
check('some ground unit can shoot at aircraft', groundAA.length > 0,
|
|
'nothing on the ground has anti-air');
|
|
|
|
// ---- a map with a full-height water channel down the middle ----
|
|
const W = 40, H = 40;
|
|
const flat = () => {
|
|
const t = new Uint8Array(W * H).fill(ar.terrainById.ground.index);
|
|
for (let y = 0; y < H; y++) for (let x = 18; x <= 21; x++) t[y * W + x] = waterIdx;
|
|
return t;
|
|
};
|
|
const px = (t) => t * TS + TS / 2;
|
|
const arena = (seed = 5) => {
|
|
const st = L.createMatch(ar, {
|
|
seed, victory: 'annihilation',
|
|
map: { w: W, h: H, terrain: flat(), starts: [], theme: 'grasslands' },
|
|
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
|
});
|
|
// Both armies need SOMETHING alive or checkResult ends the match on tick one and every
|
|
// fixture below silently measures a frozen sim. These sit in opposite far corners, well
|
|
// outside any sight or weapon range used here, so they never touch what is being measured.
|
|
L.spawnUnit(st, ar, 0, 'infantry', px(1), px(1));
|
|
L.spawnUnit(st, ar, 1, 'infantry', px(W - 2), px(H - 2));
|
|
st.over = null;
|
|
for (const a of st.armies) a.alive = true;
|
|
return st;
|
|
};
|
|
const runFor = (st, sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, ar); };
|
|
|
|
{
|
|
const st = arena();
|
|
const hover = L.spawnUnit(st, ar, 0, 'hoverconstructor', px(5), px(20));
|
|
const tank = L.spawnUnit(st, ar, 0, 'tank', px(5), px(24));
|
|
const fighter = L.spawnUnit(st, ar, 0, 'fighter', px(5), px(28));
|
|
for (const u of [hover, tank, fighter]) {
|
|
L.issueOrder(st, ar, { army: 0, unitIds: [u.id], order: { type: 'move', x: px(35), y: u.y } });
|
|
}
|
|
runFor(st, 120);
|
|
const crossed = (e) => e.x > px(25);
|
|
check('a hover unit crosses open water', crossed(hover), `x=${(hover.x / TS).toFixed(1)} tiles`);
|
|
check('an aircraft crosses open water', crossed(fighter), `x=${(fighter.x / TS).toFixed(1)} tiles`);
|
|
check('a tracked unit is stopped by the same water', !crossed(tank), `x=${(tank.x / TS).toFixed(1)} tiles`);
|
|
// Aircraft must never enter the pathfinder: a path costs A* budget it can't use, and a
|
|
// fighter holding a stale ground path would refuse to fly over the very water it just crossed.
|
|
check('aircraft never hold a nav path', fighter.path == null);
|
|
}
|
|
|
|
// ---- ground weapons cannot touch aircraft, and air-only weapons cannot touch the ground ----
|
|
{
|
|
// The Fighter carries the Jeep's gun for ground work, so it hurts infantry — but the
|
|
// infantry still cannot reach back, which is the asymmetry the whole domain split exists
|
|
// to create. It has to be flying for any of this: see §6g for the grounded case.
|
|
check('the air-to-air cannon cannot touch the ground', ar.weaponById.aacannon.targetsGround === false);
|
|
check('the Fighter carries the Jeep\'s gun for ground targets',
|
|
(ar.unitById.fighter.weapons ?? []).includes('pintlegun'));
|
|
|
|
const st = arena(6);
|
|
const inf = [];
|
|
for (let i = 0; i < 8; i++) inf.push(L.spawnUnit(st, ar, 0, 'infantry', px(8), px(14) + i * 30));
|
|
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(14), px(16));
|
|
L.issueOrder(st, ar, { army: 0, unitIds: inf.map((u) => u.id), order: { type: 'attackMove', x: px(12), y: px(16) } });
|
|
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: inf[0].id } });
|
|
runFor(st, 60);
|
|
check('rifles cannot damage a Fighter', fighter.hp === fighter.maxHp,
|
|
`${fighter.hp.toFixed(0)}/${fighter.maxHp}`);
|
|
check('a Fighter strafing infantry does hurt them',
|
|
inf.some((u) => u.dead || u.hp < u.maxHp),
|
|
'the ground gun never connected');
|
|
}
|
|
{
|
|
const st = arena(7);
|
|
const troopers = [];
|
|
for (let i = 0; i < 8; i++) troopers.push(L.spawnUnit(st, ar, 0, 'rockettrooper', px(8), px(14) + i * 30));
|
|
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(11), px(16));
|
|
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: troopers[0].id } });
|
|
runFor(st, 60);
|
|
check('shoulder rockets do reach a Fighter', fighter.dead || fighter.hp < fighter.maxHp,
|
|
`${fighter.hp.toFixed(0)}/${fighter.maxHp}`);
|
|
}
|
|
{
|
|
// Fighters are the answer to fighters; that is the whole point of an air-only weapon.
|
|
// They need somewhere to be — an idle aircraft lands, and a landed one cannot shoot.
|
|
const st = arena(8);
|
|
const a = L.spawnUnit(st, ar, 0, 'fighter', px(10), px(20));
|
|
const b = L.spawnUnit(st, ar, 1, 'fighter', px(16), px(20));
|
|
L.issueOrder(st, ar, { army: 0, unitIds: [a.id], order: { type: 'attack', targetId: b.id } });
|
|
L.issueOrder(st, ar, { army: 1, unitIds: [b.id], order: { type: 'attack', targetId: a.id } });
|
|
runFor(st, 90);
|
|
check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp || b.hp < b.maxHp,
|
|
`${a.hp.toFixed(0)} v ${b.hp.toFixed(0)}`);
|
|
}
|
|
|
|
// ---- splash and blast radii respect the domain ----
|
|
{
|
|
const st = arena(9);
|
|
const bomber = L.spawnUnit(st, ar, 0, 'bomber', px(8), px(20));
|
|
const tank = L.spawnUnit(st, ar, 1, 'tank', px(13), px(20));
|
|
// Kept FLYING over the tank on a short patrol: a 96px bomb blast covers it, and must not
|
|
// scratch it while it is in the air. Idling it would land it, and a landed aircraft is
|
|
// deliberately no longer immune — see §6g.
|
|
const overhead = L.spawnUnit(st, ar, 1, 'fighter', px(13), px(20));
|
|
L.issueOrder(st, ar, { army: 0, unitIds: [bomber.id], order: { type: 'attack', targetId: tank.id } });
|
|
L.issueOrder(st, ar, { army: 1, unitIds: [overhead.id], order: { type: 'patrol', x: px(15), y: px(20) } });
|
|
runFor(st, 60);
|
|
check('a Bomber damages ground armour', tank.dead || tank.hp < tank.maxHp,
|
|
`${tank.hp.toFixed(0)}/${tank.maxHp}`);
|
|
check('the patrolling aircraft stayed airborne', overhead.dead || overhead.liftFrac === 1,
|
|
'the fixture landed it, so it proves nothing about splash');
|
|
check('bomb splash does not reach the aircraft above it', overhead.hp === overhead.maxHp,
|
|
`${overhead.hp.toFixed(0)}/${overhead.maxHp}`);
|
|
}
|
|
|
|
// ---- orders and collision ----
|
|
{
|
|
const st = arena(10);
|
|
const inf = L.spawnUnit(st, ar, 0, 'infantry', px(10), px(20));
|
|
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(12), px(20));
|
|
// Airborne, so the rifleman genuinely cannot reach it. (Landed it would be a legal target;
|
|
// that is the whole point of §6g's grounded-aircraft checks.)
|
|
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'move', x: px(30), y: px(20) } });
|
|
L.tick(st, ar);
|
|
L.issueOrder(st, ar, { army: 0, unitIds: [inf.id], order: { type: 'attack', targetId: fighter.id } });
|
|
L.tick(st, ar);
|
|
check('an attack order on an unreachable domain is dropped', inf.orders.length === 0,
|
|
'the rifleman would chase the aircraft forever');
|
|
}
|
|
{
|
|
const st = arena(11);
|
|
const tanks = [];
|
|
for (let i = 0; i < 5; i++) tanks.push(L.spawnUnit(st, ar, 0, 'tank', px(12) + i * 8, px(20)));
|
|
runFor(st, 5); // let the cluster settle first
|
|
const before = tanks.map((t) => ({ x: t.x, y: t.y }));
|
|
const fighter = L.spawnUnit(st, ar, 0, 'fighter', px(6), px(20));
|
|
L.issueOrder(st, ar, { army: 0, unitIds: [fighter.id], order: { type: 'move', x: px(30), y: px(20) } });
|
|
runFor(st, 15);
|
|
const moved = tanks.reduce((m, t, i) => Math.max(m, Math.hypot(t.x - before[i].x, t.y - before[i].y)), 0);
|
|
check('an aircraft flies through ground units without shoving them', moved < 0.5,
|
|
`worst displacement ${moved.toFixed(2)}px`);
|
|
check('the aircraft actually crossed them', fighter.x > px(25));
|
|
}
|
|
|
|
// ---- the flag survives a save/load round trip ----
|
|
{
|
|
const st = arena(12);
|
|
L.spawnUnit(st, ar, 0, 'fighter', px(10), px(20));
|
|
const back = L.deserialize(ar, L.serialize(st));
|
|
check('isAir survives serialization',
|
|
!!back && back.entities.find((e) => e.defId === 'fighter')?.isAir === true);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
section('6g. Strafing flight');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// An armed aircraft has exactly two states: LANDED (still, and unable to shoot) or FLYING (at
|
|
// cruise, always going somewhere). Every check here is about that having no third option —
|
|
// the failure mode this replaces is a Fighter parked in mid-air over a target, sniping it
|
|
// from a standstill, which is what the previous stop-on-arrival movement produced.
|
|
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
|
raw.constants.eliminateWhenUnrecoverable = false;
|
|
const sr = compileRules(raw);
|
|
const TS = sr.constants.tileSize;
|
|
const W = 64, H = 64;
|
|
|
|
check('armed aircraft are marked as strafing', sr.unitById.fighter.strafes === true
|
|
&& sr.unitById.bomber.strafes === true);
|
|
check('the Hover Constructor is exempt from strafing', sr.unitById.hoverconstructor.strafes === false,
|
|
'it is a ground unit that merely floats, and it does not attack');
|
|
|
|
const px = (t) => t * TS + TS / 2;
|
|
const arena = (seed = 3) => {
|
|
const st = L.createMatch(sr, {
|
|
seed, victory: 'annihilation',
|
|
map: { w: W, h: H, terrain: new Uint8Array(W * H).fill(sr.terrainById.ground.index), starts: [], theme: 'grasslands' },
|
|
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
|
});
|
|
// Both armies need something alive or checkResult ends the match on tick one — see §6f.
|
|
L.spawnUnit(st, sr, 0, 'infantry', px(1), px(1));
|
|
L.spawnUnit(st, sr, 1, 'infantry', px(W - 2), px(H - 2));
|
|
st.over = null;
|
|
for (const a of st.armies) a.alive = true;
|
|
return st;
|
|
};
|
|
const runFor = (st, sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, sr); };
|
|
const speedOf = (e) => Math.hypot(e.x - e.px, e.y - e.py) * HZ;
|
|
|
|
// ---- a move order ends in a landing, not a hover ----
|
|
{
|
|
const st = arena(31);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30));
|
|
check('a fresh aircraft starts on the ground', f.liftFrac === 0);
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(40), y: px(30) } });
|
|
runFor(st, 1.0);
|
|
check('an ordered aircraft takes off', f.liftFrac === 1, `liftFrac ${f.liftFrac.toFixed(2)}`);
|
|
runFor(st, 30);
|
|
check('a move order ends with the aircraft landed', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`);
|
|
check('it landed where it was sent', Math.hypot(f.x - px(40), f.y - px(30)) < TS * 2,
|
|
`${(Math.hypot(f.x - px(40), f.y - px(30)) / TS).toFixed(1)} tiles away`);
|
|
check('a landed aircraft is stationary', speedOf(f) < 0.01, `${speedOf(f).toFixed(1)} px/s`);
|
|
check('a landed aircraft holds no orders', f.orders.length === 0);
|
|
}
|
|
|
|
// ---- landed units cannot shoot ----
|
|
{
|
|
const st = arena(32);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
|
const foe = L.spawnUnit(st, sr, 1, 'infantry', px(32), px(30)); // well inside pintlegun range
|
|
runFor(st, 20);
|
|
check('a landed aircraft never takes off on its own', f.liftFrac === 0);
|
|
check('a landed aircraft does not shoot', foe.hp === foe.maxHp && !foe.dead,
|
|
`the target lost ${(foe.maxHp - foe.hp).toFixed(0)} hp`);
|
|
check('a landed aircraft holds no target', f.targetId === 0);
|
|
}
|
|
|
|
// ---- a landed aircraft is an ordinary ground target ----
|
|
{
|
|
// Immunity is a property of FLYING, not of having wings. A parked aircraft can be shot by
|
|
// anything, which is what gives the keepout rule and the choice of where to land teeth.
|
|
const st = arena(40);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
|
const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30)); // tankgun is ground-only
|
|
L.tick(st, sr);
|
|
check('a landed aircraft is not an air target', f.airborne === false && f.isAir === true,
|
|
'isAir describes the layer it moves in; airborne describes where it is now');
|
|
runFor(st, 40);
|
|
check('a tank gun can destroy a parked aircraft', f.dead || f.hp < f.maxHp,
|
|
`${f.hp.toFixed(0)}/${f.maxHp}`);
|
|
}
|
|
{
|
|
// ...and the same aircraft is untouchable the moment it is off the ground.
|
|
const st = arena(41);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
|
const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30));
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } });
|
|
runFor(st, 1.0);
|
|
check('taking off makes it an air target again', f.airborne === true && f.liftFrac === 1);
|
|
const hpAirborne = f.hp;
|
|
runFor(st, 25);
|
|
check('a tank gun cannot touch it once flying', f.hp === hpAirborne,
|
|
`lost ${(hpAirborne - f.hp).toFixed(0)} hp while airborne`);
|
|
check('and it kills the tank on its passes', tank.dead || tank.hp < tank.maxHp);
|
|
}
|
|
{
|
|
// Landing under guns is now a real mistake, not a free escape.
|
|
const st = arena(42);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
|
const tank = L.spawnUnit(st, sr, 1, 'tank', px(33), px(30));
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } });
|
|
runFor(st, 1.0);
|
|
const wasAir = f.airborne;
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'hold' } }); // set down
|
|
runFor(st, 2.0);
|
|
check('an aircraft told to hold leaves the air domain', wasAir === true && f.airborne === false,
|
|
`liftFrac ${f.liftFrac.toFixed(2)}`);
|
|
}
|
|
|
|
// ---- it never stops in the air ----
|
|
{
|
|
const st = arena(33);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
|
const foe = L.spawnUnit(st, sr, 1, 'tank', px(40), px(30));
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: foe.id } });
|
|
runFor(st, 3);
|
|
let slowest = Infinity, samples = 0;
|
|
for (let i = 0; i < 40 * HZ; i++) {
|
|
L.tick(st, sr);
|
|
if (f.dead || foe.dead) break;
|
|
if (f.liftFrac < 1) continue; // ignore the takeoff ramp
|
|
slowest = Math.min(slowest, speedOf(f));
|
|
samples++;
|
|
}
|
|
const cruise = sr.unitById.fighter.speed;
|
|
check('an attacking aircraft samples its speed', samples > 100, `${samples} samples`);
|
|
check('an attacking aircraft never slows down', slowest > cruise * 0.98,
|
|
`dropped to ${slowest.toFixed(0)} of ${cruise} px/s`);
|
|
}
|
|
|
|
// ---- the strafing run: overfly, carry through, come about, repeat ----
|
|
{
|
|
const st = arena(34);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
|
// A building, so it cannot run away and the geometry of the pass is unambiguous.
|
|
const tgt = L.placeBuilding(st, sr, 1, 'energygen', 40, 30);
|
|
tgt.site = false; tgt.progress = 1; tgt.hp = sr.buildingById.energygen.hp;
|
|
tgt.maxHp = tgt.hp;
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tgt.id } });
|
|
|
|
let closest = Infinity, farthest = 0, passes = 0, wasNear = false, egressSeen = false;
|
|
for (let i = 0; i < 60 * HZ; i++) {
|
|
L.tick(st, sr);
|
|
tgt.hp = tgt.maxHp; // immortal, so the run repeats indefinitely
|
|
if (f.liftFrac < 1) continue;
|
|
const d = Math.hypot(f.x - tgt.x, f.y - tgt.y);
|
|
closest = Math.min(closest, d);
|
|
farthest = Math.max(farthest, d);
|
|
if (f.egressing) egressSeen = true;
|
|
const near = d < TS * 3;
|
|
if (near && !wasNear) passes++;
|
|
wasNear = near;
|
|
}
|
|
check('the aircraft actually overflies its target', closest < TS * 2,
|
|
`closest approach ${(closest / TS).toFixed(1)} tiles`);
|
|
check('it carries through past the target', egressSeen && farthest > sr.unitById.fighter.flight.overshoot * 0.6,
|
|
`farthest ${farthest.toFixed(0)}px vs overshoot ${sr.unitById.fighter.flight.overshoot}`);
|
|
check('it comes about and runs in again', passes >= 3, `${passes} passes in 60s`);
|
|
check('it stays airborne throughout', f.liftFrac === 1);
|
|
}
|
|
|
|
// ---- opportunistic fire while transiting ----
|
|
{
|
|
const st = arena(35);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30));
|
|
// Ordered at something far away, with a bystander parked on the flight path.
|
|
const far = L.spawnUnit(st, sr, 1, 'tank', px(55), px(30));
|
|
const bystander = L.spawnUnit(st, sr, 1, 'infantry', px(25), px(30));
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: far.id } });
|
|
const startX = f.x;
|
|
runFor(st, 12);
|
|
check('a transiting aircraft shoots what it passes',
|
|
bystander.dead || bystander.hp < bystander.maxHp, 'the bystander was never engaged');
|
|
check('shooting a bystander does not stop the aircraft', f.x > startX + TS * 8,
|
|
`only travelled ${((f.x - startX) / TS).toFixed(1)} tiles`);
|
|
}
|
|
|
|
// ---- aircraft will not land in a hostile base ----
|
|
{
|
|
const st = arena(36);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
|
const base = L.placeBuilding(st, sr, 1, 'energygen', 40, 30);
|
|
base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp;
|
|
base.maxHp = base.hp;
|
|
// Told to move ONTO the enemy structure. That is not a move, it is an attack run.
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: base.x, y: base.y } });
|
|
L.tick(st, sr);
|
|
check('a move into a hostile base becomes an attack run',
|
|
f.orders[0]?.type === 'attack' && f.orders[0].targetId === base.id,
|
|
`order is ${f.orders[0]?.type}`);
|
|
let landed = false;
|
|
for (let i = 0; i < 40 * HZ; i++) {
|
|
L.tick(st, sr);
|
|
base.hp = base.maxHp;
|
|
if (f.liftFrac === 0) { landed = true; break; }
|
|
}
|
|
check('it never puts down inside the keepout', !landed,
|
|
`landed ${(Math.hypot(f.x - base.x, f.y - base.y) / TS).toFixed(1)} tiles from the base`);
|
|
}
|
|
|
|
// ---- an idle aircraft inside a keepout clears out, then lands ----
|
|
{
|
|
const st = arena(37);
|
|
const base = L.placeBuilding(st, sr, 1, 'energygen', 30, 30);
|
|
base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp;
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(32), px(30)); // parked right next to it
|
|
runFor(st, 40);
|
|
const away = L.surfaceDist(sr, f, base);
|
|
check('an idle aircraft leaves a hostile base before settling',
|
|
away >= sr.unitById.fighter.flight.keepout * 0.9,
|
|
`only got ${away.toFixed(0)}px from the base`);
|
|
check('and then it lands', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`);
|
|
}
|
|
|
|
// ---- the Hover Constructor keeps the old stop-in-place behaviour ----
|
|
{
|
|
const st = arena(38);
|
|
const h = L.spawnUnit(st, sr, 0, 'hoverconstructor', px(20), px(30));
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [h.id], order: { type: 'move', x: px(26), y: px(30) } });
|
|
runFor(st, 30);
|
|
check('a hovering builder still stops exactly on its destination',
|
|
Math.hypot(h.x - px(26), h.y - px(30)) < TS, `${(Math.hypot(h.x - px(26), h.y - px(30)) / TS).toFixed(2)} tiles off`);
|
|
check('and settles back down when idle', h.liftFrac === 0);
|
|
}
|
|
|
|
// ---- builders leave aircraft alone until they are down ----
|
|
{
|
|
// A nanolathe cannot reach something in flight, and a builder that tried would trail a
|
|
// Fighter across the map at a third of its speed — walking itself out of the base for
|
|
// nothing. Every route into that mistake is closed: auto-heal, an explicit order, a
|
|
// patient that takes off mid-repair, and guard.
|
|
const st = arena(50);
|
|
const cmd = L.spawnUnit(st, sr, 0, 'commander', px(30), px(30));
|
|
const hurt = L.spawnUnit(st, sr, 0, 'fighter', px(33), px(30));
|
|
hurt.hp = hurt.maxHp * 0.4;
|
|
|
|
// Grounded: the ordinary case must still work, or the guard is too broad.
|
|
runFor(st, 2);
|
|
check('a builder does mend a LANDED aircraft', cmd.buildTargetId === hurt.id,
|
|
`link ${cmd.buildTargetId}`);
|
|
const mended = hurt.hp;
|
|
runFor(st, 10);
|
|
check('and its hit points actually come back', hurt.hp > mended);
|
|
|
|
// Airborne: send it somewhere and everything must let go.
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [hurt.id], order: { type: 'move', x: px(55), y: px(30) } });
|
|
runFor(st, 2);
|
|
check('the aircraft is airborne for the rest of this fixture', hurt.airborne === true);
|
|
check('a builder drops the repair when its patient takes off', cmd.buildTargetId === 0,
|
|
'the Commander kept nanolathing a Fighter in flight');
|
|
const inFlight = hurt.hp;
|
|
const cmdX = cmd.x;
|
|
runFor(st, 15);
|
|
check('and does not heal it in the air', Math.abs(hurt.hp - inFlight) < 1e-6,
|
|
`${inFlight.toFixed(0)} -> ${hurt.hp.toFixed(0)}`);
|
|
check('and does not chase it', Math.abs(cmd.x - cmdX) < 1, `drifted ${(cmd.x - cmdX).toFixed(1)}px`);
|
|
|
|
// An explicit order is refused rather than silently ignored.
|
|
const r = L.issueOrder(st, sr, { army: 0, unitIds: [cmd.id], order: { type: 'repair', targetId: hurt.id } });
|
|
check('an explicit repair on a flying aircraft is refused', r.ok === false, JSON.stringify(r));
|
|
}
|
|
|
|
{
|
|
// Guard keeps the order but stops following, so it resumes when the aircraft lands.
|
|
const st = arena(51);
|
|
const cmd = L.spawnUnit(st, sr, 0, 'commander', px(30), px(30));
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(33), px(30));
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: f.id } });
|
|
// A PATROL, not a move: a move ends in a landing, and once the aircraft is down the
|
|
// Commander is supposed to start following it again — which is the behaviour below, not a
|
|
// violation of it. Patrolling keeps it in the air for the whole window.
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'patrol', x: px(58), y: px(30) } });
|
|
runFor(st, 2);
|
|
const cmdX = cmd.x;
|
|
runFor(st, 12);
|
|
check('the guarded aircraft stayed airborne', f.airborne === true);
|
|
check('a builder guarding an aircraft holds station while it flies',
|
|
Math.abs(cmd.x - cmdX) < 1, `drifted ${(cmd.x - cmdX).toFixed(1)}px`);
|
|
check('and keeps the guard order for when it lands',
|
|
cmd.orders[0]?.type === 'guard' && cmd.orders[0].targetId === f.id);
|
|
}
|
|
|
|
// ---- altitude survives a save ----
|
|
{
|
|
const st = arena(39);
|
|
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
|
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(50), y: px(30) } });
|
|
runFor(st, 2);
|
|
const back = L.deserialize(sr, L.serialize(st));
|
|
const rf = back?.entities.find((e) => e.defId === 'fighter');
|
|
check('altitude survives serialization', !!rf && Math.abs(rf.liftFrac - f.liftFrac) < 0.01,
|
|
`${rf?.liftFrac} vs ${f.liftFrac}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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('7b. Radar, advanced radar and jamming');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// Radar is a SECOND detection layer: sight tells you what a thing is, radar only that
|
|
// something is there. Everything here is about the two staying independent — a jammer must
|
|
// blind radar without dimming anyone's eyes, and a contact must never be confused for a
|
|
// sighting.
|
|
const radar = rules.buildingById.radar;
|
|
const adv = rules.buildingById.advancedradar;
|
|
const jam = rules.buildingById.radarjammer;
|
|
const launcher = rules.weaponById.towermissile;
|
|
|
|
check('the Radar Tower exists', !!radar && radar.radarRange > 0);
|
|
check('its reach is about twice the Missile Launcher',
|
|
Math.abs(radar.radarRange / launcher.range - 2) < 0.15,
|
|
`${radar.radarRange} vs ${launcher.range}`);
|
|
check('the Advanced Radar covers any map', adv.radarRange > 128 * rules.constants.tileSize * Math.SQRT2,
|
|
`${adv.radarRange} vs the largest map diagonal`);
|
|
check('the Advanced Radar also sees through fog at the basic dish\'s radius',
|
|
adv.sight === radar.radarRange, `${adv.sight} vs ${radar.radarRange}`);
|
|
check('the Jammer reaches as far as the basic dish', jam.jamRange === radar.radarRange,
|
|
`${jam.jamRange} vs ${radar.radarRange}`);
|
|
check('the Jammer is not itself a radar', !jam.radarRange);
|
|
// Who may build what — the two upper tiers are the construction craft's job.
|
|
check('the Commander can build the basic Radar', rules.unitById.commander.builds.includes('radar'));
|
|
for (const id of ['advancedradar', 'radarjammer']) {
|
|
check(`the Commander cannot build the ${id}`, !rules.unitById.commander.builds.includes(id));
|
|
check(`the Construction Vehicle can build the ${id}`, rules.unitById.constructor.builds.includes(id));
|
|
check(`the Hover Constructor can build the ${id}`, rules.unitById.hoverconstructor.builds.includes(id));
|
|
}
|
|
|
|
const map = generateMap(rules, { seed: 5, size: 'small', symmetry: 'mirror-x' });
|
|
const rig = () => {
|
|
const st = L.createMatch(rules, { seed: 5, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
|
return st;
|
|
};
|
|
const put = (st, army, type, tx, ty) => {
|
|
const b = L.placeBuilding(st, rules, army, type, tx, ty);
|
|
b.site = false; b.progress = 1; b.hp = rules.buildingById[type].hp;
|
|
return b;
|
|
};
|
|
const covered = (st, a) => st.armies[a].radar.reduce((s, v) => s + v, 0);
|
|
|
|
{
|
|
const st = rig();
|
|
const cells = st.visW * st.visH;
|
|
const s0 = st.starts.find((x) => x.army === 0);
|
|
L.computeVision(st, rules);
|
|
check('an army with no dish has no radar at all', covered(st, 0) === 0);
|
|
|
|
put(st, 0, 'radar', s0.x + 2, s0.y + 2);
|
|
L.computeVision(st, rules);
|
|
const basic = covered(st, 0);
|
|
check('a Radar Tower covers ground but not the map', basic > 0 && basic < cells,
|
|
`${basic} of ${cells} cells`);
|
|
|
|
put(st, 0, 'advancedradar', s0.x - 4, s0.y + 2);
|
|
L.computeVision(st, rules);
|
|
check('an Advanced Radar covers the whole map', covered(st, 0) === cells,
|
|
`${covered(st, 0)} of ${cells}`);
|
|
}
|
|
|
|
{
|
|
// The jammer's whole job: punch a hole in enemy coverage, including a whole-map dish.
|
|
const st = rig();
|
|
const s0 = st.starts.find((x) => x.army === 0);
|
|
const s1 = st.starts.find((x) => x.army === 1);
|
|
put(st, 0, 'advancedradar', s0.x + 2, s0.y + 2);
|
|
L.computeVision(st, rules);
|
|
const before = covered(st, 0);
|
|
const j = put(st, 1, 'radarjammer', s1.x, s1.y);
|
|
L.computeVision(st, rules);
|
|
const after = covered(st, 0);
|
|
check('an enemy Jammer takes coverage away', after < before, `${before} -> ${after}`);
|
|
check('it only takes a bite, not the lot', after > 0);
|
|
// ...and it must not blind the side that owns it.
|
|
put(st, 1, 'radar', s1.x + 3, s1.y);
|
|
L.computeVision(st, rules);
|
|
check('your own Jammer never blinds you', covered(st, 1) > 0, `${covered(st, 1)} cells`);
|
|
j.dead = true;
|
|
L.computeVision(st, rules);
|
|
check('killing the Jammer restores coverage', covered(st, 0) === before);
|
|
}
|
|
|
|
{
|
|
// Contact vs sighting. A unit out in the fog but under radar is a CONTACT; the same unit
|
|
// in plain view is not, because there is nothing radar adds to looking straight at it.
|
|
const st = rig();
|
|
const s0 = st.starts.find((x) => x.army === 0);
|
|
put(st, 0, 'radar', s0.x + 2, s0.y + 2);
|
|
const far = L.spawnUnit(st, rules, 1, 'tank', (s0.x + 12) * st.tileSize, s0.y * st.tileSize);
|
|
L.computeVision(st, rules);
|
|
check('a unit hidden by fog but under radar is a contact',
|
|
!L.isVisibleTo(st, 0, far) && L.isDetectedBy(st, 0, far));
|
|
const near = L.spawnUnit(st, rules, 1, 'tank', s0.x * st.tileSize, s0.y * st.tileSize);
|
|
L.computeVision(st, rules);
|
|
check('a unit in plain sight is a sighting, not a contact',
|
|
L.isVisibleTo(st, 0, near) && !L.isDetectedBy(st, 0, near));
|
|
check('your own units are never radar contacts',
|
|
!L.isDetectedBy(st, 1, far));
|
|
|
|
// A jammer over that unit takes the contact away again.
|
|
put(st, 1, 'radarjammer', s0.x + 12, s0.y);
|
|
L.computeVision(st, rules);
|
|
check('a Jammer hides a unit standing inside it', !L.isDetectedBy(st, 0, far));
|
|
}
|
|
|
|
{
|
|
// A dish still going up is not yet a dish.
|
|
const st = rig();
|
|
const s0 = st.starts.find((x) => x.army === 0);
|
|
L.placeBuilding(st, rules, 0, 'advancedradar', s0.x + 2, s0.y + 2); // left as a site
|
|
L.computeVision(st, rules);
|
|
check('a half-built dish gives no radar', covered(st, 0) === 0);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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.
|
|
//
|
|
// Re-baselined after units were made to hold at a stand-off from buildings rather than
|
|
// driving into them. That behaviour costs real ladder strength, graded by how far out they
|
|
// stop: measured at 64 games, 5v1 ran 90% with no hold, 82% at 0.45, 72% at 0.6 and 68% at
|
|
// 0.85. Two different seed families put the shipped 0.45 setting between 68% and 82%, so
|
|
// the bar sits at 0.60 — clearly above chance and above skill 3, without straddling the
|
|
// noise band of a 32-game sample.
|
|
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.60, `${(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('12b. AI build repertoire');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// Everything in the rules is dead data unless the AI will actually build it. Defences and
|
|
// the whole air branch were exactly that for a while: the Airfield gate was keyed on a mass
|
|
// income the AI never reaches (it peaks at 11-27, the bar was 24), so a skill-5 AI built one
|
|
// in zero games out of six while the roster claimed it could.
|
|
//
|
|
// Counts BOTH armies across several games — these are conditional behaviours, not a fixed
|
|
// build order, so the assertion is that each shows up across a sample rather than in any
|
|
// particular match.
|
|
const games = QUICK ? 6 : 12;
|
|
const built = Object.create(null);
|
|
for (let i = 0; i < games; i++) {
|
|
const map = generateMap(rules, { seed: 8000 + i * 13, size: 'medium', symmetry: 'mirror-x' });
|
|
const st = L.createMatch(rules, { seed: 8000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
|
while (!st.over && st.tick < 900 * HZ) {
|
|
runAI(rules, st, 0, { skill: 4 });
|
|
runAI(rules, st, 1, { skill: 4 });
|
|
for (const ev of L.tick(st, rules)) {
|
|
if (ev.t === 'buildingComplete' || ev.t === 'spawn') {
|
|
built[ev.defId] = (built[ev.defId] ?? 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const n = (id) => (built[id] ?? 0);
|
|
console.log(` ${games} games · towers ${n('lasertower')} · launchers ${n('missilelauncher')}`
|
|
+ ` · airfields ${n('airfield')} · fighters ${n('fighter')} · bombers ${n('bomber')}`);
|
|
check('the AI builds Laser Towers', n('lasertower') > 0, 'never built one');
|
|
check('the AI builds Missile Launchers', n('missilelauncher') > 0, 'never built one');
|
|
check('the AI builds Airfields', n('airfield') > 0, 'never built one');
|
|
check('the AI produces aircraft', n('fighter') + n('bomber') > 0, 'never built any');
|
|
// Defence is a reaction, not a habit. An AI that answers every game with a wall of towers
|
|
// has stopped building an army, and the skill ladder above is measuring the wrong thing.
|
|
check('defence stays a minority of construction',
|
|
n('lasertower') + n('missilelauncher') < n('energygen') + n('massgen'),
|
|
`${n('lasertower') + n('missilelauncher')} defences vs ${n('energygen') + n('massgen')} generators`);
|
|
// Decisiveness is §11's job: it uses parameters tuned for it, whereas two equal skill-4 AIs
|
|
// on a mirrored map are supposed to be able to draw.
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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} (${(rate * 100) | 0}%)`);
|
|
// The gate is WINNABILITY, not a 50/50 split. Measured over 8 runs the missions sit at
|
|
// 50-100%, and asserting >=50% off a 4-run sample would fail roughly a third of the time
|
|
// on a genuinely fine mission. Seed quality is held to a higher bar by hand when a
|
|
// mission is authored (tools/genTACampaign.js); this catches one that became impossible.
|
|
check(`${m.id} is winnable by a skill-5 bot`, wins >= 1, `${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)' : ''}.`);
|