// Headless verification for Master of Vega (Master of Orion clone). // node tools/verifyMasterOfVega.js [--quick] [--games=N] // Exits non-zero on any failure. // // 1. Rules integrity: ids unique, tech chains acyclic and fully ranked, every // tech matters, hull/weapon/building bounds, frame indexes in range. // 2. Procedural art: run the real painters against a fake canvas and assert // every frame the rules reference was registered. // 3. Galaxy generation: determinism, star counts, lane connectivity, homeworld // spacing and habitability, opening-range fairness. // 4. Ship Marks: damage and hull monotonic in tech; no NaN anywhere. // 5. Combat: determinism, mirror-match fairness, tech/number advantage, // auto-resolve agrees with playing it out, no battle hits the round cap. // 6. Colony economy: slider normalisation, mandatory ecology, spillover, // factory cap, growth, waste recovery. // 7. Diplomacy and the Galactic Council: vote arithmetic, refusal, no deadlock. // 8. Leaders: hiring, postings, upkeep bounds. // 9. Serialisation: round-trip byte-identical, hash stable, version rejected. // 10. AI self-play soak: full games terminate, both victory kinds occur, // invariants hold, AI turn-time budget. import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { compileRules, techCost, markNumeral } from '../src/games/mastervega/VegaRules.js'; import { generateGalaxy, isConnected, parsecs, mulberry32, PARSEC_PX } from '../src/games/mastervega/VegaGalaxyGen.js'; import * as Ships from '../src/games/mastervega/VegaShips.js'; import * as Combat from '../src/games/mastervega/VegaCombat.js'; import * as Logic from '../src/games/mastervega/VegaLogic.js'; import * as AI from '../src/games/mastervega/VegaAI.js'; import * as Diplo from '../src/games/mastervega/VegaDiplomacy.js'; import * as Leaders from '../src/games/mastervega/VegaLeaders.js'; // Phaser-free half of the star map's zoom behaviour. import { buildZoomLadder, minZoomFor, MAX_ZOOM, DEFAULT_ZOOM_INDEX } from '../src/games/mastervega/VegaZoom.js'; // Pure art module: the painters need a canvas, but the sheet bookkeeping around // them is checkable headlessly and worth checking. import { ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame, speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, } from '../src/games/mastervega/VegaArt.js'; const QUICK = process.argv.includes('--quick'); const gamesArg = process.argv.find((a) => a.startsWith('--games=')); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const rulesJson = JSON.parse(readFileSync(join(root, 'data/mastervega-rules.json'), 'utf8')); const artJson = JSON.parse(readFileSync(join(root, 'data/mastervega-artwork.json'), 'utf8')); let failures = 0; let passes = 0; function check(name, cond, detail = '') { if (cond) { passes += 1; return; } failures += 1; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function section(name) { console.log(`\n== ${name}`); } const RULES = compileRules(rulesJson); // --------------------------------------------------------------------------- section('1. Rules integrity'); // --------------------------------------------------------------------------- { check('compileRules accepted the shipped rules', !!RULES); check('six tech fields', RULES.techFieldList.length === 6, `${RULES.techFieldList.length}`); check('ten species', RULES.speciesList.length === 10, `${RULES.speciesList.length}`); // Every tech ranked (proves the chains are acyclic and fully reachable). check('every tech is ranked', Object.keys(RULES.techRank).length === RULES.techList.length); for (const t of RULES.techList) { check(`tech ${t.id} rank equals its tier`, RULES.techRank[t.id] === t.tier); } // "Every tech matters": it either gates a building, feeds a later tech, or // carries an effect of its own. for (const t of RULES.techList) { const g = RULES.techGates[t.id]; const matters = g.buildings.length > 0 || g.prereqOf.length > 0 || g.effects.length > 0; check(`tech ${t.id} matters`, matters); } // Frame indexes must be unique per sheet and inside it. const techFrames = RULES.techList.map((t) => t.iconFrame); check('tech icon frames unique', new Set(techFrames).size === techFrames.length); const techSheet = artJson.sheets.techicons; const techCap = techSheet.cols * techSheet.rows; check('tech icon frames fit the sheet', Math.max(...techFrames) < techCap, `max ${Math.max(...techFrames)} of ${techCap}`); const buildFrames = RULES.buildingList.map((b) => b.frame); check('building frames unique', new Set(buildFrames).size === buildFrames.length); check('building frames fit the sheet', Math.max(...buildFrames) < artJson.sheets.buildings.cols * artJson.sheets.buildings.rows); const portraitFrames = RULES.speciesList.map((s) => s.portraitFrame); check('species portrait frames unique', new Set(portraitFrames).size === portraitFrames.length); check('species ship rows fit the ship sheet', Math.max(...RULES.speciesList.map((s) => s.shipFrame)) < artJson.sheets.ships.rows); check('hull columns fit the ship sheet', Math.max(...RULES.hullList.map((h) => h.frame)) < artJson.sheets.ships.cols); check('planet frames fit the sheet', Math.max(...RULES.planetTypeList.map((p) => p.frame)) < artJson.sheets.planets.cols * artJson.sheets.planets.rows); check('leader frames fit the sheet', Math.max(...RULES.leaderList.map((l) => l.portraitFrame)) < artJson.sheets.leaders.cols * artJson.sheets.leaders.rows); // Weapons must be mountable on something. const smallest = Math.min(...RULES.hullList.filter((h) => h.space > 0).map((h) => h.space)); for (const t of RULES.techList) { const w = t.effects?.weapon; if (!w) continue; check(`weapon ${w.id} fits some hull`, w.space <= Math.max(...RULES.hullList.map((h) => h.space))); check(`weapon ${w.id} has sane damage`, w.min > 0 && w.max >= w.min); if (w.kind === 'missile') check(`missile ${w.id} has salvoes`, w.shots > 0); } check('the smallest warship can mount the starting beam', RULES.techs.lasercannon.effects.weapon.space <= smallest); // Research cost must climb. const c0 = techCost(RULES, RULES.techs.lasercannon, 0); const c9 = techCost(RULES, RULES.techs.stellarconverter, 9); check('research costs climb across a field', c9 > c0 * 50, `${c0} -> ${c9}`); check('markNumeral covers Mark VII', markNumeral(7) === 'VII'); // Species sanity: at least one clear strength each, and no species is // strictly better than another on every axis. for (const s of RULES.speciesList) { const t = s.traits; const good = [t.industryMult > 1, t.researchMult > 1, t.tradeMult > 1, t.growthMult > 1, t.shipAttack > 0, t.shipDefense > 0, t.groundAttack > 0, t.espionage > 0, t.factoriesPerPop > 2, t.colonizeAnything, t.hostileImmune, t.diplomacy > 0].filter(Boolean).length; check(`species ${s.id} has a real strength`, good > 0); } } // --------------------------------------------------------------------------- section('2. Procedural art'); // --------------------------------------------------------------------------- { // A Proxy canvas that records every value the painters push at it. A NaN // reaching a real canvas surfaces as an opaque WebGL error far from the // mistake, so it is caught here instead. 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 === 'createLinearGradient' || prop === 'createRadialGradient') { return (...args) => { num(String(prop), ...args); return { addColorStop: () => {} }; }; } 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), 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); const sheetNames = Object.keys(artJson.sheets ?? {}); for (const name of sheetNames) { check(`sheet ${name} resolves to a 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 === sheetNames.length, `${procedural.length}/${sheetNames.length}`); check('no painter emitted a non-finite value', bad.length === 0, [...new Set(bad)].slice(0, 5).join(', ')); // Every frame the rules point at must actually have been painted. const shipsTex = made.get(keys.ships); for (const s of RULES.speciesList) { for (const h of RULES.hullList) { check(`ship frame ${s.id}/${h.id} painted`, shipsTex?.frames.includes(shipFrame(RULES, s.id, h.id))); } } const planetsTex = made.get(keys.planets); for (const p of RULES.planetTypeList) { check(`planet frame ${p.id} painted`, planetsTex?.frames.includes(planetFrame(RULES, p.id))); } const techTex = made.get(keys.techicons); for (const t of RULES.techList) { check(`tech icon ${t.id} painted`, techTex?.frames.includes(techFrame(RULES, t.id))); } const buildTex = made.get(keys.buildings); for (const b of RULES.buildingList) { check(`building icon ${b.id} painted`, buildTex?.frames.includes(buildingFrame(RULES, b.id))); } // Species portraits are looping videos where one has been recorded and a // sheet frame where one has not. The manifest has to name every species so a // new one cannot be silently forgotten, and every non-null path has to point // at a file that is actually there — otherwise the first sign of a typo is a // 404 and a blank portrait in the browser. const vids = artJson.portraitVideos ?? {}; const vidIds = Object.keys(vids).filter((k) => !k.startsWith('_')); for (const id of vidIds) { check(`portraitVideos entry ${id} is a known species`, !!RULES.species[id]); check(`portraitVideos ${id} key matches the loader's key`, vids[id].key === speciesVideoKey(id), `${vids[id].key} vs ${speciesVideoKey(id)}`); if (vids[id].path) { check(`portraitVideos ${id} file exists`, existsSync(join(root, vids[id].path)), vids[id].path); check(`portraitVideos ${id} is an mp4`, vids[id].path.endsWith('.mp4')); } } for (const s of RULES.speciesList) { check(`species ${s.id} has a portraitVideos entry`, vidIds.includes(s.id)); // Whether or not it has a video, the sheet fallback must exist for it. const portraitsTex = made.get(keys.portraits); check(`species ${s.id} has a portrait fallback frame`, portraitsTex?.frames.includes(s.portraitFrame)); } // The still-portrait tier, checked the same way. Its filenames are NOT // derived from the species id — one of them is spelled differently on disk — // so the path in the manifest is the only source of truth and has to be // confirmed against the filesystem. const stills = artJson.portraitStills ?? {}; const stillIds = Object.keys(stills).filter((k) => !k.startsWith('_')); for (const id of stillIds) { check(`portraitStills entry ${id} is a known species`, !!RULES.species[id]); check(`portraitStills ${id} key matches the loader's key`, stills[id].key === speciesStillKey(id), `${stills[id].key} vs ${speciesStillKey(id)}`); if (stills[id].path) { check(`portraitStills ${id} file exists`, existsSync(join(root, stills[id].path)), stills[id].path); } } for (const s of RULES.speciesList) { check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id)); } const recorded = vidIds.filter((id) => vids[id].path).length; const stillCount = stillIds.filter((id) => stills[id].path).length; console.log(` (${recorded}/${RULES.speciesList.length} portraits are video, ` + `${stillCount} have a still; the rest fall back to the painted sheet)`); // Species speech. Unlike the portraits this is NOT declared in the artwork // manifest — ui/SpeechQueue.js streams it straight from // assets/speech/.mp3 without going through Phaser's loader — so the // filename convention itself is the contract, and this is the only place it // gets enforced. for (const sp of RULES.speciesList) { const clip = speciesSpeechClip(sp.id); check(`species ${sp.id} speech clip exists`, existsSync(join(root, 'assets/speech', `${clip}.mp3`)), `${clip}.mp3`); } for (const [name, clip] of Object.entries(UI_SPEECH)) { check(`UI speech clip ${name} exists`, existsSync(join(root, 'assets/speech', `${clip}.mp3`)), `${clip}.mp3`); } // Soundtrack: every track the JSON names must be on disk, or the game boots // into silence with a console error and nothing says why. const music = JSON.parse(readFileSync(join(root, 'data/masterofvega-music.json'), 'utf8')); check('soundtrack declares tracks', Array.isArray(music.tracks) && music.tracks.length > 0); for (const t of music.tracks ?? []) { check(`soundtrack file ${t.file} exists`, existsSync(join(root, 'assets/music', t.file)), t.file); check(`soundtrack ${t.file} has artist and title`, !!t.artist && !!t.title); } if (typeof music.volume === 'number') { check('soundtrack volume in range', music.volume > 0 && music.volume <= 1, `${music.volume}`); } } // --------------------------------------------------------------------------- section('3. Galaxy generation'); // --------------------------------------------------------------------------- { const shapes = RULES.galaxyShapeList.map((s) => s.id); const sizes = RULES.galaxySizeList.map((s) => s.id); const allSpecies = RULES.speciesList.map((s) => s.id); for (const shapeId of shapes) { for (const sizeId of sizes) { const size = RULES.galaxySizes[sizeId]; const speciesIds = allSpecies.slice(0, size.maxEmpires); const g = generateGalaxy(RULES, { sizeId, shapeId, seed: 99, speciesIds }); check(`${shapeId}/${sizeId} star count`, g.stars.length === size.stars, `${g.stars.length} of ${size.stars}`); check(`${shapeId}/${sizeId} lane graph connected`, isConnected(g.stars, g.adj)); check(`${shapeId}/${sizeId} stars inside bounds`, g.stars.every((s) => s.x >= 0 && s.y >= 0 && s.x <= g.width && s.y <= g.height)); check(`${shapeId}/${sizeId} star names unique`, new Set(g.stars.map((s) => s.name)).size === g.stars.length); check(`${shapeId}/${sizeId} one homeworld per empire`, new Set(g.homeIdx).size === speciesIds.length); // Each empire must start on its own species' native world. speciesIds.forEach((sid, e) => { const home = g.stars[g.homeIdx[e]]; check(`${shapeId}/${sizeId} ${sid} starts on its homeworld type`, home.planets[0]?.typeId === RULES.species[sid].homeworld); check(`${shapeId}/${sizeId} ${sid} homeworld is habitable for it`, RULES.planetTypes[home.planets[0].typeId].colonizable); }); // Fairness: no empire may start meaningfully closer to a rival than the // rest — that decides the game before turn one. if (speciesIds.length > 1) { const nearest = g.homeIdx.map((a, i) => Math.min( ...g.homeIdx.filter((_, j) => j !== i).map((b) => parsecs(g, a, b)), )); const spread = Math.max(...nearest) / Math.max(0.001, Math.min(...nearest)); check(`${shapeId}/${sizeId} homeworld spacing fair`, spread < 3.2, `spread ${spread.toFixed(2)}`); } // Opening range must reach something worth settling. const openRange = RULES.economy.baseFuelRange + 1.5; for (let e = 0; e < speciesIds.length; e += 1) { let open = 0; for (let i = 0; i < g.stars.length; i += 1) { if (i === g.homeIdx[e]) continue; if (parsecs(g, g.homeIdx[e], i) > openRange) continue; open += g.stars[i].planets.filter((p) => RULES.planetTypes[p.typeId].hostility === 0 && RULES.planetTypes[p.typeId].colonizable).length; } check(`${shapeId}/${sizeId} empire ${e} has room to expand`, open >= 2, `${open} open worlds`); } } } // Determinism. const a = generateGalaxy(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 4242, speciesIds: ['human', 'kkrix', 'lithox'] }); const b = generateGalaxy(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 4242, speciesIds: ['human', 'kkrix', 'lithox'] }); check('galaxy generation is deterministic', JSON.stringify(a) === JSON.stringify(b)); const c = generateGalaxy(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 4243, speciesIds: ['human', 'kkrix', 'lithox'] }); check('a different seed gives a different galaxy', JSON.stringify(a) !== JSON.stringify(c)); check('too many empires for the galaxy is rejected', (() => { try { generateGalaxy(RULES, { sizeId: 'small', shapeId: 'spiral', seed: 1, speciesIds: RULES.speciesList.map((s) => s.id) }); return false; } catch (err) { return true; } })()); } // --------------------------------------------------------------------------- section('3b. Star-map zoom'); // --------------------------------------------------------------------------- { const VIEW_W = 1920; const VIEW_H = 1080; for (const size of RULES.galaxySizeList) { const ladder = buildZoomLadder(size.width, size.height); check(`${size.id} ladder is non-empty`, ladder.length > 0); check(`${size.id} ladder ascends`, ladder.every((z, i) => i === 0 || z > ladder[i - 1]), ladder.map((z) => z.toFixed(3)).join(' ')); // The point of the whole exercise: at NO step may the galaxy fail to fill // the screen, or the player can pull back past the edge of the map into // empty space. for (const z of ladder) { check(`${size.id} zoom ${z.toFixed(3)} covers the viewport`, size.width * z >= VIEW_W - 1e-6 && size.height * z >= VIEW_H - 1e-6, `${Math.round(size.width * z)}x${Math.round(size.height * z)} vs ${VIEW_W}x${VIEW_H}`); } // The bottom rung must be exactly the covering zoom — any higher and the // player cannot see the whole galaxy at once. check(`${size.id} bottom rung is the covering zoom`, Math.abs(ladder[0] - minZoomFor(size.width, size.height)) < 1e-9); check(`${size.id} top rung reaches close inspection`, Math.abs(ladder[ladder.length - 1] - MAX_ZOOM) < 1e-9 || ladder.length === 1); check(`${size.id} default zoom index is in range`, DEFAULT_ZOOM_INDEX < ladder.length || ladder.length === 1); } // A galaxy so small that covering it is already past the close-inspection // zoom collapses to one fixed step rather than an inverted ladder. const tiny = buildZoomLadder(600, 400); check('a tiny galaxy collapses to a single zoom step', tiny.length === 1); check('a tiny galaxy still covers the viewport', 600 * tiny[0] >= VIEW_W - 1e-6 && 400 * tiny[0] >= VIEW_H - 1e-6); } // --------------------------------------------------------------------------- section('4. Ship Marks'); // --------------------------------------------------------------------------- { // Learn the whole tree tier by tier and assert a refit never makes a ship // worse. This is the property the knapsack loadout exists to guarantee: with // preset hulls and no designer, the Mark is the ONLY way research shows up in // the fleet, so a regression here makes the tech tree feel inert. const known = {}; const order = []; for (let tier = 0; tier < 10; tier += 1) { for (const f of Object.keys(RULES.techFields)) { const t = RULES.techsByField[f].find((x) => x.tier === tier); if (t) order.push(t); } } const hulls = ['frigate', 'destroyer', 'cruiser', 'battleship', 'starbase']; let prev = null; let drops = 0; let nan = 0; let hadMissiles = false; for (const t of order) { known[t.id] = true; // Learning the FIRST missile tech reserves part of every large hull for // missile racks (see MISSILE_SHARE in VegaShips), which trades a little // sustained beam damage for an opening salvo. That is a deliberate // one-time step down and the only sanctioned exception; it happens on the // second rung of the weapons tree, long before it could matter. Every // other transition must be non-decreasing. const nowHasMissiles = Ships.bestComponents(RULES, known).missiles.length > 0; const missileTransition = nowHasMissiles && !hadMissiles; hadMissiles = nowHasMissiles; const now = {}; for (const h of hulls) { const d = Ships.designFor(RULES, known, h, RULES.species.human.traits); now[h] = d; for (const v of Object.values(d)) { if (typeof v === 'number' && !Number.isFinite(v)) { nan += 1; } } if (prev && !missileTransition) { if (d.damage < prev[h].damage - 1e-6) drops += 1; if (d.hp < prev[h].hp) drops += 1; } } prev = now; } check('no ship stat regresses as tech is learned', drops === 0, `${drops} regressions`); check('the missile-share transition was actually exercised', hadMissiles); check('no design produced a non-finite stat', nan === 0); const full = Ships.designFor(RULES, known, 'battleship', RULES.species.human.traits); check('a fully teched hull reaches Mark VII', full.mark === Ships.MAX_MARK, `Mark ${full.mark}`); check('a fully teched warship mounts weapons', full.mounts.length > 0); check('warships carry both beams and missiles across the game', order.some(() => true) && full.mounts.some((m) => m.weapon.kind === 'beam')); // Every warship must still have a beam — an all-missile ship empties its // racks and then cannot fight at all. const mid = {}; for (const t of RULES.techList) if (t.tier <= 6) mid[t.id] = true; for (const h of ['destroyer', 'cruiser', 'battleship']) { const d = Ships.designFor(RULES, mid, h, RULES.species.human.traits); check(`${h} keeps a sustained beam battery`, d.beamDamage > 0, `beam ${d.beamDamage}`); } // Unarmed hulls stay unarmed; immobile hulls stay immobile. const scout = Ships.designFor(RULES, known, 'scout', RULES.species.human.traits); check('scout is unarmed', scout.mounts.length === 0 && scout.damage === 0); check('scout outranges a warship', scout.range > full.range); const base = Ships.designFor(RULES, known, 'starbase', RULES.species.human.traits); check('star base is immobile', base.immobile && base.speed === 0); check('refit costs something and is finite', (() => { const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits); return Number.isFinite(c) && c > 0; })()); check('refitting to the same Mark is free', Ships.refitCost(RULES, known, 'cruiser', Ships.MAX_MARK, RULES.species.human.traits) === 0); } // --------------------------------------------------------------------------- section('5. Combat'); // --------------------------------------------------------------------------- { const techsUpTo = (tier) => { const k = {}; for (const t of RULES.techList) if (t.tier <= tier) k[t.id] = true; return k; }; const mkEmp = (sid, tier) => ({ known: techsUpTo(tier), traits: RULES.species[sid].traits }); const battle = (aS, aT, aShips, dS, dT, dShips, seed, colony = null) => Combat.runBattle( Combat.createBattle(RULES, { attacker: { empireIdx: 0, name: aS, empire: mkEmp(aS, aT), ships: aShips }, defender: { empireIdx: 1, name: dS, empire: mkEmp(dS, dT), ships: dShips }, colony, rnd: mulberry32(seed), }), ); const N = QUICK ? 120 : 400; // Mirror matches must be a coin flip. Any systematic edge here means every // other balance number measured against a mirror is meaningless. let worstBias = 0; let capped = 0; let total = 0; for (const tier of [0, 2, 4, 6, 8, 9]) { let atk = 0; for (let s = 1; s <= N; s += 1) { const r = battle('human', tier, [{ hullId: 'cruiser', count: 5 }], 'human', tier, [{ hullId: 'cruiser', count: 5 }], s * 7919); if (r.winner === 'attacker') atk += 1; if (r.rounds >= RULES.combat.maxRounds) capped += 1; total += 1; } const bias = Math.abs(atk / N - 0.5); worstBias = Math.max(worstBias, bias); check(`mirror match at tier ${tier} is fair`, bias < 0.12, `attacker ${(atk / N * 100).toFixed(1)}%`); } check('no battle ends on the round cap', capped === 0, `${capped}/${total}`); check('worst mirror bias within tolerance', worstBias < 0.12, `${(worstBias * 100).toFixed(1)}pp`); const rate = (fn, n = QUICK ? 80 : 200) => { let w = 0; for (let s = 1; s <= n; s += 1) if (fn(s * 7919).winner === 'attacker') w += 1; return w / n; }; check('a two-tier tech lead is decisive', rate((s) => battle('human', 6, [{ hullId: 'cruiser', count: 5 }], 'human', 4, [{ hullId: 'cruiser', count: 5 }], s)) > 0.8); check('numbers matter', rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 7 }], 'human', 5, [{ hullId: 'cruiser', count: 5 }], s)) > 0.8); check('a ship-attack species beats a neutral one', rate((s) => battle('rrashaa', 5, [{ hullId: 'cruiser', count: 5 }], 'human', 5, [{ hullId: 'cruiser', count: 5 }], s)) > 0.7); check('a ship-defence species beats a neutral one', rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 5 }], 'kestrelli', 5, [{ hullId: 'cruiser', count: 5 }], s)) < 0.3); // The two opposite racial bonuses must cancel — if they do not, one of them // is being applied on the wrong side of the hit formula. const cancel = rate((s) => battle('kestrelli', 5, [{ hullId: 'cruiser', count: 5 }], 'rrashaa', 5, [{ hullId: 'cruiser', count: 5 }], s)); check('opposing attack and defence bonuses cancel', Math.abs(cancel - 0.5) < 0.15, `${(cancel * 100).toFixed(1)}%`); // Determinism, and auto-resolve agreeing with a played-out battle. They run // the same stepper, so this is a structural guarantee rather than a tuning // one — but it is exactly the kind of thing a refactor silently breaks. const mk = (seed) => Combat.createBattle(RULES, { attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 5), ships: [{ hullId: 'cruiser', count: 4 }] }, defender: { empireIdx: 1, name: 'd', empire: mkEmp('ursaal', 5), ships: [{ hullId: 'destroyer', count: 9 }] }, colony: null, rnd: mulberry32(seed), }); const r1 = Combat.runBattle(mk(1234)); const r2 = Combat.runBattle(mk(1234)); check('battles are deterministic', JSON.stringify(r1) === JSON.stringify(r2)); const stepped = mk(4321); let guard = 0; while (!stepped.done && guard < RULES.combat.maxRounds + 2) { guard += 1; Combat.stepRound(stepped, {}); } const autoNoRetreat = Combat.runBattle(mk(4321), { allowRetreat: false }); check('stepping a battle out matches auto-resolve', Combat.battleResult(stepped).winner === autoNoRetreat.winner); // A colony's defences must matter without being unassailable. const undefended = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [], s, null), 60); const defended = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [], s, { defenseHp: 600, shieldBonus: 5 }), 60); check('planetary defences make a difference', defended <= undefended, `${defended} vs ${undefended}`); // Ground combat. const inv = Combat.resolveInvasion(RULES, mulberry32(7), 60, 50, { groundDefense: 0 }, 0, 40); check('a large invasion force takes a lightly held world', inv.captured); const inv2 = Combat.resolveInvasion(RULES, mulberry32(7), 4, 0, { groundDefense: 200 }, 200, 300); check('a token force fails against a fortress', !inv2.captured); } // --------------------------------------------------------------------------- section('6. Colony economy'); // --------------------------------------------------------------------------- { const st = Logic.createGame(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 31, difficultyId: 'normal', speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: -1, }); st.rules = RULES; const colony = st.colonies[0]; // Sliders always normalise to 1. Logic.setSlider(RULES, st, colony, 'industry', 0.8); const sum = Logic.CHANNELS.reduce((t, ch) => t + colony.sliders[ch], 0); check('sliders normalise to 1', Math.abs(sum - 1) < 1e-6, `${sum}`); check('the set channel takes the value it was given', Math.abs(colony.sliders.industry - 0.8) < 1e-6); Logic.setSlider(RULES, st, colony, 'industry', 5); check('slider values are clamped', colony.sliders.industry <= 1); // Run a long stretch and assert the colony stays healthy without any AI. for (let i = 0; i < 200 * st.empires.length; i += 1) { Logic.beginEmpireTurn(RULES, st, st.current); Logic.endEmpireTurn(RULES, st, st.current); } for (const c of st.colonies) { check(`colony at ${c.starIdx} has non-negative population`, c.pop >= 0); check(`colony at ${c.starIdx} respects its population cap`, c.pop <= Logic.colonyMaxPop(RULES, st, c) + 1e-6); check(`colony at ${c.starIdx} never exceeds its worked-factory cap`, Logic.effectiveFactories(RULES, st, c) <= Logic.colonyFactoryCap(RULES, st, c) + 1e-6); check(`colony at ${c.starIdx} stays within its defence cap`, c.defenseHp <= Logic.colonyDefenseCap(RULES, st, c) + 1e-6); // Ecology is funded off the top, so waste must never run away. check(`colony at ${c.starIdx} is not drowning in waste`, c.waste < 5, `${c.waste.toFixed(1)}`); } // Absolute population totals are not comparable across species — a Lithox // start on a barren world supports a fraction of a Human terran one, by // design. Measure each colony against its OWN ceiling instead. check('every colony grows toward its own ceiling', st.colonies.every( (c) => c.pop >= Logic.colonyMaxPop(RULES, st, c) * 0.6, ), st.colonies.map((c) => `${(c.pop / Logic.colonyMaxPop(RULES, st, c)).toFixed(2)}`).join(' ')); check('treasuries are never negative', st.empires.every((e) => e.bc >= 0)); // Lithox generate no waste at all — the trait must reach the economy. const lith = st.colonies.find((c) => st.empires[c.empireIdx].speciesId === 'lithox'); if (lith) check('a pollution-immune species generates no waste', lith.waste === 0); // Waste recovery: dump a backlog on a colony and confirm it cleans up. const dirty = st.colonies[0]; dirty.waste = 400; for (let i = 0; i < 60 * st.empires.length; i += 1) { Logic.beginEmpireTurn(RULES, st, st.current); Logic.endEmpireTurn(RULES, st, st.current); } check('a colony recovers from a waste backlog', dirty.waste < 5, `${dirty.waste.toFixed(1)}`); } // --------------------------------------------------------------------------- section('7. Diplomacy and the Galactic Council'); // --------------------------------------------------------------------------- { const st = Logic.createGame(RULES, { sizeId: 'medium', shapeId: 'elliptical', seed: 77, difficultyId: 'normal', speciesIds: ['human', 'kkrix', 'rrashaa', 'lithox'], humanIndex: -1, }); st.rules = RULES; check('a species with no diplomacy cannot negotiate', (() => { st.empires[0].contacted[3] = true; st.empires[3].contacted[0] = true; return !Diplo.canNegotiate(RULES, st, 0, 3); })()); Diplo.declareWar(RULES, st, 0, 1); check('war is mutual', Logic.atWar(st, 0, 1) && Logic.atWar(st, 1, 0)); check('being attacked is resented', st.empires[1].attitude[0] < 0); Diplo.makePeace(RULES, st, 0, 1); check('peace is mutual', !Logic.atWar(st, 0, 1) && !Logic.atWar(st, 1, 0)); check('attitudes stay in range', (() => { for (let i = 0; i < 400; i += 1) { for (const e of st.empires) Diplo.driftAttitudes(RULES, st, e.idx); } return st.empires.every((e) => Object.values(e.attitude) .every((v) => v >= -100 && v <= 100 && Number.isFinite(v))); })()); // Council arithmetic. for (const e of st.empires) { e.totalPop = 100; for (const o of st.empires) if (o.idx !== e.idx) e.attitude[o.idx] = 60; } st.empires[0].totalPop = 400; const result = Logic.runCouncil(RULES, st); check('the council names exactly two candidates', result.candidates.length === 2); check('every vote is accounted for', (() => { const cast = Object.values(result.votes).reduce((t, v) => t + v, 0); return Math.abs(cast + result.abstained - result.totalPop) < 1e-6; })(), `${JSON.stringify(result.votes)} + ${result.abstained} vs ${result.totalPop}`); check('a landslide elects a High Guardian or is refused', result.winner >= 0 || result.refused); // Refusal: a candidate at war with the winner walks out. const st2 = Logic.createGame(RULES, { sizeId: 'medium', shapeId: 'elliptical', seed: 78, difficultyId: 'normal', speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: -1, }); st2.rules = RULES; for (const e of st2.empires) { e.totalPop = 100; for (const o of st2.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 80; } } st2.empires[0].totalPop = 900; Diplo.declareWar(RULES, st2, 0, 1); const r2 = Logic.runCouncil(RULES, st2); check('a candidate at war refuses to submit', r2.refused === true && r2.winner === -1); check('a refusal is never also a victory', !(r2.refused && st2.over)); check('the council reschedules itself', st2.council.nextTurn > st2.turn); check('powerOf is finite for every empire', st2.empires.every((e) => Number.isFinite(Diplo.powerOf(RULES, st2, e.idx)))); } // --------------------------------------------------------------------------- section('8. Leaders'); // --------------------------------------------------------------------------- { const st = Logic.createGame(RULES, { sizeId: 'small', shapeId: 'cluster', seed: 5, difficultyId: 'normal', speciesIds: ['human', 'ssakar'], humanIndex: -1, }); st.rules = RULES; const emp = st.empires[0]; emp.bc = 10000; const offers = Leaders.leaderOffers(RULES, st, 0); check('leaders are offered', offers.length > 0); check('offers are stable within a turn', JSON.stringify(Leaders.leaderOffers(RULES, st, 0)) === JSON.stringify(offers)); const hired = Logic.hireLeader(RULES, st, 0, offers[0].id); check('a leader can be hired', hired && emp.leaders.length === 1); check('hiring costs the treasury', emp.bc < 10000); check('the same leader cannot be hired twice', !Logic.hireLeader(RULES, st, 0, offers[0].id)); check('a hired leader leaves the shared pool', Leaders.leaderTaken(st, offers[0].id)); check('another empire cannot hire them', !Leaders.availableLeaders(RULES, st).some((l) => l.id === offers[0].id)); const def = RULES.leaders[offers[0].id]; const colony = Logic.empireColonies(st, 0)[0]; if (def.kind === 'admin') { check('an admin can take a colony posting', Logic.assignLeader(RULES, st, 0, def.id, 'colony', colony.id)); check('an admin cannot command a fleet', !Logic.assignLeader(RULES, st, 0, def.id, 'fleet', 1)); check('a posted admin is found by the colony', !!Object.keys( Logic.colonyLeaderSkills(RULES, st, colony)).length); } else { check('a captain cannot govern a colony', !Logic.assignLeader(RULES, st, 0, def.id, 'colony', colony.id)); } check('every leader skill is a finite number', RULES.leaderList.every((l) => Object.values(l.skills) .every((v) => typeof v === 'number' && Number.isFinite(v)))); check('every leader costs upkeep', RULES.leaderList.every((l) => l.upkeep > 0)); // Postings must survive a turn and be cleaned up when their target dies. Leaders.runLeaderTurn(RULES, st, 0); check('leader postings stay valid after a turn', emp.leaders.every((l) => l.assignKind === null || l.assignId >= 0)); } // --------------------------------------------------------------------------- section('9. Serialisation'); // --------------------------------------------------------------------------- { const st = Logic.createGame(RULES, { sizeId: 'small', shapeId: 'ring', seed: 909, difficultyId: 'hard', speciesIds: ['umbrix', 'mekhan', 'cerebrai'], humanIndex: 0, }); st.rules = RULES; for (let i = 0; i < 20 * 3; i += 1) { Logic.beginEmpireTurn(RULES, st, st.current); AI.runAITurn(RULES, st, st.current); Logic.endEmpireTurn(RULES, st, st.current); } const json = Logic.serialize(st); const back = Logic.deserialize(json); check('a save round-trips byte-identically', Logic.serialize(back) === json); check('the hash survives a round-trip', Logic.hashState(back) === Logic.hashState(st)); check('rules are never serialised', !json.includes('"rules"') || !JSON.parse(json).rules); check('derived caches are never serialised', !json.includes('_comps') && !json.includes('_range') && !json.includes('_designs')); const bad = JSON.parse(json); bad.version = 99; check('a save from another version is rejected', Logic.deserialize(JSON.stringify(bad)) === null); // Same seed, same game. const mk = () => { const s = Logic.createGame(RULES, { sizeId: 'small', shapeId: 'ring', seed: 2024, difficultyId: 'normal', speciesIds: ['ssakar', 'lithox', 'kestrelli'], humanIndex: -1, }); s.rules = RULES; for (let i = 0; i < 60 * 3; i += 1) { Logic.beginEmpireTurn(RULES, s, s.current); AI.runAITurn(RULES, s, s.current); Logic.endEmpireTurn(RULES, s, s.current); } return Logic.hashState(s); }; check('replaying a seed reproduces the game exactly', mk() === mk()); } // --------------------------------------------------------------------------- section('10. AI self-play soak'); // --------------------------------------------------------------------------- { const ALL = RULES.speciesList.map((s) => s.id); const SIZES = ['small', 'medium', 'large']; const DIFFS = ['easy', 'normal', 'hard']; const SHAPES = ['spiral', 'elliptical', 'cluster', 'ring']; const N = gamesArg ? Number(gamesArg.split('=')[1]) : (QUICK ? 6 : 27); // Returns a string on violation, else null. Checked at intervals rather than // every turn — the point is to catch corruption, not to profile. function checkInvariants(st, label) { for (const e of st.empires) { if (!Number.isFinite(e.bc) || e.bc < 0) return `${label}: empire ${e.idx} bc ${e.bc}`; if (!Number.isFinite(e.totalPop) || e.totalPop < 0) return `${label}: empire ${e.idx} pop ${e.totalPop}`; for (const f of Object.keys(RULES.techFields)) { if (!Number.isFinite(e.beakers[f]) || e.beakers[f] < 0) return `${label}: empire ${e.idx} beakers ${f}`; } if (!e.alive && Logic.empireColonies(st, e.idx).length > 0) { return `${label}: dead empire ${e.idx} still holds colonies`; } } for (const c of st.colonies) { if (!Number.isFinite(c.pop) || c.pop < 0) return `${label}: colony ${c.id} pop ${c.pop}`; if (!st.empires[c.empireIdx]?.alive) return `${label}: colony ${c.id} owned by a dead empire`; if (c.starIdx < 0 || c.starIdx >= st.galaxy.stars.length) return `${label}: colony ${c.id} off-map`; if (!st.galaxy.stars[c.starIdx].planets[c.orbit]) return `${label}: colony ${c.id} on no planet`; if (c.waste > 5000) return `${label}: colony ${c.id} waste ${c.waste}`; } // No two colonies may share an orbit. const seen = new Set(); for (const c of st.colonies) { const key = `${c.starIdx}:${c.orbit}`; if (seen.has(key)) return `${label}: two colonies in orbit ${key}`; seen.add(key); } for (const f of st.fleets) { if (!f.ships.length) return `${label}: empty fleet ${f.id}`; if (f.ships.some((s) => s.count <= 0)) return `${label}: fleet ${f.id} has an empty stack`; if (f.starIdx < 0 && (f.toStar < 0 || f.fromStar < 0)) return `${label}: fleet ${f.id} nowhere`; if (f.starIdx >= st.galaxy.stars.length) return `${label}: fleet ${f.id} off-map`; if (!st.empires[f.empireIdx]?.alive) return `${label}: fleet ${f.id} of a dead empire`; } return null; } function runGame(idx) { const sizeId = SIZES[idx % SIZES.length]; const difficultyId = DIFFS[Math.floor(idx / SIZES.length) % DIFFS.length]; const shapeId = SHAPES[idx % SHAPES.length]; const count = Math.min(RULES.galaxySizes[sizeId].maxEmpires, 3 + (idx % 3)); const speciesIds = []; for (let i = 0; i < count; i += 1) speciesIds.push(ALL[(idx * 3 + i) % ALL.length]); const st = Logic.createGame(RULES, { sizeId, shapeId, difficultyId, seed: 1000 + idx, speciesIds, humanIndex: -1, }); st.rules = RULES; let aiMs = 0; let aiTurns = 0; let invariantErr = null; while (!st.over && st.turn < RULES.victory.turnCap) { Logic.beginEmpireTurn(RULES, st, st.current); const t0 = performance.now(); AI.runAITurn(RULES, st, st.current); aiMs += performance.now() - t0; aiTurns += 1; Logic.endEmpireTurn(RULES, st, st.current); if (st.turn % 50 === 0 && !invariantErr) { invariantErr = checkInvariants(st, `game ${idx} turn ${st.turn}`); } } if (!invariantErr) invariantErr = checkInvariants(st, `game ${idx} end`); return { st, invariantErr, avgAiMs: aiTurns ? aiMs / aiTurns : 0 }; } const games = []; for (let i = 0; i < N; i += 1) games.push(runGame(i)); const firstErr = games.find((g) => g.invariantErr); check('game invariants hold throughout', !firstErr, firstErr?.invariantErr ?? ''); const decided = games.filter((g) => g.st.over); check('every game terminates', decided.length === games.length, `${decided.length}/${games.length}`); const kinds = {}; for (const g of games) kinds[g.st.victoryKind] = (kinds[g.st.victoryKind] ?? 0) + 1; check('most games reach a real victory', (kinds.conquest ?? 0) + (kinds.council ?? 0) >= games.length * 0.6, JSON.stringify(kinds)); check('conquest victories occur', (kinds.conquest ?? 0) > 0, JSON.stringify(kinds)); if (!QUICK) { check('council victories occur', (kinds.council ?? 0) > 0, JSON.stringify(kinds)); } const turns = games.map((g) => g.st.turn).sort((a, b) => a - b); const median = turns[Math.floor(turns.length / 2)]; check('games are decided in a reasonable window', median >= 60 && median <= 650, `median ${median}`); // Performance budget. The scene runs every AI empire synchronously between // the player's turns, so this is what the player waits for. const worst = Math.max(...games.map((g) => g.avgAiMs)); check('AI turn time budget (<=50ms avg)', worst <= 50, `worst ${worst.toFixed(2)}ms`); // No single species should dominate the whole sweep. const wins = {}; for (const g of games) { if (g.st.winnerIdx >= 0) { const sid = g.st.empires[g.st.winnerIdx].speciesId; wins[sid] = (wins[sid] ?? 0) + 1; } } const topShare = Math.max(0, ...Object.values(wins)) / Math.max(1, decided.length); check('no species wins everything', topShare < 0.6, `${JSON.stringify(wins)}`); // Wars must actually happen, and colonies must actually change hands. const anyWar = games.some((g) => g.st.empires.some((e) => Object.values(e.treaties).includes('war') || g.st.empires.some((o) => o.idx !== e.idx && !o.alive))); check('empires go to war', anyWar); const totalEliminated = games.reduce((t, g) => t + g.st.empires.filter((e) => !e.alive).length, 0); check('empires are eliminated in war', totalEliminated > 0, `${totalEliminated}`); } // --------------------------------------------------------------------------- console.log(`\n${passes} passed, ${failures} failed`); if (failures > 0) process.exit(1);