orbit/dev/galaxy-map.test.mjs

410 lines
17 KiB
JavaScript
Raw Permalink 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.

/**
* Galaxy map test (dev tool, run with Node — no browser):
*
* node dev/galaxy-map.test.mjs
*
* Covers the GALAXY tab's pure model + save seam (js/galaxy/GalaxyChart.js,
* js/save/SaveData.js):
* - edgeKey: the undirected jump-lane key (min<max, symmetric);
* - buildGalaxySnapshot: systems (the FACTIONS seam: `faction: null`),
* the dedup'd lane web (used / frontier / live), the stats;
* - convexHull / paddedHullPolygon: the charted-region geometry
* (1 pt → ring, 2 → capsule, 3+ → the parallel polygon, inflated);
* - starPulse / starTypeColor: the per-archetype heartbeat (deterministic,
* bounded) + the archetype chart colors;
* - clipLineToRect / clipPolygonToRect: the plate clip (lanes, the hull,
* the rings stay INSIDE the chart's window at any zoom);
* - SaveData: the run's footprint (visitedSystems + usedGates) captured,
* restored, reset — and the LEGACY default for saves that predate it.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(file('config/Config.js'));
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(file('galaxy/Galaxy.js'));
const {
edgeKey,
buildGalaxySnapshot,
convexHull,
paddedHullPolygon,
starPulse,
starTypeColor,
} = await import(file('galaxy/GalaxyChart.js'));
const { SaveManager, SAVE_FORMAT } = await import(file('save/SaveManager.js'));
const { captureState, prepareLoad, resetRunState } = await import(file('save/SaveData.js'));
const { Discovery } = await import(file('galaxy/Discovery.js'));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const SEED = 'GALAXYMAPTEST';
const galaxy = Galaxy.create(SEED, { systemCount: 9 });
const homeId = galaxy.currentSystemId;
const others = galaxy.records.filter((r) => r.id !== homeId);
const a = others[0];
const b = others[1];
// ---------------------------------------------------------------------------
// edgeKey — the undirected lane identity
// ---------------------------------------------------------------------------
{
check('edgeKey: symmetric', edgeKey('S000001', 'S000002') === edgeKey('S000002', 'S000001'));
check('edgeKey: min<max ordering', edgeKey('S000009', 'S000002') === 'S000002<S000009');
check('edgeKey: distinct lanes differ', edgeKey('A', 'B') !== edgeKey('A', 'C'));
}
// ---------------------------------------------------------------------------
// buildGalaxySnapshot — the plate's live data
// ---------------------------------------------------------------------------
{
const lane = edgeKey(homeId, a.id);
const snap = buildGalaxySnapshot({
galaxy,
visited: [homeId, a.id],
used: [lane],
live: [`${homeId}>${a.id}`],
currentSystemId: homeId,
});
check('snap: one entry per system', snap.systems.length === galaxy.records.length);
check('snap: identity carried (name/seed/home/current)',
snap.name === galaxy.name && snap.seed === SEED
&& snap.homeSystemId === homeId && snap.currentSystemId === homeId);
const recA = snap.systems.find((s) => s.id === a.id);
const recB = snap.systems.find((s) => s.id === b.id);
check('snap: visited flags', recA.visited === true && recB.visited === false);
check('snap: home/current flags', recA.isHome === false && recA.isCurrent === false);
check('snap: gate counts from the jump network',
recA.gates === (galaxy.jumpNetwork.gates.get(a.id)?.length ?? 0));
check('snap: FACTIONS seam present (faction: null, not yet implemented)',
snap.systems.every((s) => 'faction' in s && s.faction === null));
check('snap: a spanning tree = n1 lanes (dedupd)',
galaxy.records.length === 9 ? snap.edges.length === 8 : snap.edges.length === galaxy.records.length - 1);
const homeA = snap.edges.find((e) => edgeKey(e.a, e.b) === lane);
check('snap: the traveled lane is used', homeA?.used === true);
check('snap: the live lane out of the current system is live', homeA?.live === true);
const homeB = snap.edges.find((e) => edgeKey(e.a, e.b) === edgeKey(homeId, b.id));
check('snap: a lane with exactly one end visited is frontier',
homeB ? homeB.frontier === true && homeB.used === false : false);
check('snap: stats',
snap.stats.systems === 9 && snap.stats.visited === 2
&& snap.stats.lanes === snap.edges.length && snap.stats.lanesUsed === 1);
// No live set → no live lane (the jump confirm must not apply).
const snap2 = buildGalaxySnapshot({
galaxy,
visited: [homeId],
used: [],
live: new Set(),
currentSystemId: homeId,
});
check('snap: no live gates → no live edges', snap2.edges.every((e) => e.live === false));
// Current-system-only visit → no frontier (both ends unvisited or both the
// current system's own edges count: exactly one end visited = frontier).
check('snap: frontier count matches the definition',
snap2.edges.filter((e) => e.frontier).length
=== snap2.edges.filter((e) => {
const va = snap2.systems.find((s) => s.id === e.a).visited;
const vb = snap2.systems.find((s) => s.id === e.b).visited;
return va !== vb;
}).length);
// ROUTE + DESTINATION: the active SET DESTINATION path (its lanes marked
// `route: true`) and the destination star (`isDestination: true`).
const routeLane = edgeKey(homeId, a.id);
const snapR = buildGalaxySnapshot({
galaxy,
visited: [homeId],
used: [],
live: new Set(),
currentSystemId: homeId,
route: [routeLane],
destinationId: a.id,
});
check('snapR: destinationId carried', snapR.destinationId === a.id);
check('snapR: destination star flagged', snapR.systems.find((s) => s.id === a.id)?.isDestination === true);
check('snapR: non-destination stars unflagged', snapR.systems.find((s) => s.id === b.id)?.isDestination === false);
check('snapR: route lane flagged', snapR.edges.find((e) => e.key === routeLane)?.route === true);
check('snapR: non-route lanes unflagged', snapR.edges.every((e) => e.route === (e.key === routeLane)));
// No route / no destination → everything unflagged (the default).
const snapNR = buildGalaxySnapshot({ galaxy, visited: [homeId], currentSystemId: homeId });
check('snapNR: no destination → all unflagged', snapNR.destinationId === null && snapNR.systems.every((s) => s.isDestination === false));
check('snapNR: no route → no route edges', snapNR.edges.every((e) => e.route === false));
}
// ---------------------------------------------------------------------------
// charted-region geometry
// ---------------------------------------------------------------------------
{
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
{ x: 5, y: 5 }, // interior — must drop
];
const hull = convexHull(square);
check('hull: interior point dropped', hull.length === 4);
check('hull: all-collinear → the two extremes',
convexHull([{ x: 0, y: 0 }, { x: 5, y: 5 }, { x: 10, y: 10 }]).length === 2);
check('hull: 2 pts → both kept', convexHull([{ x: 0, y: 0 }, { x: 3, y: 4 }]).length === 2);
check('hull: 1 pt → itself', convexHull([{ x: 2, y: 2 }]).length === 1);
check('hull: [] → []', convexHull([]).length === 0);
const ring = paddedHullPolygon([{ x: 4, y: 4 }], 5);
check('pad(1 pt) → a ring of ~radius 5',
ring.length >= 8 && Math.abs(Math.hypot(ring[0].x - 4, ring[0].y - 4) - 5) < 0.5);
const cap = paddedHullPolygon([{ x: 0, y: 0 }, { x: 10, y: 0 }], 3);
check('pad(2 pts) → a capsule (segment + end discs)',
cap.length >= 4 && cap.every((p) => Math.abs(p.y) <= 3 + 1e-9));
const padSq = paddedHullPolygon(square, 2);
const inBounds = (p, lo, hi) => p >= lo - 1e-9 && p <= hi + 1e-9;
const area = (poly) => {
let s = 0;
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
s += p.x * q.y - q.x * p.y;
}
return Math.abs(s) / 2;
};
check('pad(square) → a bigger square (the parallel polygon)',
padSq.length === 4
&& padSq.every((p) => inBounds(p.x, -2, 12) && inBounds(p.y, -2, 12))
&& area(padSq) === 14 * 14 // (10 + 2·2)²
);
check('pad(square): corners are the true offsets (not a 12-vertex fan)',
padSq.length === 4);
const padTri = paddedHullPolygon(
[
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 5, y: 8 },
],
1.5
);
check('pad(triangle) → 3 parallel corners', padTri.length === 3);
check('pad(0) → the hull unchanged',
paddedHullPolygon(square, 0).length === 4 && area(paddedHullPolygon(square, 0)) === 100);
}
// ---------------------------------------------------------------------------
// the per-archetype heartbeat
// ---------------------------------------------------------------------------
{
const p1 = starPulse('binary', 1234, 0.7);
const p2 = starPulse('binary', 1234, 0.7);
check('pulse: deterministic for (type, time, phase)', p1 === p2);
check('pulse: in the 0..1 band', p1 >= 0 && p1 <= 1);
const lo = Infinity;
const hi = -Infinity;
let min = Infinity;
let max = -Infinity;
for (let t = 0; t < 200; t++) {
const v = starPulse('nebula', t * 17.3, 0.2);
min = Math.min(min, v);
max = Math.max(max, v);
}
const pc = config.get('map.galaxy.pulse.nebula', {});
const amp = Math.min(1, Math.max(0, Number(pc?.amp ?? 0.6) || 0));
check('pulse: bounded by its amp (0.5 ± 0.5·amp)',
min >= 0.5 - 0.5 * amp - 1e-9 && max <= 0.5 + 0.5 * amp + 1e-9);
void lo;
void hi;
// every configured archetype has a chart color + a pulse definition
const types = Object.keys(config.get('systems.types', {}));
check('types: the six archetypes present',
['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'].every((t) => types.includes(t)));
check('types: each has a chart color', types.every((t) => starTypeColor(t).startsWith('#')));
check('types: each has a pulse entry (the per-type animation)',
types.every((t) => {
const pc2 = config.get(`map.galaxy.pulse.${t}`, null);
return pc2 && Number.isFinite(Number(pc2.speed)) && Number.isFinite(Number(pc2.amp));
}));
check('pulse: unknown type falls back (no crash)',
Number.isFinite(starPulse('unknownTypeXyz', 100, 0)));
check('color: unknown type falls back to the default hex',
starTypeColor('unknownTypeXyz') === '#9fb6d8');
}
// ---------------------------------------------------------------------------
// SaveData — the run's footprint (visitedSystems + usedGates)
// ---------------------------------------------------------------------------
{
const system = galaxy.byId.get(homeId);
const lane = edgeKey(homeId, a.id);
const reg = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
const scene = {
registry: reg,
galaxy,
systemRecord: { id: homeId, name: system.name },
ship: { x: 1, y: 2, rotation: 0.3, minerals: 0 },
discovery: new Discovery(540),
tetherField: { tethers: [] },
playTimeMs: 1000,
activatedGates: new Set([`${homeId}>${a.id}`]),
visitedSystems: new Set([homeId, a.id]),
usedGates: new Set([lane]),
};
const rec = captureState(scene);
check('capture: visitedSystems carried', JSON.stringify(rec.visitedSystems.sort()) === JSON.stringify([homeId, a.id].sort()));
check('capture: usedGates carried', rec.usedGates.includes(lane));
check('capture: record still validates', SaveManager.validateRecord(rec) === null);
const reg2 = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
prepareLoad(reg2, rec);
check('prepare: visitedSystems restored (the region)',
reg2.get('visitedSystems') instanceof Set && reg2.get('visitedSystems').has(homeId) && reg2.get('visitedSystems').has(a.id));
check('prepare: usedGates restored (the bright lanes)',
reg2.get('usedGates') instanceof Set && reg2.get('usedGates').has(lane));
// LEGACY — a record from before the galaxy tab: no footprint fields at all.
const legacy = { ...rec };
delete legacy.visitedSystems;
delete legacy.usedGates;
check('legacy: still validates (old saves load)', SaveManager.validateRecord(legacy) === null);
const reg3 = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
prepareLoad(reg3, legacy);
check('legacy: visited defaults to the current system only',
reg3.get('visitedSystems') instanceof Set && reg3.get('visitedSystems').size === 1
&& reg3.get('visitedSystems').has(homeId));
check('legacy: usedGates default to empty', reg3.get('usedGates') instanceof Set && reg3.get('usedGates').size === 0);
// New Game — the footprint must not leak into the next run.
const reg4 = {
map: new Map(),
set(k, v) {
this.map.set(k, v);
},
get(k) {
return this.map.get(k);
},
};
reg4.set('visitedSystems', new Set(['X']));
reg4.set('usedGates', new Set(['Y']));
resetRunState(reg4);
check('reset: the footprint is cleared for a new run',
reg4.get('visitedSystems') === null && reg4.get('usedGates') === null);
}
// ── the plate clip (the chart stays inside its window) ──────────────────
{
const { clipLineToRect, clipPolygonToRect } = await import(file('galaxy/GalaxyChart.js'));
const R = { x: 10, y: 10, w: 100, h: 60 }; // plate rect: 10..110 × 10..70
// line fully inside → untouched
const li = clipLineToRect(20, 20, 90, 50, R);
check('clipLine: a fully-inside segment is unchanged',
li && Math.abs(li[0] - 20) < 1e-9 && Math.abs(li[1] - 20) < 1e-9 &&
Math.abs(li[2] - 90) < 1e-9 && Math.abs(li[3] - 50) < 1e-9);
// line fully outside → null
check('clipLine: a fully-outside segment is dropped',
clipLineToRect(120, 80, 140, 90, R) === null);
// line crossing the right edge (x = 110): from (50,20) to (150,40)
// t at x=110: (110-50)/(150-50) = 0.6 → y = 20 + 0.6*20 = 32
const lr = clipLineToRect(50, 20, 150, 40, R);
check('clipLine: a right-edge crossing ends exactly on the frame',
lr && Math.abs(lr[2] - 110) < 1e-9 && Math.abs(lr[3] - 32) < 1e-9);
// line crossing the top edge (y = 10): from (20, -20) to (20, 30)
const lt = clipLineToRect(20, -20, 20, 30, R);
check('clipLine: a top-edge crossing starts exactly on the frame',
lt && Math.abs(lt[0] - 20) < 1e-9 && Math.abs(lt[1] - 10) < 1e-9);
// a diagonal corner crossing (bottom-right): (50,50) → (150,100)
// hits x=110 at t=(110-50)/(150-50)=0.6 → y=50+0.6*50=80 (outside h)
// → then hits y=70 at t=(70-50)/(100-50)=0.4 → x=50+0.4*100=90 (inside)
// → the kept end is (90, 70)
const lc = clipLineToRect(50, 50, 150, 100, R);
check('clipLine: a corner crossing clips to the nearer boundary',
lc && Math.abs(lc[2] - 90) < 1e-9 && Math.abs(lc[3] - 70) < 1e-9);
// polygon fully inside → same winding, same point count
const sq = [{ x: 20, y: 20 }, { x: 90, y: 20 }, { x: 90, y: 50 }, { x: 20, y: 50 }];
const pi = clipPolygonToRect(sq, R);
check('clipPoly: a fully-inside polygon is unchanged',
pi.length === 4 && Math.abs(pi[0].x - 20) < 1e-9 && Math.abs(pi[2].y - 50) < 1e-9);
// polygon fully outside → empty
check('clipPoly: a fully-outside polygon is dropped',
clipPolygonToRect(
[{ x: 120, y: 80 }, { x: 140, y: 80 }, { x: 140, y: 100 }, { x: 120, y: 100 }], R,
).length === 0);
// polygon straddling the right edge: 20..150 × 20..50
// → clipped to 20..110 × 20..50 (a quad with two new edge points)
const ps = clipPolygonToRect(
[{ x: 20, y: 20 }, { x: 150, y: 20 }, { x: 150, y: 50 }, { x: 20, y: 50 }], R,
);
check('clipPoly: a straddling polygon is cut at the frame',
ps.length >= 4 &&
ps.every((p) => p.x <= 110 + 1e-9 && p.x >= 10 - 1e-9 && p.y >= 10 - 1e-9 && p.y <= 70 + 1e-9) &&
ps.some((p) => Math.abs(p.x - 110) < 1e-9));
// a hull-like ring that panned half out the TOP-LEFT corner
const ring = [];
for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2;
ring.push({ x: 10 + Math.cos(a) * 30, y: 10 + Math.sin(a) * 30 }); // center on the corner
}
const pr = clipPolygonToRect(ring, R);
check('clipPoly: a corner-clipped ring stays in-frame (and has area)',
pr.length >= 3 && pr.every((p) => p.x >= 10 - 1e-9 && p.y >= 10 - 1e-9 && p.x <= 110 + 1e-9 && p.y <= 70 + 1e-9));
// determinism (same input → same output)
const d1 = JSON.stringify(clipPolygonToRect(sq.map((p) => ({ ...p })), R));
const d2 = JSON.stringify(clipPolygonToRect(sq.map((p) => ({ ...p })), R));
check('clipPoly: deterministic', d1 === d2);
}
// ---------------------------------------------------------------------------
console.log(failures === 0 ? '\nall galaxy-map checks passed ✓' : `\n${failures} check(s) FAILED`);
if (failures > 0) process.exit(1);