/** * 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, 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);