/** * JumpTravel — the pure geometry of a gate jump (no Phaser): * * returnGateFor(content, fromId) * The destination system's RETURN gate — the gate in `content` * whose destination is the system the ship just left (the first * `content.jumps` entry with `to === fromId`, or null). * * arrivalPoint(gate, cfg) * Where the ship materialises when it exits a gate: offset BACK * along the gate's facing (a gate's `rotation` is the bearing to * its DESTINATION star — the system we came from), just clear of * the keepout circle (gate radius + ship clearance + ship radius * + the config gap). The ship keeps its nose along the way it was * travelling (heading = facing + π). * * jumpArrival(content, fromId, cfg) * Both, together: { x, y, heading, gateId } — or null when the * destination holds no return gate (a one-way SHORTCUT jump — * JumpNetwork: tree edges run both ways, shortcuts don't). With * shortcuts OFF (the current config) the network is a pure spanning * tree, so a return gate always exists and this never returns null; * the null path is defensive (re-enabled shortcuts, or a malformed * record). The scene falls back to the destination's origin (its * home-tether zone) when this is null. * * factionGateLocked(o) * The FACTION GATE LOCK (data/gates.json → jump.factionLock): a * gate FROM a neutral system INTO a faction-controlled one, locked * while the player's level is below minLevel. * * GameScene.jumpThroughGate supplies the live numbers (the gate's * radius/clearance, the ship's radius, data/gates.json → jump) and * does the actual transport (re-stage the run for the destination * system via the save pipeline, then scene.restart); this module only * decides WHICH gate and WHERE — pure enough for Node tests * (dev/jump-travel.test.mjs). */ /** * @param {object|null} content the destination system's content record * (galaxy.ensureContent(id): { jumps: [{ id, name, to, x, y, rotation }] }) * @param {string} fromId the id of the system the ship is leaving * @returns {object|null} the return gate's content record, or null */ export function returnGateFor(content, fromId) { if (!content || fromId == null) return null; const jumps = Array.isArray(content.jumps) ? content.jumps : []; return jumps.find((j) => j && j.to === fromId) ?? null; } /** * The spawn point just past a gate's keepout, on the side the ship * lands on (the far side of its facing). * * @param {object} gate the gate's content record: { x, y, rotation } * @param {object} [cfg] { radius (gate keepout, px), clearance (ship * keepout from the gate, px), shipRadius (px), gap (extra px, from * data/gates.json → jump.arrivalGap) } * @returns {{x:number, y:number, heading:number}} */ export function arrivalPoint(gate, cfg = {}) { const radius = Number(cfg.radius) || 0; const clearance = Number(cfg.clearance) || 0; const shipRadius = Number(cfg.shipRadius) || 0; const gap = Number(cfg.gap) || 0; const a = Number(gate?.rotation) || 0; const offset = radius + clearance + shipRadius + gap; return { x: (Number(gate?.x) || 0) - Math.cos(a) * offset, y: (Number(gate?.y) || 0) - Math.sin(a) * offset, heading: a + Math.PI, }; } /** * The jump's arrival for a destination system: at its return gate (if * it holds one — tree-edge jumps do; one-way shortcut jumps don't; * shortcuts are OFF in the current config, so the return gate always * exists and the null path below is defensive only). * * @returns {{x:number, y:number, heading:number, gateId:string}|null} * null when there is no return gate — the caller falls back. */ export function jumpArrival(content, fromId, cfg = {}) { const gate = returnGateFor(content, fromId); if (!gate) return null; return { ...arrivalPoint(gate, cfg), gateId: gate.id }; } /** * The 'Pirate Problems' ambush's TRIGGER DISTANCE — half the * gate→capital-world stretch (GameScene._maybeStartPirateAmbush): the * raiders materialise once the player is that far from the capital * world, i.e. at the MIDPOINT of the entry-jumpgate → world line, so * the conflict starts halfway to the capital from the jumpgate. * * @param {{x:number,y:number}|null} gate the entry gate (a content * record, or any {x, y} anchor — the scene's spawn fallback) * @param {{x:number,y:number}|null} world the capital world * @returns {number|null} dist(gate, world) / 2, or null when either * point is missing or the stretch is degenerate (zero) */ export function ambushTriggerDistance(gate, world) { const gx = Number(gate?.x); const gy = Number(gate?.y); const wx = Number(world?.x); const wy = Number(world?.y); if (![gx, gy, wx, wy].every(Number.isFinite)) return null; const d = Math.hypot(gx - wx, gy - wy); return d > 0 ? d / 2 : null; } /** * The FACTION GATE LOCK (data/gates.json → jump.factionLock): a jump * gate FROM a NEUTRAL system INTO a FACTION-CONTROLLED one is locked * while the player's level is below `minLevel`. * * - A system is NEUTRAL when its record's `faction` is null, '' or the * string 'neutral' (the roster defaults to null — neutral wild space); * any other `faction` (apex/mindweave/zenith, data/factions.json) is * FACTION-CONTROLLED. * - Gates FROM a faction system are never locked (the run is already * inside faction space — leaving it, or moving within it, stays open), * and neutral→neutral hops are never locked. * - A missing level (null — no XP bank yet) counts as level 0, so a * fresh run finds the gates locked. * - A missing destination (unknown id) is not faction-controlled, so it * is never locked by this rule (the scene's own "unknown destination" * denial stands on its own). * * Pure (no Phaser, no config import) — the caller resolves the config * section and the system records and passes them in (Node-testable in * dev/jump-travel.test.mjs). * * @param {object} o { enabled (the feature switch, default true), * minLevel (the level that OPENS the lock, default 5), level (the * player's combat level — null/undefined = 0), from (the current * system's record), dest (the gate's DESTINATION system's record — * null when unknown) } — a system record only contributes `faction` * @returns {boolean} true while the gate's jump is blocked */ export function factionGateLocked(o = {}) { if (o.enabled === false) return false; // the feature switch const minLevel = Number.isFinite(Number(o.minLevel)) ? Number(o.minLevel) : 5; const level = Number.isFinite(Number(o.level)) ? Number(o.level) : 0; if (level >= minLevel) return false; // the lock is open at/above minLevel const neutral = (rec) => { const f = rec?.faction; return f == null || f === '' || f === 'neutral'; }; if (!neutral(o.from)) return false; // already inside faction space if (o.dest == null) return false; // an unknown destination is not faction-controlled return !neutral(o.dest); }