orbit/dev/factions.test.mjs

467 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* FACTIONS test (dev tool, run with Node — no browser needed):
*
* node dev/factions.test.mjs
*
* The faction worldgen layer (data/factions.json + data/gates.json →
* bridges; js/galaxy/Galaxy.js → _assignFactions; js/galaxy/JumpNetwork.js
* → bridge pass; js/galaxy/SystemGenerator.js → capital worlds + owners) —
* see docs/PROJECT_NOTES.md, "FACTIONS". Asserts:
* - the CONFIG: four factions (neutral/apex/mindweave/zenith), each with
* a label + hex color; bridges enabled with perPair/dMin/dMax;
* - the BRANCHES: g.factions.branches = the top-level roots of the jump
* tree (home's gate neighbors), and branchFaction maps EVERY branch to
* one of the three player factions (a seeded order);
* - the NEUTRAL CAPITAL: a direct gate neighbor of home, its system stays
* neutral (faction null) and hosts the neutral capital world
* (class capitalNeutral, frame 18, the 'capital' settlement,
* owner-less); g.neutralCapitalTarget() names that system + its
* OUTERMOST planet (the capital world) as the 'Leaving the Nest'
* last-brief's auto destination (js/scenes/GameScene.js → _commEnded);
* - the FACTION CAPITALS: exactly three, one per faction, each in a
* non-barren system of its branch, each system carrying the capital
* world (class capital<faction>, frame 19/20/21) + the 'capital'
* settlement owned by the faction;
* - the TERRITORY RULE + BALANCE (data/factions.json → balance):
* each branch's middle systems (depth 2 … maxDepth − 1) are
* ELIGIBLE; the branch's home faction keeps its min(eligible, quota)
* closest to the middle band (|d − 0.5|, index tiebreak) — quota =
* the MEDIAN of the eligible sizes (the CAP: no dominant faction,
* the overflow stays neutral wild space); and every faction is
* lifted UP to the FLOOR (balance.minTerritory — no one-system
* sliver), absorbing the nearest unowned eligible overflow first,
* then the richest faction's least-core systems (a faction with no
* branch is built entirely from borrowed space); the ring right
* outside home (depth 1) + the branch tips (maxDepth) stay neutral —
* except a faction capital demoted outside its territory (only when
* the territory has no free system);
* - ARID: every arid planet is ALWAYS habitable (no roll) and hosts a
* COLONY (data/settlements.json → settledKindByClass); arid worlds
* show up in the roster (rebalanced planetClasses);
* - the BRIDGES: cross-branch (the two endpoints sit in DIFFERENT
* branches), local (each endpoint in the other's neighbor pool),
* within the gate budget (≤ maxGates per system), on non-barren
* systems, mid-band (dMin..dMax) when the branch pair affords it;
* the snapshot edges carry `bridge: true` for exactly those lanes;
* - the MAP SNAPSHOT: every system carries faction/capital (null-safe);
* - DETERMINISM: same seed ⇒ identical factions, capitals, territories,
* bridges, owners; different seed ⇒ different assignment (spot check).
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { buildGalaxySnapshot, edgeKey } = await import(pathToFileURL(join(__dirname, '../js/galaxy/GalaxyChart.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
if (!cond) failures++;
};
const FACS = config.get('factions.factions', {});
const FIDS = Object.keys(FACS).filter((k) => k !== 'neutral');
const BRC = config.get('gates.bridges', {});
const MAX_GATES = config.get('gates.maxGates', 3);
const POOL = Math.max(1, config.get('gates.neighborPool', 8));
// ----------------------------------------------------------------------
// 0. The config
// ----------------------------------------------------------------------
{
check('factions.json: neutral + apex + mindweave + zenith, each labeled + colored',
['neutral', 'apex', 'mindweave', 'zenith'].every((k) => typeof FACS[k]?.label === 'string' && /^#[0-9a-f]{6}$/i.test(FACS[k]?.color ?? '')));
check('bridges config: enabled, perPair ≥ 1, 0 < dMin < dMax < 1',
BRC.enabled === true && Number(BRC.perPair) >= 1 && Number(BRC.dMin) > 0 && Number(BRC.dMax) < 1 && Number(BRC.dMin) < Number(BRC.dMax));
check('capital planet classes + frames 18..21 (planets.json → capitals)',
FIDS.concat('neutral').every((fid) => {
const cls = `capital${fid.charAt(0).toUpperCase()}${fid.slice(1)}`;
return Array.isArray(config.get(`planets.frames.${cls}`)) && config.get(`planets.capitals.frames.${fid}`, -1) >= 18;
}));
check("the 'capital' settlement kind + arid→colony mapping exist",
typeof config.get('settlements.kinds.capital') === 'object' && config.get('settlements.settledKindByClass.arid') === 'colony' && FIDS.every((f) => config.get(`settlements.settledKindByClass.capital${f.charAt(0).toUpperCase()}${f.slice(1)}`) === 'capital'));
}
// ----------------------------------------------------------------------
// Shared walks: tree depth (home = 0) + per-system branch root.
// ----------------------------------------------------------------------
const SEED = 'factions-test-seed';
const g = Galaxy.create(SEED);
const HOME = g.currentSystemId;
const recOf = new Map(g.records.map((r) => [r.id, r]));
// The BRANCH STRUCTURE comes from the jump network's own spanning tree
// (js/galaxy/JumpNetwork.js exposes branches/branchOf/depth — the exact
// tree the bridge pass used; a BFS over the final gate list — tree +
// bridges — can re-parent systems across branches, which is NOT the
// branch structure the faction pass assigns).
const branchOf = g.jumpNetwork.branchOf; // Map id → branch root id
const depth = g.jumpNetwork.depth; // Map id → tree depth (home = 0)
const treeBranches = [...new Set([...branchOf.values()])];
const isBarren = (id) => {
if (id === HOME) return false;
const c = g.ensureContent(id);
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
return c.planets.length === 0 && spaceCount === 0;
};
// ----------------------------------------------------------------------
// 1. Branches, neutral capital, faction capitals, territory, owners
// ----------------------------------------------------------------------
{
check('factions present on the galaxy (neutral capital + 3 capitals + branches + mapping)',
!!g.factions && typeof g.factions.neutralCapital === 'string'
&& (Object.keys(g.factions.capitals ?? {}).length === 3)
&& Array.isArray(g.factions.branches) && g.factions.branches.length >= 2
&& typeof g.factions.branchFaction === 'object');
check('branches = the tree\u2019s top-level roots (home\u2019s gate neighbors)',
JSON.stringify([...g.factions.branches].sort()) === JSON.stringify([...new Set(treeBranches)].sort())
&& JSON.stringify([...g.factions.branches].sort()) === JSON.stringify(g.jumpNetwork.branches),
`${g.factions.branches.length} vs ${new Set(treeBranches).size}`);
check('branchFaction: every branch mapped to one of the 3 player factions',
g.factions.branches.every((b) => FIDS.includes(g.factions.branchFaction[b])));
// The NEUTRAL CAPITAL: a direct gate neighbor of home; its system stays
// neutral (faction null) + hosts the neutral capital world.
const nc = g.factions.neutralCapital;
const ncRec = recOf.get(nc);
check('neutral capital: a direct gate neighbor of home', ncRec && g.jumpGatesFor(HOME).some((t) => t.id === nc), nc);
check('neutral capital: its system stays neutral (faction null) but is designated',
!!ncRec && ncRec.faction === null && ncRec.capital === 'neutral',
ncRec ? `${ncRec.faction}/${ncRec.capital}` : 'missing record');
const ncContent = g.ensureContent(nc);
const ncPlanet = ncContent.planets.find((p) => p.capital);
check('neutral capital: hosts the capital world (class capitalNeutral, frame 18, habitable)',
!!ncPlanet && ncPlanet.class === 'capitalNeutral' && ncPlanet.frame === 18 && ncPlanet.habitable === true,
ncPlanet ? `${ncPlanet.class}/f${ncPlanet.frame}` : 'no capital planet');
const ncSettle = ncContent.settlements.find((s) => s.kind === 'capital');
check('neutral capital: the capital world hosts an owner-less capital settlement',
!!ncSettle && ncSettle.owner === null && ncSettle.anchor?.ordinal === ncPlanet?.ordinal,
ncSettle ? `${ncSettle.kind} owner=${ncSettle.owner}` : 'no capital settlement');
// The destination target (the 'Leaving the Nest' last brief — Trestle
// "sends the coordinates"; js/scenes/GameScene.js → _commEnded hands
// this to setDestination on the acknowledgment). The object id is the
// map's discoveryId vocabulary (the planet's NAME — what MapWindow's
// SET DESTINATION passes + the arrival/compass/plate lookups read).
const ncTarget = g.neutralCapitalTarget();
const ncOuter = ncContent.planets[ncContent.planets.length - 1];
check('neutral capital target: system = the designated capital + the capital world is the OUTERMOST planet (object id = its name)',
ncTarget?.systemId === nc
&& ncTarget?.objectId === ncOuter?.name
&& ncOuter?.capital === true && ncOuter?.class === 'capitalNeutral',
ncTarget ? `${ncTarget.systemId} / ${ncTarget.objectId}` : 'null target');
// The FACTION CAPITALS: one per faction, non-barren, capital world of
// the faction's class + frame, settlement owned by the faction.
const capFrames = { apex: 19, mindweave: 20, zenith: 21 };
const caps = Object.entries(g.factions.capitals ?? {});
check('exactly 3 faction capitals, one per faction, distinct systems',
caps.length === 3 && new Set(caps.map(([, id]) => id)).size === 3
&& caps.every(([fid, id]) => FIDS.includes(fid) && recOf.has(id) && recOf.get(id).faction === fid && recOf.get(id).capital === fid));
let capWorlds = true;
let capWhy = '';
for (const [fid, id] of caps) {
const cls = `capital${fid.charAt(0).toUpperCase()}${fid.slice(1)}`;
const c = g.ensureContent(id);
const p = c.planets.find((pl) => pl.capital);
const s = c.settlements.find((st) => st.kind === 'capital');
if (!p || p.class !== cls || p.frame !== capFrames[fid] || p.habitable !== true) { capWorlds = false; capWhy = `${id}: planet ${p ? `${p.class}/f${p.frame}` : 'none'}`; break; }
if (!s || s.owner !== fid) { capWorlds = false; capWhy = `${id}: settlement owner ${s?.owner ?? 'none'}`; break; }
}
check('faction capitals: capital world (class + frame + habitable) + faction-owned settlement', capWorlds, capWhy);
// TERRITORY + BALANCE (data/factions.json → balance): the branch's
// middle systems (depth 2 … maxDepth − 1) are ELIGIBLE; the branch's
// HOME FACTION keeps its min(eligible, quota) closest to the middle
// band (|d − 0.5|, index tiebreak) — quota = the MEDIAN of the eligible
// sizes (the CAP: no faction outruns a typical branch, the overflow
// stays neutral wild space); and every faction is lifted UP to the
// FLOOR (balance.minTerritory — no one-system sliver), absorbing the
// nearest unowned eligible overflow first, then the richest faction's
// least-core systems (a faction with no branch is built entirely from
// borrowed space). A faction capital may sit OUTSIDE its territory
// (demoted — only when the territory has no free system).
const branchMax = new Map(g.factions.branches.map((b) => [b, 0]));
const eligibleOf = new Map(g.factions.branches.map((b) => [b, []]));
for (const [id, d] of depth) {
if (id === HOME || !branchOf.has(id)) continue;
const b = branchOf.get(id);
branchMax.set(b, Math.max(branchMax.get(b), d));
}
for (const r of g.records) {
if (r.id === HOME || !branchOf.has(r.id)) continue;
const b = branchOf.get(r.id);
const d = depth.get(r.id);
if (d >= 2 && d <= branchMax.get(b) - 1) eligibleOf.get(b).push(r.id);
}
const sizes = g.factions.branches.map((b) => eligibleOf.get(b).length).sort((x, y) => x - y);
const quota = sizes[Math.floor((sizes.length - 1) / 2)];
const MIN_T = Math.max(1, Math.floor(Number(config.get('factions.balance.minTerritory') ?? 5)));
const counts = { apex: 0, mindweave: 0, zenith: 0 };
for (const r of g.records) if (FIDS.includes(r.faction)) counts[r.faction]++;
const totalElig = g.factions.branches.reduce((a, b) => a + eligibleOf.get(b).length, 0);
check('balance quota: the median of the branches’ eligible sizes', g.factions.quota === quota, `${g.factions.quota} vs ${quota} (sizes ${sizes.join(',')})`);
check('balance floor: minTerritory exposed on the galaxy', g.factions.minTerritory === MIN_T, `${g.factions.minTerritory} vs ${MIN_T}`);
// Per-FACTION final territories (the truth — the floor pass can cross
// branches; `g.factions.territories` is the derived branch view).
const ft = g.factions.factionTerritories ?? {};
check('factionTerritories: one list per player faction', FIDS.every((f) => Array.isArray(ft[f])), JSON.stringify(Object.keys(ft)));
const ownerOf = new Map();
let overlap = false;
for (const f of FIDS) for (const id of ft[f] ?? []) {
if (ownerOf.has(id)) overlap = true;
ownerOf.set(id, f);
}
check('territories are disjoint (no system twice-owned)', !overlap);
// Stamp rule: every faction-stamped system is in exactly one
// factionTerritory; everything else is neutral — except a demoted
// capital (its seat outside the territory, stamped with its own
// faction).
let terrOk = true;
let terrWhy = '';
for (const r of g.records) {
if (r.id === HOME) continue;
const expectFaction = ownerOf.get(r.id) ?? null;
if (r.faction === expectFaction) continue;
const demoted = FIDS.includes(r.capital) && r.capital === r.faction;
if (!demoted) { terrOk = false; terrWhy = `${r.id}: expect=${expectFaction} got=${r.faction}`; break; }
}
check('territory stamps: faction systems are exactly the union of the factionTerritories (demoted capitals excepted); the rest neutral', terrOk, terrWhy);
// Sources: the floor pass only ever absorbs ELIGIBLE systems (a
// demoted capital may sit outside).
const eligAll = new Set();
for (const b of g.factions.branches) for (const id of eligibleOf.get(b)) eligAll.add(id);
let srcOk = true;
let srcWhy = '';
for (const f of FIDS) for (const id of ft[f] ?? []) {
if (!eligAll.has(id) && g.factions.capitals?.[f] !== id) { srcOk = false; srcWhy = `${f}: ${id} is not an eligible system`; break; }
}
check('territory systems all come from eligible (middle) space', srcOk, srcWhy);
// CAP: no faction outruns a typical branch (the FLOOR may only push a
// tiny galaxy past the quota).
check('CAP: no faction outruns a typical branch (≤ max(quota, floor))',
FIDS.every((f) => counts[f] <= Math.max(quota, MIN_T)),
`counts ${FIDS.map((f) => `${f}=${counts[f]}`).join(' ')} quota=${quota} floor=${MIN_T}`);
// FLOOR: a galaxy with 3× the floor of eligible space must lift every
// faction to it (fewer than that is best-effort — a working territory
// beats an empty one).
check('FLOOR: every faction reaches minTerritory (when 3× the floor of eligible space exists)',
totalElig < 3 * MIN_T || FIDS.every((f) => counts[f] >= MIN_T),
`counts ${FIDS.map((f) => `${f}=${counts[f]}`).join(' ')} floor=${MIN_T} totalElig=${totalElig}`);
// The BRANCH VIEW: territories[b] = the branch's systems that ended up
// owned (its home faction's kept systems, minus any it lent out, plus
// any it absorbed).
let brOk = true;
let brWhy = '';
for (const b of g.factions.branches) {
const want = [...(g.factions.territories?.[b] ?? [])].sort();
const got = g.records.filter((r) => r.id !== HOME && branchOf.get(r.id) === b && ownerOf.has(r.id)).map((r) => r.id).sort();
if (JSON.stringify(want) !== JSON.stringify(got)) { brOk = false; brWhy = `${b}: view ${want.length} vs owned ${got.length}`; break; }
}
check('branch view: factions.territories[b] = the branch’s owned systems', brOk, brWhy);
// OWNERS: every settlement in a faction-controlled system is owned by
// that faction (neutral systems: owner null) — the reputation seam is
// live, not a reserved null anymore.
let ownOk = true;
let ownWhy = '';
for (const r of g.records) {
const c = g.ensureContent(r.id);
for (const s of c.settlements) {
if (s.owner !== r.faction) { ownOk = false; ownWhy = `${r.id}: settlement ${s.kind} owner=${s.owner} system faction=${r.faction}`; break; }
}
if (!ownOk) break;
}
check('settlement owners: faction systems own their settlements; neutral space does not', ownOk, ownWhy);
}
// ----------------------------------------------------------------------
// 2. Arid — the common habitable class (frames 15..17)
// ----------------------------------------------------------------------
{
const ARID = config.get('planets.frames.arid');
check('arid frames are 15..17 (planets.json)', JSON.stringify(ARID) === '[15,16,17]', JSON.stringify(ARID));
let seenArid = 0, aridHabit = true, aridColony = true;
let whyHabit = '', whyColony = '';
for (const r of g.records) {
const c = g.ensureContent(r.id);
for (const p of c.planets) {
if (p.class !== 'arid') continue;
seenArid++;
if (p.habitable !== true) { aridHabit = false; whyHabit = `${r.id}/${p.name}: arid not habitable`; }
const s = c.settlements.find((st) => st.anchor?.type === 'planet' && st.anchor?.ordinal === p.ordinal && st.kind === 'colony');
if (!s) { aridColony = false; whyColony = `${r.id}/${p.name}: no colony on arid`; }
}
}
check(`arid worlds appear in the roster (${seenArid} found)`, seenArid > 0, 'none found');
check('every arid world is habitable (the always-habitable class)', aridHabit, whyHabit);
check('every arid world hosts a colony', aridColony, whyColony);
}
// ----------------------------------------------------------------------
// 3. Bridges — the inter-branch middle-belt links
// ----------------------------------------------------------------------
{
const bridges = g.jumpNetwork?.bridges ?? [];
const maxBridges = BRC.enabled === false ? 0 : BRC.perPair * (g.factions.branches.length * (g.factions.branches.length - 1) / 2);
check(`bridges placed: ${bridges.length} (> 0, ≤ ${maxBridges})`,
bridges.length > 0 && bridges.length <= maxBridges,
`${bridges.length}`);
let span = true, local = true, budget = true, barren = true;
let spanWhy = '', localWhy = '', budgetWhy = '', barrenWhy = '';
for (const [a, b] of bridges) {
const ba = branchOf.get(a), bb = branchOf.get(b);
if (a === b) { span = false; spanWhy = `${a} self-loop`; continue; }
if (ba === undefined || bb === undefined || ba === bb) { span = false; spanWhy = `${a}(branch ${ba ?? '?'})↔${b}(branch ${bb ?? '?'})`; continue; }
const inPool = g.neighborsOf(a, POOL).some((x) => x.id === b) || g.neighborsOf(b, POOL).some((x) => x.id === a);
if (!inPool) { local = false; localWhy = `${a}↔${b} outside the ${POOL}-nearest pool`; }
if (g.jumpGatesFor(a).length > MAX_GATES || g.jumpGatesFor(b).length > MAX_GATES) { budget = false; budgetWhy = `${a}(${g.jumpGatesFor(a).length})/${b}(${g.jumpGatesFor(b).length}) > ${MAX_GATES}`; }
if (isBarren(a) || isBarren(b)) { barren = false; barrenWhy = `${a} or ${b} is barren`; }
}
check('every bridge spans two different branches (on the TREE branch map)', span, spanWhy);
check('every bridge is local (endpoints in each other\u2019s neighbor pool)', local, localWhy);
check('bridges respect the gate budget (≤ maxGates per system)', budget, budgetWhy);
check('bridges never touch barren systems', barren, barrenWhy);
// THE PREFERENCE, verified by re-simulating the bridge pass (it is a
// pure function of the roster + pools + budget + config): a faithful
// replay must reproduce the EXACT placed bridges, in order — which
// subsumes the middle-belt preference (band pairs sort first), the
// distinct-endpoint preference (pass 0), and the per-pair count.
const k = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a);
const bridgeKeys = new Set(bridges.map(([a, b]) => k(a, b)));
const n = g.records.length;
const idx = new Map(g.records.map((r, i) => [r.id, i]));
const root = idx.get(HOME);
const out = g.records.map((r) => g.neighborsOf(r.id, POOL).map((x) => idx.get(x.id)));
const rev = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) for (const j of out[i]) rev[j].push(i);
const nbr = g.records.map((_, i) => {
const seen = new Set(out[i]);
const arr = out[i].slice();
for (const j of rev[i]) if (!seen.has(j)) { seen.add(j); arr.push(j); }
return arr;
});
// Starting degrees: the tree degree (the gate list minus the bridge
// targets it carries — shortcuts are OFF in the config, so the gate
// list is tree edges + bridge gates). jumpGatesFor returns RECORD
// objects — map to ids first.
const deg = g.records.map((r) => g.jumpGatesFor(r.id).map((t) => t.id).filter((t) => !bridgeKeys.has(k(r.id, t))).length);
const dMin = Number(BRC.dMin), dMax = Number(BRC.dMax);
const dOf = (i) => Number(g.records[i].d);
const inBand = (i) => dOf(i) >= dMin && dOf(i) <= dMax;
const bOf = (i) => {
const b = branchOf.get(g.records[i].id); // branch root id (string)
return b !== undefined ? idx.get(b) : -1;
};
const dist2 = (a, b) => {
const dx = g.records[a].x - g.records[b].x;
const dy = g.records[a].y - g.records[b].y;
return dx * dx + dy * dy;
};
const simulated = [];
const branchesSorted = g.jumpNetwork.branches.map((id) => idx.get(id)).sort((a, b) => a - b);
for (let bi = 0; bi < branchesSorted.length; bi++) {
for (let bj = bi + 1; bj < branchesSorted.length; bj++) {
const A = branchesSorted[bi], B = branchesSorted[bj];
const cands = [];
for (let u = 0; u < n; u++) {
if (bOf(u) !== A || isBarren(g.records[u].id) || u === root) continue;
for (const v of nbr[u]) {
if (bOf(v) !== B || isBarren(g.records[v].id) || v === root) continue;
cands.push({ u, v, band: inBand(u) && inBand(v), d: dist2(u, v) });
}
}
cands.sort((p, q) =>
(p.band === q.band ? 0 : p.band ? -1 : 1) ||
p.d - q.d || p.u - q.u || p.v - q.v);
const usedEnds = new Set();
let added = 0;
for (let pass = 0; pass < 2 && added < BRC.perPair; pass++) {
for (const c of cands) {
if (added >= BRC.perPair || c.added) continue;
if (pass === 0 && (usedEnds.has(c.u) || usedEnds.has(c.v))) continue;
if (deg[c.u] >= MAX_GATES || deg[c.v] >= MAX_GATES) continue;
c.added = true;
deg[c.u]++;
deg[c.v]++;
usedEnds.add(c.u);
usedEnds.add(c.v);
simulated.push([g.records[c.u].id, g.records[c.v].id]);
added++;
}
}
}
}
const simOk = JSON.stringify(simulated) === JSON.stringify(bridges);
check('bridge pass replay: a faithful re-simulation reproduces the exact placed bridges (in order — locality, budget, band + distinct-endpoint preference)',
simOk,
simOk ? '' : `got ${JSON.stringify(bridges)} vs simulated ${JSON.stringify(simulated)}`);
// The snapshot: edges.bridge flags exactly the bridge lanes (and the
// systems carry faction/capital).
const snap = buildGalaxySnapshot({ galaxy: g });
const snapBridge = new Set();
for (const e of snap.edges) if (e.bridge) snapBridge.add(e.key);
const wantBridge = new Set(bridges.map(([a, b]) => edgeKey(a, b)));
check('snapshot edges carry bridge:true for exactly the placed bridges',
snapBridge.size === wantBridge.size && [...wantBridge].every((x) => snapBridge.has(x)),
`${snapBridge.size} vs ${wantBridge.size}`);
const sysOk = snap.systems.every((s) => s.faction === null || FIDS.includes(s.faction))
&& snap.systems.every((s) => s.capital === null || ['neutral', ...FIDS].includes(s.capital));
check('snapshot systems carry faction (null | faction id) + capital (null | designation)', sysOk);
}
// ----------------------------------------------------------------------
// 4. Determinism — same seed ⇒ identical; different seed ⇒ different
// ----------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
const sameF = JSON.stringify(g.factions) === JSON.stringify(g2.factions);
let sameTerr = true;
for (let i = 0; i < g.records.length && sameTerr; i++) {
if (g.records[i].faction !== g2.records[i].faction || g.records[i].capital !== g2.records[i].capital) sameTerr = false;
}
let sameBridges = JSON.stringify(g.jumpNetwork?.bridges) === JSON.stringify(g2.jumpNetwork?.bridges);
check('same seed ⇒ identical factions (capitals + branches + mapping)', sameF);
check('same seed ⇒ identical per-system faction/capital designations', sameTerr);
check('same seed ⇒ identical bridges', sameBridges);
const g3 = Galaxy.create('factions-test-OTHER');
let diff = false;
for (let i = 0; i < 60 && !diff; i++) {
const a = g.records[i], b = g3.records[i];
if (a.id !== b.id) continue;
if (a.faction !== b.faction || a.capital !== b.capital) diff = true;
}
check('different seed ⇒ different faction assignment (spot check)', diff);
}
console.log(failures === 0 ? '\nAll faction tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);