1848 lines
93 KiB
JavaScript
1848 lines
93 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';
|
|
// 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, worldBgKey,
|
|
colonyVideoKey, audienceVideoKey, sourceWidth,
|
|
} from '../src/games/mastervega/VegaArt.js';
|
|
import { CHAT } from '../src/games/mastervega/VegaChat.js';
|
|
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
|
|
// and will not interrupt the player for is checkable here.
|
|
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL } from '../src/games/mastervega/VegaTurnReport.js';
|
|
// Ship media is addressed here and nowhere else, so the key convention is
|
|
// checkable without a canvas.
|
|
import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js';
|
|
// Dependency-free, so what the game room eagerly pulls is checkable here.
|
|
import { resolveGameAssets } from '../src/data/assetManifest.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);
|
|
}
|
|
|
|
// Exactly one species (lithox) is diplomacy-incapable. The Audience screen's
|
|
// Seek Audience button and the audienceVideos roster both key off this exact
|
|
// count, so a silent change here would silently change who gets a working
|
|
// diplomacy screen.
|
|
const incapable = RULES.speciesList.filter((s) => (s.traits.diplomacy ?? 0) <= -100);
|
|
check('exactly one diplomacy-incapable species', incapable.length === 1,
|
|
incapable.map((s) => s.id).join(','));
|
|
check('the diplomacy-incapable species is lithox', incapable[0]?.id === 'lithox');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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));
|
|
}
|
|
|
|
// Ship commander videos, one per species PER HULL. Unlike the three blocks
|
|
// above, an absent entry here is not a defect — it falls back to that
|
|
// species' own portrait video, which is why nine of the ten species are a
|
|
// single `null`. So the count below is reported rather than asserted, and
|
|
// what IS asserted is that nothing declared is wrong: a key that does not
|
|
// match shipVideoKey() loads a video nothing will ever ask for, and is
|
|
// exactly the mistake the colonyship/`ship-human-colony.mp4` filename
|
|
// mismatch invites.
|
|
const shipVids = artJson.shipVideos ?? {};
|
|
const shipVidSpecies = Object.keys(shipVids).filter((k) => !k.startsWith('_'));
|
|
let shipClips = 0;
|
|
for (const id of shipVidSpecies) {
|
|
check(`shipVideos entry ${id} is a known species`, !!RULES.species[id]);
|
|
const hulls = shipVids[id];
|
|
if (!hulls) continue;
|
|
for (const hullId of Object.keys(hulls).filter((k) => !k.startsWith('_'))) {
|
|
const v = hulls[hullId];
|
|
check(`shipVideos ${id}.${hullId} is a known hull`, !!RULES.hulls[hullId]);
|
|
check(`shipVideos ${id}.${hullId} key matches the loader's key`,
|
|
v?.key === shipVideoKey(id, hullId), `${v?.key} vs ${shipVideoKey(id, hullId)}`);
|
|
if (v?.path) {
|
|
shipClips += 1;
|
|
check(`shipVideos ${id}.${hullId} file exists`,
|
|
existsSync(join(root, v.path)), v.path);
|
|
check(`shipVideos ${id}.${hullId} is an mp4`, v.path.endsWith('.mp4'));
|
|
}
|
|
}
|
|
}
|
|
for (const s of RULES.speciesList) {
|
|
// Declared-or-null, so adding a species cannot silently skip the question.
|
|
check(`species ${s.id} has a shipVideos entry (possibly null)`,
|
|
shipVidSpecies.includes(s.id));
|
|
// The fallback the gaps rely on. Without it a missing clip is a hole.
|
|
check(`species ${s.id} has a shipVideos fallback portrait`, !!vids[s.id]?.path);
|
|
}
|
|
console.log(` (${shipClips}/${RULES.speciesList.length * RULES.hullList.length} `
|
|
+ 'ship commander clips recorded; the rest fall back to the species portrait)');
|
|
|
|
// poptransport's commander clip is deliberately aliased to the colony
|
|
// ship's (VegaShipMedia.js) — same picture as the Troop Transport, but the
|
|
// "settlement run" video. hasShipVideo() takes a scene as a plain
|
|
// duck-typed parameter (no Phaser import in that module), so the alias is
|
|
// checkable here with a stub rather than needing a browser.
|
|
{
|
|
const stubScene = (cachedKeys) => ({ cache: { video: { exists: (k) => cachedKeys.has(k) } } });
|
|
const withColonyClip = stubScene(new Set([shipVideoKey('human', 'colonyship')]));
|
|
check('poptransport resolves to a video when the species has a colony-ship clip',
|
|
hasShipVideo(withColonyClip, 'human', 'poptransport'));
|
|
const withoutAnyClip = stubScene(new Set());
|
|
check('poptransport reports no video when the colony-ship clip is absent too',
|
|
!hasShipVideo(withoutAnyClip, 'human', 'poptransport'));
|
|
// A clip filed under poptransport's OWN key must NOT be what satisfies
|
|
// this — the alias means only the colony ship's key is ever consulted,
|
|
// and one under poptransport's own name is never expected to exist.
|
|
const onlyOwnKey = stubScene(new Set([shipVideoKey('human', 'poptransport')]));
|
|
check('the alias looks at the colony ship\'s key, not poptransport\'s own',
|
|
!hasShipVideo(onlyOwnKey, 'human', 'poptransport'));
|
|
}
|
|
|
|
// Audience-screen mood clips: species -> {angry, neutral, happy}. Lithox
|
|
// (diplomacy-incapable) must have NO entry at all — canNegotiate() is
|
|
// permanently false for them, so the Audience screen never opens for that
|
|
// species and a clip would never play. Every OTHER present entry must have
|
|
// all three moods, unlike shipVideos' per-hull gaps: a missing mood should
|
|
// be an explicit "whole species is null" decision, not a silently absent key.
|
|
const audVids = artJson.audienceVideos ?? {};
|
|
const audVidSpecies = Object.keys(audVids).filter((k) => !k.startsWith('_'));
|
|
let audClips = 0;
|
|
check('audienceVideos has no lithox entry', !audVidSpecies.includes('lithox'));
|
|
for (const id of audVidSpecies) {
|
|
check(`audienceVideos entry ${id} is a known species`, !!RULES.species[id]);
|
|
check(`audienceVideos entry ${id} is diplomacy-capable`,
|
|
(RULES.species[id]?.traits.diplomacy ?? 0) > -100);
|
|
const moods = audVids[id];
|
|
if (!moods) continue;
|
|
for (const mood of ['angry', 'neutral', 'happy']) {
|
|
const v = moods[mood];
|
|
check(`audienceVideos ${id}.${mood} is present`, !!v, `species ${id} declared but missing ${mood}`);
|
|
if (!v) continue;
|
|
check(`audienceVideos ${id}.${mood} key matches the loader's key`,
|
|
v.key === audienceVideoKey(id, mood), `${v.key} vs ${audienceVideoKey(id, mood)}`);
|
|
if (v.path) {
|
|
audClips += 1;
|
|
check(`audienceVideos ${id}.${mood} file exists`, existsSync(join(root, v.path)), v.path);
|
|
check(`audienceVideos ${id}.${mood} is an mp4`, v.path.endsWith('.mp4'));
|
|
}
|
|
}
|
|
}
|
|
for (const s of RULES.speciesList.filter((sp) => (sp.traits.diplomacy ?? 0) > -100)) {
|
|
check(`diplomacy-capable species ${s.id} has an audienceVideos entry (possibly null)`,
|
|
audVidSpecies.includes(s.id));
|
|
}
|
|
console.log(` (${audClips}/${audVidSpecies.filter((id) => audVids[id]).length * 3} `
|
|
+ 'audience mood clips recorded; the rest fall back to the species portrait)');
|
|
|
|
// Every species x hull must land inside the declared `ships` grid, or a row
|
|
// of the fleet panel draws a frame that does not exist.
|
|
const shipSheet = artJson.sheets?.ships;
|
|
const shipCells = (shipSheet?.cols ?? 0) * (shipSheet?.rows ?? 0);
|
|
for (const s of RULES.speciesList) {
|
|
for (const h of RULES.hullList) {
|
|
const f = shipFrame(RULES, s.id, h.id);
|
|
check(`shipFrame ${s.id}/${h.id} is inside the ships sheet`,
|
|
f >= 0 && f < shipCells, `${f} of ${shipCells}`);
|
|
}
|
|
}
|
|
|
|
// World backdrops for the colony screen. These are 1920x1080 opaque images,
|
|
// NOT frames on the `planets` sheet — that one holds transparent 192px discs
|
|
// for the orrery, which is a different picture of the same world. Same
|
|
// drop-in contract as the portraits: every type must be declared so a new one
|
|
// cannot be silently forgotten, a null path is a legitimate "not painted yet"
|
|
// that falls back to a gradient, and any path that IS set must resolve.
|
|
const worlds = artJson.worldBackgrounds ?? {};
|
|
const worldIds = Object.keys(worlds).filter((k) => !k.startsWith('_'));
|
|
for (const id of worldIds) {
|
|
check(`worldBackgrounds entry ${id} is a known planet type`, !!RULES.planetTypes[id]);
|
|
check(`worldBackgrounds ${id} key matches the loader's key`,
|
|
worlds[id].key === worldBgKey(id), `${worlds[id].key} vs ${worldBgKey(id)}`);
|
|
if (worlds[id].path) {
|
|
check(`worldBackgrounds ${id} file exists`, existsSync(join(root, worlds[id].path)),
|
|
worlds[id].path);
|
|
}
|
|
}
|
|
for (const p of RULES.planetTypeList) {
|
|
check(`planet type ${p.id} has a worldBackgrounds entry`, worldIds.includes(p.id));
|
|
}
|
|
const worldsPainted = worldIds.filter((id) => worlds[id].path).length;
|
|
const colonisablePainted = RULES.planetTypeList
|
|
.filter((p) => p.colonizable && worlds[p.id]?.path).length;
|
|
const colonisableTotal = RULES.planetTypeList.filter((p) => p.colonizable).length;
|
|
console.log(` (${worldsPainted}/${worldIds.length} world backdrops painted; `
|
|
+ `${colonisablePainted}/${colonisableTotal} of the colonisable types)`);
|
|
|
|
// Colony-founding vignettes, one per COLONISABLE planet type. A type with no
|
|
// clip falls back to the backdrop still, so a gap is legal — but a clip for a
|
|
// world that can never be settled is dead weight nothing will ever play, and
|
|
// a key that does not match colonyVideoKey() loads a video the vignette will
|
|
// never find. Both are asserted; the roster count is only reported.
|
|
const colVids = artJson.colonyVideos ?? {};
|
|
const colVidIds = Object.keys(colVids).filter((k) => !k.startsWith('_'));
|
|
for (const id of colVidIds) {
|
|
check(`colonyVideos entry ${id} is a known planet type`, !!RULES.planetTypes[id]);
|
|
check(`colonyVideos ${id} is a colonisable type`, !!RULES.planetTypes[id]?.colonizable);
|
|
check(`colonyVideos ${id} key matches the loader's key`,
|
|
colVids[id]?.key === colonyVideoKey(id), `${colVids[id]?.key} vs ${colonyVideoKey(id)}`);
|
|
if (colVids[id]?.path) {
|
|
check(`colonyVideos ${id} file exists`, existsSync(join(root, colVids[id].path)),
|
|
colVids[id].path);
|
|
check(`colonyVideos ${id} is an mp4`, colVids[id].path.endsWith('.mp4'));
|
|
}
|
|
}
|
|
for (const p of RULES.planetTypeList.filter((t) => t.colonizable)) {
|
|
// Not a hard requirement — but every gap must still have the still-image
|
|
// fallback the vignette drops to, or founding a colony there is a gradient.
|
|
if (!colVidIds.includes(p.id)) {
|
|
check(`colonisable type ${p.id} without a colony clip has a backdrop to fall back on`,
|
|
!!worlds[p.id]?.path, p.id);
|
|
}
|
|
}
|
|
const colClips = colVidIds.filter((id) => colVids[id]?.path).length;
|
|
console.log(` (${colClips}/${colonisableTotal} colony-founding clips recorded; `
|
|
+ 'the rest fall back to the world backdrop)');
|
|
|
|
// …and they must stay OUT of the eager manifest. The whole set is ~19 MB for
|
|
// clips a game barely touches, so VegaColonyIntro.ensureColonyVideo() fetches
|
|
// one on demand. Resolving the block here again would quietly put all of it
|
|
// back on the game-room load, which nothing else would notice.
|
|
// assetManifest.js is dependency-free, so the real resolver runs headlessly
|
|
// against a cache stub.
|
|
{
|
|
const stub = { cache: { json: { get: (k) => (k === 'mastervega-artwork' ? artJson : null) } } };
|
|
const eager = resolveGameAssets(stub, 'mastervega');
|
|
const eagerColony = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-colony-'));
|
|
check('colony-founding clips are not eager-loaded', eagerColony.length === 0,
|
|
eagerColony.map((d) => d.key).join(' '));
|
|
// Same reasoning as colonyVideos: 27 potential clips is a speculative
|
|
// payload most playthroughs only partly touch, and checkContactAt marking
|
|
// a fresh human contact is just as strong an "about to be needed" signal
|
|
// as a colony ship parking over a settleable world.
|
|
const eagerAudience = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-audience-'));
|
|
check('audience mood clips are not eager-loaded', eagerAudience.length === 0,
|
|
eagerAudience.map((d) => d.key).join(' '));
|
|
// The cue is the opposite case: small, and it has to be ready the instant
|
|
// the vignette opens.
|
|
check('the colony-founding cue IS eager-loaded',
|
|
eager.some((d) => d.type === 'audio' && d.key === 'vega-colony-cue'));
|
|
// Nothing else lost its ride while that was being arranged.
|
|
check('species portraits are still eager-loaded',
|
|
eager.some((d) => d.type === 'video' && d.key === speciesVideoKey('human')));
|
|
check('ship commander clips are still eager-loaded',
|
|
eager.some((d) => d.type === 'video' && d.key === shipVideoKey('human', 'scout')));
|
|
}
|
|
|
|
// sourceWidth() — the guard every video in this game is scaled through.
|
|
//
|
|
// A Phaser Video carries a placeholder 256x256 size until its first frame
|
|
// decodes, so the obvious `obj.width || SRC` never fires and divides by 256.
|
|
// That is invisible while every clip IS 256 px (placeholder and fallback
|
|
// agree) and is exactly what opened the 960x544 colony clips at 2.5x. Pure
|
|
// function, so the distinction is assertable here rather than in a browser.
|
|
{
|
|
const undecoded = { type: 'Video', videoTexture: null, width: 256 };
|
|
const decoded = { type: 'Video', videoTexture: {}, width: 960 };
|
|
check('sourceWidth ignores an undecoded Video\'s placeholder width',
|
|
sourceWidth(undecoded, 960) === 960, `${sourceWidth(undecoded, 960)}`);
|
|
check('sourceWidth trusts a decoded Video',
|
|
sourceWidth(decoded, 256) === 960, `${sourceWidth(decoded, 256)}`);
|
|
check('sourceWidth trusts an Image outright',
|
|
sourceWidth({ type: 'Image', width: 192 }, 256) === 192);
|
|
check('sourceWidth still falls back on a zero width',
|
|
sourceWidth({ type: 'Image', width: 0 }, 256) === 256);
|
|
// The scale the vignette actually sets, both ways round: a 640px window on
|
|
// a 960px clip is 0.667, and never the 2.5 the placeholder would give.
|
|
check('a 640px clip window scales a 960px clip by 2/3',
|
|
Math.abs(640 / sourceWidth(undecoded, 960) - 0.6667) < 0.001);
|
|
}
|
|
|
|
// The just-in-time loader reads its path straight out of the artwork JSON
|
|
// rather than a descriptor, so the two must agree on where a clip lives.
|
|
for (const id of colVidIds) {
|
|
if (!colVids[id]?.path) continue;
|
|
check(`colonyVideos ${id} path is under the vega video folder`,
|
|
colVids[id].path.startsWith('assets/videos/vega/'), colVids[id].path);
|
|
}
|
|
|
|
// The founding cue the vignette ducks the soundtrack for. Declared in the
|
|
// asset manifest rather than the artwork JSON, so this is the only place the
|
|
// path gets checked.
|
|
check('colony-founding cue exists',
|
|
existsSync(join(root, 'assets/music/vega/colony.mp3')), 'assets/music/vega/colony.mp3');
|
|
|
|
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('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('4b. Fleet orders and detachments');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// The star map's command panel gives orders through sendDetachment: the ships
|
|
// the player dialled in depart, the rest stay behind. Ships are the one thing
|
|
// in the game that cannot be conjured or lost silently, so every path here is
|
|
// checked for conservation as well as for doing the right thing.
|
|
const mk = () => {
|
|
const st = Logic.createGame(RULES, {
|
|
sizeId: 'medium', shapeId: 'spiral', seed: 77, difficultyId: 'normal',
|
|
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0,
|
|
});
|
|
st.rules = RULES;
|
|
// addFleet MERGES into an existing fleet at the same star, so the starting
|
|
// fleet has to go or every case below is really testing "starting fleet
|
|
// plus mine" and the counts stop meaning anything.
|
|
st.fleets = [];
|
|
return st;
|
|
};
|
|
const countShips = (st, e) => Logic.empireFleets(st, e)
|
|
.reduce((t, f) => t + f.ships.reduce((n, s) => n + s.count, 0), 0);
|
|
// A star this empire can actually reach, other than the one it is sitting on.
|
|
const targetFor = (st, fleet) => Object.keys(Logic.reachableStars(RULES, st, fleet.empireIdx))
|
|
.map(Number).find((i) => i !== fleet.starIdx);
|
|
|
|
{
|
|
const st = mk();
|
|
const home = st.galaxy.homeIdx[0];
|
|
const fleet = Logic.addFleet(RULES, st, 0, home,
|
|
[{ hullId: 'frigate', mark: 1, count: 4 }, { hullId: 'scout', mark: 1, count: 2 }]);
|
|
const before = countShips(st, 0);
|
|
const to = targetFor(st, fleet);
|
|
|
|
// ETA is asked BEFORE the order is given, and must react to the selection:
|
|
// a scout is faster than a frigate, so sending scouts alone is never slower.
|
|
const etaAll = Logic.etaTo(RULES, st, fleet, to);
|
|
const etaScouts = Logic.etaTo(RULES, st, fleet, to, [{ hullId: 'scout', mark: 1, count: 2 }]);
|
|
check('pre-order ETA is a positive whole number of turns',
|
|
Number.isInteger(etaAll) && etaAll >= 1, `${etaAll}`);
|
|
check('a faster detachment never arrives later than the whole fleet',
|
|
etaScouts <= etaAll, `${etaScouts} vs ${etaAll}`);
|
|
check('ETA to the star you are already at is zero',
|
|
Logic.etaTo(RULES, st, fleet, fleet.starIdx) === 0);
|
|
|
|
const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'scout', mark: 1, count: 2 }]);
|
|
check('a detachment departs', !!sent && sent.toStar === to);
|
|
check('the detachment is a NEW fleet', sent !== fleet);
|
|
check('the detachment carries exactly what was asked for',
|
|
sent.ships.length === 1 && sent.ships[0].hullId === 'scout' && sent.ships[0].count === 2);
|
|
check('the rest of the fleet stays put', fleet.starIdx === home && fleet.toStar < 0);
|
|
check('the parent fleet keeps the ships that were left behind',
|
|
fleet.ships.reduce((t, s) => t + s.count, 0) === 4);
|
|
check('splitting conserves ships', countShips(st, 0) === before, `${countShips(st, 0)} vs ${before}`);
|
|
check('the committed ETA matches the one the panel quoted',
|
|
Logic.fleetEta(RULES, st, sent) === etaScouts,
|
|
`${Logic.fleetEta(RULES, st, sent)} vs ${etaScouts}`);
|
|
}
|
|
|
|
{
|
|
// Selecting everything is a plain move — no stray empty fleet left behind.
|
|
const st = mk();
|
|
const home = st.galaxy.homeIdx[0];
|
|
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]);
|
|
const fleetsBefore = st.fleets.length;
|
|
const to = targetFor(st, fleet);
|
|
const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 3 }]);
|
|
check('selecting the whole fleet moves that fleet itself', sent === fleet);
|
|
check('a whole-fleet move creates no extra fleet', st.fleets.length === fleetsBefore);
|
|
}
|
|
|
|
{
|
|
// Refusals must be total: a rejected order leaves the fleet untouched
|
|
// rather than carved in two with the pieces going nowhere.
|
|
const st = mk();
|
|
const home = st.galaxy.homeIdx[0];
|
|
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]);
|
|
const before = countShips(st, 0);
|
|
const fleetsBefore = st.fleets.length;
|
|
const reach = Logic.reachableStars(RULES, st, 0);
|
|
const far = st.galaxy.stars.findIndex((s) => !reach[s.idx]);
|
|
if (far >= 0) {
|
|
check('an out-of-range order is refused',
|
|
Logic.sendDetachment(RULES, st, fleet, far, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null);
|
|
check('a refused order leaves the fleet whole',
|
|
st.fleets.length === fleetsBefore && countShips(st, 0) === before);
|
|
}
|
|
const to = targetFor(st, fleet);
|
|
check('an empty selection is refused',
|
|
Logic.sendDetachment(RULES, st, fleet, to, []) === null);
|
|
check('asking for more ships than exist is refused',
|
|
Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 9 }]) === null);
|
|
check('asking for a stack that is not there is refused',
|
|
Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'battleship', mark: 1, count: 1 }]) === null);
|
|
check('every refusal conserved the fleet',
|
|
st.fleets.length === fleetsBefore && countShips(st, 0) === before);
|
|
}
|
|
|
|
{
|
|
// The same stack named twice must be summed, not checked twice against the
|
|
// same stock — otherwise 2 + 2 of a stack of 3 would both pass.
|
|
const st = mk();
|
|
const home = st.galaxy.homeIdx[0];
|
|
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]);
|
|
const to = targetFor(st, fleet);
|
|
check('a duplicated stack request is summed before it is checked',
|
|
Logic.sendDetachment(RULES, st, fleet, to, [
|
|
{ hullId: 'frigate', mark: 1, count: 2 }, { hullId: 'frigate', mark: 1, count: 2 },
|
|
]) === null);
|
|
check('the fleet survived the duplicated request',
|
|
fleet.ships.reduce((t, s) => t + s.count, 0) === 3);
|
|
}
|
|
|
|
{
|
|
// A star base cannot sail. Sending the mobile half of a mixed fleet must
|
|
// leave the base behind, and a fleet of nothing but bases cannot be sent.
|
|
const st = mk();
|
|
const home = st.galaxy.homeIdx[0];
|
|
const fleet = Logic.addFleet(RULES, st, 0, home,
|
|
[{ hullId: 'starbase', mark: 1, count: 1 }, { hullId: 'frigate', mark: 1, count: 2 }]);
|
|
const before = countShips(st, 0);
|
|
const to = targetFor(st, fleet);
|
|
const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 2 }]);
|
|
check('the mobile half of a mixed fleet can be sent', !!sent && sent.toStar === to);
|
|
check('the star base is not carried along',
|
|
!!sent && sent.ships.every((s) => s.hullId !== 'starbase'));
|
|
check('the star base is still at home',
|
|
Logic.fleetsAt(st, home).some((f) => f.ships.some((s) => s.hullId === 'starbase')));
|
|
check('a mixed-fleet split conserves ships', countShips(st, 0) === before);
|
|
|
|
const bases = Logic.addFleet(RULES, st, 0, st.galaxy.homeIdx[0],
|
|
[{ hullId: 'starbase', mark: 1, count: 1 }]);
|
|
check('a fleet of nothing but star bases cannot be sent',
|
|
Logic.sendDetachment(RULES, st, bases, to, [{ hullId: 'starbase', mark: 1, count: 1 }]) === null);
|
|
}
|
|
|
|
{
|
|
// splitFleet on its own: the two halves must both be real fleets, and a
|
|
// fleet already under way cannot be split at all.
|
|
const st = mk();
|
|
const home = st.galaxy.homeIdx[0];
|
|
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 5 }]);
|
|
const before = countShips(st, 0);
|
|
const half = Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 2 }]);
|
|
check('splitFleet returns a new fleet at the same star',
|
|
!!half && half.starIdx === home && half.id !== fleet.id);
|
|
check('splitFleet conserves ships', countShips(st, 0) === before);
|
|
check('splitting off everything is refused',
|
|
Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 3 }]) === null);
|
|
const to = targetFor(st, fleet);
|
|
Logic.sendFleet(RULES, st, fleet, to);
|
|
check('a fleet under way cannot be split',
|
|
Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null);
|
|
check('a fleet under way cannot be given a new order',
|
|
Logic.sendDetachment(RULES, st, fleet, home, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null);
|
|
}
|
|
|
|
{
|
|
// Two idle fleets in one system are merged again at the start of the next
|
|
// turn (trap 17). That is the rule the panel is designed around — the only
|
|
// split it offers is one that departs immediately — so it has to hold.
|
|
const st = mk();
|
|
const home = st.galaxy.homeIdx[0];
|
|
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 4 }]);
|
|
Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 1 }]);
|
|
check('a split leaves two fleets in the system',
|
|
Logic.fleetsAt(st, home).filter((f) => f.empireIdx === 0).length === 2);
|
|
Logic.beginEmpireTurn(RULES, st, 0);
|
|
check('idle detachments merge back at the start of the turn',
|
|
Logic.fleetsAt(st, home).filter((f) => f.empireIdx === 0).length === 1);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
section('4c. Population transport');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// MOO1's "send N population from one colony to another" — dispatched
|
|
// directly as an already-in-transit 'poptransport' fleet (VegaLogic.js:
|
|
// sendPopulation), never built from a colony's queue.
|
|
const hull = RULES.hulls.poptransport;
|
|
check('the poptransport hull exists', !!hull);
|
|
check('poptransport shares the Troop Transport\'s sheet frame (no new art)',
|
|
hull?.frame === RULES.hulls.transport.frame);
|
|
check('poptransport is unarmed (space 0, matching its role)', hull?.space === 0);
|
|
check('poptransport carries no troops', !(hull?.troops > 0));
|
|
|
|
// 'large' + only two species maximises the odds of an unclaimed, in-range
|
|
// star existing near the human's homeworld to found a second colony on —
|
|
// same defensive style as section 6b's colonize() setup, which checks
|
|
// rather than assumes a settleable target exists.
|
|
function mkTwoColonyGame(seed) {
|
|
const st = Logic.createGame(RULES, {
|
|
sizeId: 'large', shapeId: 'cluster', seed, difficultyId: 'normal',
|
|
speciesIds: ['human', 'lithox'], humanIndex: 0,
|
|
});
|
|
st.rules = RULES;
|
|
const home = st.galaxy.homeIdx[0];
|
|
const target = Object.keys(Logic.reachableStars(RULES, st, 0)).map(Number)
|
|
.find((i) => i !== home && i !== st.galaxy.homeIdx[1]);
|
|
if (target === undefined) return { st, home, target: -1, second: null };
|
|
const second = Logic.foundColony(RULES, st, 0, target, 0, 5);
|
|
return { st, home, target, second };
|
|
}
|
|
|
|
{
|
|
const { st, home, target, second } = mkTwoColonyGame(4141);
|
|
check('the test galaxy offers a second site to found on', target >= 0);
|
|
if (target >= 0) {
|
|
const source = Logic.colonyAt(st, home);
|
|
const before = source.pop;
|
|
const cap = Logic.maxSendablePopulation(source);
|
|
check('maxSendablePopulation leaves the floor behind', cap === before - 0.5);
|
|
|
|
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap);
|
|
check('sendPopulation succeeds for a reachable, owned destination', ok);
|
|
check('the source colony loses exactly what was sent', Math.abs(source.pop - 0.5) < 1e-9,
|
|
`${source.pop}`);
|
|
|
|
// Already in transit (starIdx -1), so it is found on state.fleets
|
|
// directly rather than fleetsAt(home) — fleetsAt filters by starIdx,
|
|
// which this fleet no longer has.
|
|
const fleet = st.fleets.find((f) => f.empireIdx === 0
|
|
&& f.ships.some((s) => s.hullId === 'poptransport'));
|
|
check('a poptransport fleet departs immediately (already in transit)',
|
|
!!fleet && fleet.toStar === target && fleet.starIdx < 0);
|
|
check('the payload rides on the ship stack, not a fixed per-hull capacity',
|
|
Math.abs(fleet.ships[0].popPayload - cap) < 1e-9);
|
|
|
|
// Run it to arrival.
|
|
const destBefore = second.pop;
|
|
let turns = 0;
|
|
while (fleet.toStar >= 0 && turns < 200) { Logic.moveFleetsFor(RULES, st, 0); turns += 1; }
|
|
check('the transport arrives within a sane number of turns', turns < 200, `${turns}`);
|
|
// A near-total transfer from a mature homeworld can comfortably exceed
|
|
// a freshly founded colony's max population — delivery caps at that
|
|
// max rather than overflowing it, discarding the excess silently
|
|
// (a deliberate simplification: the sender's responsibility to check
|
|
// the destination has room, exactly like a colony ship arriving at a
|
|
// world already at capacity).
|
|
const maxPopSecond = Logic.colonyMaxPop(RULES, st, second);
|
|
check('arrival delivers the payload, capped at the destination\'s max population',
|
|
Math.abs(second.pop - Math.min(maxPopSecond, destBefore + cap)) < 1e-9,
|
|
`${second.pop} vs min(${maxPopSecond}, ${destBefore + cap})`);
|
|
check('the delivered payload never exceeds the destination\'s max population',
|
|
second.pop <= maxPopSecond + 1e-9);
|
|
check('the spent transport is cleaned up, not left idle forever',
|
|
!Logic.fleetsAt(st, target).some((f) => f.empireIdx === 0
|
|
&& f.ships.some((s) => s.hullId === 'poptransport')));
|
|
check('a populationSent event was recorded',
|
|
st.events.some((e) => e.type === 'populationSent' && e.empire === 0 && e.starIdx === home));
|
|
check('a populationDelivered event was recorded',
|
|
st.events.some((e) => e.type === 'populationDelivered' && e.empire === 0 && e.starIdx === target));
|
|
}
|
|
}
|
|
|
|
{
|
|
// Guardrails: a colony can never be fully emptied, and a request beyond
|
|
// what is available is clamped rather than refused outright.
|
|
const { st, home, target } = mkTwoColonyGame(4242);
|
|
if (target >= 0) {
|
|
const source = Logic.colonyAt(st, home);
|
|
const cap = Logic.maxSendablePopulation(source);
|
|
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap + 1000);
|
|
check('an oversized request is clamped to what the colony can spare', ok);
|
|
check('the source is left at exactly the floor', Math.abs(source.pop - 0.5) < 1e-9);
|
|
}
|
|
}
|
|
|
|
{
|
|
// Refusals: not your colony, same star, not your destination, nothing to spare.
|
|
const { st, home, target } = mkTwoColonyGame(4343);
|
|
if (target >= 0) {
|
|
const enemyHome = st.galaxy.homeIdx[1];
|
|
check('sending from a colony you do not own is refused',
|
|
Logic.sendPopulation(RULES, st, 0, enemyHome, target, 1) === false);
|
|
check('sending to a star with no colony of yours is refused',
|
|
Logic.sendPopulation(RULES, st, 0, home, st.galaxy.homeIdx[1], 1) === false);
|
|
check('sending to the same star is refused',
|
|
Logic.sendPopulation(RULES, st, 0, home, home, 1) === false);
|
|
const source = Logic.colonyAt(st, home);
|
|
source.pop = 0.5;
|
|
check('a colony already at the floor has nothing left to send',
|
|
Logic.sendPopulation(RULES, st, 0, home, target, 1) === false);
|
|
}
|
|
}
|
|
|
|
{
|
|
// A poptransport in transit must never be swept up as invasion troops or
|
|
// combat power — it shares no hullId with 'transport' and has no weapons.
|
|
const { st, home, target } = mkTwoColonyGame(4444);
|
|
if (target >= 0) {
|
|
Logic.sendPopulation(RULES, st, 0, home, target, 1);
|
|
// Already in transit — see the note above about why this is
|
|
// state.fleets directly and not fleetsAt(home).
|
|
const fleet = st.fleets.find((f) => f.ships.some((s) => s.hullId === 'poptransport'));
|
|
check('invasionForecast ignores poptransport stacks',
|
|
Logic.invasionForecast(RULES, st, 1, home)?.troops === 0
|
|
|| Logic.invasionForecast(RULES, st, 1, home) === null);
|
|
check('poptransport contributes no combat power',
|
|
Logic.fleetPower(RULES, st, fleet) === 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);
|
|
|
|
// The two blocks below poke at one colony's allocation and queue. The
|
|
// 200-turn soak further down measures every colony against its own ceiling,
|
|
// so put this one back exactly as it was found rather than handing the soak a
|
|
// colony configured by a unit test.
|
|
const restore = {
|
|
sliders: { ...colony.sliders }, locked: { ...colony.locked }, queue: [...colony.queue],
|
|
};
|
|
|
|
// --- padlocks. `colony.locked` has been honoured by setSlider since the
|
|
// engine was written but had no UI until the colony screen, so this is the
|
|
// first thing that proves the behaviour is what that screen assumes.
|
|
{
|
|
const c = st.colonies[0];
|
|
Logic.CHANNELS.forEach((ch) => { delete c.locked[ch]; });
|
|
Logic.setSlider(RULES, st, c, 'research', 0.2);
|
|
c.locked.research = true;
|
|
const pinned = c.sliders.research;
|
|
Logic.setSlider(RULES, st, c, 'industry', 0.7);
|
|
check('a locked channel holds its share', Math.abs(c.sliders.research - pinned) < 1e-9,
|
|
`${c.sliders.research} vs ${pinned}`);
|
|
const lockedSum = Logic.CHANNELS.reduce((t, ch) => t + c.sliders[ch], 0);
|
|
check('locking still normalises the whole allocation to 1',
|
|
Math.abs(lockedSum - 1) < 1e-6, `${lockedSum}`);
|
|
// A channel cannot claim room a lock has already spoken for. The colony
|
|
// screen relies on this to keep the last channel unlockable.
|
|
c.locked.ecology = true;
|
|
c.locked.defense = true;
|
|
Logic.setSlider(RULES, st, c, 'industry', 1);
|
|
const room = 1 - (c.sliders.research + c.sliders.ecology + c.sliders.defense);
|
|
check('a slider cannot take room the locks reserved',
|
|
c.sliders.industry <= room + 1e-9, `${c.sliders.industry} vs ${room}`);
|
|
Logic.CHANNELS.forEach((ch) => { delete c.locked[ch]; });
|
|
Logic.setSlider(RULES, st, c, 'industry', 0.4);
|
|
}
|
|
|
|
// --- build queue: repeats, reordering, and the ETA the colony screen quotes.
|
|
{
|
|
const c = st.colonies[0];
|
|
c.queue.length = 0;
|
|
|
|
check('enqueueMany queues every copy it was asked for',
|
|
Logic.enqueueMany(RULES, st, c, 'ship', 'scout', 3) === 3, `${c.queue.length}`);
|
|
check('repeats are stored as separate entries', c.queue.length === 3);
|
|
// Buildings are unique per colony, so a repeat request stops after the first.
|
|
const bId = RULES.buildingList.find((b) => !b.prereq).id;
|
|
check('enqueueMany refuses duplicate buildings',
|
|
Logic.enqueueMany(RULES, st, c, 'building', bId, 4) === 1);
|
|
|
|
c.queue.length = 0;
|
|
Logic.enqueue(RULES, st, c, 'ship', 'scout');
|
|
Logic.enqueue(RULES, st, c, 'ship', 'frigate');
|
|
Logic.enqueue(RULES, st, c, 'ship', 'destroyer');
|
|
c.queue[0].progress = 7;
|
|
|
|
check('moveQueueItem rejects an out-of-range source',
|
|
!Logic.moveQueueItem(RULES, st, c, 9, 0) && !Logic.moveQueueItem(RULES, st, c, -1, 0));
|
|
check('moving an item onto itself is a no-op that still succeeds',
|
|
Logic.moveQueueItem(RULES, st, c, 1, 1) && c.queue[1].id === 'frigate');
|
|
|
|
Logic.moveQueueItem(RULES, st, c, 2, 0);
|
|
check('moveQueueItem reorders the queue',
|
|
c.queue.map((q) => q.id).join(',') === 'destroyer,scout,frigate',
|
|
c.queue.map((q) => q.id).join(','));
|
|
// Progress lives on the item, so demoting the half-built head banks its BC
|
|
// rather than handing them to whatever was promoted over it.
|
|
check('progress travels with the item it belongs to',
|
|
c.queue[1].id === 'scout' && c.queue[1].progress === 7 && c.queue[0].progress === 0,
|
|
`${c.queue[0].id}:${c.queue[0].progress} ${c.queue[1].id}:${c.queue[1].progress}`);
|
|
check('a destination past the end clamps to the last slot',
|
|
Logic.moveQueueItem(RULES, st, c, 0, 99) && c.queue[2].id === 'destroyer');
|
|
|
|
// --- collapsed runs. A "x N" row is N queue entries, and moving one is N
|
|
// splices whose indices differ by direction — get it wrong and the run
|
|
// silently interleaves with its neighbour instead of throwing.
|
|
c.queue.length = 0;
|
|
Logic.enqueueMany(RULES, st, c, 'ship', 'scout', 3);
|
|
Logic.enqueueMany(RULES, st, c, 'ship', 'frigate', 2);
|
|
let runs = Logic.collapseQueue(c);
|
|
check('identical consecutive ships collapse into one run',
|
|
runs.length === 2 && runs[0].count === 3 && runs[1].count === 2,
|
|
runs.map((r) => `${r.item.id}x${r.count}`).join(','));
|
|
check('a run records the span it covers',
|
|
runs[0].index === 0 && runs[0].lastIndex === 2 && runs[1].index === 3);
|
|
|
|
Logic.moveQueueRun(RULES, st, c, runs, 0, 1);
|
|
check('moving a run down keeps it contiguous',
|
|
c.queue.map((q) => q.id).join(',') === 'frigate,frigate,scout,scout,scout',
|
|
c.queue.map((q) => q.id).join(','));
|
|
runs = Logic.collapseQueue(c);
|
|
Logic.moveQueueRun(RULES, st, c, runs, 1, -1);
|
|
check('moving a run back up restores the original order',
|
|
c.queue.map((q) => q.id).join(',') === 'scout,scout,scout,frigate,frigate',
|
|
c.queue.map((q) => q.id).join(','));
|
|
check('moving a run off either end is refused',
|
|
!Logic.moveQueueRun(RULES, st, c, runs, 0, -1)
|
|
&& !Logic.moveQueueRun(RULES, st, c, runs, runs.length - 1, 1));
|
|
|
|
// A part-built entry must stand alone: its progress bar has to stay
|
|
// legible, and it is the one slot the engine actually spends on.
|
|
c.queue[0].progress = 5;
|
|
runs = Logic.collapseQueue(c);
|
|
check('a part-built entry does not join the run behind it',
|
|
runs[0].count === 1 && runs[0].item.progress === 5 && runs[1].count === 2,
|
|
runs.map((r) => `${r.item.id}x${r.count}`).join(','));
|
|
c.queue[0].progress = 0;
|
|
|
|
const rate = Logic.colonyBuildRate(RULES, st, c);
|
|
check('colonyBuildRate is finite and non-negative', Number.isFinite(rate) && rate >= 0, `${rate}`);
|
|
const eta = Logic.queueItemEta(RULES, st, c, c.queue[0]);
|
|
check('a funded queue quotes a whole number of turns',
|
|
rate <= 0 || (Number.isInteger(eta) && eta >= 1), `${eta}`);
|
|
// Nothing reaching construction must read as "never", not as 0 turns.
|
|
const idle = { ...c, sliders: { ...c.sliders, ships: 0, industry: 0, defense: 0 } };
|
|
check('an unfunded queue reports no ETA rather than an instant one',
|
|
Logic.queueItemEta(RULES, st, idle, c.queue[0]) === Infinity);
|
|
}
|
|
|
|
colony.sliders = restore.sliders;
|
|
colony.locked = restore.locked;
|
|
colony.queue = restore.queue;
|
|
|
|
// 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('6b. Founding vignette and the turn report');
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
// Founding a colony is announced ONCE, by VegaColonyIntro.js the moment it
|
|
// happens. Putting it back in the "New Turn" popup would announce it twice.
|
|
check('founding a colony is not a turn-report row', !NOTABLE_TYPES.has('colonised'));
|
|
check('colonised has no turn-report label either', !TYPE_LABEL.colonised);
|
|
for (const type of NOTABLE_TYPES) {
|
|
check(`notable event ${type} has a turn-report label`, !!TYPE_LABEL[type]);
|
|
}
|
|
// The event itself must survive: the ticker log still classifies it, and the
|
|
// engine's own bookkeeping (announced/trimmed) runs over it.
|
|
{
|
|
const st = Logic.createGame(RULES, {
|
|
sizeId: 'small', shapeId: 'spiral', difficultyId: 'normal', seed: 6161,
|
|
speciesIds: ['human', 'kkrix'], humanIndex: 0,
|
|
});
|
|
const home = st.empires[0].homeStar;
|
|
// Settleable means habitable AT THIS TECH, not merely a colonisable type —
|
|
// planetology is what gates hostile worlds, and picking on the static flag
|
|
// alone lands on a barren world colonize() will refuse on turn one.
|
|
let target = -1;
|
|
let orbit = -1;
|
|
st.galaxy.stars.forEach((s, i) => {
|
|
if (target >= 0 || i === home) return;
|
|
const o = s.planets.findIndex((p, oo) => Logic.canColonize(RULES, st, 0, i, oo));
|
|
if (o >= 0) { target = i; orbit = o; }
|
|
});
|
|
check('the opening galaxy offers somewhere to settle', target >= 0);
|
|
if (target >= 0) {
|
|
st.empires[0].explored[target] = true;
|
|
Logic.addFleet(RULES, st, 0, target, [{ hullId: 'colonyship', mark: 1, count: 1 }]);
|
|
const ok = Logic.colonize(RULES, st, 0, target, orbit);
|
|
const ev = st.events.find((e) => e.type === 'colonised' && e.starIdx === target);
|
|
check('colonising still pushes a colonised event', ok && !!ev);
|
|
// The vignette looks the new colony up by orbit, exactly like this.
|
|
check('the founded colony is findable by its orbit',
|
|
!!Logic.coloniesAt(st, target).find((c) => c.orbit === orbit));
|
|
// describeEvent must not throw on an event that is no longer notable —
|
|
// the ticker log walks every event, notable or not.
|
|
if (ev) check('describeEvent survives a colonised event', !!describeEvent(RULES, st, ev));
|
|
}
|
|
}
|
|
|
|
// Every number the vignette reads out, on a colony one tick old, for every
|
|
// colonisable world type any species could land on. These are called before
|
|
// the colony has ever been processed, which is a state no other screen sees.
|
|
{
|
|
const st = Logic.createGame(RULES, {
|
|
sizeId: 'large', shapeId: 'cluster', difficultyId: 'normal', seed: 2727,
|
|
speciesIds: RULES.speciesList.slice(0, 6).map((s) => s.id), humanIndex: 0,
|
|
});
|
|
const seen = new Set();
|
|
st.galaxy.stars.forEach((star, starIdx) => {
|
|
star.planets.forEach((planet, orbit) => {
|
|
if (!RULES.planetTypes[planet.typeId].colonizable) return;
|
|
if (seen.has(planet.typeId)) return;
|
|
if (Logic.coloniesAt(st, starIdx).some((c) => c.orbit === orbit)) return;
|
|
seen.add(planet.typeId);
|
|
const c = Logic.foundColony(RULES, st, 0, starIdx, orbit, 5);
|
|
const stats = {
|
|
maxPop: Logic.colonyMaxPop(RULES, st, c),
|
|
output: Logic.colonyProduction(RULES, st, c),
|
|
build: Logic.colonyBuildRate(RULES, st, c),
|
|
factoryCap: Logic.colonyFactoryCap(RULES, st, c),
|
|
};
|
|
for (const [name, v] of Object.entries(stats)) {
|
|
check(`vignette ${name} on a fresh ${planet.typeId} colony is a finite number`,
|
|
Number.isFinite(v) && v >= 0, `${v}`);
|
|
}
|
|
check(`a fresh ${planet.typeId} colony can hold the settlers it landed with`,
|
|
stats.maxPop >= c.pop, `${stats.maxPop} < ${c.pop}`);
|
|
st.colonies.pop();
|
|
});
|
|
});
|
|
console.log(` (${seen.size} colonisable world types exercised)`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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))));
|
|
|
|
// --- checkContactAt: the star-scoped rewrite. Sharing a star triggers
|
|
// contact; being merely nearby (the old parsec-range behaviour) does not.
|
|
{
|
|
const st3 = Logic.createGame(RULES, {
|
|
sizeId: 'small', shapeId: 'cluster', seed: 909, difficultyId: 'normal',
|
|
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0,
|
|
});
|
|
st3.rules = RULES;
|
|
const homes = new Set(st3.empires.map((e) => e.homeStar));
|
|
const spare = st3.galaxy.stars.map((s, i) => i).filter((i) => !homes.has(i));
|
|
const [starA, starB] = spare;
|
|
check('the test galaxy has two spare stars', starA >= 0 && starB >= 0 && starA !== starB);
|
|
if (starA >= 0 && starB >= 0) {
|
|
Logic.foundColony(RULES, st3, 0, starA, 0, 5);
|
|
Logic.foundColony(RULES, st3, 1, starB, 0, 5);
|
|
Logic.checkContactAt(RULES, st3, starA);
|
|
Logic.checkContactAt(RULES, st3, starB);
|
|
check('mere proximity does not create contact (old proximity behaviour is gone)',
|
|
!st3.empires[0].contacted[1]);
|
|
|
|
Logic.addFleet(RULES, st3, 1, starA, [{ hullId: 'scout', mark: 1, count: 1 }]);
|
|
Logic.checkContactAt(RULES, st3, starA);
|
|
check('sharing a star creates mutual contact',
|
|
st3.empires[0].contacted[1] === true && st3.empires[1].contacted[0] === true);
|
|
check('sharing a star pushes a contact event at that star',
|
|
st3.events.some((e) => e.type === 'contact' && e.starIdx === starA
|
|
&& [e.empire, e.other].includes(0) && [e.empire, e.other].includes(1)));
|
|
}
|
|
|
|
// --- proposeOrOffer / respondToOffer / expiry, using the contact just made.
|
|
const held = Diplo.proposeOrOffer(RULES, st3, 1, 0, 'peace');
|
|
check('an AI peace proposal to the human is held, not resolved', held === 'pending');
|
|
check('a held offer does not touch the treaty yet', st3.empires[0].treaties[1] !== 'peace');
|
|
check('a held offer is recorded on the human empire, keyed by proposer',
|
|
st3.empires[0].pendingOffers[1]?.kind === 'peace');
|
|
|
|
const offersBefore = st3.events.filter((e) => e.type === 'offerReceived').length;
|
|
Diplo.proposeOrOffer(RULES, st3, 1, 0, 'peace');
|
|
const offersAfter = st3.events.filter((e) => e.type === 'offerReceived').length;
|
|
check('re-proposing while one offer is outstanding does not duplicate it',
|
|
offersAfter === offersBefore);
|
|
|
|
const humanProposed = Diplo.proposeOrOffer(RULES, st3, 0, 1, 'peace');
|
|
check('a human proposal to an AI resolves immediately, never held',
|
|
typeof humanProposed === 'boolean');
|
|
check('a human proposal never creates a pendingOffers entry on the AI',
|
|
!st3.empires[1].pendingOffers[0]);
|
|
|
|
check('respondToOffer with no matching offer is a safe no-op',
|
|
Diplo.respondToOffer(RULES, st3, 0, 2, true) === false);
|
|
|
|
const accepted = Diplo.respondToOffer(RULES, st3, 0, 1, true);
|
|
check('accepting a held offer succeeds', accepted === true);
|
|
check('accepting a held peace offer applies the treaty both ways',
|
|
st3.empires[0].treaties[1] === 'peace' && st3.empires[1].treaties[0] === 'peace');
|
|
check('accepting clears the pending offer', !st3.empires[0].pendingOffers[1]);
|
|
|
|
const heldAlliance = Diplo.proposeOrOffer(RULES, st3, 1, 0, 'alliance');
|
|
check('a second kind of offer can be held once the first is resolved',
|
|
heldAlliance === 'pending');
|
|
const rejected = Diplo.respondToOffer(RULES, st3, 0, 1, false);
|
|
check('rejecting a held offer succeeds', rejected === true);
|
|
check('rejecting does not apply the treaty', st3.empires[0].treaties[1] !== 'alliance');
|
|
check('rejecting clears the pending offer', !st3.empires[0].pendingOffers[1]);
|
|
check('rejecting pushes an offerRejected event',
|
|
st3.events.some((e) => e.type === 'offerRejected' && e.empire === 0 && e.other === 1));
|
|
|
|
Diplo.proposeOrOffer(RULES, st3, 1, 0, 'alliance');
|
|
st3.turn += 10;
|
|
Diplo.runDiplomacyTurn(RULES, st3, 1);
|
|
check('a stale pending offer expires after enough turns',
|
|
st3.events.some((e) => e.type === 'offerExpired' && e.empire === 1 && e.other === 0));
|
|
}
|
|
|
|
// --- colonize() must trigger contact even when nobody's fleet just moved.
|
|
{
|
|
const stC = Logic.createGame(RULES, {
|
|
sizeId: 'small', shapeId: 'spiral', seed: 4242, difficultyId: 'normal',
|
|
speciesIds: ['human', 'kkrix'], humanIndex: 0,
|
|
});
|
|
let target = -1;
|
|
let orbit = -1;
|
|
stC.galaxy.stars.forEach((s, i) => {
|
|
if (target >= 0 || i === stC.empires[0].homeStar || i === stC.empires[1].homeStar) return;
|
|
const o = s.planets.findIndex((p, oo) => Logic.canColonize(RULES, stC, 1, i, oo));
|
|
if (o >= 0) { target = i; orbit = o; }
|
|
});
|
|
check('the colonize-contact galaxy offers somewhere to settle', target >= 0);
|
|
if (target >= 0) {
|
|
stC.empires[1].explored[target] = true;
|
|
Logic.addFleet(RULES, stC, 0, target, [{ hullId: 'scout', mark: 1, count: 1 }]);
|
|
Logic.addFleet(RULES, stC, 1, target, [{ hullId: 'colonyship', mark: 1, count: 1 }]);
|
|
const ok = Logic.colonize(RULES, stC, 1, target, orbit);
|
|
check('colonize succeeded onto the shared star', ok);
|
|
check('colonize() triggers contact without any fleet move',
|
|
ok && stC.empires[0].contacted[1] === true && stC.empires[1].contacted[0] === true);
|
|
}
|
|
}
|
|
|
|
// --- claimAudienceContacts: the routing helper deciding auto-open vs. the
|
|
// ordinary turn report.
|
|
{
|
|
const stD = Logic.createGame(RULES, {
|
|
sizeId: 'small', shapeId: 'cluster', seed: 5151, difficultyId: 'normal',
|
|
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0,
|
|
});
|
|
stD.rules = RULES;
|
|
stD.empires[0].contacted[1] = true; stD.empires[1].contacted[0] = true;
|
|
stD.empires[0].contacted[2] = true; stD.empires[2].contacted[0] = true;
|
|
stD.events.push({ type: 'contact', empire: 0, other: 1, starIdx: 1, turn: stD.turn });
|
|
stD.events.push({ type: 'contact', empire: 2, other: 0, starIdx: 2, turn: stD.turn });
|
|
|
|
const claimed = Diplo.claimAudienceContacts(RULES, stD, 0);
|
|
check('claimAudienceContacts claims only the diplomacy-capable contact',
|
|
claimed.length === 1 && claimed[0] === 1);
|
|
check('the claimed event is marked announced',
|
|
stD.events.find((e) => e.type === 'contact' && e.other === 1).announced === true);
|
|
check('the lithox contact event is left unannounced for the ordinary turn report',
|
|
stD.events.find((e) => e.type === 'contact' && e.empire === 2).announced !== true);
|
|
}
|
|
|
|
// --- VegaChat.js structural completeness: every diplomacy-capable species
|
|
// has every situation key the Audience screen's buttons and openers look
|
|
// up. pickLine's own empty-pool guard fails silently in production, so this
|
|
// is the only safety net for a content-authoring gap.
|
|
{
|
|
const SITUATIONS = [
|
|
'firstContact', 'proposePeace', 'proposeAlliance', 'declareWar', 'tradeTechOffer',
|
|
'replyPeaceAccept', 'replyPeaceReject', 'replyAllianceAccept', 'replyAllianceReject',
|
|
'replyWarDeclared', 'replyTechAccept', 'replyTechReject',
|
|
'offerPeaceOpener', 'offerAllianceOpener', 'acceptOffer', 'rejectOffer',
|
|
'afterAccepted', 'afterRejected',
|
|
];
|
|
const capable = RULES.speciesList.filter((s) => (s.traits.diplomacy ?? 0) > -100);
|
|
for (const s of capable) {
|
|
const chat = CHAT[s.id];
|
|
check(`VegaChat has an entry for ${s.id}`, !!chat);
|
|
if (!chat) continue;
|
|
for (const mood of ['angry', 'neutral', 'happy']) {
|
|
check(`VegaChat ${s.id}.opener.${mood} is non-empty`, chat.opener?.[mood]?.length > 0);
|
|
}
|
|
for (const key of SITUATIONS) {
|
|
check(`VegaChat ${s.id}.${key} is non-empty`, chat[key]?.length > 0);
|
|
}
|
|
}
|
|
check('lithox has a contact-only pool', CHAT.lithox?.contactOnly?.length > 0);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
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'));
|
|
|
|
// pendingOffers and chatLog are plain-object empire/state fields, same
|
|
// convention as contacted/treaties/attitude — no special-casing in
|
|
// serialize()/deserialize(), so a round-trip has to preserve them for free.
|
|
st.empires[0].pendingOffers[1] = { kind: 'peace', turn: st.turn };
|
|
st.chatLog = { 1: [{ who: 'o', text: 'Contact logged. State function.' }] };
|
|
const json2 = Logic.serialize(st);
|
|
const back2 = Logic.deserialize(json2);
|
|
check('pendingOffers survives a round-trip',
|
|
JSON.stringify(back2.empires[0].pendingOffers) === JSON.stringify(st.empires[0].pendingOffers));
|
|
check('chatLog survives a round-trip',
|
|
JSON.stringify(back2.chatLog) === JSON.stringify(st.chatLog));
|
|
|
|
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`;
|
|
}
|
|
// Trivially bounded (one entry per other empire at most), but the
|
|
// pending-offer engine is new — a leak here would mean an offer is
|
|
// never being cleared on accept/reject/expiry.
|
|
if (Object.keys(e.pendingOffers).length > st.empires.length) {
|
|
return `${label}: empire ${e.idx} has ${Object.keys(e.pendingOffers).length} pending offers`;
|
|
}
|
|
// Contact only ever grows and is always mutual — checkContactAt sets
|
|
// both sides in the same call, so an asymmetric or vanished entry means
|
|
// the star-scoped rewrite regressed.
|
|
for (const otherIdx of Object.keys(e.contacted)) {
|
|
if (e.contacted[otherIdx] && !st.empires[otherIdx]?.contacted[e.idx]) {
|
|
return `${label}: empire ${e.idx} contacted ${otherIdx} is not mutual`;
|
|
}
|
|
}
|
|
}
|
|
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);
|