orbit/dev/factions.test.mjs

435 lines
24 KiB
JavaScript
Raw 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 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);