467 lines
26 KiB
JavaScript
467 lines
26 KiB
JavaScript
/**
|
||
* 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);
|