/** * 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 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${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);