877 lines
42 KiB
JavaScript
877 lines
42 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, 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';
|
|
// 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/<clip>.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('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);
|