273 lines
14 KiB
JavaScript
273 lines
14 KiB
JavaScript
/**
|
|
* 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 —
|
|
* the gate comm window's REQUEST JUMP, 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 FACTION GATE LOCK (data/gates.json → jump.factionLock, the
|
|
* pure rule js/galaxy/JumpTravel.js → factionGateLocked): a gate
|
|
* from a NEUTRAL system into a FACTION-CONTROLLED one is locked
|
|
* below the lock's minLevel (level 5) — and the REAL galaxy has
|
|
* such gates at a fresh run's level, all open at level 5;
|
|
* - 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 landing = read('landing.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: combatToast is the live-fight denial (non-empty string)',
|
|
typeof jump?.combatToast === 'string' && jump.combatToast.length > 0);
|
|
|
|
// The FACTION GATE LOCK (GameScene.openGateCommsPanel → the gate comm
|
|
// window's grayed REQUEST JUMP + its message; GameScene.jumpThroughGate
|
|
// keeps its own flat denial): the shipped contract — the feature on,
|
|
// the level-5 rule, and the message the panel must show, verbatim.
|
|
const flock = jump?.factionLock;
|
|
check('factionLock: section present', !!flock && typeof flock === 'object');
|
|
check('factionLock: enabled (feature switch, on)', flock?.enabled === true);
|
|
check('factionLock: minLevel is the level that OPENS the lock (5)', Number(flock?.minLevel) === 5);
|
|
check('factionLock: the message is the panel\'s verbatim copy',
|
|
flock?.message === 'Jump Gate is under Faction Control. Status: Locked');
|
|
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 COMBAT gates (GameScene.jumpThroughGate / startLanding — a live
|
|
// fight, CombatSystem.active, denies the jump AND the landing: the run
|
|
// can't leave the system it's fighting in, and can't hide on a surface
|
|
// while the wave runs): the toasts' shapes.
|
|
check('landing: combatDenyToast configured (glyph + text + color)',
|
|
!!landing.combatDenyToast && typeof landing.combatDenyToast.glyph === 'string' &&
|
|
typeof landing.combatDenyToast.text === 'string' && landing.combatDenyToast.text.length > 0 &&
|
|
/^#[0-9a-fA-F]{6}$/.test(landing.combatDenyToast.color ?? ''));
|
|
|
|
// ----------------------------------------------------------------------
|
|
// The pure geometry (js/galaxy/JumpTravel.js)
|
|
// ----------------------------------------------------------------------
|
|
const { returnGateFor, arrivalPoint, jumpArrival, ambushTriggerDistance, factionGateLocked } = 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);
|
|
|
|
// The 'Pirate Problems' ambush's trigger — HALF the gate→capital stretch
|
|
// (GameScene._maybeStartPirateAmbush fires the wave once the ship is
|
|
// within that distance of the capital world: the midpoint of the
|
|
// jumpgate→world line).
|
|
const gate0 = { x: 1000, y: 2000 };
|
|
const cap0 = { x: 4000, y: 6000 }; // 3000, 4000 apart → 5000
|
|
check('ambushTriggerDistance: half the gate→world stretch (the ambush midpoint)',
|
|
ambushTriggerDistance(gate0, cap0) === 2500);
|
|
check('ambushTriggerDistance: symmetric (gate/world order is immaterial)',
|
|
ambushTriggerDistance(cap0, gate0) === 2500);
|
|
check('ambushTriggerDistance: null on a missing point (no gate / no capital)',
|
|
ambushTriggerDistance(null, cap0) === null &&
|
|
ambushTriggerDistance(gate0, null) === null &&
|
|
ambushTriggerDistance({ x: 1 }, { y: 1 }) === null);
|
|
check('ambushTriggerDistance: null on a zero stretch (gate on the world)',
|
|
ambushTriggerDistance({ x: 5, y: 5 }, { x: 5, y: 5 }) === null);
|
|
|
|
// ----------------------------------------------------------------------
|
|
// The FACTION GATE LOCK (js/galaxy/JumpTravel.js → factionGateLocked) —
|
|
// a gate FROM a neutral system INTO a faction-controlled one, locked
|
|
// while the player's level is below minLevel (data/gates.json →
|
|
// jump.factionLock; the gate comm window's grayed REQUEST JUMP + the
|
|
// "under Faction Control" message are the visible half).
|
|
// ----------------------------------------------------------------------
|
|
const NEUTRAL = { id: 'n', faction: null };
|
|
const NEUTRAL_STR = { id: 'ns', faction: 'neutral' };
|
|
const FACTION = { id: 'f', faction: 'apex' };
|
|
const FACTION2 = { id: 'f2', faction: 'mindweave' };
|
|
|
|
check('factionGateLocked: neutral → faction, below minLevel, is LOCKED',
|
|
factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL, dest: FACTION }) === true &&
|
|
factionGateLocked({ minLevel: 5, level: 4, from: NEUTRAL, dest: FACTION }) === true);
|
|
check('factionGateLocked: at/above minLevel the lock is OPEN (level 5)',
|
|
factionGateLocked({ minLevel: 5, level: 5, from: NEUTRAL, dest: FACTION }) === false &&
|
|
factionGateLocked({ minLevel: 5, level: 80, from: NEUTRAL, dest: FACTION }) === false);
|
|
check('factionGateLocked: a missing level counts as 0 (a fresh run finds it locked)',
|
|
factionGateLocked({ minLevel: 5, level: null, from: NEUTRAL, dest: FACTION }) === true &&
|
|
factionGateLocked({ minLevel: 5, level: undefined, from: NEUTRAL, dest: FACTION }) === true);
|
|
check('factionGateLocked: the neutral→neutral hop is never locked',
|
|
factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL, dest: NEUTRAL }) === false &&
|
|
factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL_STR, dest: NEUTRAL }) === false);
|
|
check('factionGateLocked: gates FROM a faction system are never locked (either way)',
|
|
factionGateLocked({ minLevel: 5, level: 0, from: FACTION, dest: NEUTRAL }) === false &&
|
|
factionGateLocked({ minLevel: 5, level: 0, from: FACTION, dest: FACTION2 }) === false);
|
|
check('factionGateLocked: an unknown destination is not faction-controlled (never locked)',
|
|
factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL, dest: null }) === false);
|
|
check('factionGateLocked: enabled:false lifts the lock entirely',
|
|
factionGateLocked({ enabled: false, minLevel: 5, level: 0, from: NEUTRAL, dest: FACTION }) === false);
|
|
check('factionGateLocked: a custom minLevel rules (10: level 5 is still locked, 10 open)',
|
|
factionGateLocked({ minLevel: 10, level: 5, from: NEUTRAL, dest: FACTION }) === true &&
|
|
factionGateLocked({ minLevel: 10, level: 10, from: NEUTRAL, dest: FACTION }) === false);
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 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);
|
|
|
|
// The FACTION GATE LOCK against this galaxy: at a fresh run's level (0)
|
|
// the neutral→faction gates stand LOCKED (the panel's grayed REQUEST
|
|
// JUMP + its message); at level 5 every lock is open — and the galaxy
|
|
// actually holds such gates (the lock has a target).
|
|
const lockedAt = (level) => {
|
|
let n = 0;
|
|
for (const rec of g.records) {
|
|
for (const j of g.ensureContent(rec.id).jumps ?? []) {
|
|
const dest = g.byId.get(j.to) ?? null;
|
|
if (factionGateLocked({
|
|
enabled: flock.enabled !== false,
|
|
minLevel: Number(flock.minLevel) || 5,
|
|
level,
|
|
from: rec,
|
|
dest,
|
|
})) n += 1;
|
|
}
|
|
}
|
|
return n;
|
|
};
|
|
const lockedFresh = lockedAt(0);
|
|
check(`galaxy: ${lockedFresh} neutral→faction gates stand at a fresh run's level (the lock has a target)`,
|
|
lockedFresh > 0);
|
|
check('galaxy: level 5 lifts every faction-gate lock', lockedFresh > 0 && lockedAt(5) === 0);
|
|
check(`galaxy: ${withReturn} gates 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');
|