// 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, techCostFactor, 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'); // Racial skill (MOO1-corrected): Poor/Average/Good/Excellent changes what a // tech COSTS, bucketed from the species' existing techAffinity rating. { check('a Poor-field bucket costs 125%', techCostFactor(RULES.species.ursaal, 'computers') === 1.25); check('an Average-field bucket costs 100%', techCostFactor(RULES.species.human, 'weapons') === 1); check('a Good-field bucket costs 80%', techCostFactor(RULES.species.umbrix, 'computers') === 0.8); check('an Excellent-field bucket costs 60%', techCostFactor(RULES.species.cerebrai, 'computers') === 0.6); const tech = RULES.techs.electroniccomputer; const base = techCost(RULES, tech, 0); const excellent = techCost(RULES, tech, 0, techCostFactor(RULES.species.cerebrai, 'computers')); const poor = techCost(RULES, tech, 0, techCostFactor(RULES.species.ursaal, 'computers')); check('an Excellent species pays less than the base cost', excellent < base, `${excellent} vs ${base}`); check('a Poor species pays more than the base cost', poor > base, `${poor} vs ${base}`); // Every species must land in exactly one of the four buckets for every // field — a NaN or unbucketed affinity would silently zero out cost. for (const s of RULES.speciesList) { for (const f of Object.keys(RULES.techFields)) { const factor = techCostFactor(s, f); check(`${s.id}.${f} cost factor is one of the four MOO1 buckets`, [1.25, 1, 0.8, 0.6].includes(factor), `${factor}`); } } } // Branching-tier rung gating (MOO1-style): a rung clears once ANY sibling // is known, not all of them, and the un-picked sibling stays researchable // (never force-required) afterward. { const branchedField = 'weapons'; const rungTier = RULES.techs.protontorpedoes.tier; // 2 — same tier as neutronpellet const emp = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])), }; const state = { empires: [emp] }; const e = 0; // Grant everything below the branched rung so it's the live edge. for (const t of RULES.techsByField[branchedField]) { if (t.tier < rungTier) emp.known[t.id] = true; } check('branched rung is open before either sibling is known', Logic.canResearch(RULES, state, e, RULES.techs.neutronpellet) && Logic.canResearch(RULES, state, e, RULES.techs.protontorpedoes)); emp.known.neutronpellet = true; // pick ONE sibling const nextTier = RULES.techsByField[branchedField].find((t) => t.tier === rungTier + 1); check('the rung clears after only ONE sibling is known', Logic.canResearch(RULES, state, e, nextTier)); check('the un-picked sibling stays researchable, not force-required', Logic.canResearch(RULES, state, e, RULES.techs.protontorpedoes)); // setResearchTarget switches emp.researching[field] without touching // emp.beakers[field] — RP banked in a field is a shared pool. emp.researching = { weapons: null }; emp.beakers = { weapons: 137 }; check('setResearchTarget accepts an open sibling', Logic.setResearchTarget(RULES, state, e, 'weapons', 'protontorpedoes')); check('setResearchTarget points researching at the chosen tech', emp.researching.weapons === 'protontorpedoes'); check('setResearchTarget leaves banked beakers untouched', emp.beakers.weapons === 137); check('setResearchTarget rejects a tech gated by an uncleared lower rung', !Logic.setResearchTarget(RULES, state, e, 'weapons', 'deathray')); } // Corrected MOO1 availability roll: flat per-tech odds regardless of // racial skill (skill affects cost only, techCostFactor above), 75% for // Cerebrai, 50% for everyone else, and a branched rung can never end up // with zero available techs. Statistical — one roll proves nothing. { const TRIALS = 4000; const rollRate = (spec) => { let availableCount = 0; let totalCount = 0; let everEmpty = false; for (let seed = 1; seed <= TRIALS; seed += 1) { const state = { rngState: (seed * 2654435761) | 0 }; const available = Logic.rollTechAvailability(state, RULES, {}, spec); for (const field of Object.keys(RULES.techFields)) { for (const rung of RULES.techRungsByField[field]) { if (rung.techs.length > 1 && rung.techs.every((t) => !available[t.id])) everEmpty = true; } } for (const t of RULES.techList) { if (t.tier === 0) continue; totalCount += 1; if (available[t.id]) availableCount += 1; } } return { rate: availableCount / totalCount, everEmpty }; }; const human = rollRate(RULES.species.human); check('human (flat 50%) empirical availability rate is close to 50%', human.rate > 0.45 && human.rate < 0.55, `${human.rate.toFixed(3)}`); check('no branched rung is ever left with zero available techs (human roll)', !human.everEmpty); const cerebrai = rollRate(RULES.species.cerebrai); check("Cerebrai's (75%) empirical availability rate is close to 75% and above human's", cerebrai.rate > 0.70 && cerebrai.rate < 0.80 && cerebrai.rate > human.rate, `${cerebrai.rate.toFixed(3)}`); check('no branched rung is ever left with zero available techs (Cerebrai roll)', !cerebrai.everEmpty); } // 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/.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('6c. Research allocation locks'); // --------------------------------------------------------------------------- { // setResearchAlloc has honoured emp.allocLocked since VegaResearchScreen.js // introduced the padlock UI — this is what proves the behaviour that // screen assumes, the same way section 6's padlock block does for colony // sliders. const st = Logic.createGame(RULES, { sizeId: 'small', shapeId: 'spiral', seed: 41, difficultyId: 'normal', speciesIds: ['human', 'kkrix'], humanIndex: 0, }); const emp = st.empires[0]; const fieldIds = Object.keys(RULES.techFields); Logic.setResearchAlloc(RULES, st, 0, 'weapons', 0.4); emp.allocLocked.weapons = true; const pinned = emp.alloc.weapons; Logic.setResearchAlloc(RULES, st, 0, 'computers', 0.5); check('a locked research field holds its share', Math.abs(emp.alloc.weapons - pinned) < 1e-9, `${emp.alloc.weapons} vs ${pinned}`); const sum1 = fieldIds.reduce((t, f) => t + emp.alloc[f], 0); check('locking a research field still normalises the whole allocation to 1', Math.abs(sum1 - 1) < 1e-6, `${sum1}`); // Lock every field but one and confirm the last unlocked field can still // take the full remaining room without NaN/negative fallout. for (const f of fieldIds) emp.allocLocked[f] = true; delete emp.allocLocked.propulsion; Logic.setResearchAlloc(RULES, st, 0, 'propulsion', 1); check('the sole unlocked field absorbs whatever room the locks left', Number.isFinite(emp.alloc.propulsion) && emp.alloc.propulsion >= 0, `${emp.alloc.propulsion}`); const sum2 = fieldIds.reduce((t, f) => t + emp.alloc[f], 0); check('allocation still sums to 1 with five of six fields locked', Math.abs(sum2 - 1) < 1e-6, `${sum2}`); // A locked field's own slider is inert — dragging it directly is a no-op // (the padlock UI disables the control, but setResearchAlloc guards it too // since AI code shares the same function). for (const f of fieldIds) delete emp.allocLocked[f]; emp.allocLocked.forcefields = true; const before = emp.alloc.forcefields; Logic.setResearchAlloc(RULES, st, 0, 'forcefields', 0.9); check('a locked field ignores an attempt to drag its own slider', emp.alloc.forcefields === before, `${emp.alloc.forcefields} vs ${before}`); for (const f of fieldIds) delete emp.allocLocked[f]; } // --------------------------------------------------------------------------- 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', 'askPeople', 'peopleLore', 'askHomeworld', 'homeworldLore', 'askStory', 'storyLore', ]; // Lore reply pools are meant to carry real variety — enforce a minimum // beyond "non-empty" since pickLine's empty-pool fallback is silent and // a single-variant pool would defeat the whole point of these being // richer than a one-line treaty reply. const LORE_REPLY_MIN = { peopleLore: 3, homeworldLore: 3, storyLore: 3 }; 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); } for (const [key, min] of Object.entries(LORE_REPLY_MIN)) { check(`VegaChat ${s.id}.${key} has at least ${min} variants`, (chat[key]?.length ?? 0) >= min); } } 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);