fertig-classic-games/tools/verifyMasterOfVega.js

800 lines
38 KiB
JavaScript

// 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 } 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';
// 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 } 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)));
}
}
// ---------------------------------------------------------------------------
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('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);