410 lines
17 KiB
JavaScript
410 lines
17 KiB
JavaScript
/**
|
||
* 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 = n−1 lanes (dedup’d)',
|
||
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);
|