/** * JUMP TRAVEL test (dev tool, run with Node — no browser): * * node dev/jump-travel.test.mjs * * Pins the contract behind the gate JUMP (GameScene.jumpThroughGate — * click an active gate, the run re-stages for the connected system via * the save pipeline, arriving near the destination's RETURN gate): * - config: data/gates.json → jump (the feature switch, the arrival * geometry, the toasts with their placeholders); * - the pure geometry (js/galaxy/JumpTravel.js): the return-gate * lookup (tree-edge jumps have one, one-way shortcut jumps don't), * the spawn point (just past the gate's keepout, on the side the * ship lands on, nose along the travel direction), inside the * activated gate's tether zone; * - against a REAL galaxy: every gate's destination is a known * system, jumps with a return gate land at it (and jumps without * one — shortcuts — report null for the scene's origin fallback), * determinism (same seed ⇒ same arrivals). * * The scene-level transport itself (captureState → prepareLoad → * scene.restart) is Phaser glue — covered by the existing save tests' * pipeline plus this file's contract; run the game to play it. */ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); const dataDir = join(root, 'data'); const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8')); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // ---------------------------------------------------------------------- // Config — data/gates.json → jump // ---------------------------------------------------------------------- const gates = read('gates.json'); const tether = read('tether.json'); const jump = gates.jump; check('jump: section present', !!jump && typeof jump === 'object'); check('jump: enabled (feature switch, on)', jump?.enabled === true); check('jump: arrivalGap is a non-negative finite number (px)', Number.isFinite(jump?.arrivalGap) && jump.arrivalGap >= 0); check('jump: jumpDelayMs is a positive finite number (the cut, ms)', Number.isFinite(jump?.jumpDelayMs) && jump.jumpDelayMs > 0); check('jump: the toast templates keep their placeholders', typeof jump?.toast === 'string' && jump.toast.includes('{dest}') && typeof jump?.dormantToast === 'string' && jump.dormantToast.includes('{system}') && typeof jump?.miningToast === 'string' && jump.miningToast.length > 0); check('jump: the gap keeps the ship outside the keepout with room to spare', Number(jump?.arrivalGap ?? -1) + Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0) > Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0)); // ---------------------------------------------------------------------- // The pure geometry (js/galaxy/JumpTravel.js) // ---------------------------------------------------------------------- const { returnGateFor, arrivalPoint, jumpArrival } = await import('../js/galaxy/JumpTravel.js'); const contentA = { jumps: [ { id: 'a-j1', to: 'B', x: 0, y: 0, rotation: 0 }, { id: 'a-j2', to: 'C', x: 1, y: 1, rotation: 0.5 }, ], }; check('returnGateFor: finds the gate pointing back at the system left', returnGateFor(contentA, 'B')?.id === 'a-j1' && returnGateFor(contentA, 'C')?.id === 'a-j2'); check('returnGateFor: null when the destination holds no return gate (one-way shortcut)', returnGateFor(contentA, 'D') === null); check('returnGateFor: null on degenerate content', returnGateFor(null, 'B') === null && returnGateFor({ jumps: 'nope' }, 'B') === null); const g0 = { x: 1000, y: 2000, rotation: 0 }; const cfg = { radius: 96, clearance: 50, shipRadius: 16, gap: 128 }; const off = cfg.radius + cfg.clearance + cfg.shipRadius + cfg.gap; // 290 const p0 = arrivalPoint(g0, cfg); check('arrivalPoint: sits back along the facing, just past the keepout', Math.abs(p0.x - (1000 - off)) < 1e-9 && Math.abs(p0.y - 2000) < 1e-9); check('arrivalPoint: the ship keeps its nose along the travel direction', Math.abs(p0.heading - Math.PI) < 1e-9); const p90 = arrivalPoint({ x: 1000, y: 2000, rotation: Math.PI / 2 }, cfg); check('arrivalPoint: facing π/2 → spawn below the gate (far side of the facing)', Math.abs(p90.x - 1000) < 1e-9 && Math.abs(p90.y - (2000 - off)) < 1e-9); const d0 = Math.hypot(p0.x - 1000, p0.y - 2000); check('arrivalPoint: OUTSIDE the keepout (radius + clearance)', d0 === off && d0 > cfg.radius + cfg.clearance); const l1 = Number(tether.level1Radius) || 5120; check('arrivalPoint: INSIDE the activated gate\'s tether zone (level-1 radius)', d0 < l1); const ja = jumpArrival(contentA, 'B', cfg); check('jumpArrival: { x, y, heading, gateId } for a tree-edge jump', !!ja && ja.gateId === 'a-j1' && Number.isFinite(ja.x) && Number.isFinite(ja.y) && Number.isFinite(ja.heading)); check('jumpArrival: null for a one-way shortcut (the scene falls back to the origin)', jumpArrival(contentA, 'D', cfg) === null); // ---------------------------------------------------------------------- // A REAL galaxy (bootstrap config the way main.js does, from the data dir) // ---------------------------------------------------------------------- const manifest = read('manifest.json'); const data = {}; for (const f of manifest.files ?? []) { const file = typeof f === 'string' ? f : f.file; // Same keying as ConfigLoader: file name without .json (subdir stripped). const name = file.split('/').pop().replace(/\.json$/, ''); data[name] = JSON.parse(fs.readFileSync(join(dataDir, file), 'utf8')); } const { config } = await import('../js/config/Config.js'); config.init(data); const { Galaxy } = await import('../js/galaxy/Galaxy.js'); const seed = 'JUMP-TRAVEL-77'; const g = Galaxy.create(seed); const startId = g.currentSystemId; check('galaxy: the starting system holds at least one gate (minGates)', (g.ensureContent(startId).jumps ?? []).length >= 1); let total = 0, withReturn = 0, badLanding = 0, badFb = 0; for (const rec of g.records) { const c = g.ensureContent(rec.id); for (const j of c.jumps ?? []) { total += 1; if (!g.byId.has(j.to)) badFb += 1; // a gate must point at a KNOWN system const dest = g.ensureContent(j.to); const radius = Number(j.size) || Number(gates.size) || 96; const clearance = Number(j.clearance) || Number(gates.shipClearance) || 0; const arr = jumpArrival(dest, rec.id, { radius, clearance, shipRadius: 16, gap: Number(jump?.arrivalGap) || 0, }); if (arr) { withReturn += 1; const rg = returnGateFor(dest, rec.id); const d = Math.hypot(arr.x - rg.x, arr.y - rg.y); const keepout = radius + clearance; if (!(d >= keepout - 1e-6 && d < l1)) badLanding += 1; } } } check('galaxy: every gate\'s destination is a known system (no dangling links)', badFb === 0); check(`galaxy: ${total} gates, ${withReturn} land at a return gate, the rest are one-way (origin fallback)`, withReturn > 0 && total - withReturn >= 0); check('galaxy: every landing is past the keepout and inside the gate tether', badLanding === 0); // Determinism — same seed ⇒ same network ⇒ same arrivals. const g2 = Galaxy.create(seed); const c1 = g.ensureContent(startId), c2 = g2.ensureContent(startId); const same = c1.jumps.length === c2.jumps.length && c1.jumps.every((j, i) => j.id === c2.jumps[i].id && j.to === c2.jumps[i].to && Math.abs(j.x - c2.jumps[i].x) < 1e-9 && Math.abs(j.y - c2.jumps[i].y) < 1e-9); const a1 = jumpArrival(g.ensureContent(c1.jumps[0].to), startId, cfg); const a2 = jumpArrival(g2.ensureContent(c1.jumps[0].to), startId, cfg); check('galaxy: deterministic arrivals (same seed ⇒ same gate ⇒ same spawn)', same && !!a1 && !!a2 && Math.abs(a1.x - a2.x) < 1e-9 && Math.abs(a1.y - a2.y) < 1e-9); // ---------------------------------------------------------------------- console.log(''); if (failures) { console.log(`✘ ${failures} check(s) FAILED`); process.exit(1); } console.log('✔ all jump-travel checks passed');