orbit/js/galaxy/JumpTravel.js

156 lines
6.9 KiB
JavaScript

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