/** * 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 QUOTA: each branch's middle systems * (depth 2 … maxDepth − 1) are ELIGIBLE; each branch keeps its * min(eligible, quota) closest to the middle band (|d − 0.5|, index * tiebreak), quota = the MEDIAN of the eligible sizes — no faction * outruns a typical branch (no dominant faction; the overflow stays * neutral wild space, and a small branch keeps everything it has); * the ring right outside home (depth 1) + the branch tips (maxDepth) * stay neutral — except a faction capital demoted onto a branch root * (only when the branch has no territory at all); * - 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 QUOTA: the branch's middle systems (depth 2 … // maxDepth − 1) are ELIGIBLE; each branch keeps its min(eligible, quota) // closest to the middle band (|d − 0.5|, index tiebreak) — quota = the // MEDIAN of the eligible sizes, so no faction can outsize a typical // branch (the overflow stays neutral wild space); a small branch keeps // everything it has (except a faction capital demoted onto a branch // root — only when the branch has no territory at all). 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 idx = new Map(g.records.map((r, i) => [r.id, i])); const mid = (id) => Math.abs((g.byId.get(id)?.d ?? 0.5) - 0.5); const keepOf = new Map(g.factions.branches.map((b) => { const elig = eligibleOf.get(b); return [b, new Set(elig.slice().sort((a, c) => mid(a) - mid(c) || idx.get(a) - idx.get(c)).slice(0, Math.min(elig.length, quota)))]; })); check('balance quota: the median of the branches’ eligible sizes', g.factions.quota === quota, `${g.factions.quota} vs ${quota} (sizes ${sizes.join(',')})`); let terrOk = true; let terrWhy = ''; for (const r of g.records) { if (r.id === HOME || !branchOf.has(r.id)) continue; const b = branchOf.get(r.id); const fid = g.factions.branchFaction[b]; const expectFaction = keepOf.get(b).has(r.id) ? fid : null; const got = r.faction === fid ? fid : null; const demoted = g.factions.capitals[fid] === r.id && expectFaction === null; if (got !== expectFaction && !(demoted && got === fid)) { terrOk = false; terrWhy = `${r.id}: keep=${keepOf.get(b).has(r.id)} expect=${expectFaction} got=${got}`; break; } } check('territory rule: the branch’s kept (quota-capped) middle systems = its faction; the rest neutral', terrOk, terrWhy); // NO DOMINANT FACTION: every territory is exactly its branch's // min(eligible, quota) systems — the big branch is trimmed to the // median, the small branch keeps everything; and the kept set is the // middle-band-nearest one (the map's territory-region fill will read // factions.territories). let terrSizeOk = true; let terrSizeWhy = ''; for (const b of g.factions.branches) { const terr = g.factions.territories?.[b] ?? []; const want = Math.min(eligibleOf.get(b).length, quota); if (terr.length !== want) { terrSizeOk = false; terrSizeWhy = `${b}: ${terr.length} systems, want ${want}`; break; } for (const id of terr) { if (!keepOf.get(b).has(id)) { terrSizeOk = false; terrSizeWhy = `${b}: ${id} is not among its ${want} middle-band-nearest eligible systems`; break; } } if (!terrSizeOk) break; } check('no dominant faction: every territory is its branch’s min(eligible, quota) middle-band systems', terrSizeOk, terrSizeWhy); const terrSizes = g.factions.branches.map((b) => g.factions.territories[b].length); check('faction sizes near-equal: max territory ≤ quota (no faction outruns a typical branch)', Math.max(...terrSizes) <= quota, `sizes ${terrSizes.join('/')} quota ${quota}`); // 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);