473 lines
22 KiB
JavaScript
473 lines
22 KiB
JavaScript
/**
|
||
* Jump gates test (dev tool, run with Node — no browser needed):
|
||
*
|
||
* node dev/jumps.test.mjs
|
||
*
|
||
* Asserts the GATE NETWORK (data/gates.json, js/galaxy/JumpNetwork.js,
|
||
* exposed as Galaxy.jumpNetwork / jumpGatesFor):
|
||
* - every system holds minGates..maxGates gates (1–3 in data);
|
||
* - every gate is LOCAL — its destination is in the system's
|
||
* nearest-star pool (symmetric union, pool = gates.neighborPool);
|
||
* - the network is STRONGLY CONNECTED: from the home system every
|
||
* other system is reachable (forward BFS) AND every system can
|
||
* reach home (reverse BFS) — no closed systems, no trapped sets;
|
||
* - the network is a PURE SPANNING TREE (shortcuts OFF in the config):
|
||
* exactly N−1 undirected edges — no loops, one route between any two
|
||
* systems (the maze) — and every BARREN system (objectCount → 0) is
|
||
* a LEAF: exactly one gate, in and out the same way (the dead end);
|
||
*
|
||
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
|
||
* - content.jumps matches the network (count + destinations);
|
||
* - ANCHORED systems (a planet — or the home world in the starting
|
||
* system): every gate is within level-1 tether (tether.level1Radius)
|
||
* of a planet anchor and FACES its destination star on the 2-D map
|
||
* (soft rule — the gate sits on the DESTINATION SIDE of the system
|
||
* center whenever some anchor can reach that side (maxProj + tether
|
||
* > 0), and from the anchoring object it is within 90° of the
|
||
* system→star bearing; when the only anchor sits farther than the
|
||
* tether opposite the destination — a single far planet — the gate
|
||
* takes the best aimed point instead); stations are NOT anchors (the
|
||
* player must be able to build out from a world), but they DO get
|
||
* clearance;
|
||
* - every non-barren system holds at least ONE planet (the gate's
|
||
* anchor is guaranteed to exist);
|
||
* - BARREN systems (objectCount → 0 — the network's dead-end leaves):
|
||
* the gate sits ON the ray toward its destination, in the radius
|
||
* band, facing it — and 1–2 asteroid clusters drift INSIDE the
|
||
* gate's level-1 tether (the stop's only payload, data/asteroids.json
|
||
* → barren);
|
||
* - gates stay gates.minRadius..gates.maxRadius from the center;
|
||
* - gates keep size+clearance from every solid disc (planets +
|
||
* stations + the home world) and 2·size+gateGap from each other;
|
||
* - gate ids/names are unique per system;
|
||
* - every gate record carries active: false (data/gates.json → ACTIVITY);
|
||
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||
* non-home system holds 0, 2, 3, 4, or 5 objects (planets +
|
||
* free-space stations) in the configured proportions; a barren
|
||
* system holds no planets or stations — its payload is the gate's
|
||
* asteroid cluster;
|
||
*
|
||
* and DETERMINISM: same seed ⇒ same network, same gates, same layout;
|
||
* different seed ⇒ different network (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);
|
||
|
||
let failures = 0;
|
||
const check = (label, cond, extra = '') => {
|
||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
|
||
if (!cond) failures++;
|
||
};
|
||
|
||
const GATES = config.section('gates', {});
|
||
const MIN_GATES = GATES.minGates ?? 1;
|
||
const MAX_GATES = GATES.maxGates ?? 3;
|
||
const POOL = Math.max(1, GATES.neighborPool ?? 8);
|
||
const TETHER = config.get('tether.level1Radius', 5120);
|
||
const SIZE = GATES.size ?? 96;
|
||
const CLEAR = GATES.clearance ?? 256;
|
||
const GAP = GATES.gateGap ?? 192;
|
||
const MIN_R = GATES.minRadius ?? 2048;
|
||
const MAX_R = GATES.maxRadius ?? 20480;
|
||
const BARN_D = GATES.barrenDistance ?? 8192;
|
||
|
||
const SEED = 'jumps-test-seed';
|
||
const g = Galaxy.create(SEED);
|
||
const HOME = g.currentSystemId;
|
||
const recOf = new Map(g.records.map((r) => [r.id, r]));
|
||
const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 1. The network: counts, locality, strong connectivity
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const bad = [];
|
||
for (const r of g.records) {
|
||
const n = g.jumpGatesFor(r.id).length;
|
||
if (n < MIN_GATES || n > MAX_GATES) bad.push(`${r.id}:${n}`);
|
||
}
|
||
check(
|
||
`every system holds ${MIN_GATES}–${MAX_GATES} gates (${g.records.length} systems)`,
|
||
bad.length === 0,
|
||
bad.slice(0, 5).join(', '),
|
||
);
|
||
|
||
// Locality: every destination sits in the system's nearest-star pool
|
||
// (the symmetric union — the gate network's `neighborsOf` contract).
|
||
let local = true;
|
||
let why = '';
|
||
outer: for (const r of g.records) {
|
||
for (const t of g.jumpGatesFor(r.id)) {
|
||
if (t.id === r.id) {
|
||
local = false;
|
||
why = `${r.id} jumps to itself`;
|
||
break outer;
|
||
}
|
||
const inPool =
|
||
g.neighborsOf(r.id, POOL).some((x) => x.id === t.id) ||
|
||
g.neighborsOf(t.id, POOL).some((x) => x.id === r.id);
|
||
if (!inPool) {
|
||
local = false;
|
||
why = `${r.id} → ${t.id} outside the ${POOL}-nearest pool`;
|
||
break outer;
|
||
}
|
||
}
|
||
}
|
||
check('every gate jumps to a star in the system’s nearest-star pool (local jumps)', local, why);
|
||
|
||
// Strong connectivity, forward: from home, every system is reachable.
|
||
const fwd = new Set([HOME]);
|
||
const q = [HOME];
|
||
while (q.length) {
|
||
const u = q.pop();
|
||
for (const t of g.jumpGatesFor(u)) if (!fwd.has(t.id)) fwd.add(t.id), q.push(t.id);
|
||
}
|
||
check('reachable from home: every system (no forward dead ends)', fwd.size === g.records.length, `${fwd.size}/${g.records.length}`);
|
||
|
||
// HOP SCALE (the trade-economy property): with the even star field,
|
||
// hop counts track MAP distance — the journey to the far corner is a
|
||
// normal trip (a fraction of the roster), not a winding labyrinth.
|
||
const depth = new Map([[HOME, 0]]);
|
||
const qd = [HOME];
|
||
while (qd.length) {
|
||
const u = qd.pop();
|
||
for (const t of g.jumpGatesFor(u)) if (!depth.has(t.id)) depth.set(t.id, depth.get(u) + 1), qd.push(t.id);
|
||
}
|
||
const maxHops = Math.max(...depth.values());
|
||
check(
|
||
`hop scale: the farthest system is ${maxHops} hops from home (hops ≈ map distance)`,
|
||
maxHops < g.records.length / 2,
|
||
`max ${maxHops} of ${g.records.length} systems`,
|
||
);
|
||
|
||
// Strong connectivity, backward: every system can reach home
|
||
// (no closed systems, no trapped sets).
|
||
const radj = new Map(g.records.map((r) => [r.id, []]));
|
||
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) radj.get(t.id).push(r.id);
|
||
const rev = new Set([HOME]);
|
||
const qr = [HOME];
|
||
while (qr.length) {
|
||
const u = qr.pop();
|
||
for (const s of radj.get(u)) if (!rev.has(s)) rev.add(s), qr.push(s);
|
||
}
|
||
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
|
||
|
||
// TREE (shortcuts OFF in data/gates.json): exactly N−1 undirected
|
||
// edges — no loops, no redundant routes (the maze's single path).
|
||
const pairs = new Set();
|
||
let treeEdges = 0;
|
||
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
|
||
const key = [r.id, t.id].sort().join('\u0000');
|
||
if (!pairs.has(key)) pairs.add(key), treeEdges++;
|
||
}
|
||
check(`spanning tree: exactly ${g.records.length - 1} undirected edges (no loops)`,
|
||
treeEdges === g.records.length - 1, `${treeEdges} edges over ${g.records.length} systems`);
|
||
|
||
// MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF —
|
||
// exactly one gate, in and out the same way. (Home is exempt — it
|
||
// always holds planets.)
|
||
const leafBad = [];
|
||
for (const r of g.records) {
|
||
if (r.id === HOME) continue;
|
||
const c = g.ensureContent(r.id);
|
||
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||
const isBarren = c.planets.length === 0 && spaceCount === 0;
|
||
if (!isBarren) continue;
|
||
const n = g.jumpGatesFor(r.id).length;
|
||
if (n !== 1) leafBad.push(`${r.id}:${n}`);
|
||
}
|
||
check('every barren system is a leaf — exactly one gate (the dead end)', leafBad.length === 0, leafBad.slice(0, 5).join(', '));
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 2. Placement: tether, facing, radius band, clearances, uniqueness
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const sample = [recOf.get(HOME), ...g.records.slice(0, 3000).filter((r) => r.id !== HOME)];
|
||
let tether = 0, facing = 0, radius = 0, clearance = 0, gap = 0, unique = 0, barrenBad = 0, active = 0;
|
||
let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = '', bWhy = '';
|
||
let netMismatch = false;
|
||
for (const sys of sample) {
|
||
const c = g.ensureContent(sys.id);
|
||
const isHome = sys.id === HOME;
|
||
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||
const isBarren = c.planets.length === 0 && spaceCount === 0;
|
||
// content.jumps matches the network (count + destinations, in order).
|
||
const net = g.jumpGatesFor(sys.id);
|
||
if (
|
||
c.jumps.length !== net.length ||
|
||
c.jumps.some((j, i) => j.to !== net[i].id || j.toName !== net[i].name)
|
||
) {
|
||
netMismatch = true;
|
||
if (!mWhy) mWhy = `${sys.id}: content.jumps ≠ jumpGatesFor`;
|
||
}
|
||
// ACTIVITY (data/gates.json → ACTIVITY): every gate starts inert.
|
||
if (c.jumps.some((j) => j.active !== false)) {
|
||
active++;
|
||
if (!bWhy) bWhy = `${sys.id}: a gate is not active:false`;
|
||
}
|
||
// ANCHORS: the PLANETS and (home only) the home world — the bodies a
|
||
// gate's tether may hang from (stations are NOT anchors — the player
|
||
// must be able to build out from a world).
|
||
const anchors = [
|
||
...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })),
|
||
];
|
||
if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' });
|
||
// CLEARANCE DISCS: every solid body the gate keeps clear of — the
|
||
// anchors plus the free-space stations.
|
||
const discs = [
|
||
...anchors,
|
||
...((c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })) ?? []),
|
||
];
|
||
|
||
c.jumps.forEach((j, gi) => {
|
||
const t = recOf.get(j.to);
|
||
if (!t || t.id === sys.id) {
|
||
tether++;
|
||
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} has no valid destination`;
|
||
return;
|
||
}
|
||
const th = Math.atan2(t.y - sys.y, t.x - sys.x);
|
||
const d0 = Math.hypot(j.x, j.y);
|
||
if (isBarren) {
|
||
// BARREN: on the ray toward the destination, ≥ barrenDistance from
|
||
// the star (stepped outward within the band if the gap forced it),
|
||
// facing it (the bearing nudge, if any, stays within 90°).
|
||
const dev = Math.abs(norm(Math.atan2(j.y, j.x) - th));
|
||
if (d0 < BARN_D - 1e-6 || d0 > MAX_R + 1e-6 || dev >= Math.PI / 2) {
|
||
barrenBad++;
|
||
if (!bWhy) bWhy = `${sys.id}: barren gate ${j.id} at ${Math.round(d0)} px, ${(dev * 57.3).toFixed(1)}° off the target ray`;
|
||
}
|
||
} else {
|
||
// TETHER (hard): within level-1 range of some anchor.
|
||
let bestD = Infinity;
|
||
for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y));
|
||
if (bestD > TETHER + 1e-6) {
|
||
tether++;
|
||
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`;
|
||
}
|
||
// FACING (soft, from the CENTER): the gate sits on the DESTINATION
|
||
// side of the system center — dot(gate, destDir) > 0 — whenever
|
||
// geometry allows it: some anchor's tether circle reaches that side
|
||
// (maxProj + tether > 0). When every anchor sits farther than the
|
||
// tether opposite the destination (a single far planet), the gate
|
||
// takes the best aimed point — the tether rule (hard) outranks the
|
||
// facing rule (soft).
|
||
const maxProj = Math.max(...anchors.map((a) => a.x * Math.cos(th) + a.y * Math.sin(th)));
|
||
if (maxProj + TETHER > 0 && j.x * Math.cos(th) + j.y * Math.sin(th) <= 1e-6) {
|
||
facing++;
|
||
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} faces AWAY from its destination (wrong side of the center)`;
|
||
}
|
||
// FACING (soft, from the anchor): from the anchoring object, the
|
||
// gate is on the target side — within 90° of the system→star
|
||
// bearing (some anchor must satisfy BOTH the tether and the facing).
|
||
let onSide = false;
|
||
for (const a of anchors) {
|
||
const d = Math.hypot(j.x - a.x, j.y - a.y);
|
||
if (d > TETHER + 1e-6) continue;
|
||
if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true;
|
||
}
|
||
if (!onSide) {
|
||
facing++;
|
||
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`;
|
||
}
|
||
}
|
||
// RADIUS band from the center.
|
||
if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) {
|
||
radius++;
|
||
if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the center (band ${MIN_R}..${MAX_R})`;
|
||
}
|
||
// CLEARANCE from every solid disc (planets + stations + home world
|
||
// — every disc in this game is under 800 px across, so a size +
|
||
// 100 px floor is a fair check).
|
||
for (const a of discs) {
|
||
const d = Math.hypot(j.x - a.x, j.y - a.y);
|
||
if (d < SIZE + 100) {
|
||
clearance++;
|
||
if (!cWhy) cWhy = `${sys.id}: gate ${j.id} is ${Math.round(d)} px from ${a.name}`;
|
||
break;
|
||
}
|
||
}
|
||
// GAP to the other gates of this system.
|
||
c.jumps.forEach((k, ki) => {
|
||
if (ki <= gi) return;
|
||
const d = Math.hypot(j.x - k.x, j.y - k.y);
|
||
if (d < 2 * SIZE + GAP) {
|
||
gap++;
|
||
if (!gWhy) gWhy = `${sys.id}: gates ${j.id}/${k.id} are ${Math.round(d)} px apart (< ${2 * SIZE + GAP})`;
|
||
}
|
||
});
|
||
});
|
||
|
||
const ids = new Set(c.jumps.map((j) => j.id));
|
||
const names = new Set(c.jumps.map((j) => j.name));
|
||
const idShape = c.jumps.every((j, i) => j.id === `${sys.id}-j${i + 1}`);
|
||
if (ids.size !== c.jumps.length || names.size !== c.jumps.length || !idShape) {
|
||
unique++;
|
||
}
|
||
}
|
||
check('content.jumps matches the gate network (count, destinations, order)', !netMismatch, mWhy);
|
||
check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet anchor`, tether === 0, tWhy);
|
||
check('facing (soft): every anchored-system gate is on the target side of its anchor (< 90°)', facing === 0, fWhy);
|
||
check(`barren gates sit on the target ray, in the band ${MIN_R}..${MAX_R}, facing it`, barrenBad === 0, bWhy);
|
||
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the center`, radius === 0, rWhy);
|
||
check(`clearance: every gate keeps ${SIZE} + 100 px from solid discs (planets + stations + home)`, clearance === 0, cWhy);
|
||
check(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy);
|
||
check('gate ids are <systemId>-j<n> and names are unique per system', unique === 0);
|
||
check('every gate record is active:false (inert until activation)', active === 0, bWhy);
|
||
|
||
// The OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||
// non-home system holds 0, 2, 3, 4, or 5 objects (planets +
|
||
// free-space stations) in the configured proportions — 0 = a barren,
|
||
// jump-gate-only dead end (the network's leaves). Every NON-barren
|
||
// system holds at least ONE PLANET (the gate's anchor — the player must
|
||
// be able to build out from a world), and a barren system's payload is
|
||
// 1–2 asteroid clusters INSIDE its single gate's level-1 tether.
|
||
const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } });
|
||
const expected = { 0: OC.barren ?? 0.1 };
|
||
for (const [k, w] of Object.entries(OC.objects ?? {})) expected[Number(k)] = w;
|
||
const counts = {};
|
||
let shapeBad = 0, whyShape = '';
|
||
let noPlanet = 0, whyPlanet = '', barrenCl = 0, whyCl = '';
|
||
const AC = config.section('asteroids', {});
|
||
const clLo = Math.max(0, Math.floor(AC.barren?.clusters?.[0] ?? 1));
|
||
const clHi = Math.max(clLo, Math.floor(AC.barren?.clusters?.[1] ?? 2));
|
||
for (const r of g.records) {
|
||
if (r.id === HOME) continue; // the home system is exempt (fixed 2 planets)
|
||
const c = g.ensureContent(r.id);
|
||
const n = c.planets.length + (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||
counts[n] = (counts[n] ?? 0) + 1;
|
||
if (!(n in expected)) {
|
||
shapeBad++;
|
||
if (!whyShape) whyShape = `${r.id}: ${n} objects`;
|
||
}
|
||
// ≥ 1 PLANET in every non-barren system (the gate's anchor is real).
|
||
if (n > 0 && c.planets.length === 0) {
|
||
noPlanet++;
|
||
if (!whyPlanet) whyPlanet = `${r.id}: ${n} objects, 0 planets`;
|
||
}
|
||
// BARREN: the dead end's payload — clLo..clHi clusters, each inside
|
||
// its single gate's level-1 tether (center + extent ≤ tether rim).
|
||
if (n === 0) {
|
||
const cl = c.asteroids ?? [];
|
||
if (cl.length < clLo || cl.length > clHi) {
|
||
barrenCl++;
|
||
if (!whyCl) whyCl = `${r.id}: ${cl.length} clusters (want ${clLo}..${clHi})`;
|
||
continue;
|
||
}
|
||
const gate = (c.jumps ?? [])[0];
|
||
if (!gate) {
|
||
barrenCl++;
|
||
if (!whyCl) whyCl = `${r.id}: barren but no gate to hang the cluster on`;
|
||
continue;
|
||
}
|
||
for (const a of cl) {
|
||
const d = Math.hypot(a.x - gate.x, a.y - gate.y);
|
||
if (d - (a.bound ?? 0) > TETHER + 1e-6) {
|
||
barrenCl++;
|
||
if (!whyCl) whyCl = `${r.id}: cluster at ${Math.round(d)} px from the gate (+extent > ${TETHER})`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const nN = g.records.length - 1;
|
||
let distOk = true;
|
||
for (const [n, p] of Object.entries(expected)) {
|
||
const obs = (counts[Number(n)] ?? 0) / nN;
|
||
const sd = Math.sqrt(p * (1 - p) / nN);
|
||
if (Math.abs(obs - p) > 4 * sd + 0.004) {
|
||
distOk = false;
|
||
console.log(` ${n} objects: observed ${(obs * 100).toFixed(1)}% vs expected ${(p * 100).toFixed(1)}%`);
|
||
}
|
||
}
|
||
check(`composition: every non-home system holds 0/2/3/4/5 objects — ${g.records.length} systems`, shapeBad === 0, whyShape);
|
||
check('composition: the object counts match the configured proportions (±4σ)', distOk);
|
||
check('every non-barren system holds ≥ 1 planet (the gate anchor exists)', noPlanet === 0, whyPlanet);
|
||
check(`barren dead ends carry ${clLo}–${clHi} clusters INSIDE the gate's level-1 tether`, barrenCl === 0, whyCl);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 3. Determinism — same seed ⇒ same network + gates + layout
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const g2 = Galaxy.create(SEED);
|
||
let net = true;
|
||
let placement = true;
|
||
for (let i = 0; i < 40 && net; i++) {
|
||
if (JSON.stringify(g.jumpGatesFor(g.records[i].id).map((t) => t.id)) !== JSON.stringify(g2.jumpGatesFor(g2.records[i].id).map((t) => t.id))) net = false;
|
||
}
|
||
for (let i = 0; i < 40 && placement; i++) {
|
||
const a = g.ensureContent(g.records[i].id);
|
||
const b = g2.ensureContent(g2.records[i].id);
|
||
if (JSON.stringify(a.jumps) !== JSON.stringify(b.jumps)) placement = false;
|
||
if (
|
||
a.planets.length !== b.planets.length ||
|
||
a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y || p.frame !== b.planets[k].frame) ||
|
||
JSON.stringify((a.settlements ?? []).map((s) => [s.x, s.y])) !== JSON.stringify((b.settlements ?? []).map((s) => [s.x, s.y]))
|
||
)
|
||
placement = false;
|
||
}
|
||
check('same seed ⇒ same gate network', net);
|
||
check('same seed ⇒ same gate placements + object layout', placement);
|
||
|
||
const g3 = Galaxy.create('jumps-test-OTHER');
|
||
let diff = false;
|
||
for (let i = 0; i < 50 && !diff; i++) {
|
||
const idA = g.records[i].id;
|
||
const idB = g3.records[i].id;
|
||
if (idA !== idB) continue;
|
||
diff =
|
||
JSON.stringify(g.jumpGatesFor(idA).map((t) => t.id)) !== JSON.stringify(g3.jumpGatesFor(idB).map((t) => t.id));
|
||
}
|
||
check('different seed ⇒ different gate network (spot check)', diff);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 4. The home system
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const c = g.ensureContent(HOME);
|
||
const objects = c.planets.length + (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||
check('home system holds ≤ 3 objects (2 planets + ≤ 1 free-space station)', objects <= 3 && c.planets.length === 2, `${objects} objects, ${c.planets.length} planets`);
|
||
check('home system holds jump gates', c.jumps.length >= MIN_GATES && c.jumps.length <= MAX_GATES, `${c.jumps.length} gates`);
|
||
const home = recOf.get(HOME);
|
||
let homeOk = true;
|
||
for (const j of c.jumps) {
|
||
const t = recOf.get(j.to);
|
||
const th = Math.atan2(t.y - home.y, t.x - home.x);
|
||
// From a home anchor (the home world, or — when the gate gap pushes
|
||
// a pair of close targets apart — a home planet) the gate is within
|
||
// level-1 tether and on the target side.
|
||
const anchors = [...c.planets.map((p) => [p.x, p.y]), [0, 0]];
|
||
let ok = false;
|
||
for (const [ax, ay] of anchors) {
|
||
const d = Math.hypot(j.x - ax, j.y - ay);
|
||
const a = Math.abs(norm(Math.atan2(j.y - ay, j.x - ax) - th));
|
||
if (d <= TETHER + 1e-6 && a < Math.PI / 2) ok = true;
|
||
}
|
||
if (!ok) homeOk = false;
|
||
}
|
||
check('home gates sit within level-1 tether of a home anchor, facing their star', homeOk);
|
||
}
|
||
|
||
console.log(failures === 0 ? '\nAll jump-gate tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
||
process.exit(failures === 0 ? 0 : 1);
|