4554 lines
243 KiB
JavaScript
4554 lines
243 KiB
JavaScript
// Headless verification for Master of Vega (Master of Orion clone).
|
||
// node tools/verifyMasterOfVega.js [--quick] [--games=N]
|
||
// Exits non-zero on any failure.
|
||
//
|
||
// 1. Rules integrity: ids unique, tech chains acyclic and fully ranked, every
|
||
// tech matters, hull/weapon/building bounds, frame indexes in range.
|
||
// 2. Procedural art: run the real painters against a fake canvas and assert
|
||
// every frame the rules reference was registered.
|
||
// 3. Galaxy generation: determinism, star counts, lane connectivity, homeworld
|
||
// spacing and habitability, opening-range fairness.
|
||
// 4. Ship Marks: damage and hull monotonic in tech; no NaN anywhere.
|
||
// 5. Combat: determinism, mirror-match fairness, tech/number advantage,
|
||
// auto-resolve agrees with playing it out, no battle hits the round cap.
|
||
// 6. Colony economy: slider normalisation, mandatory ecology, spillover,
|
||
// factory cap, growth, waste recovery.
|
||
// 7. Diplomacy and the Galactic Council: vote arithmetic, refusal, no deadlock.
|
||
// 8. Leaders: hiring, postings, upkeep bounds.
|
||
// 9. Serialisation: round-trip byte-identical, hash stable, version rejected.
|
||
// 10. AI self-play soak: full games terminate, both victory kinds occur,
|
||
// invariants hold, AI turn-time budget.
|
||
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
import { compileRules, techCost, 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 CombatV2 from '../src/games/mastervega/VegaCombatV2.js';
|
||
import { FORMATION_STRATEGIES } from '../src/games/mastervega/VegaFormations.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, pickFitZoomIndex, 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, advisorVideoKey,
|
||
} 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, groupShipDoneEvents } from '../src/games/mastervega/VegaTurnReport.js';
|
||
// Galactic News Network — also Phaser-free (VegaGnnScreen.js is the Phaser
|
||
// half), so its classification/copy/ranking logic is checkable here.
|
||
import * as Gnn from '../src/games/mastervega/VegaGnn.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}`);
|
||
// Field icons split onto their own sheet (data/mastervega-artwork.json's
|
||
// "techfields") — same shape checks as the tech icons above.
|
||
const fieldFrames = RULES.techFieldList.map((f) => f.iconFrame);
|
||
check('tech field icon frames unique', new Set(fieldFrames).size === fieldFrames.length);
|
||
const fieldSheet = artJson.sheets.techfields;
|
||
const fieldCap = fieldSheet.cols * fieldSheet.rows;
|
||
check('tech field icon frames fit their own sheet', Math.max(...fieldFrames) < fieldCap,
|
||
`max ${Math.max(...fieldFrames)} of ${fieldCap}`);
|
||
check('tech field icons and tech icons are on separate sheets', fieldSheet.key !== techSheet.key);
|
||
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);
|
||
|
||
// GNN headline techs — hand-curated, not threshold-based (Brian's ask), so
|
||
// this just sanity-checks the curation stayed within the intended range
|
||
// rather than picking specific ids.
|
||
const headlineTechs = RULES.techList.filter((t) => t.gnnHeadline);
|
||
check('gnnHeadline tech count is within the intended 12-20 range',
|
||
headlineTechs.length >= 12 && headlineTechs.length <= 20, `${headlineTechs.length}`);
|
||
check('gnnHeadline is only ever true or absent', RULES.techList.every(
|
||
(t) => t.gnnHeadline === undefined || t.gnnHeadline === true));
|
||
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'));
|
||
}
|
||
|
||
// The weapons T8 rung is 3-way (second branching pass) — the same
|
||
// rung-clearing logic already proven generic for 2-way rungs above must
|
||
// also hold for 3.
|
||
{
|
||
const emp = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
|
||
const state = { empires: [emp] };
|
||
const e = 0;
|
||
for (const t of RULES.techsByField.weapons) if (t.tier < 8) emp.known[t.id] = true;
|
||
const rung8 = RULES.techRungsByField.weapons.find((r) => r.tier === 8);
|
||
check('the weapons T8 rung has three alternatives', rung8.techs.length === 3, `${rung8.techs.length}`);
|
||
check('all three T8 weapons are researchable before any is known',
|
||
rung8.techs.every((t) => Logic.canResearch(RULES, state, e, t)));
|
||
emp.known[rung8.techs[0].id] = true; // pick one of the three
|
||
const t9 = RULES.techsByField.weapons.find((t) => t.tier === 9);
|
||
check('the T8 rung clears after only ONE of three siblings is known', Logic.canResearch(RULES, state, e, t9));
|
||
check('the two un-picked T8 siblings stay researchable',
|
||
rung8.techs.slice(1).every((t) => Logic.canResearch(RULES, state, e, t)));
|
||
}
|
||
|
||
// fieldTechLevel (per-field level display): hand-computed against real
|
||
// computers tiers (electroniccomputer T0, battlescanner T1, optroniccomputer
|
||
// T2, securecommarray T1 sibling of battlescanner).
|
||
{
|
||
const emp = { known: {} };
|
||
const state = { empires: [emp] };
|
||
const e = 0;
|
||
check('an empire with nothing known is level 0 in every field',
|
||
Object.keys(RULES.techFields).every((f) => Logic.fieldTechLevel(RULES, state, e, f) === 0));
|
||
emp.known.electroniccomputer = true; // T0 freebie only
|
||
check('T0-only (the free opener) is level 0', Logic.fieldTechLevel(RULES, state, e, 'computers') === 0);
|
||
emp.known.battlescanner = true; // T1
|
||
emp.known.optroniccomputer = true; // T2, new frontier: knownTiers=[0,1,2], extra(<2)=2 -> 2*5+2=12
|
||
check('a clean T2 frontier is level 12', Logic.fieldTechLevel(RULES, state, e, 'computers') === 12,
|
||
`${Logic.fieldTechLevel(RULES, state, e, 'computers')}`);
|
||
emp.known.securecommarray = true; // T1 side-pick, sibling of battlescanner
|
||
// knownTiers=[0,1,1,2], highest=2, extra(<2)=3 -> 2*5+3=13
|
||
check('mopping up a T1 side-pick below the frontier adds exactly +1',
|
||
Logic.fieldTechLevel(RULES, state, e, 'computers') === 13,
|
||
`${Logic.fieldTechLevel(RULES, state, e, 'computers')}`);
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// The "close to 50%/75%" claim is measured over UNBRANCHED (size-1) rungs
|
||
// only, deliberately — a branched rung's guaranteed-survivor pass only
|
||
// ever ADDS availability, never removes it, so the aggregate rate across
|
||
// the WHOLE tree drifts upward from the raw roll chance by an amount that
|
||
// scales with how much of the tree happens to be branched (61 of 85
|
||
// non-tier-0 techs sit in a branched rung as of the second branching
|
||
// pass, pulling the human aggregate to ~0.59 — a real, correct effect of
|
||
// the guarantee, not a bug). Measuring size-1 rungs isolates the actual
|
||
// unmodified flat roll, so this check stays accurate regardless of future
|
||
// branching density changes instead of needing re-tuning every time.
|
||
{
|
||
const TRIALS = 4000;
|
||
const rollRate = (spec) => {
|
||
let flatAvailableCount = 0;
|
||
let flatTotalCount = 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.tier === 0) continue;
|
||
if (rung.techs.length === 1) {
|
||
flatTotalCount += 1;
|
||
if (available[rung.techs[0].id]) flatAvailableCount += 1;
|
||
} else if (rung.techs.every((t) => !available[t.id])) {
|
||
everEmpty = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { rate: flatAvailableCount / flatTotalCount, everEmpty };
|
||
};
|
||
const human = rollRate(RULES.species.human);
|
||
check('human (flat 50%) empirical availability rate on unbranched rungs 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 on unbranched rungs 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);
|
||
}
|
||
|
||
// openResearchChoices: the set of techs at a field's current frontier rung
|
||
// that are actually researchable — 0/1/2/3-way cases, using real data.
|
||
{
|
||
const emp = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
|
||
const state = { empires: [emp] };
|
||
const e = 0;
|
||
check('a fresh empire has exactly 1 open choice at computers T0 (the free opener)',
|
||
Logic.openResearchChoices(RULES, state, e, 'computers').length === 1);
|
||
for (const t of RULES.techsByField.computers) if (t.tier < 3) emp.known[t.id] = true;
|
||
const twoWay = Logic.openResearchChoices(RULES, state, e, 'computers');
|
||
check('computers T3 (2-way) reports exactly 2 open choices', twoWay.length === 2, `${twoWay.length}`);
|
||
check('the 2-way choices are neuralscanner and tachyoncomputer',
|
||
new Set(twoWay.map((t) => t.id)).size === 2
|
||
&& twoWay.every((t) => ['neuralscanner', 'tachyoncomputer'].includes(t.id)));
|
||
emp.known.neuralscanner = true;
|
||
// The un-picked sibling is still the lowest open tier — it stays the
|
||
// sole (unambiguous) frontier pick even though T4 is also now
|
||
// independently canResearch-true; openResearchChoices only surfaces the
|
||
// minimum tier among candidates, so T4 isn't "seen" until tachyoncomputer
|
||
// itself resolves (known, in this case never force-required).
|
||
const oneLeft = Logic.openResearchChoices(RULES, state, e, 'computers');
|
||
check('the un-picked sibling remains the sole frontier choice after the other is known',
|
||
oneLeft.length === 1 && oneLeft[0].id === 'tachyoncomputer', `${oneLeft.map((t) => t.id)}`);
|
||
emp.known.tachyoncomputer = true;
|
||
const advanced = Logic.openResearchChoices(RULES, state, e, 'computers');
|
||
check('once both T3 siblings are known, T4 becomes the sole frontier choice',
|
||
advanced.length === 1 && advanced[0].id === 'positroniccomputer', `${advanced.map((t) => t.id)}`);
|
||
|
||
const emp2 = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
|
||
const state2 = { empires: [emp2] };
|
||
for (const t of RULES.techsByField.weapons) if (t.tier < 8) emp2.known[t.id] = true;
|
||
const threeWay = Logic.openResearchChoices(RULES, state2, e, 'weapons');
|
||
check('the weapons T8 3-way rung reports exactly 3 open choices', threeWay.length === 3, `${threeWay.length}`);
|
||
|
||
const emp3 = { known: {}, available: {} };
|
||
const state3 = { empires: [emp3] };
|
||
check('a field with nothing researchable reports zero open choices',
|
||
Logic.openResearchChoices(RULES, state3, e, 'computers').length === 0);
|
||
}
|
||
|
||
// pendingResearchChoices' own filtering predicate (human-owned, source
|
||
// 'research', deduped by field, ambiguous rung only) — exercised directly
|
||
// since the real method lives on the Phaser scene, mirroring its exact
|
||
// filter/dedupe logic against a synthetic event list.
|
||
{
|
||
const emp = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
|
||
for (const t of RULES.techsByField.computers) if (t.tier < 3) emp.known[t.id] = true;
|
||
const state = { empires: [emp, { known: {}, available: {} }] };
|
||
const humanIdx = 0;
|
||
const notable = [
|
||
{ type: 'techDone', empire: humanIdx, techId: 'optroniccomputer', source: 'research' }, // ambiguous field
|
||
{ type: 'techDone', empire: humanIdx, techId: 'optroniccomputer', source: 'research' }, // duplicate field, should dedupe
|
||
{ type: 'techDone', empire: humanIdx, techId: 'lasercannon', source: 'trade' }, // wrong source
|
||
{ type: 'techDone', empire: 1, techId: 'lasercannon', source: 'research' }, // wrong empire
|
||
{ type: 'contact', empire: humanIdx }, // wrong type
|
||
];
|
||
const pending = [];
|
||
const seen = new Set();
|
||
for (const ev of notable) {
|
||
if (ev.type !== 'techDone' || ev.empire !== humanIdx || ev.source !== 'research') continue;
|
||
const field = RULES.techs[ev.techId].field;
|
||
if (seen.has(field)) continue;
|
||
seen.add(field);
|
||
const choices = Logic.openResearchChoices(RULES, state, humanIdx, field);
|
||
if (choices.length > 1) pending.push({ field, choices, completedTechId: ev.techId });
|
||
}
|
||
check('pendingResearchChoices keeps exactly the one ambiguous human/research field',
|
||
pending.length === 1 && pending[0].field === 'computers', JSON.stringify(pending.map((p) => p.field)));
|
||
check('the surviving entry carries the completed tech id for the prompt copy',
|
||
pending[0].completedTechId === 'optroniccomputer');
|
||
}
|
||
|
||
// 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)');
|
||
|
||
// Colonies-screen advisor loops (VegaColoniesScreen.js), one per species.
|
||
// Declared-or-null like shipVideos/audienceVideos: a gap is legal (falls
|
||
// back to the species portrait), but a key that does not match
|
||
// advisorVideoKey() loads a video the screen will never find.
|
||
const advisorVids = artJson.advisorVideos ?? {};
|
||
const advisorVidIds = Object.keys(advisorVids).filter((k) => !k.startsWith('_'));
|
||
for (const id of advisorVidIds) {
|
||
check(`advisorVideos entry ${id} is a known species`, !!RULES.species[id]);
|
||
check(`advisorVideos ${id} key matches the loader's key`,
|
||
advisorVids[id]?.key === advisorVideoKey(id), `${advisorVids[id]?.key} vs ${advisorVideoKey(id)}`);
|
||
if (advisorVids[id]?.path) {
|
||
check(`advisorVideos ${id} file exists`, existsSync(join(root, advisorVids[id].path)),
|
||
advisorVids[id].path);
|
||
check(`advisorVideos ${id} is an mp4`, advisorVids[id].path.endsWith('.mp4'));
|
||
}
|
||
}
|
||
for (const s of RULES.speciesList) {
|
||
check(`species ${s.id} has an advisorVideos entry (possibly null)`, advisorVidIds.includes(s.id));
|
||
}
|
||
const advisorClips = advisorVidIds.filter((id) => advisorVids[id]?.path).length;
|
||
console.log(` (${advisorClips}/${RULES.speciesList.length} `
|
||
+ 'colonial advisor clips recorded; the rest fall back to the species portrait)');
|
||
|
||
// …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(' '));
|
||
// Same reasoning again: the Colonies screen only ever needs the human
|
||
// player's own species, so the other nine advisor clips (once recorded)
|
||
// must not ride the game-room load either.
|
||
const eagerAdvisor = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-advisor-'));
|
||
check('colonial advisor clips are not eager-loaded', eagerAdvisor.length === 0,
|
||
eagerAdvisor.map((d) => d.key).join(' '));
|
||
// The cue is the opposite case: small, and it has to be ready the instant
|
||
// the vignette opens.
|
||
check('the colony-founding cue IS eager-loaded',
|
||
eager.some((d) => d.type === 'audio' && d.key === 'vega-colony-cue'));
|
||
// Nothing else lost its ride while that was being arranged.
|
||
check('species portraits are still eager-loaded',
|
||
eager.some((d) => d.type === 'video' && d.key === speciesVideoKey('human')));
|
||
check('ship commander clips are still eager-loaded',
|
||
eager.some((d) => d.type === 'video' && d.key === shipVideoKey('human', 'scout')));
|
||
}
|
||
|
||
// sourceWidth() — the guard every video in this game is scaled through.
|
||
//
|
||
// A Phaser Video carries a placeholder 256x256 size until its first frame
|
||
// decodes, so the obvious `obj.width || SRC` never fires and divides by 256.
|
||
// That is invisible while every clip IS 256 px (placeholder and fallback
|
||
// agree) and is exactly what opened the 960x544 colony clips at 2.5x. Pure
|
||
// function, so the distinction is assertable here rather than in a browser.
|
||
{
|
||
const undecoded = { type: 'Video', videoTexture: null, width: 256 };
|
||
const decoded = { type: 'Video', videoTexture: {}, width: 960 };
|
||
check('sourceWidth ignores an undecoded Video\'s placeholder width',
|
||
sourceWidth(undecoded, 960) === 960, `${sourceWidth(undecoded, 960)}`);
|
||
check('sourceWidth trusts a decoded Video',
|
||
sourceWidth(decoded, 256) === 960, `${sourceWidth(decoded, 256)}`);
|
||
check('sourceWidth trusts an Image outright',
|
||
sourceWidth({ type: 'Image', width: 192 }, 256) === 192);
|
||
check('sourceWidth still falls back on a zero width',
|
||
sourceWidth({ type: 'Image', width: 0 }, 256) === 256);
|
||
// The scale the vignette actually sets, both ways round: a 640px window on
|
||
// a 960px clip is 0.667, and never the 2.5 the placeholder would give.
|
||
check('a 640px clip window scales a 960px clip by 2/3',
|
||
Math.abs(640 / sourceWidth(undecoded, 960) - 0.6667) < 0.001);
|
||
}
|
||
|
||
// The just-in-time loader reads its path straight out of the artwork JSON
|
||
// rather than a descriptor, so the two must agree on where a clip lives.
|
||
for (const id of colVidIds) {
|
||
if (!colVids[id]?.path) continue;
|
||
check(`colonyVideos ${id} path is under the vega video folder`,
|
||
colVids[id].path.startsWith('assets/videos/vega/'), colVids[id].path);
|
||
}
|
||
|
||
// The founding cue the vignette ducks the soundtrack for. Declared in the
|
||
// asset manifest rather than the artwork JSON, so this is the only place the
|
||
// path gets checked.
|
||
check('colony-founding cue exists',
|
||
existsSync(join(root, 'assets/music/vega/colony.mp3')), 'assets/music/vega/colony.mp3');
|
||
|
||
const recorded = vidIds.filter((id) => vids[id].path).length;
|
||
const stillCount = stillIds.filter((id) => stills[id].path).length;
|
||
console.log(` (${recorded}/${RULES.speciesList.length} portraits are video, `
|
||
+ `${stillCount} have a still; the rest fall back to the painted sheet)`);
|
||
|
||
// Species speech. Unlike the portraits this is NOT declared in the artwork
|
||
// manifest — ui/SpeechQueue.js streams it straight from
|
||
// assets/speech/<clip>.mp3 without going through Phaser's loader — so the
|
||
// filename convention itself is the contract, and this is the only place it
|
||
// gets enforced.
|
||
for (const sp of RULES.speciesList) {
|
||
const clip = speciesSpeechClip(sp.id);
|
||
check(`species ${sp.id} speech clip exists`,
|
||
existsSync(join(root, 'assets/speech', `${clip}.mp3`)), `${clip}.mp3`);
|
||
}
|
||
|
||
for (const [name, clip] of Object.entries(UI_SPEECH)) {
|
||
check(`UI speech clip ${name} exists`,
|
||
existsSync(join(root, 'assets/speech', `${clip}.mp3`)), `${clip}.mp3`);
|
||
}
|
||
|
||
// Soundtrack: every track the JSON names must be on disk, or the game boots
|
||
// into silence with a console error and nothing says why. Structure is
|
||
// per-category (VegaMusic.js's _poolFor reads d.fallback/d.menu/d.peace/
|
||
// d.combat/d.diplomacy.default/d.diplomacy.bySpecies[id], each a
|
||
// { volume?, tracks: [...] } pool) — not one flat top-level `tracks` array.
|
||
const music = JSON.parse(readFileSync(join(root, 'data/masterofvega-music.json'), 'utf8'));
|
||
const musicPools = [
|
||
['fallback', music.fallback], ['menu', music.menu], ['peace', music.peace], ['combat', music.combat],
|
||
['diplomacy.default', music.diplomacy?.default],
|
||
...Object.entries(music.diplomacy?.bySpecies ?? {}).map(([id, pool]) => [`diplomacy.bySpecies.${id}`, pool]),
|
||
];
|
||
check('soundtrack declares at least one track pool with tracks',
|
||
musicPools.some(([, pool]) => Array.isArray(pool?.tracks) && pool.tracks.length > 0));
|
||
// Every other pool falls back to this one when empty (_poolFor), so an
|
||
// empty fallback means the game can still boot into total silence.
|
||
check('soundtrack fallback pool has tracks',
|
||
Array.isArray(music.fallback?.tracks) && music.fallback.tracks.length > 0);
|
||
for (const [poolName, pool] of musicPools) {
|
||
for (const t of pool?.tracks ?? []) {
|
||
check(`soundtrack ${poolName} file ${t.file} exists`, existsSync(join(root, 'assets/music', t.file)), t.file);
|
||
check(`soundtrack ${poolName} ${t.file} has artist and title`, !!t.artist && !!t.title);
|
||
}
|
||
if (typeof pool?.volume === 'number') {
|
||
check(`soundtrack ${poolName} volume in range`, pool.volume > 0 && pool.volume <= 1, `${pool.volume}`);
|
||
}
|
||
}
|
||
|
||
// Same "not eager-loaded" contract as the colony/audience video clips
|
||
// above: a given playthrough may contact only a handful of the game's nine
|
||
// species, if any, so assetManifest.js's vegaMusicFrom() must not resolve
|
||
// diplomacy.bySpecies at game-room entry — VegaMusic's own on-demand
|
||
// `new Audio(...)` at setDiplomacy() time is the actual fetch.
|
||
{
|
||
const stub = { cache: { json: { get: (k) => (k === 'masterofvega-music' ? music : null) } } };
|
||
const eagerMusic = resolveGameAssets(stub, 'mastervega').filter((d) => d.type === 'audio');
|
||
check('diplomacy bySpecies tracks are not eager-loaded',
|
||
!eagerMusic.some((d) => d.key.includes('-diplomacy-') && !d.key.includes('-diplomacy-default-')),
|
||
eagerMusic.map((d) => d.key).join(' '));
|
||
check('the diplomacy default pool is still eager-loaded',
|
||
eagerMusic.some((d) => d.key.includes('-diplomacy-default-')));
|
||
check('fallback/menu/peace/combat pools are still eager-loaded',
|
||
['fallback', 'menu', 'peace', 'combat'].every((p) => eagerMusic.some((d) => d.key.includes(`-${p}-`))));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
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);
|
||
|
||
// A warship hull with literally zero weapon techs known is a real reachable
|
||
// state (Mark is an average across all five fields, so the other four can
|
||
// carry it well past Mark I) — without a fallback it deals 0 damage
|
||
// forever, silently. bestComponents must synthesize a minimal weapon.
|
||
{
|
||
const noTech = Ships.bestComponents(RULES, {});
|
||
check('an empire with zero known techs still has exactly one fallback weapon',
|
||
noTech.allWeapons.length === 1, `${noTech.allWeapons.length}`);
|
||
check('the fallback weapon is the documented baseline, not a real tech',
|
||
noTech.weapon?.id === 'baselinemassdriver', noTech.weapon?.id);
|
||
const bareFrigate = Ships.designFor(RULES, {}, 'frigate', RULES.species.human.traits);
|
||
check('a warship with zero known techs still deals damage',
|
||
bareFrigate.damage > 0, `${bareFrigate.damage}`);
|
||
check('non-warship hulls stay unarmed even with zero known techs (hull.space gates it)',
|
||
Ships.designFor(RULES, {}, 'scout', RULES.species.human.traits).damage === 0);
|
||
// The fallback must be strictly worse than the real tier-0 weapon, so
|
||
// researching it (or anything else) is still a genuine upgrade.
|
||
const lasercannonOnly = Ships.bestComponents(RULES, { lasercannon: true });
|
||
check('the fallback is replaced (not stacked) once a real weapon is known',
|
||
lasercannonOnly.allWeapons.length === 1 && lasercannonOnly.weapon?.id !== 'baselinemassdriver',
|
||
JSON.stringify(lasercannonOnly.allWeapons.map((w) => w.id)));
|
||
const realFrigate = Ships.designFor(RULES, { lasercannon: true }, 'frigate', RULES.species.human.traits);
|
||
check('the real tier-0 weapon out-damages the fallback',
|
||
realFrigate.damage > bareFrigate.damage, `${realFrigate.damage} vs ${bareFrigate.damage}`);
|
||
}
|
||
|
||
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, source.id, second.id, 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, second } = mkTwoColonyGame(4242);
|
||
if (target >= 0) {
|
||
const source = Logic.colonyAt(st, home);
|
||
const cap = Logic.maxSendablePopulation(source);
|
||
const ok = Logic.sendPopulation(RULES, st, 0, source.id, second.id, 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, second } = mkTwoColonyGame(4343);
|
||
if (target >= 0) {
|
||
const source = Logic.colonyAt(st, home);
|
||
const enemyColony = Logic.colonyAt(st, st.galaxy.homeIdx[1]);
|
||
check('sending from a colony you do not own is refused',
|
||
Logic.sendPopulation(RULES, st, 0, enemyColony.id, second.id, 1) === false);
|
||
check('sending to a star with no colony of yours is refused',
|
||
Logic.sendPopulation(RULES, st, 0, source.id, enemyColony.id, 1) === false);
|
||
check('sending to the same star is refused',
|
||
Logic.sendPopulation(RULES, st, 0, source.id, source.id, 1) === false);
|
||
source.pop = 0.5;
|
||
check('a colony already at the floor has nothing left to send',
|
||
Logic.sendPopulation(RULES, st, 0, source.id, second.id, 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, second } = mkTwoColonyGame(4444);
|
||
if (target >= 0) {
|
||
Logic.sendPopulation(RULES, st, 0, Logic.colonyAt(st, home).id, second.id, 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);
|
||
}
|
||
}
|
||
|
||
// A star hosting two colonies (different empires, different orbits) used
|
||
// to break Bombard/Invade/invasionForecast whenever the non-hostile one
|
||
// came first in state.colonies: colonyAt() is orbit-blind, so an ally
|
||
// sharing a system with the real target silently ate every click. Brian
|
||
// hit this directly — allied Rrashaa on one planet, hostile Lithox on
|
||
// another, orbital superiority confirmed, both actions refused anyway.
|
||
{
|
||
const stM = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'elliptical', seed: 606, difficultyId: 'normal',
|
||
speciesIds: ['human', 'rrashaa', 'kkrix'], humanIndex: 0,
|
||
});
|
||
stM.rules = RULES;
|
||
let starIdx = -1;
|
||
for (let i = 0; i < stM.galaxy.stars.length; i += 1) {
|
||
if ((stM.galaxy.stars[i].planets?.length ?? 0) >= 2 && !stM.galaxy.homeIdx.includes(i)) { starIdx = i; break; }
|
||
}
|
||
check('the test galaxy has a star with 2+ planets to place two colonies on', starIdx >= 0);
|
||
if (starIdx >= 0) {
|
||
// Ally (rrashaa, empire 1) founded FIRST so it lands first in
|
||
// state.colonies — colonyAt()'s old first-match behaviour would have
|
||
// picked this one regardless of which orbit was actually being acted
|
||
// on. Different populations so a forecast computed against the wrong
|
||
// colony is numerically distinguishable from one computed correctly.
|
||
const allyColony = Logic.foundColony(RULES, stM, 1, starIdx, 0, 20);
|
||
const enemyColony = Logic.foundColony(RULES, stM, 2, starIdx, 1, 10);
|
||
Diplo.declareWar(RULES, stM, 0, 2); // human at war with kkrix only; rrashaa stays unallied-but-not-at-war
|
||
Logic.addFleet(RULES, stM, 0, starIdx, [
|
||
{ hullId: 'frigate', count: 3, mark: 1 },
|
||
{ hullId: 'transport', count: 1, mark: 1 },
|
||
]);
|
||
|
||
check('colonyAt (orbit-blind) resolves to the ally here, confirming the bug precondition is real',
|
||
Logic.colonyAt(stM, starIdx)?.empireIdx === 1);
|
||
|
||
const noOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx);
|
||
const byEnemyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, enemyColony.orbit);
|
||
const byAllyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, allyColony.orbit);
|
||
check('invasionForecast without an orbit falls back to the at-war colony, not the ally',
|
||
noOrbit?.troops > 0 && noOrbit.defenders === byEnemyOrbit?.defenders,
|
||
`${JSON.stringify(noOrbit)} vs ${JSON.stringify(byEnemyOrbit)}`);
|
||
check('an explicit orbit actually changes which colony is targeted',
|
||
byAllyOrbit?.defenders !== byEnemyOrbit?.defenders,
|
||
`ally ${byAllyOrbit?.defenders} vs enemy ${byEnemyOrbit?.defenders}`);
|
||
|
||
check('invade pinned to the ally\'s own orbit correctly refuses (not at war), even though it is a real colony here',
|
||
Logic.invade(RULES, stM, 0, starIdx, allyColony.orbit) === null);
|
||
|
||
// The actual reported bug: no orbit passed at all (VegaAI.js's calling
|
||
// convention), with the ally still ahead of the enemy in state.colonies.
|
||
const bombardResult = Logic.bombard(RULES, stM, 0, starIdx);
|
||
check('bombard without an orbit correctly reaches the hostile colony sharing the star',
|
||
!!bombardResult, JSON.stringify(bombardResult));
|
||
}
|
||
}
|
||
|
||
// --- bombard() is capped at once per (attacker, colony) per turn. Without
|
||
// it, VegaSystemView.js's Bombard button could be clicked any number of
|
||
// times in a single turn — warships aren't consumed the way invade()'s
|
||
// transports are, so nothing else stopped a colony being wiped out
|
||
// instantly regardless of fleet size.
|
||
{
|
||
const stB = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'cluster', seed: 4747, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0,
|
||
});
|
||
stB.rules = RULES;
|
||
let starIdxB = -1;
|
||
for (let i = 0; i < stB.galaxy.stars.length; i += 1) {
|
||
if ((stB.galaxy.stars[i].planets?.length ?? 0) >= 1 && !stB.galaxy.homeIdx.includes(i)) { starIdxB = i; break; }
|
||
}
|
||
check('bombard-cap test galaxy has a spare star', starIdxB >= 0);
|
||
if (starIdxB >= 0) {
|
||
const target = Logic.foundColony(RULES, stB, 1, starIdxB, 0, 500); // big enough to survive one hit
|
||
Diplo.declareWar(RULES, stB, 0, 1);
|
||
Logic.addFleet(RULES, stB, 0, starIdxB, [{ hullId: 'frigate', count: 3, mark: 1 }]);
|
||
|
||
const first = Logic.bombard(RULES, stB, 0, starIdxB);
|
||
check('bombard-cap fixture: first bombard succeeds and does not destroy the colony',
|
||
!!first && !first.destroyed, JSON.stringify(first));
|
||
check('bombardedThisTurn reports true immediately after a successful bombard',
|
||
Logic.bombardedThisTurn(stB, 0, target) === true);
|
||
|
||
const popAfterFirst = target.pop;
|
||
const second = Logic.bombard(RULES, stB, 0, starIdxB);
|
||
check('a second bombard on the same colony in the same turn is refused', second === null);
|
||
check('a refused repeat bombard costs no additional population', target.pop === popAfterFirst);
|
||
|
||
stB.turn += 1;
|
||
const nextTurn = Logic.bombard(RULES, stB, 0, starIdxB);
|
||
check('bombard works again on a later turn', !!nextTurn);
|
||
check('a later-turn bombard actually reduces population further', target.pop < popAfterFirst);
|
||
|
||
// The cap is per (attacker, colony), not global — a different attacker
|
||
// bombarding the same colony in the same turn is unaffected.
|
||
Diplo.declareWar(RULES, stB, 2, 1);
|
||
Logic.addFleet(RULES, stB, 2, starIdxB, [{ hullId: 'frigate', count: 3, mark: 1 }]);
|
||
const popBeforeOther = target.pop;
|
||
const otherAttacker = Logic.bombard(RULES, stB, 2, starIdxB);
|
||
check('a second, different attacker can still bombard the same colony in the same turn',
|
||
!!otherAttacker, JSON.stringify(otherAttacker));
|
||
check('the other attacker\'s bombard actually lands', target.pop < popBeforeOther);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
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);
|
||
|
||
// Cloaked (Stealth Field) and singularity (Black Hole Generator) used to be
|
||
// computed onto the design and never read anywhere in combat. Isolate each
|
||
// flag by hand-cloning an otherwise-identical fully-teched design — passing
|
||
// `design` directly on a ship entry skips the known-tech derivation, so the
|
||
// A/B pair differs in exactly one field.
|
||
const baseDesign = Ships.designFor(RULES, techsUpTo(9), 'cruiser', RULES.species.human.traits);
|
||
check('a fully-teched design has both flags available to strip', baseDesign.cloaked && baseDesign.singularity);
|
||
const withoutCloak = { ...baseDesign, cloaked: false };
|
||
const withoutSingularity = { ...baseDesign, singularity: false };
|
||
|
||
const abBattle = (aDesign, dDesign, seed) => Combat.runBattle(Combat.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 9), ships: [{ hullId: 'cruiser', count: 5, design: aDesign }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmp('human', 9), ships: [{ hullId: 'cruiser', count: 5, design: dDesign }] },
|
||
rnd: mulberry32(seed),
|
||
}));
|
||
// Both effects are deliberately modest — calibrated (see VegaCombat.js
|
||
// comments) to land near what a mild species combat-trait bonus is worth in
|
||
// this exact harness (~62%), nowhere near the >0.8 "decisive" bar used
|
||
// above for a two-tier tech lead or a numbers advantage.
|
||
const cloakRate = rate((s) => abBattle(baseDesign, withoutCloak, s));
|
||
check('cloak makes an otherwise-identical fleet harder to beat, but not decisively',
|
||
cloakRate > 0.52 && cloakRate < 0.8, `attacker (cloaked) won ${(cloakRate * 100).toFixed(1)}%`);
|
||
const singularityRate = rate((s) => abBattle(baseDesign, withoutSingularity, s));
|
||
check('singularity shield-pierce makes an otherwise-identical fleet harder to beat, but not decisively',
|
||
singularityRate > 0.52 && singularityRate < 0.8, `attacker (singularity) won ${(singularityRate * 100).toFixed(1)}%`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
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 Focus autopilot: exercised directly, each focus in isolation
|
||
// on an empty queue. Runs before the restore below, so nothing here needs
|
||
// to worry about leaving the colony as it was found.
|
||
{
|
||
const c = st.colonies[0];
|
||
check('a freshly-founded colony defaults to manual focus',
|
||
st.colonies.every((col) => col.focus === 'manual'));
|
||
|
||
c.queue.length = 0;
|
||
c.focus = 'manual';
|
||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||
check('manual focus never auto-queues anything', c.queue.length === 0);
|
||
|
||
c.queue.length = 0;
|
||
c.focus = 'improvement';
|
||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||
check('colony improvement queues a building when the queue is empty',
|
||
c.queue.length === 1 && c.queue[0].kind === 'building', JSON.stringify(c.queue[0]));
|
||
|
||
c.queue.length = 0;
|
||
c.focus = 'fleet';
|
||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||
check('fleet production queues a warship hull',
|
||
c.queue.length === 1 && c.queue[0].kind === 'ship' && RULES.hulls[c.queue[0].id]?.role === 'warship',
|
||
c.queue[0]?.id);
|
||
|
||
// With every research building's prereq stripped, Research Focus has
|
||
// nothing it is allowed to queue — the queue must stay empty rather than
|
||
// queuing something else or throwing.
|
||
c.queue.length = 0;
|
||
c.focus = 'research';
|
||
const emp = st.empires[c.empireIdx];
|
||
const savedKnown = { ...emp.known };
|
||
for (const b of RULES.buildingList.filter((bb) => bb.channel === 'research')) {
|
||
if (b.prereq) delete emp.known[b.prereq];
|
||
}
|
||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||
check('research focus leaves the queue empty when no research buildings are available',
|
||
c.queue.length === 0);
|
||
emp.known = savedKnown;
|
||
|
||
c.focus = 'manual';
|
||
}
|
||
|
||
// --- Fleet Production's weighted 4:3:2:1 mix (frigate:destroyer:cruiser:
|
||
// battleship). Grants full weapon tech and maxes the ships slider so every
|
||
// warship hull clears pickFleet's damage>0 and affordability gates —
|
||
// otherwise this would be testing which hulls got filtered out, not the
|
||
// mix logic itself.
|
||
{
|
||
const c = st.colonies[0];
|
||
const emp = st.empires[c.empireIdx];
|
||
const savedKnown = { ...emp.known };
|
||
const savedSliders = { ...c.sliders };
|
||
const savedFleets = st.fleets;
|
||
const savedPop = c.pop;
|
||
for (const t of RULES.techsByField.weapons) emp.known[t.id] = true;
|
||
// empireDesign memoizes per-empire designs keyed on emp.techsKnown, which
|
||
// a direct emp.known mutation (unlike Logic.grantTech) never bumps — null
|
||
// the cache out by hand or empireDesign silently keeps returning the
|
||
// pre-grant (no-weapon) design below.
|
||
emp._designs = null; emp._designsAt = -1;
|
||
emp._comps = null; emp._compsAt = -1;
|
||
Logic.setSlider(RULES, st, c, 'ships', 1);
|
||
// A fresh homeworld's production can't clear battleship's cost*15
|
||
// affordability gate on its own (that's a real, correct game-balance
|
||
// fact, not a bug) — overridden here purely so this check can exercise
|
||
// all four warship hulls at once rather than skipping the top of the mix.
|
||
c.pop = Logic.colonyMaxPop(RULES, st, c) * 3;
|
||
const warshipIds = RULES.hullList.filter((h) => h.role === 'warship').map((h) => h.id);
|
||
const allAffordable = warshipIds.every((id) => {
|
||
const d = Logic.empireDesign(RULES, st, c.empireIdx, id);
|
||
return d.damage > 0 && d.cost <= Logic.colonyBuildRate(RULES, st, c) * 15;
|
||
});
|
||
|
||
if (allAffordable) {
|
||
// Unambiguous minimum: battleship is the only hull under its target
|
||
// share (ratio 0 vs 1.0 for the other three), so it must win regardless
|
||
// of score tie-breaking.
|
||
st.fleets = [];
|
||
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx,
|
||
[{ hullId: 'frigate', count: 4, mark: 1 }, { hullId: 'destroyer', count: 3, mark: 1 },
|
||
{ hullId: 'cruiser', count: 2, mark: 1 }]);
|
||
c.queue.length = 0;
|
||
c.focus = 'fleet';
|
||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||
check('fleet mix picks the sole hull strictly below its target share',
|
||
c.queue[0]?.id === 'battleship', c.queue[0]?.id);
|
||
|
||
// A full 10-ship cycle from empty must land exactly on 4:3:2:1 — the
|
||
// weighted round-robin's defining property — no matter how the score
|
||
// tie-break orders picks along the way.
|
||
st.fleets = [];
|
||
const counts = { frigate: 0, destroyer: 0, cruiser: 0, battleship: 0 };
|
||
for (let i = 0; i < 10; i += 1) {
|
||
c.queue.length = 0;
|
||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||
const picked = c.queue[0]?.id;
|
||
counts[picked] = (counts[picked] ?? 0) + 1;
|
||
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx, [{ hullId: picked, count: 1, mark: 1 }]);
|
||
}
|
||
check('one full mix cycle (10 ships) lands exactly on 4:3:2:1',
|
||
counts.frigate === 4 && counts.destroyer === 3 && counts.cruiser === 2 && counts.battleship === 1,
|
||
JSON.stringify(counts));
|
||
} else {
|
||
check('fleet mix hull-set is affordable/tech-eligible for the 4:3:2:1 checks above (skipped)',
|
||
true, 'skipped: not all warship hulls cleared damage/affordability at this budget');
|
||
}
|
||
|
||
st.fleets = savedFleets;
|
||
emp.known = savedKnown;
|
||
emp._designs = null; emp._designsAt = -1;
|
||
emp._comps = null; emp._compsAt = -1;
|
||
c.sliders = savedSliders;
|
||
c.pop = savedPop;
|
||
c.queue.length = 0;
|
||
c.focus = 'manual';
|
||
}
|
||
|
||
// --- Advisor recommendations: recommendColonyFocus / recommendAllocationFocus
|
||
// / checkAdvisorRecommendations. Uses a throwaway state so nothing here
|
||
// needs restoring afterward.
|
||
{
|
||
const st2 = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'spiral', seed: 77, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0,
|
||
});
|
||
st2.rules = RULES;
|
||
const c = st2.colonies[0];
|
||
const emp2 = st2.empires[c.empireIdx];
|
||
const COLONY_FOCUS_VALUES = new Set(['manual', 'improvement', 'research', 'fleet', 'growth', 'trade', 'defense']);
|
||
const ALLOC_FOCUS_KEYS = new Set(['default', 'research', 'growth', 'production', 'military']);
|
||
|
||
check('a fresh colony has no advisor tracking armed yet',
|
||
c.advisor.focusQuietUntil === null && c.advisor.allocQuietUntil === null
|
||
&& c.advisor.focusNotifiedValue === null && c.advisor.allocKey === null && c.advisor.allocNotifiedKey === null);
|
||
|
||
// Growth: starve population well below its ceiling and grant the
|
||
// cheapest growth building's prereq so one is actually eligible.
|
||
const maxPop = Logic.colonyMaxPop(RULES, st2, c);
|
||
c.pop = maxPop * 0.3;
|
||
emp2.known.controlledbarren = true; // unlocks cloningcenter
|
||
let rec = Logic.recommendColonyFocus(RULES, st2, c);
|
||
check('low population recommends Population Growth', rec.value === 'growth', rec.value);
|
||
let recA = Logic.recommendAllocationFocus(RULES, st2, c);
|
||
check('low population recommends the Population Growth allocation preset', recA.key === 'growth', recA.key);
|
||
c.pop = maxPop;
|
||
|
||
// Improvement: cap factories out. automatedfactory has no prereq, so
|
||
// nothing extra needs granting.
|
||
const factoryCap = Logic.colonyFactoryCap(RULES, st2, c);
|
||
c.factories = factoryCap;
|
||
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
||
check('capped factories recommend Colony Improvement', rec.value === 'improvement', rec.value);
|
||
// Neither "capped" (colony focus) nor "still building out" (allocation
|
||
// focus) — clears both factory rungs so the fleet check below is the
|
||
// first thing either ladder actually trips on.
|
||
c.factories = Math.round(factoryCap * 0.8);
|
||
|
||
// Fleet: a fleetless empire this early is always below the turn-scaled threshold.
|
||
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
||
check('a fleetless empire this early recommends Fleet Production', rec.value === 'fleet', rec.value);
|
||
recA = Logic.recommendAllocationFocus(RULES, st2, c);
|
||
check('a fleetless empire this early recommends the Military Buildup allocation preset',
|
||
recA.key === 'military', recA.key);
|
||
|
||
// A strong fleet moves the recommendation on.
|
||
Logic.addFleet(RULES, st2, c.empireIdx, c.starIdx, [{ hullId: 'battleship', mark: 1, count: 10 }]);
|
||
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
||
check('a strong fleet no longer recommends Fleet Production', rec.value !== 'fleet', rec.value);
|
||
check('every recommendColonyFocus value is a real Colony Focus option', COLONY_FOCUS_VALUES.has(rec.value), rec.value);
|
||
check('every recommendAllocationFocus key is a real Allocation Focus preset',
|
||
ALLOC_FOCUS_KEYS.has(Logic.recommendAllocationFocus(RULES, st2, c).key));
|
||
|
||
// checkAdvisorRecommendations: silent until the player has picked
|
||
// something (both Quiet fields still null).
|
||
Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx);
|
||
check('advisors stay silent until the player has picked something',
|
||
!st2.events.some((ev) => ev.type === 'advisorRecommendation'));
|
||
|
||
// Arm tracking, force the current setting to diverge from the live
|
||
// recommendation, and confirm exactly one event fires.
|
||
c.advisor.focusQuietUntil = st2.turn;
|
||
c.advisor.allocQuietUntil = st2.turn;
|
||
const before = Logic.recommendColonyFocus(RULES, st2, c);
|
||
c.focus = before.value === 'research' ? 'trade' : 'research';
|
||
Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx);
|
||
let fired = st2.events.filter((ev) => ev.type === 'advisorRecommendation');
|
||
check('a diverging recommendation fires exactly one event', fired.length === 1, `${fired.length}`);
|
||
check('the event names this colony', fired[0]?.colonyId === c.id);
|
||
|
||
// Calling it again next turn with nothing changed must not repeat.
|
||
st2.turn += 1;
|
||
Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx);
|
||
check('an unchanged still-diverging recommendation does not fire again',
|
||
st2.events.filter((ev) => ev.type === 'advisorRecommendation').length === 1);
|
||
|
||
// Matching the recommendation clears the dedup marker, so a LATER
|
||
// divergence is reported fresh.
|
||
c.focus = before.value;
|
||
st2.turn += 1;
|
||
Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx);
|
||
check('matching the recommendation clears the notified marker', c.advisor.focusNotifiedValue === null);
|
||
c.focus = c.focus === 'research' ? 'trade' : 'research';
|
||
st2.turn += 1;
|
||
Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx);
|
||
fired = st2.events.filter((ev) => ev.type === 'advisorRecommendation');
|
||
check('a fresh divergence after matching fires again', fired.length === 2, `${fired.length}`);
|
||
|
||
// The report layer must render this without throwing and name the colony.
|
||
const desc = describeEvent(RULES, st2, fired[fired.length - 1]);
|
||
check('advisorRecommendation renders a headline naming the colony',
|
||
desc.headline.includes(c.name ?? ''), desc.headline);
|
||
|
||
// empireColoniesByStar — the grouping VegaColoniesScreen.js's
|
||
// spreadsheet needs: one group per star this empire holds, ordered by
|
||
// that star's total population (highest first). A minimal synthetic
|
||
// second colony is enough — the function only reads empireIdx/starIdx/pop.
|
||
{
|
||
const otherStar = st2.galaxy.stars.findIndex((s, i) => i !== c.starIdx
|
||
&& s.planets.some((p) => RULES.planetTypes[p.typeId]?.colonizable));
|
||
check('a second colonisable star exists for the grouping test', otherStar >= 0);
|
||
if (otherStar >= 0) {
|
||
st2.colonies.push({ id: -1, empireIdx: c.empireIdx, starIdx: otherStar, pop: c.pop + 50 });
|
||
const groups = Logic.empireColoniesByStar(st2, c.empireIdx);
|
||
check('empireColoniesByStar returns one group per distinct star',
|
||
groups.length === new Set(Logic.empireColonies(st2, c.empireIdx).map((cc) => cc.starIdx)).size);
|
||
check('empireColoniesByStar orders star groups by total population, highest first',
|
||
groups.every((g, i) => i === 0 || groups[i - 1].totalPop >= g.totalPop));
|
||
check("empireColoniesByStar only includes this empire's colonies",
|
||
groups.every((g) => g.colonies.every((cc) => cc.empireIdx === c.empireIdx)));
|
||
st2.colonies.pop();
|
||
}
|
||
}
|
||
|
||
// applyColonyFocus / applyAllocationFocus — the shared setters
|
||
// VegaColonyView.js's flyouts and VegaColoniesScreen.js's inline pills
|
||
// both call, so a fix to one path fixes the other by construction.
|
||
{
|
||
c.advisor.focusQuietUntil = null;
|
||
Logic.applyColonyFocus(st2, c, 'trade');
|
||
check('applyColonyFocus sets the focus value', c.focus === 'trade');
|
||
check('applyColonyFocus arms the advisor quiet timer', c.advisor.focusQuietUntil === st2.turn + 15);
|
||
|
||
c.advisor.allocQuietUntil = null;
|
||
c.advisor.allocKey = null;
|
||
const opt = {
|
||
key: 'production',
|
||
sliders: {
|
||
ships: 0.2, defense: 0.05, industry: 0.55, ecology: 0.1, research: 0.1,
|
||
},
|
||
};
|
||
Logic.applyAllocationFocus(st2, c, opt);
|
||
check('applyAllocationFocus overwrites the sliders',
|
||
Object.keys(opt.sliders).every((ch) => Math.abs(c.sliders[ch] - opt.sliders[ch]) < 1e-9));
|
||
check('applyAllocationFocus records the preset key', c.advisor.allocKey === 'production');
|
||
check('applyAllocationFocus arms the advisor quiet timer', c.advisor.allocQuietUntil === st2.turn + 15);
|
||
}
|
||
|
||
// advisorColonyReport — combines both recommendations plus deficiency
|
||
// call-outs into the lines VegaColoniesScreen.js's terminal types out.
|
||
{
|
||
const lines = Logic.advisorColonyReport(RULES, st2, c);
|
||
check('advisorColonyReport returns a non-empty array of strings',
|
||
Array.isArray(lines) && lines.length > 0 && lines.every((l) => typeof l === 'string'));
|
||
check('advisorColonyReport mentions the recommended Colony Focus label',
|
||
lines.some((l) => l.includes(Logic.recommendColonyFocus(RULES, st2, c).label)));
|
||
check('advisorColonyReport mentions the recommended Allocation Focus label',
|
||
lines.some((l) => l.includes(Logic.recommendAllocationFocus(RULES, st2, c).label)));
|
||
const savedWaste = c.waste;
|
||
c.waste = 10;
|
||
const dirtyLines = Logic.advisorColonyReport(RULES, st2, c);
|
||
check('advisorColonyReport calls out uncleaned waste as a deficiency',
|
||
dirtyLines.some((l) => l.toLowerCase().includes('waste')));
|
||
c.waste = savedWaste;
|
||
}
|
||
}
|
||
|
||
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('a council session is not a turn-report row (it has its own ceremony)',
|
||
!NOTABLE_TYPES.has('council'));
|
||
check('council refusal still is a turn-report row (a distinct war-declaration consequence)',
|
||
NOTABLE_TYPES.has('councilRefused'));
|
||
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));
|
||
}
|
||
}
|
||
|
||
// Brian's ask: multiple copies of the same hull finishing at the same
|
||
// colony in one turn used to render as N identical "Frigate completed at
|
||
// Sol." rows in the New Turn report — groupShipDoneEvents collapses them
|
||
// into one row with a count.
|
||
{
|
||
const stG = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'spiral', difficultyId: 'normal', seed: 7171,
|
||
speciesIds: ['human', 'kkrix'], humanIndex: 0,
|
||
});
|
||
const homeStar = stG.empires[0].homeStar;
|
||
const mk = (colonyId, hullId, empire = 0) => ({ type: 'shipDone', empire, colonyId, hullId, starIdx: homeStar, turn: 1 });
|
||
const events = [
|
||
mk(1, 'frigate'), mk(1, 'frigate'), mk(1, 'frigate'), // 3 frigates, same colony
|
||
mk(1, 'destroyer'), // a different hull, same colony — stays separate
|
||
mk(2, 'frigate'), // same hull, DIFFERENT colony — stays separate
|
||
{ type: 'techDone', empire: 0, techId: 'lasercannon', turn: 1 }, // non-shipDone — passes through untouched
|
||
];
|
||
const grouped = groupShipDoneEvents(events);
|
||
check('groupShipDoneEvents collapses same-colony same-hull duplicates into one row',
|
||
grouped.length === 4, `${grouped.length}`);
|
||
const frigateAt1 = grouped.find((ev) => ev.type === 'shipDone' && ev.colonyId === 1 && ev.hullId === 'frigate');
|
||
check('the collapsed row counts every duplicate', frigateAt1?.count === 3, `${frigateAt1?.count}`);
|
||
check('a different hull at the same colony is not folded in',
|
||
grouped.find((ev) => ev.hullId === 'destroyer')?.count === 1);
|
||
check('the same hull at a different colony is not folded in',
|
||
grouped.find((ev) => ev.colonyId === 2 && ev.hullId === 'frigate')?.count === 1);
|
||
check('non-shipDone events pass through untouched',
|
||
grouped.some((ev) => ev.type === 'techDone'));
|
||
check('ungrouped shipDone rows (count 1) still read as singular',
|
||
!describeEvent(RULES, stG, grouped.find((ev) => ev.hullId === 'destroyer')).headline.includes('×'));
|
||
check('a grouped shipDone row headlines the count',
|
||
describeEvent(RULES, stG, frigateAt1).headline.startsWith('3× '), describeEvent(RULES, stG, frigateAt1).headline);
|
||
}
|
||
|
||
// 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 fresh game has no pending council session', st.council.pendingSession === false);
|
||
|
||
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);
|
||
})());
|
||
|
||
// A diplomacy-incapable species at war and clearly losing must never queue
|
||
// a held peace offer for the human — its Seek Audience button is
|
||
// permanently disabled (VegaScreens.js/MasterOfVegaGame.js), so a queued
|
||
// offer would leave an un-clearable "seeks an audience" card up with
|
||
// nothing the human could ever do about it. 300 iterations at the
|
||
// underlying 12%-per-turn propose chance would almost certainly have
|
||
// caught at least one leak under the old (unguarded) behaviour.
|
||
{
|
||
const stL = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'elliptical', seed: 55, difficultyId: 'normal',
|
||
speciesIds: ['human', 'lithox'], humanIndex: 0,
|
||
});
|
||
stL.rules = RULES;
|
||
stL.empires[0].contacted[1] = true;
|
||
stL.empires[1].contacted[0] = true;
|
||
stL.empires[0].totalPop = 900;
|
||
stL.empires[1].totalPop = 50; // lithox is losing badly: power ratio well under 0.55
|
||
Diplo.declareWar(RULES, stL, 1, 0);
|
||
for (let i = 0; i < 300; i += 1) Diplo.runDiplomacyTurn(RULES, stL, 1);
|
||
check('a losing diplomacy-incapable empire never queues a held peace offer',
|
||
!stL.empires[0].pendingOffers[1]);
|
||
}
|
||
|
||
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);
|
||
|
||
// Per-voter breakdown, added for VegaCouncilSession.js's one-at-a-time
|
||
// reveal — must stay consistent with the aggregate `votes`/`abstained`
|
||
// this session's UI checks above, since both are read from the same
|
||
// result object.
|
||
check('every alive empire appears exactly once in the voter breakdown',
|
||
result.voters.length === st.empires.filter((e) => e.alive).length);
|
||
check("voter weights sum to the session's total population", (() => {
|
||
const sum = result.voters.reduce((t, v) => t + v.weight, 0);
|
||
return Math.abs(sum - result.totalPop) < 1e-6;
|
||
})());
|
||
check('every voter choice is a candidate or an abstention (null)',
|
||
result.voters.every((v) => v.choice === null || result.candidates.includes(v.choice)));
|
||
check('a candidate always votes for itself',
|
||
result.candidates.every((idx) => result.voters.find((v) => v.idx === idx)?.choice === idx));
|
||
check('runCouncil leaves a pending session for the UI to consume',
|
||
st.council.pendingSession === true);
|
||
|
||
// 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));
|
||
}
|
||
|
||
// --- "make peace with them" third-party requests (VegaDiplomacy.js's
|
||
// wouldAcceptPeaceRequest/requestPeace/offerPeaceRequest) — a favor about
|
||
// someone ELSE's war, not a treaty between the two people actually
|
||
// talking. Brian's ask: the player can ask an AI to end a war with a
|
||
// third empire, an AI can ask the player the same, and refusing either
|
||
// direction costs the relationship.
|
||
{
|
||
const stP = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'cluster', seed: 7373, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'ursaal'], humanIndex: 0,
|
||
});
|
||
stP.rules = RULES;
|
||
const player = 0;
|
||
const target = 1;
|
||
const third = 2;
|
||
for (const e of stP.empires) {
|
||
e.totalPop = 100;
|
||
for (const o of stP.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 0; }
|
||
}
|
||
Diplo.declareWar(RULES, stP, target, third);
|
||
check('fixture: target is at war with third', Logic.atWar(stP, target, third));
|
||
|
||
stP.empires[target].attitude[third] = -50; // not losing badly, not devoted either
|
||
stP.empires[target].attitude[player] = 0; // not yet a friend
|
||
check('a stranger\'s request is refused when the target has no independent reason to accept',
|
||
!Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third));
|
||
|
||
stP.empires[target].attitude[player] = 60;
|
||
check('a friend\'s request succeeds as a favor even when the target would not sue for peace unprompted',
|
||
Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third));
|
||
|
||
stP.empires[target].attitude[third] = -90;
|
||
check('even a friend\'s request is refused when the target\'s own war attitude is bad enough',
|
||
!Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third));
|
||
|
||
// requestPeace: success actually makes peace and warms target toward the
|
||
// requester beyond makePeace's own bilateral bump; failure stings instead.
|
||
stP.empires[target].attitude[third] = -50;
|
||
stP.empires[target].attitude[player] = 60;
|
||
const beforeAtt = Diplo.attitudeOf(stP, target, player);
|
||
const ok = Diplo.requestPeace(RULES, stP, player, target, third);
|
||
check('requestPeace succeeds when wouldAcceptPeaceRequest is true', ok === true);
|
||
check('a successful request actually makes peace', !Logic.atWar(stP, target, third));
|
||
check('a successful request warms the target toward the requester',
|
||
Diplo.attitudeOf(stP, target, player) > beforeAtt);
|
||
|
||
Diplo.declareWar(RULES, stP, target, third); // reset the war for the failure case
|
||
stP.empires[target].attitude[player] = -50; // no longer a friend
|
||
stP.empires[target].attitude[third] = -90; // and losing badly
|
||
const beforeAtt2 = Diplo.attitudeOf(stP, target, player);
|
||
const failed = Diplo.requestPeace(RULES, stP, player, target, third);
|
||
check('requestPeace fails when wouldAcceptPeaceRequest is false', failed === false);
|
||
check('a failed request still leaves them at war', Logic.atWar(stP, target, third));
|
||
check('a failed request costs the requester a small sting',
|
||
Diplo.attitudeOf(stP, target, player) < beforeAtt2);
|
||
|
||
// offerPeaceRequest: held exactly like proposeOrOffer's human-targeted
|
||
// branch, and respondToOffer's peaceRequest case resolves acceptance as
|
||
// "try to make peace with the third party," not a treaty with the asker.
|
||
Diplo.declareWar(RULES, stP, player, third); // now the HUMAN is at war with third
|
||
const heldReq = Diplo.offerPeaceRequest(RULES, stP, target, player, third);
|
||
check('offerPeaceRequest holds, same contract as proposeOrOffer', heldReq === 'pending');
|
||
check('the held peaceRequest offer carries its thirdParty',
|
||
stP.empires[player].pendingOffers[target]?.thirdParty === third);
|
||
const heldAgain = Diplo.offerPeaceRequest(RULES, stP, target, player, third);
|
||
check('a second peaceRequest from the same asker does not clobber the first', heldAgain === 'pending');
|
||
|
||
stP.empires[third].attitude[player] = 50; // so the resulting proposeTreaty to third can land
|
||
const beforeAskerAtt = Diplo.attitudeOf(stP, target, player);
|
||
const respAccept = Diplo.respondToOffer(RULES, stP, player, target, true);
|
||
check('respondToOffer resolves a held peaceRequest (true, same contract as any offer)',
|
||
respAccept === true);
|
||
check('accepting a peaceRequest attempts peace with the third party',
|
||
!Logic.atWar(stP, player, third));
|
||
check('accepting clears the pending offer', !stP.empires[player].pendingOffers[target]);
|
||
check('the asker is pleased the human tried',
|
||
Diplo.attitudeOf(stP, target, player) > beforeAskerAtt);
|
||
check('accepting a peaceRequest pushes a peaceRequestResolved event',
|
||
stP.events.some((e) => e.type === 'peaceRequestResolved' && e.empire === player && e.other === target
|
||
&& e.thirdParty === third && e.madePeace === true));
|
||
|
||
// Rejecting reuses the exact generic refusal path every other offer kind
|
||
// already uses — refusing costs the relationship, Brian's explicit ask.
|
||
Diplo.declareWar(RULES, stP, player, third);
|
||
Diplo.offerPeaceRequest(RULES, stP, target, player, third);
|
||
const beforeReject = Diplo.attitudeOf(stP, target, player);
|
||
Diplo.respondToOffer(RULES, stP, player, target, false);
|
||
check('rejecting a peaceRequest costs the relationship, same as any other refused offer',
|
||
Diplo.attitudeOf(stP, target, player) < beforeReject);
|
||
check('rejecting a peaceRequest never touches the third party\'s treaty',
|
||
Logic.atWar(stP, player, third));
|
||
|
||
// AI-initiation: an AI that likes a friend enough, with the human at war
|
||
// with that friend, eventually offers the human a peaceRequest.
|
||
const stAI = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'cluster', seed: 8181, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'ursaal'], humanIndex: 0,
|
||
});
|
||
stAI.rules = RULES;
|
||
for (const e of stAI.empires) {
|
||
e.totalPop = 100;
|
||
for (const o of stAI.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 0; }
|
||
}
|
||
const asker = 1;
|
||
const friend = 2;
|
||
Diplo.declareWar(RULES, stAI, stAI.humanIndex, friend);
|
||
let anyOffered = false;
|
||
for (let i = 0; i < 100 && !anyOffered; i += 1) {
|
||
stAI.empires[asker].attitude[friend] = 80; // pin above friendThreshold — isolate the dice roll
|
||
Diplo.runDiplomacyTurn(RULES, stAI, asker);
|
||
anyOffered = !!stAI.empires[stAI.humanIndex].pendingOffers[asker];
|
||
}
|
||
check('runDiplomacyTurn eventually offers the human a peaceRequest for a war against a liked empire',
|
||
anyOffered, `pendingOffers=${JSON.stringify(stAI.empires[stAI.humanIndex].pendingOffers)}`);
|
||
}
|
||
|
||
// --- 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);
|
||
}
|
||
|
||
// --- Gifts: BC moves giver -> receiver, attitude rises, a quick repeat is
|
||
// worth less (diminishing returns, not a hard cooldown), and an
|
||
// unaffordable gift is a safe no-op.
|
||
{
|
||
const stE = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'spiral', seed: 606, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kestrelli'], humanIndex: -1,
|
||
});
|
||
stE.rules = RULES;
|
||
stE.empires[0].contacted[1] = true; stE.empires[1].contacted[0] = true;
|
||
stE.empires[0].bc = 500;
|
||
const bcBefore1 = stE.empires[1].bc;
|
||
const attBefore1 = stE.empires[1].attitude[0] ?? 0;
|
||
const tier0 = RULES.diplomacy.gift.tiers[0];
|
||
|
||
const ok1 = Diplo.giveGift(RULES, stE, 0, 1, tier0.id);
|
||
check('giveGift succeeds when affordable', ok1 === true);
|
||
check('giveGift moves BC from giver to receiver',
|
||
stE.empires[0].bc === 500 - tier0.bc && stE.empires[1].bc === bcBefore1 + tier0.bc);
|
||
check('giveGift raises the receiver\'s attitude toward the giver',
|
||
stE.empires[1].attitude[0] > attBefore1);
|
||
check('giveGift pushes a gift event',
|
||
stE.events.some((e) => e.type === 'gift' && e.empire === 0 && e.other === 1));
|
||
|
||
const attAfter1 = stE.empires[1].attitude[0];
|
||
Diplo.giveGift(RULES, stE, 0, 1, tier0.id);
|
||
const secondDelta = stE.empires[1].attitude[0] - attAfter1;
|
||
check('a repeat gift inside the cooldown window still helps, but less than the first did',
|
||
secondDelta > 0 && secondDelta < tier0.delta, `${secondDelta} vs ${tier0.delta}`);
|
||
|
||
stE.empires[0].bc = 1;
|
||
const bcBeforeFail = stE.empires[0].bc;
|
||
const failed = Diplo.giveGift(RULES, stE, 0, 1, RULES.diplomacy.gift.tiers.at(-1).id);
|
||
check('an unaffordable gift fails and changes nothing',
|
||
failed === false && stE.empires[0].bc === bcBeforeFail);
|
||
}
|
||
|
||
// --- Trade agreements: full propose/accept ceremony (mirrors alliance),
|
||
// symmetric formation, BC income isolated via two identical clones, and
|
||
// cancellation when war breaks out between the two parties.
|
||
{
|
||
const stF = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'spiral', seed: 707, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kestrelli', 'ursaal'], humanIndex: 0,
|
||
});
|
||
stF.rules = RULES;
|
||
|
||
// AI-to-AI (1 -> 2): resolves immediately via proposeTreaty.
|
||
stF.empires[1].contacted[2] = true; stF.empires[2].contacted[1] = true;
|
||
stF.empires[2].attitude[1] = 50;
|
||
const formed = Diplo.proposeTreaty(RULES, stF, 1, 2, 'tradeAgreement');
|
||
check('an AI-to-AI trade-agreement proposal that clears the threshold succeeds', formed === true);
|
||
check('forming a trade agreement sets both sides symmetrically',
|
||
stF.empires[1].tradeAgreements[2] === true && stF.empires[2].tradeAgreements[1] === true);
|
||
check('forming a trade agreement pushes a tradeAgreementFormed event',
|
||
stF.events.some((e) => e.type === 'tradeAgreementFormed' && e.empire === 1 && e.other === 2));
|
||
check('forming a trade agreement raises attitude both ways',
|
||
stF.empires[1].attitude[2] > 0 && stF.empires[2].attitude[1] > 50);
|
||
|
||
// AI-to-human (1 -> 0): held, not resolved immediately — same
|
||
// proposeOrOffer/pendingOffers/respondToOffer machinery Alliance uses.
|
||
stF.empires[0].contacted[1] = true; stF.empires[1].contacted[0] = true;
|
||
const held = Diplo.proposeOrOffer(RULES, stF, 1, 0, 'tradeAgreement');
|
||
check('an AI trade-agreement proposal to the human is held, not resolved', held === 'pending');
|
||
check('a held trade-agreement offer does not touch the relationship yet',
|
||
!stF.empires[0].tradeAgreements[1]);
|
||
const accepted = Diplo.respondToOffer(RULES, stF, 0, 1, true);
|
||
check('accepting a held trade-agreement offer succeeds', accepted === true);
|
||
check('accepting applies the trade agreement both ways',
|
||
stF.empires[0].tradeAgreements[1] === true && stF.empires[1].tradeAgreements[0] === true);
|
||
|
||
// War cancels an active trade agreement.
|
||
Diplo.declareWar(RULES, stF, 0, 1);
|
||
check('declaring war clears an active trade agreement on both sides',
|
||
!stF.empires[0].tradeAgreements[1] && !stF.empires[1].tradeAgreements[0]);
|
||
check('war cancellation pushes a tradeAgreementEnded event',
|
||
stF.events.some((e) => e.type === 'tradeAgreementEnded' && e.reason === 'war'
|
||
&& [e.empire, e.other].includes(0) && [e.empire, e.other].includes(1)));
|
||
|
||
// BC income: two clones of the same state, differing ONLY in whether a
|
||
// trade agreement is active, isolates the income term from every other
|
||
// per-turn effect beginEmpireTurn also applies.
|
||
const baseState = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'spiral', seed: 808, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kestrelli'], humanIndex: -1,
|
||
});
|
||
baseState.rules = RULES;
|
||
baseState.empires[0].contacted[1] = true; baseState.empires[1].contacted[0] = true;
|
||
Diplo.makePeace(RULES, baseState, 0, 1);
|
||
const savedBase = Logic.serialize(baseState);
|
||
const withoutTA = Logic.deserialize(savedBase);
|
||
withoutTA.rules = RULES;
|
||
const withTA = Logic.deserialize(savedBase);
|
||
withTA.rules = RULES;
|
||
withTA.empires[0].tradeAgreements[1] = true;
|
||
withTA.empires[1].tradeAgreements[0] = true;
|
||
const bcBeforeNo = withoutTA.empires[0].bc;
|
||
const bcBeforeYes = withTA.empires[0].bc;
|
||
Logic.beginEmpireTurn(RULES, withoutTA, 0);
|
||
Logic.beginEmpireTurn(RULES, withTA, 0);
|
||
check('an active trade agreement adds BC income beyond an identical empire without one',
|
||
(withTA.empires[0].bc - bcBeforeYes) > (withoutTA.empires[0].bc - bcBeforeNo));
|
||
}
|
||
|
||
// --- Border/fleet proximity tension: closer colonies/fleets lose more
|
||
// attitude than distant ones, and war/alliance pairs are untouched by
|
||
// these two passes specifically (driftAttitudes already covers war, and
|
||
// allies aren't meant to grind on each other's borders).
|
||
{
|
||
const stG = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'spiral', seed: 909, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kestrelli', 'ursaal'], humanIndex: -1,
|
||
});
|
||
stG.rules = RULES;
|
||
stG.colonies.length = 0;
|
||
stG.fleets.length = 0;
|
||
stG.empires[0].contacted[1] = true; stG.empires[1].contacted[0] = true;
|
||
stG.empires[0].contacted[2] = true; stG.empires[2].contacted[0] = true;
|
||
|
||
const stars = stG.galaxy.stars;
|
||
let far = 0;
|
||
let maxD = -1;
|
||
for (let i = 1; i < stars.length; i += 1) {
|
||
const d = Math.hypot(stars[i].x - stars[0].x, stars[i].y - stars[0].y);
|
||
if (d > maxD) { maxD = d; far = i; }
|
||
}
|
||
const near = 0;
|
||
stG.colonies.push({
|
||
id: 9001, empireIdx: 0, starIdx: near, orbit: 0, pop: 5,
|
||
});
|
||
stG.colonies.push({
|
||
id: 9002, empireIdx: 1, starIdx: near, orbit: 1, pop: 5,
|
||
});
|
||
stG.colonies.push({
|
||
id: 9003, empireIdx: 2, starIdx: far, orbit: 0, pop: 5,
|
||
});
|
||
|
||
// Species carry non-zero starting attitude baselines — zero the pairs
|
||
// under test so a delta from this pass alone is unambiguous.
|
||
stG.empires[0].attitude[1] = 0; stG.empires[1].attitude[0] = 0;
|
||
stG.empires[0].attitude[2] = 0; stG.empires[2].attitude[0] = 0;
|
||
Diplo.applyBorderTension(RULES, stG);
|
||
check('colonies on the same star (minimum distance) lose attitude from border tension',
|
||
stG.empires[0].attitude[1] < 0 && stG.empires[1].attitude[0] < 0);
|
||
check('colonies far beyond the border range are untouched',
|
||
(stG.empires[0].attitude[2] ?? 0) === 0 && (stG.empires[2].attitude[0] ?? 0) === 0);
|
||
|
||
stG.empires[0].attitude[1] = 0; stG.empires[1].attitude[0] = 0;
|
||
stG.empires[0].treaties[1] = 'alliance'; stG.empires[1].treaties[0] = 'alliance';
|
||
Diplo.applyBorderTension(RULES, stG);
|
||
check('allied colonies at minimum distance are skipped by border tension',
|
||
stG.empires[0].attitude[1] === 0 && stG.empires[1].attitude[0] === 0);
|
||
|
||
stG.empires[0].treaties[1] = 'war'; stG.empires[1].treaties[0] = 'war';
|
||
Diplo.applyBorderTension(RULES, stG);
|
||
check('warring colonies at minimum distance are skipped by border tension (driftAttitudes covers war)',
|
||
stG.empires[0].attitude[1] === 0 && stG.empires[1].attitude[0] === 0);
|
||
stG.empires[0].treaties[1] = 'none'; stG.empires[1].treaties[0] = 'none';
|
||
|
||
stG.colonies.length = 0;
|
||
stG.fleets.push({
|
||
id: 9101, empireIdx: 0, starIdx: near, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
|
||
});
|
||
stG.fleets.push({
|
||
id: 9102, empireIdx: 1, starIdx: near, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
|
||
});
|
||
stG.fleets.push({
|
||
id: 9103, empireIdx: 2, starIdx: far, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
|
||
});
|
||
stG.empires[0].attitude[1] = 0; stG.empires[1].attitude[0] = 0;
|
||
stG.empires[0].attitude[2] = 0; stG.empires[2].attitude[0] = 0;
|
||
Diplo.applyFleetProximityTension(RULES, stG);
|
||
check('fleets sharing a star lose attitude from fleet proximity tension',
|
||
stG.empires[0].attitude[1] < 0 && stG.empires[1].attitude[0] < 0);
|
||
check('fleets far beyond the fleet range are untouched',
|
||
(stG.empires[0].attitude[2] ?? 0) === 0 && (stG.empires[2].attitude[0] ?? 0) === 0);
|
||
}
|
||
|
||
// --- Fleet intrusion: the arrival turn is free, penalties escalate the
|
||
// longer a foreign fleet dwells, leaving resets the tracker, allied and
|
||
// at-war fleets never accrue an entry, and claimFleetComplaints behaves
|
||
// exactly like claimAudienceContacts (capable-only, idempotent).
|
||
{
|
||
const stH = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'spiral', seed: 1010, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kestrelli'], humanIndex: -1,
|
||
});
|
||
stH.rules = RULES;
|
||
stH.colonies.length = 0;
|
||
stH.fleets.length = 0;
|
||
const star = stH.galaxy.stars.length - 1;
|
||
stH.colonies.push({ id: 9201, empireIdx: 0, starIdx: star, orbit: 0, pop: 5 });
|
||
stH.fleets.push({
|
||
id: 9301, empireIdx: 1, starIdx: star, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
|
||
});
|
||
|
||
const attOnArrival = stH.empires[0].attitude[1] ?? 0;
|
||
stH.turn = 10;
|
||
Diplo.checkFleetIntrusions(RULES, stH);
|
||
check('a fleet on its arrival turn is free — no penalty, no event',
|
||
(stH.empires[0].attitude[1] ?? 0) === attOnArrival && !stH.events.some((e) => e.type === 'fleetComplaint'));
|
||
|
||
stH.turn = 11;
|
||
const attBeforeDwell1 = stH.empires[0].attitude[1] ?? 0;
|
||
Diplo.checkFleetIntrusions(RULES, stH);
|
||
const attAfterDwell1 = stH.empires[0].attitude[1];
|
||
check('past the grace period, a penalty applies', attAfterDwell1 < attBeforeDwell1);
|
||
check('the first violation pushes exactly one fleetComplaint event with first:true',
|
||
stH.events.filter((e) => e.type === 'fleetComplaint' && e.first === true).length === 1);
|
||
|
||
stH.turn = 12;
|
||
Diplo.checkFleetIntrusions(RULES, stH);
|
||
const attAfterDwell2 = stH.empires[0].attitude[1];
|
||
const mag1 = attBeforeDwell1 - attAfterDwell1;
|
||
const mag2 = attAfterDwell1 - attAfterDwell2;
|
||
check('the penalty escalates turn over turn while the fleet stays', mag2 > mag1, `${mag1} then ${mag2}`);
|
||
check('later turns of the same intrusion are not marked first',
|
||
stH.events.find((e) => e.type === 'fleetComplaint' && e.turn === 12)?.first === false);
|
||
|
||
stH.fleets.length = 0;
|
||
Diplo.checkFleetIntrusions(RULES, stH);
|
||
check('the fleet leaving clears the tracked intrusion entry',
|
||
Object.keys(stH.empires[0].fleetIntrusions[star] ?? {}).length === 0);
|
||
|
||
stH.fleets.push({
|
||
id: 9302, empireIdx: 1, starIdx: star, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
|
||
});
|
||
stH.turn = 13;
|
||
Diplo.checkFleetIntrusions(RULES, stH);
|
||
check('a later return restarts the grace period rather than resuming the old escalation',
|
||
stH.empires[0].fleetIntrusions[star]?.[1]?.sinceTurn === 13
|
||
&& (stH.empires[0].attitude[1] ?? 0) === attAfterDwell2); // dwell 0 on the new arrival — no fresh penalty yet
|
||
|
||
stH.empires[0].treaties[1] = 'alliance'; stH.empires[1].treaties[0] = 'alliance';
|
||
stH.empires[0].fleetIntrusions[star] = {};
|
||
stH.turn = 20;
|
||
Diplo.checkFleetIntrusions(RULES, stH);
|
||
check('an allied fleet never accrues an intrusion entry', !stH.empires[0].fleetIntrusions[star]?.[1]);
|
||
|
||
stH.empires[0].treaties[1] = 'war'; stH.empires[1].treaties[0] = 'war';
|
||
stH.turn = 21;
|
||
Diplo.checkFleetIntrusions(RULES, stH);
|
||
check('a fleet from an empire already at war never accrues an intrusion entry (a siege, not a faux-pas)',
|
||
!stH.empires[0].fleetIntrusions[star]?.[1]);
|
||
stH.empires[0].treaties[1] = 'none'; stH.empires[1].treaties[0] = 'none';
|
||
}
|
||
|
||
// --- claimFleetComplaints: mirrors claimAudienceContacts exactly.
|
||
{
|
||
const stI = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'spiral', seed: 1111, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kestrelli', 'lithox'], humanIndex: 0,
|
||
});
|
||
stI.rules = RULES;
|
||
stI.empires[0].contacted[1] = true; stI.empires[1].contacted[0] = true;
|
||
stI.empires[0].contacted[2] = true; stI.empires[2].contacted[0] = true;
|
||
stI.events.push({ type: 'fleetComplaint', empire: 1, other: 0, starIdx: 5, dwell: 1, first: true, turn: stI.turn });
|
||
stI.events.push({ type: 'fleetComplaint', empire: 2, other: 0, starIdx: 6, dwell: 1, first: true, turn: stI.turn });
|
||
|
||
const claimed = Diplo.claimFleetComplaints(RULES, stI, 0);
|
||
check('claimFleetComplaints claims only the diplomacy-capable complaint',
|
||
claimed.length === 1 && claimed[0] === 1);
|
||
check('the claimed complaint event is marked announced',
|
||
stI.events.find((e) => e.type === 'fleetComplaint' && e.empire === 1).announced === true);
|
||
check('the lithox complaint is left unclaimed for the ordinary log line',
|
||
stI.events.find((e) => e.type === 'fleetComplaint' && e.empire === 2).announced !== true);
|
||
check('claiming is idempotent — a second call finds nothing new',
|
||
Diplo.claimFleetComplaints(RULES, stI, 0).length === 0);
|
||
}
|
||
|
||
// --- Attitude triangulation ("enemy of my enemy"): directional, clamped,
|
||
// and inert between empires already at war with each other.
|
||
{
|
||
const stJ = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'spiral', seed: 1212, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kestrelli', 'ursaal'], humanIndex: -1,
|
||
});
|
||
stJ.rules = RULES;
|
||
for (const e of stJ.empires) for (const o of stJ.empires) if (e.idx !== o.idx) e.contacted[o.idx] = true;
|
||
Diplo.declareWar(RULES, stJ, 0, 1); // A(0) vs B(1)
|
||
Diplo.declareWar(RULES, stJ, 1, 2); // B(1) vs C(2)
|
||
|
||
const before = Diplo.attitudeOf(stJ, 2, 0);
|
||
Diplo.triangulateAttitudes(RULES, stJ);
|
||
const after = Diplo.attitudeOf(stJ, 2, 0);
|
||
check('a shared hostile third party nudges attitude upward (enemy of my enemy is my friend)',
|
||
after > before, `${before} -> ${after}`);
|
||
|
||
for (let i = 0; i < 300; i += 1) Diplo.triangulateAttitudes(RULES, stJ);
|
||
check('triangulation stays within [-100, 100] under repeated passes',
|
||
stJ.empires[2].attitude[0] >= -100 && stJ.empires[2].attitude[0] <= 100
|
||
&& Number.isFinite(stJ.empires[2].attitude[0]));
|
||
|
||
const beforeReverse = Diplo.attitudeOf(stJ, 0, 2);
|
||
Diplo.triangulateAttitudes(RULES, stJ);
|
||
check('triangulation is directional — the reverse pair is untouched by this function alone',
|
||
Diplo.attitudeOf(stJ, 0, 2) === beforeReverse);
|
||
|
||
check('no nudge between two empires already at war with each other', (() => {
|
||
Diplo.declareWar(RULES, stJ, 0, 2);
|
||
const b = Diplo.attitudeOf(stJ, 2, 0);
|
||
Diplo.triangulateAttitudes(RULES, stJ);
|
||
return Diplo.attitudeOf(stJ, 2, 0) === b;
|
||
})());
|
||
}
|
||
|
||
// --- 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',
|
||
'giftOffer', 'replyGiftWarm', 'replyGiftNeutral',
|
||
'proposeTradeAgreement', 'replyTradeAgreementAccept', 'replyTradeAgreementReject',
|
||
'offerTradeAgreementOpener',
|
||
'fleetComplaintOpener', 'acknowledgeComplaintWithdraw', 'acknowledgeComplaintDefy',
|
||
'afterComplaintWithdrawPromise', 'afterComplaintDefy',
|
||
'requestPeaceThird', 'replyRequestPeaceAccept', 'replyRequestPeaceReject',
|
||
'offerPeaceRequestOpener', 'acceptPeaceRequest', 'rejectPeaceRequest',
|
||
'afterPeaceRequestAcceptedSuccess', 'afterPeaceRequestAcceptedTried', 'afterPeaceRequestRejected',
|
||
];
|
||
// 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('7b. Galactic News Network');
|
||
// ---------------------------------------------------------------------------
|
||
{
|
||
// --- classification: isGnnStory / pendingGnnStories, hand-built events so
|
||
// every branch (research+headline, espionage-sourced, non-headline,
|
||
// attributed/anonymous sabotage+theft, spyCaught, already-announced) is
|
||
// exercised directly rather than waiting for a soak to happen to produce
|
||
// one of each.
|
||
{
|
||
const headlineTech = 'lasercannon'; // gnnHeadline: true, tier 0 weapons
|
||
const plainTech = 'battlescanner'; // not flagged
|
||
check('fixture headline tech is actually flagged', RULES.techs[headlineTech]?.gnnHeadline === true);
|
||
check('fixture plain tech is not flagged', !RULES.techs[plainTech]?.gnnHeadline);
|
||
|
||
// humanIndex 0. Empire 3 is a bystander the human knows and likes, 4 a
|
||
// bystander the human knows and despises, 5 a bystander never met at
|
||
// all — exercises isGnnStory's contact+attitude gate for a successful
|
||
// mission that doesn't touch the human directly.
|
||
const fakeState = {
|
||
humanIndex: 0,
|
||
empires: [{ idx: 0, contacted: { 3: true, 4: true }, attitude: { 3: 10, 4: -80 } }],
|
||
events: [],
|
||
};
|
||
|
||
const evResearchHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'research' };
|
||
const evEspionageHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'espionage' };
|
||
const evResearchPlain = { type: 'techDone', empire: 0, techId: plainTech, source: 'research' };
|
||
const evResearchHeadlineMetBystander = { type: 'techDone', empire: 3, techId: headlineTech, source: 'research' };
|
||
const evResearchHeadlineUnmetBystander = { type: 'techDone', empire: 5, techId: headlineTech, source: 'research' };
|
||
const evSabotageMine = { type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 };
|
||
const evTechStolenMine = { type: 'techStolen', empire: 0, target: 1, techId: headlineTech };
|
||
const evTechStolenVictimIsMe = { type: 'techStolen', empire: 1, target: 0, techId: headlineTech };
|
||
const evTechStolenBystanderFriendly = { type: 'techStolen', empire: 1, target: 3, techId: headlineTech };
|
||
const evTechStolenBystanderHostile = { type: 'techStolen', empire: 1, target: 4, techId: headlineTech };
|
||
const evTechStolenBystanderUnknown = { type: 'techStolen', empire: 1, target: 5, techId: headlineTech };
|
||
const evSpyCaught = { type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' };
|
||
const evPeace = { type: 'peace', empire: 0, other: 1 };
|
||
const evWarAlreadyAnnounced = { type: 'warDeclared', empire: 0, other: 1, gnnAnnounced: true };
|
||
|
||
check('research-sourced headline tech is a GNN story', Gnn.isGnnStory(RULES, fakeState, evResearchHeadline));
|
||
check('espionage-sourced headline tech is NOT a GNN story (nobody can publicly name the thief\'s target)',
|
||
!Gnn.isGnnStory(RULES, fakeState, evEspionageHeadline));
|
||
check('research-sourced non-headline tech is NOT a GNN story', !Gnn.isGnnStory(RULES, fakeState, evResearchPlain));
|
||
check('a met bystander\'s research-sourced headline tech IS a GNN story',
|
||
Gnn.isGnnStory(RULES, fakeState, evResearchHeadlineMetBystander));
|
||
check('an unmet bystander\'s research-sourced headline tech is NOT a GNN story (never contacted)',
|
||
!Gnn.isGnnStory(RULES, fakeState, evResearchHeadlineUnmetBystander));
|
||
check('the player\'s own successful sabotage IS a GNN story, attributed to them',
|
||
Gnn.isGnnStory(RULES, fakeState, evSabotageMine));
|
||
check('the player\'s own successful tech theft IS a GNN story, attributed to them',
|
||
Gnn.isGnnStory(RULES, fakeState, evTechStolenMine));
|
||
check('the player being victimized IS a GNN story regardless of contact/attitude',
|
||
Gnn.isGnnStory(RULES, fakeState, evTechStolenVictimIsMe));
|
||
check('a bystander in contact and on decent terms with the victim hears the rumor',
|
||
Gnn.isGnnStory(RULES, fakeState, evTechStolenBystanderFriendly));
|
||
check('a bystander hostile/cold toward the victim does not hear the rumor',
|
||
!Gnn.isGnnStory(RULES, fakeState, evTechStolenBystanderHostile));
|
||
check('a bystander with no contact with the victim does not hear the rumor',
|
||
!Gnn.isGnnStory(RULES, fakeState, evTechStolenBystanderUnknown));
|
||
check('a caught spy ("exposed") is a GNN story', Gnn.isGnnStory(RULES, fakeState, evSpyCaught));
|
||
check('peace is a GNN story', Gnn.isGnnStory(RULES, fakeState, evPeace));
|
||
|
||
fakeState.events = [
|
||
evResearchHeadline, evEspionageHeadline, evResearchPlain, evSabotageMine, evTechStolenMine,
|
||
evTechStolenVictimIsMe, evTechStolenBystanderFriendly, evTechStolenBystanderHostile,
|
||
evTechStolenBystanderUnknown, evSpyCaught, evPeace, evWarAlreadyAnnounced,
|
||
];
|
||
const pending = Gnn.pendingGnnStories(RULES, fakeState);
|
||
check('pendingGnnStories excludes an already-gnnAnnounced event', !pending.includes(evWarAlreadyAnnounced));
|
||
const expectedPending = [
|
||
evResearchHeadline, evSabotageMine, evTechStolenMine, evTechStolenVictimIsMe,
|
||
evTechStolenBystanderFriendly, evSpyCaught, evPeace,
|
||
];
|
||
check('pendingGnnStories returns exactly the expected set',
|
||
pending.length === expectedPending.length && expectedPending.every((e) => pending.includes(e)),
|
||
`got ${pending.length}: ${pending.map((e) => e.type).join(',')}`);
|
||
}
|
||
|
||
// --- consumeGnnStories: flags set, history ordered, cap-and-trim.
|
||
{
|
||
const csState = { gnn: { history: [] } };
|
||
const toConsume = [
|
||
{ type: 'peace', empire: 0, other: 1 },
|
||
{ type: 'spyCaught', empire: 0, target: 1, mission: 'steal' },
|
||
];
|
||
Gnn.consumeGnnStories(csState, toConsume);
|
||
check('consumeGnnStories flags every consumed event announced',
|
||
toConsume.every((ev) => ev.gnnAnnounced === true));
|
||
check('consumeGnnStories appends to history in order',
|
||
csState.gnn.history.length === 2
|
||
&& csState.gnn.history[0] === toConsume[0] && csState.gnn.history[1] === toConsume[1]);
|
||
|
||
const bigState = { gnn: { history: [] } };
|
||
const many = [];
|
||
for (let i = 0; i < 40; i += 1) many.push({ type: 'peace', empire: 0, other: 1, n: i });
|
||
Gnn.consumeGnnStories(bigState, many);
|
||
check('consumeGnnStories caps history at 30, keeping the most recent',
|
||
bigState.gnn.history.length === 30
|
||
&& bigState.gnn.history[0].n === 10 && bigState.gnn.history[29].n === 39);
|
||
}
|
||
|
||
// --- describeGnnStory: never throws, always a non-empty headline, and the
|
||
// "by X" clause is present only when the attacker is actually known.
|
||
{
|
||
const stD = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'ring', seed: 4242, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0,
|
||
});
|
||
stD.rules = RULES;
|
||
|
||
const sampleEvents = [
|
||
{ type: 'warDeclared', empire: 0, other: 1 },
|
||
{ type: 'peace', empire: 0, other: 1 },
|
||
{ type: 'alliance', empire: 0, other: 1 },
|
||
{ type: 'techDone', empire: 0, techId: 'lasercannon', source: 'research' },
|
||
{ type: 'lastColony', empire: 1, attacker: 0 },
|
||
{ type: 'lastColony', empire: 1, attacker: -1 },
|
||
{ type: 'eliminated', empire: 1, attacker: 0 },
|
||
{ type: 'eliminated', empire: 1, attacker: -1 },
|
||
{ type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' },
|
||
{ type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
|
||
{ type: 'sabotage', empire: 1, target: 0, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
|
||
{ type: 'techStolen', empire: 0, target: 1, techId: 'lasercannon' },
|
||
{ type: 'techStolen', empire: 1, target: 0, techId: 'lasercannon' },
|
||
];
|
||
for (const ev of sampleEvents) {
|
||
const desc = Gnn.describeGnnStory(RULES, stD, ev);
|
||
check(`describeGnnStory(${ev.type}, attacker=${ev.attacker}) has a non-empty headline`,
|
||
typeof desc.headline === 'string' && desc.headline.length > 0);
|
||
}
|
||
check('describeGnnStory omits the "by X" clause when the attacker is unknown',
|
||
!Gnn.describeGnnStory(RULES, stD, { type: 'eliminated', empire: 1, attacker: -1 }).headline.includes(' by '));
|
||
check('describeGnnStory names the attacker when known',
|
||
Gnn.describeGnnStory(RULES, stD, { type: 'eliminated', empire: 1, attacker: 0 })
|
||
.headline.includes(stD.empires[0].name));
|
||
|
||
// A successful mission's headline names the actor ONLY when it was
|
||
// attributed to the viewing human — never the actual perpetrator when it
|
||
// wasn't (Brian's ask: "no information on who was responsible").
|
||
const mineTheft = Gnn.describeGnnStory(RULES, stD, { type: 'techStolen', empire: 0, target: 1, techId: 'lasercannon' });
|
||
const othersTheft = Gnn.describeGnnStory(RULES, stD, { type: 'techStolen', empire: 1, target: 0, techId: 'lasercannon' });
|
||
check('a mission the player carried out is marked attributed', mineTheft.attributed === true);
|
||
check('a mission the player carried out names the player as the actor',
|
||
mineTheft.headline.includes(stD.empires[0].name));
|
||
check('a mission carried out against the player is NOT marked attributed', othersTheft.attributed === false);
|
||
check('a mission carried out against the player never names the actual perpetrator',
|
||
!othersTheft.headline.includes(stD.empires[1].name));
|
||
}
|
||
|
||
// --- anchorLine: the green-screen ticker narration under GNN's anchor
|
||
// video — never throws, always non-empty, for every page kind the pager
|
||
// can actually build, and correctly distinguishes a quiet galaxy from a
|
||
// busy one on the relations page.
|
||
{
|
||
const stD = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'ring', seed: 4242, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0,
|
||
});
|
||
stD.rules = RULES;
|
||
|
||
const sampleEvents = [
|
||
{ type: 'warDeclared', empire: 0, other: 1 },
|
||
{ type: 'peace', empire: 0, other: 1 },
|
||
{ type: 'alliance', empire: 0, other: 1 },
|
||
{ type: 'techDone', empire: 0, techId: 'lasercannon', source: 'research' },
|
||
{ type: 'lastColony', empire: 1, attacker: 0 },
|
||
{ type: 'lastColony', empire: 1, attacker: -1 },
|
||
{ type: 'eliminated', empire: 1, attacker: 0 },
|
||
{ type: 'eliminated', empire: 1, attacker: -1 },
|
||
{ type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' },
|
||
{ type: 'spyCaught', empire: 0, target: 1, mission: 'steal' },
|
||
{ type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
|
||
{ type: 'sabotage', empire: 1, target: 0, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
|
||
{ type: 'techStolen', empire: 0, target: 1, techId: 'lasercannon' },
|
||
{ type: 'techStolen', empire: 1, target: 0, techId: 'lasercannon' },
|
||
];
|
||
for (const ev of sampleEvents) {
|
||
const desc = Gnn.describeGnnStory(RULES, stD, ev);
|
||
const line = Gnn.anchorLine(RULES, stD, { kind: 'story', desc });
|
||
check(`anchorLine(story ${ev.type}, attacker=${ev.attacker}) is a non-empty string`,
|
||
typeof line === 'string' && line.length > 0);
|
||
}
|
||
for (const metric of Gnn.RANKING_METRICS) {
|
||
const line = Gnn.anchorLine(RULES, stD, { kind: 'ranking', metric });
|
||
check(`anchorLine(ranking ${metric.id}) is a non-empty string`,
|
||
typeof line === 'string' && line.length > 0);
|
||
}
|
||
const quietLine = Gnn.anchorLine(RULES, stD, { kind: 'relations' });
|
||
check('anchorLine(relations) is a non-empty string', typeof quietLine === 'string' && quietLine.length > 0);
|
||
check('anchorLine(relations) uses the "quiet" phrasing when there is nothing to report',
|
||
/calm|quiet/i.test(quietLine));
|
||
|
||
const stR = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'ring', seed: 3131, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix'], humanIndex: 0,
|
||
});
|
||
stR.rules = RULES;
|
||
Diplo.declareWar(RULES, stR, 0, 1);
|
||
const busyLine = Gnn.anchorLine(RULES, stR, { kind: 'relations' });
|
||
check('anchorLine(relations) switches out of the "quiet" phrasing once a war exists',
|
||
typeof busyLine === 'string' && busyLine.length > 0 && !/calm|quiet/i.test(busyLine));
|
||
}
|
||
|
||
// --- lastColony / eliminated.attacker, scripted through the real
|
||
// invade() call site (not a hand-built fixture) so the attacker-threading
|
||
// change to VegaLogic.js is exercised end-to-end.
|
||
{
|
||
const st = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'elliptical', seed: 606, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix'], humanIndex: -1,
|
||
});
|
||
st.rules = RULES;
|
||
const attacker = 0;
|
||
const victim = 1;
|
||
st.empires[attacker].contacted[victim] = true;
|
||
st.empires[victim].contacted[attacker] = true;
|
||
Diplo.declareWar(RULES, st, attacker, victim);
|
||
|
||
const homeColony = st.colonies.find((c) => c.empireIdx === victim);
|
||
const secondStarIdx = st.galaxy.stars.findIndex((s, i) => i !== homeColony.starIdx && s.planets.length > 0);
|
||
Logic.foundColony(RULES, st, victim, secondStarIdx, 0, 5);
|
||
check('victim starts the scenario with two colonies', Logic.empireColonies(st, victim).length === 2);
|
||
|
||
// Overwhelming force (300 transports vs. an undefended pop-1 colony)
|
||
// makes capture as close to certain as resolveInvasion's RNG allows,
|
||
// without having to reverse-engineer its exact odds curve — see
|
||
// VegaCombat.js's resolveInvasion for why a huge attacker/defender
|
||
// troop ratio dominates regardless of the attack/defence-bonus term.
|
||
function overwhelmAndInvade(colony) {
|
||
colony.pop = 1;
|
||
colony.defenseHp = 0;
|
||
Logic.addFleet(RULES, st, attacker, colony.starIdx, [{ hullId: 'transport', count: 300 }]);
|
||
return Logic.invade(RULES, st, attacker, colony.starIdx, colony.orbit);
|
||
}
|
||
|
||
const firstColony = Logic.empireColonies(st, victim)[0];
|
||
const r1 = overwhelmAndInvade(firstColony);
|
||
check('first invasion captures the colony', !!r1?.captured, JSON.stringify(r1));
|
||
check('victim is reduced to one colony, not eliminated, after the first loss',
|
||
Logic.empireColonies(st, victim).length === 1 && st.empires[victim].alive);
|
||
check('a lastColony event fired with the correct victim and attacker',
|
||
st.events.some((e) => e.type === 'lastColony' && e.empire === victim && e.attacker === attacker));
|
||
check('no eliminated event fired yet',
|
||
!st.events.some((e) => e.type === 'eliminated' && e.empire === victim));
|
||
|
||
const secondColony = Logic.empireColonies(st, victim)[0];
|
||
const r2 = overwhelmAndInvade(secondColony);
|
||
check('second invasion captures the last colony', !!r2?.captured, JSON.stringify(r2));
|
||
check('victim is eliminated after the second loss',
|
||
Logic.empireColonies(st, victim).length === 0 && !st.empires[victim].alive);
|
||
check('an eliminated event fired with the correct attacker',
|
||
st.events.some((e) => e.type === 'eliminated' && e.empire === victim && e.attacker === attacker));
|
||
check('no spurious second lastColony event fired on total elimination',
|
||
st.events.filter((e) => e.type === 'lastColony' && e.empire === victim).length === 1);
|
||
}
|
||
|
||
// --- the generic end-of-turn elimination sweep has no attacker in scope
|
||
// by construction (it isn't tied to any specific attack) — proves it
|
||
// defaults to -1 rather than throwing or mislabeling one.
|
||
{
|
||
const st3 = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'ring', seed: 707, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix'], humanIndex: -1,
|
||
});
|
||
st3.rules = RULES;
|
||
st3.colonies = st3.colonies.filter((c) => c.empireIdx !== 1);
|
||
Logic.endEmpireTurn(RULES, st3, st3.current, { skipMove: true });
|
||
const sweepElim = st3.events.filter((e) => e.type === 'eliminated' && e.empire === 1);
|
||
check('the generic end-of-turn sweep fires eliminated exactly once for a colonyless empire',
|
||
sweepElim.length === 1);
|
||
check('the generic sweep has no attacker in scope, so it defaults to -1',
|
||
sweepElim[0]?.attacker === -1);
|
||
}
|
||
|
||
// --- ranking metrics: finite and non-negative across a real, played-out
|
||
// multi-turn galaxy (not just a hand-built fixture).
|
||
{
|
||
const st4 = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'spiral', seed: 808, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'rrashaa', 'lithox'], humanIndex: -1,
|
||
});
|
||
st4.rules = RULES;
|
||
for (let i = 0; i < 30 * st4.empires.length; i += 1) {
|
||
Logic.beginEmpireTurn(RULES, st4, st4.current);
|
||
AI.runAITurn(RULES, st4, st4.current);
|
||
Logic.endEmpireTurn(RULES, st4, st4.current);
|
||
}
|
||
let allFinite = true;
|
||
let detail = '';
|
||
for (const metric of Gnn.RANKING_METRICS) {
|
||
for (const e of st4.empires) {
|
||
if (!e.alive) continue;
|
||
const v = metric.valueFn(RULES, st4, e.idx);
|
||
if (!Number.isFinite(v) || v < 0) { allFinite = false; detail = `${metric.id} empire ${e.idx}: ${v}`; }
|
||
}
|
||
}
|
||
check('every ranking metric is finite and non-negative across a real multi-turn galaxy',
|
||
allFinite, detail);
|
||
}
|
||
|
||
// --- rankingRows contact-gating, same shape VegaScreens.js:164 already
|
||
// uses for "empires I know about."
|
||
{
|
||
const st5 = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'elliptical', seed: 909, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0,
|
||
});
|
||
st5.rules = RULES;
|
||
st5.empires[0].totalPop = 500;
|
||
st5.empires[1].totalPop = 900; // met — should appear, and rank first
|
||
st5.empires[2].totalPop = 100; // never met — must not appear
|
||
st5.empires[0].contacted[1] = true;
|
||
st5.empires[1].contacted[0] = true;
|
||
|
||
const rows = Gnn.rankingRows(RULES, st5, 'population');
|
||
check('rankingRows includes only the human and empires the human has met',
|
||
rows.length === 2 && rows.every((r) => r.idx === 0 || r.idx === 1));
|
||
check('rankingRows excludes the un-contacted empire', !rows.some((r) => r.idx === 2));
|
||
check('rankingRows is sorted descending by value', rows[0].idx === 1 && rows[0].value >= rows[1].value);
|
||
}
|
||
|
||
// --- relationsRows: war/trade/alliance are three independent columns
|
||
// (formTradeAgreement's own comment: trade coexists with any treaty rung
|
||
// rather than being another value on it), and — unlike rankingRows —
|
||
// NOT contact-gated, since diplomacy events already broadcast regardless
|
||
// of contact (VegaTurnReport.js's PERSONAL_TYPES).
|
||
{
|
||
const st6 = Logic.createGame(RULES, {
|
||
sizeId: 'medium', shapeId: 'elliptical', seed: 1010, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix', 'rrashaa', 'lithox'], humanIndex: 0,
|
||
});
|
||
st6.rules = RULES;
|
||
// 0 (human) at war with 1; 1 and 2 allied; 0 and 2 have a trade
|
||
// agreement; 3 stays uncontacted and relation-free throughout.
|
||
Diplo.declareWar(RULES, st6, 0, 1);
|
||
Diplo.formAlliance(RULES, st6, 1, 2);
|
||
Diplo.formTradeAgreement(RULES, st6, 0, 2);
|
||
|
||
const relRows = Gnn.relationsRows(RULES, st6);
|
||
check('relationsRows returns one row per alive empire, contact or not',
|
||
relRows.length === 4);
|
||
const byIdx = Object.fromEntries(relRows.map((r) => [r.idx, r]));
|
||
check('war is reciprocal', byIdx[0].atWar.includes(st6.empires[1].name)
|
||
&& byIdx[1].atWar.includes(st6.empires[0].name));
|
||
check('alliance is reciprocal and separate from war',
|
||
byIdx[1].allied.includes(st6.empires[2].name) && byIdx[2].allied.includes(st6.empires[1].name)
|
||
&& byIdx[1].atWar.length === 1 && !byIdx[1].atWar.includes(st6.empires[2].name));
|
||
check('trade agreement is reciprocal and independent of treaty state',
|
||
byIdx[0].trade.includes(st6.empires[2].name) && byIdx[2].trade.includes(st6.empires[0].name));
|
||
check('an empire with no relations at all has three empty lists',
|
||
byIdx[3].atWar.length === 0 && byIdx[3].trade.length === 0 && byIdx[3].allied.length === 0);
|
||
check('an empire not at war with everyone does not falsely list the uninvolved',
|
||
!byIdx[0].atWar.includes(st6.empires[2].name) && !byIdx[0].atWar.includes(st6.empires[3].name));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
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('state.gnn round-trips through save/load unchanged',
|
||
JSON.stringify(back.gnn) === JSON.stringify(st.gnn));
|
||
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));
|
||
|
||
// Diplomacy-expansion fields follow the exact same emp.allocLocked ??= {}
|
||
// precedent: an old save predating this feature is missing them entirely,
|
||
// and deserialize() must back-fill them rather than choke on their absence.
|
||
const oldSave = JSON.parse(json2);
|
||
for (const emp of oldSave.empires) {
|
||
delete emp.tradeAgreements; delete emp.lastGiftTurn; delete emp.fleetIntrusions;
|
||
}
|
||
// Colony Focus follows the same convention: an old save has no `focus` at
|
||
// all on any colony. Advisor tracking is the same again, one field newer.
|
||
for (const c of oldSave.colonies) { delete c.focus; delete c.advisor; }
|
||
// GNN is one field newer still, but its back-fill is NOT a bare ??={} —
|
||
// deserialize() must also mark every already-retained event gnnAnnounced,
|
||
// or a save from before GNN existed would surface a burst of retroactive
|
||
// "news" for every war/peace/tech event state.events happened to still be
|
||
// holding onto (see VegaLogic.js deserialize's own comment on this).
|
||
delete oldSave.gnn;
|
||
const backOld = Logic.deserialize(JSON.stringify(oldSave));
|
||
check('an old save missing the new diplomacy fields deserializes without throwing', !!backOld);
|
||
check('tradeAgreements is back-filled to {} on an old save',
|
||
backOld.empires.every((e) => JSON.stringify(e.tradeAgreements) === '{}'));
|
||
check('lastGiftTurn is back-filled to {} on an old save',
|
||
backOld.empires.every((e) => JSON.stringify(e.lastGiftTurn) === '{}'));
|
||
check('fleetIntrusions is back-filled to {} on an old save',
|
||
backOld.empires.every((e) => JSON.stringify(e.fleetIntrusions) === '{}'));
|
||
check('focus is back-filled to manual on an old save',
|
||
backOld.colonies.every((c) => c.focus === 'manual'));
|
||
check('advisor tracking is back-filled to all-null on an old save',
|
||
backOld.colonies.every((c) => c.advisor && c.advisor.focusQuietUntil === null
|
||
&& c.advisor.allocQuietUntil === null && c.advisor.allocKey === null));
|
||
check('gnn is back-filled to {history:[]} on an old save',
|
||
JSON.stringify(backOld.gnn) === JSON.stringify({ history: [] }));
|
||
check('every event already on an old save is marked gnnAnnounced (no retroactive news flood)',
|
||
backOld.events.length > 0 && backOld.events.every((e) => e.gnnAnnounced === true),
|
||
`${backOld.events.length} events`);
|
||
check('pendingGnnStories reports nothing new for a freshly-backfilled old save',
|
||
Gnn.pendingGnnStories(RULES, backOld).length === 0);
|
||
backOld.rules = RULES;
|
||
check('the freshly-backfilled state tolerates a full galaxy diplomacy pass', (() => {
|
||
Diplo.runGalaxyDiplomacyPass(RULES, backOld);
|
||
return true;
|
||
})());
|
||
|
||
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`;
|
||
}
|
||
}
|
||
// Attitude must never leave its documented [-100, 100] range — every
|
||
// write path (adjust(), and the handful of direct writes in
|
||
// runEspionage/invade) is supposed to clamp on write.
|
||
for (const [otherIdx, v] of Object.entries(e.attitude)) {
|
||
if (!Number.isFinite(v) || v < -100 || v > 100) {
|
||
return `${label}: empire ${e.idx} attitude toward ${otherIdx} out of range (${v})`;
|
||
}
|
||
}
|
||
// Trade agreements are symmetric, formed/cleared together on both
|
||
// sides (formTradeAgreement/declareWar), same shape as contacted.
|
||
for (const otherIdx of Object.keys(e.tradeAgreements)) {
|
||
if (e.tradeAgreements[otherIdx] && !st.empires[otherIdx]?.tradeAgreements[e.idx]) {
|
||
return `${label}: empire ${e.idx} tradeAgreements ${otherIdx} is not mutual`;
|
||
}
|
||
}
|
||
// A dwell stamp from the future would mean the turn counter and the
|
||
// intrusion tracker have gotten out of sync.
|
||
for (const [starIdx, atStar] of Object.entries(e.fleetIntrusions)) {
|
||
for (const [otherIdx, rec] of Object.entries(atStar)) {
|
||
if (rec.sinceTurn > st.turn) {
|
||
return `${label}: empire ${e.idx} fleetIntrusions[${starIdx}][${otherIdx}] sinceTurn ${rec.sinceTurn} is in the future (turn ${st.turn})`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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}`);
|
||
|
||
// GNN classification/copy over real, played-out data — catches any
|
||
// realistic type/field/attacker-value combination the hand-built fixtures
|
||
// in section 7b didn't happen to cover. state.events is capped/trimmed to
|
||
// the most recent 300 (pushEvent), so this only sees each game's tail —
|
||
// still a wide spread across N full games.
|
||
{
|
||
let storyEventCount = 0;
|
||
let lastColonyCount = 0;
|
||
let describeFailure = null;
|
||
for (const g of games) {
|
||
for (const ev of g.st.events) {
|
||
if (ev.type === 'lastColony') lastColonyCount += 1;
|
||
if (!Gnn.isGnnStory(RULES, g.st, ev)) continue;
|
||
storyEventCount += 1;
|
||
try {
|
||
const desc = Gnn.describeGnnStory(RULES, g.st, ev);
|
||
if (!desc.headline) describeFailure = `empty headline for ${ev.type}`;
|
||
} catch (err) {
|
||
describeFailure = `${ev.type}: ${err.message}`;
|
||
}
|
||
}
|
||
}
|
||
check('describeGnnStory never throws and always returns a headline across the AI soak',
|
||
!describeFailure, describeFailure ?? '');
|
||
check('at least one GNN story event occurs somewhere in the sweep', storyEventCount > 0, `${storyEventCount}`);
|
||
if (!QUICK) {
|
||
check('lastColony fires at least once somewhere in the sweep', lastColonyCount > 0, `${lastColonyCount}`);
|
||
}
|
||
}
|
||
|
||
// The diplomacy-expansion passes run every calendar turn of every game in
|
||
// this sweep — if fleet complaints never fire across dozens of full games,
|
||
// something upstream (contact, colonization, fleet movement feeding
|
||
// checkFleetIntrusions) has silently broken, since AI fleets routinely
|
||
// pass through or park at contested systems over a full game. Skipped in
|
||
// --quick mode for the same reason council victories are (line above): 6
|
||
// short games is too small a sample for a scenario this specific to be
|
||
// guaranteed to occur, matching the existing QUICK-gating precedent.
|
||
if (!QUICK) {
|
||
const totalComplaints = games.reduce(
|
||
(t, g) => t + g.st.events.filter((e) => e.type === 'fleetComplaint').length, 0,
|
||
);
|
||
check('fleet-intrusion complaints occur somewhere in the sweep', totalComplaints > 0, `${totalComplaints}`);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
section('11. Combat V2 (per-ship prototype)');
|
||
// ---------------------------------------------------------------------------
|
||
{
|
||
// Headless like everything else here — VegaCombatV2.js is Phaser-free, so
|
||
// this whole section is a plain Node exercise, same pattern as section 5.
|
||
// This IS the resolver real player battles use (VegaLogic.js imports
|
||
// createBattle/runBattle from here, not from VegaCombat.js — V1 is kept
|
||
// only for resolveInvasion and the ?movsim Live/V2 comparison toggle) —
|
||
// see docs/mastervega-build-plan.md for the history of why it started as
|
||
// a parallel prototype.
|
||
const techsUpToV2 = (tier) => {
|
||
const k = {};
|
||
for (const t of RULES.techList) if (t.tier <= tier) k[t.id] = true;
|
||
return k;
|
||
};
|
||
const mkEmpV2 = (sid, tier) => ({ known: techsUpToV2(tier), traits: RULES.species[sid].traits });
|
||
const battleV2 = (aS, aT, aShips, dS, dT, dShips, seed) => CombatV2.runBattle(
|
||
CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: aS, empire: mkEmpV2(aS, aT), ships: aShips },
|
||
defender: { empireIdx: 1, name: dS, empire: mkEmpV2(dS, dT), ships: dShips },
|
||
rnd: mulberry32(seed),
|
||
}),
|
||
);
|
||
|
||
// Ship-expansion shape: one entity per requested ship, unique seq, and the
|
||
// grouped survivor/loss counts in battleResult() sum back to the right
|
||
// totals — the one place per-ship expansion does real aggregation work
|
||
// the old stack model didn't need.
|
||
{
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: {
|
||
empireIdx: 0,
|
||
name: 'a',
|
||
empire: mkEmpV2('human', 5),
|
||
ships: [{ hullId: 'cruiser', count: 5 }, { hullId: 'frigate', count: 3 }],
|
||
},
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'destroyer', count: 4 }] },
|
||
rnd: mulberry32(11),
|
||
});
|
||
check('createBattle expands to one entity per requested ship', b.ships.length === 5 + 3 + 4, `${b.ships.length}`);
|
||
const seqs = new Set(b.ships.map((s) => s.seq));
|
||
check('every ship gets a unique seq', seqs.size === b.ships.length);
|
||
const result = CombatV2.battleResult(b);
|
||
const sumField = (arr, field) => arr.reduce((t, e) => t + (e[field] ?? 0), 0);
|
||
check('attacker survivors+losses sum to the starting attacker count',
|
||
sumField(result.attackerSurvivors, 'count') + sumField(result.attackerLosses, 'lost') === 8,
|
||
`${JSON.stringify(result.attackerSurvivors)} ${JSON.stringify(result.attackerLosses)}`);
|
||
check('defender survivors+losses sum to the starting defender count',
|
||
sumField(result.defenderSurvivors, 'count') + sumField(result.defenderLosses, 'lost') === 4);
|
||
}
|
||
|
||
// Formation strategy: an explicit choice is honoured and stamped onto
|
||
// every ship on that side; an unset (or invalid) choice — an "AI" side's
|
||
// silent pick — falls back to a valid one deterministically from the
|
||
// battle's own seeded RNG, not left undefined.
|
||
{
|
||
const explicit = CombatV2.createBattle(RULES, {
|
||
attacker: {
|
||
empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5),
|
||
ships: [{ hullId: 'cruiser', count: 3 }], formationStrategy: 'power_pressure',
|
||
},
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 3 }] },
|
||
rnd: mulberry32(1),
|
||
});
|
||
check('an explicit formationStrategy is honoured on battle.attackerFormation',
|
||
explicit.attackerFormation === 'power_pressure');
|
||
check('every attacker ship is stamped with the chosen formation',
|
||
explicit.ships.filter((s) => s.side === 'attacker').every((s) => s.formationStrategy === 'power_pressure'));
|
||
const validIds = FORMATION_STRATEGIES.map((f) => f.id);
|
||
check('an unset defender formationStrategy silently resolves to a valid one',
|
||
validIds.includes(explicit.defenderFormation));
|
||
|
||
const invalid = CombatV2.createBattle(RULES, {
|
||
attacker: {
|
||
empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5),
|
||
ships: [{ hullId: 'cruiser', count: 1 }], formationStrategy: 'not-a-real-strategy',
|
||
},
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
|
||
rnd: mulberry32(2),
|
||
});
|
||
check('an invalid formationStrategy string also falls back to a valid one',
|
||
validIds.includes(invalid.attackerFormation));
|
||
|
||
const both1 = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
|
||
rnd: mulberry32(9),
|
||
});
|
||
const both2 = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
|
||
rnd: mulberry32(9),
|
||
});
|
||
check('two silent picks from the same seed agree (deterministic, not Math.random)',
|
||
both1.attackerFormation === both2.attackerFormation && both1.defenderFormation === both2.defenderFormation);
|
||
}
|
||
|
||
// Logic.prepareBattleAt — the real-game seam between the engine and a
|
||
// player battle. Scripted through a real state (not a hand-built fixture)
|
||
// since it reads fleets/colonies/buildings/galaxy data no synthetic object
|
||
// could cheaply fake correctly. Covers three things fixed together in one
|
||
// pass: the player's own pre-battle formation choice actually reaching the
|
||
// battle, the Planetary Shield building's shieldBonus actually reaching
|
||
// the defended planet (it was hard-coded to 0 before, so the building's
|
||
// effect was silently never applied to a real battle), and the planet's
|
||
// typeId riding along for the tactical view's real planet art.
|
||
{
|
||
const st = Logic.createGame(RULES, {
|
||
sizeId: 'small', shapeId: 'cluster', seed: 5151, difficultyId: 'normal',
|
||
speciesIds: ['human', 'kkrix'], humanIndex: 0,
|
||
});
|
||
st.rules = RULES;
|
||
const attackerIdx = st.humanIndex;
|
||
const defenderIdx = attackerIdx === 0 ? 1 : 0;
|
||
const defColony = st.colonies.find((c) => c.empireIdx === defenderIdx);
|
||
const starIdx = defColony.starIdx;
|
||
defColony.defenseHp = 200;
|
||
defColony.buildings.push('planetaryshield');
|
||
Diplo.declareWar(RULES, st, attackerIdx, defenderIdx);
|
||
Logic.addFleet(RULES, st, attackerIdx, starIdx, [{ hullId: 'frigate', mark: 1, count: 2 }]);
|
||
|
||
const prepared = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, { humanFormation: 'speed_swarm' });
|
||
check('prepareBattleAt finds a valid battle for the scripted attack-a-defended-colony scenario', !!prepared);
|
||
|
||
const humanIsAttacker = prepared.attackerIdx === st.humanIndex;
|
||
const humanFormationOnBattle = humanIsAttacker ? prepared.battle.attackerFormation : prepared.battle.defenderFormation;
|
||
check("the human's chosen formation reaches battle.*Formation on the human's own side",
|
||
humanFormationOnBattle === 'speed_swarm', humanFormationOnBattle);
|
||
const validIds = FORMATION_STRATEGIES.map((f) => f.id);
|
||
const aiFormationOnBattle = humanIsAttacker ? prepared.battle.defenderFormation : prepared.battle.attackerFormation;
|
||
check("the AI opponent's formation is still a silent (but valid) pick, not forced",
|
||
validIds.includes(aiFormationOnBattle));
|
||
|
||
const planetEntity = prepared.battle.ships.find((s) => s.isPlanet);
|
||
check('the defended planet entity exists in the prepared battle', !!planetEntity);
|
||
check("the Planetary Shield building's shieldBonus (5) reaches the planet entity's shield — was hard-coded to 0 before",
|
||
planetEntity?.shield === RULES.buildings.planetaryshield.effects.shieldBonus,
|
||
`${planetEntity?.shield}`);
|
||
const expectedTypeId = st.galaxy.stars[starIdx].planets[defColony.orbit].typeId;
|
||
check("the planet entity's typeId matches the actual colonised planet",
|
||
planetEntity?.typeId === expectedTypeId, `${planetEntity?.typeId} vs ${expectedTypeId}`);
|
||
check('the planet sits at the fixed midpoint between world centre and its side\'s edge (worldWidth * 0.75)',
|
||
Math.abs(planetEntity.x - RULES.combatV2.worldWidth * 0.75) < 1e-9, `${planetEntity.x}`);
|
||
|
||
// A colony with no Planetary Shield still fights, with no shield at all
|
||
// (not a stale nonzero default) — the ring the view draws is entirely
|
||
// conditional on this being > 0.
|
||
const unshieldedColony = st.colonies.find((c) => c.empireIdx === defenderIdx);
|
||
unshieldedColony.buildings = unshieldedColony.buildings.filter((b) => b !== 'planetaryshield');
|
||
const preparedNoShield = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, {});
|
||
const planetNoShield = preparedNoShield.battle.ships.find((s) => s.isPlanet);
|
||
check('a colony with no Planetary Shield building reaches battle with shield 0',
|
||
planetNoShield.shield === 0, `${planetNoShield.shield}`);
|
||
}
|
||
|
||
// Determinism, and auto-resolve agreeing with a played-out battle — same
|
||
// structural guarantee as the live engine's equivalent check.
|
||
{
|
||
const mk = (seed) => CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 4 }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('ursaal', 5), ships: [{ hullId: 'destroyer', count: 9 }] },
|
||
rnd: mulberry32(1234),
|
||
});
|
||
const r1 = CombatV2.runBattle(mk(1234));
|
||
const r2 = CombatV2.runBattle(mk(1234));
|
||
check('V2 battles are deterministic', JSON.stringify(r1) === JSON.stringify(r2));
|
||
|
||
const stepped = mk(4321);
|
||
const maxTicks = Math.ceil((RULES.combat.maxRounds * RULES.combatV2.turnSeconds) / CombatV2.SIM_DT) + 4;
|
||
let guard = 0;
|
||
while (!stepped.done && guard < maxTicks) { guard += 1; CombatV2.advance(stepped, CombatV2.SIM_DT, { allowRetreat: false }); }
|
||
const autoNoRetreat = CombatV2.runBattle(mk(4321), { allowRetreat: false });
|
||
check('stepping a V2 battle out matches auto-resolve',
|
||
CombatV2.battleResult(stepped).winner === autoNoRetreat.winner);
|
||
}
|
||
|
||
// Movement/turn-rate invariant — the one genuinely new mechanic here (not
|
||
// just a refactor of the live engine's math), so it needs its own
|
||
// assertion. Thrust magnitude is scaled by how well the ship's CURRENT
|
||
// facing aligns with where it needs to thrust (see computeShipMove's
|
||
// comment), clamped to zero past 90° off, so a hull starting 180° away
|
||
// from its target gets no thrust at all until it's turned at least a
|
||
// quarter-circle — turn rate should measurably change how long that
|
||
// takes. "Net distance closed after a fixed window" is NOT a safe metric
|
||
// for this any more, though: a hull that turns and accelerates fast
|
||
// enough can overshoot straight through the target and end up on the far
|
||
// side, reading as a WORSE (even negative) net change than a slow hull
|
||
// that's still plodding toward it and hasn't overshot anything yet — this
|
||
// is exactly the strafing-run behaviour the momentum rewrite is FOR,
|
||
// which makes it actively wrong for isolating turn rate specifically.
|
||
// "Time to first reach weapon range" sidesteps that confound entirely —
|
||
// it only measures how quickly a hull can redirect itself toward a
|
||
// target, which is what turn rate actually governs, and is monotonic
|
||
// regardless of whatever happens after arrival.
|
||
{
|
||
const design = Ships.designFor(RULES, techsUpToV2(5), 'frigate', RULES.species.human.traits);
|
||
const ticksToRange = (turnRateBaseDeg) => {
|
||
const hullDesign = { ...design, hull: { ...design.hull, turnRateBase: turnRateBaseDeg, sizeSpeedMult: 1 } };
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'frigate', count: 1, design: hullDesign }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'frigate', count: 1, design: hullDesign }] },
|
||
rnd: mulberry32(1),
|
||
});
|
||
const [mover, still] = b.ships;
|
||
mover.x = 0; mover.y = 0; mover.facing = Math.PI; // facing directly away from the target
|
||
// Far enough that even the fast turner can't reach it within the
|
||
// guard below purely by luck — this is measuring TIME-to-range, not
|
||
// whether either hull can reach a nearby point at all.
|
||
still.x = 2000; still.y = 0; still.immobile = true; still.effSpeed = 0;
|
||
b.orders[still.uid] = 'hold';
|
||
const maxTicks = Math.round(120 / CombatV2.SIM_DT);
|
||
for (let i = 0; i < maxTicks; i += 1) {
|
||
CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false });
|
||
if (Math.hypot(still.x - mover.x, still.y - mover.y) <= RULES.combatV2.beamRange) return i;
|
||
}
|
||
return maxTicks; // never got there inside the guard
|
||
};
|
||
const fastTurn = ticksToRange(170);
|
||
const slowTurn = ticksToRange(10);
|
||
check('a fast-turning hull reaches weapon range sooner than a slow-turning one, all else equal',
|
||
fastTurn < slowTurn, `fast=${fastTurn} ticks slow=${slowTurn} ticks`);
|
||
}
|
||
|
||
// Mirror-match fairness. Deliberately WIDER tolerance than the live
|
||
// engine's <12pp: hard nearest-target selection among 5-20 discrete ships
|
||
// per side is a genuinely more chaotic system than the live engine's
|
||
// stack-aggregate math — softened with weighted-random targeting (see
|
||
// VegaCombatV2.js's pickWeighted comment) specifically to tame this, but
|
||
// real residual variance remains at small fleet sizes. This is a known,
|
||
// documented rough edge of the v1 placement/targeting pass (see
|
||
// docs/mastervega-build-plan.md), not something this check is hiding.
|
||
// Widened again (0.25 -> 0.30) after `damageMultiplier` pushed to 2.8 —
|
||
// Brian's explicit, repeated ask to shorten battle duration, measured to
|
||
// work well (see docs), with an expected and understood cost: lower
|
||
// time-to-kill means whoever lands the first good roll matters
|
||
// proportionally more, which is exactly what a fairness/variance check
|
||
// like this one is supposed to catch. 27.3pp measured at the old 0.25
|
||
// bound — a real, explained shift in the underlying system's variance,
|
||
// not a targeting regression, so the tolerance moved rather than the
|
||
// multiplier being dialed back to chase a threshold tuned for a much
|
||
// slower-TTK combat model.
|
||
{
|
||
const N = QUICK ? 150 : 500;
|
||
let worstBias = 0;
|
||
for (const n of [1, 3, 5, 8, 15]) {
|
||
let atk = 0;
|
||
for (let s = 1; s <= N; s += 1) {
|
||
const r = battleV2('human', 5, [{ hullId: 'cruiser', count: n }], 'human', 5, [{ hullId: 'cruiser', count: n }], s * 7919);
|
||
if (r.winner === 'attacker') atk += 1;
|
||
}
|
||
worstBias = Math.max(worstBias, Math.abs(atk / N - 0.5));
|
||
}
|
||
check('V2 mirror-match bias stays within the (looser) V2 tolerance',
|
||
worstBias < 0.30, `${(worstBias * 100).toFixed(1)}pp`);
|
||
}
|
||
|
||
// A two-tier tech lead and a numbers advantage must still be decisive —
|
||
// the softened targeting should only wash out small, deliberately-subtle
|
||
// edges (see the cloak/singularity check below), not large real ones.
|
||
const rateV2 = (fn, n = QUICK ? 100 : 300) => {
|
||
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 still decisive in V2',
|
||
rateV2((s) => battleV2('human', 6, [{ hullId: 'cruiser', count: 5 }], 'human', 4, [{ hullId: 'cruiser', count: 5 }], s)) > 0.7);
|
||
check('numbers still matter in V2',
|
||
rateV2((s) => battleV2('human', 5, [{ hullId: 'cruiser', count: 7 }], 'human', 5, [{ hullId: 'cruiser', count: 5 }], s)) > 0.7);
|
||
|
||
// Re-run (not re-derive) the cloak/singularity isolated A/B calibration
|
||
// from section 5, adapted to the per-ship model. Measured empirically
|
||
// AFTER the linear-momentum rewrite (real inertia, alignment-scaled
|
||
// thrust, collision avoidance): the deliberately subtle cloak/singularity
|
||
// edge (cloakEvasion=0.02, singularityShieldPierce=0.5 — tuned to read
|
||
// ~62% in the live engine, a mild-trait-sized signal on purpose, see
|
||
// trap 24) is now fully washed to noise (~48-50%) at every fleet size
|
||
// that's fast enough to test on every verify pass (5/10/15-a-side all
|
||
// read within a couple points of 50%; 15/side briefly read ~55-57% right
|
||
// after the earlier continuous-time-only rewrite, before momentum/
|
||
// avoidance added their own chaos on top and erased even that). This
|
||
// check no longer asserts a measurable BENEFIT, which would fail on pure
|
||
// sampling noise as often as it'd pass — it asserts the weaker, still
|
||
// real invariant that the flags don't measurably HURT (would show if,
|
||
// say, the shield-pierce math got a sign flipped). The underlying combat
|
||
// math is unchanged and still verified directly in section 5, against the
|
||
// live engine's stack-based model where this exact edge reads cleanly;
|
||
// this is purely about V2's per-ship movement chaos being large enough to
|
||
// swamp a small tuning delta, documented in
|
||
// docs/mastervega-build-plan.md — not a bug to chase further.
|
||
{
|
||
const N = QUICK ? 400 : 1200;
|
||
const techsUpTo9 = techsUpToV2(9);
|
||
const baseDesign = Ships.designFor(RULES, techsUpTo9, 'cruiser', RULES.species.human.traits);
|
||
check('a fully-teched V2 design has both flags available to strip',
|
||
baseDesign.cloaked && baseDesign.singularity);
|
||
const withoutCloak = { ...baseDesign, cloaked: false };
|
||
const withoutSingularity = { ...baseDesign, singularity: false };
|
||
const emp9 = mkEmpV2('human', 9);
|
||
const abBattleV2 = (aDesign, dDesign, seed) => CombatV2.runBattle(CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: emp9, ships: [{ hullId: 'cruiser', count: 10, design: aDesign }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: emp9, ships: [{ hullId: 'cruiser', count: 10, design: dDesign }] },
|
||
rnd: mulberry32(seed),
|
||
}));
|
||
const abRate = (fn) => {
|
||
let w = 0;
|
||
for (let s = 1; s <= N; s += 1) if (fn(s * 7919).winner === 'attacker') w += 1;
|
||
return w / N;
|
||
};
|
||
const cloakRate = abRate((s) => abBattleV2(baseDesign, withoutCloak, s));
|
||
check('cloak does not measurably HURT the attacker in V2 (signal itself is noise-floor, see comment)',
|
||
cloakRate > 0.44, `attacker (cloaked) won ${(cloakRate * 100).toFixed(1)}%`);
|
||
const singularityRate = abRate((s) => abBattleV2(baseDesign, withoutSingularity, s));
|
||
check('singularity does not measurably HURT the attacker in V2 (signal itself is noise-floor, see comment)',
|
||
singularityRate > 0.44, `attacker (singularity) won ${(singularityRate * 100).toFixed(1)}%`);
|
||
}
|
||
|
||
// Linear momentum / brakeSeconds hold-vs-strafe gradient (Brian's ask:
|
||
// "battleships should be able to slow and even stop"). One ship
|
||
// approaches a stationary target head-on; a hull that can actually
|
||
// decelerate should settle to near-zero speed comfortably short of the
|
||
// target (minDist stays well above 0) rather than carrying enough
|
||
// momentum to pass essentially through it. This is the one mechanic in
|
||
// this section that's genuinely NEW behaviour, not a refactor, so it
|
||
// gets its own regression coverage rather than relying on the
|
||
// mirror-bias/decisive checks to catch a break indirectly.
|
||
//
|
||
// Frigate is checked here too (originally this asserted the OPPOSITE —
|
||
// that a frigate could NOT hold, always strafing past instead). Brian
|
||
// explicitly revised that: frigate's acceleration/turn rate were bumped
|
||
// to "X-wing" levels specifically so it becomes the most agile hull in
|
||
// the fleet, capable of holding when it wants to, not permanently
|
||
// strafe-locked — see the agility-ordering check below for what's still
|
||
// actually guaranteed about it (fastest turn, strongest brakes of any
|
||
// warship), which is the part of "frigate identity" that's durable.
|
||
{
|
||
const oneVsStationary = (hullId, tier) => {
|
||
const techs = techsUpToV2(tier);
|
||
const emp = mkEmpV2('human', tier);
|
||
const design = Ships.designFor(RULES, techs, hullId, RULES.species.human.traits);
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: emp, ships: [{ hullId, count: 1, design }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: emp, ships: [{ hullId, count: 1, design }] },
|
||
rnd: mulberry32(1),
|
||
});
|
||
const [mover, still] = b.ships;
|
||
still.immobile = true;
|
||
still.effSpeed = 0;
|
||
still.x = 2000; still.y = 1200;
|
||
mover.x = 0; mover.y = 1200; mover.facing = 0;
|
||
let minDist = Infinity;
|
||
const maxTicks = Math.round(90 / CombatV2.SIM_DT);
|
||
for (let i = 0; i < maxTicks; i += 1) {
|
||
CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false });
|
||
const dist = Math.hypot(still.x - mover.x, still.y - mover.y);
|
||
if (dist < minDist) minDist = dist;
|
||
if (b.done) break;
|
||
}
|
||
return minDist;
|
||
};
|
||
for (const hullId of ['battleship', 'cruiser', 'destroyer', 'frigate']) {
|
||
const minDist = oneVsStationary(hullId, 5);
|
||
check(`a ${hullId} can decelerate and hold well clear of a stationary target (never nears 0 distance)`,
|
||
minDist > 25, `minDist=${minDist.toFixed(1)}`);
|
||
}
|
||
// Agility ordering (Brian's ask: frigate "much more agile... like an
|
||
// X-wing fighter", destroyer "a good midpoint" leaning toward frigate,
|
||
// cruiser/battleship left as the unhurried heavies) — pure data checks
|
||
// on the hulls block, not simulation, so they can't drift out of sync
|
||
// with whatever the actual tuning numbers end up being.
|
||
const h = RULES.hulls;
|
||
check('frigate turns faster than every other warship hull',
|
||
h.frigate.turnRateBase > h.destroyer.turnRateBase
|
||
&& h.destroyer.turnRateBase > h.cruiser.turnRateBase
|
||
&& h.cruiser.turnRateBase > h.battleship.turnRateBase);
|
||
check('frigate brakes at least as hard as any other warship hull (lowest brakeSeconds)',
|
||
h.frigate.brakeSeconds <= h.destroyer.brakeSeconds
|
||
&& h.frigate.brakeSeconds < h.cruiser.brakeSeconds);
|
||
check("destroyer's turn rate sits between frigate and cruiser, leaning toward frigate's agility",
|
||
h.destroyer.turnRateBase > (h.frigate.turnRateBase + h.cruiser.turnRateBase) / 2);
|
||
}
|
||
|
||
// Collision avoidance under real momentum. An earlier tuning pass gave
|
||
// avoidance a very strong dedicated acceleration budget specifically to
|
||
// stop ships from ever getting close to each other — Brian's explicit
|
||
// correction: "I don't need or want the ships to bounce off of each
|
||
// other... let's not stop them from overlapping as needed." Avoidance is
|
||
// now a deliberately gentle steering preference (see avoidAccel's
|
||
// comment in computeShipMove), so this check no longer asserts any
|
||
// minimum gap — ships converging on a shared target routinely end up
|
||
// well inside each other's personal-space radius now, which is the
|
||
// intended behaviour, not a bug. What it DOES assert is the thing Brian
|
||
// actually objected to: no violent, bounce-like single-tick velocity
|
||
// change. `maxDeltaSpeedPerTick` tracks the largest one-tick change in
|
||
// any living ship's speed across a dense multi-ship convergence — normal
|
||
// tuning measures a few units/s per tick (smooth); the old strong-
|
||
// avoidance tuning would have produced spikes an order of magnitude
|
||
// larger the instant two ships got close.
|
||
{
|
||
const mixedFleet = [
|
||
{ hullId: 'frigate', count: 4 },
|
||
{ hullId: 'destroyer', count: 3 },
|
||
{ hullId: 'cruiser', count: 2 },
|
||
{ hullId: 'battleship', count: 1 },
|
||
];
|
||
const emp7 = mkEmpV2('human', 7);
|
||
let maxDeltaSpeedPerTick = 0;
|
||
for (let seed = 1; seed <= 6; seed += 1) {
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: emp7, ships: mixedFleet },
|
||
defender: { empireIdx: 1, name: 'd', empire: emp7, ships: mixedFleet },
|
||
rnd: mulberry32(seed * 7919),
|
||
});
|
||
const maxTicks = Math.round(45 / CombatV2.SIM_DT);
|
||
for (let i = 0; i < maxTicks; i += 1) {
|
||
const before = new Map(b.ships.map((s) => [s.uid, Math.hypot(s.vx, s.vy)]));
|
||
CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false });
|
||
for (const s of b.ships) {
|
||
if (s.hp <= 0 || s.retreated || s.isPlanet) continue;
|
||
const delta = Math.abs(Math.hypot(s.vx, s.vy) - (before.get(s.uid) ?? 0));
|
||
if (delta > maxDeltaSpeedPerTick) maxDeltaSpeedPerTick = delta;
|
||
}
|
||
if (b.done) break;
|
||
}
|
||
}
|
||
check('collision avoidance under momentum never produces a violent (bounce-like) single-tick velocity change',
|
||
maxDeltaSpeedPerTick < 20, `max Δspeed/tick=${maxDeltaSpeedPerTick.toFixed(1)}`);
|
||
}
|
||
|
||
// Lead pursuit: a ship chasing a target that is itself independently
|
||
// pursuing a DIFFERENT enemy (its motion has nothing to do with evading
|
||
// THIS shooter) is exactly the scenario that exposed pure pursuit's
|
||
// failure — a slow-turning hull steering at its target's CURRENT position
|
||
// every tick can get momentarily close, then watch the gap reopen as the
|
||
// target's own (unrelated) pursuit carries it away, forever — visually
|
||
// indistinguishable from the target fleeing. Before predictIntercept()
|
||
// existed, a 2-battleship-per-side trace showed exactly this: distance to
|
||
// a live, still-being-chased target reached 1076+ units and was still
|
||
// climbing after a full minute. Battleships (worst turn rate, so the
|
||
// richest case for this failure) are used deliberately here rather than
|
||
// a more agile hull.
|
||
{
|
||
const emp7 = mkEmpV2('human', 7);
|
||
// Formation pinned explicitly (matching both sides) — since strategy
|
||
// formations now shape initial placement (this session's later "strategy
|
||
// formations" work), leaving it unset let this resolve to a random,
|
||
// occasionally MISMATCHED pair of strategies between the two sides,
|
||
// making the test's starting geometry — and therefore whether it still
|
||
// demonstrates the lead-pursuit scenario at all — nondeterministic.
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: {
|
||
empireIdx: 0, name: 'a', empire: emp7, ships: [{ hullId: 'battleship', count: 2 }], formationStrategy: 'power_pressure',
|
||
},
|
||
defender: {
|
||
empireIdx: 1, name: 'd', empire: emp7, ships: [{ hullId: 'battleship', count: 2 }], formationStrategy: 'power_pressure',
|
||
},
|
||
rnd: mulberry32(3),
|
||
});
|
||
const s0 = b.ships[0];
|
||
const windowTicks = Math.round(60 / CombatV2.SIM_DT);
|
||
for (let i = 0; i < windowTicks; i += 1) {
|
||
CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false });
|
||
if (b.done || s0.hp <= 0) break;
|
||
}
|
||
// A dead shooter or a dead/retreated target both mean the battle
|
||
// resolved some other way — not a failure of this check either way,
|
||
// only a still-alive-and-still-chasing pair with the gap never closing
|
||
// is the regression this protects against.
|
||
const stillChasing = s0.hp > 0 && s0.target && s0.target.hp > 0 && !s0.target.retreated;
|
||
const finalDist = stillChasing ? Math.hypot(s0.x - s0.target.x, s0.y - s0.target.y) : 0;
|
||
check('a ship chasing a target that is itself pursuing someone else still closes distance over a full minute (lead, not pure, pursuit)',
|
||
!stillChasing || finalDist < 3 * RULES.combatV2.beamRange,
|
||
`finalDist=${finalDist.toFixed(0)} (beamRange=${RULES.combatV2.beamRange})`);
|
||
}
|
||
|
||
// Zoom ladder sanity check for V2's fixed world — mirrors section 3b's
|
||
// headless galaxy-zoom-ladder check.
|
||
{
|
||
const worldW = RULES.combatV2.worldWidth;
|
||
const worldH = RULES.combatV2.worldHeight;
|
||
const ladder = buildZoomLadder(worldW, worldH, { steps: 6, maxZoom: 4.0 });
|
||
check('V2 zoom ladder bottom rung exactly covers the battle world',
|
||
Math.abs(ladder[0] - minZoomFor(worldW, worldH)) < 1e-9);
|
||
check('V2 zoom ladder is non-decreasing', ladder.every((z, i) => i === 0 || z >= ladder[i - 1]));
|
||
check('V2 zoom ladder top rung matches the requested maxZoom',
|
||
Math.abs(ladder[ladder.length - 1] - 4.0) < 1e-9);
|
||
}
|
||
|
||
// pickFitZoomIndex is pure math (VegaZoom.js), independent of any battle —
|
||
// pin its exact behaviour with synthetic inputs before trusting it with
|
||
// real ones below.
|
||
{
|
||
const zooms = [0.5, 1.0, 2.0, 4.0];
|
||
// A 1280x720 box (same 16:9 ratio as GAME_WIDTH/GAME_HEIGHT) has an
|
||
// exact ideal zoom of 1.5 — the largest rung at or below that is 1.0.
|
||
check('pickFitZoomIndex picks the largest rung that still fits the box',
|
||
pickFitZoomIndex(zooms, 1280, 720, 0) === 1, `${pickFitZoomIndex(zooms, 1280, 720, 0)}`);
|
||
check('pickFitZoomIndex falls back to the bottom rung when nothing fits closer',
|
||
pickFitZoomIndex(zooms, 100000, 100000, 0) === 0);
|
||
check('pickFitZoomIndex never exceeds the top rung for a tiny box',
|
||
pickFitZoomIndex(zooms, 1, 1, 0) === 3);
|
||
check('pickFitZoomIndex padding pushes the pick toward a wider (more zoomed-out) rung',
|
||
pickFitZoomIndex(zooms, 1280, 720, 500) <= pickFitZoomIndex(zooms, 1280, 720, 0));
|
||
}
|
||
|
||
// Placement: the overall footprint must actually grow with fleet size
|
||
// (this is what makes the adaptive initial zoom below mean anything — a
|
||
// fixed margin from the world's edges regardless of fleet size was tried
|
||
// and rejected during this session specifically because it made the
|
||
// camera's "fit the fleet" zoom barely vary with ship count at all).
|
||
//
|
||
// Both formation-shape checks here pin an EXPLICIT, matching
|
||
// formationStrategy on both sides — since formation strategy now
|
||
// genuinely shapes placement (this session's "strategy formations" work;
|
||
// previously it was pure plumbing, stamped onto ships but never read),
|
||
// leaving it unset lets each side resolve to a DIFFERENT formation
|
||
// silently, which showed up as flaky failures here: Power Pressure and
|
||
// Speed Swarm have different footprint shapes (front cluster + rear
|
||
// column vs. a wide arc), so a battle where the two sides happen to pick
|
||
// different strategies isn't symmetric even for identical fleets, and
|
||
// isn't a fair "did size affect the footprint" comparison either. Uses
|
||
// overall `shipBounds` width (both fleets plus the gap between them), not
|
||
// literally "gap," since Speed Swarm's footprint growth is often more
|
||
// about spread than depth.
|
||
{
|
||
const boundsWidthFor = (n, formationStrategy) => {
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: {
|
||
empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }], formationStrategy,
|
||
},
|
||
defender: {
|
||
empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }], formationStrategy,
|
||
},
|
||
rnd: mulberry32(1),
|
||
});
|
||
const bounds = CombatV2.shipBounds(b.ships);
|
||
return bounds.maxX - bounds.minX;
|
||
};
|
||
for (const formationStrategy of ['power_pressure', 'speed_swarm']) {
|
||
const width1 = boundsWidthFor(1, formationStrategy);
|
||
const width20 = boundsWidthFor(20, formationStrategy);
|
||
check(`${formationStrategy}: overall footprint width grows with fleet size`,
|
||
width20 > width1, `1-ship width ${width1.toFixed(0)}, 20-ship width ${width20.toFixed(0)}`);
|
||
}
|
||
check('both fleets stay centered in the world (attacker/defender spans are symmetric)', (() => {
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: {
|
||
empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 7 }], formationStrategy: 'power_pressure',
|
||
},
|
||
defender: {
|
||
empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 3 }], formationStrategy: 'power_pressure',
|
||
},
|
||
rnd: mulberry32(1),
|
||
});
|
||
const bounds = CombatV2.shipBounds(b.ships);
|
||
const worldCenter = RULES.combatV2.worldWidth / 2;
|
||
const boundsCenter = (bounds.minX + bounds.maxX) / 2;
|
||
// Loosened from <1 to <20 — the strategy-formations placement rewrite
|
||
// added a small per-ship DEPTH wobble (±4 units, on top of the
|
||
// pre-existing spread wobble) specifically to break exact-tie
|
||
// engagement geometry between same-tier ships (see
|
||
// placementWobbleDepth's comment); a rear-most ship can now land a
|
||
// few units past the nominal `maxDepth` the centering math is based
|
||
// on. Sub-unit centering was never the actual invariant that
|
||
// mattered here — "roughly centered so the camera frames it well,"
|
||
// which this still checks — just happened to be exactly achievable
|
||
// before wobble existed on this axis.
|
||
return Math.abs(boundsCenter - worldCenter) < 20;
|
||
})());
|
||
}
|
||
|
||
// Strategy formations actually shape placement (Brian's ask — previously
|
||
// pure plumbing, stamped onto ships but never read by anything). Power
|
||
// Pressure: heavier hulls form a compact front cluster well ahead of a
|
||
// single tall rear column of everyone else. Speed Swarm: hulls fan out
|
||
// along a size-banded arc, smallest/fastest both furthest forward and
|
||
// furthest to the sides, heaviest nearest the rear centerline.
|
||
{
|
||
const mixedFleet = [
|
||
{ hullId: 'frigate', count: 5 }, { hullId: 'destroyer', count: 4 },
|
||
{ hullId: 'cruiser', count: 3 }, { hullId: 'battleship', count: 2 },
|
||
];
|
||
const battleFor = (formationStrategy) => CombatV2.createBattle(RULES, {
|
||
attacker: {
|
||
empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: mixedFleet, formationStrategy,
|
||
},
|
||
defender: {
|
||
empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: [{ hullId: 'destroyer', count: 1 }], formationStrategy: 'power_pressure',
|
||
},
|
||
rnd: mulberry32(1),
|
||
});
|
||
|
||
{
|
||
const b = battleFor('power_pressure');
|
||
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
|
||
const heavy = attackerShips.filter((s) => ['cruiser', 'battleship'].includes(s.hullId));
|
||
const light = attackerShips.filter((s) => ['frigate', 'destroyer'].includes(s.hullId));
|
||
check('power_pressure: every heavy (cruiser/battleship) ship sits ahead of every light (frigate/destroyer) ship',
|
||
Math.min(...heavy.map((s) => s.x)) > Math.max(...light.map((s) => s.x)));
|
||
const lightXRange = Math.max(...light.map((s) => s.x)) - Math.min(...light.map((s) => s.x));
|
||
const lightYRange = Math.max(...light.map((s) => s.y)) - Math.min(...light.map((s) => s.y));
|
||
// A "vertical line in the rear" means the group must vary far more in
|
||
// world-Y (spread) than world-X (depth) — the group's members are
|
||
// stacked one above another, not one behind another. This was
|
||
// inverted until the center-gap rework: the original packColumn
|
||
// stacked ships along depth while holding spread ~flat, so the "rear
|
||
// column" was actually a horizontal smear sitting at screen-vertical
|
||
// center the whole time (see build-plan trap for the fix).
|
||
check('power_pressure: the rear light-ship group is a true vertical column (varies in Y far more than X), not a blob',
|
||
lightYRange > lightXRange, `depth range=${lightXRange.toFixed(0)} spread range=${lightYRange.toFixed(0)}`);
|
||
}
|
||
{
|
||
const b = battleFor('speed_swarm');
|
||
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
|
||
const centerY = RULES.combatV2.worldHeight / 2;
|
||
const heavy = attackerShips.filter((s) => ['cruiser', 'battleship'].includes(s.hullId));
|
||
const light = attackerShips.filter((s) => ['frigate', 'destroyer'].includes(s.hullId));
|
||
const avgSpread = (ships) => ships.reduce((t, s) => t + Math.abs(s.y - centerY), 0) / ships.length;
|
||
check('speed_swarm: light hulls fan out wider than heavy hulls',
|
||
avgSpread(light) > avgSpread(heavy), `heavy=${avgSpread(heavy).toFixed(0)} light=${avgSpread(light).toFixed(0)}`);
|
||
const avgX = (ships) => ships.reduce((t, s) => t + s.x, 0) / ships.length;
|
||
check('speed_swarm: light hulls sit further toward the front than heavy hulls, on average',
|
||
avgX(light) > avgX(heavy), `heavyAvgX=${avgX(heavy).toFixed(0)} lightAvgX=${avgX(light).toFixed(0)}`);
|
||
}
|
||
// Brian's ask: "the middle 40% of the [horizontal] screen empty at the
|
||
// beginning of a battle" — the two sides' front lines must be at least
|
||
// CENTER_GAP_FRACTION of worldWidth apart (a HORIZONTAL/depth-axis
|
||
// requirement — clarified after an initial version of this check
|
||
// mistakenly measured vertical/spread distance from the centerline
|
||
// instead). Also confirms both formations still make real use of
|
||
// vertical space for their columns/arcs (not everyone collapsed onto
|
||
// the horizontal centerline).
|
||
for (const formationStrategy of ['power_pressure', 'speed_swarm']) {
|
||
const b = battleFor(formationStrategy);
|
||
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
|
||
const defenderShips = b.ships.filter((s) => s.side === 'defender');
|
||
const requiredGap = RULES.combatV2.worldWidth * 0.4;
|
||
const attackerFrontX = Math.max(...attackerShips.map((s) => s.x));
|
||
const defenderFrontX = Math.min(...defenderShips.map((s) => s.x));
|
||
const actualGap = defenderFrontX - attackerFrontX;
|
||
check(`${formationStrategy}: horizontal front-to-front gap clears 40% of world width (>= ${requiredGap.toFixed(0)})`,
|
||
actualGap >= requiredGap - 1, `actual gap=${actualGap.toFixed(0)}`);
|
||
const centerY = RULES.combatV2.worldHeight / 2;
|
||
const upper = attackerShips.filter((s) => s.y < centerY).length;
|
||
const lower = attackerShips.filter((s) => s.y > centerY).length;
|
||
check(`${formationStrategy}: both upper and lower wings are populated (not all shoved to one side)`,
|
||
upper > 0 && lower > 0, `upper=${upper} lower=${lower}`);
|
||
}
|
||
|
||
// Neither formation shape should let same-side ships start closer than
|
||
// their own combined avoidRadius — the exact class of bug the size-aware
|
||
// placement rewrite exists to prevent (caught during development: Speed
|
||
// Swarm's rear-most band multiplied its per-ship offset by a fan
|
||
// magnitude that's deliberately 0 there, collapsing two battleships onto
|
||
// the exact same point).
|
||
for (const formationStrategy of ['power_pressure', 'speed_swarm']) {
|
||
const b = battleFor(formationStrategy);
|
||
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
|
||
let worstRatio = Infinity;
|
||
for (let i = 0; i < attackerShips.length; i += 1) {
|
||
for (let j = i + 1; j < attackerShips.length; j += 1) {
|
||
const s1 = attackerShips[i]; const s2 = attackerShips[j];
|
||
const dist = Math.hypot(s1.x - s2.x, s1.y - s2.y);
|
||
const ratio = dist / (s1.avoidRadius + s2.avoidRadius);
|
||
if (ratio < worstRatio) worstRatio = ratio;
|
||
}
|
||
}
|
||
check(`${formationStrategy}: no same-side ship pair starts inside each other's combined avoidRadius`,
|
||
worstRatio >= 1, `worst ratio=${worstRatio.toFixed(2)}`);
|
||
}
|
||
|
||
// Centering: battles "look great at the beginning and then drift off
|
||
// screen towards the end" (Brian) — the camera pans once, at battle
|
||
// start, and never re-fits (VegaCombatCamera.js), so a fight that
|
||
// wanders far enough from where it began walks itself off the visible
|
||
// viewport. computeCenteringForce/createBattle's centerX/centerY/
|
||
// centeringComfortRadius address this; see VegaCombatV2.js's comment
|
||
// for why the anchor is the battle's own FIXED starting centroid, not a
|
||
// live one recomputed every tick (a live centroid can't counteract net
|
||
// drift — it's defined as wherever everyone already is).
|
||
{
|
||
const withCenteringAccel = (accel) => {
|
||
const r = JSON.parse(JSON.stringify(rulesJson));
|
||
r.combatV2.centeringAccel = accel;
|
||
return compileRules(r);
|
||
};
|
||
const b = battleFor('power_pressure');
|
||
const worstStart = Math.max(...b.ships.map((s) => Math.hypot(s.x - b.centerX, s.y - b.centerY)));
|
||
check('every entity starts inside its own battle\'s centering comfort radius (zero force at t=0)',
|
||
worstStart <= b.centeringComfortRadius + 1e-6, `worst=${worstStart.toFixed(0)} radius=${b.centeringComfortRadius.toFixed(0)}`);
|
||
|
||
const maxDriftOverRun = (rules, seed) => {
|
||
const battle = CombatV2.createBattle(rules, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: mixedFleet, formationStrategy: 'power_pressure' },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: mixedFleet, formationStrategy: 'speed_swarm' },
|
||
rnd: mulberry32(seed),
|
||
});
|
||
let worst = 0;
|
||
let i = 0;
|
||
while (!battle.done && i < 20000) {
|
||
CombatV2.advance(battle, 1 / 30, { allowRetreat: true });
|
||
i += 1;
|
||
for (const s of battle.ships) {
|
||
if (s.hp > 0 && !s.retreated) worst = Math.max(worst, Math.hypot(s.x - battle.centerX, s.y - battle.centerY));
|
||
}
|
||
}
|
||
return worst;
|
||
};
|
||
const rulesOff = withCenteringAccel(0);
|
||
const rulesOn = withCenteringAccel(RULES.combatV2.centeringAccel);
|
||
let offTotal = 0;
|
||
let onTotal = 0;
|
||
const N = 5;
|
||
for (let seed = 1; seed <= N; seed += 1) {
|
||
offTotal += maxDriftOverRun(rulesOff, seed);
|
||
onTotal += maxDriftOverRun(rulesOn, seed);
|
||
}
|
||
check(`centering reduces average max-drift-from-start across ${N} mixed-fleet seeds`,
|
||
onTotal < offTotal, `off avg=${(offTotal / N).toFixed(0)} on avg=${(onTotal / N).toFixed(0)}`);
|
||
}
|
||
|
||
// shipBounds must include a defended planet even with no defender ships
|
||
// at all — the exact scenario that broke when the planet was still
|
||
// anchored to a fixed world-edge position after this fleet-size-aware
|
||
// placement landed (it drifted far from a defender fleet placed near
|
||
// the world's center, and briefly fell outside the camera's initial
|
||
// "frame the fleet" box).
|
||
const planetBattle = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 3 }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [] },
|
||
colony: { defenseHp: 500, shieldBonus: 2 },
|
||
rnd: mulberry32(1),
|
||
});
|
||
const planetBounds = CombatV2.shipBounds(planetBattle.ships);
|
||
check('shipBounds includes a defended planet with no defender fleet present',
|
||
planetBounds.maxX >= planetBattle.planet.x - 1 && planetBounds.maxX <= planetBattle.planet.x + 1);
|
||
|
||
// End to end: a bigger fleet's fit-to-bounds zoom index must not exceed
|
||
// a smaller fleet's — i.e. the initial camera genuinely opens more
|
||
// zoomed out for a bigger battle, not the same rung regardless of size.
|
||
const zoomIndexFor = (n) => {
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }] },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }] },
|
||
rnd: mulberry32(1),
|
||
});
|
||
const bounds = CombatV2.shipBounds(b.ships);
|
||
const ladder = buildZoomLadder(RULES.combatV2.worldWidth, RULES.combatV2.worldHeight, { steps: 6, maxZoom: 4.0 });
|
||
return pickFitZoomIndex(ladder, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY, 180);
|
||
};
|
||
check('a bigger battle opens more zoomed out than a smaller one',
|
||
zoomIndexFor(20) <= zoomIndexFor(1), `1-ship idx ${zoomIndexFor(1)}, 20-ship idx ${zoomIndexFor(20)}`);
|
||
}
|
||
|
||
// Large-battle performance fix (real self-play soak produced a 278-ship
|
||
// battle that stalled a single game turn for 24-46+ SECONDS before this):
|
||
// computeSeparation dispatches to a brute-force flat scan below
|
||
// SEPARATION_GRID_THRESHOLD and a spatial grid above it. The grid's 3x3
|
||
// neighbor-cell lookup MUST produce the same avoidance force as the flat
|
||
// scan for every ship, including ships sitting exactly on a cell boundary
|
||
// (SEPARATION_CELL_SIZE=400) — that's exactly where a grid radius/lookup
|
||
// bug would silently miss a real neighbor and only show up in a rare,
|
||
// hard-to-eyeball large battle. Tested directly against synthetic ship
|
||
// sets (not a real battle) since it's pure geometry, independent of
|
||
// combat/targeting.
|
||
{
|
||
function mkTestShip(uid, x, y, avoidRadius) {
|
||
return { uid, x, y, avoidRadius };
|
||
}
|
||
function seededShips(n, seed) {
|
||
let state = seed;
|
||
const rnd = () => {
|
||
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
||
return state / 0x7fffffff;
|
||
};
|
||
const ships = [];
|
||
for (let i = 0; i < n; i += 1) {
|
||
ships.push(mkTestShip(`s${i}`, rnd() * 2000 - 1000, rnd() * 2000 - 1000, 40 + rnd() * 140));
|
||
}
|
||
// Deliberately straddle a grid cell edge (x=400) — the case most
|
||
// likely to expose a neighbor-lookup bug.
|
||
ships.push(mkTestShip('boundaryA', 399, 100, 175));
|
||
ships.push(mkTestShip('boundaryB', 401, 100, 175));
|
||
ships.push(mkTestShip('boundaryC', 400, 400, 175));
|
||
ships.push(mkTestShip('boundaryD', 401, 401, 175));
|
||
return ships;
|
||
}
|
||
let worstDiff = 0;
|
||
let checked = 0;
|
||
for (const seed of [1, 2, 3, 4, 5]) {
|
||
const ships = seededShips(120, seed);
|
||
const grid = CombatV2.buildSeparationGrid(ships);
|
||
for (const s of ships) {
|
||
const flat = CombatV2.computeSeparationFlat(s, ships, null);
|
||
const gridResult = CombatV2.computeSeparationGrid(s, grid, null);
|
||
worstDiff = Math.max(worstDiff, Math.hypot(flat.x - gridResult.x, flat.y - gridResult.y));
|
||
checked += 1;
|
||
}
|
||
}
|
||
check(`spatial-grid separation matches brute-force flat separation exactly (${checked} ships, incl. cell-boundary cases)`,
|
||
worstDiff < 1e-9, `worst diff=${worstDiff}`);
|
||
|
||
// Same check with excludeUid (own-target reduced-avoidance) set.
|
||
const ships = seededShips(80, 7);
|
||
const grid = CombatV2.buildSeparationGrid(ships);
|
||
let worstExclude = 0;
|
||
ships.forEach((s, i) => {
|
||
const excludeUid = ships[(i + 5) % ships.length].uid;
|
||
const flat = CombatV2.computeSeparationFlat(s, ships, excludeUid);
|
||
const gridResult = CombatV2.computeSeparationGrid(s, grid, excludeUid);
|
||
worstExclude = Math.max(worstExclude, Math.hypot(flat.x - gridResult.x, flat.y - gridResult.y));
|
||
});
|
||
check('spatial-grid matches flat with excludeUid (own-target reduced avoidance) set',
|
||
worstExclude < 1e-9, `worst diff=${worstExclude}`);
|
||
}
|
||
|
||
// The actual regression this whole fix exists for: a battle far larger
|
||
// than any hand-tuned scenario elsewhere in this suite must still resolve
|
||
// in a reasonable time, not the 24-46+ SECONDS measured pre-fix. Mirrors
|
||
// the exact scale (278 ships) a real self-play soak produced.
|
||
{
|
||
const megaFleet = [
|
||
{ hullId: 'frigate', count: 60 }, { hullId: 'destroyer', count: 40 },
|
||
{ hullId: 'cruiser', count: 25 }, { hullId: 'battleship', count: 14 },
|
||
];
|
||
const t0 = performance.now();
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: megaFleet, formationStrategy: 'power_pressure' },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: megaFleet, formationStrategy: 'speed_swarm' },
|
||
rnd: mulberry32(1),
|
||
});
|
||
CombatV2.runBattle(b, { allowRetreat: true });
|
||
const dt = performance.now() - t0;
|
||
check('a 278-ship battle resolves in well under a second on the grid path (was 24-46+ SECONDS pre-spatial-grid)',
|
||
dt < 5000, `${dt.toFixed(0)}ms`);
|
||
}
|
||
|
||
// Even the spatial grid degrades at real self-play's actual extremes:
|
||
// late-game wars (turn 400-500+) produced battles up to 790 ships at one
|
||
// star (16.9s worst case even WITH the grid) — a fixed 3600x2400 world
|
||
// gets dense enough that the grid's O(n) advantage erodes. Brian's fix:
|
||
// cap how many ships per side get full simulation
|
||
// (MAX_SIMULATED_SHIPS_PER_SIDE, VegaCombatV2.js), folding the rest in
|
||
// via V1's cheap aggregate math (resolveOverflow/battleResult).
|
||
{
|
||
const totalCount = (lines) => lines.reduce((t, l) => t + (l.count ?? 0), 0);
|
||
const totalLost = (lines) => lines.reduce((t, l) => t + (l.lost ?? 0), 0);
|
||
|
||
// Typical battles are completely unaffected — this is a rare-case
|
||
// safety valve, not a general behavior change.
|
||
{
|
||
const fleet = [{ hullId: 'destroyer', count: 8 }];
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
|
||
rnd: mulberry32(1),
|
||
});
|
||
check('typical battle: no overflow at all', b.attackerOverflow.length === 0 && b.defenderOverflow.length === 0);
|
||
}
|
||
|
||
// Proportional sampling + exact cap on a fleet well over the threshold.
|
||
{
|
||
const fleet = [
|
||
{ hullId: 'frigate', count: 500 }, { hullId: 'destroyer', count: 200 },
|
||
{ hullId: 'cruiser', count: 80 }, { hullId: 'battleship', count: 20 },
|
||
];
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: [{ hullId: 'destroyer', count: 5 }], formationStrategy: 'power_pressure' },
|
||
rnd: mulberry32(2),
|
||
});
|
||
const simulatedCount = b.ships.filter((s) => s.side === 'attacker').length;
|
||
check('mega-fleet: exactly 100 attacker ships simulated (the per-side cap)', simulatedCount === 100, `got ${simulatedCount}`);
|
||
const overflowTotal = b.attackerOverflow.reduce((t, l) => t + l.count, 0);
|
||
check('mega-fleet: simulated + overflow == original total (no ships lost to rounding)',
|
||
simulatedCount + overflowTotal === 800, `simulated=${simulatedCount} overflow=${overflowTotal}`);
|
||
const frigateSim = b.ships.filter((s) => s.side === 'attacker' && s.hullId === 'frigate').length;
|
||
check('mega-fleet: hull-type mix preserved in the simulated sample (frigates ~62.5% of 100)',
|
||
Math.abs(frigateSim - 62.5) <= 1.5, `frigateSim=${frigateSim}`);
|
||
}
|
||
|
||
// The actual regression this fix exists for: a battle at real self-play's
|
||
// measured extreme (790 ships/side) must resolve fast AND conserve every
|
||
// ship (survivors + losses == original count, on both sides).
|
||
{
|
||
const fleet = [
|
||
{ hullId: 'frigate', count: 350 }, { hullId: 'destroyer', count: 250 },
|
||
{ hullId: 'cruiser', count: 140 }, { hullId: 'battleship', count: 50 },
|
||
];
|
||
const t0 = performance.now();
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'speed_swarm' },
|
||
rnd: mulberry32(3),
|
||
});
|
||
const result = CombatV2.runBattle(b, { allowRetreat: true });
|
||
const dt = performance.now() - t0;
|
||
check('a 790-ship battle (real self-play\'s measured worst case) resolves in well under 5s (was 16.9s pre-cap)',
|
||
dt < 5000, `${dt.toFixed(0)}ms`);
|
||
const aTotal = totalCount(result.attackerSurvivors) + totalLost(result.attackerLosses);
|
||
const dTotal = totalCount(result.defenderSurvivors) + totalLost(result.defenderLosses);
|
||
check('790-ship battle: every attacker ship accounted for (survivors+losses == 790, none silently vanish)',
|
||
aTotal === 790, `got ${aTotal}`);
|
||
check('790-ship battle: every defender ship accounted for (survivors+losses == 790, none silently vanish)',
|
||
dTotal === 790, `got ${dTotal}`);
|
||
}
|
||
|
||
// Winner-decision edge case: only one side overflows (the other's whole
|
||
// fleet fit under the cap) — the untouched reserve must survive intact
|
||
// and be able to flip the outcome even if the simulated skirmish alone
|
||
// wouldn't have decided it.
|
||
{
|
||
const b = CombatV2.createBattle(RULES, {
|
||
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: [{ hullId: 'frigate', count: 300 }], formationStrategy: 'power_pressure' },
|
||
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: [{ hullId: 'destroyer', count: 3 }], formationStrategy: 'power_pressure' },
|
||
rnd: mulberry32(4),
|
||
});
|
||
const result = CombatV2.runBattle(b, { allowRetreat: true });
|
||
const aTotal = totalCount(result.attackerSurvivors) + totalLost(result.attackerLosses);
|
||
check('one-sided overflow: attacker\'s reserve ships are conserved (total stays 300)', aTotal === 300, `got ${aTotal}`);
|
||
check('one-sided overflow: attacker wins (simulated force plus an untouched reserve vs. 3 destroyers)',
|
||
result.winner === 'attacker', `winner=${result.winner}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log(`\n${passes} passed, ${failures} failed`);
|
||
if (failures > 0) process.exit(1);
|