orbit/js/scenes/GameScene.js

10624 lines
515 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { canonicalPlanetName } from '../utils/WorldNames.js';
import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
import { EnemyShip } from '../combat/EnemyShip.js';
import { enemySheetList, enemyTypeIds, neutralTypeIds, factionIds, factionTypeIds, enemyTypeConfig, transportSheet } from '../combat/EnemyTypes.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
import { JumpGate } from '../entities/JumpGate.js';
import { jumpArrival, returnGateFor, ambushTriggerDistance, factionGateLocked } from '../galaxy/JumpTravel.js';
import { Starfield } from '../visuals/Starfield.js';
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
import { NebulaLightning } from '../visuals/NebulaLightning.js';
import { EmberField } from '../visuals/EmberField.js';
import { EngineFlare } from '../visuals/EngineFlare.js';
import { WandererStar } from '../visuals/WandererStar.js';
import { RedHaze } from '../visuals/RedHaze.js';
import { VoidBase } from '../visuals/VoidBase.js';
import { StormLayer } from '../visuals/StormLayer.js';
import { SystemEffects } from '../visuals/SystemEffects.js';
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
import { pickFxSystem } from '../galaxy/FxSystems.js';
import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { CombatBar } from '../ui/CombatBar.js';
import { LevelUpMarquee } from '../ui/LevelUpMarquee.js';
import { CombatSystem } from '../combat/CombatSystem.js';
import { XpManager } from '../combat/XpManager.js';
import { EncounterDirector, encounterAllowed } from '../combat/Encounters.js';
import { CombatHud } from '../combat/CombatHud.js';
import { DuelPicker } from '../combat/DuelPicker.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
import { scoutGood } from '../economy/TradeScout.js';
import * as Stock from '../economy/Stock.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { GameOverOverlay } from '../ui/GameOverOverlay.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
import { CommsHub } from '../comms/CommsHub.js';
import { commsForTrigger } from '../comms/CommsModel.js';
import { Tether } from '../tether/Tether.js';
import { TetherField } from '../tether/TetherField.js';
import { Mining } from '../mining/Mining.js';
import { MiningPopup } from '../ui/MiningPopup.js';
import { ScanPulse } from '../scan/ScanPulse.js';
import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
import { CommsPanel } from '../ui/CommsPanel.js';
import { ResearchWindow } from '../ui/ResearchWindow.js';
import { MapWindow } from '../ui/MapWindow.js';
import { ShipWindow } from '../ui/ShipWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
import {
SideQuestState,
sideQuestPostingsFor,
parseSideQuestId,
buildPatrolRoute,
derivePatrolEncounters,
patrolEncounterScript,
patrolEncounterXp,
patrolRouteComplete,
sideQuestAcceptable,
bountyDenyCopy,
deriveBountyPlan,
bountySkirmishScript,
bountyEngagementScript,
bountyHuntXp,
nextBountyWaypoint,
bountyRouteComplete,
bountyText,
escortDestinationFor,
escortFriendlyDestination,
escortEncounterXp,
escortWaveRoster,
} from '../quests/SideQuests.js';
import { tutorialTarget } from '../quests/TutorialGuide.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
import { planRoute } from '../galaxy/Route.js';
import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable, hasAvailable, buildDefs, attachQuestGates, attachBuildGates } from '../research/ResearchModel.js';
import {
SYSTEM_CATEGORY,
MAP_NODE,
GATES_NODE,
systemNodeId,
systemIdOfGatesNode,
buildSystemTree,
isNavComplete,
navChart,
activationKeys,
applyActivation,
} from '../research/SystemCategory.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { JobBoardWindow } from '../ui/JobBoardWindow.js';
import { BuildState } from '../build/BuildState.js';
import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js';
import { ClassWindow, loadClassMedia } from '../ui/ClassWindow.js';
import { ClassState } from '../player/ClassState.js';
import { SkillState } from '../skills/SkillState.js';
import { SkillWindow } from '../ui/SkillWindow.js';
import {
SKILL_ICON_KEY,
barSpec as skillBarSpec,
fillTemplate as fillSkillTemplate,
hasTree as skillHasTree,
iconSheet as skillIconSheet,
skillList as skillListOf,
treeClassIds as skillTreeClassIds,
windowSpec as skillWindowSpec,
} from '../skills/SkillModel.js';
import {
CLASS_ICON_KEY,
classAt as classLevelAt,
classDef,
classesEnabled,
perkNames,
successMessage,
windowSpec as classWindowSpec,
} from '../player/ClassModel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
const BODY_FONT = () => fontStack('body', FONT_FALLBACK);
// The jump clip (data/gates.json → jump.video) — the full-screen one-shot
// played between the two systems on a gate jump. Its cache key + the
// double-click skip window (ms; two presses this close skip the rest of the
// clip — the same window as SurfaceScene's landing/takeoff skip).
const JUMP_VIDEO_KEY = 'jump_warp';
const JUMP_DOUBLE_CLICK_MS = 350;
/**
* The dossier's decode pacing (the shared decode effect, per line):
* a beat of arrival, then each line starts a little after the last.
*/
const HUD_LEAD_IN = 250; // ms before the dossier starts typing in
const HUD_STAGGER = 140; // ms between the start of each line
const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in
/** The open-by-default dossier folds itself 10 s after arrival (unless the player toggles it first). */
const HUD_AUTO_COLLAPSE_MS = 10000;
/**
* The game world (v0.3: the current system — the central body at the
* origin (the player's home world in the starting system, the system's
* star in every other one) plus the system's other worlds scattered
* around it, in open space). Click anywhere to fly there — holding
* SHIFT on the click THROTTLE-LOCKS the ship: it commits to that heading
* and keeps flying the way until you steer it somewhere else, or it runs
* into something (a world, a rock, the tether rim) and stops there.
*
* Discovery: come within discovery distance (data/game.json) of a world's
* edge and it is DISCOVERED (state in this.discovery). Discovered worlds
* that are off-screen get a themed compass arrow on the screen edge
* (this.compass) pointing the way back — and clicking its name tag
* autopilots the ship there (GameScene.autopilotTo: it targets the
* keep-out rim on the side the ship is approaching from).
*
* The system dossier (top-left) opens by default on arrival and folds
* itself after 10 s; clicking the system NAME (or the caret beside it)
* toggles the detail lines open/closed — see createSystemHud().
*/
export class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
preload() {
// The planet spritesheet: frameWidth×frameHeight frames; the first
// frames are Terran worlds (see data/planets.json → frames).
this.load.spritesheet(
Planet.TEXTURE_KEY,
config.get('planets.texture', 'assets/images/planets.png'),
{
frameWidth: config.get('planets.frameWidth', 1024),
frameHeight: config.get('planets.frameHeight', 1024),
},
);
// The player's ship spritesheet (data/ship.json → texture). If it
// isn't configured or the file is missing, Ship falls back to its
// built-in procedural dart (a console note says so in create()).
const shipTexture = config.get('ship.texture', '');
if (shipTexture) {
this.load.spritesheet(
Ship.TEXTURE_KEY,
shipTexture,
{
frameWidth: config.get('ship.frameWidth', 256),
frameHeight: config.get('ship.frameHeight', 256),
},
);
}
// The ENEMY spritesheets (data/combat.json → enemy.texture + the
// enemyTypes' own textures — EnemyTypes.enemySheetList is the seam,
// one key per source file, EnemyShip.textureKey). Loaded here too —
// the art is available in the main game for the WAVE RUNS
// (js/combat/Waves.js → CombatSystem.startWaves); the sandbox is
// where the numbers get tuned.
for (const { src, frameWidth, frameHeight } of enemySheetList()) {
this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight });
}
// The ESCORT's TRANSPORT sheet (data/combat.json → transport.texture —
// assets/images/ships-transport.png, frames 0–3 at 180×320): a distinct
// file from the enemy sheet, loaded under its own key (the escort
// contract's freighter — js/combat/TransportShip.js).
const transportSheetInfo = transportSheet();
if (transportSheetInfo) {
this.load.spritesheet(
EnemyShip.textureKey(transportSheetInfo.src),
transportSheetInfo.src,
{ frameWidth: transportSheetInfo.frameWidth, frameHeight: transportSheetInfo.frameHeight },
);
}
// The asteroid spritesheet (data/asteroids.json → texture): 128×128
// rock frames, one per cluster member (the generator picks frames).
const asteroidTexture = config.get('asteroids.texture', '');
if (asteroidTexture && config.get('asteroids.enabled', true) !== false) {
this.load.spritesheet(
AsteroidCluster.TEXTURE_KEY,
asteroidTexture,
{
frameWidth: config.get('asteroids.frameWidth', 128),
frameHeight: config.get('asteroids.frameHeight', 128),
},
);
}
// The space-station spritesheet (data/stations.json → texture): the
// deep-space station variants (variants 0..2 today — the first three
// 256×256 frames). Station falls back to its procedural hardware if
// the sheet is missing.
const stationTexture = config.get('stations.texture', '');
if (stationTexture && config.get('stations.enabled', true) !== false) {
this.load.spritesheet(
Station.TEXTURE_KEY,
stationTexture,
{
frameWidth: config.get('stations.frameWidth', 256),
frameHeight: config.get('stations.frameHeight', 256),
},
);
}
// The jump gate spritesheet (data/gates.json → texture): frame 0 =
// the gate body, frame 1 = the active swirl. If it isn't configured
// or the file is missing, JumpGate falls back to its built-in
// procedural gate (a console note says so in build()).
const gateTexture = config.get('gates.texture', '');
if (gateTexture) {
this.load.spritesheet(
JumpGate.TEXTURE_KEY,
gateTexture,
{
frameWidth: config.get('gates.frameWidth', 256),
frameHeight: config.get('gates.frameHeight', 256),
},
);
}
// The goods spritesheet (data/goods.json → sheet): 128×128 icon frames
// (12 across, 120 cells) — one per trade good, in goods.json order
// (frame N = goods[N]). Loaded now so the icons are ready when the
// shop UI lands (trading is locked until the third story quest); an
// empty/blank file is harmless (120 transparent frames).
const goodsTexture = config.get('goods.sheet.texture', '');
if (goodsTexture) {
this.load.spritesheet(
GOODS_TEXTURE_KEY,
goodsTexture,
{
frameWidth: config.get('goods.sheet.frameWidth', 128),
frameHeight: config.get('goods.sheet.frameHeight', 128),
},
);
}
// The research console's archive feed (data/research.json → video):
// a muted 2:3 loop behind the RESEARCH window. A missing file just
// leaves the window's NO SIGNAL plate up — the console still works.
if (config.get('research.enabled', true)) {
const researchVideo = String(config.get('research.video.file') ?? '');
if (researchVideo) {
// The config stores the full relative path (like the sfx paths);
// a bare filename still works (→ assets/videos/<name>).
const url = /^(https?:)?\/\//.test(researchVideo) || researchVideo.startsWith('assets/')
? researchVideo
: `assets/videos/${researchVideo}`;
this.load.video(ResearchWindow.VIDEO_KEY, url);
}
}
// The map console's cartography feed (data/map.json → video): a muted
// 2:3 loop behind the MAP window. Same contract as the research feed —
// a missing file just leaves the window's NO SIGNAL plate up.
if (config.get('map.enabled', true)) {
const mapVideo = String(config.get('map.video.file') ?? '');
if (mapVideo) {
const url = /^(https?:)?\/\//.test(mapVideo) || mapVideo.startsWith('assets/')
? mapVideo
: `assets/videos/${mapVideo}`;
this.load.video(MapWindow.VIDEO_KEY, url);
}
}
// The quests console's mission feed (data/quests.json → video): a muted
// 2:3 loop behind the QUESTS window — the same dimensions as the
// research feed. Same contract — a missing file just leaves the
// window's NO SIGNAL plate up.
if (config.get('quests.enabled', true)) {
const questVideo = String(config.get('quests.video.file') ?? '');
if (questVideo) {
const url = /^(https?:)?\/\//.test(questVideo) || questVideo.startsWith('assets/')
? questVideo
: `assets/videos/${questVideo}`;
this.load.video(QuestWindow.VIDEO_KEY, url);
}
}
// The ship console's bay camera (data/shipScreen.json → video): a muted
// 2:3 loop behind the SHIP window. Same contract — the clip is planned
// but not authored yet (assets/videos/ship.mp4 is not in the repo), and a
// missing file just leaves the window's procedural NO SIGNAL plate up.
if (config.get('shipScreen.enabled', true)) {
const shipVideo = String(config.get('shipScreen.video.file') ?? '');
if (shipVideo) {
const url = /^(https?:)?\/\//.test(shipVideo) || shipVideo.startsWith('assets/')
? shipVideo
: `assets/videos/${shipVideo}`;
this.load.video(ShipWindow.VIDEO_KEY, url);
}
}
// The build console's feed (data/builds.json → video): a muted 2:3
// loop behind the BUILD window on a planet surface (SurfaceScene's
// BuildWindow reads the shared cache). A missing file just leaves the
// window's NO SIGNAL plate up — the console still works.
if (config.get('builds.enabled', true)) {
const buildVideo = String(config.get('builds.video.file') ?? '');
if (buildVideo) {
const url = /^(https?:)?\/\//.test(buildVideo) || buildVideo.startsWith('assets/')
? buildVideo
: `assets/videos/${buildVideo}`;
this.load.video(BuildWindow.VIDEO_KEY, url);
}
}
// The job board's feed (data/actionbar.json → surface.explore.video):
// a muted 2:3 loop behind the JOB BOARD console on a surface
// (SurfaceScene's JobBoardWindow reads the shared cache). A missing
// file just leaves the window's NO SIGNAL plate up — the board still
// works.
{
const jobVideo = String(config.get('actionbar.surface.explore.video.file') ?? '');
if (jobVideo) {
const url = /^(https?:)?\/\//.test(jobVideo) || jobVideo.startsWith('assets/')
? jobVideo
: `assets/videos/${jobVideo}`;
this.load.video(JobBoardWindow.VIDEO_KEY, url);
}
}
// The jump clip (data/gates.json → jump.video): a full-screen one-shot
// played between the two systems on a gate jump (jumpThroughGate →
// _playJumpClip). A missing/empty file just leaves the short-cut
// fallback — the jump still works.
{
const jumpVideo = String(config.get('gates.jump.video') ?? '');
if (jumpVideo) {
const url = /^(https?:)?\/\//.test(jumpVideo) || jumpVideo.startsWith('assets/')
? jumpVideo
: `assets/videos/${jumpVideo}`;
this.load.video(JUMP_VIDEO_KEY, url);
}
}
// Sound effects (data/sfx.json → enabled). Skipped entirely when the
// master switch is off — no load cost, no files fetched.
if (config.get('sfx.enabled', true)) {
this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3'));
this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3'));
this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3'));
this.load.audio('sfx_laser', config.get('sfx.laser', 'assets/fx/combat-laser.mp3'));
this.load.audio('sfx_mass_cannon', config.get('sfx.mass_cannon', 'assets/fx/combat-mass-cannon.mp3'));
this.load.audio('sfx_mass_cannon_hit', config.get('sfx.mass_cannon_hit', 'assets/fx/combat-mass-cannon-hit.mp3'));
this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3'));
this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3'));
this.load.audio('sfx_mining_split', config.get('sfx.mining_split', 'assets/fx/mining-02.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3'));
this.load.audio('sfx_solar_buzz', config.get('sfx.solar_buzz', 'assets/fx/solar-buzz.mp3'));
this.load.audio('sfx_solar_flare_01', config.get('sfx.solar_flare_01', 'assets/fx/solar-flare-01.mp3'));
this.load.audio('sfx_solar_flare_02', config.get('sfx.solar_flare_02', 'assets/fx/solar-flare-02.mp3'));
this.load.audio('sfx_thunder_01', config.get('sfx.thunder_01', 'assets/fx/thunder-01.mp3'));
this.load.audio('sfx_thunder_02', config.get('sfx.thunder_02', 'assets/fx/thunder-02.mp3'));
this.load.audio('sfx_thunder_03', config.get('sfx.thunder_03', 'assets/fx/thunder-03.mp3'));
this.load.audio('sfx_thunder_04', config.get('sfx.thunder_04', 'assets/fx/thunder-04.mp3'));
this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3'));
this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3'));
this.load.audio('sfx_ui_window', config.get('sfx.ui_window', 'assets/fx/ui-window.mp3'));
this.load.audio('sfx_ui_close', config.get('sfx.ui_close', 'assets/fx/ui-close.mp3'));
}
// The deep-space soundtrack (data/music.json → game) — the shuffled
// playlist. Each file queues under its derived key (music_deepspace_01…).
const gameTracks = config.get('music.game', []);
if (config.get('music.enabled', true) && Array.isArray(gameTracks)) {
for (const f of gameTracks) this.load.audio(gameTrackKey(f), f);
}
// The COMBAT soundtrack (data/music.json → combat) — the shuffled
// playlist that swaps in the moment the fight goes live (setCombatMusic,
// on the onActiveChange seam). Same derived-key rule (music_combat_01…).
const combatTracks = config.get('music.combat', []);
if (config.get('music.enabled', true) && Array.isArray(combatTracks)) {
for (const f of combatTracks) this.load.audio(gameTrackKey(f), f);
}
// The NEUTRAL-PILOT soundtrack (data/music.json → neutral) — the
// neutral-only encounter's hum (setCombatMusic, active + neutral — the
// onActiveChange seam's second argument). Same derived-key rule
// (music_neutral_01…).
const neutralTracks = config.get('music.neutral', []);
if (config.get('music.enabled', true) && Array.isArray(neutralTracks)) {
for (const f of neutralTracks) this.load.audio(gameTrackKey(f), f);
}
}
create() {
// A jump's cut (jumpThroughGate) sets this on the dying scene — the
// restart lands here, so clear it: input unlocks again.
this._jumping = false;
// The jump clip's in-flight state (the full-screen one-shot between the
// two systems — _playJumpClip). The clip is created on the dying scene
// and torn down in _finishJump before the restart, so these are fresh on
// every entry regardless.
this._jumpVideo = null;
this._jumpBackdrop = null;
this._jumpFinished = false;
this._jumpLastClickT = 0;
// Camera: smoothly follows the ship (updateCamera below). This motion
// is what drives the parallax starfield — ship flies, view trails.
this.cameraFollowShip = config.get('game.camera.followShip', true);
this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s
// Session time (the save's playTimeMs). Accumulates in update();
// a LOAD replaces it with the saved value (applyRestore).
this.playTimeMs = 0;
// The deep-space soundtrack — the data/music.json (game) playlist,
// shuffled: one track at a time, next picked when one ends. The COMBAT
// soundtrack (→ combat) swaps in on the onActiveChange seam (see
// setCombatMusic): the deep-space one stops, the combat one starts, and
// they swap back the moment the fight ends.
// LANDING does not transition us — it LAUNCHES SurfaceScene on top
// and SLEEPS this scene (v4: `launch` doesn't freeze the caller, and
// a sleeping scene never emits 'shutdown') — so the soundtrack and
// the mining hum stop on 'sleep' and come back on 'wake' (Take Off
// wakes the scene). 'shutdown' still covers return-to-menu and game
// destroy (the shutdown() method stays as the destroy safety net).
this._asleep = false; // surface state (the sleep/wake events flip it) — setCombatMusic's seam
this._combatWasNeutral = false; // the last live combat's neutral-only flag — the seam's memory
this.startGameMusic();
this.events.once('shutdown', () => {
this.stopGameMusic();
this.setMiningLoop(false); // the hum can't outlive the scene
this.systemEffects?.release(); // the effect can't outlive its scene
this.levelMarquee?.destroy(); // the marquee can't outlive its scene
});
this.events.on('sleep', () => {
this._asleep = true; // on a world's surface — the surface owns the silence
this.stopGameMusic(); // no space hum on a world's surface (combat or deep-space)
this.setMiningLoop(false); // …nor the mining hum
this.engineFx?.sleep(); // no ghost exhaust waiting on the pad
});
this.events.on('wake', () => {
this._asleep = false;
// Back in space — resume the RIGHT context: the combat shuffle if the
// fight is still live (landing is denied mid-fight, so this is the
// defensive branch), the deep-space hum otherwise.
if (this.combat?.active) (this._combatWasNeutral ? this.startNeutralMusic() : this.startCombatMusic());
else this.startGameMusic(); // the shuffle starts fresh
if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live
});
// We are, after all, in a system. Establish the galaxy (shared
// registry), the current system record, and its lazy contents — the
// dossier below and the world's other planets both read them.
this.ensureGalaxy();
// THE ORIGIN WORLDS' DAILY BATCHES (model v2 — the economy's one
// stateful piece, the time-gated BUY side: each origin world's
// signature good sells a batch per day and regenerates on the day
// clock; js/economy/Stock.js). Registry-backed run state like
// depletedClusters (save/restore in js/save/SaveData.js); a fresh run
// starts full everywhere (the empty map reads as "full batch for
// today"), and spendStock() below swaps the reference in place.
this.economyStock = this.registry.get('economyStock') ?? null;
if (!this.economyStock) {
this.economyStock = {};
this.registry.set('economyStock', this.economyStock);
}
// THE EFFECTS DEMO (?fx=<type> — js/visuals/SystemEffects.js): drop
// the run into the richest system of that archetype (ranked in
// js/galaxy/FxSystems.js), fully charted, all gates live — a clean
// minute of flying to judge the star's character. It retargets the
// CURRENT SYSTEM only (the seed and the galaxy are untouched), so
// everything downstream — the record, the contents, the worlds,
// the gates, the tethers — builds for the demo system for free.
//
// THE WORLD PREVIEW (?type=<class>[-NN] / ss[-NN] — js/galaxy/
// TypeSystems.js): drop the run onto the richest system holding a
// world/station of the requested kind (a specific SHEET FACE when
// -NN names one — the face its landing clips + surface loop wear),
// and spawn the ship right beside it (below), so a landing puts the
// player on its surface for the videos/music. ?type= wins the system
// pick; when BOTH are given the world must live in a system of the
// ?fx= type (a preview under a chosen star's character), and a
// ?type= that finds nothing there falls back to the plain ?fx= demo.
this.fxSystemType = this._readFxSystemType();
this.typeSpec = this._readTypeSpec();
this.typeSpecRaw = this._typeSpecRaw;
this.typeTarget = null;
if (this.typeSpec) {
const pick = pickPreviewSystem(this.galaxy, this.typeSpec, this.fxSystemType);
if (pick) {
this.galaxy.currentSystemId = pick.record.id;
this.typeTarget = pick; // { record, ordinal|station, reskin }
if (pick.reskin) {
console.info(
`[orbit] ?type=${this.typeSpecRaw} — no world wears that exact face in this galaxy; ` +
`previewing it on ${pick.record.id}'s closest match (reskinned for the demo).`,
);
}
} else if (this.fxSystemType) {
console.warn(
`[orbit] ?type=${this.typeSpecRaw} — no world of that kind in a ${this.fxSystemType}-type system; falling back to the ?fx= demo.`,
);
const rec = pickFxSystem(this.galaxy, this.fxSystemType);
if (rec) this.galaxy.currentSystemId = rec.id;
else {
console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`);
this.fxSystemType = null;
}
} else {
console.warn(`[orbit] ?type=${this.typeSpecRaw} — no world of that kind in this galaxy; starting as usual.`);
this.typeSpec = null;
}
} else if (this.fxSystemType) {
const rec = pickFxSystem(this.galaxy, this.fxSystemType);
if (rec) this.galaxy.currentSystemId = rec.id;
else {
console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`);
this.fxSystemType = null;
}
}
// A LOAD from the save panel staged its live state (ship / tethers /
// playtime) in the registry (js/save/SaveData.js) — the ship and the
// tether field don't exist yet, so apply it once they do (below).
this._pendingRestore = consumeRestore(this.registry);
// The last JUMP's source system (jumpThroughGate stages it into the
// restore record; captureState carries it through a panel save too):
// the 'Pirate Problems' ambush measures its midpoint from the gate
// the player came in through — returnGateFor(content, lastJumpFrom)
// (GameScene._pirateAmbushEntryGate). Null on a fresh run or a save
// predating the field — the ambush's nearest-gate fallback stands
// in for it.
this.lastJumpFromSystem =
this._pendingRestore && typeof this._pendingRestore.lastJumpFrom === 'string'
? this._pendingRestore.lastJumpFrom
: null;
// A FRESH run (no staged restore) is what the 'newGame' comms fire
// for (the welcome plays on a new game, never on a load) — and a
// jump-cut restart (scene.restart, no resetRunState) is NOT one: its
// played ledger (the registry) already holds the welcome's id, so
// the trigger below re-arms nothing (the model's played filter).
this._freshRun = this._pendingRestore === null;
this.systemRecord = this.galaxy.currentSystem();
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
// WORLD PREVIEW RESKIN (?type=<class>-NN whose face the galaxy wears
// nowhere — pick.reskin): paint the requested sheet frame on the
// target BEFORE its entities build (below) — the demo world wears
// the face, and its landing/surface clips + surface loop follow
// (data/landing.json / data/music.json index off the drawn frame).
if (this.typeTarget?.reskin && typeof this.typeSpec?.frame === 'number') {
const frame = this.typeSpec.frame;
if (this.typeTarget.station) {
const s = (this.systemContent.settlements ?? []).find((x) => x?.kind === 'deepSpaceStation');
if (s) s.stationFrame = frame;
} else if (this.typeTarget.ordinal === 0) {
this.systemContent.homeFrame = frame; // the home world (starting system)
} else {
const p = (this.systemContent.planets ?? [])[this.typeTarget.ordinal - 1];
if (p) p.frame = frame;
}
}
// DEPLETED FIELDS — asteroid clusters the run has mined to nothing:
// per-run world state, registry-backed like discovery/research
// (js/save/SaveData.js). The galaxy's content regenerates from the
// seed on every load, so the run's depleted ids are spliced OUT of
// the content BEFORE the cluster entities build (below) — a mined-out
// field stays gone across a save, and it is off the map (the chart +
// resource stats read content.asteroids) with the entities gone.
this.depletedClusters = this.registry.get('depletedClusters') ?? null;
if (!this.depletedClusters) {
this.depletedClusters = new Set();
this.registry.set('depletedClusters', this.depletedClusters);
}
if (this.depletedClusters.size && Array.isArray(this.systemContent?.asteroids)) {
const sysId = this.systemRecord.id;
this.systemContent.asteroids = this.systemContent.asteroids.filter(
(c) => !this.depletedClusters.has(`${sysId}:${c?.id}`),
);
}
// PIRATE KILL TALLIES — the per-system count of the pirates the run
// has put down (data/reputation.json → bonuses.pirateKills — every
// `every` kills in a system, every place in it gains `bonus`
// standing; GameScene.recordPirateKill pays it out). Per-run world
// state — registry-backed (like depletedClusters): New Game clears
// it, a load restores it (js/save/SaveData.js).
this.pirateKillsBySystem = this.registry.get('pirateKillsBySystem') ?? null;
if (!this.pirateKillsBySystem) {
this.pirateKillsBySystem = {};
this.registry.set('pirateKillsBySystem', this.pirateKillsBySystem);
}
// JUMP GATE ACTIVATION (the SYSTEM research category —
// js/research/SystemCategory.js): the run's activation keys are
// per-run world state — registry-backed (like discovery: New Game
// clears it, a load restores it; js/save/SaveData.js). A gate the
// run activated — or whose RETURN the run activated from a linked
// system — flips on here, BEFORE the gate entities below read
// `active` (the dormant look is baked at construction); the gate
// tethers (data/gates.json → ACTIVITY) attach after the field exists
// (below) and save with the run's tether list.
this.activatedGates = this.registry.get('activatedGates') ?? null;
if (!this.activatedGates) {
this.activatedGates = new Set();
this.registry.set('activatedGates', this.activatedGates);
}
// THE RUN'S GALAXY FOOTPRINT (the MAP console's GALAXY tab —
// js/ui/GalaxyView.js): visitedSystems = the systems the run has
// ENTERED (chart the region), usedGates = the jump lanes the run has
// TRAVELED (edgeKey(a,b), undirected — the lane glows brighter). Both
// are per-run world state — registry-backed (like activatedGates):
// New Game clears them, a load restores them (js/save/SaveData.js).
this.visitedSystems = this.registry.get('visitedSystems') ?? null;
if (!this.visitedSystems) {
this.visitedSystems = new Set();
this.registry.set('visitedSystems', this.visitedSystems);
}
this.usedGates = this.registry.get('usedGates') ?? null;
if (!this.usedGates) {
this.usedGates = new Set();
this.registry.set('usedGates', this.usedGates);
}
// THE PLOTTED DESTINATION (the MAP console's SYSTEM tab — js/galaxy/
// Route.js): where the run is headed. Persisted as { systemId,
// objectId } — the ROUTE itself is derived on demand (planRoute from
// wherever the ship is now to destination.systemId), so following it
// just shortens it and a detour re-plots it (no progress counter to
// desync). Null until the player sets one; cleared on arrival.
this.destination = this.registry.get('destination') ?? null;
// A ROUTE NOTICE parked by the jump we just completed (Route._routeFor
// Jump → _queueRouteNotice): the destination-REACHED / route-RE-PLOTTED
// message. The jump's own toast + full-screen clip already played on
// the source scene, so surface this one here, a beat after spawn, so
// it reads cleanly on the destination system.
{
const notice = this.registry.get('routeNotice');
if (notice && typeof notice.text === 'string') {
this.registry.set('routeNotice', null);
this.time.delayedCall(900, () =>
this.consoleToast(notice.text, {
glyph: notice.glyph ?? '◆',
glyphColor: notice.glyphColor ?? toCss(this._routeColor()),
durationMs: notice.durationMs ?? 3400,
}),
);
}
}
this.visitedSystems.add(this.systemRecord.id); // we start here — it's charted
for (const j of this.systemContent.jumps ?? []) {
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
}
if (this.fxSystemType) {
// The demo wants the whole system LIVE: every gate on (its tether
// is the flight range — the union of them is the playable space),
// so a jump out and back is one click away.
for (const j of this.systemContent.jumps ?? []) {
j.active = true;
this.activatedGates.add(`${this.systemRecord.id}>${j.to}`);
}
}
// The CENTRAL BODY — the home world, present ONLY in the STARTING
// system, at the world origin (the generator deals its name —
// content.homeName — so it reads as a real place rather than a generic
// "Terra"; the fallback keeps the old configured name if the bank is
// ever unavailable). It is the only central body in the galaxy: in
// every other system the center is EMPTY (the star is invisible flavor
// — content.star name/class feeds the dossier and gate names, never
// rendered) and is the ONLY system where the origin is a comms target
// (settled, landable) and reads "Home World".
// NOTE: home is the STARTING system (galaxy.homeSystemId) — stable
// across jumps. Comparing against galaxy.currentSystemId would be
// wrong: that pointer tracks where the player IS NOW, so every
// freshly-arrived system would read as home.
this.isHomeSystem = this.galaxy.isHomeSystem(this.systemRecord.id);
this.homeWorldName = this.isHomeSystem
? (this.systemContent.homeName || config.get('planets.homeName', 'Terra'))
: null;
this.createSystemHud();
if (this.isHomeSystem) {
// The home world — the player's Terran world, present ONLY in the
// system the player starts in. It sits at the world origin. Which
// Terran face it shows comes from the galaxy-wide frame pass
// (content.homeFrame — the same (class, face) spreading as the
// planets), falling back to a seed-deterministic pool pick, so the
// same galaxy always yields the same home world.
const homeName = config.get('planets.homePlanet', 'terran');
const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home');
const homeFrame =
typeof this.systemContent.homeFrame === 'number'
? this.systemContent.homeFrame
: Planet.frameFor(homeName, homeRng);
this.planet = new Planet(this, 0, 0, homeFrame, homeName);
this.planet.discoveryName = this.homeWorldName;
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
} else {
// Every other system has NO central body: the star is INVISIBLE
// flavor (content.star — name/class/binary — feeds the dossier and
// gate names, but is never rendered). The origin is empty space;
// the gate tethers are the player's anchors here.
this.planet = null;
}
// The rest of the solar system — the generated worlds, placed by the
// generator (data/planets.json → solarSystem) on orbits around the
// central body. Same solid-disc rules as the worlds: fly close,
// never through.
this.systemPlanets = [];
if (config.get('planets.solarSystem.enabled', true)) {
for (const rec of this.systemContent.planets ?? []) {
if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue;
const kind = rec.class || 'rocky';
// The galaxy-wide frame pass (js/galaxy/PlanetFrames.js) stamped
// `rec.frame` to spread each (class, face) across the galaxy; fall
// back to a random pool pick for content that predates the pass.
const frame =
typeof rec.frame === 'number'
? rec.frame
: Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name));
// (PLANET TINT — REMOVED FOR NOW: per-class canvas tints used to be
// applied here from data/planets.json → classTint, i.e.
// `tint: toColor(config.get(`planets.classTint.${kind}`))`. The
// Planet still accepts the option — pass it back when we want it.)
const p = new Planet(this, rec.x, rec.y, frame, kind, {
scale: rec.scale ?? 1,
});
p.setDepth(5);
p.discoveryId = rec.name; // unique within the system
p.discoveryName = rec.name;
this.systemPlanets.push(p);
}
}
// Asteroid clusters — loose groups of slowly tumbling rocks scattered
// through the system's void (data/asteroids.json). Every cluster is a
// DISCOVERABLE object (the compass knows it once found), a SOLID one
// (the ship can park at a rock's rim, never fly through it) — the same
// contract as the worlds, at rock scale — and a MINABLE one: a click
// on a rock opens the mining menu (see the Mining wiring below).
this.asteroidClusters = [];
if (config.get('asteroids.enabled', true) !== false) {
for (const rec of this.systemContent.asteroids ?? []) {
this.asteroidClusters.push(new AsteroidCluster(this, rec, { depth: 5 }));
}
}
// Free-space stations (settlements with `anchor.type === 'space'`
// — deep-space stations): settlements that float in
// the void, rendered as world objects (js/entities/Station.js).
// Solid (the ship keeps its clearance), discoverable (compass +
// toast), and comms targets — a click opens the comms panel there
// (openCommsPanel below) — it is NOT a fly-here (the ship stays put).
this.systemStations = [];
if (config.get('stations.enabled', true) !== false) {
for (const s of this.systemContent.settlements ?? []) {
if (s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue;
this.systemStations.push(new Station(this, s, { depth: 5 }));
}
}
// Jump gates (content.jumps — one per destination in the system's
// gate network, js/galaxy/JumpNetwork.js): the system's exits.
// Solid (the ship keeps its clearance), discoverable (compass +
// toast, in their own cyan), and each one faces its destination
// star on the map (entity.rotation). A click on a gate opens the
// GATE COMM WINDOW (openGateCommsPanel — the shared comms panel with
// a LINK status line; REQUEST JUMP initiates
// GameScene.jumpThroughGate, the transport) — never a fly-here.
// worldObjectAt deliberately excludes them (the panel opens via
// gateAt(), the gates' own hit test, not the comms target set).
this.systemGates = [];
if (config.get('gates.enabled', true) !== false) {
for (const j of this.systemContent.jumps ?? []) {
if (typeof j.x !== 'number' || typeof j.y !== 'number') continue;
this.systemGates.push(new JumpGate(this, j, { depth: 5 }));
}
}
// FACTION GATE LOCK — the swirl tint (the in-world tell): a gate
// into faction-controlled space, below the player's level-5 lock,
// swirls in that faction's hue (gateFactionTintFor). With combat on
// the XP ledger isn't stood up yet (it lands below with the save's
// level), so _refreshGateFactionTints() re-resolves once the ledger
// exists; a level-up that lifts the lock clears it again.
this._refreshGateFactionTints();
// Every solid in the system — worlds first (their keep-out circles are
// disjoint), then the clusters, then the stations, then the gates. The
// central body (the home world — the starting system only; other
// systems have no star) leads when it exists.
// Ship constraint, click-to-fly clamping and autopilot all run
// against this list.
this.solids = [
...(this.planet ? [this.planet] : []),
...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates,
];
// The central body's discovery id is 'home' — the chart's central NAV
// point (SystemCategory.navPoints) — in the STARTING SYSTEM ONLY (the
// home world). Every other system's center is empty: no 'home' NAV
// point there (its star is invisible flavor, not a chart waypoint).
if (this.planet) this.planet.discoveryId = 'home';
// The ship — in the starting system, a short hop (~150 px,
// edge-to-edge) from the home world's rim, in a seed-derived
// direction: same galaxy ⇒ same start. Everywhere else the ship's
// position comes from the jump/save restore (the arrival gate), so
// the origin (an empty center — no star) is only a defensive
// fallback.
if (config.get('ship.texture', '') && !this.textures.exists(Ship.TEXTURE_KEY)) {
console.warn(
`[orbit] ship spritesheet "${config.get('ship.texture')}" did not load — using the built-in dart.`,
);
}
this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10);
// ENGINE FX — flare-up + exhaust trail on the ship's side pods
// (js/visuals/EngineFlare.js). Created per create() because a jump
// restarts this scene in place: the display list (emitters, glows)
// is destroyed with it, while our ref survives — same contract as
// the effect layers below. It reads the ship's throttle per frame
// (update) and cleans up on landing (sleep).
this.engineFx?.destroy();
this.engineFx = new EngineFlare(this, this.ship);
// WORLD PREVIEW (?type=…): the ship starts beside the TARGET world
// (this._previewEntity — the planet/station the pick named), not
// the home world — the point is to land on it and judge its clips
// and surface loop. Same edge-to-edge hop as the home spawn, same
// seed-derived direction (same galaxy ⇒ same start).
const spawnBody = (this.typeTarget ? this._previewEntity() : null) ?? this.planet;
const spawn = spawnBody
? spawnBody.edgePoint(
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
config.get('planets.spawnDistanceFromEdge', 150),
this.ship.radius,
)
: { x: 0, y: 0 };
this.ship.setPosition(spawn.x, spawn.y);
// DEV QA handle for the world preview (?type=…): what the boot named
// and where the ship was placed — inspect in the console.
if (typeof window !== 'undefined') {
window.__ORBIT_PREVIEW__ = {
typeSpec: this.typeSpec,
systemId: this.systemRecord?.id,
spawnBody: spawnBody ? spawnBody.discoveryName ?? spawnBody.label ?? null : null,
spawn: { x: Math.round(spawn.x), y: Math.round(spawn.y) },
ship: { x: Math.round(this.ship.x), y: Math.round(this.ship.y) },
};
}
// Center the camera on the ship from the very first frame.
this.cameras.main.setScroll(
this.ship.x - this.scale.width / 2,
this.ship.y - this.scale.height / 2,
);
// Background (stars are placed around the current camera view).
this.starfield = new Starfield(this);
this.starfield.create();
// A JUMP restarts this scene IN PLACE (v4: the same instance — the
// 'shutdown' EVENT tears down the display list, but the engine never
// calls our shutdown() method, and our own refs survive; see the
// MenuScene note on the v4 quirk). The per-system EFFECT LAYERS below
// are conditional, so a layer the LAST entry built would keep ticking
// — and talking — in this system: a habitable storm's update() would
// keep striking, and keep THUNDERING, on a starless sky. Drop whatever
// the last entry built; each conditional below re-adds its layer or
// leaves the ref null.
this._teardownEffectLayers();
// NEBULA GAS — soft coloured clouds in TWO layers, tinted with the
// system's own atmosphere color (assigned at random at galaxy
// generation — SystemGenerator): a BACK layer behind the art (depth 3,
// base color) and a FRONT layer above the ship (depth 11, a derived
// accent hue) that drifts past you — flying THROUGH the nebula.
// Nebula systems only. See js/visuals/NebulaAtmosphere.js.
if (
this.systemRecord?.type === 'nebula' &&
this.systemContent?.atmosphere?.color
) {
this.nebula = new NebulaAtmosphere(
this,
this.systemContent.atmosphere.color,
config.get('game.nebula', {}),
);
this.nebula.create();
}
// Per-system CONTENT effects (world-space sprites, Canvas-safe):
// - redDwarf ember dust (effect.particles) → EmberField (depth 3)
// - redDwarf red haze (effect.haze) → RedHaze (depth -1)
// - binary void base (effect.void) → VoidBase (depth -1)
// - binary companion star (effect.wanderer) → WandererStar (depth 3)
// (The camera FILTERS — ripple + grade — are SystemEffects' job.)
const _fx = config.get(`systems.types.${this.systemRecord?.type}.effect`, null);
if (_fx?.haze) {
this.haze = new RedHaze(this, _fx.haze, this.systemContent?.fx ?? {});
this.haze.create();
}
if (_fx?.void) {
this.voidBase = new VoidBase(this, _fx.void, this.systemContent?.fx ?? {});
this.voidBase.create();
}
if (_fx?.storm) {
this.storm = new StormLayer(this, _fx.storm, this.systemContent?.fx ?? {});
this.storm.create();
}
if (_fx?.particles) {
this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {});
this.embers.create();
}
if (_fx?.wanderer) {
this.wanderer = new WandererStar(this, _fx.wanderer.color, _fx.wanderer, this.systemContent?.fx ?? {});
this.wanderer.create();
}
if (_fx?.lightning && _fx.lightning.enabled !== false) {
// OVERT far-sky lightning (nebula): the bolt wears the system's GAS
// colors — the base atmosphere + the derived accent (the front gas's
// tint), alternating per strike. See js/visuals/NebulaLightning.js.
const baseCol = this.systemContent?.atmosphere?.color ?? '#5fd4d0';
const accentCol = typeof this.nebula?.frontTint === 'number' ? this.nebula.frontTint : baseCol;
this.nebulaLightning = new NebulaLightning(this, baseCol, accentCol, _fx.lightning, this.systemContent?.fx ?? {});
this.nebulaLightning.create();
}
// The TETHER — the player's range. In the STARTING system it starts
// as one level-1 tether anchored on the home world (data/tether.json):
// the ship may fly anywhere within its rim (level 1 = 5120 px from the
// planet's center); the rim is a hard barrier drawn as a thick
// glitchy dotted line. In every OTHER system there is no native
// tether — the center is empty — the player's range is the activated
// GATE tethers (the arrival gate anchors a level-1 tether on landing,
// data/gates.json → ACTIVITY). The field owns the tether list
// (add/remove/setLevel are the seam the build system will use later);
// where multiple tethers' zones overlap there is no line and no wall
// — the union is the player's space.
this.tetherField = new TetherField(this, {
depth: 6, // above planets (5), below the ship (10)
});
if (this.isHomeSystem) {
this.tetherField.add(
config.get('tether.homeId', 'home'),
0, 0, // the home world's center (the system origin)
config.get('tether.homeLevel', 1),
config.get('tether.homeLabel', '') || this.homeWorldName,
);
}
// Each activated gate anchors its tether (data/gates.json → ACTIVITY:
// an active gate is a TETHER ANCHOR in its own right — the room to
// move in a barren system). Idempotent: add() replaces by id, and a
// save's tether list restores to the same ids.
const gateTetherLevel = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
for (const j of this.systemContent.jumps ?? []) {
if (j && j.active) this.tetherField.add(`gate:${j.id}`, j.x, j.y, gateTetherLevel, j.name ?? '');
}
this.tetherToastAt = null;
// The active PATROL's in-world WAYPOINT beacons (data/sidequests.json
// → patrol.marker): a diamond + ring + soft glow per waypoint (the
// yellow pulse on the NEXT waypoint — the route is a path flown in
// order — the dim done mark once visited) — plain
// coordinates, not world objects (the route's shape), repainted per
// frame (js/quests/SideQuests.js's route record on the scene's
// sideQuestState). Depth 9: above the tether line (6) + the route
// pulse, below the ship (10).
this.patrolG = this.add.graphics().setDepth(9); // world coordinates (the ship + tether scroll with it)
// Discovery: which objects the player has found (within discovery
// distance of an edge — data/game.json), tracked per system. Kept in
// the shared registry so it survives scene restarts; serializable for
// saves later (Discovery.toJSON). The compass turns the discovered set
// into screen-edge arrows for the objects currently out of sight.
this.discovery = this.registry.get('discovery') ?? null;
if (!this.discovery) {
this.discovery = new Discovery(config.get('game.discovery.distance', 540));
this.registry.set('discovery', this.discovery);
}
if (this.fxSystemType) {
// The demo's "fully charted": the system is already discovered —
// the compass rests, the dossier reads complete, no pings compete
// with the effect for your eye. (check() can't do this — its
// radius is capped at the configured discovery distance — so the
// demo marks the roster directly.)
this.discovery.markAllDiscovered(this.systemRecord.id, this.discoverableObjects());
}
// REPUTATION — the player's standing on each planet and space station
// (js/reputation/Reputation.js): −20…+20 (data/reputation.json),
// neutral 0 everywhere the player has no standing, the home world
// pinned at +20. The faction check (settlements' reserved `owner`
// seam) is a placeholder until factions land. Nothing the player can
// see or influence yet — this is the data layer + the save seam the
// influence mechanics plug into. Like discovery it lives in the shared
// registry (survives scene restarts; New Game resets it).
this.reputation = this.registry.get('reputation') ?? null;
if (!this.reputation) {
this.reputation = new Reputation();
this.registry.set('reputation', this.reputation);
}
// The command deck's bottom strip (config: data/actionbar.json) — the
// compass lays out its arrows/name tags ABOVE it so a tag is never
// buried under the deck, and the hint sits above it too.
const deckEnabled = config.get('actionbar.enabled', true) === true;
const deckReserve = deckEnabled
? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12)
: 0;
this.compass = new DiscoveryCompass(this, {
// Autopilot: clicking a compass name tag sends the ship to that
// discovered object (GameScene.autopilotTo below).
onSelect: (id) => this.autopilotTo(id),
reserveBottom: deckReserve,
});
// The command deck — the cyberpunk action bar across the bottom of the
// screen (config: data/actionbar.json). Six evenly spaced slots:
// Research, Scan, Ship, Quests, Map, Menu. The slots are the seams
// for the player's loop — research (time-based, one at a time, see
// data/research.json), scan, the ship, the quest dossier (see
// data/quests.json) and the star map.
this.actionBar = deckEnabled
? new ActionBar(this, {
onAction: (id) => this.deckAction(id),
})
: null;
// ---- COMBAT — the hostile pack (the ?combat boot) + the combat bar --
// COMBAT IS LIVE IN THE MAIN GAME: the 'Pirate Problems' quest
// (data/quests.json → pirate_problems) ambushes the player at the
// MIDPOINT of the entry-jumpgate → Neutral-Capital-world stretch —
// the enter lands the ship at the gate (a full stretch out); the
// wave starts when it reaches half that distance from the world —
// GameScene.
// _maybeStartPirateAmbush fires the pirate_ambush wave run (data/
// combat.json → waves.scripts.pirate_ambush — one rolling wave of six
// frame-0 raiders, materializing off-screen around the player from a
// random direction each and streaming in on their chase AI), and the
// deck swap,
// the readability layer, the auto-fire, and the armor are all the
// same shared parts (the ?combat sandbox is where the numbers get
// tuned). data/combat.json (game.*): the ?combat boot adds the hostile
// pack — spawnCount hostiles in a ring around the ship (spawnRadius px).
// The mouse steers; while a hostile is in range the ship's cannon
// AUTO-FIRES at the nearest one (game.autoFire — the cannon's numbers
// are combat.player.weapon, shared with the range). While combat is
// LIVE the deck steps aside for the combat bar (js/ui/CombatBar.js):
// shields + hull left, ENERGY right, eight skill seats (keys 1-8) in
// the middle — the skills land later. Built for every scene (an idle
// system with no enemies and no wave run costs nothing in update()),
// the bar hidden until a wave makes combat live.
this.combat = null;
this.combatBar = null;
this.combatHud = null;
this._combatSettleFrames = 0;
if (config.get('combat.enabled', true) !== false && this.ship) {
this.combatBar = new CombatBar(this, {
onCast: (i) => this.castSkillSeat(i),
// The ASSIGNMENT hand (the skill tree clicks a skill, then a seat) and the
// right-press that clears one to make room. Both land in the ledger —
// js/skills/SkillState.js — which is what the save writes.
onSeatPick: (i) => this.pickSkillSeat(i),
// (the console's ASSIGN SEAT press puts the skill in the same hand)
onSeatUnbind: (i) => this.clearSkillSeat(i),
});
this.combatBar.setVisible(false);
// The seats' icons (one sheet, data/skills.json → icons). Loaded here rather
// than in preload(): a run that never fights — and every scene that has no
// combat bar at all — does not pay for the sheet.
this._ensureSkillIcons();
// The run's COMBAT XP ledger (js/combat/XpManager.js — the curve is
// data/progression.json; each hostile's type carries its own `xp`,
// data/combat.json → enemy.xp / enemyTypes). A save predating the
// ledger has no field → the run starts at level 0 (old saves load).
const savedXp = this.registry.get('combatXp');
// THE SHIP'S VITALS (hull/shields/energy): a LOAD restores the
// damage the run was flying with (record.combat → prepareLoad's
// 'combatVitals'); a JUMP's arrival is a SYSTEM LANDING — the
// 'combatRepair' flag (staged in jumpThroughGate's record) heals
// the ship full instead; a fresh run (neither staged) is a fresh
// ship (full armor).
const arrivalRepair = this.registry.get('combatRepair') === true;
this.registry.set('combatRepair', false);
this.combat = new CombatSystem(this, {
ship: this.ship,
xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(),
vitals: arrivalRepair ? null : (this.registry.get('combatVitals') ?? null),
onActiveChange: (active, neutral) => this._setCombatBar(active, neutral),
// The readability layer's bookkeeping (js/combat/CombatHud.js —
// the same shared layer the range runs): a kill lands in the feed,
// and a hit flashes the bar's gauge that took it. The enemy rides
// along for the BOUNTY HUNT's witness (handleCombatKill — the mark
// is the only elite the game deploys, so its death IS the contract).
onKill: (e) => {
this.combatHud?.kill((e.name ? `${e.name.toUpperCase()} — ` : '') + 'DESTROYED');
this.recordPirateKill(e);
this.handleCombatKill(e);
},
// THE PIRATE'S COMM LINES (data/combat.json → enemyTypes.raider.comms
// rolls in CombatSystem — 50% on-screen / 20% rolling-arrival for the
// attack, 20% for the defeat; the comms themselves are data/comms.json's
// pirate-attack / pirate-defeat — the clip + the random voice + the
// line): arm the hub's trigger (js/comms — the box plays the clip
// muted with the speech and the message).
onComm: (trigger) => this.comms?.trigger(trigger),
// A combat level landed (the system fires its own burst + voice +
// the armor bump on the manager's 'levelup' event — the skill
// layer will subscribe to that same event later): the feed's line.
onLevelUp: (level) => this.combatHud?.kill(`LEVEL ${level} — XP`),
onPlayerDamage: (n, a) => {
if (!this.combatHud || !a) return;
if (a.shieldHit > 0) this.combatHud.flash('shield');
if (a.hullHit > 0) this.combatHud.flash('hull');
},
// THE DEATH FLOW (the real one): the hull broke → the scene raises
// the Game Over beat (js/ui/GameOverOverlay.js) instead of the
// placeholder respawn (that flag is set on the skill layer's seam,
// below — _playerDeathRespawns).
onPlayerDown: () => this._playerWentDown(),
});
// THE LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js) — the big beat on a
// combat level landing: the world freezes and dims, the old level
// shatters into falling shards, the new one decodes in digit by
// digit, and a neon shutter peels the frame away. Subscribed to the
// ledger's 'levelup' event (the armor bump + the level-landing voice
// still ride CombatSystem's own subscription); the scene's update()
// gates the ship, the combat, and the time-based ticks on its
// `active` — the same freeze contract as the comms' ack pause.
this.levelMarquee = new LevelUpMarquee(this);
this.levelMarquee.bind(this.combat.xp);
// FACTION GATE LOCK — the swirl tint follows the level: the ledger
// now stands (the save's level restored), so re-resolve the gates'
// tints (a level that already lifted the lock clears them), and
// every level-up after re-resolves too (the lock lifts at minLevel).
this._refreshGateFactionTints();
this.combat.xp.on('levelup', () => this._refreshGateFactionTints());
// THE ENCOUNTER DIRECTOR (js/combat/Encounters.js — the pure clock;
// the numbers are data/combat.json → encounters): while flying in
// neutral wild space (not home, no wave run in flight, the player
// upright) it rolls a random encounter on the interval schedule —
// pirates, neutrals, or a mix, sized off the combat level. It owns
// no ships: on a hit it returns a composition and _spawnEncounter
// materializes the pack off-screen (the existing spawn path; their
// AI streams them in).
this.encounters = new EncounterDirector(config.get('combat.encounters', {}));
// THE ESCORT LANE'S DIRECTOR (data/sidequests.json → escort.encounters —
// a HOT lane: a short 20–30 s roll interval at a middling chance, a
// 15–30 s quiet after each fight). It owns no ships either:
// on a hit it's just a "go", and updateEscort fires the escort's mixed-fighters
// wave (escortWaveRoster's roster, bigger concurrent counts). One at a
// time (the wave run's completion is the quiet's gate — the patrol's law).
this.escortDirector = new EncounterDirector(config.get('sidequests.escort.encounters', {}));
this._escortTransport = null; // the escort contract's transport (the scene-local convoy reference)
this._encounterSerial = 10000; // off the dev console's 100+i band
// ?combat boot (the tuning sandbox): the SEVEN-TYPE DEMO around the
// ship (data/combat.json → waves.scripts.combat_demo) — a sequence
// of rolling waves, one per new hostile type in difficulty order
// (bomber → warden, frames 1–7), six of each, three on screen at a
// time, each kill sending in a replacement (js/combat/Waves.js →
// WaveRunner, driven by CombatSystem._waveTick — wave 1 lands after
// the script's 1.5 s lead; the bar is up from the start).
// The main game's combat still arrives with the quest's ambush
// (its own wave run) — the boot just adds the showcase.
// NOTE — the player's home system is SAFE SPACE: enemies should
// NEVER be placed there. This dev boot is the one place they could
// land in it, so don't run ?combat at home, and keep that rule for
// any new enemy placement (the quest ambush already targets the
// Neutral Capital, never home).
const boot = this._combatBootMode(); // 'demo' | a combat.factions id | null
if (boot === 'demo') {
this.combat.startWaves('combat_demo');
// SETTLE WINDOW — the ship's start position is finalized AFTER
// create() (a fresh run's home rim comes from the tether barrier;
// a load's from the save), so the run ships — spawned around the
// create-time position — are re-anchored around the ship for the
// first few frames (update() → CombatSystem.resyncAroundShip).
this._combatSettleFrames = 30;
console.info(
'[orbit] ?combat — the seven-type demo is live: 7 rolling waves, bomber → warden (data/combat.json → waves.scripts.combat_demo)',
);
}
// THE FACTION DUEL ARENA (the ?combat=apex / ?combat=mindweave /
// ?combat=zenith boots — data/combat.json → combat.factions): the
// APEX (frames 30–37 — cloaked ambushes, flanking strikes, forward
// firepower), the MINDWEAVE (frames 40–47 — blinks, phantoms,
// telekinetic tides, spiralling barrages) and the ZENITH (frames
// 50–57 — wing volleys of slow seekers, arcs that bloom into
// electrified fields) duel arena: a picker panel (js/combat/
// DuelPicker.js) offers the faction's eight fighters (keys 1–8 / a
// click), and '0' sends the whole pack. One fighter at a time —
// the factions run 2× the pirate's damage and 3.5× its armor
// (data/combat.json → enemyTypes, the $factions_note / the
// _zenith_note's balance rule), with the faction's SKILL LAYER
// (js/combat/EnemyAbilities.js) on top of its weapon pattern. When
// one goes down the picker re-shows for the next choice. These
// rosters are NOT in any quest/galaxy content — the arena is their
// only home (same safe-space caveat as the demo: don't run it at
// home, where hostiles never go).
if (factionIds().includes(boot)) {
this.combat.startDuel(boot);
this.duelPicker = new DuelPicker(this, this.combat, boot);
console.info(
`[orbit] ?combat=${boot} — the faction duel arena is live: pick a fighter (keys 1–8, 0 = the whole pack)`,
);
}
// The ?neutral boot (the dev stand-in for the NEUTRAL-ONLY
// encounter): two pilots stream in from off-screen on a SHORT escort
// window (8 s — the data's band is 10–15 s), so the whole beat plays
// in one sitting: the neutral hum in (setCombatMusic, neutral), the
// pilot's SCAN comm as one flies on-screen, the break-off, the
// SCAN-COMPLETE comm, and the deep-space hum back. Player-side ships
// only — safe to run even at home (where hostiles never go).
if (this._wantsNeutralDemo()) {
const ship = this.ship;
const nids = neutralTypeIds();
const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2);
for (let i = 0; i < 2; i++) {
const a = Math.PI * (i + 0.5) + (Math.random() - 0.5) * 0.6;
this.combat.spawnAlly(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
90000 + this._encounterSerial++,
nids.length ? nids[(Math.random() * nids.length) | 0] : null,
{ escortMs: 8000 }, // short window — the break-off lands fast
);
}
console.info(
'[orbit] ?neutral — neutral-only encounter live: two pilots, 8 s window (the neutral hum + the scan / scan-complete comms)',
);
}
// The READABILITY LAYER (shared with the range): the lock on the
// auto-fire target, the threat markers, the off-screen arrows
// (the camera follows the ship — hostiles can sit outside it),
// the kill feed, and the combat bar's gauge flash on player hits.
this.combatHud = new CombatHud(this, {
getEnemies: () => (this.combat ? this.combat.enemies : []),
getTarget: () => this.combat?.currentTarget() ?? null,
getCamera: () => this.cameras?.main ?? null,
worldToScreen: (x, y) => {
const c = this.cameras?.main;
const ox = c?.worldView?.x ?? c?.x ?? 0;
const oy = c?.worldView?.y ?? c?.y ?? 0;
const z = c?.zoom ?? 1;
return { x: (x - ox) * z, y: (y - oy) * z };
},
gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null,
});
if (typeof window !== 'undefined') {
// Tuning handle: poke the pack / the armor / the bar from the console.
window.__ORBIT_COMBAT__ = {
system: this.combat,
bar: this.combatBar,
player: () => (this.combat ? this.combat.stats() : null),
kills: () => this.combat?.kills ?? 0,
spawn: (n = 1) => {
if (!this.combat || !this.ship) return;
const r = this.combat._offscreenRadius();
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
this.combat.spawnEnemy(
this.ship.x + Math.cos(a) * r,
this.ship.y + Math.sin(a) * r,
100 + i,
);
}
},
damagePlayer: (n = 10) => this.combat?.damagePlayer(n),
cast: (i) => this.combat?.cast(i),
// COMBAT XP (js/combat/XpManager.js): read the bank (level, the
// xp toward the next, the cap), or feed it straight to test the
// curve / the level-up beat (source 'dev' — the ledger tags it).
xp: () => this.combat?.xp?.stats() ?? null,
grantXp: (n = 100) => this.combat?.xp?.grant(n, 'dev') ?? null,
// The LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js): preview the
// full beat without a kill (from → to; +N LEVELS for a jump).
marquee: (from = 7, to = 9) =>
(this.levelMarquee?.play({ from, to, levelsGained: Math.max(1, to - from) }) ?? null),
// WAVE RUNS (js/combat/Waves.js — the quest-step combat): start a
// script (a combat.waves.scripts NAME or a raw wave list), watch
// it, cancel it. The quest's own hook is live (the 'Pirate
// Problems' ambush — GameScene._maybeStartPirateAmbush); this
// seam is the sandbox's entry point.
waves: {
start: (script, opts) => this.combat?.startWaves(script, opts) ?? null,
cancel: (destroy = false) => this.combat?.cancelWaves(destroy) ?? null,
status: () => this.combat?.waveStatus() ?? null,
demo: (name = 'raid') =>
this.combat?.startWaves(name, {
onWaveStart: (i) => console.info(`[orbit] wave ${i + 1} in`),
onWaveClear: (i) => console.info(`[orbit] wave ${i + 1} cleared`),
onComplete: (s) => console.info('[orbit] combat complete', s),
}) ?? null,
},
};
}
}
// ---- The SAVE SYSTEM — the deck's Menu sub-bar + the slot pop-up --
// The sub-bar folds up out of the Menu button (Save Game / Load Game
// [grayed while no saves exist] / Return to Main Menu); both save
// verbs open the 10-slot pop-up, which owns the overwrite + load
// confirmations and the bulk download (js/ui/MenuSubBar.js +
// js/ui/SavePanel.js, config in data/save.json).
this.saveManager = new SaveManager(); // the localStorage bank
this.menuSubBar = new MenuSubBar(this, {
anchor: this.menuAnchor(),
onAction: (id) => this.subBarAction(id),
});
this.savePanel = new SavePanel(this, {
// Load confirmed: the restore is staged in the shared registry —
// restart this scene in place (the jump cut's seam) and let the
// fresh create() pick it up. A confirmed LOAD drops the run back
// where it was saved; it is not a detour through the menu (the
// menu's New Game resets the run state, so it never carried a
// staged restore).
onLoadComplete: () => this.reloadRun(),
});
// The MINERALS readout — upper right (the corner the old tether stack
// used to occupy): the hold's fill as a bar + counting number.
// The CREDITS readout stacks ABOVE it (the trade currency — see
// js/economy/Economy.js + data/creditshud.json); the minerals HUD is
// shifted down by the credits line's height so the two read as one
// console, and the comms box (anchored to the minerals bar's bottom)
// keeps clear of both.
this.creditsHud = new CreditsHud(this);
this.creditsHud.set(this.ship.credits);
this.mineralHud = new MineralHud(this, { topOffset: this.creditsHud.height + this.creditsHud.gap });
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
// ---- COMMS (the characters speak to the player — data/comms.json) --
// The hub (js/comms/CommsHub.js) owns the comms' lives: the queue
// (events arm them; pump() below plays the next due one), the
// DEFERRED asset loads (the comm's character clip + speech file load
// when the comm is due — never in preload, so a big comm roster
// doesn't bloat the initial load), the voice (with the music ducked
// under it), and the ACK pause contract (below: update() gates the
// action on comms.paused; the input seams gate on it too). The box
// itself (js/ui/CommsBox.js) docks just below the mineral bar — the
// hud's lower-right is the anchor.
this.comms = new CommsHub(this, {
// The scene isn't free while any of these owns the screen — the
// comm waits (re-pumped each frame) until the screen is clear.
// The hub passes the comm it's about to start (def.type):
// - a MODAL owns the screen (a window/panel is up, or a jump is
// in flight) — no comm of ANY type starts until it's clear;
// - an ACTION is in progress (mining live, the scan sweep out) —
// the box comms (ack/visual: they pause the action or take the
// screen) wait for it to settle, but an audio-only comm is
// voice with no box, no pause, no input — it rides over the
// action (the research-complete sign-off lands mid-mining).
busy: (type) => {
const screenOwned =
(this.savePanel && this.savePanel.isOpen) ||
(this.researchWindow && this.researchWindow.isOpen) ||
(this.mapWindow && this.mapWindow.isOpen) ||
(this.shipWindow && this.shipWindow.isOpen) ||
(this.questWindow && this.questWindow.isOpen) ||
(this.commsPanel && this.commsPanel.isOpen) ||
(this.menuSubBar && this.menuSubBar.isOpen) ||
!!this._jumping;
if (screenOwned) return true;
if (type === 'audio') return false; // voice over the action — allowed
return (
(this.mining && this.mining.isActive) ||
(this.miningPopup && this.miningPopup.isOpen) ||
(this.scanPulse && this.scanPulse.busy)
);
},
anchor: () => {
const hud = this.mineralHud;
if (!hud) return null;
return { right: this.scale.width - hud.pad, top: hud.barY + hud.barH };
},
// IN-COMBAT 'visual' comms take the compact form: the box's small
// video-only plate hugging the right edge of the screen (2/3-size
// clip, no text) — the battlefield stays clear while the video and
// the voice still play. 'ack' comms keep the full box (they need
// the words + the ACKNOWLEDGE button).
compact: (def) => def?.type === 'visual' && !!(this.combat && this.combat.active),
// The ack pause's scene-side effects: the action holds — the ship
// parks dead (target + throttle cleared, velocity zeroed) and the
// mining arm drops. Resuming needs no work (the ship stays parked;
// the player's next click steers it again).
onPause: (on) => {
if (!on) return;
this.ship.stop();
if (this.mining && this.mining.isActive) this.mining.stop();
},
// The comm has STARTED (its id is on the run's played-ledger): the
// 'Leaving the Nest' quest's Trestle witnesses flip on exactly this
// moment — all three briefs in the ledger = 'Talk to Trestle' done,
// the arrival in it = 'Meet Trestle' done (GameScene._trestleCheck
// reads the ledger live). Refresh the quest ledger so the checklist
// row ticks over and, if the quest just completed, the READY moment
// (toast + chime) carries it when the tracker is slid off.
onStart: (def) => {
if (def && def.from === 'trestle') this._questRefresh();
},
// The comm was ACKNOWLEDGED (the 'ack' comm's press itself — fired
// BEFORE onEnd, so the end-side pass already sees the mark): the
// run's comms ACKED-ledger (registry 'commsAcked' —
// GameScene._commAcked) records the id. 'Pirate Problems' (data/
// quests.json → pirate_problems.unlocks.acked) stands on this:
// its grant waits for the 'Special Delivery' payoff chain to be
// ACKed all the way through (SaveData captures it under
// record.comms.acked).
onAck: (def) => this._commAcked(def),
// The comm ENDED (for 'ack' comms: the ACKNOWLEDGE press): the
// scene's ack-side bookkeeping — Trestle's last brief ('trestle
// sends the coordinates to your computer right now') sets the
// player's destination to the neutral capital on acknowledgment
// (GameScene._commEnded).
onEnd: (def) => this._commEnded(def),
});
// The run's FIRST rock field (the 'firstCluster' comm beat — see
// celebrateDiscovery): a scene-local one-shot, so the event fires
// exactly once per scene life. The comm's own once-ledger is the
// authoritative dedupe across saves (a flag reset on load can never
// re-play a comm the ledger already holds).
this._firstClusterSeen = false;
this._firstWorldSeen = false; // the first DISCOVERED WORLD (Trestle's intro — the quest's own witness)
// The systems whose NAV chart COMPLETED this scene life (the
// 'systemComplete' milestone — oac-system-complete, see
// celebrateDiscovery): one fire per system. A later asteroid-cluster
// discovery in an already-charted system still passes
// isSystemNavComplete() (clusters aren't NAV points), so the set —
// not the chart state — is the arming gate.
this._systemCompleteSeen = new Set();
// The run's FIRST crossing of the 200-mineral line (the 'minerals200'
// comm beat — see onOre): same one-shot shape; the threshold check
// also runs at boot (create, below) so a loaded run that already
// crossed it still gets the beat.
this._minerals200Armed = false;
// The tutorial's pointing finger (data/quests.json → tutorial, the beat
// table in js/quests/TutorialGuide.js): the 'press SCAN' beat is past
// the moment the player presses SCAN — the scene-local one-shot (the
// comm's played-ledger marks oac-upgrade the moment it starts, and is
// the authoritative witness from then on).
this._tutScanPressed = false;
// Hint (pinned just ABOVE the command deck, not under it)
this.hint = this.add
.text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), {
fontFamily: BODY_FONT(),
fontSize: '14px',
color: '#54608a',
letterSpacing: 1,
})
.setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
// ---- MINING (the clusters are now verbs, not just rocks) -----------
// The state machine (js/mining/Mining.js): idle → extending (~1.5 s,
// the ship holds station, "Extending Mining Arm..." in the console
// slot) → mining (the beam is live — js/mining/MiningBeam.js)
// → retracting. The pop-up (js/ui/MiningPopup.js) is the context
// menu a rock click opens, world-anchored right where the player
// clicked.
this.mining = new Mining(this, {
onPhase: (p) => this.onMiningPhase(p),
onDepleted: (c) => this.onClusterDepleted(c),
onEvent: (name, data) => this.onMiningEvent(name, data),
onOre: (gained) => {
this.refreshMineralHud(); // live: the hold fills as the rock shrinks
// Lifetime asteroid mining — the quest ledger's input (the
// 'Mine 200 Minerals from asteroids' requirement counts this,
// not the hold — the hold is cargo, this is the record).
this.totalMined = (this.totalMined ?? 0) + Math.max(0, Number(gained) || 0);
this._questRefresh();
// The comms beat (data/comms.json: minerals200 — oac-tether2):
// OAC confirms the moment the run's lifetime mining first crosses
// 200 — the 'Mine 200 Minerals' quest line (data/quests.json) and
// the tether-l2 build cost (data/builds.json). The scene flag fires
// the event once per scene life; the comm's once-ledger is the
// authoritative dedupe across saves.
if (!this._minerals200Armed && this.totalMined >= 200) {
this._minerals200Armed = true;
this.comms?.trigger('minerals200');
}
},
});
this.miningPopup = new MiningPopup(this, {
onAction: (id, rock) => this.miningAction(id, rock),
});
// COMM PANEL (js/ui/CommsPanel.js) — the starship comms console that
// opens at the player's click on a planet or space station: a rusty
// metal case around a green phosphor readout. The name decodes at
// the top; a SETTLED object (a world hosting a colony / mining
// station / cloud base, or a free-space station) shows a reputation
// bar — 41 marks, −20…+20, red → green, lit up to the standing —
// plus REQUEST LANDING (grayed at standing ≤ −4) and CANCEL; an
// UNSETTLED world shows LAND + CANCEL. The landing buttons launch
// SurfaceScene (startLanding) — a one-shot landing video over a
// looping surface clip with its own deck (data/landing.json).
this.commsPanel = new CommsPanel(this, {
onAction: (id, target) => this.commsAction(id, target),
});
// ---- RESEARCH CONSOLE (the deck's RESEARCH button) ---------------------
// The rules live in data/research.json (+ one file per category under
// data/research/); the progress in ResearchState; the view in
// ResearchWindow (js/ui/ResearchWindow.js, depth 80). This scene owns
// the effects: tether level-ups, capability flags, toasts, the deck
// progress bar, and the save data (record.research).
this.researchState = new ResearchState();
for (const cat of categories()) {
const tree = loadCategory(cat.id);
if (!tree) continue;
for (const id of tree.starting) this.researchState.unlock(cat.id, id);
}
// The SYSTEM category's live tree — built for the system the player is
// in (categories flagged `dynamic` in research.json have no data file):
// the '{System} Map' (granted on entry — _onEnterSystem below) and
// 'Unlock Jumpgates' (chart complete → researchable → the
// gates + the linked systems' return gates go live). js/research/
// SystemCategory.js; copy + duration in data/gates.json → activation.
const sysCat = categories().find((c) => c.id === SYSTEM_CATEGORY);
this.systemTree = buildSystemTree({
systemId: this.systemRecord.id,
systemName: this.systemRecord.name,
accent: sysCat?.accent,
isComplete: () => this.isSystemNavComplete(),
});
this.researchWindow = new ResearchWindow(this, {
state: this.researchState,
systemTree: this.systemTree,
onResearch: (catId, id) => this.beginResearch(catId, id),
});
// ---- MAP CONSOLE (the deck's MAP button, right of SHIP) ----
// The cartography window (data/map.json, js/ui/MapWindow.js, depth 80):
// left cartography feed + THREE tabs — CURRENT SYSTEM (discovered
// objects on a padded frame, the tether union boundary, the fog of
// what the tether doesn't cover yet, the system discovery / resources
// readout — clicking a discovered object plots the course, the same
// autopilot as a world click), SYSTEM (a CHARTED star's chart — the
// same painter for another system, no ship/tether; armed until the
// player picks that star on the GALAXY tab, then clicking an object
// asks SET DESTINATION), and GALAXY (js/ui/GalaxyView.js:
// the whole-galaxy chart — glowing, pulsing stars per archetype, the
// jump-lane web with traveled lanes bright + flow packets, the
// charted region's outline, hover readouts, and a CHARTED star's
// chart in the SYSTEM tab).
// The window is a pure view — it polls mapChartSnapshot() /
// systemChartSnapshotFor() / galaxySnapshot() while open and repaints
// on any change.
this.mapWindow = new MapWindow(this, {
getChart: () => this.mapChartSnapshot(),
getSystemChart: (systemId) => this.systemChartSnapshotFor(systemId),
getGalaxy: () => this.galaxySnapshot(),
getShip: () => (this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null),
onSelect: (objectId) => this.autopilotTo(objectId),
onSetDestination: (systemId, objectId) => this.setDestination(systemId, objectId),
onLocked: (tabId) =>
this.consoleToast(
tabId === 'system'
? config.get('map.systemLockedToast', 'NO SYSTEM TARGET — PICK A CHARTED STAR ON THE GALAXY MAP')
: config.get('map.galaxyLockedToast', 'GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED'),
{
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
},
),
});
// ---- SHIP CONSOLE (the deck's SHIP button) ------------------------
// The hull & hardware console (data/shipScreen.json, js/ui/ShipWindow.js,
// depth 80): left the bay camera (assets/videos/ship.mp4 — a muted 2:3
// loop; until the clip is authored the window's procedural NO SIGNAL
// plate stands in), right the SHIP STATUS / SKILL TREE / CARGO HOLDS tabs
// over one plate. Ship Status reads the run through the data-driven
// source table (data/shipScreen.json → status.rows, resolved by the pure
// js/ship/ShipScreenModel.js — ship.stats, the live combat session's
// armor, the cannon's cooldown→rate, the hold's bays) and shows the
// XP ledger (js/combat/XpManager.js) as a bar + the level 0→5 journey,
// which gives way to CHOOSE YOUR CLASS at the class level (the class
// layer's seam — onChooseClass). Skill Tree is a STANDBY socket until the
// class layer lands: pressing it shakes the tab and toasts. Cargo Holds
// reads js/economy/CargoHold.js (one good per bay) + the aggregate
// mineral load, and a filled bay opens its manifest with the charted
// exits (js/economy/TradeScout.js — the planet shop's own report).
this.shipWindow = new ShipWindow(this, {
getSnapshot: () => this.shipScreenSnapshot(),
// The bay manifest's report is passed the bay's RECORDED purchase cost
// (CargoHold.costs), so its exits compare against what the player
// actually paid for that bay.
getScoutReport: (goodId, cost) => this.shipScreenScoutReport(goodId, cost),
// A BEST-EXIT row on the bay manifest was CONFIRMED (the console's own
// SET DESTINATION dialog, js/ui/ConfirmOverlay.js): the exit's system +
// its WORLD become the route target through the map's seam. The console
// closes on the way out (what MapWindow does), so the DESTINATION SET
// toast and the orange compass are actually on screen for it.
onSetDestination: (systemId, objectId) => {
this.shipWindow?.close();
// A CARGO delivery — the manifest's run (kind 'cargo' — the mark
// reads BROWN, unless it is a held story quest's delivery — see
// setDestination's kind guard).
this.setDestination(systemId, objectId, 'cargo');
},
// The row for the system the ship is IN can never be a destination — the
// manifest marks it HERE and leaves it inert.
isLocalSystem: (systemId) => !!systemId && systemId === this.systemRecord?.id,
onLocked: () =>
this.consoleToast(String(config.get('shipScreen.skillsLockedToast', 'SKILL TREE OFFLINE')), {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
}),
// The level-5 door: it opens the SELECT YOUR CLASS console over the
// parked world (js/ui/ClassWindow.js) — and it is where a DEFERRED pick
// comes back for. With the class layer switched off
// (data/classes.json → enabled: false) it stays a voice, never a click.
onChooseClass: () => this.openClassWindow({ source: 'shipConsole' }),
// The SKILL TREE door (js/ui/SkillWindow.js). The tab is lit by the
// snapshot's skillDoor flag below — a class with no tree keeps the socket.
onSkills: () => this.openSkillWindow({ source: 'shipConsole' }),
});
// ---- THE CLASS LAYER (data/classes.json, js/player/ClassState.js) ----
// The run's class LEDGER — the class locked in at combat level 5, the perks
// its initial bonus paid (the combat code's gates: dualCannon /
// targetingComputer) and the skill points banked with them. Registry-backed
// like discovery/quests: a load stages the saved record
// (SaveData.prepareLoad → record.playerClass), a fresh run starts empty, and
// New Game clears it. The CONSOLE itself is built on the first open — its
// media (~2 MB of feed) loads with js/vignettes/VignetteHub.js's deferred
// rule, so a run that never reaches level 5 never downloads it.
this.classState = this.registry.get('playerClass') ?? null;
if (!(this.classState instanceof ClassState)) this.classState = new ClassState();
this.registry.set('playerClass', this.classState);
// The door is spent once a class is on file (a loaded run is never asked
// again) and once the auto-prompt has been made (closing it DEFERS — the
// ship console's door re-opens it, but the game does not nag).
this._classPrompted = this.classState.hasClass;
this._classAutoShown = false; // the auto prompt has been made this session
this._classAutoOpen = false; // the console is up FROM the auto trigger
this._classMediaStarted = false;
this._classMediaReady = false;
this._classPendingOpen = null; // an open request waiting on the media pass
this.classWindow = null;
// A LOADED run already wears its class — hand the ledger to the combat
// system so its perks (dual cannon / targeting computer) are live from the
// first frame, exactly as they are after a fresh lock-in.
if (this.combat) this.combat.classState = this.classState;
// ---- THE SKILL LAYER (data/skills.json, js/skills/SkillState.js) ----
// The run's skill LEDGER — the points spent in the class's tree and which
// active skills sit on the combat bar's eight seats. THE BANK IS NOT HERE:
// points live on the class ledger above (the lock-in banks them), this one
// only ever spends them. Registry-backed like the class layer: a load stages
// the saved record (SaveData.prepareLoad → record.playerSkills), a fresh run
// starts empty, and New Game clears it. A loaded/older ledger with a class on
// file but no bindings takes the data's default seats (data/skills.json →
// classes.<id>.skills[].seat).
this.skillState = this.registry.get('playerSkills') ?? null;
if (!(this.skillState instanceof SkillState)) this.skillState = new SkillState(this.classState);
else this.skillState.classState = this.classState;
if (this.classState.hasClass) this.skillState.applyDefaultSeats();
this.registry.set('playerSkills', this.skillState);
// THE COMBAT SEAM: the fight reads the ledger — its seats resolve through
// CombatSystem.cast(), and a hull passive owns the armor's max there. A
// LOADED run re-rolls the armor so its ranks are live from frame one (the
// multiplier is rolled from base, so this is idempotent — see refresh).
if (this.combat) {
this.combat.skillState = this.skillState;
this.combat.refreshSkillPassives();
// THE DEATH FLOW's respawn gate (the seam reads the LIVE ledger, so a
// skill that overrides death stays in sync as ranks change):
this.combat.playerRespawn = this._playerDeathRespawns();
}
// The assignment hand: {id, from} while a skill is being placed on a seat
// (js/ui/CombatBar.js waits on it), null the rest of the time.
this._skillAssign = null;
this._ensureSkillIcons();
// The dev door (?skill — see _skillDevMode): files the class, funds or maxes
// its tree and can open the console, so the layer is testable without 5 levels
// of grinding. No parameter, nothing happens.
this._applySkillDevDoor();
// THE SKILL CONSOLE (js/ui/SkillWindow.js, depth 80) — the ship console's
// SKILL TREE door. It is a pure VIEW of the ledger: every press goes back out
// through the scene's own doors (rankUpSkill / beginSkillAssign / clearSkillSeat)
// so points, seats, registry stamps, passives and toasts behave exactly as they
// do when a KEY does the same thing. Built once per scene life (no media).
this.skillWindow = new SkillWindow(this, {
getSnapshot: () => this.skillWindowSnapshot(),
onRankUp: (id) => {
this.rankUpSkill(id);
this.skillWindow?.refresh();
},
onAssign: (id) => this.beginSkillAssign(id),
onClearSeat: (index) => {
this.clearSkillSeat(index);
this.skillWindow?.refresh();
},
// THE SESSION DOOR: give back what THIS OPENING of the console ranked, and
// only what can be given back without stranding a committed point — the
// ledger works the cascade and says when it cannot (SkillState.resetPlan).
onReset: (id) => {
this.resetSkillPending(id);
this.skillWindow?.refresh();
},
// CLOSING IS COMMITTING. The one close that is not is the seat hand-off, which
// steps the console aside for a moment and takes it straight back — committing
// there would take the undo out of the player's hand mid-transfer.
onClose: ({ reason } = {}) => {
if (reason !== 'assign') this.commitSkillSession();
},
});
// ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
// left the 2:3 mission feed (assets/videos/quests.mp4 — the same
// dimensions as the research feed), right the MAIN STORY / SIDE QUESTS
// tabs (side is a STANDBY socket — side quests are deferred by design),
// a THIN list of the active quests, and the big detail plate below:
// name, issuer, description, the requirement checklist, and the reward
// with the CLAIM action. The surface deck's QUESTS slot opens the same
// window over the paused GameScene's state (SurfaceScene.buildDeck).
// A quest's progress is evaluated LIVE (questSnapshot below — research,
// lifetime asteroid mining, the home tether's standing, the discovery
// ledger) and, once a step reads DONE, it is RECORDED on the ledger
// (QuestState's completed set — monotonic: the step keeps standing
// after the live read can no longer see it, e.g. the home tether's
// standing once the ship has left the home system). The ledger
// itself — which quests are held, which rewards have been claimed,
// and which steps have stood — is the QuestState in the shared
// registry (like discovery/research) and rides the saves
// (record.quests, plus the lifetime counter record.totalMined the
// mining requirement reads).
this.questState = this.registry.get('quests') ?? null;
if (!this.questState) {
this.questState = new QuestState();
this.registry.set('quests', this.questState);
}
// ---- SIDE QUESTS (the JOB BOARD's ledger — data/sidequests.json) ----
// The contracts the run has taken on the job board (every WORLD
// except home — the Explore drawer's own gate): accepted + completed
// ids (the postings themselves are seed derivations — a save = seed +
// player state, js/quests/SideQuests.js). The board is PER SURFACE
// WORLD (the postings stand on the world the surface stands on —
// sideQuestPostings(worldName, isStation) below), and the window
// renders them over the SLEEPING scene (the QuestWindow precedent —
// state + effects here, the view on the surface); the ACCEPT lands
// here (acceptSideQuest below).
this.sideQuestState = this.registry.get('sidequests') ?? null;
if (!this.sideQuestState) {
this.sideQuestState = new SideQuestState();
this.registry.set('sidequests', this.sideQuestState);
}
this._questAnnounced = new Set(); // the 'QUEST READY' toasts + chime, once each
this.questWindow = new QuestWindow(this, {
getSnapshot: () => this.questSnapshot(),
onClaim: (id) => this.claimQuest(id),
onSetPriority: (id) => this.setPriorityQuest(id),
getPriority: () => this.questState?.priority ?? null,
// The FRESH open (the deck's QUESTS button) lands on the run's
// PRIORITY quest — the one the tracker HUD features (the story
// quest the player is actively engaged in): the tracker's own seam.
resolvePriority: (ids) => this.questState?.resolvePriority(ids) ?? null,
onLocked: (tabId) =>
this.consoleToast(
tabId === 'side'
? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET')
: 'NO SIGNAL',
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
),
});
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
// requirement's input — the hold is cargo, this is the ledger). Registry
// -backed like discovery: a load stages the saved value (prepareLoad),
// a fresh run starts at zero, and the New Game reset clears it.
this.totalMined = Math.max(0, Math.round(Number(this.registry.get('totalMined')) || 0));
// The run's STORY ledger (data/quests.json → 'Special Delivery' — the
// neutral capital's bar, the package handoff, the delivery leg — and
// the surface TRADE gate: ShopView reads isTradeUnlocked before the
// shop's Buy/Sell tab is allowed). Registry-backed like totalMined:
// a load stages the saved record (SaveData.prepareLoad), a fresh run
// starts blank, and the New Game reset clears it (resetRunState).
const storyRaw = this.registry.get('story') ?? null;
this.story = {
capitalLanded: storyRaw?.capitalLanded === true, // the bar (the trade gate lifts with it)
launchedFromCapital: storyRaw?.launchedFromCapital === true, // the delivery leg's start
lastLandedWorld:
typeof storyRaw?.lastLandedWorld === 'string' && storyRaw.lastLandedWorld
? storyRaw.lastLandedWorld
: null, // the last world landed on
delivered: storyRaw?.delivered === true, // step 3's proof (the home-world SALE of the package — monotonic)
// 'Pirate Problems' step 3 — the return ARRIVAL at the capital
// world (GameScene._maybeWitnessCapitalReturn set it: the ship
// within the world's keep-out rim while the quest is held +
// unclaimed — monotonic; the 'capitalReturned' check reads it).
capitalReturned: storyRaw?.capitalReturned === true,
};
this.registry.set('story', this.story);
// deck progress bar — drawn over the RESEARCH button while a project runs
this._researchDeckBar = {
g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false),
txt: this.add
.text(0, 0, '', {
fontFamily: fontStack('body'),
fontSize: '10px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
align: 'center',
})
.setOrigin(0.5, 1)
.setScrollFactor(0)
.setDepth(52)
.setVisible(false),
};
// ---- BUILD CONSOLE (the deck's BUILD button, on a planet surface) ----
// The rules live in data/builds.json (js/build/BuildModel.js); the
// progress in BuildState — the run's build records (which builds are
// installed on which planets) plus the single in-progress build (one
// at a time). The SurfaceScene is the passive view (BuildWindow,
// depth 80) and TICKS the state in its update() (this scene sleeps
// while the surface is active). The build's time base is the game-
// LOOP clock (game.loop.now) — global + monotonic — so a build keeps
// running if the player takes off mid-build (then we tick it here).
// This scene owns the effects (the tether field is world state, so it
// outlives the stay) and the save data (record.builds).
this.buildState = new BuildState();
this._seedStartingBuilds();
// The staged restore (if this run was LOADED): ship back where it
// was, the saved tether field, the saved session time — and the saved
// research + build state. AFTER the state objects above exist: the
// restore mutates them (applyRestore), so it must come last in the
// creation order.
if (this._pendingRestore) {
this.applyRestore(this._pendingRestore);
this._pendingRestore = null;
}
// WORLD PREVIEW (?type=…): the ship is parked at the TARGET world —
// anchor that world's first-landing tether (data/builds.json →
// tether-l1: free + instant, "the moment the player lands on a
// world") so the player's room to move is the world's rim — exactly
// the state a first landing leaves. Without it the ship would sit
// outside every tether (the other systems' gate tethers don't
// necessarily reach the target world) and the rim constraint would
// drag it back. Idempotent: the home world already hosts its home
// tether, and ensureLandingTether adopts existing fields.
if (this.typeTarget) {
const previewEntity = this._previewEntity();
if (previewEntity?.discoveryName) this.ensureLandingTether(previewEntity.discoveryName);
}
// The starter quest — granted ONCE onto a fresh ledger (a loaded save's
// ledger already holds whatever was granted back then — and give() is
// idempotent, so a jump-cut restart that rebuilds this scene off the
// registry's ledger stays silent). It is the run's opening arc: the
// checklist (data/quests.json) is the first loop — research, mine,
// build, discover.
if (config.get('quests.enabled', true) !== false) {
for (const id of starterQuestIds()) {
if (this.questState.give(id)) {
const def = questDef(id);
this.playSfx('discovery'); // the 'something new is here' voice
// The toast lands AFTER the opening 'DISCOVERED — HOME WORLD'
// toast (one toast slot — the later call replaces the earlier):
// ~2.4s is that toast's life, so +0.3s keeps the order readable.
this.time.delayedCall(2700, () => {
this.consoleToast(
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
.replace('{title}', String(def?.title ?? id).toUpperCase()),
{
glyph: config.get('quests.starterToast.glyph', '◆'),
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
});
}
}
}
// The RUN's chain (data/quests.json → quests[].unlocks): the quests
// whose unlock requirement (unlocks.claimed — the reward CLAIMED) is
// met land on the ledger now. On a fresh run the starter is unclaimed
// — a no-op; on a LOADED save whose starter is already paid out,
// 'Leaving the Nest' grants here (a fresh run's later claim grants it
// via claimQuest → the same method; give() is idempotent either way).
this._grantUnlockedQuests();
// The field's native set (home + the activated gates) is above; the
// tethers the player INSTALLED on this system's other worlds are
// world state (built records) — re-anchor them on their worlds, since
// a jump cut / scene restart rebuilds the field to the native set
// only (idempotent: existing tethers are only ever strengthened).
this._rematerializeBuiltTethers();
// The ship's MINING upgrades are derived the same way (build records
// are the source of truth — the save carries them, not the stats):
// a load / jump-cut re-asserts the arm rate + hold cap the player
// installed (idempotent; a fresh run has none).
this._restoreMiningUpgrades();
// Entering a system charts it: the SYSTEM category's '{System} Map'
// tech (duration 0) is owned on arrival — a new run gets the starting
// system's, a load re-asserts the saved system's (the restore above
// replaced the unlocked set), and a jump (jumpThroughGate →
// scene.restart → this create) grants the destination's.
this._onEnterSystem();
// ---- DEEP SCAN (the deck's SCAN button) -------------------------------
// The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull,
// then an omnidirectional wavefront expanding across the TETHER REGION
// (clipped to the union boundary — absorbed at the rim). Objects in the
// region ring as the front crosses them (updateScanHits), the starfield
// ripples as the wave passes, the camera thumps + rolls with it, and the
// barrier shivers where the wave is swallowed (TetherField.excite).
// The RESULTS are a seam for now — finishScan logs the in-region objects;
// the next instruction decides what a scan yields.
this.scanPulse = new ScanPulse(this);
this.scanObjects = null;
this.scanObjectScale = config.get('scan.ripple.objectScale', 0.045);
// SIGNAL COMPASS (js/ui/SignalCompass.js) — the secondary compass:
// a faint ring (radius px) around the ship. When a scan COMPLETES,
// every undiscovered in-region object lights up a soft "radio signal"
// on that ring at its bearing (full 20 s, then a 5 s fade). A signal
// drops the instant its object is discovered; re-scanning re-emits the
// set with a fresh clock. `this.scanReveal` = the current emission
// ({ bornAt, ids }) — the clock + which objects it covers.
this.signalCompass = new SignalCompass(this);
this.scanReveal = null;
this.signalFullMs = config.get('signalCompass.lifetime.fullMs', 20000);
this.signalFadeMs = config.get('signalCompass.lifetime.fadeMs', 5000);
// SHIP STATE (js/entities/Ship.js): 'normal' is the default — the
// ship is free; 'mining' — the arm's sequence owns the ship. The
// mining visuals live and die with that state: ANY change out of it
// (the player moving the ship — a world click or the compass
// autopilot — a cancel, or a future state) ends the mining sequence.
this.ship.onStateChange = (next, prev) => {
if (prev === 'mining' && next !== 'mining') this.mining.stop();
};
// Input: click = fly there. A click ON the command deck is deck
// business, a click on a compass name tag is an autopilot (it
// already retargeted the ship), and a click on the system NAME (or
// the caret beside it) toggles the dossier open/closed — none of
// those is a fly-here. A click on an ASTEROID opens the mining
// pop-up (a context menu — its buttons own their clicks; any click
// elsewhere closes it and is consumed). A click on a PLANET or
// STATION opens the comms panel — the landing-request window — and
// does nothing else: it is NOT a fly-here (the ship stays put), and
// it does not touch the mining state (no move ⇒ a live beam keeps
// running). Any OTHER click moves the ship — which ends the mining
// state (the beam retracts as the ship goes; a mid-reach arm
// aborts). Holding SHIFT on that click = THROTTLE LOCK: the ship
// commits to the heading toward the click point and keeps flying it
// — past the point, full throttle — until the player steers it
// somewhere else (a plain click flies there and stops; another
// Shift+click re-aims the heading) — or until it runs into a solid /
// the tether rim, where the scene stops it dead (onPostUpdate). A
// click BEYOND the player's tether range clamps to the union boundary
// — the target marker lands on the barrier line itself (and while
// throttle-locked, the ship runs straight into the line and stops
// there instead of resting on it).
this.input.on('pointerdown', (pointer) => {
// The Game Over beat is up — it owns the whole screen (its two buttons
// fire on their own pointerdown; a world click never lands behind it,
// so a button press never doubles as a fly-here for the downed ship).
if (this._gameOver === true) return;
// A jump clip is in flight — it owns the whole screen. A DOUBLE-CLICK
// (two quick presses) skips the rest of the clip (the same window as
// SurfaceScene's landing/takeoff skip); a single press is swallowed.
if (this._jumping && this._jumpVideo) {
const now = performance.now();
if (now - this._jumpLastClickT <= JUMP_DOUBLE_CLICK_MS) this._finishJump();
this._jumpLastClickT = now;
return;
}
// A jump is in flight (between the clip's end and the restart): the
// old scene is already gone — swallow anything that lands in the gap.
if (this._jumping) return;
// An ACK comm is up — it owns the whole screen (the box + its
// ACKNOWLEDGE button press itself; the world holds still — the
// CommsHub's pause contract; no skip, the player reads + clicks).
// _commInputBlocked also swallows the click that ACKNOWLEDGED (the
// endedWithin race window) — that click is the comm's own.
if (this._commInputBlocked()) return;
// The save pop-up is MODAL — while it's up it owns all input
// (its scrim / cards / dialog eat the click; the world stays put).
if (this.savePanel && this.savePanel.isOpen) return;
// The research console (depth 80) is full-screen — it owns all input
// (its own buttons, the close, ESC); a world click never lands behind it.
if (this.researchWindow && this.researchWindow.isOpen) return;
// The map console (depth 80) — the same full-screen contract.
if (this.mapWindow && this.mapWindow.isOpen) return;
// The quests console (depth 80) — the same full-screen contract.
if (this.questWindow && this.questWindow.isOpen) return;
// The ship console (depth 80) — the same full-screen contract.
if (this.shipWindow && this.shipWindow.isOpen) return;
// The skill tree console (depth 80) — the same full-screen contract.
if (this.skillWindow && this.skillWindow.isOpen) return;
// A click the console's OWN ✕ just handled (the two handlers race on
// event order — close() lands first, so the guard above can miss it):
// it is the console's click, never a fly-here. Same-frame test, the
// mining menu's and the comms panel's contract. The console is full-
// screen, so no footprint check — any click that frame is its own.
if (
this.skillWindow &&
this.skillWindow.closedByButtonAt !== null &&
this.time.now - this.skillWindow.closedByButtonAt < 20
) {
return;
}
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
// its buttons — the buttons fire on their own pointerdown). ANY
// click OUTSIDE — world, deck, HUD, compass — folds it back down
// and that click is done (no fly-here, no dossier toggle, no
// autopilot). Deck buttons still get their own press from the
// deck's handler; the Menu button's toggle is safe either order
// because open() is a no-op while the bar is still 'closing'.
if (this.menuSubBar && this.menuSubBar.isOpen) {
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
this.menuSubBar.close();
return;
}
if (this.actionBar && this.actionBar.visible && this.actionBar.contains(pointer.x, pointer.y)) return;
// The combat bar (in the deck's place while combat is live) — a
// press on it is the bar's (a skill slot); no fly-here behind it.
if (this.combatBar && this.combatBar.visible && this.combatBar.contains(pointer.x, pointer.y)) return;
if (this.compass.contains(pointer.x, pointer.y)) return;
if (this.hudTitleContains(pointer.x, pointer.y)) {
this.toggleHud(); // the system name toggles the dossier details
return;
}
// The quest tracker (below the dossier, depth 30) — the mining-
// menu contract: while its row popup is up, a click inside the
// popup is a button's (its own pointerdown); the same-frame click
// on the row that OPENED it is swallowed; any other click closes
// the popup and is consumed. With the popup down, a click on the
// tracker's footprint is the tracker's (a row's own pointerdown
// opens the popup) — never a fly-here; a click its popup buttons
// just handled (they close it from their side first) is that
// click, never a world click.
if (this.questTracker && this.questTracker.shown) {
const tx = pointer.x;
const ty = pointer.y;
if (this.questTracker.popupOpen) {
if (this.questTracker.popupContains(tx, ty)) return;
if (this.questTracker.sourceContains(tx, ty)) {
if (this.questTracker.popupOpenedWithin(40)) return; // the opening click
this.questTracker.closePopup(); // a later deliberate re-click — fold it
return;
}
this.questTracker.closePopup();
return;
}
if (this.questTracker.closedPopupAt(tx, ty)) return;
if (this.questTracker.contains(tx, ty)) return;
}
// Mining pop-up OPEN: a click on one of its buttons is the
// button's (its own pointerdown listener); a click ANYWHERE else
// closes the menu and that click is consumed — no fly-here (the
// same contract as the sub-bar).
if (this.miningPopup && this.miningPopup.isOpen) {
if (this.miningPopup.contains(pointer.worldX, pointer.worldY)) return;
this.miningPopup.close();
return;
}
// A click the pop-up's OWN buttons just handled (they close it from
// their side first — the two handlers race on event order): it is
// the menu's click, never a fly-here. The button and this handler
// fire on the SAME input pass (same frame — this.time.now is
// identical), so "same frame AND inside the panel's footprint"
// isolates that exact click without swallowing the player's next
// deliberate one (a click on the rock, to re-open the menu).
if (
this.miningPopup &&
this.miningPopup.closedByButtonAt !== null &&
this.time.now - this.miningPopup.closedByButtonAt < 20 &&
this.miningPopup.containsScreen(pointer.x, pointer.y)
) {
return;
}
// COMM PANEL OPEN (js/ui/CommsPanel.js) — the same contract as the
// mining menu: a click on one of its buttons is the button's (its
// own pointerdown listener); a click INSIDE it is swallowed (no
// fly-here); a click on ANOTHER planet/station moves the panel
// there (the ship does not fly — the click is the panel's, not a
// world move); any other click closes it and that click is
// consumed.
if (this.commsPanel && this.commsPanel.isOpen) {
if (this.commsPanel.contains(pointer.worldX, pointer.worldY)) return;
const obj = this.worldObjectAt(pointer.worldX, pointer.worldY);
if (obj) {
this.openCommsPanel(obj, pointer);
return;
}
// A gate click re-anchors the gate window on that gate (the same
// "another world moves the panel" contract).
const gt = this.gateAt(pointer.worldX, pointer.worldY);
if (gt) {
this.openGateCommsPanel(gt, pointer);
return;
}
this.commsPanel.close();
return;
}
// A click the panel's OWN buttons just handled (they close it from
// their side first — the two handlers race on event order): it is
// the panel's click, never a fly-here (the same same-frame test as
// the mining menu's button guard below it).
if (
this.commsPanel &&
this.commsPanel.closedByButtonAt !== null &&
this.time.now - this.commsPanel.closedByButtonAt < 20 &&
this.commsPanel.containsScreen(pointer.x, pointer.y)
) {
return;
}
// A rock under the cursor: clicking an asteroid opens the mining
// menu (or the stop menu, when this cluster is the beam's target)
// — this click does not fly the ship.
const rock = this.rockAt(pointer.worldX, pointer.worldY);
if (rock) {
this.openMiningMenu(rock, pointer);
return;
}
// A JUMP GATE under the cursor (solid — the ship can't pass
// through it): the click opens the GATE COMM WINDOW
// (openGateCommsPanel) — the LINK line reads ACTIVE / DORMANT (or
// the FACTION LOCK's message — the gate is from a neutral system
// into a faction-controlled one and the player is below the lock's
// minLevel — data/gates.json → jump.factionLock), REQUEST JUMP
// initiates the transport (GameScene.jumpThroughGate,
// data/gates.json → jump; a grayed ghost while the gate is
// dormant or locked) and CANCEL closes the window. Like a planet
// click it is NOT a fly-here — the ship stays put.
const gateObj = this.gateAt(pointer.worldX, pointer.worldY);
if (gateObj) {
this.hideHint();
this.openGateCommsPanel(gateObj, pointer);
return;
}
// A PLANET or STATION under the cursor (a rock click is the mining
// flow above): the click opens the comms panel (the landing-request
// window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a
// fly-here (the ship stays put), and it does not end the mining
// state (no move, so a live beam keeps running).
const obj = this.worldObjectAt(pointer.worldX, pointer.worldY);
if (obj) {
this.hideHint();
this.openCommsPanel(obj, pointer);
return;
}
// Any OTHER click MOVES the ship — which ends the mining state
// (the beam retracts as the ship goes; a mid-reach arm aborts).
// The state exit is signalled via ship.onStateChange above.
if (this.mining.isActive) this.mining.stop();
// The stop point a plain click would have taken: off every solid's
// keep-out rim, clamped to the player's tether range.
let aim = { x: pointer.worldX, y: pointer.worldY };
for (const s of this.solids) {
aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
}
aim = this.tetherField.clampPoint(aim.x, aim.y);
// SHIFT HELD = THROTTLE LOCK: the ship commits to the heading
// toward the click point (raw — the direction the player pointed,
// not the stop point) and keeps flying that way — past the point
// and on, full throttle — until the player steers it somewhere
// else. The world still owns the motion: solids and the tether
// rim hold it back as it drives into them (postupdate constraint).
// (pointer.event is the DOM pointerdown — the press that just
// landed, with its modifier keys.)
if (pointer.event?.shiftKey) {
this.showTargetMarker(aim.x, aim.y); // the aim feedback
this.ship.thrustToward(pointer.worldX, pointer.worldY);
this.hideHint();
return;
}
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
});
// ESC: the topmost open thing closes — the confirm dialog, then the
// save pop-up, then the sub-bar.
this.input.keyboard?.on('keydown-ESC', () => this.escAction());
// KEYS 1-8 — THE SKILL SEATS. The bar's seats are the same eight addresses, so
// the key press runs the SAME door as a click (castSkillSeat → the fight's
// cast(), which answers with a reason the console reads out), and while the
// skill tree is holding a skill up to be placed the digits PLACE it instead.
this.input.keyboard?.on('keydown', (e) => this._skillKeyAction(e));
// The solid keep-outs run AFTER the arcade world has integrated the
// ship's motion (the scene's 'postupdate' fires after the physics step
// has moved the sprite). Constrained in update() instead, the body
// re-applies this frame's inward velocity right after the push-out —
// the ship would dip a frame-deep into every rim it meets at speed.
this.events.on('postupdate', this.onPostUpdate, this);
// SYSTEM EFFECT (the star's character — js/visuals/SystemEffects.js):
// data-driven per type (data/systems.json → effect). "none" (the
// common case) attaches nothing — the scene renders exactly as
// before. A live effect (nebula's ripple) splits the cameras first
// (world pass carries the filter; the UI pass draws the HUD on top,
// unfiltered — js/visuals/UiCameras.js), then attaches the filter
// to the world camera.
this.systemEffects = new SystemEffects(this);
this.systemEffects.apply(this.systemRecord.type);
// ---- COMMS: the fresh-run welcome ----------------------------------
// The 'newGame' trigger (data/comms.json) arms the run's opening
// comm — OAC introducing the ship (ack: the action pauses, the box
// holds, the player reads + clicks ACKNOWLEDGE). It fires on a FRESH
// start only (above: _freshRun) — a LOAD's played ledger already
// holds its id, so a resumed run doesn't re-hear the welcome. The
// hub's delayMs beats the opening toast, and its deferred load pulls
// OAC's clip + speech on demand (the scene's preload stays lean).
if (this._freshRun) this.comms.trigger('newGame');
// A loaded run that ALREADY crossed the 200-mineral line (a save from
// before the 'minerals200' beat existed) arms it here too — on a fresh
// run totalMined is zero so this is a no-op; the hub's ledger /
// in-flight guards keep the beat a one-shot either way.
if ((this.totalMined ?? 0) >= 200) this.comms.trigger('minerals200');
// A loaded run that ALREADY holds discovered worlds (a save from
// before the 'firstWorld' beat existed) arms it here too — on a fresh
// run the count is zero so this is a no-op; the hub's ledger /
// in-flight guards keep the pair a one-shot either way.
if ((this.discoveredWorldCount?.() ?? 0) >= 1) this.comms.trigger('firstWorld');
// The 'Leaving the Nest' beats (Trestle's briefing + arrival —
// data/comms.json: trestle-brief-1/2/3 / trestle-arrive-1/2): the
// scene-create arm covers the loaded-save case — a save parked at
// the capital world (the arrival's witness) or one whose home gates
// were already online (the briefing's) still gets its beats.
// _trestleBeats is the guard (quest held + unclaimed) and the comm's
// once-ledger dedupes the rest (a mid-sequence quit re-arms only the
// unplayed beats).
this._trestleBeats();
// The 'Pirate Problems' warning (data/comms.json: trestle-q4-1/2/3,
// trigger 'trestleQ4') — the same arm for a loaded save that holds
// the quest unclaimed: the comm's once-ledger dedupes the already-
// heard beats (a mid-sequence quit re-arms only the unplayed ones),
// and the quest guard (_trestleQ4Beats) keeps the beats off the
// player before the grant.
this._trestleQ4Beats();
// The 'Pirate Problems' AMBUSH — the ENTER of the Neutral Capital's
// system (the scene was just built for it): the ship is at the entry
// gate (a full gate→capital stretch from the world), so the wave
// starts here only when the spawn already sits within the MIDPOINT
// (a degenerate short stretch) — otherwise update() arms it the
// frame the flight crosses the halfway point (the same method's
// guards are no-op-gated either way). The third arm is the grant
// itself (_questStartBeats — a save whose grant landed while the
// player was already IN the system, past the midpoint or not).
// When it fires, its ships materialize OFF-SCREEN around the player,
// each from a random direction (CombatSystem._spawnWaveEnemy), and
// converge on their own chase AI.
this._ambushSpawn = this.ship ? { x: this.ship.x, y: this.ship.y } : null;
this._maybeStartPirateAmbush();
}
/**
* Post-physics constraints: every solid in the system (worlds AND
* asteroid clusters) holds the ship back at its clearance — fly close,
* never through — and the tether holds the player's range (clamped to
* the union boundary, the line shudders where it was hit).
*
* THROTTLE LOCK (Shift+click): a thrusting ship is the one mover that
* deliberately drives INTO those constraints (it never brakes on its
* own) — so the first frame any constraint actually touches it, the
* ship stops dead right there: the hold ends, the world wins.
*/
onPostUpdate(_time, delta) {
let touched = false;
for (const s of this.solids) {
if (s.constrainShip(this.ship, this.ship.radius)) touched = true;
// The combat pack (the ?combat demo) keeps the same keep-out as the
// ship — the solids push the hostiles off their rims too.
if (this.combat) {
for (const e of this.combat.enemies) {
if (!e.dead) s.constrainShip(e, e.hitRadius);
}
}
}
const tether = this.tetherField.constrainShip(this.ship);
if (tether) {
touched = true;
this.onTetherContact(tether); // the line shudders where it was hit
}
if (touched && this.ship.state === 'thrust') {
this.ship.stop(); // brake dead at the obstacle
this.ship.setState('normal', 'contact'); // the hold is over — back to normal
}
}
/**
* The ?combat boot MODE (a dev URL parameter, like ?fx= / ?type=):
* ?combat → 'demo' (the seven-type showcase waves)
* ?combat=apex → the APEX duel arena (frames 30–37)
* ?combat=mindweave → the MINDWEAVE duel arena (frames 40–47)
* ?combat=zenith → the ZENITH duel arena (frames 50–57)
* Any id present in data/combat.json → combat.factions is a valid
* faction boot (the roster is data — a new faction needs no code
* change here); a bare ?combat is the classic boot, and anything
* unknown is treated as the demo so a typo still shows combat.
* @returns {string|null} 'demo' | a combat.factions id | null
*/
_combatBootMode() {
if (config.get('combat.enabled', true) === false) return null;
if (typeof location === 'undefined') return null;
const v = new URLSearchParams(location.search).get('combat');
if (v === null) return null;
if (factionIds().includes(v)) return v;
return 'demo';
}
/**
* The ?neutral boot (a dev URL parameter, like ?combat): a NORMAL game
* with a NEUTRAL-ONLY encounter live from the start (two pilots, a short
* escort window) — the test stand for the neutral hum + the pilot's scan
* / scan-complete comms (see the block in create()).
* @returns {boolean}
*/
_wantsNeutralDemo() {
if (config.get('combat.enabled', true) === false) return false;
if (typeof location === 'undefined') return false;
return new URLSearchParams(location.search).has('neutral');
}
/**
* The action-deck swap (combat mode, Diablo 4 style — one bar in the
* same place, depending on the mode): combat LIVE → the combat bar up
* and the deck down; combat over → back to the deck.
* The COMBAT COMMS ride this same flip (data/comms.json →
* oac-enemies-detected / oac-enemies-defeated, both visual, both
* once:false): live → OAC's contact call (the wave run's lead — before
* the first hostile lands — or the ?combat pack's spawn); over (the
* last hostile down) → OAC's sign-off (its delayMs, 1 s, lands it
* after the final explosion's read). The hub's in-flight guard + the
* played ledger keep each flip to one voice. A NEUTRAL-ONLY encounter
* (neutral — the seam's second argument: escorts in, zero hostiles)
* swaps the soundtrack for the neutral hum INSTEAD, and OAC's
* hostile-signature beats stay silent (there's no hostile signature —
* the pilot's own scan comms carry the encounter).
* @param {boolean} active
* @param {boolean} [neutral] the live roster is a neutral-only encounter
*/
_setCombatBar(active, neutral = false) {
if (this.actionBar) this.actionBar.setVisible(!active);
if (this.combatBar) this.combatBar.setVisible(active);
this.setCombatMusic(active, neutral); // the soundtrack swap (combat/neutral ↔ deep-space)
if (active) {
this._combatWasNeutral = neutral;
if (!neutral) this.comms?.trigger('combatStart');
} else {
if (!this._combatWasNeutral) this.comms?.trigger('combatEnd');
this._combatWasNeutral = false;
}
}
/**
* The galaxy comes from the shared registry (built by the menu from the
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
* fresh dev galaxy so the scene always works standalone — with a
* deterministic seed when one is injected (`globalThis.__ORBIT_DEV_SEED`,
* set by dev/smoke-game.mjs from ?seed=…).
*/
ensureGalaxy() {
this.galaxy = this.registry.get('galaxy') ?? null;
if (!this.galaxy) {
const devSeed = typeof globalThis !== 'undefined' ? globalThis.__ORBIT_DEV_SEED : null;
const seed = devSeed && String(devSeed).length > 0 ? String(devSeed) : Rng.randomSeedString(8);
this.galaxy = Galaxy.create(seed);
this.registry.set('galaxy', this.galaxy);
this.registry.set('seed', seed);
if (!devSeed) console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`);
}
}
/**
* The effects demo's archetype (js/visuals/SystemEffects.js): read the
* `?fx=` URL parameter (browser only — no-op in Node/tests) and keep it
* only if it names a known system type (data/systems.json → types),
* matched case-insensitively (so `?fx=redDwarf` resolves to the camelCase
* `redDwarf` key). `?fx` alone defaults to `nebula`. The live families
* today: nebula, redDwarf, binary.
* Example: http://localhost:8080/?fx=redDwarf
*
* @returns {string | null} a systems.types key, or null
*/
_readFxSystemType() {
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
if (!loc?.search) return null;
try {
const raw = (new URLSearchParams(loc.search).get('fx') ?? '').trim() || 'nebula';
const types = config.section('systems.types', {});
if (!types || typeof types !== 'object') return null;
// Case-insensitive match on the config keys, so `?fx=redDwarf`,
// `?fx=reddwarf`, `?fx=REDWARf` all resolve to the `redDwarf` type.
const wanted = raw.toLowerCase();
for (const key of Object.keys(types)) {
if (key.toLowerCase() === wanted) return key;
}
return null;
} catch {
return null;
}
}
/**
* The world preview's spec (js/galaxy/TypeSystems.js): read the
* `?type=` URL parameter (browser only — no-op in Node/tests) and
* parse it into a { kind, cls?, frame? } spec — a planet class
* (terran/rocky/gas/ice/lava) with an optional 1-based face ordinal
* (terran-02 = the 2nd face of the class's pool), or a station
* (ss / ss-NN variant). Absent → null (no preview); malformed →
* null (with a note — `this._typeSpecRaw` keeps the raw value for
* the message). Case-insensitive throughout.
*
* Examples: http://localhost:8080/?type=terran-01 …or ?type=ss-02 …or ?type=ice-03
*
* @returns {object | null} a parseTypeSpec() result, or null
*/
_readTypeSpec() {
this._typeSpecRaw = null;
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
if (!loc?.search) return null;
try {
const raw = new URLSearchParams(loc.search).get('type');
if (raw === null) return null;
this._typeSpecRaw = raw;
const spec = parseTypeSpec(raw);
if (!spec) {
const classes = Object.keys(config.get('planets.frames', {})).join('/');
console.warn(`[orbit] ?type=${raw} — unknown world (try ${classes} or -NN faces) or station (ss / ss-NN).`);
}
return spec;
} catch {
return null;
}
}
/**
* The world preview's TARGET entity (the planet/station the ship
* spawns beside) — resolved from this.typeTarget now that the
* system's entities exist. null = no preview target (or the entity
* is missing — the home-world spawn fallback applies).
*/
_previewEntity() {
const t = this.typeTarget;
if (!t) return null;
if (t.station) {
// Up to two deep-space stations per system — match the entity
// to the settlement record by id (name-safe).
const rec = (this.systemContent?.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation');
if (!rec) return null;
return (this.systemStations ?? []).find((s) => s.settlement?.id === rec.id) ?? null;
}
if (t.ordinal === 0) return this.planet ?? null; // the home world
const rec = (this.systemContent?.planets ?? [])[t.ordinal - 1];
if (!rec) return null;
return (this.systemPlanets ?? []).find((p) => p.discoveryName === rec.name) ?? null;
}
/**
* Top-left HUD: the current system's dossier — the system name, its
* identity ("<Type> system · star <class>"), and its standing: faction
* control (Neutral for now) + total population ("Unclaimed" when none).
* Deliberately compact — the per-settlement and gate detail lives
* elsewhere (star chart, map, the content itself).
* Formatted by the pure SystemReport helper; this method only renders.
*
* The dossier types itself in with the menu's decode scramble
* (js/utils/Decode.js): the system name first, then each data line below
* it, staggered — the console acquiring a signal as the ship arrives.
* A small caret sits to the RIGHT of the name — pointing DOWN while the
* details are open, RIGHT while they're folded — and the name (or the
* caret) is the click target that toggles them (toggleHud):
*
* arrive ──► OPEN by default (name decodes, caret fades in below it…
* beside it), details type in
* │ after 10 s (HUD_AUTO_COLLAPSE_MS) — OR a click on the name
* ▼
* details DECONSTRUCT in reverse build order (status line
* first … subtitle last), then the caret swings down→right
* │
* ▼ a click on the name (or the caret)
* the caret swings right→down, the details type back in
*
* Any manual toggle cancels the one-shot auto-fold — the player took
* control of the dossier. updateHud drives it all (anchored to the
* first frame after create — the scene's TimeClock is still stale in
* create()). It runs every time the scene is created: the first start
* and every new solar system.
*
* The system's CONTENTS were ensured in create() (ensureGalaxy +
* ensureContent) — the first touch of the lazy level-2 generation.
* (Roster/positions were fixed at the menu's New Game click; this is
* just the "build the room" part.)
*/
createSystemHud() {
const report = formatSystemReport(this.systemContent);
const fam = BODY_FONT();
const famHeader = HEADER_FONT();
const current = this.systemRecord;
const X = 16;
let y = 14;
// --- The name: the dossier's anchor and its toggle -----------------
const titleStyle = {
fontFamily: famHeader,
fontSize: '17px',
// v4 quirk: text colors must be CSS strings (see toCss, utils/Color.js).
color: toCss(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
letterSpacing: 2,
};
const title = this.add
.text(X, y, report.title, titleStyle)
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
const titleW = title.width; // measure the FINISHED name once…
const titleH = title.height;
title.setText(''); // …then let the decode type it out
this.hudTitle = title;
// The state caret — a small triangle to the right of the name:
// pointing down (▾) = details open, right = folded. It fades in once
// the name has landed and swings with the state (rotateCaret).
const caret = this.add
.text(X + titleW + 10, y + titleH / 2, '\u25be', {
fontFamily: fam,
fontSize: '14px',
color: '#8fa0c9',
})
.setOrigin(0.5)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30)
.setAlpha(0);
this.hudCaret = caret;
this.hudCaretShown = false;
// Click target: the name plus the caret beside it (screen space — the
// dossier is pinned UI, so local space == screen space).
this.hudTitleRect = { x: X - 4, y: y - 4, w: titleW + 24, h: titleH + 8 };
y += 26;
// --- The details (open by default) ----------------------------------
const detail = [];
const line = (value, style) => {
const t = this.add
.text(X, y, '', style)
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
detail.push({ text: t, value });
y += 20;
};
line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#ffffff', letterSpacing: 1 });
y += 2;
line(report.status, { fontFamily: fam, fontSize: '13px', color: toCss(report.statusColor, '#8fa0c9') });
this.hudDetail = detail;
this.hudEndY = y + 6; // bottom of the dossier block
// --- State + the arrival timeline (anchored on the first frame) -----
this.hudPhase = 'constructing'; // 'constructing' | 'expanded' | 'collapsing' | 'collapsed'
this.autoCollapseArmed = true; // the one-shot 10 s auto-fold
this.hudArrivalT0 = null;
this.hudTimeline = {
mode: 'arrive', // the name first, then the data below it
t0: null,
lines: [
{ text: title, value: report.title, isTitle: true },
...detail,
].map((ln, i) => ({
...ln,
delay: HUD_LEAD_IN + i * HUD_STAGGER,
dur: decodeDur(ln.value.length),
settled: false,
})),
};
// ---- The QUEST TRACKER — the thin quest list right below the name --
// Slides in from offscreen-left the moment the dossier has FOLDED
// (the one-shot auto-fold, or the player's manual fold — the first
// fold arms it; visible ⇔ the dossier is folded). It is a VIEW over
// the same live split as the console (questSnapshot — the checklist
// ticks over as research/mining/builds/discovery complete) and the
// same ledger (questState — the ONE priority quest). updateHud shows
// it when a collapse lands; startExpand slides it out first (make
// room); GameScene.update drives its per-frame poll.
this.questTracker = new QuestTrackerHud(this, {
anchorX: X,
anchorY: 40, // just below the dossier title (where the details start)
getSnapshot: () => this.questSnapshot(),
resolvePriority: (ids) => this.questState.resolvePriority(ids),
onSetPriority: (id) => this.setPriorityQuest(id),
onClearPriority: () => this.clearPriorityQuest(),
onViewQuest: (id) => this.openQuestAt(id),
onShowMore: (id) => this.openQuestAt(id),
onClaim: (id) => this.claimQuest(id),
});
}
/**
* The dossier's lifecycle, driven every frame (updateHud is called from
* update() with the engine's loop time — the scene's TimeClock is the
* only clock that runs, see the v4 quirk in update()):
* - the active timeline (arrive / expand / collapse) reveals or
* erases its lines one by one with the shared decode scramble;
* - on arrival the name lands first and the state caret fades in
* beside it;
* - on a collapse the LAST line built erases first (status →
* subtitle), then the caret swings down→right;
* - the one-shot 10 s auto-fold fires once (cancelled by any manual
* toggle).
*/
updateHud(time) {
if (this.hudArrivalT0 === null) {
// First frame after create(): anchor the arrival timeline here
// (this is the same time base update() receives; the scene's
// time.now is still stale).
this.hudArrivalT0 = time;
this.playSfx('construct'); // the dossier starts typing in
const tl = this.hudTimeline;
tl.t0 = time;
for (const ln of tl.lines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur);
}
const tl = this.hudTimeline;
if (tl) {
let done = true;
for (const ln of tl.lines) {
if (ln.settled) continue;
if (!ln.dec.started(time)) {
done = false;
continue;
}
ln.text.setText(ln.dec.display(time));
if (ln.dec.finished(time)) {
ln.settled = true;
if (ln.isTitle) this.showCaret(); // the name has landed — the state is legible
} else done = false;
}
if (done) {
this.hudTimeline = null;
if (tl.mode === 'collapse') {
this.hudPhase = 'collapsed';
this.rotateCaret(true); // details gone → caret swings to point right
// The dossier has folded — the tracker slides in from offscreen
// left (the first fold arms it; a later fold re-shows it after
// startExpand slid it out to make room).
this.questTracker?.show(this.time.now);
} else {
this.hudPhase = 'expanded';
}
}
}
// The open-by-default dossier folds itself 10 s after arrival —
// unless the player already toggled it (autoCollapseArmed).
if (
this.autoCollapseArmed &&
this.hudPhase === 'expanded' &&
!this.hudTimeline &&
time >= this.hudArrivalT0 + HUD_AUTO_COLLAPSE_MS
) {
this.autoCollapseArmed = false;
this.startCollapse(time);
}
}
/** The state caret fades in once the name has landed (arrival only). */
showCaret() {
if (this.hudCaretShown) return;
this.hudCaretShown = true;
this.tweens.add({ targets: this.hudCaret, alpha: 1, duration: 160, ease: 'Sine.easeOut' });
}
/** The caret swings with the state: down (0) = open, right (−90°) = folded. */
rotateCaret(toRight) {
this.tweens.add({
targets: this.hudCaret,
angle: toRight ? -90 : 0,
duration: 220,
ease: 'Sine.easeInOut',
});
}
/**
* Re-open the details: the caret swings back down first, then the lines
* type back in in build order (subtitle → status) — the same decode as
* arrival, just without the name (it never left).
*/
startExpand(t) {
const begin = (tt) => {
this.hudPhase = 'constructing';
this.playSfx('construct'); // the details type back in
this.rotateCaret(false);
this.hudTimeline = {
mode: 'expand',
lines: this.hudDetail.map((ln, i) => ({
text: ln.text,
value: ln.value,
settled: false,
dec: new ScrambleDecode(ln.value, tt + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)),
})),
};
};
const trk = this.questTracker;
if (trk && (trk.isBusy() || trk.shown)) {
// Make room FIRST: the tracker slides offscreen-left, then the
// dossier unfolds (its lines would paint straight over the list).
trk.hide(this.time.now, () => begin(this.time.now));
} else {
begin(t);
}
}
/**
* Fold the details: the lines DECONSTRUCT in reverse build order (status
* line erases first … subtitle last) — the same scramble played
* backwards — and the caret swings down→right once the last one is gone
* (updateHud, when the timeline completes).
*/
startCollapse(t) {
this.hudPhase = 'collapsing';
this.playSfx('deconstruct'); // the details deconstruct
this.hudTimeline = {
mode: 'collapse',
lines: [...this.hudDetail].reverse().map((ln, i) => ({
text: ln.text,
value: ln.value,
settled: false,
// reverse = true: value → shrinking prefix → static → ''
dec: new ScrambleDecode(ln.value, t + i * HUD_STAGGER, decodeDur(ln.value.length), true),
})),
};
}
/**
* The player clicked the system name (or its caret): toggle the details.
* Ignored mid-animation — let the current construct/deconstruct finish.
* A manual toggle also cancels the one-shot auto-fold.
*/
toggleHud() {
if (this.hudTimeline) return;
if (this.questTracker && this.questTracker.isBusy()) return; // a slide is in flight — let it land
this.autoCollapseArmed = false;
if (this.hudPhase === 'expanded') this.startCollapse(this.time.now);
else if (this.hudPhase === 'collapsed') this.startExpand(this.time.now);
}
/** Is (px,py) on the dossier's toggle target — the name or the caret beside it? */
hudTitleContains(px, py) {
const r = this.hudTitleRect;
return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
update(_time, delta) {
// Phaser v4 (Giedi): the engine does not step the scene's TimeClock or
// TweenManager — drive them here or delayedCall/addEvent/tweens never
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
this.time.update(_time, delta);
this.tweens.update();
this.comms?.pump(_time); // comms: queue → play (and its pause contract)
this.levelMarquee?.update(_time, delta); // the level-up beat (drives its own timeline)
// The ACK pause contract (the CommsHub froze the action): the ship
// holds station, the research/build ticks wait (their deadlines are
// time-based — a project that finishes while the box holds completes
// on the first tick after, its toast landing when the action resumes),
// and discovery is off (the ship isn't moving). The world KEEPS
// breathing — starfield, tether, the comm's own clip — only the
// player's action is paused.
const _commsPaused = this.comms?.paused === true;
// The level-up marquee's freeze (js/ui/LevelUpMarquee.js): the beat
// owns the screen, so the action holds while it plays — the same
// rule as the ack pause, one frame of the world standing still.
// The JUMP CLIP's freeze (GameScene._playJumpClip): the transport
// owns the screen (opaque backdrop + the gate video) for the clip's
// length, so the action holds the same way — the ship parks, the
// fight waits, the ticks stand down — and restart() lands the
// destination the moment it ends (the deadlines are time-based, so
// a project that finished mid-clip completes on the first tick
// after, exactly like the ack pause's completion path).
// The CLASS CONSOLE's freeze (js/ui/ClassWindow.js): picking a class is a
// decision, not an overlay on top of the fight — the world holds exactly
// the way it does for a comm's ACK and the level-up marquee.
// The SKILL TREE console's freeze (js/ui/SkillWindow.js): ranking skills
// and binding seats is a decision the same way — the world holds behind
// it (the cabinet idiom), and its clicks never steer the ship.
const _frozen =
_commsPaused ||
this.levelMarquee?.active === true ||
!!this._jumping ||
this.classWindow?.isOpen === true ||
this.skillWindow?.isOpen === true ||
this._gameOver === true; // the Game Over beat owns the screen (the run is over)
// The freeze's PARKING (the ack pause's onPause is the same contract
// for its own freeze): while ANY freeze holds, the action is parked
// dead EVERY frame — the ship (target + throttle cleared, velocity
// and acceleration zeroed) and the combat actors' bodies. The Arcade
// world keeps stepping through a freeze (only the drivers —
// ship.update / combat.update — stand down), so anything that still
// injects motion (a coasting body, a collision response, a body that
// was set after the freeze began) is killed the very next frame.
// Resuming a freeze needs no work (the ship stays parked; the next
// input steers it again — the ack pause's same rule).
// The skill console repaints on its own signature (points spent, a seat
// bound) — including while the world is parked behind it.
if (this.skillWindow?.isOpen) this.skillWindow.refresh();
if (_frozen) this._parkForFreeze();
// The deck goes to STANDBY while an 'ack' comm holds (the pause
// contract's face): the slots dim and stop hovering/pressing, so the
// pulsing ACKNOWLEDGE button is the only live control on screen.
// setEnabled is a no-op when the state is unchanged (per-frame free),
// and re-arms the frame the comm ends.
this.actionBar?.setEnabled(!_frozen);
this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle
this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
this.questTracker?.update(_time); // the tracker: live checklist + priority
if (!_frozen) this.ship.update(_time, delta);
// COMBAT (the ?combat demo): the pack's AI + lances, the ship's
// auto-fire, the tracers, the rams, the player's armor — frozen by an
// ACK comm's pause, like the ship.
if (this.combat) {
// The settle window (above): one-shot re-anchor of the pack while
// the ship's start position settles; a no-op once anchored.
if (this._combatSettleFrames > 0) {
this._combatSettleFrames--;
if (this.combat.resyncAroundShip()) this._combatSettleFrames = 0;
}
if (!_frozen) {
this.combat.update(_time, delta);
this.duelPicker?.update(_time); // the faction duel's picker (hidden while its fighter is up)
this.combatBar?.setStats(this.combat);
this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash)
// THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit
// returns the composition to spawn — off-screen, streamed in by
// their own AI (the clock is pure; the scene stays the spawner).
// FACTION SPACE: the context (the controller's rivals, with their
// rosters) turns the roll into an opposing-faction attack — never
// pirates or neutrals — and the local faction's fighters join us.
const fctx = this._encounterContext();
const comp = this.encounters.update(_time, {
allowed:
encounterAllowed({
ship: this.ship,
isHomeSystem: this.isHomeSystem,
faction: this.systemRecord ? this.systemRecord.faction : null,
factionContext: fctx,
waveRunLive:
!!this.combat.waveRun &&
this.combat.waveRun.state !== 'done' &&
this.combat.waveRun.state !== 'cancelled',
playerDown: this.combat.playerDown,
}) && !this._encountersLockedByQuest(), // the 'Special Delivery' hold
context: fctx,
live: this.combat.active,
xpLevel: this.combat.xp.level,
});
if (comp) this._spawnEncounter(comp);
}
}
this.engineFx?.update(_time, delta); // burn fades even through a pause
// Session time (saved with the game) — capped so a backgrounded tab
// can't fast-forward it.
this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100);
// The save UI (the sub-bar + the pop-up) drive their own per-frame
// state (folds, decodes, toasts, the confirm dialog).
this.menuSubBar?.update(_time, delta);
this.savePanel?.update(_time);
this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker
// Research: tick the in-flight project (time-based; tick() reports any
// completion and already unlocked it — the scene applies effects/SFX).
// Paused by an ACK comm (above) — the tick resumes after, and the
// completed project's effects land then (the toast, the deck bar).
const _researchDone = !_frozen && this.researchState ? this.researchState.tick(_time) : null;
if (_researchDone?.length) {
for (const d of _researchDone) this._completeResearch(d.category, d.id);
}
// The starter quest's pointing finger — applied before the surfaces
// draw (their update loops paint the rim's pulse from the target).
this._applyTutorialHighlight();
this.researchWindow?.update(_time); // the console's living details (open state)
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
this.shipWindow?.update(_time); // the ship console: reveal, decodes, sweep, telemetry poll
this.classWindow?.update(_time); // the class console: the brief's typing, feed sweep, decodes
this._classBeat(); // THE LEVEL-5 DOOR: opens when combat goes quiet
this._skillDoorBeat(); // ?skilltree asks for the console once the deck is calm
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
// Builds: a build started on a surface keeps running in space if the
// player took off mid-build (game-loop clock — see beginBuild); when
// its deadline passes HERE we apply the effect (the world change is
// world state: the tether range outlives the stay).
if (this.buildState && !_frozen) {
const _buildDone = this.buildState.tick(this.game.loop.now);
if (_buildDone.length) {
for (const c of _buildDone) this.completeBuild(c.planet, c.build);
this.playSfx('discovery'); // the 'something new is here' voice
}
}
// The clusters are ALIVE: each rock tumbles, the loose group drifts,
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
// after the physics step has moved the ship.)
for (const c of this.asteroidClusters) c.update(_time);
// The route's NEXT STOP in this system wears the route pulse
// (js/entities/RoutePulse.js — the in-world "your destination is
// HERE" mark, visible the moment the target is on screen; the
// compass's arrow is the off-screen half), in the mark's color —
// the route's ORANGE, or BROWN for the player's own cargo delivery
// (_routeMarkColor): in an intermediate
// system it is the system's JUMP GATE toward the destination
// (routeNextGate — the FULL pulse, route.pulse); in the DESTINATION
// system it is the destination OBJECT (a planet or a space station —
// the subdued pulse, route.objectPulse; _routeDestinationEntity).
// Every other object drops it. Both marks are null when no
// destination is set or the route is done.
const _routeGate = this.routeNextGate();
const _routeDest = this._routeDestinationEntity();
const _markColor = this._routeMarkColor(); // the pulse follows the mark (cargo brown / route orange)
if (this.planet) this.planet.setRouteTarget(this.planet === _routeDest, _markColor);
for (const p of this.systemPlanets) p.setRouteTarget(p === _routeDest, _markColor);
for (const st of this.systemStations) st.setRouteTarget(st === _routeDest, _markColor);
for (const gt of this.systemGates) gt.setRouteTarget(gt === _routeGate, _markColor);
for (const p of this.systemPlanets) p.update(_time); // the route pulse (a no-op when unmarked)
for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes, the route pulse
for (const gt of this.systemGates) gt.update(_time); // the swirl spins + breathes (active gates); fallback art drifts
this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring
this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle
this.updateSignalCompass(_time); // the secondary compass: bearing signals on the ring
this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion
this.nebula?.update(); // the gas: parallax + slow self-swirl
this.haze?.update(_time, delta); // redDwarf red haze: follow the camera
this.voidBase?.update(_time, delta); // binary void base: follow the camera
this.storm?.update(_time, delta); // habitable storm: parallax + charge/strike/afterglow
this.nebulaLightning?.update(_time, delta); // nebula: the far-sky bolts (strobe + draw/clear)
this.embers?.update(_time, delta); // redDwarf ember dust: drift + parallax
this.wanderer?.update(_time, delta); // binary companion star: slow wander
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
this.tetherField.draw(_time); // the barrier (on-screen dots only)
this.actionBar?.update(_time, delta); // the deck's living details
this._checkDestinationReached(); // last leg done? clear the destination (REACHED)
// 'Pirate Problems' step 2 — the AMBUSH's arm: with the quest live
// and its combat step open, the pirate wave starts the moment the
// ship is within HALF the entry-gate → capital-world stretch — the
// conflict begins halfway to the world from the jumpgate (the
// method is no-op-gated, so the per-frame cost is a few ledger
// reads + one distance check).
this._maybeStartPirateAmbush();
// 'Pirate Problems' step 3 — the RETURN's witness: the ship at the
// capital world's rim with the quest held + unclaimed sets
// story.capitalReturned (monotonic; _maybeWitnessCapitalReturn is a
// no-op once it stands, so the per-frame cost is a ledger read).
this._maybeWitnessCapitalReturn();
this._drawSideQuestMarkers(_time); // the active contract's beacons (patrol or hunt — the route record's kind picks)
this.updateSideQuestRoute(); // its check-ins + engagements (the compass + beacons read the fresh ledger this frame)
if (!_frozen) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
}
/**
* Park the action for a frozen frame (the level-up beat, the jump
* clip, the ack comm's pause — all three gate on _frozen in update
* above, and all three need this): the ship dead (stop() clears its
* target + throttle and zeros velocity/acceleration), and the combat
* actors' bodies zeroed too. The Arcade world still steps while the
* drivers (ship.update / combat.update) stand down, so un-parked
* bodies would keep gliding through the beat/clip — the visible
* "the action isn't actually paused" drift. Cheap + idempotent, so it
* runs every frozen frame (a velocity set mid-freeze dies next frame);
* resuming a freeze needs no work (the next input steers again).
*/
_parkForFreeze() {
try {
this.ship?.stop?.();
} catch {
/* the ship is optional (a scene mid-setup) — the park stands or falls with it */
}
const c = this.combat;
if (!c) return;
for (const list of [c.enemies, c.allies]) {
for (const a of list ?? []) {
const b = a?.body;
if (!b) continue;
if (typeof b.setVelocity === 'function') b.setVelocity(0, 0);
if (typeof b.setAcceleration === 'function') b.setAcceleration(0, 0);
}
}
}
/**
* The ship met the tether boundary: a small camera kick (impact), and a
* console warning — throttled so a held push against the line doesn't
* spam it. The line itself already shudders locally (TetherField pulse).
*/
onTetherContact(contact) {
const cam = this.cameras.main;
if (typeof cam.shake === 'function') {
cam.shake(80, 0.003 + Math.min(0.004, (contact.vn ?? 0) * 0.00002));
}
const now = this.time.now;
const cooldown = config.get('tether.contact.toastCooldown', 4000);
if (this.tetherToastAt !== null && now - this.tetherToastAt < cooldown) return;
this.tetherToastAt = now;
this.tetherToast();
}
/**
* The console note — the game's top-center signal slot. Discovery toasts,
* the tether warning, and the mining calls all live here: a small accent
* glyph + a console-caps label, fading in, holding, fading out. One at a
* time — a new note replaces any old one.
*
* @param {string} label the console line
* @param {object} [o] { glyph, glyphColor, durationMs, y (screen px; default ~75 above center) }
*/
consoleToast(label, { glyph = '\u25b8', glyphColor = '#00e5ff', durationMs = 2400, y } = {}) {
if (Array.isArray(this.consoleToastG)) {
for (const g of this.consoleToastG) g.destroy();
this.consoleToastG = null;
}
const fam = BODY_FONT();
const g1 = this.add
.text(0, 0, glyph, { fontFamily: fam, fontSize: '13px', color: toCss(glyphColor) })
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const g2 = this.add
.text(0, 0, label, {
fontFamily: fam,
fontSize: '11px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const total = g1.width + 10 + g2.width;
const x0 = this.scale.width / 2 - total / 2;
const yy = y === undefined ? this.scale.height / 2 - 75 : y; // ~75 px above the screen center
g1.setPosition(x0, yy).setDepth(45).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0);
this.consoleToastG = [g1, g2];
assignUi(this, g1); // the UI pass (no double-draw while the split is live)
assignUi(this, g2);
this.tweens.add({ targets: this.consoleToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(durationMs, () => {
if (!Array.isArray(this.consoleToastG)) return;
const [a, b] = this.consoleToastG;
this.consoleToastG = null;
this.tweens.add({
targets: [a, b],
alpha: 0,
duration: 350,
onComplete: () => {
a.destroy();
b.destroy();
},
});
});
}
/** Top-center console warning (magenta), high up, clear of the signal slot. */
tetherToast() {
this.consoleToast('TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', {
glyph: '\u26a0',
glyphColor: toCss(themeColor('neon2', 0xff2d6f)),
durationMs: 2600,
y: 54,
});
}
/**
* The camera chases the ship with a frame-rate-independent ease:
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
* While the ship flies, the camera trails it and the starfield streams
* past in the opposite direction (real parallax). When the ship stops,
* the camera keeps easing until it is centered on the ship again — a
* slow recenter (≈1.5 s at followRate 3) with the stars parallaxing
* along for the ride.
*/
updateCamera(delta) {
if (!this.cameraFollowShip || !this.ship) return;
const rate = this.cameraFollowRate;
if (!(rate > 0)) return;
const cam = this.cameras.main;
const k = 1 - Math.exp(-rate * (Math.min(delta, 64) / 1000));
cam.setScroll(
Phaser.Math.Linear(cam.scrollX, this.ship.x - this.scale.width / 2, k),
Phaser.Math.Linear(cam.scrollY, this.ship.y - this.scale.height / 2, k),
);
}
/**
* Discovery, every frame (a handful of distance tests — cheap):
* 1. Ship within discovery distance of a world's edge → DISCOVERED
* (once — state lives in this.discovery), with a rim ping + toast.
* 2. Discovered worlds that are off-screen feed the compass, which
* draws the themed screen-edge arrows pointing back to them.
*/
updateDiscovery(time, delta) {
const objects = this.discoverableObjects();
const sysId = this.systemRecord.id;
const fresh = this.discovery.check(sysId, this.ship.x, this.ship.y, objects);
for (const o of fresh) this.celebrateDiscovery(o);
// Which objects are NOT on screen right now? (The camera never
// zooms, so the world view is scroll + canvas size.)
const cam = this.cameras.main;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
// COMBAT LIVE (a hostile up, or a wave run in flight —
// this.combat.active, the same predicate that raises the combat bar):
// the edge ARROWS and name BOXES yield to the fight — the combat
// chrome (threat markers, off-screen hostile arrows, the kill feed)
// owns the screen edge while the player is shooting, and the nav
// chips would only compete with it. refresh([]) retires the entries;
// the first non-combat frame re-feeds them — the target set is
// recomputed here every frame, so nothing is lost (a mid-fight
// discovery still pings above; only the compass display steps aside).
if (this.combat?.active) {
this.compass.refresh([], view, this.scale.width, this.scale.height, time, delta, this.ship);
return;
}
// The active route's NEXT STOP. In an intermediate system it is that
// system's jump gate toward the destination (routeNextGate); in the
// DESTINATION system it is the destination OBJECT itself (the last hop
// is across the system to the world — routeDestinationObject). It
// claims a compass slot in the mark's color — the route's ORANGE
// (data/gates.json → route.compassColor), or BROWN (route.cargoColor)
// when the destination is the player's own cargo delivery — so the
// player always knows where to fly next — and is shown EVEN while
// still undiscovered (it is the thing to find). That object's ordinary
// (cyan/green) entry is suppressed so the two don't stack; when it is on
// screen there is no arrow at all (the player can see it) and the
// suppress is moot. The destination object stays the mark's color
// until the ship actually reaches it (_checkDestinationReached).
const routeColor = this._routeMarkColor(); // the mark's accent (cargo = brown, else the route's orange)
const routeGate = this.routeNextGate();
const routeGateId = routeGate?.discoveryId ?? null;
const routeOffscreen = !!(
routeGate && !circleInView(routeGate.x, routeGate.y, routeGate.bound, view)
);
// The final stop: only meaningful in the destination system (no next
// gate). It is the object the player set as the destination.
const destObj = routeGate ? null : this.routeDestinationObject();
const destObjId = destObj?.id ?? null;
const destOffscreen = !!(destObj && !circleInView(destObj.x, destObj.y, destObj.radius, view));
// The active PATROL's next stop (data/sidequests.json → patrol.compass —
// the YELLOW arrow, its own color off the route gate's orange):
// the NEXT unvisited waypoint (the route is a path — 1, 2, 3, …).
// In the route's own system it is the
// waypoint itself (plain coordinates — no discovery entry to
// suppress, and only off-screen: on screen the beacon is visible);
// in another system it is the jumpgate that jumps there — the yellow
// takes the gate's slot, so the gate's ordinary cyan entry is
// suppressed (the route gate's rule, a different color).
const patrolT = this.activeRouteCompassTarget();
const patrolGateId = patrolT?.kind === 'gate' ? patrolT.gate?.discoveryId : null;
// The ESCORT's destination world (its magenta arrow takes the world's slot —
// the route gate's suppression rule).
const patrolDestId = patrolT?.kind === 'destination' ? patrolT.objectId : null;
const offscreen = [];
for (const o of objects) {
if (o.id === routeGateId) continue; // the route arrow takes this slot
if (o.id === destObjId) continue; // the destination arrow takes this slot
if (o.id === patrolGateId) continue; // the patrol arrow takes this slot
if (o.id === patrolDestId) continue; // the escort destination arrow takes this slot
if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) {
offscreen.push(o);
}
}
if (routeOffscreen) {
offscreen.push({
id: `route:${routeGate.discoveryId}`,
x: routeGate.x,
y: routeGate.y,
radius: routeGate.bound,
typeLabel: config.get('gates.route.typeLabel', 'Route Gate'),
color: routeColor,
name: routeGate.discoveryName,
// The "next gate" is the one thing the player must find — keep it at
// the full readout (arrow at scale 1 + type + name) no matter how far
// it sits, and resolvable by autopilotTo (the `route:` id).
alwaysFull: true,
});
}
if (destOffscreen) {
offscreen.push({
id: `dest:${destObj.id}`,
x: destObj.x,
y: destObj.y,
radius: destObj.radius,
typeLabel: config.get('gates.route.destTypeLabel', 'Destination'),
color: routeColor,
name: destObj.name,
// The final stop — same always-on treatment as the next gate.
alwaysFull: true,
});
}
if (patrolT) {
if (patrolT.kind === 'gate' && patrolT.gate && !circleInView(patrolT.gate.x, patrolT.gate.y, patrolT.gate.bound, view)) {
offscreen.push({
id: `patrol:gate:${patrolT.gate.discoveryId}`,
x: patrolT.gate.x,
y: patrolT.gate.y,
radius: patrolT.gate.bound,
// The target carries its OWN colors when it stands (a hunt's arrow is
// the data's crimson, a patrol's the yellow); the patrol keys are the
// fallback, so an old route reads exactly as it always did.
typeLabel: patrolT.typeLabel ?? config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
color: patrolT.color ?? config.get('sidequests.patrol.compass.color', '#ffd60a'),
name: patrolT.gate.discoveryName,
// The patrol's next gate — the full readout (like the route gate),
// autopilot-resolvable (the `patrol:gate:` id's own branch).
alwaysFull: true,
});
} else if (patrolT.kind === 'destination' && patrolT.objectId && !circleInView(patrolT.x, patrolT.y, patrolT.radius ?? 40, view)) {
offscreen.push({
id: `escort:obj:${patrolT.objectId}`,
x: patrolT.x,
y: patrolT.y,
radius: patrolT.radius ?? 40,
typeLabel: patrolT.typeLabel ?? config.get('sidequests.escort.compass.typeLabel', 'Escort'),
color: patrolT.color ?? config.get('sidequests.escort.compass.color', '#e87cf5'),
name: patrolT.name ?? 'DESTINATION',
// The escort's final stop — the full readout (like the route's
// destination), autopilot-resolvable (the `escort:obj:` id's own
// branch: the world's real discovery id, not a waypoint index).
alwaysFull: true,
});
} else if (patrolT.kind === 'waypoint' && !circleInView(patrolT.x, patrolT.y, 32, view)) {
offscreen.push({
id: `patrol:wp:${patrolT.idx}`,
x: patrolT.x,
y: patrolT.y,
radius: 32,
typeLabel: patrolT.typeLabel ?? config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
color: patrolT.color ?? config.get('sidequests.patrol.compass.color', '#ffd60a'),
name: `WAYPOINT ${patrolT.idx + 1}`,
// The patrol's next point — the full readout (the thing to fly),
// autopilot-resolvable (the `patrol:wp:` id's own branch).
alwaysFull: true,
});
}
}
// The ship's position drives the compass's FAR display (targets
// beyond game.discovery.compass.farDistance fold to their compact
// chip + shrunken arrow until hovered — js/ui/DiscoveryCompass.js).
this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta, this.ship);
}
/** The discoverable objects of the system, with compass metadata. */
discoverableObjects() {
const out = [];
// The central body — the home world in the STARTING SYSTEM ONLY
// (discovery id 'home': the chart's central NAV point). Every other
// system's center is empty — its star is invisible flavor (content.star
// feeds the dossier, not the chart) — so no central NAV point there.
if (this.planet) {
out.push({
id: 'home',
x: this.planet.x,
y: this.planet.y,
radius: this.planet.radius,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
// The compass accent: the home world is a PLANET (green —
// data/planets.json → compassColor, like the system's worlds).
color: config.get('planets.compassColor', '#3dff88'),
name: this.planet.discoveryName,
});
}
for (const p of this.systemPlanets) {
out.push({
id: p.discoveryId,
x: p.x,
y: p.y,
radius: p.radius,
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
// The compass shows planets in green (data/planets.json →
// compassColor) — the arrow + name tag pick it up.
color: config.get('planets.compassColor', '#3dff88'),
name: p.discoveryName,
});
}
// Asteroid clusters count as objects too: discoverable, compass arrows,
// autopilot — at the scale of their extent (bound).
for (const c of this.asteroidClusters) {
out.push({
id: c.discoveryId,
x: c.x,
y: c.y,
radius: c.bound,
typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'),
// The compass shows clusters in their own light gray (their own
// accent — data/asteroids.json → compassColor) — the arrow +
// name tag pick it up.
color: config.get('asteroids.compassColor', '#c8d2e0'),
name: c.discoveryName,
});
}
// Space stations are objects too: discoverable, compass arrows,
// autopilot — and comms targets — at the scale of their keepout.
for (const st of this.systemStations) {
out.push({
id: st.discoveryId,
x: st.x,
y: st.y,
radius: st.bound,
typeLabel: config.get(`settlements.kinds.${st.kind}.label`, 'Station'),
// The compass shows stations in bright purple (data/stations.json →
// compassColor) — the arrow + name tag pick it up.
color: config.get('stations.compassColor', '#c84dff'),
name: st.discoveryName,
});
}
// Jump gates are objects too: discoverable, compass arrows, autopilot —
// in their own cyan (the exits, so easy to spot on the compass).
for (const gt of this.systemGates) {
out.push({
id: gt.discoveryId,
x: gt.x,
y: gt.y,
radius: gt.bound,
typeLabel: config.get('gates.typeLabel', 'Jump Gate'),
color: config.get('gates.theme.color', '#5fd4ff'),
name: gt.discoveryName,
});
}
return out;
}
/**
* Autopilot — the player clicked a compass name tag (any UI could wire
* to this): send the ship to a discovered object. It flies to the
* keep-out rim — the clearance point on the side the ship is coming
* from — and arrives to a stop, exactly like a click-to-fly onto the
* rim. Works for worlds AND asteroid clusters (the solid behind the
* discovery entry). Clicking elsewhere retargets the same way (last
* click wins).
*/
autopilotTo(id) {
if (this._commInputBlocked()) return; // the comm owns the screen (the ship holds)
// While the sub-bar is open (or this very click just closed it — the
// scene's handler and the chip's own listener race on event order),
// the chip's click is the menu-dismiss, not a navigation command.
const bar = this.menuSubBar;
if (bar && (bar.isOpen || bar.closing)) {
if (bar.isOpen) bar.close();
return;
}
// A ROUTE compass entry carries a prefixed id (see updateDiscovery):
// `route:<gateId>` for the orange next-gate, `dest:<objectId>` for the
// orange final stop. Resolve either to the object's real discovery id so
// both can be autopiloted like any other off-screen object.
let effectiveId = id;
if (typeof id === 'string') {
if (id.startsWith('route:')) effectiveId = id.slice('route:'.length);
else if (id.startsWith('dest:')) effectiveId = id.slice('dest:'.length);
else if (id.startsWith('escort:')) {
// The ESCORT's destination world (`escort:obj:<objectId>` — the
// world's real discovery id, resolved like `route:`/`dest:`):
// autopiloting it by the `patrol:wp:` index shape would have aimed
// the ship at the escort's RENDEZVOUS waypoint instead. A payload
// that is a content RECORD id (an entry stood before the
// discoveryId fix, or a stale session) is mapped onto the world's
// discovery entry here — without it the lookup would miss and the
// click would silently no-op.
const payload = id.slice('escort:'.length);
if (payload.startsWith('obj:')) {
const oid = payload.slice('obj:'.length);
const objs = this.discoverableObjects();
const recs = [
...(Array.isArray(this.systemContent?.planets) ? this.systemContent.planets : []),
...(Array.isArray(this.systemContent?.settlements) ? this.systemContent.settlements : []),
];
const rec = recs.find((r) => r && String(r.id) === oid);
const hit =
objs.find((v) => v.id === oid) ??
(rec ? objs.find((v) => v.name === rec.name) : undefined);
effectiveId = hit ? hit.id : oid; // no hit → the generic lookup below misses → no-op
}
} else if (id.startsWith('patrol:')) {
// The active PATROL's compass entry (see updateDiscovery):
// `patrol:gate:<gateId>` — the yellow route gate in ANOTHER
// system (the gate's own autopilot flies it — the orange route
// gate's rule); `patrol:wp:<i>` — the yellow next-waypoint (plain
// coordinates, NOT a discovery object: the aim clamps to the
// tether union — the ship flies as far as its tether lets it,
// the waypoint's radius is generous enough to land the check-in).
const payload = id.slice('patrol:'.length);
if (payload.startsWith('gate:')) {
this.autopilotTo(payload.slice('gate:'.length));
return;
}
if (payload.startsWith('wp:')) {
const idx = Number.parseInt(payload.slice('wp:'.length), 10);
const st = this.sideQuestState;
const route = st?.activeId ? st.routeFor(st.activeId) : null;
const wp =
Number.isInteger(idx) && route && Array.isArray(route.waypoints) ? route.waypoints[idx] : null;
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1])) {
const aim = this.tetherField.clampPoint(wp[0], wp[1]);
this.showTargetMarker(aim.x, aim.y); // the aim feedback
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
}
return;
}
return;
}
}
const o =
this.discoverableObjects().find((v) => v.id === effectiveId) ??
this.discoverableObjects().find((v) => v.id === id);
if (!o) return;
// The solid body behind this discovery entry (a world, a cluster, or
// for a ROUTE gate — the gate itself). Must use `effectiveId`, not the
// raw `route:<id>` — otherwise the lookup misses and the aim falls back
// to the central body, so the ship flew at the STAR's rim in the gate's
// direction instead of to the gate (the "wrong direction" the player hit).
const solid =
this.solids.find((s) => s.discoveryId === effectiveId) ??
this.solids.find((s) => s.discoveryId === id) ??
this.planet;
// Approach point: on the rim (clearance + hull), on the side the ship
// is approaching from (center → ship direction) — it ends up facing
// the object. If the object is outside the player's tether range, the
// aim clamps to the union boundary — the ship flies as far as its
// tether lets it (and rests on the line) until the tether grows.
const dx = this.ship.x - o.x;
const dy = this.ship.y - o.y;
let aim = solid.edgePoint(Math.atan2(dy, dx), solid.clearance, this.ship.radius);
// Belt and braces: no other solid may own this point either.
for (const s of this.solids) aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
aim = this.tetherField.clampPoint(aim.x, aim.y);
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
}
// ==================================================================
// Mining — the ship's energy arm (js/mining/Mining.js, MiningBeam.js)
// ==================================================================
/**
* Which rock (if any) sits under a WORLD point — the click-to-mine
* hit test. Nearest rock wins (clusters are far apart; members may
* slightly overlap). A few px of slop so smaller rocks are easy to hit.
*/
rockAt(wx, wy) {
let best = null;
let bestD = Infinity;
for (const c of this.asteroidClusters) {
for (const m of c.members) {
const d = Math.hypot(wx - m.wx, wy - m.wy);
if (d <= m.radius + 8 && d < bestD) {
best = { cluster: c, member: m, x: wx, y: wy };
bestD = d;
}
}
}
return best;
}
/**
* A rock click: the context menu unfolds right where the player
* clicked (js/ui/MiningPopup.js — world-anchored on the asteroid).
* The primary button is "Mine Asteroids" — or "Stop Mining" when
* this cluster is already the beam's target.
*/
openMiningMenu(rock, pointer) {
const isTarget = this.mining.state === 'mining' && this.mining.cluster === rock.cluster;
// The pop-up plays its own ui_window whoosh (MiningPopup.open).
this.miningPopup.open(rock.x, rock.y, pointer.y, {
name: rock.cluster.discoveryName,
primaryId: isTarget ? 'stop' : 'mine',
primaryLabel: isTarget ? 'Stop Mining' : 'Mine Asteroids',
target: rock,
});
this.hideHint();
}
/** The pop-up's button presses (MiningPopup → onAction). */
miningAction(id, rock) {
this.miningPopup.close();
if (!rock) return;
if (id === 'mine') {
if (this.mining.state === 'extending') return; // the arm is committed
this.mining.begin(rock.cluster, rock.member);
} else if (id === 'stop') {
this.mining.stop();
}
// 'cancel' — just close.
}
// ==================================================================
// Comms — the planet / station comms panel (js/ui/CommsPanel.js)
// ==================================================================
/**
* Which comms object (if any) sits under a WORLD point — the home
* world, a system planet, or a space station — hit-tested at the
* scale of its keepout circle (radius + clearance, the flyable rim).
* Nearest wins (they don't overlap). Rocks are NOT comms objects —
* they are the mining flow's (rockAt above).
*/
worldObjectAt(wx, wy) {
let best = null;
let bestD = Infinity;
// The central body is a comms target only when it IS the home world —
// the star (every other system) is not a comms object (like a gate).
for (const s of [...(this.isHomeSystem ? [this.planet] : []), ...this.systemPlanets, ...this.systemStations]) {
const r = s.radius + (s.clearance ?? 0);
const d = Math.hypot(wx - s.x, wy - s.y);
if (d <= r && d < bestD) {
best = s;
bestD = d;
}
}
return best;
}
/**
* The gate under (wx, wy), closest wins — the hit circle is its
* keepout (gate radius + ship clearance) plus a touch of pad, the
* same spirit as rockAt. (Gates are deliberately absent from
* worldObjectAt — they are NOT comms targets.)
*/
gateAt(wx, wy) {
let best = null;
let bestD = Infinity;
for (const gt of this.systemGates ?? []) {
const r = (gt.radius ?? 0) + (gt.clearance ?? 0) + 8;
const d = Math.hypot(wx - gt.x, wy - gt.y);
if (d <= r && d < bestD) {
best = gt;
bestD = d;
}
}
return best;
}
/**
* THE JUMP (data/gates.json → jump): click an ACTIVE gate and the
* run moves to the connected system, arriving near that system's
* RETURN gate (the one pointing back — activated with the gate per
* the ACTIVITY rule, its tether anchoring the ship's room to move;
* the pure geometry is js/galaxy/JumpTravel.js). With shortcuts OFF
* the network is a pure spanning tree, so every jump has a return
* gate; the origin fallback below is defensive only (the destination
* center is empty — its star is invisible flavor).
*
* HOW: the save pipeline, in miniature. captureState() snapshots the
* WHOLE run (discovery, reputation, research, builds, minerals,
* playtime, activatedGates); the destination system + arrival
* position are swapped in; prepareLoad() re-stages the shared state
* (the galaxy rebuilt from the seed — deterministic, same names and
* placements — with the new current system); scene.restart() rebuilds
* this scene onto it: the destination's native tethers (home + its
* activated gates) re-form in create(), its dossier/HUDs rebuild,
* and _onEnterSystem() grants its map tech.
*
* @param {object} gt the gate entity (JumpGate — .gate record, .x/.y,
* .radius, .clearance, .active)
*/
jumpThroughGate(gt) {
if (this._jumping) return;
const from = this.systemRecord;
const destId = gt?.gate?.to;
const dest = destId ? this.galaxy.byId.get(destId) : null;
if (!dest) {
this.consoleToast('NO LINK — GATE DESTINATION UNKNOWN', {
glyph: '⌁',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
if (this.mining?.isActive) {
this.consoleToast(
String(config.get('gates.jump.miningToast', 'CANNOT JUMP WHILE MINING')),
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) },
);
return;
}
// COMBAT — a live fight (CombatSystem.active — a wave run in flight,
// the 'Pirate Problems' ambush included, or hostiles up) holds the
// ship in the system: a jump mid-wave would carry the run out of its
// own fight (the wave run is scene-local — the enemies and the
// fight's momentum would die with the scene, the run would simply
// vanish with the threat). Same flat denial shape as the mining
// guard above.
if (this.combat?.active) {
this.consoleToast(
String(config.get('gates.jump.combatToast', 'CANNOT JUMP WHILE UNDER ATTACK')),
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) },
);
return;
}
// ESCORT CONVOY (data/sidequests.json → escort) — while the convoy is
// linked ('transit') or setting down ('delivering'), the jump HOLDS:
// the player must have the convoy — the transport + its surviving
// escorts — AND this gate ON SCREEN (the convoy is witnessed, not
// merely left behind: a jump re-stages the scene and would break the
// convoy off mid-contract). 'delivering' holds until the touchdown
// closes. Same flat-denial shape as the guards above.
{
const hold = this._escortJumpHold(gt);
if (hold) {
const t = config.get(
hold === 'delivering' ? 'sidequests.ui.escortDeliveringToast' : 'sidequests.ui.escortJumpHoldToast',
{},
);
const fallback = hold === 'delivering'
? 'CONVOY SETTING DOWN — HOLD POSITION UNTIL TOUCHDOWN'
: 'CONVOY OUT OF VIEW — BRING THE TRANSPORT, ITS ESCORTS AND THE GATE INTO VIEW TO JUMP';
this.comms?.trigger('escortDeny'); // OAC's denial beat (data/comms.json — the escort-wait voice)
this.consoleToast(String(t.text ?? fallback), {
glyph: t.glyph ?? '⚓',
glyphColor: t.color ? toCss(t.color) : undefined,
});
return;
}
}
// FACTION GATE LOCK (data/gates.json → jump.factionLock) — the gate
// comm window's grayed REQUEST JUMP + the "under Faction Control"
// message are the visible half; this is the scene's own flat denial
// on top (the same shape as the mining/combat guards above): a gate
// from a neutral system into a faction-controlled one, below the
// lock's minLevel.
if (this.factionGateLockedFor(gt)) {
const t = config.get('gates.jump.factionLock', {});
this.consoleToast(
String(t.message ?? 'JUMP GATE IS UNDER FACTION CONTROL. STATUS: LOCKED'),
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) },
);
return;
}
// Where we arrive: just past the return gate's keepout (JumpTravel).
// Fallback — no return gate (a one-way shortcut, shortcuts are OFF in
// the current config): the destination's origin (its center is empty —
// the star is invisible flavor, so this is just open space).
const destContent = this.galaxy.ensureContent(destId);
// ROUTE BOOKKEEPING — resolve what this hop means for the active route
// BEFORE captureState below snapshots the run (so a destination we've
// just reached is cleared in the saved state, not resurrected):
// arrived-at-destination → clear; on-course → silent; detour → toast.
this._routeForJump(from.id, destId);
// THE RUN'S FOOTPRINT — record the lane we just traveled + the system
// we are entering (the GALAXY tab's brighter lane + charted region;
// captured into `rec` right below, so the save carries it).
this.usedGates.add(edgeKey(from.id, destId));
this.visitedSystems.add(destId);
const arrival =
jumpArrival(destContent, from.id, {
radius: gt.radius ?? 0,
clearance: gt.clearance ?? 0,
shipRadius: this.ship?.radius ?? 0,
gap: Number(config.get('gates.jump.arrivalGap', 128)) || 0,
}) ?? { x: 0, y: 0, heading: 0, gateId: null };
const rec = captureState(this, this.time.now);
rec.currentSystemId = destId;
// The AMBUSH's bookkeeping — which gate the player came in through:
// the new scene resolves its entry gate as
// returnGateFor(content, lastJumpFrom) and measures the 'Pirate
// Problems' midpoint from it (GameScene._pirateAmbushEntryGate).
// A panel save carries the field through (captureState), so a
// reload keeps the same midpoint; a save predating it simply lacks
// the field (the nearest-gate fallback).
rec.lastJumpFrom = from.id;
rec.systemName = dest.name;
// SYSTEM ARRIVAL = LANDING — the destination's dock repairs the ship
// (hull/shields/energy full) the moment the cut lands. A panel LOAD
// never carries the key, so a save's damage survives its load.
rec.combatRepair = true;
rec.ship = {
x: arrival.x,
y: arrival.y,
heading: arrival.heading,
minerals: rec.ship?.minerals ?? 0,
// The credit ledger + trade-goods hold ride the jump too (the ship
// keeps them — only the position + the system change hands). Without
// these the restore below would re-seed 0 credits and an empty hold.
credits: rec.ship?.credits ?? 0,
cargo: rec.ship?.cargo ?? [],
// The unlocked cargo bays ride the jump too (the bar scene's DONE
// beat — effects.cargoHold — or a cargo-bay build). Without this the
// restore would re-seed the fresh ship's 0 and shed the goods
// aboard (the 4-bay hold would die on the first jump).
cargoHold: rec.ship?.cargoHold ?? 0,
// The per-bay cost ledger rides the jump too — the manifest prices
// exits against what was ACTUALLY paid per bay; dropping it would
// re-price every bay at the catalog's own value on the way down.
cargoCosts: rec.ship?.cargoCosts ?? null,
};
rec.tethers = []; // the destination's native set re-forms in create()
prepareLoad(this.registry, rec);
// The cut: the console line + the sfx, then the restart — short
// enough to read, long enough to hear (the scene's TimeClock drives
// the delay; update() steps it, see the v4 quirk note there).
this._jumping = true;
this.ship.stop(); // no steering across the cut
this.hideHint();
this.consoleToast(
String(config.get('gates.jump.toast', 'JUMP — {dest}'))
.replace('{dest}', String(dest.name).toUpperCase()),
{ glyph: '⌁', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: 1200 },
);
this.playSfx('discovery');
this._playJumpClip();
}
/**
* The jump clip — a full-screen one-shot played between the two systems
* (data/gates.json → jump.video). The destination is ALREADY staged behind
* it (prepareLoad ran in jumpThroughGate), so the moment the clip ends we
* restart the scene onto it. Mirrors SurfaceScene's landing clip: play to
* completion, with a STALL guard (a clip that never starts must not hold
* the jump) and a CAP (duration + margin), both advancing anyway. A
* missing/empty clip falls back to the short jumpDelayMs cut.
*/
_playJumpClip() {
this._jumpFinished = false;
this._jumpLastClickT = 0;
if (!this.hasVideo(JUMP_VIDEO_KEY)) {
// No clip (disabled or file missing) — the old short cut.
this.time.delayedCall(
Number(config.get('gates.jump.jumpDelayMs', 420)) || 420,
() => this._finishJump(),
);
return;
}
const W = this.scale.width;
const H = this.scale.height;
// Opaque backdrop: the source system must not show through the clip (or
// its first-frame decode gap). scrollFactor(0) pins it to the SCREEN —
// this scene's camera follows the ship and scrolls, so a world-space
// overlay (the default scrollFactor 1) would land off-screen; the same
// reason every screen-pinned UI here (toast, action bar, HUD) does it.
this._jumpBackdrop = this.add
.rectangle(W / 2, H / 2, W, H, toColor(themeColor('bg', 0x04060d)))
.setScrollFactor(0)
.setDepth(60);
assignUi(this, this._jumpBackdrop); // the UI pass (no double-draw)
// v4: add.video(x, y, key) — the key is the LAST argument (it loads the
// cached clip and attaches the <video> element). scrollFactor(0) keeps
// the clip pinned to the screen as the camera scrolls (see above).
const v = this.add.video(0, 0, JUMP_VIDEO_KEY).setOrigin(0.5).setScrollFactor(0).setDepth(61);
this._jumpVideo = v;
assignUi(this, v); // the UI pass (no double-draw)
v.setVolume(Math.max(0, Math.min(1, Number(config.get('gates.jump.videoVolume', 1)))));
this._fitJumpClip(v);
// Re-fit on the first presented frame (v4: the bookkeeping size is a
// placeholder until then — see SurfaceScene.attachClip).
v.on('created', (vv, w, h) => {
if (vv === v) this._fitJumpClip(vv, w, h);
});
v.play();
v.on('complete', () => this._finishJump());
v.on('error', () => this._finishJump());
const el = v.video;
const playing = () => !!el && (el.currentTime > 0.1 || !el.paused);
// STALL — no progress after a grace period (autoplay locked, decode
// failure, …): advance anyway.
this.time.delayedCall(6000, () => {
if (this._jumpVideo === v && !this._jumpFinished && !playing()) this._finishJump();
});
// CAP — expected to end by duration + margin; if the element goes silent
// before it, advance anyway.
const durS = Number(el && el.duration);
if (Number.isFinite(durS) && durS > 0) {
this.time.delayedCall(durS * 1000 + 5000, () => {
if (this._jumpVideo === v && !this._jumpFinished) this._finishJump();
});
}
}
/** Cover-scale the jump clip to the screen, cropping whatever overflows. */
_fitJumpClip(v, iw = 0, ih = 0) {
const el = v.video;
const vw =
iw || (el && (el.videoWidth || el.width)) || (v.frame && v.frame.realWidth) || 864;
const vh =
ih || (el && (el.videoHeight || el.height)) || (v.frame && v.frame.realHeight) || 480;
const W = this.scale.width;
const H = this.scale.height;
const s = Math.max(W / vw, H / vh);
v.setPosition(W / 2, H / 2);
v.setScale(s);
}
hasVideo(key) {
const c = this.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/**
* The jump's cut — idempotent. Tears down the clip (free the decoder, drop
* the listeners) and restarts the scene onto the staged destination.
* scene.restart() destroys the scene's objects anyway; this is explicit and
* safe (the complete/error/stall/cap guards never double-restart).
*/
_finishJump() {
if (this._jumpFinished) return;
this._jumpFinished = true;
if (this._jumpVideo) {
try {
this._jumpVideo.off();
this._jumpVideo.stop(false);
this._jumpVideo.destroy();
} catch {
/* already gone */
}
this._jumpVideo = null;
}
if (this._jumpBackdrop) {
try {
this._jumpBackdrop.destroy();
} catch {
/* already gone */
}
this._jumpBackdrop = null;
}
this.scene.restart();
}
/**
* What the comms panel shows for a planet or station:
* - settled — it hosts a settlement (a colony / mining station /
* cloud base anchored to the planet), or IS one (a
* free-space station); the home world is settled by
* definition (the player's homestead)
* - key — the reputation standing key (the anchored planet's
* FIRST settlement id — the stable `<system>-s<n>` id;
* HOME_KEY for home; the station's own id)
* - reputation — the standing on that key (neutral 0 when none)
* - canLand — standing > −4 (the panel grays the button at ≤ −4)
* - kindLabel — a short flavor line under the name
* - isPlanet / isStation / frame — the landing handoff (startLanding →
* SurfaceScene): a PLANTED planet or a deep-space station
* (both wear a spritesheet frame — the planet's class
* frame / the station's variant frame — and land with
* the matching clips: data/landing.json → videos /
* stationVideos).
*/
commsTargetFor(obj) {
let key = null;
let settled = false;
let owner = null; // the place's FACTION (settlement.owner / planet.faction)
let kindLabel = '';
let isStation = false;
let frame = null;
if (obj === this.planet && this.isHomeSystem) {
// The home world: standing pinned at the scale's top.
key = HOME_KEY;
settled = true;
kindLabel = config.get('planets.homeTypeLabel', 'Home World');
frame = this.planet?.sheetFrame ?? 0;
} else if (obj.settlement) {
// A free-space station — settled by definition; key = its id.
key = obj.settlement.id;
settled = true;
owner = obj.settlement.owner ?? null;
// Deep-space stations wear a spacestations.png variant (the
// galaxy-wide spread pass — js/galaxy/StationFrames.js) and label
// themselves by it (data/stations.json → typeLabels — the fallback
// is the kind's label).
isStation = obj.kind === 'deepSpaceStation';
const variantLabel = typeof obj.sheetFrame === 'number'
? config.get(`stations.typeLabels.${obj.sheetFrame}`)
: null;
kindLabel = variantLabel || config.get(`settlements.kinds.${obj.kind}.label`, 'Station');
frame = isStation ? (obj.sheetFrame ?? 0) : null;
} else {
// A system planet: settled when a settlement is anchored to it
// (anchor.type 'planet', anchor.ordinal = the planet's ordinal).
const rec = (this.systemContent.planets ?? []).find((p) => p.name === obj.discoveryName);
if (rec) {
// typeLabels is keyed by the planet's CLASS (rec.class —
// 'rocky'/'gas'/…), not its proper name (rec.name) — a name
// lookup would miss and fall back to the name, so the comms
// panel and the surface HUD would show the name twice.
kindLabel = config.get(`planets.typeLabels.${rec.class}`, rec.class);
const anchored = (this.systemContent.settlements ?? []).filter(
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === rec.ordinal,
);
if (anchored.length > 0) {
settled = true;
key = anchored[0].id;
}
owner = rec.faction ?? null; // the planet's own faction (the owner seam)
}
frame = obj.sheetFrame ?? 0;
}
// standingFor() with an {id, owner} record gets the FACTION CHECK
// (a faction-controlled place reads the player's standing with that
// faction — neutral (0) until something sets it); a raw key is fine
// for home (pinned) — it has no owner.
const rep = settled
? (this.reputation.standingFor(owner != null ? { id: key, owner } : key) ?? 0)
: 0;
const isPlanet = !obj.settlement; // home world + system planets vs free-space stations
const landingOn = config.get('landing.enabled', true) === true; // landing.json kill-switch
return {
name: obj.discoveryName,
settled,
key,
reputation: rep,
// Landing = the surface sequence: a PLANTED world (population > 0)
// or a deep-space station (always settled) with enough goodwill.
// The home world's button is hidden as a UI fact (the comms panel).
canLand: settled && rep > -4 && landingOn && (isPlanet || isStation),
kindLabel,
isPlanet,
isStation,
frame, // planets.png / spacestations.png frame → landing.json videos
};
}
/**
* A planet or station was clicked: the comms panel opens at the click
* — the name decodes in, the reputation bar draws (settled), the
* landing buttons wait. The click is NOT a fly-here — the ship stays
* exactly where it is. The panel is world-anchored at the click point
* and picks a side (up/down/left/right) that keeps it fully on screen
* (CommsPanel.pickSideAndPlace).
*/
openCommsPanel(obj, pointer) {
const t = this.commsTargetFor(obj);
// The FULL target goes through the panel — lastTarget comes back to
// commsAction() and needs `isPlanet`/`frame` (the landing handoff),
// not just the display fields.
this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, {
...t,
litMarks: this.reputation.marksFor(t.reputation),
});
}
/**
* A JUMP GATE was clicked: the comm window opens at the click — the
* name decodes in, the LINK line reads ACTIVE / DORMANT, and the
* buttons wait (REQUEST JUMP is a grayed ghost while the gate is
* dormant or gates.jump.enabled is off). The FACTION GATE LOCK
* (data/gates.json → jump.factionLock) replaces the LINK line with its
* own message ("Jump Gate is under Faction Control. Status: Locked")
* and grays REQUEST JUMP too — a gate from a NEUTRAL system into a
* FACTION-CONTROLLED one, below the player's level 5. Same contract
* as a planet click: NOT a fly-here (the ship stays put),
* world-anchored at the click, side-picked to stay on screen
* (CommsPanel.pickSideAndPlace).
*/
openGateCommsPanel(gt, pointer) {
const fl = config.get('gates.jump.factionLock', {});
const locked = this.factionGateLockedFor(gt);
this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, {
name: gt.discoveryName,
kindLabel: String(config.get('gates.typeLabel', 'Jump Gate')),
gate: {
active: gt.active,
jumpEnabled: config.get('gates.jump.enabled', true) !== false,
// FACTION GATE LOCK — the panel's gate variant shows `message`
// (the LINK line's place) + the grayed REQUEST JUMP; the scene's
// own flat denial sits in jumpThroughGate (defense in depth).
locked,
lockMessage: String(
fl.message ?? 'Jump Gate is under Faction Control. Status: Locked',
),
// The JumpGate entity — commsAction hands it to jumpThroughGate.
entity: gt,
},
});
}
/**
* The FACTION GATE LOCK (data/gates.json → jump.factionLock; the pure
* rule in js/galaxy/JumpTravel.js → factionGateLocked): true while
* this gate's jump is blocked — a gate FROM a NEUTRAL system INTO a
* FACTION-CONTROLLED one, while the player's combat level
* (js/combat/XpManager.js) is below the lock's minLevel. The gate
* comm window's grayed REQUEST JUMP + the "under Faction Control"
* message are the visible half; jumpThroughGate's flat denial is the
* scene's own guard on top (the same shape as its mining/combat
* guards).
* @param {object} gt the gate entity (JumpGate — .gate record)
*/
factionGateLockedFor(gt) {
const cfg = config.get('gates.jump.factionLock', {});
if (cfg.enabled === false) return false; // the feature switch
const destId = gt?.gate?.to;
return factionGateLocked({
enabled: true,
minLevel: Number(cfg.minLevel) || 5,
level: this.combatLevel(),
from: this.systemRecord ?? null,
dest: destId != null && this.galaxy ? (this.galaxy.byId.get(destId) ?? null) : null,
});
}
/**
* The FACTION GATE LOCK's SWIRL TINT (data/gates.json →
* jump.factionLock; the in-world half of the lock): while this gate's
* jump is LOCKED — a gate from a NEUTRAL system into a
* FACTION-CONTROLLED one, below the lock's minLevel — the gate's
* swirling center reads in the CONTROLLING FACTION's hue: the
* destination system's `faction` (data/factions.json →
* factions.\<id\>.color — apex red / mindweave blue / zenith gold).
* null when the lock doesn't hold (an ordinary swirl) — the gate comm
* window's grayed REQUEST JUMP + its message stay the primary read;
* this is the "that gate belongs to someone" tell on the gate itself.
* @param {object} gt the gate entity (JumpGate — .gate record)
* @returns {?number} the faction's 0xRRGGBB hue (ready for setTint), or null
*/
gateFactionTintFor(gt) {
if (!this.factionGateLockedFor(gt)) return null; // unlocked → the ordinary swirl
const destId = gt?.gate?.to;
const faction =
destId != null && this.galaxy ? (this.galaxy.byId.get(destId)?.faction ?? null) : null;
if (!faction || faction === 'neutral') return null;
const hex = config.get(`factions.factions.${faction}.color`, '');
return typeof hex === 'string' && hex.length > 0 ? toColor(hex) : null;
}
/**
* Re-resolve every gate's faction-lock tint (JumpGate.setFactionTint).
* Called at build (the tint follows the lock as it stands — level 0
* while the XP ledger is still to come), once the combat ledger exists
* (the save's level may already have lifted the lock), and on each
* level-up (the ledger's 'levelup' event — the same hook the level-up
* marquee rides), when a locked gate's tint clears.
*/
_refreshGateFactionTints() {
for (const gt of this.systemGates ?? []) gt.setFactionTint(this.gateFactionTintFor(gt));
}
/**
* The panel's button presses (CommsPanel → onAction). The panel is
* already closed by the time this runs:
* 'request-jump' — the gate window, REQUEST JUMP → the JUMP
* 'request-landing' — a settled world, standing > −4 → land (the
* capital world: startLanding's claim gate denies
* until 'Leaving the Nest' is claimed)
* 'land' — an unsettled world → land (same gate for the
* capital world, were it ever unsettled)
* 'cancel' — just close (the console log is the ack)
* Both transports keep their OWN combat guard on top of the panel's
* (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).
*/
commsAction(id, target) {
this.commsPanel.close(); // the buttons close the panel (like miningAction)
if (!target) return;
if (id === 'request-jump') {
// The panel only offers this when the gate is active and
// gates.jump.enabled — jumpThroughGate keeps its own guards
// (mining in progress, a live combat, a missing destination).
const gt = target.gate?.entity;
if (gt) this.jumpThroughGate(gt);
return;
}
if ((id === 'request-landing' || id === 'land') && (target.isPlanet || target.isStation)) {
this.startLanding(target);
return;
}
// A landing press on a non-port target (defensive — the panel only
// offers it on planets + stations) — acknowledged on the console.
console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`);
}
/**
* The capital world's landing gate (data/quests.json →
* capitalDenyToast): true when the comms target is the NEUTRAL CAPITAL
* WORLD — the 'Leaving the Nest' quest's destination planet (the
* neutralCapitalTarget's world name, matched case-insensitively; the
* comms panel only opens on this system's objects, so a name hit IS the
* capital) — and the quest's reward is NOT CLAIMED yet
* (QuestState.isClaimed). Quests off (the config kill-switch) = the
* gate is off too: with no quest there is no claim to wait for.
* @param {object} target the comms panel's landing target (commsTargetFor)
* @returns {boolean} landing on this target is blocked
*/
_capitalLandingBlocked(target) {
if (config.get('quests.enabled', true) === false) return false;
if (this.questState?.isClaimed?.('leaving_the_nest')) return false;
const cap = String(this.galaxy?.neutralCapitalTarget?.()?.objectId ?? '').trim().toLowerCase();
const name = String(target?.name ?? '').trim().toLowerCase();
return cap !== '' && cap === name;
}
/**
* The random-encounter hold (the 'Special Delivery' gate): the run's
* first flights into neutral space stay calm while the delivery chain
* is live — the director rolls (EncounterDirector, the pure clock)
* only once 'Special Delivery' is COMPLETED AND CLAIMED (the same
* witness the run's chain uses — QuestState.isClaimed; the claim is
* the scene's own, paid out only on a fully satisfied quest). Quests
* off (the config kill-switch, data/game.json) = the gate is off too:
* with no quest there is nothing to wait on.
* @returns {boolean} true while random encounters stay locked
*/
_encountersLockedByQuest() {
if (config.get('quests.enabled', true) === false) return false;
if (this.questState?.isClaimed?.('special_delivery') !== true) return true; // the 'Special Delivery' hold
// ESCORT transit: the convoy's own director owns the lane — the normal
// director holds (the escort's waves are the combat, one at a time).
const sel = this._escortRoute?.();
if (sel && (sel.route.escort?.phase === 'transit' || sel.route.escort?.phase === 'delivering')) return true;
return false;
}
/**
* LAND (commsAction) — the surface sequence: SurfaceScene starts ON
* TOP of this scene, handed the world's planets.png sheet frame so it
* can pick the landing/surface videos from data/landing.json, plus
* the world's type label and tether level for its upper-left HUD.
* v4 note: `launch` does not freeze the caller, so sleep the flight
* world explicitly — Take Off (SurfaceScene) wakes it back, with the
* ship, tethers and discovery exactly where they were.
*/
startLanding(target) {
if (!config.get('landing.enabled', true)) {
console.info(`[orbit] comms: landing disabled for ${target.name}`);
return;
}
// COMBAT — a live fight (CombatSystem.active — a wave run in flight,
// the 'Pirate Problems' ambush included, or hostiles up) holds the
// ship in the air: landing would SLEEP the flight scene under the
// wave — the player would hide on the surface while the raiders
// wait, and the fight's momentum (the wave run is scene-local) would
// die with the sleep. The fight ends where it started, in the air.
// Same flat denial shape as the capital's claim gate below.
if (this.combat?.active) {
const t = config.get('landing.combatDenyToast', {});
this.playSfx('ui_close');
this.consoleToast(
String(t.text ?? 'CANNOT LAND WHILE UNDER ATTACK'),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
durationMs: 3200,
},
);
return;
}
// The CAPITAL WORLD holds its door until the 'Leaving the Nest'
// reward is CLAIMED (the quest's 200 credits — the quest itself
// completes when the ship arrives at this planet; the claim is the
// last step before the world opens). A request before the claim is
// a flat denial on the console (the comms panel already closed —
// the toast is the ack).
if (this._capitalLandingBlocked(target)) {
const t = config.get('quests.capitalDenyToast', {});
this.playSfx('ui_close');
this.consoleToast(
String(t.text ?? 'REQUEST DENIED. PLEASE CLAIM YOUR QUEST REWARD FIRST.'),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
durationMs: 3200,
},
);
return;
}
// ESCORT CONTRACT — the contract's DESTINATION WORLD holds its door
// while the transport is still out on the job: the player can't land
// on the planet/station the convoy is being delivered to until the
// transport has REACHED it (the delivery seals — phase 'delivered') or
// has been DESTROYED (phase 'failed' — that landing is the failure's
// reset's remedy, so a lost convoy never blocks the door). The denial
// rides OAC's 'escortDeny' beat (data/comms.json — the escort-wait
// voice) over the scene's own flat toast (the comms panel is already
// closed — the box is the ack).
if (this._escortLandingBlocked(target)) {
const t = config.get('sidequests.ui.escortLandingDenyToast', {});
this.playSfx('ui_close');
this.comms?.trigger('escortDeny');
this.consoleToast(
String(t.text ?? "ACCESS DENIED — THE CONVOY'S DESTINATION HOLDS UNTIL THE TRANSPORT LANDS"),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
durationMs: 3200,
},
);
return;
}
// Name-keyed world state (build records, tether labels) is keyed by
// the world's CANONICAL spelling — resolve whatever casing the
// target arrived in (UI display paths uppercase) to that spelling
// before it crosses into the surface (js/utils/WorldNames.js).
// Station names are in the list too — a deep-space station is a
// world to the surface deck, so its name keys the deck's lookups
// just like a planet's.
const name = canonicalPlanetName(target.name, [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.discoveryName),
...(this.systemStations ?? []).map((s) => s.discoveryName),
...(this.tetherField?.tethers ?? []).map((t) => t.label),
]);
// A live scan would leave the camera roll/zoom mid-wobble — the scene
// sleeps (or shuts down) and never restores it. Cut it, and drop the
// compass emission (a flight-scene navigation aid — no use on the
// surface, and it shouldn't resurface stale on the way back up).
if (this.scanPulse?.busy) {
this.scanPulse.cancel();
this.scanObjects = null;
}
this.scanReveal = null;
this.hideHint();
// A previous surface (left sleeping by an earlier Take Off) is shut
// down + restarted by this — the fresh init gets this world's frame.
// sheetFrame is the raw planets.png / spacestations.png index
// (`.frame` is the texture Frame OBJECT — Number() of it is NaN, and
// the video/music lookups need the number). `station` tells the
// surface deck which clip set the frame indexes into (data/landing.json
// → videos for planets, stationVideos for station variants).
// FIRST LANDING (data/builds.json → tether-l1): the world hosts its
// level-1 tether the moment the player lands — free + instant, so no
// build-console step (planets and deep-space stations alike). Before
// the launch, so the surface HUD's tetherLevel snapshot already
// reads 1.
this.ensureLandingTether(name);
// 'Special Delivery' bookkeeping (a real landing, post-guards): the
// story ledger's lastLandedWorld, and on the NEUTRAL CAPITAL — the
// bar: the trade gate lifts + the package lands in the hold.
this._recordLanding(name);
// ESCORT RESET (the failure's remedy): the transport is LOST and the
// player has landed — re-stand the contract. A fresh rendezvous in this
// system, a fresh transport, the same destination (the retry starts
// where they are). A no-op unless the escort is the active contract in
// its 'failed' phase (a normal landing — the convoy intact — never resets).
this._escortResetForLanding();
// THE DOCK'S REPAIR — the world is a landing, not a jump: the bay
// puts the hull, shields and energy back to full (a combat-damaged
// ship or a drained-energy one alike — the surface is where ships
// get fixed; the run's other full-heal beat is the respawn's).
this.combat?.repairFull();
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(name),
station: target.isStation === true,
});
this.scene.sleep();
}
/**
* The tether LEVEL anchored on this world (the surface HUD's second
* line — "Tether Level N"): the tether whose label is the world's
* name, or whose anchor sits on the world's center (the home tether
* is anchored at the origin with the home world's name). 0 = no
* tether on this world yet.
*/
tetherLevelFor(name) {
if (!name || !this.tetherField) return 0;
const obj =
this.systemPlanets.find((p) => p.discoveryName === name) ??
this.systemStations.find((s) => s.discoveryName === name) ??
(name === this.planet?.discoveryName ? this.planet : null);
if (!obj) return 0;
const t = this.tetherField.tethers.find(
(tt) => tt.label === name || (tt.x === obj.x && tt.y === obj.y),
);
return t ? t.level : 0;
}
/**
* FIRST-LANDING INSTALL (data/builds.json → tether-l1): the moment the
* player lands on a world — a planet or a deep-space station — the
* world hosts its level-1 tether (5120 px, data/tether.json), with no
* build-console step: the build is free + instant (cost {}, duration
* 0), so the landing simply completes it. The record then reads BUILT
* in the console, and the level-2 build's planet gate (a world holding
* a level-1 tether) is met. Idempotent — the home world's starting
* seed (tether-l1.starting) already records it, and a world that
* already hosts a tether is adopted, never anchored twice.
*/
ensureLandingTether(worldName) {
const state = this.buildState;
if (!worldName || !state) return;
if (state.isBuilt(worldName, 'tether-l1')) return;
if (this.tetherLevelFor(worldName) >= 1) {
// The world already hosts a tether without a build record — adopt
// it (the console reads BUILT, the L2 gate is met) instead of
// anchoring a second field.
state.markBuilt(worldName, 'tether-l1');
return;
}
const def = defById('tether-l1');
if (!def?.effects) return; // build system off / the data is gone
this._applyBuildEffects(worldName, def.effects); // anchors + the toast
state.markBuilt(worldName, 'tether-l1');
this.playSfx('construct'); // the power-up tick — the manual build's voice
this._questRefresh(); // a tether requirement may have just landed
}
// --------------------------------------------------------------- 'Special Delivery'
// The run's story beats around the neutral capital (data/quests.json →
// special_delivery — granted when 'Leaving the Nest' is claimed). The
// quest's checklist is LIVE (_questCheckEval); these seams own the
// WITNESSES the checks read, recorded in this.story (the registry's
// 'story' — SaveData carries it to record.story and back):
// · _recordLanding — startLanding (a real arrival): the last
// world landed on; the capital landing
// (the bar — the trade gate lifts). Landing
// on the home world is NO LONGER the
// delivery's proof (the sale is — below);
// · onVignetteComplete — the bar scene's DONE press: the hold is
// built out (the scene's effects) and the
// special package lands in the hold, and —
// while the quest is held — the HOME WORLD
// becomes the destination (the delivery's
// route — the compass points it); the bar
// conversation IS the trigger;
// · onSurfaceDeparture — SurfaceScene.finishTakeoff (a real
// launch): after the package's sale it
// arms the 'deliveredLaunch' comms chain
// (Carol ×3 → OAC's sign-off, the payoff);
// the capital launch records the delivery
// leg (launchedFromCapital). It no longer
// touches the destination — the bar
// conversation owns the route (above);
// · onQuestPackageSold — the shop's CONFIRM of the package's sale:
// saleable on the home world ONLY (TradeGrid's
// gate), at zero credits — that sale is
// step 3's proof (the parcel in Carol's
// hands), recorded monotonic.
// The witnesses are persistent, so a save/reload keeps the trade gate
// lifted, the package aboard (the cargo step's witness), and the
// delivery leg in flight.
// ---------------------------------------------------------------
/** Is this world the NEUTRAL CAPITAL (the neutralCapitalTarget's world —
* matched case-insensitively, the way _capitalLandingBlocked does)? */
isCapitalWorld(worldName) {
const cap = String(this.galaxy?.neutralCapitalTarget?.()?.objectId ?? '').trim();
const name = String(worldName ?? '').trim();
return cap !== '' && cap.toLowerCase() === name.toLowerCase();
}
/**
* The surface TRADE gate (the shop's Buy/Sell tab — the shop's sub-bar
* stays live everywhere, but trading is dark until the player reaches
* + LANDS on the neutral capital): true once the story ledger holds the
* capital landing. Read by the shop's sub-bar (js/ui/shop/ShopView.js —
* the Buy/Sell tab's disabled state; the shop button itself is always on).
*/
isTradeUnlocked() {
return this.story?.capitalLanded === true;
}
/** Is this world the HOME WORLD (the run's origin — name-matched against
* this.homeWorldName, case-insensitively)? */
isHomeWorld(worldName) {
const home = String(this.homeWorldName ?? '').trim();
const name = String(worldName ?? '').trim();
return home !== '' && home.toLowerCase() === name.toLowerCase();
}
/**
* 'Special Delivery' bookkeeping on a world landing (startLanding —
* after the guards, so this is a real arrival, not a refused one):
* · the ledger's lastLandedWorld (the run's arrival trail);
* · the NEUTRAL CAPITAL landing (the quest's bar beat): the trade gate
* lifts (isTradeUnlocked). The package handoff does NOT run here —
* it rides the bar scene's DONE press (onVignetteComplete: the
* hold's built out first, the package second — the ship starts
* with cargoHold 0 and can't take the parcel until then).
* Landing on the HOME world no longer completes the quest on its own:
* the delivery's proof is the package's SALE there (onQuestPackageSold
* — the parcel in Carol's hands, not the ship at the gate).
* Pure bookkeeping — the checklist itself is live, so _questRefresh
* announces a READY quest if the landing was the last piece (the bar,
* on the capital's arrival).
*/
_recordLanding(name) {
if (!this.story) return;
this.story.lastLandedWorld = String(name ?? '');
if (this.isCapitalWorld(name)) {
this.story.capitalLanded = true; // the trade gate lifts (the bar's scene runs its own beat)
}
this._questRefresh(); // a step may have just landed (the bar, on the capital)
}
/**
* The package handoff (the quest's packageGood — data/quests.json →
* special_delivery.packageGood, the 'special-package' in
* data/goods.json, sheet frame 114): the SPECIAL PACKAGE joins the
* hold. ONE per ship — a repeat is a no-op while it is aboard; a full
* hold makes room by dropping the OLDEST ordinary good (the package is
* the quest, cargo is trade — defensive: the scene's hold is fresh and
* empty when this runs). Called by onVignetteComplete AFTER the
* hold's upgrade (the parcel must fit — the ship starts with 0 bays).
* The player-facing beat is the SurfaceScene's console note (the
* completion carries it out — the flight HUD sits behind the surface's
* opaque plate for the whole stay).
* @returns {boolean} true when the package was JUST placed (the beat is due)
*/
_handoffSpecialPackage(goodId = null) {
const ship = this.ship;
if (!ship?.cargo) return false;
const id = String(goodId ?? questDef('special_delivery')?.packageGood ?? '').trim();
if (!id) return false; // the quest data is gone — the handoff is off too
if (ship.cargo.has(id)) return false; // already aboard — idempotent
if (ship.cargo.room <= 0) {
if (!ship.cargo.dropFirstNot(id)) return false; // a hold of nothing but the package (it can't be)
}
// The package is a handoff, not a purchase — it arrives with no cost on
// file (add() stamps null), so its manifest prices exits against the
// catalog's own value, never a guessed price.
return ship.cargo.add(id) > 0;
}
/**
* A VIGNETTE scene's DONE press (SurfaceScene._vignetteComplete → here,
* the ledger's owner): the scene's story beat, in order —
* 1. the scene's EFFECTS (data/vignettes.json → effects): the
* cargo-hold build-out (effects.cargoHold, MAX-wins — the run's
* ship starts with cargoHold 0, data/ship.json; Carol's crew is
* the labor, the scene's lines are the truth);
* 2. the scene's QUEST's package (data/quests.json → packageGood):
* the 'special-package' joins the hold — AFTER the upgrade (the
* hold must fit it). Its presence is the 'cargoUpgraded' step's
* witness (the check's id) — the parcel aboard proves the ship
* can hold cargo;
* 3. the DELIVERY ROUTE (the 'Special Delivery' bar scene, and only
* while the quest is HELD — granted, unclaimed): the HOME WORLD
* becomes the run's destination the moment the conversation is
* over. Carol's line set it up ('just bring this package back to
* the merchant on your home world' — data/vignettes.json →
* special_delivery_bar_meeting): hearing it IS the trigger, so
* the compass points home from the bar's DONE press onward. The
* scene's own gate (VignetteModel.vignetteAvailable — granted +
* unclaimed + the step open) already keeps it playing only during
* the quest, and _specialDeliveryHeld re-verifies that here
* (belt and braces — the route is the quest's, and a scene that
* outlived it must not re-aim the destination once it ends).
* _questRefresh then stands the just-made steps (steps 1 + 2) DONE.
* @returns {{upgradedTo: number|null, handoff: boolean, routed: boolean}}
* the beats that fired (the SurfaceScene calls them out in its
* console slot)
*/
onVignetteComplete(vig) {
const out = { upgradedTo: null, handoff: false, routed: false };
if (!vig) return out;
// 1 — the hold's built out (the scene's effects — pure data the
// model kept): MAX-wins, never a downgrade (a later hold — a
// cargo-bay build, 8/12/16 — is never stepped down by this).
const hold = Math.floor(Number(vig.effects?.cargoHold) || 0);
if (hold > 0 && typeof this.ship?.setCargoHold === 'function') {
const cur = Number(this.ship.stats?.cargoHold) || 0;
if (hold > cur) {
out.upgradedTo = this.ship.setCargoHold(hold);
this.refreshCreditsHud(); // the hold is now worth more — keep the readout honest
}
}
// 2 — the scene's quest's package (the handoff): the parcel joins the
// hold NOW — its presence is the 'cargoUpgraded' witness.
const questId = String(vig.quest ?? '').trim();
if (questId !== '') {
const goodId = questDef(questId)?.packageGood;
if (goodId && this._handoffSpecialPackage(goodId)) out.handoff = true;
// 3 — the delivery ROUTE: the bar conversation is the trigger (the
// quest's bar scene, and only while the quest stands) — the home
// world becomes the destination the moment the scene completes, so
// the compass points the delivery's route for the flight home.
// setDestination does the rest (the route derives from it, the
// compass marks the gate to fly; its own toast sits on the sleeping
// flight scene, so the SurfaceScene calls this beat out in its
// console slot — the scout's SET DESTINATION is the same shape).
if (questId === 'special_delivery' && this._specialDeliveryHeld()) {
const homeId = this.galaxy?.homeSystemId;
if (homeId && this.systemRecord?.id !== homeId) {
this.setDestination(homeId, 'home', 'quest'); // the compass points home
out.routed = true;
}
}
}
if (out.upgradedTo != null || out.handoff || out.routed) {
this._questRefresh(); // steps 1 + 2 may stand done now
}
return out;
}
/**
* Is the 'Special Delivery' quest HELD (granted, unclaimed)? The bar
* scene's delivery route (onVignetteComplete, beat 3 — the destination
* → the home world) lands only while it stands: the scene itself plays
* only under the same gate (VignetteModel.vignetteAvailable — granted
* + unclaimed + the step open), so this read is the seam's
* belt-and-braces against the scene outliving the quest (a ledger
* drifted by a save, a re-armed scene) — once the quest ends, the bar
* must not re-aim the player's destination.
*/
_specialDeliveryHeld() {
if (!this.questState) return false;
const qid = 'special_delivery';
return (
this.questState.isGranted(qid) === true &&
this.questState.isClaimed(qid) !== true
);
}
/**
* The surface TAKE OFF seam (SurfaceScene.finishTakeoff → here).
* Two beats ride on it:
*
* 1 — the 'Special Delivery' PAYOFF (data/comms.json →
* 'deliveredLaunch'): the launch back into space AFTER the
* package's home-world sale — Carol's bar line set it up ('After
* you've done that, launch back into space and I'll contact you',
* data/vignettes.json → special_delivery_bar_meeting). Witnessed by
* the run's story ledger (story.delivered — set by the shop's
* CONFIRM of the package's sale, onQuestPackageSold), armed here
* on any departure once it stands; the comms' once-ledger keeps
* the four-beat chain (Carol ×3 → OAC's sign-off) a one-shot and
* any later re-arm a no-op. Queued while this scene still owns the
* run — the hub plays the chain once the flight world is back
* (the FIFO, each ack holding until its own ACKNOWLEDGE).
*
* 2 — the CAPITAL DEPARTURE leg: launching off the NEUTRAL CAPITAL
* (the bar is done, the package is aboard) records the launch in
* the story ledger (launchedFromCapital — the delivery's trail).
* A launch off any other world is a no-op for this leg. This seam
* NO LONGER touches the destination: the delivery's route
* (destination → the home world) is owned by the bar conversation
* itself — the quest's bar scene DONE press sets it, while the
* quest is held (onVignetteComplete, beat 3) — so a post-quest
* visit to the capital (a 'Pirate Problems' return) leaves the
* player's destination alone.
*/
onSurfaceDeparture(worldName) {
if (this.story?.delivered === true) {
this.comms?.trigger('deliveredLaunch'); // the payoff chain — the once-ledger dedupes
}
if (!this.story || !this.isCapitalWorld(worldName)) return;
this.story.launchedFromCapital = true; // the trail: the capital's departure
this._questRefresh(); // a step may have just landed (defensive)
}
/**
* The 'Special Delivery' step 3's seam (ShopView.confirm — the shop's
* CONFIRM of a sale that included the quest's package). The package is
* the run's story, not a commodity: the ONE floor that takes it is the
* HOME WORLD's (TradeGrid's saleable gate — every other world's grid
* refuses the bay, the tooltip says NOT FOR SALE), and it prices at
* zero (the good's `sellFor`, data/goods.json). THAT sale is the
* delivery — Carol gets her parcel — and it sets the witness the
* 'packageDelivered' check reads (this.story.delivered), recorded
* MONOTONIC: once it stands, a later landing elsewhere can't take it
* back (the quest can be CLAIMED and stay claimed; the cargo step's
* witness — the package aboard — is already on the ledger's completed
* set by then, so it stays done too).
* A sale off the home world is a no-op (the grid's gate makes it
* unreachable — belt and braces).
* @param {string} worldName the world the sale ran on (the surface's planet)
* @returns {boolean} true when the delivery stood NOW (not before)
*/
onQuestPackageSold(worldName) {
if (!this.story) return false;
if (!this.isHomeWorld(worldName)) return false; // not the home world — no delivery here
if (this.story.delivered === true) return false; // already stands (monotonic)
this.story.delivered = true; // step 3 made — the parcel is in Carol's hands
this._questRefresh(); // the checklist may now read READY (all three steps)
return true;
}
/**
* Mining phase changes (Mining → onPhase): the scene's share of the
* sequence — the ship's STATE, the ship lock, the console calls, the sfx.
*/
onMiningPhase(phase) {
if (phase === 'extending') {
this.ship.stop(); // hold station — the arm needs a steady hull
this.ship.setState('mining', 'mining'); // the arm's sequence owns the ship
this.playSfx('mining'); // the arm powers up
this.hideHint();
// The console call, in the same slot as the discovery toasts —
// it holds for the whole reach, then fades as the beam fires.
this.consoleToast('Extending Mining Arm...', {
glyph: '\u2316',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: this.mining.armExtendMs,
});
} else if (phase === 'mining') {
this.setMiningLoop(true); // the beam is live — the hum runs until the sequence ends
} else if (phase === 'stopped') {
this.setMiningLoop(false); // the arm pulls back — the hum ends with it (no one-shot sound)
// Back to normal — but ONLY when the ship is still in its mining
// state (the beam-out / full-hold ending: the ship is parked). When
// the player moved the ship out of 'mining' (a click-to-fly, or a
// Shift+click throttle lock), that movement already owns the
// ship's state ('normal' or 'thrust') — don't stomp it.
if (this.ship.state === 'mining') this.ship.setState('normal', 'mining-ended');
}
}
/**
* Mining events (Mining → onEvent): the ore's scene share — the crack
* sfx (the new mining-02.mp3, once per break) and the console calls.
* 'split' the rock broke (data: kind 'absorb' = shattered into
* pieces that fly to the ship, 'divide' = split in half)
* 'absorbed' the shattered pieces hit the hull (data.gained minerals)
* 'storageFull' the hold is full — the run ended (mining stops so the
* rock isn't ground away for nothing)
*/
onMiningEvent(name, data = {}) {
const neon = toCss(themeColor('neon', 0x00e5ff));
const magenta = toCss(themeColor('neon2', 0xff2d6f));
if (name === 'split') {
this.playSfx('mining_split'); // the crack — assets/fx/mining-02.mp3, once per break
this.consoleToast(
data.kind === 'absorb'
? `SHATTERED — ${data.pieces} × ${data.pieceSize} px PIECES`
: `SPLIT — ${data.pieces} × ${data.pieceSize} px ROCKS`,
{ glyph: '\u25c6', glyphColor: neon },
);
} else if (name === 'absorbed') {
this.consoleToast(`+${data.gained} MINERALS ABOARD`, { glyph: '\u2295', glyphColor: neon });
this.refreshMineralHud(); // belt & braces — the landings already fed it via onOre
} else if (name === 'storageFull') {
this.consoleToast('MINERAL STORAGE FULL', { glyph: '!', glyphColor: magenta });
this.refreshMineralHud();
}
}
/**
* A cluster's LAST rock was consumed (Mining → onDepleted): the field is
* DEPLETED. It drops out of the WORLD (this.asteroidClusters — compass
* arrows, rock hit tests — and this.solids, so the ship may now fly
* through the spot) and out of the CONTENT RECORD (the map's chart +
* resource stats read content.asteroids), and its id joins the run's
* depleted list (a save/load keeps it gone — the content regenerates
* from the seed; js/save/SaveData.js). The shattered pieces already
* flying home are scene images, not cluster children, so they finish
* the ride after destroy(). The field's menu (if still open) closes —
* it would float over the void.
*/
onClusterDepleted(cluster) {
const i = this.asteroidClusters.indexOf(cluster);
if (i !== -1) this.asteroidClusters.splice(i, 1);
const j = this.solids.indexOf(cluster);
if (j !== -1) this.solids.splice(j, 1);
const arr = this.systemContent?.asteroids;
if (Array.isArray(arr)) {
const k = arr.findIndex((c) => c && c.id === cluster.discoveryId);
if (k !== -1) arr.splice(k, 1);
}
this.depletedClusters.add(`${this.systemRecord.id}:${cluster.discoveryId}`);
if (this.miningPopup?.isOpen && this.miningPopup.lastTarget?.cluster === cluster) {
this.miningPopup.close();
}
cluster.destroy();
this.consoleToast(`${cluster.discoveryName.toUpperCase()} FIELD DEPLETED`, {
glyph: '\u25cc',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
/**
* Mirror the ship's hold into the upper-right readout (js/ui/MineralHud.js).
* A no-op there when the value is unchanged (no re-tween, no re-flash),
* so this is safe to call from every ore seam (per extraction tick, per
* fragment landing, per event).
*/
refreshMineralHud() {
if (!this.mineralHud || !this.ship) return;
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
}
/**
* Mirror the ship's credit ledger into the upper-right readout
* (js/ui/CreditsHud.js) — the line stacked above the minerals HUD. A
* no-op when unchanged (no re-count), so it's safe to call from every
* credit seam (quest claims, selling goods, …).
*/
refreshCreditsHud() {
if (!this.creditsHud || !this.ship) return;
this.creditsHud.set(this.ship.credits);
}
/**
* THE DAILY BATCH SPEND (model v2 — the economy's one stateful seam).
* After a confirmed trade, the ORIGIN system's remaining batch drops by
* the units the player bought of its signature good (the buy side is
* the only stock-limited side — the sell side is unlimited, the market
* is a sink). Pure math in js/economy/Stock.js (spend returns a NEW map
* — the save-safe reference swap); this just owns the reference.
* ShopView.confirm() calls it for each batch-limited good bought (its
* remaining() is finite only when THIS system is that good's origin).
* A dry day is recorded as { day, left: 0 } — an ABSENT entry would
* read as a full batch for the day.
*/
spendStock(systemId, qty) {
if (!systemId) return this;
this.economyStock = Stock.spend(this.economyStock, systemId, qty);
this.registry.set('economyStock', this.economyStock);
return this;
}
/**
* The mining hum (data/sfx.json → mining_loop, assets/fx/mining-01.mp3):
* a loop that starts when the beam goes live (phase 'mining') and
* stops when the sequence ends (phase 'stopped'). A retarget re-fires
* 'mining' without a 'stopped' in between — keep ONE hum across
* retargets (the isPlaying guard). No-ops when SFX are disabled or the
* asset is missing (the shared voice's guards, js/utils/Sfx.js).
*/
setMiningLoop(on) {
if (on) {
if (!config.get('sfx.enabled', true)) return;
if (sfxPlayingOn(this, 'mining_loop')) return; // already humming (a retarget)
playSfxOn(this, 'mining_loop', { loop: true });
} else {
stopSfxOn(this, 'mining_loop');
}
}
/**
* Play one of the configured sound effects (data/sfx.json).
* Silently no-ops when SFX are disabled, the sound manager isn't
* available (headless/test), or the asset never loaded — the game
* never blocks or warns on sound. (Shared voice: js/utils/Sfx.js.)
*/
playSfx(name) {
playSfxOn(this, name);
}
/** The deep-space soundtrack (data/music.json → game): shuffled tracks,
* one at a time, advancing when a track ends. Clean to re-fire — a
* restart stops the old track and its timer first. */
startGameMusic() {
startMusicShuffleOn(this, config.get('music.game', []));
}
stopGameMusic() {
stopMusicShuffleOn(this);
}
/** The COMBAT soundtrack (data/music.json → combat): the shuffled
* combat playlist — one track at a time, the next picked at random
* when one ends (never the same twice in a row). Plays at the combat
* context's volume (music.volumes.combat can override the global mix).
* Clean to re-fire — the scene-level stop goes first, so the swap in
* setCombatMusic never stacks two shuffles. */
startCombatMusic() {
startMusicShuffleOn(this, config.get('music.combat', []), 'combat');
}
stopCombatMusic() {
stopMusicShuffleOn(this);
}
/** The NEUTRAL-PILOT soundtrack (data/music.json → neutral): the hum
* under a neutral-only encounter (escorts in, zero hostiles) — the same
* shuffle voice as combat at the neutral context's volume (music.volumes
* .neutral can override the global mix — same one-knob rule as combat). Clean to re-fire — the
* scene-level stop goes first, so the swap never stacks two shuffles. */
startNeutralMusic() {
startMusicShuffleOn(this, config.get('music.neutral', []), 'neutral');
}
/**
* The soundtrack's combat switch (the onActiveChange seam — the same
* moment the combat bar swaps in): the fight goes live → the deep-space
* hum stops and the combat shuffle starts (or the NEUTRAL shuffle when
* the encounter is neutral-only — escorts in, zero hostiles); the fight
* ends → whichever shuffle is live stops and the deep-space hum resumes
* — unless the scene is asleep (on a world's surface: the surface owns
* the silence, and the wake handler resumes the right context). Clean
* to re-fire on either side (one shuffle per scene).
* @param {boolean} active combat live / over (CombatSystem's flip)
* @param {boolean} [neutral] the live roster is a neutral-only encounter
*/
setCombatMusic(active, neutral = false) {
if (active) {
this.stopGameMusic();
if (neutral) this.startNeutralMusic();
else this.startCombatMusic();
} else {
this.stopCombatMusic();
if (!this._asleep) this.startGameMusic();
}
}
/** The "new object" moment: a rim ping at the world + a HUD toast. */
celebrateDiscovery(o) {
const neon = themeColor('neon', 0x00e5ff);
this.playSfx('discovery'); // the signal that something new is here
// Expanding ring at the world's rim (world space).
const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6);
assignWorld(this, ring); // world-anchored — keep it off the UI pass
this.tweens.add({
targets: ring,
scale: 1.12,
alpha: 0,
duration: 750,
ease: 'Sine.easeOut',
onComplete: () => ring.destroy(),
});
// Center HUD toast (~75 px above screen center), in the console language.
const type = (o.typeLabel ?? 'OBJECT').toUpperCase();
const name = o.name ? String(o.name).toUpperCase() : null;
const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`;
this.consoleToast(label, {
glyph: '\u25b8',
glyphColor: toCss(neon),
durationMs: 2400,
});
this._questRefresh(); // a 'Discover another world' requirement may have just landed
// The comms beat (data/comms.json: firstCluster — oac-first-cluster):
// OAC celebrates the FIRST rock field the player finds. The scene
// flag fires the event exactly once; the comm's once-ledger is the
// authoritative dedupe across saves.
if (!this._firstClusterSeen) {
const isCluster = (this.asteroidClusters ?? []).some((c) => c.discoveryId === o.id);
if (isCluster) {
this._firstClusterSeen = true;
this.comms?.trigger('firstCluster');
}
}
// The comms beat (data/comms.json: firstWorld — the trestle pair):
// Trestle meets the player the moment the FIRST world is discovered.
// The witness is the QUEST's own (discoveredWorldCount — the
// 'Discover another planet or space station' check: any planet or
// space station, the home world never counts, in any system), so the
// comm and the quest can never drift apart. The scene flag fires the
// event once; the comm's once-ledger dedupes across saves.
if (!this._firstWorldSeen && (this.discoveredWorldCount() ?? 0) >= 1) {
this._firstWorldSeen = true;
this.comms?.trigger('firstWorld');
}
// The system-complete milestone (data/comms.json: systemComplete —
// oac-system-complete): the moment this system's NAV chart COMPLETES —
// the discovery that makes its 'Unlock Jumpgates' research AVAILABLE
// (the SYSTEM tree's available hook, js/research/SystemCategory.js —
// the node flips live in the research console), NOT the research's
// completion. OAC reads the milestone; the voice rolls one of the
// three discovery-complete clips (audioPool); delayMs lets the
// discovery chime + toast land first. One fire per system — the
// arming set in create(), because a later cluster discovery in a
// charted system still passes isSystemNavComplete() (clusters aren't
// NAV points).
const sysId = this.systemRecord?.id;
if (sysId && this.isSystemNavComplete() && !this._systemCompleteSeen.has(sysId)) {
this._systemCompleteSeen.add(sysId);
this.comms?.trigger('systemComplete');
}
}
showTargetMarker(x, y) {
const color = toColor(config.get('game.markerColor', '#41c7ff'));
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
assignWorld(this, marker); // world-anchored — keep it off the UI pass
this.tweens.add({
targets: marker,
scale: 2.4,
alpha: 0,
duration: 450,
ease: 'Sine.easeOut',
onComplete: () => marker.destroy(),
});
}
hideHint() {
if (!this.hint || !this.hint.active) return;
if (this._hintClosing) return; // a fade-out is already running
this._hintClosing = true;
this.tweens.add({
targets: this.hint,
alpha: 0,
duration: 400,
onComplete: () => {
this._hintClosing = false;
if (this.hint) this.hint.destroy();
this.hint = null;
},
});
}
/**
* True while the ACK comm owns the input — it's up (paused), or it just
* ended on a click (the endedWithin race window, so the click that
* ACKNOWLEDGED never leaks into a world/deck/ESC action). The scene's
* input seams gate on this; the pause's action-freeze gates on
* comms.paused alone (strict).
*/
_commInputBlocked() {
const c = this.comms;
return !!(c && (c.paused || c.endedWithin(200)));
}
/**
* The deck's button presses (js/ui/ActionBar.js → onAction).
* 'menu' is the save system's door (menuAction below); the other
* slots are still seams for the player's loop.
*/
deckAction(id) {
// The ACK comm owns the input (no deck while the box holds — and the
// click that ACKNOWLEDGED is the comm's, never the deck's).
if (this._commInputBlocked()) return;
// The research console (full-screen, depth 80) — the deck's main feature.
if (id === 'research') {
if (this.researchWindow && this.researchWindow.isOpen) {
this.researchWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
this.researchWindow?.open();
return;
}
// The quests console (full-screen, depth 80) — the deck's third console.
if (id === 'quests') {
if (this.questWindow && this.questWindow.isOpen) {
this.questWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
this.questWindow?.open();
return;
}
// The map console (full-screen, depth 80) — the deck's other console.
if (id === 'map') {
if (this.mapWindow && this.mapWindow.isOpen) {
this.mapWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
this.mapWindow?.open();
return;
}
// The ship console (full-screen, depth 80) — the deck's hardware tab.
if (id === 'ship') {
if (this.shipWindow && this.shipWindow.isOpen) {
this.shipWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
if (this.mapWindow && this.mapWindow.isOpen) this.mapWindow.close();
if (this.questWindow && this.questWindow.isOpen) this.questWindow.close();
this.shipWindow?.open();
return;
}
if (id === 'menu') {
this.menuAction();
return;
}
if (id === 'scan') {
this.startScan();
return;
}
console.info(`[orbit] command deck: ${id}`);
}
// ------------------------------------------------------------ tutorial
/**
* The starter quest's POINTING FINGER (data/quests.json → tutorial —
* the targets' names ride on the data): which control is asking for
* the player's attention right now. The beat table is pure and
* node-tested in js/quests/TutorialGuide.js; this feeds it the run's
* live records:
* null the comms still own the screen, or the arc's goal (the
* Tether Level 2 research) is in flight / done — rest;
* 'scan' the welcome sequence is acknowledged (oac-intro →
* oac-tether → oac-scan all on the run's comms played-ledger)
* and SCAN hasn't been pressed yet → the deck's SCAN slot;
* 'research' OAC's 'research a Tether Level 2 upgrade' line
* (oac-upgrade) is on the ledger and the console is closed →
* the deck's RESEARCH slot;
* 'console' the same beat with the console open → the console's
* category tab, and — once the player has clicked it — the
* node's plate (ResearchWindow paints the right half from
* its own active category, so the click completes the beat).
* Derived from the records only — nothing extra is saved; a loaded run
* picks the same beat back up.
*/
_tutorialTarget() {
if (config.get('quests.enabled', true) !== true) return null;
const tut = config.section('quests.tutorial', {});
if (tut.enabled === false) return null;
const played = this.comms?.played;
if (!(played instanceof Set)) return null;
const cat = tut.console?.category ?? 'exploration';
const node = tut.console?.node ?? 'tether_l2';
const run = this.researchState?.getActive();
const resolving =
this.researchState?.isUnlocked(cat, node) === true ||
(run && run.category === cat && run.id === node);
return tutorialTarget({
starterGranted: this.questState?.isGranted('start_your_journey') === true,
hasPlayed: (id) => played.has(id),
scanPressed: this._tutScanPressed === true,
consoleOpen: this.researchWindow?.isOpen === true,
nodeResolving: resolving,
});
}
/**
* Apply the pointing finger to whichever surface owns it (per frame,
* update() — before the surfaces draw, so their update() paints the
* pulse from the target). Both setters are idempotent and no-ops for
* their current value, so re-applying every frame is cheap; the
* ActionBar hides the rim under the deck's standby (the 'ack' hold's
* contract — the ACKNOWLEDGE button is the only live control then),
* and the window hides it while closed.
*/
_applyTutorialHighlight() {
const target = this._tutorialTarget();
const tut = config.section('quests.tutorial', {});
this.actionBar?.setHighlight(
target === 'scan'
? tut.scan?.slot ?? 'scan'
: target === 'research'
? tut.research?.slot ?? 'research'
: null,
);
const con = this.researchWindow;
if (con) {
if (target === 'console') {
con.setHighlight(tut.console?.category ?? 'exploration', tut.console?.node ?? 'tether_l2');
} else {
con.setHighlight(null, null);
}
}
}
// ------------------------------------------------------------ quests
/**
* Has the named quest's reward been CLAIMED — the ledger's paid-out set
* (the same witness the run's chain uses to grant the NEXT quest,
* QuestState.grantUnlocked)? The research trees' STORY-QUEST gate
* (data/research/<id>.json → node. `quest`; today mining/premium_arm +
* mining/advanced_storage wait on 'special_delivery') asks this through
* attachQuestGates' live predicate. Claim (reward collected) is the bar
* — not checklist completion alone: the tech opens the moment the player
* presses CLAIM on the dossier and stays closed until then.
* @param {string} id a quest id (data/quests.json → quests[].id)
* @returns {boolean}
*/
questClaimed(id) {
return !!this.questState && this.questState.isClaimed(id);
}
/**
* The live dossier snapshot (QuestWindow polls it while open):
* the categories (tabs) + every HELD quest with its checklist
* evaluated against the run RIGHT NOW — research unlocks, lifetime
* asteroid mining, the home tether's standing, the discovery ledger —
* and made MONOTONIC by the ledger's completed set: a step that has
* read DONE is recorded and stays DONE (the witness rides the save),
* even where the live read can no longer see it (the home tether's
* standing once the ship has left the home system). The window is a
* pure view over this; the scene owns the effects (claimQuest) — the
* same split as the research/map consoles.
* @returns {{categories: Array<object>, quests: Array<object>}|null}
*/
questSnapshot() {
if (!this.questState) return null;
const tabs = config.get('quests.tabs', []);
const categories = (Array.isArray(tabs) ? tabs : []).map((c) => ({
id: String(c?.id ?? ''),
label: String(c?.label ?? c?.id ?? ''),
standby: c?.standby === true,
accent: c?.accent ?? null,
}));
const quests = [];
for (const def of questDefs()) {
if (!def || !this.questState.isGranted(def.id)) continue;
const checks = (Array.isArray(def.checks) ? def.checks : [])
.map((c) => this._questCheckView(def.id, c));
const doneCount = checks.filter((c) => c.done).length;
const complete = checks.length > 0 && doneCount === checks.length;
quests.push({
id: def.id,
category: def.category ?? 'main',
title: def.title ?? def.id,
description: def.description ?? '',
issuer: def.issuer ?? '',
icon: def.icon ?? 'diamond',
checks,
reward: this._questRewardLabel(def),
complete,
claimed: this.questState.isClaimed(def.id),
progress: `${doneCount}/${checks.length}`,
});
}
// The SIDE QUESTS tab (category 'side') — the ACCEPTED contracts
// (the side ledger, js/quests/SideQuests.js — the board's accepts):
// the patrol's checklist is its route's waypoints (the ledger's
// route record's visited marks — LIVE as the ship checks them in),
// its reward is the contract's payout (fixed at the accept), and
// its CLAIM rides the same claim seam (claimQuest dispatches the
// sq- ids to the side ledger's payout + idempotency). The tracker
// HUD stacks them after the main story (the QUESTS HUD, upper-left)
// — the one active contract's waypoints stand as its checklist.
const sideState = this.sideQuestState;
if (sideState) {
for (const id of sideState.acceptedList()) {
const view = this._sideQuestView(id);
if (view) quests.push(view);
}
}
return { categories, quests };
}
/**
* The ACCEPTED contract's posting (the side ledger's sq- id parsed
* back to its SYSTEM + the deterministic posting of its category/slot
* — the postings are regenerated on demand, the same regeneration the
* board + the detail readout stand on). The id's shape is
* 'sq-<systemId>-<category>-<slot>' (SideQuests's postingId — the
* greedy split is the true one: the category is letters, the slot
* digits, at the id's end).
* @returns {?{id: string, title: string, description: string, issuer: string, reward: object}}
*/
_sideQuestPosting(id) {
if (typeof id !== 'string' || !id.startsWith('sq-') || !this.galaxy) return null;
const m = parseSideQuestId(id);
if (!m) return null;
const rec = this.galaxy.byId.get(m.systemId) ?? null;
if (!rec) return null; // a foreign system id — no posting to stand a row from
return sideQuestPostingsFor(this.galaxy.seed, rec, m.worldKey, this.galaxy).find((p) => p.id === id) ?? null;
}
/**
* One side-quest row for the dossier + the tracker (the snapshot's
* category-'side' entry): the contract's posting (title /
* description / issuer), the patrol's checklist = its waypoints
* (visited marks LIVE), complete = every waypoint logged, claimed
* = the reward paid out, reward = the contract's payout (the route
* record's credits — fixed at the accept).
* @returns {?{id: string, category: string, title: string, description: string, issuer: string, icon: string, active: boolean, checks: Array<object>, reward: string, complete: boolean, claimed: boolean, progress: string}}
*/
_sideQuestView(id) {
const st = this.sideQuestState;
if (!st || !st.isAccepted(id)) return null;
const posting = this._sideQuestPosting(id);
if (!posting) return null; // a foreign id (degraded) — no posting to stand a row from
const route = st.routeFor(id);
const waypoints = route && Array.isArray(route.waypoints) ? route.waypoints : [];
const visited = route && Array.isArray(route.visited) ? route.visited : [];
const checks = waypoints.map((wp, i) => ({
label: `Waypoint ${i + 1}`,
done: visited.includes(i),
detail: visited.includes(i) ? 'LOGGED' : '',
}));
// The CONTACTS (the route's encounter waypoints — the contract's
// combat, cleared by the wave runs: the checklist's rows are the
// fight, the waypoints the flying). The rows count the contacts but
// do NOT name the waypoint a contact stands on — the ambush stays an
// ambush (where the hostiles fly is discovered in flight, not on the
// tracker). An old save's route (no encounters) stands on its
// waypoints alone.
const enc = route && route.encounters && Array.isArray(route.encounters.at) ? route.encounters : null;
const clearedSet = new Set(enc && Array.isArray(enc.cleared) ? enc.cleared : []);
if (enc) {
enc.at.forEach((wpIdx, i) => {
const cleared = clearedSet.has(wpIdx);
checks.push({
label: `Contact ${i + 1}`,
done: cleared,
detail: cleared ? 'CLEARED' : 'HOSTILES — CLEAR ON CONTACT',
});
});
}
// A HUNT's checklist stands on the ledger's mark alone: its legs check off
// WITH the kill, so a visited-count would open the CLAIM door in the frame
// between the mark going down and its escort stopping (the contract lands
// when the field clears — updateBountyHunt). A patrol derives its complete
// from the route itself, exactly as it always did.
const hunt = route?.kind === 'bounty';
const credits =
route && Number.isFinite(route.credits) && route.credits > 0
? route.credits
: (posting.reward?.credits ?? 0);
// The XP payout (the claim's second reward — beside the credits):
// the route's fixed XP (the experience worth of the patrol's
// defeated contacts — the accept's patrolEncounterXp). A route
// without one (old saves, the stubs) stands credits-only.
const xp = route && Number.isFinite(route.xp) ? Math.max(0, route.xp) : 0;
const complete = !hunt &&
waypoints.length > 0 && visited.length === waypoints.length &&
(!enc || enc.at.every((i) => clearedSet.has(i)));
return {
id,
category: 'side',
title: posting.title ?? id,
description: posting.description ?? '',
issuer: posting.issuer ?? '',
icon: 'diamond',
active: st.isActive(id),
checks,
reward: xp > 0 ? `${credits} CR · +${xp} XP` : `${credits} CR`,
// The ledger's mark is the claim's gate (the scene's claim path); the
// derived route state stands in for it in the transient frame between
// the last contact's clearance and the mark's landing.
complete: st.isCompleted(id) || complete,
claimed: st.isClaimed(id),
progress: `${visited.length}/${waypoints.length}`,
};
}
/**
* One checklist row for the dossier: the step's LIVE evaluation
* (_questCheckEval) made MONOTONIC by the quest ledger's completed
* set (QuestState). A step that reads DONE is recorded on the ledger
* (markCheck — with its detail line, refreshed while the live read
* still holds it) and from then on the RECORD is its source of truth:
* the check stands even where the live read can no longer see it (the
* home world's tether standing once the ship has left the home
* system), and a completed step can never read open again. The ledger
* is the run's — it rides the saves (record.quests), so the standing
* survives scene restarts + loads.
* @returns {{label: string, done: boolean, detail: string}}
*/
_questCheckView(questId, check) {
const live = this._questCheckEval(check);
const key = questCheckKey(check);
if (live.done) {
this.questState.markCheck(questId, key, live.detail); // the witness (idempotent)
return live;
}
if (this.questState.isCheckDone(questId, key)) {
return {
label: live.label,
done: true,
detail: this.questState.checkDetail(questId, key) ?? live.detail,
};
}
return live;
}
/**
* One checklist requirement, evaluated LIVE (data/quests.json →
* quests[].checks[].type). Pure reads — no effects here.
* research — the node is unlocked (data/research/<cat>.json)
* mineralsMined — lifetime asteroid mining >= amount (this.totalMined)
* tetherBuilt — the world's tether standing >= level ('home' = the home world)
* discoveredWorld — discovered planets/stations (not the home world) >= count
* systemCharted — the named system's NAV chart is complete (all its
* planets/stations/jumpgates discovered)
* gatesActivated — the named system's jumpgates are live
* trestleTalked — Trestle's three brief comms played (the comms ledger)
* trestleMet — Trestle's arrival pair played (the comms ledger)
* capitalLanded — the run landed on the neutral capital (this.story —
* the 'Special Delivery' bar beat + the trade gate)
* cargoUpgraded — the quest's package is ABOARD (check.id — the
* 'special-package', data/goods.json): it only fits
* once the hold is upgraded (the run's ship starts
* with cargoHold 0, data/ship.json), and the bar
* scene's DONE press is what upgrades + loads it
* (onVignetteComplete) — the parcel aboard is the
* upgrade's proof (recorded on the ledger's
* completed set the moment it reads DONE, so the
* step STAYS done even after the package is sold)
* packageDelivered— the delivery's proof holds (this.story.delivered —
* the package's HOME-WORLD SALE at zero credits;
* onQuestPackageSold sets it — landing alone no
* longer completes the step)
* vignettePlayed — the named VIGNETTE scene completed (registry
* 'vignettesPlayed' — VignetteHub marks it when the
* scene ENDS; data/vignettes.json's scene is the step
* this check is the witness of)
*/
_questCheckEval(check) {
const label = String(check?.label ?? '');
const type = String(check?.type ?? '');
if (type === 'research') {
const cat = String(check.category ?? '');
const node = String(check.node ?? '');
const done = !!this.researchState?.isUnlocked?.(cat, node);
return { label, done, detail: done ? 'RESEARCHED' : 'PENDING' };
}
if (type === 'mineralsMined') {
const amount = Math.max(0, Math.floor(Number(check.amount) || 0));
const got = Math.min(amount, Math.floor(this.totalMined ?? 0));
const done = amount > 0 && got >= amount;
return { label, done, detail: `${got}/${amount}` };
}
if (type === 'tetherBuilt') {
const level = Math.max(1, Math.floor(Number(check.level) || 1));
const name = check.planet === 'home' ? this.homeWorldName : String(check.planet ?? '');
const have = Math.max(0, this.tetherLevelFor(name) ?? 0);
const done = have >= level;
return { label, done, detail: `L${have}/L${level}` };
}
if (type === 'discoveredWorld') {
const count = Math.max(1, Math.floor(Number(check.count) || 1));
const have = Math.min(count, this.discoveredWorldCount());
const done = have >= count;
return { label, done, detail: `${have}/${count}` };
}
if (type === 'systemCharted') {
// EVERY NAV point of the named system ('home' = the home system)
// — the same discoverable set that gates the jumpgate research
// (navChart: planets, stations, gates — the central body is
// implicit, clusters don't count).
const sysId = this._questSystemId(check);
const content = sysId && this.galaxy && typeof this.galaxy.contentOf === 'function'
? (() => { try { return this.galaxy.contentOf(sysId); } catch { return null; } })()
: null;
const chart = sysId && content ? navChart(this.discovery, sysId, content) : null;
const done = !!chart && chart.complete;
return { label, done, detail: chart ? `${chart.discovered}/${chart.total}` : '??' };
}
if (type === 'gatesActivated') {
// The named system's jumpgates are LIVE (the activateGates effect's
// keys — the same set _activateSystemJumpgates writes).
const sysId = this._questSystemId(check);
const keys = sysId ? activationKeys(this.galaxy, sysId) : [];
const have = keys.filter((k) => this.activatedGates?.has?.(k)).length;
const done = keys.length > 0 && have === keys.length;
return { label, done, detail: `${have}/${keys.length}` };
}
if (type === 'trestleTalked') {
// A TRESTLE SEQUENCE (data/comms.json, trigger check.trigger —
// default 'trestleBrief', the 'Leaving the Nest' gates-online
// briefing; 'Pirate Problems' points at 'trestleQ4', the grant's
// three-beat warning): the run's comms played-ledger (registry
// 'commsPlayed') is the witness — all of them heard = the player
// talked to him (the check's trigger parameter keeps the two
// quests' Trestle lines apart, the played-ledger shared).
return this._trestleCheck(String(check.trigger ?? 'trestleBrief'), label, 'TALKED');
}
if (type === 'trestleMet') {
// His ARRIVAL PAIR (trigger 'trestleArrival' — the player arrives
// at the destination planet, the neutral capital world) in the
// ledger = met.
return this._trestleCheck('trestleArrival', label, 'MET');
}
// 'Special Delivery' (data/quests.json → special_delivery) — the
// witnesses are this.story's ledger (_recordLanding +
// onSurfaceDeparture own the writes; SaveData carries them to
// record.story and back, so the progress survives a load).
if (type === 'capitalLanded') {
// Step 1 — the bar: a real landing on the neutral capital (Trestle
// and Carol). The same world _capitalLandingBlocked gates — the
// arrival that is allowed is the arrival that counts.
const done = this.story?.capitalLanded === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'cargoUpgraded') {
// Step 2 — the ship can HOLD CARGO: the witness is the quest's
// package ABOARD (check.id — the 'special-package' in
// data/goods.json; the quest's packageGood is the fallback). It
// only fits once the hold is upgraded (the run's ship starts with
// cargoHold 0 — data/ship.json), and the bar scene's DONE press is
// what does the upgrade + the load (onVignetteComplete), so the
// parcel's presence IS the upgrade's proof — the step stands done
// the moment the package joins the hold (the scene's lines are the
// truth: Carol's crew built out the bays).
const goodId =
String(check.id ?? '').trim() ||
String(questDef('special_delivery')?.packageGood ?? '').trim();
const done = goodId !== '' && this.ship?.cargo?.has?.(goodId) === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'packageDelivered') {
// Step 3 — the parcel in Carol's hands on the home world: the proof
// is the package's HOME-WORLD SALE (zero credits — the delivery,
// not a commodity; onQuestPackageSold set it when the shop's
// Confirm ran it, and the grid only offers the sale there, so the
// sale IS the delivery — landing alone no longer completes it).
// Monotonic by design: once the delivery stands, it stands (the
// quest can be CLAIMED and stay claimed, the run's record is the
// record).
const done = this.story?.delivered === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'vignettePlayed') {
// A VIGNETTE scene (data/vignettes.json) completed: the run's
// vignette played-ledger (registry 'vignettesPlayed' — VignetteHub
// marks it when the scene ENDS; SaveData saves/restores it) is the
// witness — the scene named by check.id is the one the step is
// about (the bar's Trestle + Carol meeting for 'Special Delivery').
// The scene plays over the location's looping clip (the window is
// the moment; the clip keeps moving beneath it) and its DONE press
// is what sets this — the conversation IS the step.
const id = String(check.id ?? '').trim();
const played = this._vignettesPlayed();
const done = id !== '' && played.has(id);
return { label, done, detail: done ? 'SEEN' : 'PENDING' };
}
// 'Pirate Problems' (data/quests.json → pirate_problems) — the run's
// first COMBAT step + the return leg.
if (type === 'combatCleared') {
// The pirate wave CLEARED (check.script — data/combat.json →
// waves.scripts' name, 'pirate_ambush'): the run's combat done-
// ledger (registry 'combatDone' — CombatSystem's onComplete marks
// it via GameScene._recordCombatDone; SaveData captures it under
// record.combat.done) is the monotonic witness — the wave can be
// re-fired on a load (its in-flight state is not saved) but once
// it has FINISHED the step stands, no re-fight required. The ambush
// only starts in the capital's system (GameScene.
// _maybeStartPirateAmbush), so the step opens with the quest.
const script = String(check.script ?? '').trim();
const done = script !== '' && this._combatDone().has(script);
return { label, done, detail: done ? 'CLEARED' : 'PENDING' };
}
if (type === 'capitalReturned') {
// The RETURN arrival (the quest's destination, the Neutral Capital
// world): the story ledger's capitalReturned flag — set the moment
// the ship reaches the world's keep-out rim with the quest held +
// unclaimed (GameScene._maybeWitnessCapitalReturn, called from
// update()'s destination pass; monotonic, SaveData-captured under
// record.story). 'I'll add more speech' is a later layer — the
// arrival IS the step (the bar scene lands on it).
const done = this.story?.capitalReturned === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
return { label, done: false, detail: '??' };
}
/**
* A Trestle story-beat check (the 'Leaving the Nest' quest's two
* social lines): every comm on `trigger` (data/comms.json) in the
* run's comms played-ledger (registry 'commsPlayed' — CommsHub marks
* it when a comm STARTS; SaveData saves/restores it) = done. Reading
* the trigger's roster data-driven keeps the check and the comms
* from drifting (a beat added to the sequence completes the check
* only once it too has been heard), and a quit mid-sequence can't
* fake it — the unplayed beats re-arm on the next load and the
* ledger knows.
* @param {string} trigger the comm trigger the witness rides on
* @param {string} label the checklist line
* @param {string} doneWord the detail once done
*/
_trestleCheck(trigger, label, doneWord) {
const ids = commsForTrigger(config.section('comms', {}), trigger).map((c) => c.id);
const played = this._commsPlayed();
const have = ids.filter((id) => played.has(id)).length;
const done = ids.length > 0 && have === ids.length;
return { label, done, detail: done ? doneWord : `${have}/${ids.length}` };
}
/**
* The run's comms played-ledger (registry 'commsPlayed' — CommsHub
* owns it; SaveData captures/restores it under record.comms.played).
* Empty set when the run hasn't armed a comm yet (headless harnesses
* included — the checks just read zero).
* @returns {Set<string>}
*/
_commsPlayed() {
const cur = this.registry?.get?.('commsPlayed');
return cur instanceof Set ? cur : new Set();
}
/**
* The run's vignette played-ledger (registry 'vignettesPlayed' —
* VignetteHub owns it, SaveData captures/restores it under
* record.vignettes.played). Vignettes mark on COMPLETION (the opposite
* of comms' mark-on-start — a scene left unfinished re-arms), so this
* is also the 'vignettePlayed' check's progress record.
* @returns {Set<string>}
*/
_vignettesPlayed() {
const cur = this.registry?.get?.('vignettesPlayed');
return cur instanceof Set ? cur : new Set();
}
/**
* A quest check's system: 'home' → the home system (the nest); a
* literal id otherwise (future quests may name a specific system).
* @returns {string|null}
*/
_questSystemId(check) {
const sys = check?.system;
if (sys === 'home') return this.galaxy?.homeSystemId ?? null;
return typeof sys === 'string' && sys ? sys : null;
}
/**
* How many DISCOVERED worlds are planets or space stations (the
* 'Discover another planet or space station' requirement). The home
* world never counts (it is the origin, not a discovery), and neither
* do asteroid clusters / jump gates (the requirement is a WORLD or a
* STATION). The ledger is per-system (Discovery), so the count walks
* every system the player has found something in: the current system's
* objects are the scene's own entities; other systems resolve their
* ids against the generated content (galaxy.contentOf — deterministic,
* cached once generated).
*/
discoveredWorldCount() {
const disc = this.discovery;
if (!disc || !disc.bySystem) return 0;
const cur = this.systemRecord?.id ?? null;
let n = 0;
for (const [sysId, ids] of disc.bySystem) {
let planetIds = null;
let stationIds = null;
if (sysId === cur) {
planetIds = new Set((this.systemPlanets ?? []).map((p) => p.discoveryId));
stationIds = new Set((this.systemStations ?? []).map((st) => st.discoveryId));
} else if (this.galaxy && typeof this.galaxy.contentOf === 'function') {
let content = null;
try {
content = this.galaxy.contentOf(sysId);
} catch {
content = null;
}
planetIds = new Set(((content?.planets ?? []).map((r) => r?.name)).filter(Boolean));
stationIds = new Set(
((content?.settlements ?? []).filter((s) => s?.anchor?.type === 'space').map((s) => s?.id)).filter(Boolean),
);
}
if (!planetIds && !stationIds) continue;
for (const id of ids) {
if (!id || id === 'home') continue;
if (planetIds.has(id) || stationIds.has(id)) n++;
}
}
return n;
}
/** The reward line for a quest def (data/quests.json → reward object). */
_questRewardLabel(def) {
const rw = def?.reward;
if (rw && typeof rw === 'object') {
const parts = [];
if (typeof rw.minerals === 'number') parts.push(`${rw.minerals} MINERALS`);
if (typeof rw.credits === 'number') parts.push(`${rw.credits} CREDITS`);
return parts.length ? parts.join(' · ') : '';
}
return String(rw ?? '');
}
// ------------------------------------------------- SIDE QUESTS (board)
/**
* The WORLD's LIVE postings (the job board's list — the surface's window
* renders this over the sleeping scene, the QuestWindow precedent).
* The board is PER SURFACE WORLD: the `worldName` + `isStation` the
* caller stands on (the surface's own world — a planet's name, or a
* free-space station's name) resolve to that world's key (its record id
* minus the system prefix — p<ordinal> / s<n>) and seed the board, so two
* worlds in one system stand two different boards. Deterministic in
* (galaxy seed, system record, world) — the same contracts stand here
* across visits, scene restarts, and saves (js/quests/SideQuests.js).
* A world that can't be resolved (a data gap) borrows the first world's
* board (the model's fallback) — the board never blanks. [] when the
* feature is off or no category is live (the board's standby state).
* @param {string} [worldName] the surface world's name (a planet / station)
* @param {boolean} [isStation] the surface is a free-space station
* @returns {object[]}
*/
sideQuestPostings(worldName = null, isStation = false) {
const rec = this.systemRecord ?? null;
if (!this.galaxy || !rec) return [];
const worldKey = this.surfaceWorldKey(worldName, isStation) ?? 'p1';
// The player's combat level rides the read (the BOUNTY HUNT's gate): below
// categories.bounty.minLevel the row still stands in its seeded slot, LOCKED
// — the gate is presentation + accept, never generation.
return sideQuestPostingsFor(this.galaxy.seed, rec, worldKey, this.galaxy, this.combatLevel());
}
/**
* The SURFACE WORLD's board key — the record id minus the system prefix
* (a planet's `p<ordinal>` / a free-space station's `s<n>`), resolved off
* the system's content by the world's NAME. null when the name stands
* no record of its kind (the caller then borrows p1 — the model's
* fallback) — a board never blanks for a missing key.
* @param {string} [name] the surface world's name
* @param {boolean} [isStation] a free-space station (not a planet)
* @returns {string|null}
*/
surfaceWorldKey(name, isStation = false) {
const rec = this.systemRecord ?? null;
if (!rec || typeof rec.id !== 'string' || rec.id === '') return null;
if (typeof name !== 'string' || name === '') return null;
const content = this.systemContent ?? null;
if (!content) return null;
const recs = isStation
? ((content.settlements ?? []).filter((s) => s?.anchor?.type === 'space'))
: (content.planets ?? []);
const hit = (Array.isArray(recs) ? recs : []).find((w) => w?.name === name);
if (!hit || typeof hit.id !== 'string' || !hit.id.startsWith(rec.id + '-')) return null;
return hit.id.slice(rec.id.length + 1); // 'p2' / 's4' — the record id's world part
}
/** The run's combat level (js/combat/XpManager.js); null when no XP bank stands. */
combatLevel() {
const n = Number(this.combat?.xp?.level);
return Number.isFinite(n) ? n : null;
}
/**
* The board's ACCEPT (the surface's window → SurfaceScene.
* acceptSurfaceJob → here — the claimSurfaceQuest precedent: the
* ledger + sfx are the scene's, the toast is the surface's). Takes the
* contract ONTO the run's SideQuestState ledger exactly once (accept()
* is idempotent — a double-press or a re-open can't double-take it) and
* returns the posting so the caller can note + repaint. The caller
* stands on a WORLD (its surface), so the accept validates the id
* against THAT world's board (sideQuestPostings(worldName, isStation))
* — a posting another world posts (or a foreign id, or a category
* since gone) is a refusal, not a record.
* @param {string} jobId the contract's id
* @param {string} [worldName] the surface world's name (a planet / station)
* @param {boolean} [isStation] the surface is a free-space station
* @returns {{ok:boolean, posting?:object}}
*/
acceptSideQuest(jobId, worldName = null, isStation = false) {
if (!this.sideQuestState) return { ok: false };
const posting = this.sideQuestPostings(worldName, isStation).find((p) => p.id === jobId) ?? null;
if (!posting) return { ok: false };
// The LEVEL GATE (data/sidequests.json → categories.bounty.minLevel — the
// hunt's 2): a locked contract is a REFUSAL, never a record. The board
// renders the row LOCKED; pressing it anyway says why in the data's own
// words (`bountyAcceptDenyToast`) and hands the caller `locked` so the
// surface console can say the same thing out loud.
const gate = sideQuestAcceptable(posting, this.combatLevel());
if (!gate.ok) {
const deny = bountyDenyCopy();
this.consoleToast(String(deny?.text ?? 'COMMISSION REFUSED'), {
glyph: deny?.glyph || '\u2717',
glyphColor: toCss(deny?.color || '#ff5c77'),
});
return { ok: false, locked: true, posting };
}
if (!this.sideQuestState.accept(jobId)) return { ok: false };
// The PATROL's ROUTE stands WITH the accept — the route is the
// contract (the scene's _establishPatrol builds it from the player's
// live tether context + the target system's seeded content; the
// ledger's route record rides the save, so a re-accept after a load
// keeps the SAME coordinates — the route never re-rolls).
if (posting.category === 'patrol') this._establishPatrol(jobId, posting);
else if (posting.category === 'bounty') this._establishBountyHunt(jobId, posting);
else if (posting.category === 'escort') this._establishEscort(jobId, posting);
this.playSfx('construct'); // the power-up tick — the contract lands
return { ok: true, posting };
}
/**
* The PATROL's accept — stand its ROUTE on the ledger (buildPatrolRoute
* — js/quests/SideQuests.js). The REGION is the contract's flight
* envelope: the player's UNLOCKED tether regions when the target is
* the system they stand in (their live tether field — the range they
* can actually fly in there), or one tether region (a level-1 radius)
* around each of the target's planets / stations / gates when it is a
* system not yet unlocked (its seeded content — the worldgen contract:
* the same layout the player will find on arrival). The keep-out
* anchors are the same objects' centers (the waypoints stand clear of
* them — the data's anchorClearance). The contract's payout is fixed
* here too (route.credits — the claim pays this, even if the band is
* re-tuned later: a signed contract is a signed contract).
*/
_establishPatrol(jobId, posting) {
const targetId = posting.targetSystemId ?? this.systemRecord?.id ?? '';
if (!targetId || !this.sideQuestState) return;
const { region, anchors } = this._routeFlightEnvelope(targetId);
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, targetId, region, anchors);
route.credits = posting.reward?.credits ?? 0; // the contract's payout (fixed at the accept)
// The contract's ENCOUNTERS — its hostile contacts (data/sidequests.json
// → patrol.encounters): the count by the data's odds (1 on 20% of
// patrols, 2 on 20%, 3 on 60%), their waypoints SPREAD across the
// route, their roster the standing band's enemy types (the player's
// level × the target's hops from home — today the low band, levels
// 1–5 at 1–2 hops, the established type only), their fight one
// rolling wave of six, three at a time. Rolled seeded at the accept
// (the labelled fork) and fixed on the route record — a signed
// contract is a signed contract: a later re-tune of the data's odds
// or bands can't re-roll a standing contract's combat.
const level = Number(this.combat?.xp?.level) || 0;
const depth = this.galaxy?.jumpNetwork?.depth;
const hops = depth instanceof Map && Number.isFinite(depth.get(targetId)) ? depth.get(targetId) : 0; // the target's tree depth from home (0 = home)
// THE WAR MODEL — the sighted rival (the posting's enemyFaction): the
// contact's roster is THAT faction's own fighters (not pirates), one
// seeded rival per posting (a signed contract is a signed contract).
route.encounters = derivePatrolEncounters(this.galaxy?.seed ?? 0, jobId, targetId, route.waypoints, level, hops, posting.enemyFaction ?? null);
// The contract's XP PAYOUT (the claim's second reward — beside the
// credits): the experience WORTH of the patrol's defeated contacts
// (patrolEncounterXp — the contacts × the wave's total × the band's
// roster xp), fixed on the route with the credits (a signed
// contract is a signed contract). The claim pays it into the run's
// XP bank (claimSideQuest — the 'sidequest' source).
route.xp = patrolEncounterXp(route.encounters);
this.sideQuestState.setRoute(jobId, route);
}
/**
* The contract's FLIGHT ENVELOPE (shared by the patrol's route and the
* BOUNTY HUNT's lanes — the same worldgen law, the same keep-out): the
* player's UNLOCKED tether regions when the target is the system they stand
* in (their live tether field), else one tether region (a level-1 radius)
* around each of the target's planets / stations / gates (its seeded content
* — the layout they will find on arrival). The anchors are those same
* objects' centers. A pure read of the scene's world; behavior identical to
* the block it was lifted out of.
* @returns {{region: Array<{x:number,y:number,radius:number}>, anchors: Array<{x:number,y:number}>}}
*/
_routeFlightEnvelope(targetId) {
let region = [];
let anchors = [];
if (targetId === this.systemRecord?.id) {
// The system the player stands in: their UNLOCKED tether regions.
const live = [...(this.systemPlanets ?? []), ...(this.systemStations ?? []), ...(this.systemGates ?? [])];
if (this.planet) live.push(this.planet);
anchors = live.map((e) => ({ x: e.x, y: e.y }));
region = (this.tetherField?.tethers ?? []).map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
} else {
// A NEIGHBOR system (unlocked or not): one tether region around
// each of its planets / stations / gates (the content's layout —
// seeded, the same the player will find on arrival).
const content = this.galaxy?.contentOf?.(targetId) ?? null;
const pts = [
...(Array.isArray(content?.planets) ? content.planets : []),
...(Array.isArray(content?.settlements) ? content.settlements : []),
...(Array.isArray(content?.jumps) ? content.jumps : []),
].filter((a) => Number.isFinite(a?.x) && Number.isFinite(a?.y));
anchors = pts.map((a) => ({ x: a.x, y: a.y }));
const radius = Tether.radiusForLevel(1);
region = pts.map((a) => ({ x: a.x, y: a.y, radius }));
}
return { region, anchors };
}
// -------------------------------------------------- the BOUNTY HUNT's lanes
// (js/quests/SideQuests.js owns the MODEL — bountyRules / deriveBountyPlan /
// bountySkirmishScript / bountyEngagementScript / bountyHuntXp /
// nextBountyWaypoint / bountyRouteComplete. What follows is the FLYING: the
// accept's record, the per-frame check-in and engagements, the kill's witness,
// and the beacons. A hunt's route record stands `kind:'bounty'`, and that one
// field is what branches every path here away from patrol law.)
/**
* The BOUNTY HUNT's accept — stand its LANES + its hidden PLAN on the ledger.
* The plan rolls FIRST (deriveBountyPlan, its own labelled fork) because it
* owns the leg count; the route is then built through the SAME tether-safe,
* seeded builder as a patrol, asked for exactly that many legs under the
* 'bounty' fork. Which leg holds the mark, and which legs stand the leader's
* pickets, rides the record INVISIBLE — no board row, beacon, compass entry or
* toast reads it out before the fight stands up (the design's one rule).
* The payout is fixed beside it (credits + bountyHuntXp): a signed contract is
* a signed contract.
*/
_establishBountyHunt(jobId, posting) {
const targetId = posting.targetSystemId ?? this.systemRecord?.id ?? '';
if (!targetId || !this.sideQuestState) return;
const level = Number(this.combat?.xp?.level) || 0;
// The dossier's own story names the craft the leader flies (`craft`,
// validated in the model against combat.enemyTypes) and its NAME — the name
// rides the plan so the comms can say who it was once the fight is here.
const plan = deriveBountyPlan(this.galaxy?.seed ?? 0, jobId, targetId, level, {
craft: posting.dossier?.craft ?? null,
// THE WAR MODEL — the mark's own faction (the posting's enemyFaction):
// its elite frame + escorts are drawn from THAT faction's roster (a
// faction's wanted target flies a faction frame, not a pirate craft).
faction: posting.enemyFaction ?? null,
mark: { name: posting.dossier?.name ?? '', epithet: posting.dossier?.epithet ?? '' },
});
const { region, anchors } = this._routeFlightEnvelope(targetId);
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, targetId, region, anchors, {
waypoints: [plan.legs, plan.legs], // exactly the plan's legs — no more, no fewer
fork: 'bounty', // its own seeded stream (a patrol of the same id never collides)
});
route.kind = 'bounty'; // THE branch flag — the update / compass / markers read it
route.bounty = plan; // the hidden chain, fixed at the accept (rides the save)
route.credits = posting.reward?.credits ?? 0;
route.xp = bountyHuntXp(plan); // the claim's second reward, fixed here too
this.sideQuestState.setRoute(jobId, route);
}
// ------------------------------------------------------- ESCORT (the convoy job)
//
// The third contract: a neutral TRANSPORT (its own ship class —
// js/combat/TransportShip.js, ~2.5× a fighter, far heavier armor) plus 1–2
// escort FIGHTERS stands at a single RENDEZVOUS waypoint in the posting's
// own system. The player flies to the waypoint (visitRadius) and the convoy
// LINKS (phase 'rendezvous' → 'transit') — from then on the transport +
// fighters hold their escort ring around the player through every remaining
// leg, re-forming across jumps (the route record carries the transport's
// armor — a signed contract is a signed contract). The DESTINATION is a
// planet / free-space station 1–2 gate hops away (a seeded neutral walk),
// fixed at the accept. While linked the LANE IS HOT: the escort director
// (its own EncounterDirector) fires waves of mixed fighter types, one at a
// time (the patrol contact's law). The TRANSPORT is the job: it fights
// alongside, it is a legal enemy target, and if it is DESTROYED the
// contract FAILS (phase 'failed') — the player must LAND ON A PLANET to
// reset it (startLanding's escort reset: a fresh rendezvous, a fresh
// transport, the same destination). COMPLETION — the convoy arrives at the
// destination object with the sky clear (no enemy fighter up).
/** The active ESCORT contract (its id + route record) or null. */
_escortRoute() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || st.isCompleted(id) || st.isClaimed(id)) return null;
const route = st.routeFor(id);
if (!route || route.kind !== 'escort' || !route.escort) return null;
return { id, route };
}
/**
* ACCEPT the escort (the board's ACCEPT → here): stand the RENDEZVOUS at a
* single waypoint in the posting's own system (the route builder's shared
* placement law, its 'escort' fork), compute the 1–2 hop neutral DESTINATION
* (seeded — a signed contract is a signed contract), and fix the reward +
* the transport's standing armor on the route record (the escort sub-record
// rides the save; setRoute normalizes it).
*/
_establishEscort(jobId, posting) {
// THE WAR MODEL — a faction board's escort (the posting's enemySystemId
// stands): the transport is trapped in the HOSTILE neighbor (the
// RENDEZVOUS ground), and the DELIVERY is the board's OWN friendly
// system (the convoy must fly back through the gate). A neutral board's
// escort keeps the classic contract (the convoy stands in the posting's
// own system; the delivery is a 1–2 hop neutral walk).
const isFaction = typeof posting?.enemySystemId === 'string' && posting.enemySystemId !== '';
const originId = isFaction
? String(posting.enemySystemId) // the hostile system (the transport stands here)
: String(posting?.systemId ?? this.systemRecord?.id ?? '');
const friendlyId = isFaction
? String(posting.systemId ?? this.systemRecord?.id ?? '') // the board's own system (the delivery)
: '';
if (!originId || !this.sideQuestState) return;
const level = Number(this.combat?.xp?.level) || 0;
// DESTINATION — the neutral escort's seeded 1–2 hop neutral walk, or the
// faction escort's friendly delivery (the board's own system + a seeded
// destination world inside it). Degrades: no destination stands → the
// route still flies, the delivery waits at the origin.
const dest = isFaction
? (escortFriendlyDestination(this.galaxy?.seed ?? 0, jobId, friendlyId, this.galaxy) ?? null)
: (escortDestinationFor(this.galaxy?.seed ?? 0, jobId, originId, this.galaxy) ?? null);
// RENDEZVOUS — ONE waypoint inside the origin system's flight envelope.
const { region, anchors } = this._routeFlightEnvelope(originId);
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, originId, region, anchors, {
waypoints: [1, 1], // a single standing point — the convoy waits here
fork: 'escort', // its own seeded stream (a patrol of the same id never collides)
});
route.kind = 'escort'; // THE branch flag
route.targetSystemId = dest?.destSystemId ?? originId; // the DESTINATION system (compass / delivery)
route.credits = posting.reward?.credits ?? 0;
route.xp = dest ? escortEncounterXp({ hops: dest.hops, level }) : 0; // the claim's second reward
// THE TRANSPORT'S STANDING ARMOR (data/combat.json → transport) — the
// record is the contract: a jump or a save never re-rolls a standing hull.
const tc = config.get('combat.transport', {}) || {};
const thull = Math.max(1, Math.floor(Number(tc.hull) || 1200));
const tshields = Math.max(0, Math.floor(Number(tc.shields) || 500));
// THE ESCORT FIGHTERS' COUNT (data → escort.escortCount [1,2], seeded).
const erange = config.get('sidequests.escort.escortCount', [1, 2]);
const eLo = Math.max(0, Math.floor(Number(Array.isArray(erange) ? erange[0] : 1)));
const eHi = Math.max(eLo, Math.floor(Number(Array.isArray(erange) ? erange[1] : 2)));
const r = Rng.derive(this.galaxy?.seed ?? 0, 'sidequests', originId, 'escort', String(jobId), 'crew');
route.escort = {
phase: 'rendezvous',
originSystemId: originId,
originSystemName: String(this.systemRecord?.name ?? originId),
destSystemId: dest?.destSystemId ?? '',
destSystemName: dest?.destSystemName ?? '',
destObjectType: dest?.destObjectType ?? 'planet',
destObjectId: dest?.destObjectId ?? '',
destObjectName: dest?.destObjectName ?? '',
hops: dest?.hops ?? 1,
transportMaxHull: thull, // the STANDING armor (a reset re-stands at this)
transportMaxShields: tshields,
transportHull: thull, // the LIVE armor (persists across a jump's restart)
transportShields: tshields,
escortCount: eHi >= eLo ? r.int(eLo, eHi) : 1,
name: String(posting?.title ?? posting?.label ?? 'the Convoy'), // the convoy's name (the posting's title)
// THE WAR MODEL — the captors' faction (the posting's enemyFaction):
// the lane's waves are THAT faction's fighters, and the delivery is the
// friendly system (the convoy must fly back through the gate). A neutral
// escort carries none (the classic pirate-lane contract).
enemyFaction: posting.enemyFaction ?? null,
};
this.sideQuestState.setRoute(jobId, route);
// POINTING — the rendezvous beacon is already up (_drawEscortMarkers);
// a pre-link toast calls the player over to it.
const t = config.get('sidequests.ui.escortRendezvousToast', {});
this.playSfx('discovery');
this.consoleToast(String(t.text ?? 'CONVOY STANDING BY — REACH THE RENDEZVOUS TO LINK'), {
glyph: t.glyph ?? '◇', glyphColor: t.color ? toCss(t.color) : undefined,
});
}
/**
* The compass's next stop for the escort: before the link, the RENDEZVOUS
* waypoint (in the origin system); after the link, the DESTINATION — its
* object (when already in the destination system), else the gate that jumps
* toward it (planRoute's next). The escort's own color + type label.
*/
escortCompassTarget() {
const sel = this._escortRoute();
if (!sel || !this.ship || !this.systemRecord) return null;
const { route } = sel;
const esc = route.escort;
const style = {
color: config.get('sidequests.escort.compass.color', '#e87cf5'),
typeLabel: config.get('sidequests.escort.compass.typeLabel', 'Escort'),
};
const originId = esc.originSystemId;
const destId = esc.destSystemId;
// BEFORE the link (or with no destination): the rendezvous waypoint.
if (esc.phase === 'rendezvous' || !destId) {
if (originId === this.systemRecord.id) {
const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null;
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1])) return { kind: 'waypoint', idx: 0, x: wp[0], y: wp[1], ...style };
}
// Left the origin (defensive) — the gate back to it.
for (const gt of this.systemGates ?? []) if (gt.gate?.to === originId) return { kind: 'gate', gate: gt, ...style };
return null;
}
// AFTER the link: the DESTINATION. Its own `destination` kind (NOT the
// waypoint/idx shape): the idx would resolve autopilot's `patrol:wp:` to
// route.waypoints[0] — the escort's RENDEZVOUS point — and the ship would
// fly the rendezvous coordinates inside the destination system instead
// of the world itself.
if (destId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
// The autopilot id is the world's DISCOVERY id (a planet's NAME,
// a station's settlement id) — its record id would miss the
// discoverable/solid lookups and the click would no-op.
if (obj) return { kind: 'destination', objectId: obj.discoveryId ?? obj.id, name: obj.name, x: obj.x, y: obj.y, radius: obj.radius, ...style };
}
// Not there yet — the gate toward the next hop on the way to it.
let next = null;
try {
next = planRoute(this.galaxy, this.systemRecord.id, destId)?.next ?? null;
} catch { next = null; }
if (next) for (const gt of this.systemGates ?? []) if (gt.gate?.to === next) return { kind: 'gate', gate: gt, ...style };
return null;
}
/**
* The destination OBJECT in the CURRENT system (the delivery's final stop) —
* matched by its record id / name against the live system content (the scene's
* `systemContent` IS galaxy.ensureContent, so the ids line up with the record).
* Null when not in the destination system (the compass / delivery wait).
*/
_escortDestinationObject(route) {
const esc = route.escort;
if (!esc || !esc.destSystemId) return null;
if (route.targetSystemId !== this.systemRecord?.id) return null; // not here yet
const content = this.systemContent ?? null;
if (!content) return null;
const id = String(esc.destObjectId ?? '');
const name = String(esc.destObjectName ?? '');
const planets = Array.isArray(content.planets) ? content.planets : [];
const settlements = Array.isArray(content.settlements) ? content.settlements : [];
const rec =
planets.find((o) => o && String(o.id) === id && id !== '') ??
settlements.find((o) => o && String(o.id) === id && id !== '') ??
planets.find((o) => o && String(o.name) === name && name !== '') ??
settlements.find((o) => o && String(o.name) === name && name !== '');
if (rec && Number.isFinite(rec.x) && Number.isFinite(rec.y)) {
// The LIVE entity behind this record, when it stands here (a planet's
// disc / a station's keepout). Match on the entity's DISCOVERY name
// (the world's name) — the Planet's `name` field is its CLASS label,
// and its discoveryId is the world's NAME, not the record id — so
// discoveryName/discoveryId === rec.name is the planet's join key.
const isPlanet = planets.includes(rec);
const planet = (this.systemPlanets ?? []).find((e) => e && (e.discoveryName === rec.name || e.discoveryId === rec.id || e.discoveryId === rec.name));
const station = (this.systemStations ?? []).find((e) => e && (e.settlement?.id === rec.id || e.discoveryName === rec.name));
const live = planet ?? station ?? null;
const radius = Number(planet?.radius) || Number(station?.bound) || Number(station?.radius) || Number(rec.radius) || 40;
// discoveryId = the compass/autopilot identity (a planet's NAME, a
// station's settlement id) — the autopilot's `escort:obj:` resolution
// matches on it (the record id would miss the lookups and the click
// would no-op).
return {
x: rec.x, y: rec.y, id: rec.id,
discoveryId: live?.discoveryId ?? (isPlanet ? rec.name : rec.id),
name: rec.name, radius,
};
}
return null;
}
/**
* THE CONVOY — stand the transport + its escort fighters around the player.
* Idempotent + self-healing: a scene restart (a jump) tears the scene-local
* sprites down, so this re-forms them on the next frame; a reset (a landing)
// re-stands a fresh transport at full armor. The record's armor is the
// contract — a jump/save never re-rolls a standing hull.
*/
_ensureEscortConvoy(id, route) {
if (!this.combat) return; // no combat system — the route degrades to the flight alone
const esc = route.escort;
if (esc.phase === 'failed' || esc.phase === 'delivered') return;
const px = this.ship?.x ?? 0;
const py = this.ship?.y ?? 0;
// TRANSPORT — a persistent player-side craft (the job itself).
if (!this._escortTransport || this._escortTransport.dead) {
const a = Math.PI * 0.9; // behind the player, on its left
const rad = 130 + 40 * (Number(esc.escortCount) || 1);
const t = this.combat.spawnTransport(px + Math.cos(a) * rad, py + Math.sin(a) * rad, 0);
// THE ARMOR — the record is the contract: the STANDING (max) hull is
// fixed, and the LIVE hull is restored exactly (a jump never heals a
// scrape; a reset — _escortResetForLanding — is what re-stands it full).
const maxH = Math.max(1, Math.floor(Number(esc.transportMaxHull) || Number(esc.transportHull) || 1));
const maxS = Math.max(0, Math.floor(Number(esc.transportMaxShields) || Number(esc.transportShields) || 0));
t.maxHull = maxH;
t.hull = Math.min(maxH, Math.max(1, Number(esc.transportHull) || maxH));
t.maxShields = maxS;
t.shields = Math.min(maxS, Math.max(0, Number(esc.transportShields) || maxS));
this._escortTransport = t;
}
// ESCORT FIGHTERS — persistent too (they hold the wing for the contract's
// life). Stand the record's count (a lost one re-forms on the next frame).
const want = Math.max(0, Math.floor(Number(esc.escortCount) || 0));
let have = 0;
for (const a of this.combat.allies) if (a && a._escortCrew && !a.dead && !a._removed) have++;
for (let i = 0; i < want - have; i++) {
const ang = Math.PI * (1.1 + i * 0.6);
const rad = 150 + i * 34;
const f = this.combat.spawnAlly(px + Math.cos(ang) * rad, py + Math.sin(ang) * rad, 40 + i, null, {
escortMs: Number.POSITIVE_INFINITY, // a convoy fighter stays (no break-off)
});
f.escortMs = Number.POSITIVE_INFINITY; // the window never lapses (spawnAlly's roll is finite)
f.persistent = true; // the active / neutralOnly gates skip it (like the transport)
f._escortCrew = true; // the count above tracks only these
}
}
/**
* THE CONVOY STOPS — pull the transport + its fighters out of the world (the
* contract is over: delivered, or the transport is lost). A clean teardown —
// no kill FX, no feed entry (they stand down, they aren't shot down).
*/
_escortBreakConvoy() {
if (!this.combat) return;
const kill = (a) => {
if (!a || a.destroyed) return;
try { this.combat.fx?.releaseCraft?.(a); } catch { /* fx optional */ }
try { a.destroy(); } catch { /* already gone */ }
};
for (const a of [...(this.combat.allies ?? [])]) if (a && (a.isTransport || a._escortCrew || a === this._escortTransport)) kill(a);
this.combat.allies = (this.combat.allies ?? []).filter((a) => !(a && (a.isTransport || a._escortCrew || a === this._escortTransport)));
this._escortTransport = null;
try { this.combat._setActive?.(); } catch { /* defensive */ }
}
/** Is the destination world on the player's screen right now? */
_escortObjectOnScreen(obj) {
const cam = this.cameras?.main;
if (!cam || !obj) return false;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
return circleInView(obj.x, obj.y, Number(obj.radius) || 24, view);
}
/**
* The live convoy crafts — the transport + its surviving escort fighters
* (deduped; the transport also rides the allies roster).
*/
_escortConvoyCrafts() {
const seen = new Set();
const out = [];
if (this._escortTransport && !this._escortTransport.dead && !this._escortTransport._removed) {
seen.add(this._escortTransport);
out.push(this._escortTransport);
}
for (const a of this.combat?.allies ?? []) {
if (a && !a.dead && !a._removed && (a._escortCrew || a.isTransport || a === this._escortTransport) && !seen.has(a)) {
seen.add(a);
out.push(a);
}
}
return out;
}
/** Is one convoy craft on the player's screen right now? */
_escortCraftOnScreen(a) {
const cam = this.cameras?.main;
if (!cam || !a) return false;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
const r = Math.max(8, (Number(a.size) || Number(a.hitRadius) || 48) / 2);
return circleInView(a.x, a.y, r, view);
}
/**
* THE ESCORT'S SPEED MATCH — while the escort contract is still open
* (rendezvous / transit / delivering — 'failed' and 'delivered' are over,
* as is a claimed or completed route) AND the transport is VISIBLE ON
* SCREEN (its own witness law, _escortCraftOnScreen) AND the player is OUT
* OF COMBAT (this.combat.active is the same predicate that raises the
* combat bar), the convoy's speed law goes to the TRANSPORT: the player's
* hull takes the transport's own top speed (data/combat.json → transport.speed,
* riding the craft's `speed`) as its cap, and the escort FIGHTERS match the
* transport's speed for the flight (their own base is kept on `_baseSpeed`
* so the restore is exact). The moment the transport drops out of view,
* combat opens, or the contract ends, every one of them falls back to its
* NORMAL speed (data/ship.json → ship.maxSpeed for the hull; the fighters'
* own roster speed). A per-frame write only on change (no steady-state
* churn, no steering disturbance).
*/
_syncEscortSpeed() {
const ship = this.ship;
const normal = Number(config.get('ship.maxSpeed', 480)) || 480;
const sel = this._escortRoute?.();
const esc = sel?.route?.escort;
const tr = this._escortTransport;
const open = esc && (esc.phase === 'rendezvous' || esc.phase === 'transit' || esc.phase === 'delivering');
const matching = !!(open && tr && !tr.dead && !tr._removed && !this.combat?.active && this._escortCraftOnScreen(tr));
if (ship) {
const target = matching ? (Number(tr.speed) || normal) : normal;
if (Number.isFinite(target) && ship.maxSpeed !== target) ship.maxSpeed = target;
}
// THE ESCORT FIGHTERS — match the transport's speed (the convoy flies as
// one), else restore each fighter's own base (kept on first sight — a
// jump's re-form stands a fresh sprite with its roster speed again).
if (ship || tr) {
const ref = matching ? (Number(tr.speed) || null) : null;
for (const a of this._escortConvoyCrafts()) {
if (a === tr || !a || a.dead || a._removed) continue;
if (typeof a.speed !== 'number' || !Number.isFinite(a.speed)) continue;
if (!Number.isFinite(a._baseSpeed)) a._baseSpeed = a.speed;
const want = ref !== null ? ref : a._baseSpeed;
if (a.speed !== want) a.speed = want;
}
}
}
/**
* THE CONVOY ON SCREEN — every live convoy craft (the transport + its
* surviving escorts) flown into view, not merely near it. The escort's
* visibility law: the delivery (the convoy witnesses its own landing)
* AND the jump (the convoy is witnessed before the player leaves the
* system) both gate on it.
*/
_escortConvoyOnScreen() {
const convoy = this._escortConvoyCrafts();
return convoy.length > 0 && convoy.every((a) => this._escortCraftOnScreen(a));
}
/**
* THE ESCORT CONVOY'S JUMP HOLD — while the escort contract is in transit
* (or the convoy is setting down), a jump out of the system waits on the
* player witnessing the convoy: 'delivering' = the convoy is on its
* landing (hold until the touchdown closes); 'hidden' = the transport +
* its surviving escorts and this gate are not ALL on screen (fly them
* into view before jumping). `null` = the escort holds no claim on the
* jump (nothing linked yet — 'rendezvous' has no convoy in the scene to
* witness, nor does a finished/failed contract).
*/
_escortJumpHold(gt) {
const sel = this._escortRoute?.();
const esc = sel?.route?.escort;
if (!esc || (esc.phase !== 'transit' && esc.phase !== 'delivering')) return null;
if (esc.phase === 'delivering') return 'delivering';
const cam = this.cameras?.main;
if (!cam) return null; // no camera to witness on (a degenerate) — don't hold
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
const gateOk =
!!gt && Number.isFinite(gt.x) && Number.isFinite(gt.y) &&
circleInView(gt.x, gt.y, Number(gt.bound) || Number(gt.radius) || 60, view);
if (!gateOk) return 'hidden';
const convoy = this._escortConvoyCrafts();
if (convoy.length === 0) return null; // nothing standing (a degenerate) — nothing to witness
return convoy.every((a) => this._escortCraftOnScreen(a)) ? null : 'hidden';
}
/**
* THE DESTINATION'S DOOR — the contract's destination world (the planet /
* free-space station the convoy is being delivered to) holds its landing
* while the transport is still OUT on the job (phase 'rendezvous' — the
* convoy hasn't even linked — 'transit' — the lane — or 'delivering' —
* the touchdown in flight): the player can't land there until the
* transport has REACHED it (the delivery seals, phase 'delivered') or has
* been DESTROYED (phase 'failed' — that landing is the failure's reset's
// remedy, so a lost convoy never blocks the door). Any other world, any
// other phase: free.
*/
_escortLandingBlocked(target) {
const sel = this._escortRoute?.();
const esc = sel?.route?.escort;
if (!esc) return false;
if (esc.phase !== 'rendezvous' && esc.phase !== 'transit' && esc.phase !== 'delivering') return false;
if (!esc.destSystemId || esc.destSystemId !== this.systemRecord?.id) return false;
const tName = String(target?.name ?? '').trim().toLowerCase();
const tKey = String(target?.key ?? '').trim();
const dName = String(esc.destObjectName ?? '').trim().toLowerCase();
const dId = String(esc.destObjectId ?? '').trim();
if (dName !== '' && tName !== '' && tName === dName) return true;
if (dId !== '' && tKey !== '' && tKey === dId) return true;
return false;
}
/**
* THE DELIVERY — the convoy lands beside the destination world. The
* transport + its escort fighters break off the wing, glide in to a slot
* just off the world's disc, and fade out (the touchdown: they are through,
* not destroyed). Time-driven (frame-rate independent): a fixed fly-in,
* then a touch-down fade. On the fade's close the convoy is torn down, the
* phase seals to 'delivered', and the contract completes + pays out.
*/
_runEscortDelivery(id, route) {
const esc = route.escort;
const now = this.time?.now ?? 0;
if (esc.deliverStart == null) esc.deliverStart = now;
const elapsed = Math.max(0, now - Number(esc.deliverStart) || 0);
// THE WORLD TO LAND BESIDE — the anchor sits just off its disc (the
// 'beside the planet' spot). No world (an edge case) — anchor on the ship.
const obj = this._escortDestinationObject(route);
const hasWorld = !!(obj && Number.isFinite(obj.x) && Number.isFinite(obj.y));
const wx = hasWorld ? obj.x : (this.ship?.x ?? 0);
const wy = hasWorld ? obj.y : (this.ship?.y ?? 0);
const wr = hasWorld ? (Number(obj.radius) || 40) : 0;
const ax = wx + (wr > 0 ? wr + 48 : 72);
const ay = wy;
// THE CONVOY TO LAND — the transport + its escort fighters (deduped;
// the transport also rides the allies roster).
const seen = new Set();
const convoy = [];
if (this._escortTransport && !this._escortTransport.dead) { seen.add(this._escortTransport); convoy.push(this._escortTransport); }
for (const a of this.combat?.allies ?? []) {
if (a && !a.dead && (a._escortCrew || a.isTransport || a === this._escortTransport) && !seen.has(a)) {
seen.add(a); convoy.push(a);
}
}
// Nothing left to land (a jump tore the scene down) — seal + pay now.
if (convoy.length === 0) { this._sealEscortDelivery(id, route); return; }
const flyMs = 1500; // the approach
const fadeMs = 800; // the touch-down fade
const totalMs = flyMs + fadeMs;
for (let i = 0; i < convoy.length; i++) {
const a = convoy[i];
if (!Number.isFinite(a._landFromX)) { a._landFromX = a.x; a._landFromY = a.y; a._landBaseScale = Number(a.scale) || 1; }
// Personal slot fanned just off the world (the transport leads the line).
const side = i % 2 ? 1 : -1;
const slotX = ax + (i === 0 ? 0 : side * (28 + i * 26));
const slotY = ay + side * (16 + i * 13);
// Approach — an ease-out glide from where the ship broke off.
const p = Math.min(1, elapsed / flyMs);
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic
a.x = a._landFromX + (slotX - a._landFromX) * e;
a.y = a._landFromY + (slotY - a._landFromY) * e;
if (a.body && typeof a.body.setVelocity === 'function') a.body.setVelocity(0, 0);
// Face the world (aim along the final approach while it still has way to go).
const tX = slotX - a.x, tY = slotY - a.y;
if (tX * tX + tY * tY > 9) a.rotation = Math.atan2(tY, tX) + (a.artOffset || 0);
// Touch-down fade (begins once the approach has settled) + a shrink.
const f = Math.max(0, Math.min(1, (elapsed - flyMs) / fadeMs));
a.alpha = 1 - f;
a.setScale((a._landBaseScale || 1) * (1 - f * 0.5));
}
if (elapsed >= totalMs) this._sealEscortDelivery(id, route);
}
/**
* SEAL THE DELIVERY — the touchdown is done: tear the landed convoy down
* (a stand-down, no kill FX), close the phase, and pay out the claim.
*/
_sealEscortDelivery(id, route) {
const esc = route.escort;
if (esc.phase !== 'delivered') esc.phase = 'delivered';
esc.deliverStart = null;
this._escortBreakConvoy(); // the convoy is through — stand it down (no FX)
this.sideQuestState.setRoute(id, route);
if (!this.sideQuestState.isCompleted(id)) this.sideQuestState.complete(id);
const t = config.get('sidequests.ui.escortArrivedToast', {});
this.playSfx('construct');
this.consoleToast(
String(t.text ?? 'CONVOY DELIVERED — {name} IS THROUGH').replace('{name}', esc.name || 'the convoy'),
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
/** The escort wave's SCRIPT (a single rolling wave — escortWaveRoster's roster). */
escortWaveScript(level = 0, enemyFaction = null) {
const r = escortWaveRoster(level, enemyFaction);
return {
waves: [{
kind: 'rolling',
roster: r.roster,
total: r.total,
maxConcurrent: r.maxConcurrent,
replacePerKill: r.replacePerKill,
}],
};
}
/** THE LANE'S COMBAT — one mixed-fighters wave at a time (the patrol's law). */
_fireEscortWave(id, route) {
if (!this.combat || typeof this.combat.startWaves !== 'function') return;
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // one at a time
if (!this.escortDirector) return;
const level = Number(this.combat.xp?.level) || 0;
// THE WAR MODEL — the captors' faction (the contract's enemyFaction): the
// lane's waves are THAT faction's fighters; a neutral escort (no faction)
// keeps the data bands.
const enemyFaction = String(route?.escort?.enemyFaction ?? '').trim() || null;
const comp = this.escortDirector.update(this.time?.now ?? 0, {
allowed: this._escortLaneAllowed(),
live: this.combat.active,
xpLevel: level,
});
if (!comp) return; // the clock hasn't rolled a hit (yet)
const total = Number(escortWaveRoster(level, enemyFaction).total) || 8;
const t = config.get('sidequests.ui.patrolContactToast', {});
this.playSfx('discovery');
this.consoleToast(
String(t.text ?? 'HOSTILE CONTACT — {n} ENEMIES INBOUND').replace('{n}', String(total)),
{ glyph: t.glyph ?? '⚔', glyphColor: t.color ? toCss(t.color) : undefined },
);
this.combat.startWaves(this.escortWaveScript(level, enemyFaction), {
onComplete: () => {
this.playSfx('construct'); // the cleared tick
const tc = config.get('sidequests.ui.patrolContactClearedToast', {});
this.consoleToast(
String(tc.text ?? 'LANE CLEARED — THE CONVOY HOLDS').replace('{n}', String(total)),
{ glyph: tc.glyph ?? '✓', glyphColor: tc.color ? toCss(tc.color) : undefined },
);
},
});
}
/** The escort lane's gate — neutral, non-home, the player upright, no other run. */
_escortLaneAllowed() {
if (config.get('sidequests.escort.encounters.enabled', true) === false) return false;
if (!this.ship || this.combat?.playerDown) return false;
if (this.isHomeSystem) return false;
// THE LANE'S SPACE — a neutral escort's lane is NEUTRAL space only; the
// WAR-MODEL escort (the contract's enemyFaction stands) flies its lane in
// the HOSTILE faction system (the captors' own ground), so faction space
// is admitted while such an escort is in flight. A plain neutral escort in
// faction space still stands down (its lane is the pirate-lane contract).
const isFactionEscort = !!(this._escortRoute?.()?.route?.escort?.enemyFaction);
if (this.systemRecord?.faction != null && !isFactionEscort) return false;
const run = this.combat?.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // one at a time
return true;
}
/** THE SKY IS CLEAR? — no enemy fighter up (the delivery's witness). */
_escortFieldClear() {
const c = this.combat;
if (!c) return true;
if (c.active) return false;
return !c.enemies.some((e) => e && !e.dead);
}
/**
* The active escort's FRAME — the per-frame contract: the convoy re-forms
* (across a jump's scene restart), the link lands on the rendezvous, the lane
* fires its waves, the transport's loss FAILS the contract, and the delivery
* completes it. Reads the route's OWN phase (the record is the state — a save
// re-stands the convoy exactly where the contract left it).
*/
updateEscort() {
const sel = this._escortRoute();
if (!sel) return;
const { id, route } = sel;
const esc = route.escort;
if (!this.ship || !this.systemRecord) return;
// TRANSPORT LOST — the contract FAILS (the convoy breaks off). The player
// must LAND ON A PLANET to reset (startLanding's escort reset re-stands it).
if (esc.phase === 'rendezvous' || esc.phase === 'transit') {
if (this._escortTransport && this._escortTransport.dead) {
esc.phase = 'failed';
this.sideQuestState.setRoute(id, route); // persist the phase
this._escortBreakConvoy();
const t = config.get('sidequests.ui.escortFailToast', {});
this.playSfx('ui_close');
this.consoleToast(String(t.text ?? 'TRANSPORT LOST — LAND ON A PLANET TO RESET THE CONTRACT'), {
glyph: t.glyph ?? '✕', glyphColor: t.color ? toCss(t.color) : undefined, durationMs: 4200,
});
return;
}
}
if (esc.phase === 'failed' || esc.phase === 'delivered') return; // over (a landing resets / a claim closes)
// DELIVERY — the convoy has broken off to land beside the destination
// world: it flies in beside the planet/station and fades out (the
// touchdown). A fixed time-driven approach + fade; the seal + payout
// land on the fade's close (_sealEscortDelivery).
if (esc.phase === 'delivering') { this._runEscortDelivery(id, route); return; }
// RENDEZVOUS LINK — the ship reaches the convoy's standing point; the convoy
// forms on the wing and the lane goes hot.
if (esc.phase === 'rendezvous' && esc.originSystemId === this.systemRecord.id) {
const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null;
const radius = Math.max(1, Number(config.get('sidequests.escort.visitRadius', 460)) || 460);
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1]) && Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius) {
esc.phase = 'transit';
this.sideQuestState.setRoute(id, route); // persist the phase
this._ensureEscortConvoy(id, route); // form on the wing
const t = config.get('sidequests.ui.escortLinkToast', {});
this.playSfx('construct');
this.comms?.trigger('escortLink'); // the TRANSPORT CAPTAIN's greeting (data/comms.json — neutral-transport-link)
this.consoleToast(
String(t.text ?? 'CONVOY LINKED — {name} HOLDS YOUR WING THROUGH TO {dest}')
.replace('{name}', esc.name || 'the convoy').replace('{dest}', esc.destSystemName || 'the destination'),
{ glyph: t.glyph ?? '➳', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
return;
}
// TRANSIT — the convoy follows (re-forms after a jump), the lane fires its
// waves, and the delivery lands on arrival at the destination object.
this._ensureEscortConvoy(id, route);
// KEEP THE RECORD'S LIVE ARMOR IN SYNC (the damage survives a jump's
// scene restart — a fresh spawn reads esc.transportHull, not full). Write
// only on change, so a steady convoy doesn't dirty the save every frame.
const tr = this._escortTransport;
if (tr && !tr.dead) {
const nh = Math.max(0, Math.floor(tr.hull));
const ns = Math.max(0, Math.floor(tr.shields));
if (nh !== esc.transportHull || ns !== esc.transportShields) {
esc.transportHull = nh;
esc.transportShields = ns;
this.sideQuestState.setRoute(id, route);
}
}
if (this._escortLaneAllowed()) this._fireEscortWave(id, route);
if (esc.destSystemId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
// THE WITNESS — the destination world AND the convoy itself (the
// transport + its surviving escorts) are VISIBLE ON SCREEN (the player
// has flown them into view, not merely near them) and the sky is clear:
// the delivery begins — the convoy breaks off to land beside the world
// and fades out (phase 'delivering'; the seal + payout land on the
// fade's close, _sealEscortDelivery).
if (obj && this._escortObjectOnScreen(obj) && this._escortConvoyOnScreen() && this._escortFieldClear()) {
esc.phase = 'delivering';
esc.deliverStart = null; // stamped on the first delivering frame
this.sideQuestState.setRoute(id, route);
const t = config.get('sidequests.ui.escortArrivingToast', {});
this.playSfx('construct');
this.comms?.trigger('escortDelivered'); // the TRANSPORT CAPTAIN's thanks (data/comms.json — neutral-transport-delivery)
this.consoleToast(
String(t.text ?? 'CONVOY INBOUND — {name} IS SETTING DOWN AT {dest}')
.replace('{name}', esc.name || 'the convoy').replace('{dest}', esc.destObjectName || esc.destSystemName || 'the destination'),
{ glyph: t.glyph ?? '✈', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
}
}
/**
* THE ESCORT RESET (startLanding's hook — the failure's remedy): the
* transport is LOST and the player has landed on a planet — re-stand the
* contract. A fresh RENDEZVOUS stands in the CURRENT system (the one they
* just landed in — the retry starts where they are), a fresh TRANSPORT at
* full armor (the standing hull is the record's, restored on the next
* _ensureEscortConvoy), the SAME destination (a signed contract is a signed
* contract). The broken convoy is already gone (the failure's break-off);
* this re-arms the rendezvous + the lane's director so the run can be flown
* again. A no-op unless the escort is the active contract in 'failed'.
*/
_escortResetForLanding() {
const sel = this._escortRoute?.();
if (!sel) return;
const { id, route } = sel;
const esc = route.escort;
if (!esc || esc.phase !== 'failed') return; // only a LOST contract resets (an intact convoy never does)
const hereId = String(this.systemRecord?.id ?? esc.originSystemId ?? '');
if (!hereId) return;
// A fresh RENDEZVOUS in the CURRENT system (its own seeded stream — a
// distinct standing point from the first, so the retry's beacon is fresh).
let fresh = null;
try {
const { region, anchors } = this._routeFlightEnvelope(hereId);
fresh = buildPatrolRoute(this.galaxy?.seed ?? 0, id, hereId, region, anchors, {
waypoints: [1, 1],
fork: 'escort-reset',
});
} catch { fresh = null; }
route.waypoints = fresh?.waypoints?.length > 0 ? fresh.waypoints : [[0, 0]];
esc.phase = 'rendezvous';
esc.originSystemId = hereId;
esc.originSystemName = String(this.systemRecord?.name ?? hereId);
// A FRESH TRANSPORT at FULL armor (the standing hull — the scrape is gone).
esc.transportHull = esc.transportMaxHull;
esc.transportShields = esc.transportMaxShields;
this._escortTransport = null; // the dead reference — a fresh one stands on the next frame
// Re-arm the lane's director (a fresh roll schedule for the retry).
if (this.escortDirector) {
this.escortDirector.state = 'idle';
this.escortDirector.nextRollAt = null;
this.escortDirector.quietUntil = 0;
}
this.sideQuestState.setRoute(id, route); // persist the re-stood contract
const t = config.get('sidequests.ui.escortResetToast', {});
this.playSfx('construct');
this.consoleToast(String(t.text ?? 'CONTRACT RESET — A FRESH CONVOY STANDS AT THE RENDEZVOUS'), {
glyph: t.glyph ?? '➳', glyphColor: t.color ? toCss(t.color) : undefined,
});
}
/**
* The escort's in-world BEACONS — the rendezvous point (in the origin system, until the link) and the destination object (in the destination system, from the link on). The escort's green (data → escort.marker). Mirrors _drawPatrolMarkers' shape (the shared beacon layer).
*/
_drawEscortMarkers(time) {
const sel = this._escortRoute();
if (!sel) return;
const { route } = sel;
const esc = route.escort;
const color = config.get('sidequests.escort.marker.color', '#7ce8a4');
const doneColor = config.get('sidequests.escort.marker.doneColor', '#67e863');
const g = this.patrolG;
if (!g) return;
g.clear();
const t = (time ?? 0) / 1000;
const pulse = 0.6 + 0.4 * Math.sin(t * 2.4);
// RENDEZVOUS (origin system, until the link) — the convoy's standing point.
if (esc.originSystemId === this.systemRecord.id && esc.phase === 'rendezvous') {
const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null;
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1])) {
const c = toColor(color);
g.lineStyle(2, c, pulse);
g.strokeCircle(wp[0], wp[1], 26 + 6 * Math.sin(t * 2.4));
g.lineStyle(1.5, c, 0.9);
g.strokeCircle(wp[0], wp[1], 12);
g.fillStyle(c, 0.9 * pulse);
g.fillCircle(wp[0], wp[1], 4);
}
}
// DESTINATION (destination system, from the link on) — the delivery's stop.
if (esc.phase === 'transit' && esc.destSystemId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
if (obj) {
const c = toColor(color);
g.lineStyle(2, c, pulse);
g.strokeCircle(obj.x, obj.y, 34 + 7 * Math.sin(t * 2.2));
g.lineStyle(1.5, c, 0.9);
g.strokeCircle(obj.x, obj.y, 15);
g.fillStyle(c, 0.9 * pulse);
g.fillCircle(obj.x, obj.y, 5);
}
}
}
/** The active contract's ROUTE kind ('patrol' | 'bounty' | 'escort'); null with nothing flying. */
_sideRouteKind() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || st.isCompleted(id) || st.isClaimed(id)) return null;
return st.routeFor(id)?.kind ?? null;
}
/** Per-frame route update — the record's OWN kind picks the law (patrol untouched). */
updateSideQuestRoute() {
this._syncEscortSpeed(); // the escort's speed match (its own seam — a no-op off the escort lane)
const kind = this._sideRouteKind();
if (kind === 'bounty') this.updateBountyHunt();
else if (kind === 'escort') this.updateEscort();
else this.updatePatrol();
}
/** The route's in-world beacons, same dispatch (the hunt paints no contact red — see _drawBountyMarkers). */
_drawSideQuestMarkers(time) {
const kind = this._sideRouteKind();
if (kind === 'bounty') this._drawBountyMarkers(time);
else if (kind === 'escort') this._drawEscortMarkers(time);
else this._drawPatrolMarkers(time);
}
/**
* The compass's next stop for whichever contract flies. A HUNT and a
* PATROL alike point at ONE point — the next in the route's sequence
* (the route is a path — 1, 2, 3, …, never the set of them) — in the
* contract's own color with its own type label (data → bounty.compass),
* so it reads as the contract it is and never as a contact. Outside the lane's system it points
* at the gate that jumps there (the patrol's rule).
*/
activeRouteCompassTarget() {
const kind = this._sideRouteKind();
if (kind === 'escort') return this.escortCompassTarget();
if (kind !== 'bounty') return this.patrolCompassTarget();
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.ship || !this.systemRecord) return null;
const route = st.routeFor(id);
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
const next = nextBountyWaypoint(route);
if (!next) return null; // every leg logged (the kill's check-off / the last leg lands the contract)
const style = {
color: config.get('sidequests.bounty.compass.color', '#ff2d6f'),
typeLabel: config.get('sidequests.bounty.compass.typeLabel', 'Hunt'),
};
if (route.targetSystemId === this.systemRecord.id) {
return { kind: 'waypoint', idx: next.index, x: next.x, y: next.y, ...style };
}
for (const gt of this.systemGates ?? []) if (gt.gate?.to === route.targetSystemId) return { kind: 'gate', gate: gt, ...style };
return null;
}
/**
* The board's SET ACTIVE (the window's door → here): stand the ONE
* contract the player flies right now (the ledger validates — taken,
* not done, not claimed — the board only offers the door in that
* state). No effect is the scene's: the active state IS the in-flight
* face (the compass's yellow next-stop arrow — patrolCompassTarget —
* and the route's in-world beacons — _drawPatrolMarkers, both reading
* sideQuestState.activeId).
* @returns {boolean} true when it stands
*/
setActiveSideQuest(jobId) {
if (!this.sideQuestState) return false;
return this.sideQuestState.setActive(jobId);
}
/**
* The board's CLAIM REWARD (the window's door → here — the claimQuest
* precedent, the side-quest shape): the contract taken + COMPLETE, paid
* exactly once (the ledger's claim() is idempotent — a double-press or
* a re-open can't pay twice). The payout is the contract's OWN: the
* route record's credits (fixed at the accept) + its XP (the
* experience worth of the patrol's defeated contacts — fixed there
* too); a category without a route (the stubs, when they land) falls
* back to the posting's reward (regenerated from the seed — the
* contract stands). The scene owns the effect (the credits land in
* the hold, the XP lands in the run's XP bank — the 'sidequest'
* source, beside the kills' 'kill' grants); the ledger owns the
* record.
* @returns {{ok:boolean, credits?:number, xp?:number}}
*/
claimSideQuest(jobId) {
const st = this.sideQuestState;
if (!st || !st.isAccepted(jobId) || !st.isCompleted(jobId) || st.isClaimed(jobId)) return { ok: false };
if (!st.claim(jobId)) return { ok: false }; // someone beat us to it — idempotent
let credits = 0;
let xp = 0;
const route = st.routeFor(jobId);
if (route) {
if (Number.isFinite(route.credits)) credits = route.credits;
if (Number.isFinite(route.xp)) xp = Math.max(0, route.xp);
}
if (!credits) {
// A category without a route: the posting's reward (the seed's
// contract stands — sq-<sysId>-<world>-<cat>-<slot>).
const m = parseSideQuestId(String(jobId));
if (m) {
const rec = this.galaxy?.byId?.get(m.systemId);
if (rec && this.galaxy) {
credits = sideQuestPostingsFor(this.galaxy.seed, rec, m.worldKey, this.galaxy).find((p) => p.id === jobId)?.reward?.credits ?? 0;
}
}
}
if (credits > 0 && this.ship) {
this.ship.addCredits(credits);
this.refreshCreditsHud();
}
// The XP reward (the claim's second payout): the run's experience
// worth of the contract's defeated contacts, banked through the
// same pipe as the kills (XpManager.grant — its 'levelup' events
// stand the marquee + the armor bump, its 'grant' the listeners)
// under its own 'sidequest' source (the ledger's trail). No bank
// (the stubs' shape, a combat-less boot) — the credits stand alone.
if (xp > 0 && this.combat?.xp?.grant) this.combat.xp.grant(Math.floor(xp), 'sidequest');
// The REPUTATION reward (the claim's third payout) — the issuer's
// standing bump, landed exactly once with the claim (idempotent). The
// optional call: headless test stubs bind claimSideQuest onto a bare
// `this` without the scene's reputation seams — there it simply no-ops.
this.rewardSideQuestReputation?.(jobId);
this.playSfx('construct'); // the payout tick — the reward lands
return { ok: true, credits, xp };
}
/**
* The claim's REPUTATION payout (data/reputation.json →
* bonuses.sideQuest — 10% of the scale's top, +2 at the default +20):
* the world that POSTED the contract (the job id's world part — its
* planet or station) earns the bump on a completed + claimed job.
* Resolved through the issuer's SETTLEMENT id (the comms panel's
* standing key — commsTargetFor's rule: a station is its own
* settlement, a planet is the settlement anchored to it), so the
* standing reads back in the panel and the landing gate. A no-op for
* the home world (pinned at the top — the bonus can't move it), a
* hand-edited id, or a world without a settlement.
* @param {string} jobId the claimed contract's id
* @returns {boolean} true when the standing moved
*/
rewardSideQuestReputation(jobId) {
const rep = this.reputation;
if (!rep) return false;
const key = this.sideQuestIssuerKey(jobId);
if (!key) return false;
const pct = Number(config.get('reputation.bonuses.sideQuest', 0.10));
if (!(pct > 0)) return false;
const changed = rep.creditFraction(key, pct);
if (changed) {
const name = key === HOME_KEY ? this.homeWorldName : this.worldNameForReputationKey(key);
const pts = Math.round(Math.max(0, rep.max) * pct);
this.consoleToast(
`REPUTATION +${pts} ${name ? `WITH ${String(name).toUpperCase()}` : 'ON THIS WORLD'}`,
{ glyph: '★', glyphColor: toCss('#7ce8a4') },
);
}
return changed;
}
/**
* Every pirate that goes down (CombatSystem's onKill seam): the SYSTEM
* KILL MILESTONE (data/reputation.json → bonuses.pirateKills — every
* `every` pirates put down in a system, 10 by default, +`bonus`
* standing, 1 by default, lands on EVERY place in it: the system's
* settlements — each planet's anchored post + the free-space stations,
* the comms panel's standing keys). The tally is PER-RUN, PER-SYSTEM
* state (this.pirateKillsBySystem — registry-backed, js/save/SaveData.js),
* so it keeps counting across saves and a fresh run starts from zero.
* A milestone pays out exactly once per crossing (tally % every === 0)
* through change() — the standing clamps at the top, so a place
* already at +20 simply doesn't move (and the toast stands only when
* at least one place actually moved). A no-op for a stub scene without
* the reputation seams (headless tests — the tally still stands) or
* with no live system.
* @param {object} [e] the fallen pirate (the onKill's enemy — the
* tally is per kill, the record is only the seam's shape)
* @returns {boolean} true when a milestone paid out
*/
recordPirateKill(e) {
void e;
const sysId = this.systemRecord?.id;
if (typeof sysId !== 'string' || sysId.length === 0) return false;
if (!this.pirateKillsBySystem) {
this.pirateKillsBySystem = {};
this.registry?.set?.('pirateKillsBySystem', this.pirateKillsBySystem);
}
const tally = (Number(this.pirateKillsBySystem[sysId]) || 0) + 1;
this.pirateKillsBySystem[sysId] = tally;
const every = Math.floor(Number(config.get('reputation.bonuses.pirateKills.every', 10)) || 0);
const bonus = Number(config.get('reputation.bonuses.pirateKills.bonus', 1)) || 0;
if (!(every > 0) || !(bonus > 0) || tally % every !== 0) return false;
const rep = this.reputation;
if (!rep) return false; // a stub scene — the tally stands, the bump no-ops
let content = null;
try { content = this.galaxy?.ensureContent?.(sysId) ?? null; } catch { content = null; }
const places = (content?.settlements ?? []).filter((s) => s && typeof s.id === 'string');
let moved = false;
for (const s of places) moved = rep.change(s.id, bonus) || moved;
if (moved) {
this.consoleToast(
`REPUTATION +${bonus} WITH EVERY WORLD IN THIS SYSTEM`,
{ glyph: '★', glyphColor: toCss('#7ce8a4') },
);
}
return moved;
}
/**
* The world that POSTED a side quest (the job id's world part —
* parseSideQuestId's worldKey: a planet's `p<ordinal>` or a station's
* `s<n>`) resolved to its reputation key (the settlement's id — the
* key the comms panel reads): a station IS its settlement (the id is
* `<systemId>-s<n>` — the world part as-is); a planet is the
* settlement ANCHORED to it (commsTargetFor's rule — the first in
* generation order, the panel's standing key). null when the world is
* unresolvable (a hand-edited id, a world without settlements — the
* bonus simply doesn't land).
* @param {string} jobId the contract's id (sq-<system>-<world>-<cat>-<slot>)
* @returns {?string} the place's reputation key
*/
sideQuestIssuerKey(jobId) {
const m = parseSideQuestId(String(jobId ?? ''));
if (!m || !this.galaxy) return null;
let content = null;
try { content = this.galaxy.ensureContent(m.systemId); } catch { content = null; }
const settlements = (content?.settlements ?? [])
.filter((s) => s && typeof s.id === 'string' && s.id.length > 0);
if (m.worldKey[0] === 's') {
return settlements.find((s) => s.id === `${m.systemId}-${m.worldKey}`)?.id ?? null;
}
const ordinal = Math.floor(Number(m.worldKey.slice(1)));
if (!Number.isFinite(ordinal)) return null;
return settlements.find((s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === ordinal)?.id ?? null;
}
/**
* The world NAME behind a reputation key (the toast's "WITH KESTREL").
* The key is a settlement id — the settlement's own name; null when the
* key is unresolvable (the toast drops the name, not the bump).
* @param {string} key the place's reputation key (a settlement id)
* @returns {?string}
*/
worldNameForReputationKey(key) {
if (typeof key !== 'string' || key === '' || !this.galaxy) return null;
const sysId = key.slice(0, key.lastIndexOf('-s'));
let content = null;
try { content = this.galaxy.ensureContent(sysId); } catch { content = null; }
const s = (content?.settlements ?? []).find((x) => x?.id === key);
return typeof s?.name === 'string' && s.name !== '' ? s.name : null;
}
/**
* The shop's SALE hook (ShopView.confirm → here): every GOOD SOLD on
* the world the player stands on adds the world's standing bonus
* (data/reputation.json → bonuses.perGoodSold — 2% of the scale's top
* per unit, +0.4 at the default +20). The sub-point fraction CREDITS
* across sales (Reputation.creditFraction — five units land +2), so
* the standing still rises on the 2% step. A no-op for the home
* world (pinned) and for a world without a settlement.
* @param {string} worldName the world the sale landed on (the shop's, canonical)
* @param {number} units the goods' total units sold (the trade's sell side)
*/
onGoodsSold(worldName, units) {
const rep = this.reputation;
if (!rep) return;
const n = Math.max(0, Math.floor(Number(units) || 0));
if (n === 0) return;
const pct = Number(config.get('reputation.bonuses.perGoodSold', 0.02));
if (!(pct > 0)) return;
const key = this.reputationKeyForWorld(worldName);
if (!key) return;
const changed = rep.creditFraction(key, n * pct);
if (changed) {
this.consoleToast(
`REPUTATION RISING WITH ${String(worldName ?? 'THIS WORLD').toUpperCase()}`,
{ glyph: '★', glyphColor: toCss('#7ce8a4') },
);
}
}
/**
* A WORLD NAME → its reputation key (the settlement's id — the key
* commsTargetFor reads, so a bump lands where the panel shows it):
* the home world → HOME_KEY (creditFraction is a no-op there — it is
* PINNED at the scale's top); a free-space station → its settlement's
* id; a planet → the FIRST settlement anchored to it (the panel's
* rule). null when the name is unresolvable (the sale still pays in
* credits — reputation is the bonus, not the trade).
* @param {string} name the world's name (any casing — matched canonically)
* @returns {?string} the place's reputation key
*/
reputationKeyForWorld(name) {
if (typeof name !== 'string' || name.trim() === '') return null;
const lower = name.trim().toLowerCase();
if (this.isHomeSystem && this.homeWorldName && this.homeWorldName.toLowerCase() === lower) return HOME_KEY;
const st = this.systemStations.find((s) => s.discoveryName?.toLowerCase() === lower);
if (st?.settlement?.id) return st.settlement.id;
const rec = (this.systemContent?.planets ?? []).find((p) => p.name?.toLowerCase() === lower);
if (rec) {
const anchored = (this.systemContent?.settlements ?? [])
.find((s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === rec.ordinal);
if (anchored?.id) return anchored.id;
}
return null;
}
/**
* The window's ROUTE readout seam (the detail's "ROUTE — 2/4 WAYPOINTS
* LOGGED" line + the contacts' line — "CONTACTS — 1/2 CLEARED" + the
* payout's XP — "PAYOUT — 65 CR · +60 XP"): the accepted patrol's
* checkpoint + contact counts + the contract's XP payout (fixed at the
* accept — the board reads it once the route stands). Null when the
* contract carries no route (the other categories) or its route never
* stood; `contacts` null when the route carries no encounters (old
* saves load).
* @returns {?{visited:number, total:number, xp:number, contacts:?{cleared:number, total:number}}}
*/
patrolProgress(jobId) {
const route = this.sideQuestState?.routeFor(jobId);
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
const enc = route.encounters && Array.isArray(route.encounters.at) && route.encounters.at.length > 0 ? route.encounters : null;
return {
visited: route.visited.length,
total: route.waypoints.length,
xp: Number.isFinite(route.xp) ? Math.max(0, route.xp) : 0, // the contract's XP payout (the claim's second reward)
contacts: enc ? { cleared: (Array.isArray(enc.cleared) ? enc.cleared : []).length, total: enc.at.length } : null,
};
}
/**
* The active PATROL's NEXT STOP for the off-screen compass (the yellow
* arrow — data/sidequests.json → patrol.compass; the route gate's
* orange precedent, a different color of its own). The route is a PATH,
* flown IN ORDER (the check-ins log 1, 2, 3, …), so "next" is the FIRST
* unvisited waypoint: in the route's OWN system it is
* the waypoint itself (plain coordinates — a compass entry with no
* discovery object behind it); in ANOTHER system it is the jumpgate
* that jumps there (the yellow takes the gate's slot — the gate's
* ordinary cyan entry is suppressed, the route-gate's rule). Null when
* nothing is active / done / claimed / in the route's system /
* unvisited (the arrow stands until the last waypoint is logged).
* @returns {{kind:'waypoint', idx:number, x:number, y:number}|{kind:'gate', gate:object}|null}
*/
patrolCompassTarget() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.ship || !this.systemRecord) return null;
if (st.isCompleted(id) || st.isClaimed(id)) return null;
const route = st.routeFor(id);
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
let idx = -1;
for (let i = 0; i < route.waypoints.length; i++) {
if (!route.visited.includes(i)) { idx = i; break; } // the route is a path: 1, 2, 3, …
}
if (idx < 0) return null; // every waypoint logged (completion lands this frame)
const wp = route.waypoints[idx];
if (route.targetSystemId === this.systemRecord.id) {
return { kind: 'waypoint', idx, x: wp[0], y: wp[1] };
}
// Another system: the gate that jumps there (the player flies the
// gate, not the coordinates).
for (const gt of this.systemGates ?? []) if (gt.gate?.to === route.targetSystemId) return { kind: 'gate', gate: gt };
return null;
}
/**
* The active patrol's CHECK-INS + CONTACTS (per frame, update): the
* ship within visitRadius (data/sidequests.json → patrol.visitRadius)
* of the NEXT waypoint logs it — the route is a PATH flown in order
* (1, 2, 3, …; a later waypoint stands open until its turn —
* visitWaypoint's sequential law), each check-in toasting + pinging
* in the console's voice; at a
* CONTACT's waypoint (one of the route's encounter waypoints) the
* contact's wave fires (_firePatrolContact — one rolling wave of the
* band's roster, six, three at a time — cleared marked on the run's
* completion); every waypoint logged AND every contact cleared stands
* the contract COMPLETE — the board's CLAIM REWARD door opens, and
* the reward stays unclaimed until the player takes it (the patrol's
* completion is separate from its payout, like the story quests'
* claim).
*/
updatePatrol() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.ship || !this.systemRecord) return;
if (st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.targetSystemId !== this.systemRecord.id) return; // the route's system ≠ here — no check-ins while away
const wps = route.waypoints;
if (wps.length === 0) return;
const radius = Math.max(1, Number(config.get('sidequests.patrol.visitRadius', 420)) || 420);
for (let i = 0; i < wps.length; i++) {
if (route.visited.includes(i)) continue;
if (Math.hypot(this.ship.x - wps[i][0], this.ship.y - wps[i][1]) <= radius) {
if (st.visitWaypoint(id, i)) { // idempotent — only the first frame logs
const t = config.get('sidequests.ui.patrolWaypointToast', {});
this.playSfx('discovery'); // the waypoint's soft chime
this.consoleToast(
String(t.text ?? 'WAYPOINT {i} OF {n} LOGGED').replace('{i}', String(i + 1)).replace('{n}', String(wps.length)),
{ glyph: t.glyph ?? '◈', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
}
}
this._firePatrolContact(st, id, route, radius); // the contact's wave, at its waypoint
if (patrolRouteComplete(route) && !st.isCompleted(id)) {
st.complete(id); // the done contract drops out of the ACTIVE slot (reconcileActive stands the next top one)
const t = config.get('sidequests.ui.patrolCompleteToast', {});
this.playSfx('construct'); // the route's done tick
this.consoleToast(
String(t.text ?? 'PATROL ROUTE COMPLETE — ITS REWARD IS READY TO CLAIM'),
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
}
/**
* The active patrol's CONTACTS (data/sidequests.json →
* patrol.encounters — the contract's hostile encounters): with the
* ship at an uncleared contact's waypoint and no wave run in flight,
* the contact's wave fires — one rolling wave of the standing band's
* roster (the player's level × the target's hops from home; today the
* low band, levels 1–5 at 1–2 hops from home, the ESTABLISHED type
* only), the data's shape (six hostiles, three at a time, each kill
* replaced — patrolEncounterScript → CombatSystem.startWaves — the
* same wave machinery as the 'Pirate Problems' ambush). The witness
* is the run's COMPLETION (the cleared mark lands on onComplete — the
* combatDone precedent: a mid-fight quit re-arms the contact on
* return, and the route's completion — patrolRouteComplete — waits
* for it, so the reward can't outrun the fight). One fight at a time
* (an in-flight run — lead/gap/live — holds the fire; the ship at the
* waypoint on the next idle frame takes its wave then). No-op without
* a combat system or encounters on the route (a save predating the
* contacts — old saves load).
* @param {SideQuestState} st the side ledger
* @param {string} id the active contract's id
* @param {object} route its route record (the route's system = here)
* @param {number} radius the check-in's range (the contact fires at its waypoint)
*/
_firePatrolContact(st, id, route, radius) {
const enc = route.encounters;
if (!enc || !Array.isArray(enc.at) || enc.at.length === 0) return; // no contacts (old saves load)
if (!this.combat || typeof this.combat.startWaves !== 'function') return; // no combat system — the route stands on its waypoints (degrades)
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // one fight at a time
const cleared = new Set(Array.isArray(enc.cleared) ? enc.cleared : []);
const idx = enc.at.find((i) => {
if (cleared.has(i)) return false;
const wp = route.waypoints[i];
if (!Array.isArray(wp)) return false;
return Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius;
});
if (idx == null) return; // not at an uncleared contact's waypoint (yet)
const script = patrolEncounterScript(enc);
const total = script.waves[0]?.total ?? 6;
const t = config.get('sidequests.ui.patrolContactToast', {});
this.playSfx('discovery'); // the contact's chime (the wave's own combat comms ride the bar)
this.consoleToast(
String(t.text ?? 'HOSTILE CONTACT — {n} ENEMIES INBOUND').replace('{n}', String(total)),
{ glyph: t.glyph ?? '⚔', glyphColor: t.color ? toCss(t.color) : undefined },
);
this.combat.startWaves(script, {
onComplete: () => {
// The witness is the COMPLETION (the combatDone precedent): only
// a finished run clears the contact — a quit mid-fight re-arms it
// on return, and the route's completion waits for it.
if (st.encounterCleared(id, idx)) {
const c = route.encounters;
const done = (Array.isArray(c?.cleared) ? c.cleared : []).length;
const n = (Array.isArray(c?.at) ? c.at : []).length;
const tc = config.get('sidequests.ui.patrolContactClearedToast', {});
this.playSfx('construct'); // the cleared tick
this.consoleToast(
String(tc.text ?? 'CONTACT CLEARED — {i} OF {n}').replace('{i}', String(done)).replace('{n}', String(n)),
{ glyph: tc.glyph ?? '✓', glyphColor: tc.color ? toCss(tc.color) : undefined },
);
}
},
});
}
/**
* The active BOUNTY HUNT's frame: ONE leg at a time, the lane's fights at
* their legs, and the contract landing on the kill. A hunt checks in strictly
* IN ORDER (the chain's odds read a first / second / third leg — the ledger
* refuses any other sequence), so only the NEXT leg logs when the ship stands
* on it; the rolled pickets and the mark itself fire at their own legs, one
* fight at a time, each awaiting its own witness (a picket's is its wave run's
* COMPLETION — the patrol contact's rule — so a mid-fight jump-out re-arms it;
* the mark's witness is its DEATH, which halts the run and stops nothing else
* deploying). The contract goes COMPLETE when the mark is down AND the last of
* its escort stops shooting — never before, and no amount of flying finishes a
* hunt whose mark still stands.
*/
updateBountyHunt() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.ship || !this.systemRecord) return;
if (st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.kind !== 'bounty' || !route.bounty) return; // a degraded record stands no hunt
if (route.targetSystemId !== this.systemRecord.id) return; // the lanes are in the contract's system — the arrow points there
const plan = route.bounty;
const radius = Math.max(60, Number(plan.visitRadius) || 420);
let logged = null;
const next = nextBountyWaypoint(route);
if (next && Math.hypot(this.ship.x - next.x, this.ship.y - next.y) <= radius) {
if (st.visitWaypoint(id, next.index)) logged = next; // idempotent — only the first frame logs
}
// The lane's fight speaks BEFORE the quiet line: an engagement standing at
// the leg just logged supersedes "NOTHING ON THE LANE" (the leg still logs).
const fired = this._fireBountyEngagements(st, id, route, radius);
if (logged && !fired) {
this.playSfx('discovery'); // the leg's soft chime
this._huntToast(bountyText('bountyLegToast', { i: String(next.leg), n: String(next.of) }), '\u25c8');
}
if (bountyRouteComplete(route) && !st.isCompleted(id) && this._bountyFieldClear()) {
st.complete(id); // the done contract drops out of the ACTIVE slot (reconcileActive stands the next top one)
this.playSfx('construct');
this._huntToast(bountyText('bountyCompleteToast', { name: this._huntMarkName(plan) }), '\u2713');
}
}
/**
* The hunt's engagements, one at a time (data → bounty.encounters): the MARK
* first — its own leg, once logged, while it still stands — otherwise the
* rolled PICKET wave of an uncleared pre-bounty leg the ship is standing on.
* Both are one rolling wave through the SAME machinery as a patrol contact /
* the 'Pirate Problems' ambush (CombatSystem.startWaves); the mark's wave
* carries its `elite` block (the dossier's frame at the data's hull / shield /
* damage scales, named — js/combat/Waves.js deploys it with the opening group).
* A halted or in-flight run holds the fire; a run that ended WITHOUT the kill
* (a jump out mid-fight) simply re-arms here on return, because nothing but
* the death is witnessed.
* @returns {boolean} true when a wave stood up this frame (the leg's own toast speaks)
*/
_fireBountyEngagements(st, id, route, radius) {
const plan = route.bounty;
if (!plan || !this.combat || typeof this.combat.startWaves !== 'function') return false;
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // one fight at a time
const visited = new Set(Array.isArray(route.visited) ? route.visited : []);
const at = (i) => {
const wp = route.waypoints[i];
return Array.isArray(wp) && Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius;
};
if (!plan.down && visited.has(plan.bountyAt) && at(plan.bountyAt)) {
const script = bountyEngagementScript(plan);
const total = script.waves[0]?.total ?? plan.raiders + 1;
this.playSfx('discovery'); // the sighting's chime (the wave's own comms ride the bar)
this._huntToast(bountyText('bountySightedToast', {
name: this._huntMarkName(plan),
epithet: String(plan.mark?.epithet ?? '').toUpperCase(),
n: String(total),
}), '\u2620');
// THE MARK'S OWN VOICE — the bounty attack comm (data/comms.json →
// pirate-bounty-attack: the bounty clip, assets/videos/enemies/
// pirate-bounty.mp4, + one of the four bounty voice clips + the line):
// armed the moment the wanted leader's fight stands up — every time a
// bounty ship enters combat (a jump out mid-fight re-arms it on return).
this.comms?.trigger('pirateBountyAttack');
this.combat.startWaves(script, {}); // NO onComplete witness — the kill is the witness (handleCombatKill)
return true;
}
if (plan.down) return false; // the mark is down: the hunt throws nothing else at the ship
const cleared = new Set(Array.isArray(plan.skirmishCleared) ? plan.skirmishCleared : []);
for (let i = 0; i < (Array.isArray(plan.pickets) ? plan.pickets.length : 0); i++) {
if (plan.pickets[i] !== true || cleared.has(i) || !visited.has(i) || !at(i)) continue;
const script = bountySkirmishScript(plan);
const total = script.waves[0]?.total ?? 6;
this.playSfx('discovery');
this._huntToast(bountyText('bountySkirmishToast', { n: String(total) }), '\u2694');
this.combat.startWaves(script, {
onComplete: () => st.bountySkirmishCleared(id, i), // the witness is the COMPLETION (a quit mid-fight re-arms)
});
return true;
}
return false;
}
/** The field's clear when nothing of the hunt's own escort is left shooting. */
_bountyFieldClear() {
if (!this.combat) return true; // no combat system — the kill stands on its own (a degraded boot still finishes)
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // live, or halted with survivors
return !this.combat.enemies.some((e) => e.wave && !e.dead);
}
/**
* A hostile went DOWN (CombatSystem's onKill — the enemy rides along for
* exactly this). The only elite the game deploys is a hunt's mark, so its
* death IS the contract: every further deployment and replacement STOPS
* (CombatSystem.haltWaves — the escort stays hostile and must be put down),
* and the remaining legs check off in the same breath (SideQuestState.bountyDown),
* which is what bountyRouteComplete reads. The completion + payout land
* upstream in updateBountyHunt once the field clears. Any other kill — a
* raider, an escort, a patrol contact, or a foreign elite that isn't THIS
* contract's mark — says nothing here.
*/
handleCombatKill(e) {
if (!e || e.elite !== true) return;
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !st.isAccepted(id) || st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.kind !== 'bounty' || !route.bounty || route.bounty.down === true) return;
// Only the mark THIS contract's plan flies books it (a hand-edited or future
// elite from elsewhere can't close a hunt).
const mark = String(route.bounty.mark?.name ?? '').trim();
const fallen = String(e.markName ?? '').trim();
if (mark !== '' && fallen !== '' && fallen !== mark) return;
this.combat?.haltWaves?.(); // nothing else deploys or reforms — the survivors fight it out
if (!st.bountyDown(id)) return; // idempotent — a second elite can't book it twice
this.playSfx('construct');
this._huntToast(bountyText('bountyBroughtToBookToast', { name: mark.toUpperCase() || 'THE MARK' }), '\u2713');
}
/** The wanted leader's name for the comms (upper-case, like every console line); a plan with no dossier still reads as THE MARK. */
_huntMarkName(plan) {
return String(plan?.mark?.name ?? '').trim().toUpperCase() || 'THE MARK';
}
/** One hunt comms line (the data's copy, glyph + color), in the console's voice. */
_huntToast(line, fallbackGlyph = '\u25b8') {
if (!line?.text) return;
this.consoleToast(String(line.text), {
glyph: line.glyph || fallbackGlyph,
glyphColor: toCss(line.color || '#ff2d6f'),
});
}
/**
* The active hunt's IN-WORLD beacons — and the design's one visual law:
* EVERY leg looks the same until something shoots at you. Only the NEXT leg in
* sequence pulses (the data's bounty.marker color); a logged leg takes the dim
* done mark; an unflown leg stands dim and STATIC, all of them identical —
* there is NO contact beacon, because a hunt's marker block deliberately
* carries no `contactColor` (a patrol paints its contacts red because those
* ARE the job; a hunt's mark is hidden until it fires). The route's system
* only, the ACTIVE contract only, clear of the world's solids by construction.
*/
_drawBountyMarkers(time) {
const g = this.patrolG; // the same beacon layer (one contract's route draws at a time)
if (!g) return;
g.clear();
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.systemRecord || st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.kind !== 'bounty' || route.targetSystemId !== this.systemRecord.id) return;
const color = toColor(config.get('sidequests.bounty.marker.color', '#ff2d6f'), 0xff2d6f);
const doneColor = toColor(config.get('sidequests.bounty.marker.doneColor', '#67e863'), 0x67e863);
const next = nextBountyWaypoint(route);
for (let i = 0; i < route.waypoints.length; i++) {
const [x, y] = route.waypoints[i];
const done = route.visited.includes(i);
const live = !!next && next.index === i; // ONE leg lights; the rest keep their distance
const c = done ? doneColor : color;
const pulse = live ? 0.5 + 0.5 * Math.sin(time * 0.004 + i * 1.7) : 0;
const s = 7 + (live ? 3 * pulse : 0); // the diamond's half-diagonal
g.fillStyle(c, 0.05 + (live ? 0.05 * pulse : 0)); // the soft glow
g.fillCircle(x, y, 26 + (live ? 6 * pulse : 0));
g.lineStyle(1.5, c, done ? 0.4 : live ? 0.45 + 0.4 * pulse : 0.3); // the ring
g.strokeCircle(x, y, 15 + (live ? 5 * pulse : 0));
g.fillStyle(c, done ? 0.5 : live ? 0.95 : 0.45); // the diamond (45° square)
g.fillPoints([{ x, y: y - s }, { x: x + s, y }, { x, y: y + s }, { x: x - s, y }], true);
}
}
/**
* The active patrol's IN-WORLD beacons (per frame, update): the route
* waypoints in the route's system — a diamond + ring + soft glow per
* point (the data's patrol.marker colors): the yellow PULSE on the
* NEXT waypoint (the route is a path — 1, 2, 3, …; the rest stand dim
* until their turn), the dim done mark once visited — and the RED pulse
* on the CONTACT's waypoints (the route's encounter waypoints, the
* contract's combat) while their contact stands, the done mark once
* the contact is cleared. Only the ACTIVE contract's route, only in
* ITS system (the other contracts' routes sleep in the ledger; the
* arrow points the player to their system first). Clear of the
* world's solids by construction (the route's keep-out), so no
* collision glow is owed.
*/
_drawPatrolMarkers(time) {
const g = this.patrolG;
if (!g) return;
g.clear();
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.systemRecord || st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.targetSystemId !== this.systemRecord.id) return;
const color = toColor(config.get('sidequests.patrol.marker.color', '#ffd60a'), 0xffd60a);
const doneColor = toColor(config.get('sidequests.patrol.marker.doneColor', '#67e863'), 0x67e863);
const contactColor = toColor(config.get('sidequests.patrol.marker.contactColor', '#ff5c77'), 0xff5c77);
// The CONTACT beacons — the route's encounter waypoints (the contract's
// combat): the RED pulse while a contact stands (cleared → the done
// mark, like any visited waypoint). A route without encounters (old
// saves) is all ordinary waypoints.
const enc = route.encounters && Array.isArray(route.encounters.at) ? route.encounters : null;
const contactIdx = new Set(enc ? enc.at : []);
const next = nextBountyWaypoint(route); // the NEXT waypoint — the sequential law (1, 2, 3, …)
for (let i = 0; i < route.waypoints.length; i++) {
const [x, y] = route.waypoints[i];
const done = route.visited.includes(i);
const live = !!next && next.index === i; // ONE waypoint lights; the rest stand dim
const contact = contactIdx.has(i);
const c = done ? doneColor : (contact ? contactColor : color);
const pulse = done ? 0 : (live || contact) ? 0.5 + 0.5 * Math.sin(time * 0.004 + i * 1.7) : 0; // each point on its own phase
const s = 7 + (done ? 0 : 3 * pulse); // the diamond's half-diagonal
g.fillStyle(c, 0.05 + (done ? 0 : 0.05 * pulse)); // the soft glow
g.fillCircle(x, y, 26 + (done ? 0 : 6 * pulse));
g.lineStyle(1.5, c, done ? 0.4 : live ? 0.45 + 0.4 * pulse : 0.3); // the ring
g.strokeCircle(x, y, 15 + (done ? 0 : 5 * pulse));
g.fillStyle(c, done ? 0.5 : live ? 0.95 : 0.45); // the diamond (45° square)
g.fillPoints([{ x, y: y - s }, { x: x + s, y }, { x, y: y + s }, { x: x - s, y }], true);
}
}
/**
* The CLAIM REWARD action (QuestWindow's button → here). The window
* only offers the button when the checklist reads complete; this is
* the belt-and-braces pass — re-evaluates the live checklist and pays
* the reward exactly once (the ledger's claim() is idempotent, so a
* double-press or a re-open can never pay twice). The scene owns the
* effect (the minerals land in the hold); the ledger owns the record.
*/
claimQuest(id) {
// SIDE QUEST — the side ledger's sq- id (the tracker popup's CLAIM
// REWARD + the console's CLAIM REWARD both land here): the side
// path (claimSideQuest — the payout + the idempotency live there),
// with the console's toast shape. A story id falls through to the
// story ledger's path below.
if (typeof id === 'string' && id.startsWith('sq-')) {
const st = this.sideQuestState;
if (st?.isClaimed(id)) {
this.questWindow?.refresh();
return;
}
if (!st?.isAccepted(id) || !st.isCompleted(id)) {
const t = config.get('sidequests.ui.claimDenyToast', {});
this.consoleToast(
String(t.text ?? 'NOTHING TO CLAIM — ROUTE NOT COMPLETE'),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
},
);
this.questWindow?.refresh();
return;
}
const res = this.claimSideQuest(id);
if (res?.ok) {
const t = config.get('sidequests.ui.claimToast', {});
const xp = Number.isFinite(res.xp) ? res.xp : 0; // the claim's second payout (the XP)
const tpl = xp > 0 ? (t.textXp ?? t.text ?? 'REWARD CLAIMED — +{reward} CR · +{xp} XP') : (t.text ?? 'REWARD CLAIMED — +{reward} CR');
this.consoleToast(
String(tpl).replace('{reward}', String(res.credits ?? 0)).replace('{xp}', String(xp)),
{
glyph: t.glyph ?? '✓',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
},
);
// The claimed contract drops off the tracker's featured slot
// (the story claim's shape — the priority re-resolves to the
// next active quest on the next poll).
if (this.questState?.priority === id) {
this.questState.priority = null;
this.questState.priorityCleared = false;
}
}
this.questWindow?.refresh();
return;
}
if (!this.questState || !this.questState.isGranted(id)) return;
if (this.questState.isClaimed(id)) {
this.questWindow?.refresh();
return;
}
const def = questDef(id);
if (!def) return;
const snap = this.questSnapshot();
const q = snap?.quests?.find((x) => x.id === id);
if (!q || !q.complete) {
// Requirements still open — a refusal, in the console's voice.
const missing = (q?.checks ?? []).filter((c) => !c.done).map((c) => c.label.toUpperCase());
const t = config.get('quests.claimDenyToast', {});
this.consoleToast(
String(t.text ?? 'REQUIREMENTS INCOMPLETE — {missing}').replace('{missing}', (missing.slice(0, 2).join(' · ') || 'REQUIREMENTS')),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
},
);
this.questWindow?.refresh();
return;
}
if (!this.questState.claim(id)) return; // someone beat us to it — idempotent
// The reward: minerals into the hold (clamped to the ship's storage —
// addMinerals hands back what fit, so a full hold just wastes the
// excess, the game's rule for mined ore) + credits onto the ledger
// (data/quests.json → reward.credits; none of the first-pass quests pay
// credits yet, but the seam is live for the quest rewards that will).
const rw = def.reward ?? {};
const rewardParts = [];
if (typeof rw.minerals === 'number' && this.ship) {
this.ship.addMinerals(rw.minerals);
this.refreshMineralHud();
rewardParts.push(`+${rw.minerals} MINERALS`);
}
if (typeof rw.credits === 'number' && this.ship) {
this.ship.addCredits(rw.credits);
this.refreshCreditsHud();
rewardParts.push(`+${rw.credits} CREDITS`);
}
this.playSfx('construct'); // the power-up tick — the reward lands
const t = config.get('quests.claimToast', {});
this.consoleToast(
String(t.text ?? 'REWARD CLAIMED — {reward}').replace('{reward}', rewardParts.join(' · ') || 'REWARD'),
{
glyph: t.glyph ?? '✓',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
},
);
this.questWindow?.refresh();
// The RUN's chain — the claim that pays this quest is what arms the
// next (data/quests.json → leaving_the_nest.unlocks.claimed): the
// follow-up grants ONTO the claim (sfx + NEW QUEST toast + the OAC's
// quest-grant voice), queued after this claim's own toast.
this._grantUnlockedQuests();
}
/**
* Grant every not-yet-held quest whose unlock requirement is met
* (QuestState.grantUnlocked — pure ledger work; the scene supplies the
* ACKED half of the gate via this._commsAcked() — 'Pirate Problems'
* waits for the 'Special Delivery' payoff chain to be ACKed all the
* way through) and announce each new grant in the console's voice: the
* 'something new is here' SFX, the OAC's quest-grant voice (data/comms.json:
* questNew — once:false, re-armed on every grant), and the NEW QUEST
* toast (the starter's own toast style — quests.starterToast, one toast
* slot: the 2.7 s delay lands it after the claim toast that preceded
* it). Then the quest's START beats (_questStartBeats — 'Pirate
* Problems': the destination set + Trestle's warning + the ambush's
* grant-side arm). A fresh run's starter boot and a loaded save's boot
* both call this (the create() arm); claimQuest calls it on the claim;
* _commEnded calls it on the ACK that completes the last gate.
* Idempotent — give() is, and the announcement is per-NEW-grant.
* @returns {string[]} the newly granted ids
*/
_grantUnlockedQuests() {
if (config.get('quests.enabled', true) === false || !this.questState) return [];
const fresh = this.questState.grantUnlocked({ acked: this._commsAcked() });
for (const id of fresh) {
const def = questDef(id);
this.playSfx('discovery'); // the 'something new is here' voice
this.comms?.trigger('questNew'); // the OAC's quest-grant voice (audio-only)
this.time.delayedCall(2700, () => {
this.consoleToast(
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
.replace('{title}', String(def?.title ?? id).toUpperCase()),
{
glyph: config.get('quests.starterToast.glyph', '◆'),
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
});
}
if (fresh.length) {
this._questRefresh(); // a grant may have landed already-complete
this._questStartBeats(fresh); // the per-quest start beats (destination + comms)
this._trestleBeats(); // the gates may already be live (the claim was the last piece)
}
return fresh;
}
/**
* A quest's START beats — the one-shot work its grant owns (the
* 'the run turns' moment; runs exactly once per grant, on the scene
* that saw it). 'Pirate Problems' (data/quests.json →
* pirate_problems): the player's DESTINATION becomes the Neutral
* Capital world (the 'get back here' — set BEFORE the comms, so the
* warning answers the plotted route) and Trestle's warning sequence
* arms (trigger 'trestleQ4' — data/comms.json: trestle-q4-1/2/3);
* then the ambush's grant-side arm (a grant that lands while the
* player is already IN the capital's system). Other quests carry no
* start beats today; the seam is the shape for the ones that will.
* @param {string[]} fresh the newly granted ids (from _grantUnlockedQuests)
*/
_questStartBeats(fresh) {
if (!Array.isArray(fresh) || !fresh.length) return;
if (!fresh.includes('pirate_problems')) return;
const t = this.galaxy?.neutralCapitalTarget?.();
if (t?.systemId) {
this.setDestination(t.systemId, t.objectId, 'quest');
}
this._trestleQ4Beats();
// The AMBUSH's other arm: the grant may have landed while the player
// was already IN the capital's system (a late claim out there) — if
// the ship is already within the midpoint the wave starts here; if
// it's still gate-side, update()'s arm arms it once the flight
// crosses the halfway point. _maybeStartPirateAmbush is no-op-gated
// (quest live + step open + system match + not already running/done
// + within the midpoint trigger), so re-calling it here is safe.
this._maybeStartPirateAmbush();
}
/**
* Is the ship AT the NEUTRAL CAPITAL WORLD (the quest's destination
* planet — the outermost world of the designated neutral-capital
* system, Galaxy.neutralCapitalTarget, the destination trestle-brief-3's
* acknowledgment sends the player to)? The test is the same keep-out
* rim _checkDestinationReached uses for 'arrived at this object': the
* ship within rim + 40 px of the world's center. Returns the capital
* world's entity when at it, null otherwise (no galaxy / no target /
* another system / still in transit).
* @returns {object|null} the capital world entity, or null
*/
_atCapitalWorld() {
const t = this.galaxy?.neutralCapitalTarget?.();
if (!t?.objectId || t.systemId !== this.systemRecord?.id) return null;
const cap = (this.systemPlanets ?? []).find((p) => p.discoveryId === t.objectId);
if (!cap || !this.ship) return null;
const keepout =
cap.minCenterDistance?.(this.ship.radius) ??
(cap.radius + (cap.clearance ?? 0) + this.ship.radius);
const dist = Math.hypot(this.ship.x - cap.x, this.ship.y - cap.y);
return dist <= keepout + 40 ? cap : null;
}
/**
* The 'Leaving the Nest' quest's Trestle beats (data/comms.json:
* the trestle-brief-1/2/3 sequence + the trestle-arrive-1/2 pair) —
* armed from
* every point where their preconditions could have become true while
* the quest is held and unclaimed:
* - the BRIEFING (three back-to-back ack comms), when the home
* system's jumpgates are LIVE — the moment the player can jump
* out of the nest (the activateGates effect — _applyResearchEffects,
* or already-live when the quest grants / a save loads);
* - the ARRIVAL, when the player is AT the neutral capital world —
* the quest's destination planet: the quest completes on the
* PLANET, not on entering the neighboring system (the live flow
* earns it on the arrival moment, GameScene._checkDestinationReached;
* this scene-create arm covers a loaded save parked at the
* capital's rim).
* The comm's once-ledger + the hub's in-flight guard keep each beat a
* one-shot; the QUEST CHECKS are the authoritative witnesses (the
* played-ledger, read by _trestleCheck), so a beat the player never
* heard can't fake the check — and a quit mid-sequence re-arms only
* the unplayed beats here, on the next load.
*/
_trestleBeats() {
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'leaving_the_nest';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
const homeId = this.galaxy?.homeSystemId;
if (!homeId) return;
// The BRIEFING — home's gates are live (the quest's own 'Activate
// System Jump Gates' check holds): Trestle walks the player to the
// gate (one trigger arms all three comms; the hub's FIFO plays them
// back-to-back).
const homeKeys = activationKeys(this.galaxy, homeId);
if (homeKeys.length && homeKeys.every((k) => this.activatedGates?.has?.(k))) {
this.comms?.trigger('trestleBrief');
}
// The ARRIVAL — the player is AT the capital world (Trestle's side
// of the gate, ON the planet he promised). Only arms when the ship
// is actually there — a jump-in landing in the system (scene create,
// gate-side) does not complete the quest; the arrival moment does
// (GameScene._checkDestinationReached).
if (this._atCapitalWorld()) this.comms?.trigger('trestleArrival');
}
/**
* A comm was ACKNOWLEDGED (the CommsHub's onAck hook — the press
* itself, fired BEFORE onEnd): the run's comms ACKED-ledger mark.
* The played-ledger (registry 'commsPlayed' — GameScene._commPlayed) marks
* a comm when it STARTS; the acked-ledger (registry 'commsAcked')
* marks it when the ACKNOWLEDGE is pressed — 'heard all the way
* through'. 'Pirate Problems' (data/quests.json →
* pirate_problems.unlocks.acked) stands on this: its grant waits for
* the 'Special Delivery' payoff chain (carol-delivered-1/2/3 +
* oac-delivered-1) to be ACKed before it turns the run, and the mark
* must already stand when _commEnded's grant pass reads it (hence the
* onAck-before-onEnd order). SaveData captures the ledger under
* record.comms.acked; New Game clears it (resetRunState).
* @param {object} def the comm def that was acknowledged
*/
_commAcked(def) {
if (!def?.id) return;
const set = this._commsAcked();
if (!set.has(def.id)) set.add(def.id);
}
/**
* The run's comms ACKED-ledger (registry 'commsAcked') — the Set of
* comm ids acknowledged this run (_commAcked adds; SaveData restores
* it on load; resetRunState clears it on New Game). Created on first
* read, so old saves and new runs both find a live ledger.
* @returns {Set<string>} the acked ids
*/
_commsAcked() {
if (!this.registry) return new Set(); // a harness scene without a registry: nothing acked
let set = this.registry.get('commsAcked');
if (!(set instanceof Set)) {
set = new Set();
this.registry.set('commsAcked', set);
}
return set;
}
/**
* The run's combat done-ledger (registry 'combatDone') — the Set of
* wave-script names that have FINISHED this run (_recordCombatDone
* adds on the run's onComplete; SaveData captures it under
* record.combat.done and restores it on load; resetRunState clears it
* on New Game). The 'Pirate Problems' step's monotonic witness:
* the wave run's IN-FLIGHT state is scene-local (a load re-arms the
* ambush if it was mid-fight) but once it has cleared, the step
* stands — no re-fight.
* @returns {Set<string>} the cleared script names
*/
_combatDone() {
if (!this.registry) return new Set(); // a harness scene without a registry: nothing cleared
let set = this.registry.get('combatDone');
if (!(set instanceof Set)) {
set = new Set();
this.registry.set('combatDone', set);
}
return set;
}
/**
* A wave run FINISHED (its onComplete — the run's force is spent): the
* step's witness lands on the combat done-ledger (monotonic — a later
* re-fire of the same script, or a re-fire on load, cannot undo it)
* and the checklist row ticks over ('Defend your ship from pirates' —
* the 'Pirate Problems' step). The bar's departure (the combat going
* idle) is the player-facing cue; the console feed marks it too.
* @param {string} script the wave-script name (combat.waves.scripts key)
*/
_recordCombatDone(script) {
const id = String(script ?? '').trim();
if (!id) return;
const set = this._combatDone();
if (!set.has(id)) set.add(id);
this.combatHud?.kill('PIRATE AMBUSH CLEARED'); // the feed's moment
this._questRefresh(); // the checklist row may tick over (READY/READY)
}
/**
* The 'Pirate Problems' (data/quests.json → pirate_problems) grant's
* WARNING — Trestle's three beats (data/comms.json: trestle-q4-1/2/3,
* trigger 'trestleQ4', type 'ack': the player acknowledges each):
* the pirates' attention, the cannon + the upgrades he's seen the
* player earn, and the 'be on the lookout' that closes it. Armed the
* moment the quest is HELD + UNCLAIMED (the trigger's comm guard,
* GameScene._commTriggerGate — data/comms.json), so the beats ride
* the grant's announcement and never land before it; the comm's
* once-ledger + the hub's in-flight guard keep each beat a one-shot
* across the scene's life and across saves (a mid-sequence quit re-
* arms only the unplayed beats — the same contract as _trestleBeats).
* Arms from the grant (_questStartBeats) and the scene-create pass
* (create()'s tail — a loaded save that holds the quest unclaimed).
*/
_trestleQ4Beats() {
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
this.comms?.trigger('trestleQ4');
}
/**
* The 'Pirate Problems' AMBUSH — the quest's 'Defend your ship from
* pirates' step: with the quest live (held + unclaimed) and its
* combat step still open, the pirate wave starts the moment the ship
* reaches the MIDPOINT of the entry-gate → capital-world stretch —
* HALF the jumpgate-to-world distance from the capital (the quest's
* destination is the capital, so the run's flight crosses it), NOT
* on the system's enter (the gate-side spawn is a full stretch out).
* ONE rolling wave of six frame-0 raiders (data/combat.json →
* waves.scripts.pirate_ambush), three at a time, each kill feeding a
* replacement until all six are down. The wave ships materialize
* OFF-SCREEN around the player, each from a random direction
* (CombatSystem._spawnWaveEnemy), and stream in on their own chase
* AI. Three arms call it (all no-op-gated by the guards below):
* · update() — the per-frame arm: the flight crosses the midpoint;
* · create()'s tail — the ENTER, when the spawn already sits
* within the midpoint (a degenerate short stretch);
* · _questStartBeats — a grant that lands while the player is
* already in the system (a late claim out there).
* Guards (all must hold, in order): combat enabled + built; the
* quest held + unclaimed; the combat step not yet cleared (the
* monotonic done-ledger — a cleared ambush never re-fires); no wave
* run in flight (a live fight is the fight); the capital target
* resolves in THIS system (the ambush lives where Trestle is); the
* midpoint resolves (entry gate + capital world both found) and the
* ship is within it of the capital world.
* A mid-fight quit re-arms the ambush on load (the in-flight run is
* scene-local) — the step's witness is the completion, not the
* attempt, so the re-fire is the fair read.
*/
/**
* THE ENCOUNTER DIRECTOR'S HIT (js/combat/Encounters.js): the pack
* materializes off-screen around the ship (the _offscreenRadius band —
* the same seam the dev console's spawner uses), fanned around one base
* heading so the encounter streams in from one direction. Encounter
* hostiles are one-shots (respawn off — no mid-fight pops; the fight
* ends when the sky is clear); the escorts carry their own windows
* (neutral.escortMin/escortMax — the break-off is their state machine).
* @param {{kind:string, pirates:number, neutrals:number}} comp
*/
/**
* The ENCOUNTER CONTEXT for the current system (the war-zones): in a
* faction-controlled system, the two OPPOSING factions with their
* fighter rosters (the director rolls one of them — never the
* controller, never pirates/neutrals); null in neutral space (the
* classic roll stands) or where the roster is unknown (a non-roster
* faction — defensive).
* @returns {?{mode:string, faction:string, enemies:Array<{id:string, types:string[]}>}}
*/
_encounterContext() {
const fid = this.systemRecord ? this.systemRecord.faction : null;
if (!fid || fid === 'neutral') return null;
const all = factionIds();
if (!all.includes(fid)) return null;
const enemies = all
.filter((f) => f !== fid)
.map((f) => ({ id: f, types: factionTypeIds(f) }))
.filter((e) => e.types.length > 0);
if (enemies.length === 0) return null;
return { mode: 'faction', faction: fid, enemies };
}
_spawnEncounter(comp) {
const ship = this.ship;
if (!ship || !this.combat) return;
// FACTION SPACE — an opposing faction's fighters attack (hostile on
// sight), and the LOCAL faction's fighters join the player (allies:
// they fight the hostiles, never the player — the NeutralShip 'side:
// player' seam). See _spawnFactionEncounter.
if (comp?.kind === 'faction') { this._spawnFactionEncounter(comp); return; }
const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2);
const baseA = Math.random() * Math.PI * 2;
const eids = enemyTypeIds();
const nids = neutralTypeIds();
const pick = (list) => (list.length ? list[(Math.random() * list.length) | 0] : null);
// The band's ROSTER (data/combat.json → encounters.bands[].roster): the
// tier's weighted mix, drawn by the director's compose — a band without
// one leaves pirateTypes unset and the random pick stands (the old way).
const ptypes = Array.isArray(comp?.pirateTypes) && comp.pirateTypes.length > 0 ? comp.pirateTypes : null;
const fan = (i, n) => baseA + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (Math.random() - 0.5) * 0.4;
for (let i = 0; i < (comp.pirates | 0); i++) {
const a = fan(i, comp.pirates);
const e = this.combat.spawnEnemy(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
this._encounterSerial++,
ptypes ? ptypes[i % ptypes.length] : pick(eids),
);
e.respawn = false; // one-shots: the encounter ends with the sky clear
}
for (let i = 0; i < (comp.neutrals | 0); i++) {
const a = fan(i, comp.neutrals);
this.combat.spawnAlly(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
1000 + this._encounterSerial++,
pick(nids),
);
}
}
/**
* THE FACTION ENCOUNTER (the war-zones): an OPPOSING faction's fighters
* attack — hostile on sight, spawned exactly like the neutral-space
* hostiles (their types are roster-registered, spawnEnemy's job) — and
* the LOCAL faction's fighters join the player: player-side crafts (the
* NeutralShip 'side: player' seam — they engage the hostiles, never the
* player, and the player's guns never target them) on a finite escort
* window, so the allies stand for the fight and peel off after. The
* enemy's count/types come from the director's compose (seeded); the
* ally count + window are data (combat.encounters.faction, defaults in
* code).
*/
_spawnFactionEncounter(comp) {
const ship = this.ship;
if (!ship || !this.combat) return;
const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2);
const baseA = Math.random() * Math.PI * 2;
const count = Math.max(1, comp?.count ?? comp?.pirates ?? 1);
const types = Array.isArray(comp?.types) && comp.types.length > 0 ? comp.types : null;
const fan = (i, n) => baseA + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (Math.random() - 0.5) * 0.4;
// HOSTILES — the rival faction's fighters, one-shots like any encounter.
for (let i = 0; i < count; i++) {
const a = fan(i, count);
const e = this.combat.spawnEnemy(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
this._encounterSerial++,
types ? types[i % types.length] : null,
);
e.respawn = false;
}
// ALLIES — the local faction's fighters join the fight: the faction's
// OWN roster craft (its frame, weapon pattern, stats) with the
// player-side seam (side 'player', the rams disarmed) — spawnAlly's
// cfg override does the rest (they hold the wing for the window,
// engage any hostile up, and peel off when it lapses).
const ctx = this._encounterContext();
const myTypes = ctx ? factionTypeIds(ctx.faction) : [];
if (myTypes.length > 0) {
const allyCount = Math.max(1, Math.round(Number(config.get('combat.encounters.faction.allyCount', 2)) || 2));
const windowMs = Math.max(0, Number(config.get('combat.encounters.faction.allyWindowMs', 90000)) || 90000);
for (let i = 0; i < allyCount; i++) {
const a = fan(i, allyCount) + Math.PI; // opposite bearing: they arrive behind the attack
const tid = myTypes[(Math.random() * myTypes.length) | 0];
const base = enemyTypeConfig(tid) || {};
const allyCfg = { ...base, side: 'player', ramDamage: 0, ramDamageSelf: 0 };
this.combat.spawnAlly(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
2000 + this._encounterSerial++,
null,
{ escortMs: windowMs, cfg: allyCfg },
);
}
}
}
_maybeStartPirateAmbush() {
if (config.get('combat.enabled', true) === false) return;
if (!this.combat) return;
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
const qd = questDef('pirate_problems');
const combatStep = (qd?.checks ?? []).find((c) => c?.type === 'combatCleared');
const script = String(combatStep?.script ?? '').trim() || 'pirate_ambush';
if (this._combatDone().has(script)) return; // cleared once — the step stands
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // in flight
const t = this.galaxy?.neutralCapitalTarget?.();
if (!t?.objectId || t.systemId !== this.systemRecord?.id) return; // not this system
// The MIDPOINT TRIGGER — half the entry-gate → capital stretch:
// the raiders materialise at the halfway point, not at the gate.
const cap = (this.systemPlanets ?? []).find((p) => p.discoveryId === t.objectId);
const trigger = ambushTriggerDistance(this._pirateAmbushEntryGate(), cap);
if (trigger == null || !this.ship) return; // no stretch to halve
if (Math.hypot(this.ship.x - cap.x, this.ship.y - cap.y) > trigger) return; // not halfway yet
this.combat.startWaves(script, {
onComplete: () => this._recordCombatDone(script),
});
}
/**
* The AMBUSH's entry gate — the gate the player CAME IN through (the
* midpoint is measured from it): the destination's return gate for
* the last jump's source system first (jumpThroughGate stages
* lastJumpFrom into the restore record — exact, and stable across a
* save parked mid-stretch), then the system's gate NEAREST the spawn
* point (a jump materialises the ship just past its return gate's
* keepout, so the nearest gate is the one they used — the fallback
* for a fresh run or a save that predates lastJumpFrom), and
* finally the spawn point itself (a gate-less system — the spawn IS
* where the ship came from).
* @returns {{x:number,y:number}|null} the gate record, or null
*/
_pirateAmbushEntryGate() {
if (typeof this.lastJumpFromSystem === 'string') {
const g = returnGateFor(this.systemContent, this.lastJumpFromSystem);
if (g && typeof g.x === 'number' && typeof g.y === 'number') return g;
}
const anchor = this._ambushSpawn ?? this.ship;
if (anchor && Array.isArray(this.systemGates)) {
let best = null;
let bestD = Infinity;
for (const gt of this.systemGates) {
const d = Math.hypot(gt.x - anchor.x, gt.y - anchor.y);
if (d < bestD) {
bestD = d;
best = gt;
}
}
if (best) return best;
}
return anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number'
? { x: anchor.x, y: anchor.y }
: null;
}
/**
* The 'Pirate Problems' RETURN witness — the step's 'Return to the
* Neutral Capital World': the moment the ship is at the capital
* world's keep-out rim (the same _atCapitalWorld rim the 'Leaving
* the Nest' arrival beat reads) with the quest held + unclaimed,
* the story ledger's capitalReturned flag stands — monotonic, and
* the 'capitalReturned' check (GameScene._questCheckEval) reads it,
* so the step completes on ARRIVAL (in-space, the player hasn't even
* landed) regardless of how the player got here (the quest set the
* destination, but the witness is the position, not the route). The
* bar scene's own 'I'll add more speech' layer lands on top of this
* moment later. Called from update()'s destination pass (a no-op once
* the flag stands — the per-frame cost is a ledger read + a distance
* check) and from create()'s tail (a loaded save parked at the rim —
* the same arm shape as the 'Leaving the Nest' arrival beat).
*/
_maybeWitnessCapitalReturn() {
if (this.story?.capitalReturned) return; // monotonic — the step stands
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
if (!this._atCapitalWorld()) return; // not at the capital world's rim
this.story.capitalReturned = true;
this._questRefresh(); // the checklist row may tick over (READY)
}
/**
* A comm ENDED (the CommsHub's onEnd hook — for 'ack' comms this is
* the ACKNOWLEDGE press, for 'audio'/'visual' comms the moment the
* voice finishes): the scene's end-side bookkeeping.
*
* THE RESEARCH-COMPLETE SIGN-OFF (data/comms.json:
* oac-research-complete, audio-only — onEnd fires the moment the voice
* finishes): a completed tech may have OPENED a new one; if any category
* still holds a ready-to-research tech (researchAnyAvailable), OAC's
* 'new research' line answers the sign-off (oac-research-new — the
* hub's FIFO plays it right behind; see the in-line note).
*
* TRESTLE'S LAST BRIEF (data/comms.json: trestle-brief-3) — he is
* "in the system right next to you in this star cluster's capital" and
* "sending the coordinates to your computer right now": the moment the
* player acknowledges it, the NEUTRAL CAPITAL (the roster's faction
* pass — js/galaxy/Galaxy.js → _assignFactions; its seat is that
* system's outermost world) becomes the player's destination.
* setDestination does the rest: the route is derived from it, the
* orange compass marker points at the gate to fly, and the map's
* route lane lights up — no extra input from the player. Arriving is
* 'Meet Trestle' — the trestle-arrive-1/2 pair plays when the ship
* reaches the capital world (GameScene._checkDestinationReached).
*/
_commEnded(def) {
// The ACK that just landed is ALREADY in the acked ledger (CommsHub
// fires onAck BEFORE onEnd — GameScene._commAcked), so this end-side
// pass can read it. GRANT PASS: a comm ending can complete the last
// gate of a quest unlock — 'Pirate Problems' (data/quests.json →
// pirate_problems.unlocks) waits on the 'Special Delivery' payoff
// chain to be ACKed all the way through, and THIS press may be the
// last piece. _grantUnlockedQuests is a no-op when nothing is newly
// due (grantUnlocked reports only the new ones; give() is idempotent),
// so the pass runs on every comm end.
this._grantUnlockedQuests();
// THE RESEARCH-COMPLETE SIGN-OFF (data/comms.json: oac-research-complete,
// audio-only — onEnd fires the moment the voice finishes): the moment
// it has spoken, a completed tech may have OPENED a new one. If any
// category still holds a ready-to-research tech (researchAnyAvailable
// — nothing in flight, a node whose requires are met, the SYSTEM
// chart's gate included), arm OAC's 'new research' line
// (oac-research-new, data/comms.json → trigger 'researchNew') — the
// hub is idle again, so its FIFO starts it right now, and the voice
// rides on the comm system (music ducked, the same contract as the
// sign-off it answers). The same beat the shimmer cue lights
// (GameScene._applyResearchCue / ResearchWindow._paintCues), as voice.
// A leaf tech's completion opens nothing → the silence is the answer.
if (def && def.id === 'oac-research-complete') {
if (typeof this.researchAnyAvailable === 'function' && this.researchAnyAvailable()) {
this.comms?.trigger('researchNew');
}
return;
}
if (!def || def.id !== 'trestle-brief-3') return;
const t = this.galaxy?.neutralCapitalTarget?.();
if (!t) {
// The faction pass always designates one on a real galaxy — this
// is the degenerate-galaxy guard (no branches at all).
this.consoleToast('NO CAPITAL ON FILE — COORDS LOST IN THE STATIC', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
this.setDestination(t.systemId, t.objectId, 'quest');
}
/**
* SET PRIORITY — the tracker HUD's featured quest (the row popup's
* button, or the console's SET PRIORITY). The ledger is the source of
* truth (setPriority is idempotent + persists); the tracker and the
* console repaint on their next poll — the tracker's checklist moves
* to the newly featured quest LIVE.
*/
setPriorityQuest(id) {
if (!this.questState) return false;
// SIDE QUEST — the side ledger's sq- id (the popup/console's SET
// PRIORITY both land here): the choice is the side ledger's ACTIVE
// slot (the contract in flight) featured through the tracker's seam
// (the story ledger's priority — the scene owns both ledgers; the
// story ledger itself stays story-pure).
if (typeof id === 'string' && id.startsWith('sq-')) {
if (!this.sideQuestState?.setActive(id)) return false; // not held, done, or claimed
this.questState.priorityCleared = false; // the explicit set re-arms (a clear sticks until then)
this.questState.resolvePriority([id]); // the featured slot (stores the side id)
const t = config.get('quests.tracker.priorityToast', {});
let title = id;
try {
title = this.questSnapshot?.()?.quests?.find((q) => q.id === id)?.title ?? id;
} catch {
/* the id stands */
}
this.consoleToast(
String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(title).toUpperCase()),
{
glyph: t.glyph ?? '◆',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
},
);
this.questWindow?.refresh();
return true;
}
if (!this.questState.setPriority(id)) return false; // not held, claimed, or already the priority
const def = questDef(id);
const t = config.get('quests.tracker.priorityToast', {});
this.consoleToast(
String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(def?.title ?? id).toUpperCase()),
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)) },
);
this.questWindow?.refresh();
return true;
}
/**
* CLEAR PRIORITY — the player's "no priority right now" (the row
* popup's CLEAR PRIORITY). It STICKS on the ledger: the tracker shows
* plain titles again, and the auto-assignment re-arms only when a new
* quest is granted or one is claimed (the active set changed).
*/
clearPriorityQuest() {
if (!this.questState) return;
if (!this.questState.clearPriority()) return; // there was nothing to clear
const t = config.get('quests.tracker.clearToast', {});
this.consoleToast(String(t.text ?? 'PRIORITY CLEARED'), {
glyph: t.glyph ?? '·',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('dim', 0x7d92c4)),
});
this.questWindow?.refresh();
}
/**
* Open the QUESTS console ON A QUEST (the tracker's SHOW MORE — the
* first hidden quest — and VIEW QUEST): the deckAction('quests') close
* contract first, then the window lands on that quest (its tab active,
* its plate selected — QuestWindow.openAt).
*/
openQuestAt(questId) {
if (!this.questWindow) return;
if (this.questWindow.isOpen) {
this.questWindow.openAt(questId);
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
if (this.mapWindow && this.mapWindow.isOpen) this.mapWindow.close();
this.questWindow.openAt(questId);
this.questWindow.open();
}
/**
* A quest-relevant event happened (research completed, ore mined, a
* build finished, a world discovered, a reward claimed). Announce each
* quest that just became claimable ONCE (the console's voice) and arm
* the quest's PAYOFF comms on that same READY moment (the mission
* complete, the reward on the table — data/quests.json → the quest's
* payoffTrigger → data/comms.json; 'Pirate Problems' declares
* 'trestleQ4Payoff': Trestle's two-beat sign-off, trestle-q4-4/5 on
* the job boards + the level-5 hook — the comms' once-ledger keeps a
* loaded save's re-arm honest, the unplayed beats only), and kick the
* open console's poll so it repaints without waiting the interval.
*/
_questRefresh() {
if (!this.questState) return;
const snap = this.questSnapshot();
if (snap) {
for (const q of snap.quests) {
if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) {
this._questAnnounced.add(q.id);
this.playSfx('discovery'); // the READY chime — the 'something new is here' voice, once per quest
// The quest's PAYOFF comms — the trigger is the quest's own
// (data/quests.json → payoffTrigger), so a quest with no
// payoff stays silent; the hub's FIFO orders the beats and the
// comms' played-ledger keeps the chain a one-shot.
const trig = String(questDef(q.id)?.payoffTrigger ?? '').trim();
if (trig) this.comms?.trigger(trig);
const t = config.get('quests.readyToast', {});
this.consoleToast(
String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')
.replace('{title}', String(q.title).toUpperCase()),
{
glyph: t.glyph ?? '◆',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
}
}
}
this.questWindow?.refresh();
}
/**
* The GALAXY tab's live snapshot (js/ui/GalaxyView.js renders it, polled
* the same 500 ms the system chart is — js/ui/MapWindow.js). Pure data
* from the shared Galaxy + the run's footprint (js/galaxy/GalaxyChart.js):
* every system (type + archetype color), the jump lanes (used /
* frontier / live), the charted region's stats. Per-system `faction`
* stays `null` — the FACTIONS seam (planned, not yet implemented).
*/
galaxySnapshot() {
if (!this.galaxy || !this.systemRecord) return null;
return buildGalaxySnapshot({
galaxy: this.galaxy,
visited: this.visitedSystems,
used: this.usedGates,
live: this.activatedGates,
currentSystemId: this.systemRecord.id,
// The active route (data/gates.json → route): its lanes drawn orange
// and the destination star circled (js/ui/GalaxyView.js). Absent when
// no destination is set (the route is derived — routePlan is null).
route: this.routeEdgeKeys(),
destinationId: this.destination?.systemId ?? null,
});
}
// ------------------------------------------------------------ ship console
/**
* The SHIP console's snapshot (js/ui/ShipWindow.js polls this while the
* deck's SHIP slot is open; js/ship/ShipScreenModel.js resolves every row
* of data/shipScreen.json → status.rows against it). Plain data on purpose
* — a copy, so the view can never mutate the run:
*
* live:* the armor AS IT STANDS. Out of combat a ship is never damage
* (no persistent hull state exists by design), so the read is
* the maxima and the plate's rows carry their 'nominal' tag;
* while a fight runs these are the session's own numbers.
* ship:* the run's mutable state — stats AFTER upgrades/builds, ore
* aboard, credits, and the cargo hold's bays in fill order.
* level the XP ledger itself (js/combat/XpManager.js → stats()).
*/
// ═════════════════════════════════════════════════════════════ THE CLASS LAYER
/**
* THE LEVEL-5 DOOR.
*
* data/classes.json is the roster (Vanguard / Wraith), js/player/ClassState.js
* is the run's ledger (the class on file, its granted perks, the banked skill
* points — saved as record.playerClass), and js/ui/ClassWindow.js is the
* console that hands one out. This block owns WHEN it appears:
*
* 1. AUTO — once, when the run's combat level reaches
* data/shipScreen.json → level.classAt AND combat goes quiet (no live
* hostiles, no wave run in flight, nothing else owning the screen). The
* console then freezes the world through the existing _frozen /
* _parkForFreeze contract.
* 2. ON DEMAND — the ship console's CHOOSE YOUR CLASS door (its
* onChooseClass seam), which is also where a DEFERRED pick comes back.
* 3. DEV — index.html?level5 opens it on boot (?noclass suppresses the
* auto prompt, ?level5&tab=wraith opens straight to a tab).
*
* Once a class is on file the door never fires again; closing without picking
* spends the AUTO prompt (the game does not nag) but leaves the door.
*
* AFTER A PICK — the CONFIRMED lock-in hands the player to the SKILL TREE
* console (js/ui/SkillWindow.js) as soon as this one finishes closing: the
* class just banked skill points, so the tree opens on the beat.
*/
/** The dev door: index.html?level5 (or ?class) opens the console on boot. */
_classDevMode() {
try {
const q = new URLSearchParams(location.search);
if (q.has('noclass')) return null;
if (q.has('level5') || q.has('cls') || q.has('class')) {
const tab = String(q.get('tab') ?? '').trim().toLowerCase() || null;
return { auto: true, tab };
}
} catch {
/* no URL (headless) — the door is the level, as always */
}
return null;
}
/** The deferred media pass (VignetteHub's rule): sheet + feed + brief. */
_ensureClassMedia() {
if (this._classMediaStarted) return;
this._classMediaStarted = true;
loadClassMedia(this, () => {
this._classMediaReady = true;
const wait = this._classPendingOpen;
this._classPendingOpen = null;
if (wait) this.openClassWindow(wait);
});
}
/** Build the console on the first open (its ~2 MB of media is already in). */
_createClassWindow() {
if (this.classWindow) return this.classWindow;
this.classWindow = new ClassWindow(this, {
getClassState: () => this.classState,
// The CONFIRMED lock-in (the console's own ConfirmOverlay asked first).
onPick: (id) => this.applyClassPick(id),
// Dismissed. A pick closes; anything else is a DEFER — the auto prompt
// is spent (no nagging), and the ship console's door is the way back in.
onClose: (o = {}) => {
if (o?.picked || this.classState?.hasClass) {
this._classAutoOpen = false;
// A CONFIRMED lock-in walks straight into the SKILL TREE: the run
// just banked points (or changed class), so the moment this
// console's close beat lands, the player is handed to the tree.
if (o?.picked && this._skillTreeAfterPick) {
this._skillTreeAfterPick = false;
// past the close tween (220 ms) — the full-screen hand-off
this.time.delayedCall(320, () => this.openSkillWindow({ source: 'classPick' }));
}
return;
}
if (this._classAutoOpen) {
this._classAutoOpen = false;
const msg = String(classWindowSpec().toasts.defer ?? '');
if (msg) this.consoleToast(msg, { glyph: '›', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
}
},
onFirstOpen: () => {
this._classAutoShown = true;
},
onToast: (msg, o) => this.consoleToast(msg, o),
});
return this.classWindow;
}
/**
* Open the class console. Loads its media first when needed (the open is
* remembered and lands the moment the pass ends), and stands down to the
* data's voice when the class layer is switched off.
* @param {{source?: string, tab?: string|null, auto?: boolean}} [o]
*/
openClassWindow(o = {}) {
if (!classesEnabled()) {
this.consoleToast(String(config.get('shipScreen.level.chooseClass.toast', 'CLASS SELECT OFFLINE')), {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return false;
}
if (this.classWindow?.isOpen) return true;
if (!this._classMediaReady) {
this._classPendingOpen = o;
this._ensureClassMedia();
return false;
}
// Anything else that owns the screen gets out of the way first (the
// comms' box and the level marquee are shorter beats than a decision).
this.comms?.closeAll?.();
this._classAutoOpen = o.auto === true;
this._createClassWindow().open({ tab: o.tab ?? null });
return true;
}
/**
* The dev door's step over an ACK comm: the hub's OWN acknowledge path
* (CommsHub._ack — what the box's ACKNOWLEDGE button calls, so the onAck /
* onEnd bookkeeping still runs; not a teardown). With index.html?level5 in
* the URL the intro brief is stood down every frame it holds, so the class
* console lands (and releases) with no hand on the deck. A real run never
* takes this path — comms keep the screen exactly as designed.
*/
_devReleaseComms() {
const c = this.comms;
if (!c?.paused) return;
try {
c._ack?.();
} catch {
/* the comm's own business */
}
}
/** Is combat quiet enough to ask for a decision? (the auto-open's gate) */
_combatIsQuiet() {
const c = this.combat;
if (!c) return true;
if (c.playerDown === true) return false; // the respawn beat owns the screen
const live = Array.isArray(c.active) ? c.active.length > 0 : !!c.active?.size;
if (live) return false;
const run = c.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false;
return true;
}
/** Is any other full-screen beat holding the world? (the auto-open waits) */
_screenIsBusy() {
return !!(
this.comms?.paused ||
this.levelMarquee?.active ||
this._jumping ||
this.skillWindow?.isOpen ||
this.shipWindow?.isOpen ||
this.mapWindow?.isOpen ||
this.questWindow?.isOpen ||
this.researchWindow?.isOpen ||
this.buildWindow?.isOpen ||
this.jobBoardWindow?.isOpen
);
}
/** The per-frame look for the door (cheap: two reads until it is armed). */
_classBeat() {
const dev = this._classDevDoor ?? (this._classDevDoor = this._classDevMode());
// ?level5 keeps the deck clear of the intro comm for as long as it is up
// (see _devReleaseComms) — otherwise a queued brief grabs the screen back
// the moment the class console closes.
if (dev) this._devReleaseComms();
if (this.classWindow?.isOpen) return;
if (this._classPrompted || this.classState?.hasClass) return;
if (!classesEnabled()) return;
const level = Number(this.combat?.xp?.level ?? 0);
const armed = level >= classLevelAt();
if (!armed && !dev) return;
if (!this._classMediaStarted) this._ensureClassMedia();
if (!this._classMediaReady) return; // the pass lands it (openClassWindow waits)
// A few frames of settle before the dialog lands (the dev door arrives on a
// fresh deck; the real one arrives mid-flight, where a hostile may still be
// dying on screen).
this._classTicks = (this._classTicks ?? 0) + 1;
if (this._classTicks < (dev ? 90 : 30)) return;
// The DEV door ignores the run's stage (its whole point is landing the
// console without meeting the level); the real trigger waits its turn
// behind every other full-screen beat and any live combat.
if (!dev && (this._screenIsBusy() || !this._combatIsQuiet())) return;
this._classPrompted = true; // the prompt is made — auto-fire never repeats
this.openClassWindow({ source: 'trigger', auto: true, tab: dev?.tab ?? null });
}
/**
* The CONFIRMED lock-in: write the ledger, bank its bonus, tell the combat
* system (the perks are its gates) and say so. Idempotent — js/player/
* ClassState.js pays a class's initial bonus exactly once, and a DIFFERENT
* class is refused (changing class is a later layer with a price on it).
* @returns {{ok: boolean, reason?: string|null, id: string|null}}
*/
/**
* A combat bar SEAT press (keys 1-8 / the bar's middle seats): hand it to the
* fight, and if the fight refused, SAY WHY. Combat answers every press with a
* reason (js/combat/CombatSystem.js → cast) and the copy is data-owned
* (data/skills.json → window.castRefusals) — an empty seat, an unranked skill,
* a thin orb and a skill still recharging each read differently, because the
* player needs to know WHICH wall they hit.
* @param {number} index 0-7 (seat 1-8's index)
*/
castSkillSeat(index) {
const res = this.combat?.cast(index) ?? { ok: false, reason: 'disabled', seat: Number(index) + 1 };
if (res.ok) return res;
const spec = skillWindowSpec();
const label = res.label ? String(res.label).toUpperCase() : '';
const line = fillSkillTemplate(spec.castRefusals[res.reason] ?? (label || `SEAT ${res.seat} — NOTHING`), {
skill: label,
seat: res.seat,
left: Math.ceil(res.cooldownLeft ?? 0),
});
this.playSfx?.(spec.sfx.denied);
this.consoleToast(line, { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
return res;
}
/**
* Spend ONE skill point (the Skill Tree console's RANK UP action, the dev
* hook, and nothing else — one click is one rank). A successful spend pays out
* of the class ledger's bank, refreshes the fight's passives so a hull rank
* grows the armor NOW (js/combat/CombatSystem.js → refreshSkillPassives), and
* stamps the save registry. Refusals are a reason + the denied voice.
* @param {string} id skill id (data/skills.json)
* @returns {{ok: boolean, reason: string|null, id: string|null, rank: number,
* max: number, points: number, seat: (number|null)}}
*/
rankUpSkill(id) {
const spec = skillWindowSpec();
const res = this.skillState?.rankUp(id) ?? { ok: false, reason: 'noLedger', id: null, rank: 0, max: 0, points: 0, seat: null };
if (!res.ok) {
this.playSfx?.(spec.sfx.denied);
const why = res.reason === 'locked' && res.locked ? String(res.locked.label ?? '').toUpperCase() : '';
this.consoleToast(
why ? fillSkillTemplate(spec.lockedNote, { parent: why }) : `${String(id ?? '').toUpperCase()} — ${String(res.reason ?? 'NO').toUpperCase()}`,
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
);
return res;
}
this.registry.set('playerSkills', this.skillState);
// THE PASSIVE SEAM: a hull rank is not a promise for the next fight — the
// armor is re-rolled from base, so the pool grows (and the bar's orbs show
// it) the moment the point lands.
this.combat?.refreshSkillPassives?.();
this._syncDeathFlowRespawn(); // a ranked skill may override the death flow
this.playSfx?.(spec.sfx.spend);
const def = this.skillState.defOf(res.id);
this.consoleToast(
fillSkillTemplate(`${String(def?.label ?? res.id ?? '').toUpperCase()} {rank}`, { rank: `${res.rank}/${res.max}` }),
{ glyph: '◈', glyphColor: toCss(themeColor('cyan', 0x00e5ff)) },
);
return res;
}
/**
* The console's RESET: hand back the points THIS SESSION ranked into a skill,
* taking anything those ranks were holding up along with them (a child bought
* under the dropped ranks falls back to its OWN committed rank — the ledger
* refuses rather than reaching under the player's earlier sessions).
* The bank pays out through ClassState, passives re-roll NOW (a hull rank that
* went away shrinks the armor this frame), and the save is stamped.
* @param {string} id skill id (data/skills.json)
*/
resetSkillPending(id) {
const spec = skillWindowSpec();
const refusals = spec.resetRefusals ?? {};
const res = this.skillState?.resetSession?.(id) ?? { ok: false, reason: 'noLedger', id: null, refunded: 0, cascade: [] };
const labelOf = (key) => String(this.skillState?.defOf?.(key)?.label ?? key ?? '').toUpperCase();
const label = labelOf(res.id ?? id);
if (!res.ok) {
this.playSfx?.(spec.sfx.denied);
const child = res.blocked ? String(res.blocked.label ?? res.blocked.skill ?? '').toUpperCase() : '';
const line =
refusals[res.reason] ?? `${label} — ${String(res.reason ?? 'NO').toUpperCase()}`;
this.consoleToast(fillSkillTemplate(line, { skill: label, child, rank: res.rank ?? 0 }), {
glyph: '\u2715',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return res;
}
this.registry.set('playerSkills', this.skillState);
// THE PASSIVE SEAM, run backwards: unspent ranks stop counting now.
this.combat?.refreshSkillPassives?.();
this._syncDeathFlowRespawn(); // a refunded skill may end the override
this.playSfx?.(spec.sfx.refund ?? spec.sfx.denied);
this.consoleToast(
fillSkillTemplate(spec.resetToast, {
skill: label,
n: res.refunded ?? 0,
points: (res.refunded ?? 0) === 1 ? 'POINT' : 'POINTS',
}),
{ glyph: '\u25c7', glyphColor: toCss(themeColor('cyan', 0x00e5ff)) },
);
// A cascade takes more than the player asked for, so it is said out loud.
if ((res.cascade ?? []).length > 0) {
this.consoleToast(
fillSkillTemplate(spec.cascadeToast, {
child: res.cascade.map((c) => labelOf(c.id)).join(' / '),
skill: label,
n: res.refunded ?? 0,
points: (res.refunded ?? 0) === 1 ? 'POINT' : 'POINTS',
}),
{ glyph: '\u25c7', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
);
}
return res;
}
/**
* SIGN THE SESSION OFF — the console's way out (✕, ESC, the scrim, another
* console taking the screen). Whatever was still pending stops being undoable:
* the points stay spent and the next opening files a fresh snapshot. Nothing
* pending is not an event, so the toast only fires when there was something to
* commit (data/skills.json → window.commitToast).
*/
commitSkillSession() {
const res = this.skillState?.commitSession?.() ?? { committed: 0 };
if (!(res.committed > 0)) return res;
this.registry.set('playerSkills', this.skillState);
const spec = skillWindowSpec();
this.consoleToast(
fillSkillTemplate(spec.commitToast, {
n: res.committed,
points: res.committed === 1 ? 'POINT' : 'POINTS',
}),
{ glyph: '\u25c7', glyphColor: toCss(themeColor('cyan', 0x00e5ff)) },
);
return res;
}
/**
* KEYS 1-8 (wired in create()). Digit → seat, with the gates the mouse already
* respects: a full-screen console owns the keys (its own ESC/typing must not be
* eaten and the world is parked anyway), EXCEPT while a seat assignment is
* pending — that handoff deliberately happens over the live bar, so there the
* digits answer the placement.
* @param {KeyboardEvent} [e]
*/
_skillKeyAction(e) {
const raw = `${String(e?.key ?? '')}|${String(e?.code ?? '')}`;
const m = /^([1-9])\||^Digit([1-9])$/i.exec(raw);
const digit = Number(m?.[1] ?? m?.[2] ?? 0);
if (!digit) return undefined;
const seat = digit - 1; // the bar's seats are 0-indexed
if (this._skillAssign?.id) return this.pickSkillSeat(seat);
if (this._screenIsBusy() || this.classWindow?.isOpen === true || this.skillWindow?.isOpen === true) {
return undefined;
}
return this.castSkillSeat(seat);
}
/**
* THE ASSIGNMENT HANDOFF (the skill tree's ASSIGN SEAT press, and the dev hook).
* The bar is the picker — it can sit anywhere, so the tree closes (its open
* state is remembered), the eight seats start breathing and the next seat press
* or digit 1-8 places the skill. A passive is REFUSED here too: passives are
* always on and never hold a seat (the ledger refuses one as well — a hand-edited
* save can't smuggle one in).
* @param {string} id skill id (data/skills.json)
*/
beginSkillAssign(id) {
const spec = skillWindowSpec();
const bar = skillBarSpec();
const def = this.skillState?.defOf?.(id) ?? null;
// ONLY ACTIVATABLE SKILLS HAVE A SEAT. A passive is always on — it spends its
// rank at the ledger and never occupies a seat, so it is refused here (the
// hand never comes up), in the ledger (SkillState.bindSeat) and by the key.
if (!def || def.passive) {
this.playSfx?.(spec.sfx.denied);
this.consoleToast(bar.copy.assignBlocked.replace('{skill}', String(def?.label ?? id ?? 'SKILL').toUpperCase()), {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return { ok: false, reason: def ? 'passive' : 'unknown', seat: null };
}
this._skillAssign = {
id: def.id,
// Where the console came back from, so a pick (or an ESC) returns there.
from: this.skillWindow?.isOpen ? 'skillWindow' : null,
};
if (this.skillWindow?.isOpen) this.skillWindow.close({ reason: 'assign' });
this.combatBar?.setAwaitingSeat({ on: true, id: def.id });
this.playSfx?.(spec.sfx.assign);
this.consoleToast(
fillSkillTemplate(spec.assignHint, { maxSeat: this.skillState?.seatCount ?? 8 }),
{ glyph: '\u25c7', glyphColor: toCss(themeColor('cyan', 0x00e5ff)), sub: bar.copy.hintUnbind },
);
return { ok: true, reason: null, id: def.id };
}
/** Stand the pending assignment down (ESC, a successful bind, a closed door). */
cancelSkillAssign() {
if (!this._skillAssign) return { ok: false, reason: 'none' };
const was = this._skillAssign;
this._skillAssign = null;
this.combatBar?.setAwaitingSeat({ on: false });
if (was.from === 'skillWindow') this.openSkillWindow?.({ tab: null });
return { ok: true, reason: null, id: was.id };
}
/**
* A seat was clicked WHILE a skill is in the hand: write the binding into the
* ledger, stamp the save, say where it landed. Bindings are data (`seats` rides
* record.playerSkills), so this survives a save/load and every combat restart.
* @param {number} index 0-7 (the bar's seat index)
*/
pickSkillSeat(index) {
const spec = skillWindowSpec();
const bar = skillBarSpec();
const pending = this._skillAssign;
if (!pending?.id) {
// The bar only asks when the scene put a skill in its hand; anything else is
// a stray click (a seat press that arrives with no assignment pending).
this.playSfx?.(spec.sfx.denied);
this.consoleToast(bar.copy.pickFromTree, { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
return { ok: false, reason: 'noPending' };
}
const res = this.skillState?.bindSeat(pending.id, Number(index) + 1) ?? { ok: false, reason: 'noLedger' };
if (!res.ok) {
this.playSfx?.(spec.sfx.denied);
const label = String(this.skillState?.defOf?.(pending.id)?.label ?? pending.id).toUpperCase();
this.consoleToast(
res.reason === 'passive'
? bar.copy.assignBlocked.replace('{skill}', label)
: `${label} — ${String(res.reason ?? 'NO').toUpperCase()}`,
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
);
return res;
}
this._skillAssign = null;
this.combatBar?.setAwaitingSeat({ on: false });
// A displaced skill is not destroyed — its seat simply changed hands (the ledger
// guarantees one skill never holds two seats). The ranks are untouched.
this.registry.set('playerSkills', this.skillState);
this.playSfx?.(spec.sfx.assign);
this.consoleToast(
fillSkillTemplate(spec.assignedToast, {
skill: String(this.skillState.defOf(res.id)?.label ?? res.id).toUpperCase(),
seat: res.seat,
}),
{ glyph: '\u25c7', glyphColor: toCss(themeColor('cyan', 0x00e5ff)) },
);
if (pending.from === 'skillWindow') this.openSkillWindow?.({ tab: null });
return res;
}
/**
* RIGHT-press a seat: CLEAR it, making room for another skill. Only the BINDING
* is lost — the ranks stay spent (un-ranking is the console's RESET, and it reaches
* only what THIS SESSION ranked; data/skills.json → window.sessionNote). Saved
* with the run like every other part of the ledger.
* @param {number} index 0-7
*/
clearSkillSeat(index) {
const spec = skillWindowSpec();
const bar = skillBarSpec();
const i0 = Number(index);
const was = this.skillState?.skillAtSeat?.(i0) ?? null;
if (!was) {
this.playSfx?.(spec.sfx.denied);
this.consoleToast(fillSkillTemplate(bar.copy.clearEmpty, { seat: i0 + 1 }), {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return { ok: false, reason: 'empty' };
}
const res = this.skillState.clearSeat(i0 + 1);
this.registry.set('playerSkills', this.skillState);
this.playSfx?.(spec.sfx.denied);
this.consoleToast(fillSkillTemplate(bar.copy.clearedToast, { seat: i0 + 1 }), {
glyph: '\u25c7',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
if (this.skillWindow?.isOpen) this.skillWindow.refresh?.();
return { ...res, label: was };
}
/**
* The skill icons the bar and the console draw (loaded once, on demand, the way
* every other UI sheet in the game is). A missing file is not an error: the seats
* keep their frame, veil and sweep and simply draw no icon.
*/
_ensureSkillIcons() {
if (this._skillIconsTried) return;
const sheet = skillIconSheet();
// The path is the data's (data/skills.json → icons.texture). No path, or a
// sheet that never loads, is not an error — the seats keep their frame, veil,
// sweep and seconds and simply draw no icon.
if (!sheet.texture) {
this._skillIconsTried = true;
return;
}
if (this.textures.exists(SKILL_ICON_KEY)) {
this._skillIconsTried = true;
return;
}
// THE LOADER IS BUSY: Phaser's queue is single-file at heart and a start()
// issued while another load runs (the deferred comms/media pass, the scene
// restart's own queue) is dropped — so this waits for the idle beat instead of
// losing the sheet. A handful of beats, then it gives up and the bar draws
// seats without icons (frame, veil and sweep still tell the story).
if (this.load.isLoading?.() === true) {
this._skillIconBeats = (this._skillIconBeats ?? 0) + 1;
if (this._skillIconBeats <= 40) this.time.delayedCall(250, () => this._ensureSkillIcons());
else this._skillIconsTried = true;
return;
}
this._skillIconsTried = true;
this.load.once('complete', () => {
for (const art of this.combatBar?.seatArt ?? []) art.sig = ''; // redraw now the icons are in
});
this.load.spritesheet(SKILL_ICON_KEY, sheet.texture, {
frameWidth: sheet.frameWidth,
frameHeight: sheet.frameHeight,
});
this.load.start();
}
/**
* THE DEV DOOR — index.html?skill (aliases ?skills / ?skilltree). Normal play
* runs not one line of this: no parameter, no door (the same convention as
* ?level5, ?combat and ?noclass — and ?noclass closes this door too).
*
* ?skill file the tree's first class and top the bank up to everything
* the tree can absorb (no grinding through level 5)
* ?skill=wraith …the NAMED class, any id that owns a tree in data/skills.json
* ?skill=max …and rank every skill in that tree to its max
* ?skilltree open the Skill Tree console on boot
*
* Every number it needs (the grant already paid, each skill's maxPoints) is read
* from the data — the door grants arithmetic, never balance.
*/
_skillDevMode() {
try {
const q = new URLSearchParams(location.search);
if (q.has('noclass')) return null;
const raw = q.get('skill') ?? q.get('skills');
const open = q.has('skilltree') || q.has('skillwindow');
if (raw === null && !open) return null;
const mode = String(raw ?? '').trim().toLowerCase();
const max = mode === 'max' || mode === 'all';
return { classId: mode && !max ? mode : null, max, open };
} catch {
return null; // no URL (headless) — the door is shut
}
}
/**
* Land the dev door once, at the end of create(). The class goes in through the
* REAL lock-in (applyClassPick: perks paid, default seats seeded, registry
* stamped), the bank is topped up to what the tree can take, and ?skill=max
* spends it down. A run that already has a class on file keeps its ranks — the
* door only ever tops the bank up, so loading a saved run never un-spends it.
*/
_applySkillDevDoor() {
const dev = this._skillDevDoor ?? (this._skillDevDoor = this._skillDevMode());
if (!dev || !this.skillState || this._skillDevApplied) return null;
this._skillDevApplied = true;
const ids = skillTreeClassIds();
if (ids.length === 0) return null; // the layer is switched off in the data
const wanted = dev.classId && skillHasTree(dev.classId) ? dev.classId : null;
if (!this.classState.hasClass) {
const picked = this.applyClassPick(wanted ?? ids[0]);
if (!picked?.ok) return null;
} else if (wanted && this.classState.id !== wanted) {
// A class is already filed: the door does not swap ships with it (changing
// class is a layer that does not exist yet). The run keeps what it has.
this.consoleToast(`${String(wanted).toUpperCase()} — CLASS ALREADY ON FILE`, {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
const list = skillListOf(this.classState.id);
const capacity = list.reduce((n, def) => n + (def.maxPoints || 0), 0);
const spent = list.reduce((n, def) => n + this.skillState.rank(def.id), 0);
const need = Math.max(0, capacity - spent - this.classState.skillPoints);
if (need > 0) this.classState.awardSkillPoints(need);
if (dev.max) {
for (const def of list) {
for (let i = 0; i < (def.maxPoints || 0); i++) {
if (!this.rankUpSkill(def.id).ok) break;
}
}
}
this.registry.set('playerClass', this.classState);
this.registry.set('playerSkills', this.skillState);
// The console is asked for on the beat rather than this instant: an intro comm
// owns the screen during boot and would take it straight back (?skilltree).
if (dev.open) {
this._skillTreePendingOpen = true;
this._skillTreeDoorDeadline = this.time.now + 12000;
}
return { classId: this.classState.id, granted: need, maxed: !!dev.max };
}
/**
* Open the SKILL TREE console (the ship console's door, the dev hook
* ?skilltree). A run with no class on file gets the class voice instead — the
* tree belongs to a class, and the door that files one is the class console.
*/
openSkillWindow(o = {}) {
if (!this.skillState || skillTreeClassIds().length === 0) {
this.playSfx?.(skillWindowSpec().sfx?.denied);
this.consoleToast(String(config.get('shipScreen.skillsLockedToast', 'SKILL TREE OFFLINE')), {
glyph: '\u2715',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return false;
}
if (!this.classState?.hasClass) {
this.openClassWindow({ source: 'skillTree' });
return false;
}
// The class crest on the console's plate comes from the class media: ask for it
// the way the ship console does, then the console repaints when it lands.
if (!this.textures.exists(CLASS_ICON_KEY)) this._ensureClassMedia?.();
// Anything else that owns the screen steps aside (the full-screen contract).
for (const w of [this.shipWindow, this.mapWindow, this.questWindow, this.researchWindow]) {
if (w?.isOpen) w.close();
}
this.comms?.closeAll?.();
// THE SESSION OPENS HERE. The ranks are snapshotted (SkillState.beginSession):
// that snapshot is what RESET unwinds back to and what closing commits. It is
// idempotent, so the seat hand-off's close→reopen keeps the ORIGINAL snapshot —
// a point spent before the hand-off stays refundable after it.
this.skillState?.beginSession?.();
this.skillWindow?.open({ source: o.source ?? null });
return true;
}
/** What the console PAINTS: the ledger's rows + the class plate. No numbers of
* its own — every string and figure is the data's (data/skills.json) or the
* run's (ClassState's bank, SkillState's ranks and seats). */
skillWindowSnapshot() {
const st = this.skillState;
const cls = this.classState;
if (!st || !cls) return { hasClass: false, rows: [], points: 0 };
const def = cls.hasClass ? classDef(cls.id) : null;
return {
hasClass: cls.hasClass,
classId: cls.id ?? null,
className: def?.label ?? String(cls.id ?? '').toUpperCase(),
accentCss: def?.accentCss ?? null,
frame: def?.frame ?? null,
iconKey: CLASS_ICON_KEY,
iconReady: !!def && this.textures.exists(CLASS_ICON_KEY),
points: cls.skillPoints,
spent: st.stats().spent,
// What RESET can reach: the session's own tally (the console prints it, the
// ledger does the arithmetic).
pending: st.stats().pending,
inSession: st.stats().inSession,
seatCount: st.seatCount,
rows: st.rows(),
};
}
/** The ?skilltree ask: open the console once the deck is calm (an intro comm or
* the level marquee owns the screen during boot). Inert with no door, and it
* gives up after a bounded wait rather than fighting the player. */
_skillDoorBeat() {
if (!this._skillTreePendingOpen) return;
if (this.time.now > (this._skillTreeDoorDeadline ?? 0)) {
this._skillTreePendingOpen = false;
return;
}
if (this.comms?.paused || this.levelMarquee?.active) return;
if (this.openSkillWindow({ source: 'devDoor' })) this._skillTreePendingOpen = false;
}
applyClassPick(id) {
const res = this.classState.choose(id);
if (!res.ok) return res;
this.registry.set('playerClass', this.classState);
this._classPrompted = true;
// THE HAND-OFF: the pick is CONFIRMED, so the class console's close
// (onClose) walks the player to the SKILL TREE to spend the points.
this._skillTreeAfterPick = true;
// THE COMBAT SEAM: the firing code reads the ledger through the combat
// system (dualCannon → the twin-barrel volley, targetingComputer → the
// leading solve). See js/combat/CombatSystem.js.
if (this.combat) this.combat.classState = this.classState;
// THE SKILL SEAM: the class the run just filed OWNS a tree. A ledger that
// has never laid out its bar takes the data's default seats now, so the
// skills the player is about to rank already have somewhere to sit.
this.skillState?.applyDefaultSeats();
const def = classDef(id);
const spec = classWindowSpec();
this.playSfx?.(spec.sfx.success);
this.consoleToast(successMessage(id), {
glyph: '◈',
glyphColor: toCss(def?.accentCss ?? '#00e5ff'),
});
// The ship console's journey plate flips from the door to the class.
this.shipWindow?.refresh?.();
return res;
}
/** The class readout the ship console paints in place of its door (null =
* no class on file, so the CHOOSE YOUR CLASS door keeps standing). */
classPlateSnapshot() {
const st = this.classState;
if (!st?.hasClass) return null;
const def = classDef(st.id);
return {
name: def?.label ?? String(st.id).toUpperCase(),
insignia: def?.frame ?? 0,
accent: def?.accentCss ?? null,
skillPoints: st.skillPoints,
perks: perkNames(st.id, ' · '),
};
}
shipScreenSnapshot() {
const s = this.ship;
if (!s) return null;
const c = this.combat;
const fighting = !!(c && c.active);
// The maxima come from the combat system when it exists — that's where
// the per-level armor bump lands (progression.reward.hullPerLevel /
// shieldPerLevel), so the plate never shows a stale base number.
const hullMax = Math.max(1, Math.round(Number(c?.maxHull ?? s.stats?.hullIntegrity) || 0));
const shieldsMax = Math.max(0, Math.round(Number(c?.maxShields ?? s.stats?.shields) || 0));
// THE SKILL TREE DOOR — the ship console's standby tab lights the moment the
// run's class owns a tree (data/skills.json → classes.<id>.skills).
const skillDoor = !!(this.classState?.hasClass && skillHasTree(this.classState.id));
return {
skillDoor,
live: {
hull: fighting ? Math.max(0, Math.round(Number(c.hull) || 0)) : hullMax,
hullMax,
shields: fighting ? Math.max(0, Math.round(Number(c.shields) || 0)) : shieldsMax,
shieldsMax,
},
ship: {
stats: { ...(s.stats ?? {}) },
minerals: Math.max(0, Math.round(Number(s.minerals) || 0)),
credits: Math.max(0, Math.round(Number(s.credits) || 0)),
cargo: {
used: Math.max(0, Math.round(Number(s.cargo?.used) || 0)),
capacity: Math.max(0, Math.round(Number(s.cargo?.capacity) || 0)),
items: Array.isArray(s.cargo?.items) ? [...s.cargo.items] : [],
// The per-bay purchase costs (parallel to items — the credits/unit
// the player actually paid; null where a bay has no cost on file).
// The console's bay manifest compares its exit prices against this.
costs: Array.isArray(s.cargo?.costs) ? [...s.cargo.costs] : [],
},
},
level: typeof c?.xp?.stats === 'function' ? c.xp.stats() : null,
// The class layer's seam: the class on file (its label, its insignia
// FRAME, its own accent and its banked skill points) — which is what
// turns the ship console's CHOOSE YOUR CLASS door into the class plate.
playerClass: this.classPlateSnapshot(),
};
}
/**
* A held good's report for the SHIP console's bay manifest — the SAME
* scoutGood() the planet shop's scout panel renders (js/ui/shop/ScoutPanel.js),
* over the run's chart (galaxy + current system + visitedSystems).
*
* THE PROFIT BASELINE: the bay's own PURCHASE COST when it is on file
* (`cost` — the credits/unit the player actually paid, recorded by
* js/economy/CargoHold.js at the moment of purchase): the manifest exists
* to show how each exit's sell price compares to what the player PAID.
* A bay with no cost on file (the quest package's handoff, a save that
* predates the cost record) falls back to the catalog's own value
* (data/goods.json → baseValue — data/shipScreen.json →
* cargo.detail.baseline documents the choice). The exits themselves are
* priced exactly as the shop prices them.
*/
shipScreenScoutReport(goodId, cost = null) {
if (!goodId || !this.galaxy) return null;
const good = (config.get('goods.goods', []) ?? []).find((g) => g?.id === goodId) ?? null;
const baseline = String(config.get('shipScreen.cargo.detail.baseline', 'baseValue'));
const base = Math.max(0, Math.round(Number(good?.baseValue) || 0));
// The bay's recorded purchase cost (credits/unit), or null (no cost on
// file). A recorded cost is the reference — full stop; the data baseline
// only stands in where the hold has nothing recorded. (null in → null
// out: Number(null) === 0 would silently price against a 0-credit cost.)
const paid =
cost != null && Number.isFinite(Number(cost)) && Number(cost) >= 0
? Math.max(0, Math.round(Number(cost)))
: null;
return scoutGood(goodId, {
galaxy: this.galaxy,
systemId: this.systemRecord?.id ?? null,
// no local market → the catalog value is the reference (a non-finite
// buyUnit would make TradeScout price against the current system)
buyUnit: paid ?? (baseline === 'buy' ? undefined : base),
visited: this.visitedSystems,
topN: Math.max(1, Math.floor(Number(config.get('shipScreen.cargo.detail.topN', 3)) || 3)),
});
}
// ------------------------------------------------------------ map console
/**
* The MAP console's chart snapshot (js/ui/MapWindow.js polls this while
* open; js/galaxy/SystemChart.js shapes the stats).
*
* The frame covers EVERY object of the system — discovered or not (the
* map must always show the full extent of the solar system) — plus the
* player's tether reach; the chart DRAWS only the discovered ones.
*/
mapChartSnapshot() {
const sysId = this.systemRecord?.id;
const content = this.systemContent;
if (!sysId || !content) return null;
const disc = this.discovery;
const isDisc = (id) => (disc ? disc.isDiscovered(sysId, id) : true);
const objects = [];
for (const p of this.systemPlanets) {
objects.push({
id: p.discoveryId,
kind: 'planet',
x: p.x,
y: p.y,
radius: p.radius,
name: p.discoveryName,
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
discovered: isDisc(p.discoveryId),
tint: toCss(config.get(`planets.classTint.${p.name}`, '#9fb6d8')),
});
}
for (const st of this.systemStations) {
objects.push({
id: st.discoveryId,
kind: 'station',
x: st.x,
y: st.y,
radius: st.bound ?? st.size,
name: st.discoveryName,
typeLabel: 'Station',
discovered: isDisc(st.discoveryId),
});
}
for (const gt of this.systemGates) {
objects.push({
id: gt.discoveryId,
kind: 'gate',
x: gt.x,
y: gt.y,
radius: gt.bound ?? gt.size,
name: gt.discoveryName,
typeLabel: gt.toName ? `Gate → ${gt.toName}` : 'Jump Gate',
discovered: isDisc(gt.discoveryId),
rotation: gt.rotation,
active: gt.active,
});
}
for (const c of this.asteroidClusters) {
objects.push({
id: c.discoveryId,
kind: 'cluster',
x: c.x,
y: c.y,
radius: c.bound,
name: c.discoveryName,
typeLabel: 'Rock Field',
discovered: isDisc(c.discoveryId),
rocks: (c.members ?? []).map((m) => ({
dx: m.lx * 1, // local offset — the plate transform is a uniform scale
dy: m.ly,
r: Math.max(1, m.radius),
seed: (m.lx * 7919 + m.ly * 104729) % 1000 / 1000,
})),
});
}
// the player's tether zones — the explored region (union boundary + fog)
const tethers = (this.tetherField?.tethers ?? []).map((t) => ({
id: t.id,
x: t.x,
y: t.y,
radius: t.radius,
level: t.level,
label: t.label ?? '',
}));
// central body: the home world in the starting system; every other
// system's center is EMPTY — the star is invisible dossier flavor
// (name/class feed the plate tag, never a rendered disc: `visible`
// is false for the map's central-body draw)
const starClass = String(content.star?.class ?? '').toUpperCase();
const central = this.isHomeSystem
? {
name: this.homeWorldName ?? 'Terra',
isHome: true,
visible: true,
radius: this.planet?.radius ?? 200,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
}
: {
name: this.planet?.discoveryName ?? 'Star',
isHome: false,
visible: false,
radius: 240,
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
color: toCss(config.get(`planets.star.classColor.${starClass}`, '#ffe9b0')),
};
return {
systemId: sysId,
systemName: this.systemRecord?.name ?? sysId,
isHome: this.isHomeSystem === true,
central,
objects,
tethers,
ship: this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null,
// The DESTINATION object (if the player is in the destination system
// and the destination names one) — the chart circles it in the route's
// orange (data/gates.json → route.compassColor) so the final stop is
// unmistakable. Null otherwise (no destination, or the destination is
// in another system — then the route is shown on the GALAXY tab).
destinationId: this.destination?.objectId && this.destination.systemId === sysId
? this.destination.objectId
: null,
// The ROUTE EXIT GATE — the jump gate in THIS system that leads toward
// the destination (routeNextGate). Shown when the destination is OUTSIDE
// the current system (the player needs to leave this system to reach
// it): the chart circles it in orange + draws an orange line from the
// ship to it ("path out of the system"). Null when the destination is
// in this system (then destinationId is set instead) or there is no
// active route.
routeExitGate: (() => {
const d = this.destination;
if (!d?.systemId || d.systemId === sysId) return null;
const gate = this.routeNextGate();
if (!gate) return null;
return {
id: gate.discoveryId,
x: gate.x,
y: gate.y,
name: gate.discoveryName ?? 'Gate',
};
})(),
stats: {
nav: navDiscoveryStats(disc, sysId, content),
res: resourceStats(disc, sysId, content),
},
};
}
/**
* The SYSTEM tab's chart snapshot (js/ui/MapWindow.js polls this while
* that tab is open): a CHARTED star picked on the GALAXY tab, drawn by
* the same painter as the CURRENT SYSTEM tab. The snapshot itself is
* pure — js/galaxy/SystemChart.js → systemChartSnapshot() — built
* straight from the generated content (no live entities; the ship and
* tethers exist only in the CURRENT system), so it is safe to poll
* with nothing of the system loaded. Discovered flags are the run's
* Discovery footprint.
*/
systemChartSnapshotFor(systemId) {
if (!systemId || !this.galaxy || systemId === this.systemRecord?.id) return null;
const record = this.galaxy.byId.get(systemId);
if (!record) return null;
const content = this.galaxy.contentOf(systemId);
// A field the run depleted stays off the chart: the content may have
// been regenerated since the mining (a load rebuilds the galaxy), so
// the splice is applied here too (the CURRENT system is spliced in
// create() — and is excluded above).
if (content && Array.isArray(content.asteroids) && this.depletedClusters?.size) {
content.asteroids = content.asteroids.filter(
(c) => !this.depletedClusters.has(`${systemId}:${c?.id}`),
);
}
return systemChartSnapshot(systemId, content, {
discovery: this.discovery,
isDiscovered: (id) => this.discovery?.isDiscovered(systemId, id),
// The DESTINATION object (if this is the destination system and the
// destination names one) — the chart circles it in the route's orange.
destinationId: this.destination?.objectId && this.destination.systemId === systemId
? this.destination.objectId
: null,
});
}
/**
* The SYSTEM tab's SET DESTINATION CONFIRM (js/ui/MapWindow.js →
* onSetDestination): the player marked an object on a CHARTED star's
* chart as their destination. The destination model is still being
* defined — for now the pick is acknowledged (the seam is stable:
* systemId + objectId).
*/
/**
* SET DESTINATION (the MAP console's SYSTEM tab — MapWindow._askObject):
* record the target system and plot the route to it. The route's FIRST
* STEP is what the player flies now — the current system's jump gate
* toward the destination (routeNextGate) — and it is marked ORANGE on
* the compass (updateDiscovery) so the player always knows where to go
* next. The MAP window closes itself once this returns (MapWindow's
* confirm handler calls this.close()).
*
* The ROUTE IS DERIVED (js/galaxy/Route.js): only the DESTINATION
* (systemId + the object within it) is stored — the path from wherever
* the ship is NOW to that system is computed on demand. Following the
* route (a jump to its next system) just shortens it; a DETOUR (a jump
* anywhere else) re-plots it from the detour (_routeForJump toasts the
* re-plot); arriving at the destination CLEARS it.
*/
setDestination(systemId, objectId, kind = 'manual') {
const sys = this.galaxy?.byId?.get(systemId);
if (!sys) {
this.consoleToast('UNKNOWN DESTINATION — NO SUCH SYSTEM', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
if (systemId === this.systemRecord.id) {
this.consoleToast('ALREADY AT THAT SYSTEM', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
// KIND — who owns this destination (the compass mark's color follows it):
// 'manual' — the player's own pick (the MAP console's SYSTEM tab)
// 'cargo' — a CARGO delivery the player set (the ship console's cargo
// manifest, the surface shop's scout — their run)
// 'quest' — a story-quest beat (the quest itself aims the route)
// A cargo pick that is ALSO the delivery of a held story quest is
// that quest's run — the story keeps the route's orange (a free cargo
// run reads BROWN instead, _routeMarkColor).
let destKind = kind === 'cargo' ? 'cargo' : kind === 'quest' ? 'quest' : 'manual';
if (destKind === 'cargo') {
const obj = objectId ?? null;
const isStoryDelivery = this._storyCargoDeliveryTargets().some(
(t) => t.systemId === systemId && (t.objectId == null || t.objectId === obj),
);
if (isStoryDelivery) destKind = 'quest';
}
// Store the destination (the ROUTE itself is derived from it). The
// object within the destination system is carried along for the
// arrival handling (autopilot / the REACHED toast); the kind carries
// the mark's color (SaveData captures it alongside).
this.destination = { systemId, objectId: objectId ?? null, kind: destKind };
this.registry.set('destination', this.destination);
const plan = planRoute(this.galaxy, this.systemRecord.id, systemId);
const hops = plan?.hops ?? 0;
this.consoleToast(
String(config.get('gates.route.setToast', 'DESTINATION SET — {dest} · {hops} HOP(S)'))
.replace('{dest}', String(sys.name).toUpperCase())
.replace('{hops}', String(hops)),
{ glyph: '◆', glyphColor: toCss(this._routeColor()) },
);
}
/** The route's accent — orange (data/gates.json → route.compassColor). */
_routeColor() {
return toColor(config.get('gates.route.compassColor', '#ff8c1a'), 0xff8c1a);
}
/**
* The destination mark's accent — BROWN (data/gates.json →
* route.cargoColor) while the active destination is a CARGO delivery
* the player set (destination.kind 'cargo' — the ship console's cargo
* manifest or the surface shop's scout), so the player's own delivery
* runs read apart from the story; the route's ORANGE (compassColor)
* otherwise (a story-quest beat or the map's manual pick). The compass
* entry (arrow + chip) and the in-world pulse follow it.
*/
_routeMarkColor() {
if (this.destination?.kind === 'cargo') {
return toColor(config.get('gates.route.cargoColor', '#9c6b30'), 0x9c6b30);
}
return this._routeColor();
}
/**
* The CARGO-delivery destination a HELD story quest currently owns —
* the 'part of a story quest' carve-out for the player's own delivery
* runs. A cargo destination the player sets that MATCHES one of these
* is the story's delivery, not a free cargo run, so it keeps the route's
* ORANGE (setDestination's kind guard); everything else the player sets
* from the cargo UI reads BROWN.
*
* Today the only cargo-delivery story quest is 'Special Delivery'
* (data/quests.json → special_delivery — deliver Carol's package to the
* HOME WORLD — the one quest whose objective is a cargo run). The other
* story destinations (the neutral-capital rendezvous of 'Leaving the
* Nest' / 'Pirate Problems') are travel, not cargo deliveries, so they
* are NOT listed here. Extend this list as new cargo-delivery story
* quests are added.
* @returns {Array<{systemId: string, objectId: string|null}>}
*/
_storyCargoDeliveryTargets() {
const out = [];
const held = (id) =>
!!(this.questState?.granted?.has(id) && !this.questState?.claimed?.has(id));
// 'Special Delivery' — the package's delivery home (the quest's own
// beat, onVignetteComplete beat 3, aims the route there while held).
if (held('special_delivery')) {
const homeId = this.galaxy?.homeSystemId;
if (homeId) out.push({ systemId: homeId, objectId: 'home' });
}
return out;
}
/**
* The CURRENT route: the path from this system to the destination
* (planRoute), or null when no destination is set.
*/
routePlan() {
if (!this.destination?.systemId) return null;
return planRoute(this.galaxy, this.systemRecord.id, this.destination.systemId);
}
/**
* The lanes on the active route — the `edgeKey`s of the segments of the
* planRoute path (consecutive system pairs). The galaxy plate uses this
* to draw the route in orange (data/gates.json → route.compassColor):
* the "where am I going" path across the map. Empty when no destination.
* @returns {Array<string>} edge keys (js/galaxy/GalaxyChart.js → edgeKey)
*/
routeEdgeKeys() {
const plan = this.routePlan();
if (!plan?.path || plan.path.length < 2) return [];
const out = [];
for (let i = 0; i < plan.path.length - 1; i++) out.push(edgeKey(plan.path[i], plan.path[i + 1]));
return out;
}
/**
* The route's NEXT POINT — the jump gate in the CURRENT system that
* leads toward the destination (its gate.to === the route's next
* system). This is what the compass marks in orange and what the
* player flies to. null when there is no active route, the route is
* complete (we are at the destination), or the current system holds no
* gate toward the next system (defensive — the network is connected,
* so a next system always has a gate here).
*/
routeNextGate() {
const plan = this.routePlan();
if (!plan?.next) return null;
for (const gt of this.systemGates ?? []) if (gt.gate?.to === plan.next) return gt;
return null;
}
/**
* The destination OBJECT in the CURRENT system — the route's FINAL stop.
* Non-null only when the player is already in the destination system AND
* the destination names a specific object (objectId) that exists here.
* This is the last hop: across the system to the world itself. The
* compass marks it in ORANGE (updateDiscovery, `dest:` id) and it stays
* orange until the ship actually reaches it (_checkDestinationReached),
* so the "where to fly next" marker persists right up to the arrival.
*/
routeDestinationObject() {
const d = this.destination;
if (!d?.objectId || d.systemId !== this.systemRecord.id) return null;
return this.discoverableObjects().find((o) => o.id === d.objectId) ?? null;
}
/**
* The route's FINAL stop as a WORLD entity: the destination OBJECT in
* this system (routeDestinationObject) resolved to the entity that
* wears the subdued route pulse (js/entities/RoutePulse.js) — a
* planet (the home world included) or a space station. Null when the
* route is complete (no next stop in this system), the destination
* names no object, or the object is a kind that doesn't wear the pulse
* (gates/asteroids — the compass still marks them).
*/
_routeDestinationEntity() {
const o = this.routeDestinationObject();
if (!o) return null;
if (o.id === 'home') return this.planet ?? null;
for (const p of this.systemPlanets) if (p.discoveryId === o.id) return p;
for (const st of this.systemStations) if (st.discoveryId === o.id) return st;
return null;
}
/**
* Arrived at the FINAL stop? Called each frame (update, just before
* updateDiscovery). When the player is in the destination system and the
* ship is within the destination object's keep-out rim, the trip is done:
* clear the tracked destination (the compass orange drops, the SYSTEM tab
* unlocks) and fire the REACHED toast. (Fired here — not queued like a
* jump notice — because the player is already in this scene; there is no
* jump cut to ride out.) A destination with no specific object is handled
* at the jump instead (_routeForJump clears it on system entry).
*/
_checkDestinationReached() {
const d = this.destination;
if (!d?.objectId || d.systemId !== this.systemRecord.id) return;
if (!this.ship) return;
const solid =
this.solids.find((s) => s.discoveryId === d.objectId) ??
this.discoverableObjects().find((o) => o.id === d.objectId);
if (!solid) return;
// Keep-out rim: the distance the ship rests at when autopiloted there.
const keepout =
solid.minCenterDistance?.(this.ship.radius) ??
(solid.radius + (solid.clearance ?? 0) + this.ship.radius);
const dist = Math.hypot(this.ship.x - solid.x, this.ship.y - solid.y);
// A little grace past the rim so a settling ship counts, but not so
// much that flying by on the far side does.
if (dist > keepout + 40) return;
const destName = this.galaxy?.byId?.get(d.systemId)?.name ?? d.systemId;
this.destination = null;
this.registry.set('destination', null);
this.consoleToast(
String(config.get('gates.route.reachedToast', 'DESTINATION REACHED — {dest}'))
.replace('{dest}', String(destName).toUpperCase()),
);
// The 'Leaving the Nest' ARRIVAL (data/comms.json: trestle-arrive-1/2,
// trigger 'trestleArrival') — the quest's last beat plays on the
// CAPITAL WORLD (the neutral capital — the destination trestle-brief-3's
// acknowledgment set), not on entering the system: this is Trestle's
// 'you made it' moment. The comm's once-ledger + _trestleBeats' quest
// guard keep it a one-shot; both in the played-ledger are the quest's
// 'Meet Trestle' witness (GameScene._questCheckEval).
if (this._atCapitalWorld()) this.comms?.trigger('trestleArrival');
}
/**
* Route bookkeeping at a JUMP (called from jumpThroughGate with the
* system we are LEAVING and the one we are ENTERING). Derives what the
* hop means for the active route and QUEUES a notice (routeNotice — the
* toast must appear AFTER the jump cut, on the destination scene: the
* jump itself fires its own toast + full-screen clip, which would
* swallow an immediate one):
* · entering the DESTINATION → route complete — clear it (REACHED);
* · entering the route's NEXT → on course (the derived route simply
* shortens — no notice, the compass already pointed there);
* · entering anything else → a DETOUR — re-plot from the detour
* (RE-PLOTTED). Because the route is derived, the re-plot is
* automatic; this only announces it.
* The state change (clearing the destination on arrival) happens NOW —
* before captureState snapshots the run — so the saved state is right;
* only the player-facing message is deferred.
*/
_routeForJump(fromId, destId) {
if (!this.destination?.systemId) return; // no active route
const destId2 = this.destination.systemId;
const destName = this.galaxy?.byId?.get(destId2)?.name ?? destId2;
if (destId === destId2) {
// Arrived at the destination SYSTEM. If the destination names a
// specific OBJECT in that system, the trip is NOT over — the player
// still has to fly to it. Keep the destination active: the compass
// now points at that object (updateDiscovery) and it clears when the
// ship actually reaches it (_checkDestinationReached fires REACHED).
// No toast yet — the arrival is the last leg, not the destination.
if (this.destination.objectId) return;
// No specific object — reaching the system is the whole trip. Clear
// the tracked destination (the compass orange drops, the SYSTEM tab
// unlocks for the next target) and queue the REACHED notice for the
// new scene.
this.destination = null;
this.registry.set('destination', null);
this._queueRouteNotice(
String(config.get('gates.route.reachedToast', 'DESTINATION REACHED — {dest}'))
.replace('{dest}', String(destName).toUpperCase()),
);
return;
}
// Not the destination — were we on the route, or off it?
const expected = planRoute(this.galaxy, fromId, destId2)?.next ?? null;
if (expected === destId) return; // on course — the route just shortened
const plan = planRoute(this.galaxy, destId, destId2);
const hops = plan?.hops ?? 0;
this._queueRouteNotice(
String(config.get('gates.route.replotToast', 'ROUTE RE-PLOTTED — {hops} HOP(S) TO {dest}'))
.replace('{hops}', String(hops))
.replace('{dest}', String(destName).toUpperCase()),
);
}
/**
* Park a route notice (data/gates.json → route.* toast) in the shared
* registry so the DESTINATION scene's create() can surface it after the
* jump cut (the jump's own toast + clip would swallow an immediate one).
* Consumed (and cleared) exactly once by the next GameScene.create().
*/
_queueRouteNotice(text) {
this.registry.set('routeNotice', {
text,
glyph: '◆',
glyphColor: toCss(this._routeColor()),
durationMs: 3400,
});
}
// ------------------------------------------------------------ research
/**
* Begin a research project (ResearchWindow's RESEARCH button → here).
* The window only offers the button when the rules allow it; this is the
* scene-side enforcement — one project at a time, available tech only —
* then the state runs the clock (time-based, data/research.json rules).
*/
beginResearch(catId, id) {
const state = this.researchState;
if (!state) return;
if (state.getActive()) {
this.consoleToast('A RESEARCH PROJECT IS ALREADY IN PROGRESS', {
glyph: '◆',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
const tree = this._treeFor(catId);
const node = tree?.nodes?.[id];
if (!node) return;
if (!isAvailable(tree, state, id)) {
this.consoleToast('THAT TECH IS NOT AVAILABLE YET', {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
// The comms reaction (data/comms.json: tetherResearch — oac-minerals
// → oac-fly-mine): OAC reacts the moment the player commits to the
// Tether Level 2 research. The node is the canonical 'tether_l2'
// (data/research/exploration.json — the opening quest's 'research'
// check references the same). Queued now; the busy() contract holds
// the pair until the research console closes.
if (catId === 'exploration' && id === 'tether_l2') {
this.comms?.trigger('tetherResearch');
}
const durMs = Math.max(0, Number(node.duration ?? 0) * 1000);
if (durMs <= 0) {
// Instant tech (duration 0) — applies the moment it's requested.
state.unlock(catId, id);
this._applyResearchEffects(node.effects, node, id);
this._completeResearchFx(catId, id, node);
return;
}
// The in-flight project rides the next explicit save (captureState
// includes record.research — the game's save model is player-chosen).
state.start(catId, id, durMs, this.time.now);
this.consoleToast(`RESEARCH INITIATED — ${String(node.label ?? id).toUpperCase()}`, {
glyph: '◆',
});
this.playSfx('construct'); // the power-up tick (data/sfx.json key 'construct')
this.researchWindow?.refresh();
}
/** A project's clock ran out (update's tick) — apply + celebrate. */
_completeResearch(catId, id) {
const state = this.researchState;
if (!state) return;
state.unlock(catId, id);
const node = this._treeFor(catId)?.nodes?.[id];
if (node?.effects) this._applyResearchEffects(node.effects, node, id);
this._completeResearchFx(catId, id, node);
// The comms sign-off (data/comms.json: researchComplete —
// oac-research-complete, audio-only, once:false — it plays on EVERY
// completion). It arms now, whatever the console is doing: the
// busy() contract holds it while the research window is still open
// (and the FIFO behind any comm already playing), and it rides the
// action once the screen is free — the clock can't expire while an
// ACK comm pauses the scene, so it always lands on a live run.
this.comms?.trigger('researchComplete');
}
/** The tree for a category: the SYSTEM category's live per-system tree,
* or the static file tree (data/research/<id>.json — null if missing).
* The static tree's nodes may carry gates on top of `requires`: a
* STORY-QUEST gate (node. `quest` — 'Special Delivery' reward CLAIMED;
* attachQuestGates wires this scene's live quest check) and a
* BUILD-COMPLETION gate (node. `build` — the named build BUILT;
* attachBuildGates wires this scene's BuildState built set — today the
* cargo 12/16 Bays techs wait on the Improved/Advanced Mining Storage
* builds). Both land in the tree's available/lockNote hooks, so
* beginResearch's isAvailable pass and the READY-RESEARCH cues see them. */
_treeFor(catId) {
if (this.systemTree && catId === this.systemTree.id) return this.systemTree;
const tree = loadCategory(catId);
if (!tree) return tree;
attachBuildGates(tree, (bid) => (this.buildState?.isBuiltAnywhere(bid) ?? false) === true);
return attachQuestGates(tree, (qid) => this.questClaimed(qid));
}
/**
* Any tech in ANY category ready to research right now — none in
* flight (maxConcurrent 1 — nothing can START while a project runs)
* and at least one node with its requires met (the SYSTEM chart's
* extra gate included — the tree's availability hook says so).
* Drives the READY-RESEARCH cue: the deck's RESEARCH slot's shimmer
* (this scene) and the console's per-tab shimmers + gates pulse
* (ResearchWindow._paintCues).
*/
researchAnyAvailable() {
const state = this.researchState;
if (!state || typeof state.getActive !== 'function' || state.getActive()) return false;
for (const cat of categories()) {
const tree = this._treeFor(cat.id);
if (tree && hasAvailable(tree, state)) return true;
}
return false;
}
/**
* The READY-RESEARCH cue's scene-side switch (per frame, update): one
* cue on screen at a time —
* - console CLOSED: the deck's RESEARCH slot wears the periodic
* yellow shimmer (face sweep → edge run, js/ui/ShimmerCue.js);
* - console OPEN: the console wears it instead (its tabs' edge
* shimmers + the jump-gate tech's ACK pulse — _paintCues), and
* the deck's slot rests.
* setShimmer is idempotent, so the per-frame call is a no-op when
* the beat is unchanged. The cue also rests while a project runs
* (researchAnyAvailable) and under the deck's standby (the ActionBar
* owns that hold — the 'ack' comm) and over the tutorial's finger
* (same slot — the finger already owns the eyes).
*/
_applyResearchCue() {
const bar = this.actionBar;
if (!bar || typeof bar.setShimmer !== 'function') return;
const on = this.researchAnyAvailable() && this.researchWindow?.isOpen !== true;
bar.setShimmer(on ? 'research' : null);
}
/** Entering a system begins its NAV chart: the SYSTEM category's
* '{System} Map' tech is granted (duration 0 — never researched via the
* console). Idempotent — create() calls it for the starting system (and
* again after a load, whose restore replaced the unlocked set), and a
* jump's scene restart runs it for the destination. */
_onEnterSystem() {
if (!this.researchState || !this.systemRecord) return;
this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE));
// Invariant: if this system's gates tech is researched, its gates are
// active. Reconcile on entry — idempotent (silent when already online),
// and it recovers any system whose activation was missed at completion
// without a re-research (the node.id bug) or a save round-trip.
const gatesId = systemNodeId(this.systemRecord.id, GATES_NODE);
if (this.researchState.isUnlocked(SYSTEM_CATEGORY, gatesId)) {
this._activateSystemJumpgates(this.systemRecord.id);
}
this.researchWindow?.refresh();
}
/** The SYSTEM category's chart gate: every NAV point of the current
* system (central body, planets, space stations, jump gates — the
* discoverable set minus the asteroid clusters) discovered. */
isSystemNavComplete() {
return isNavComplete(this.discovery, this.systemRecord?.id, this.systemContent);
}
/**
* 'Unlock Jumpgates' completed (SYSTEM category): the system's
* gates AND the return gates in the systems they jump to go ACTIVE.
*
* The activation keys are per-run world state (the registry-backed set —
* New Game clears, a load restores). The current system's gate entities
* wake up here and, per data/gates.json → ACTIVITY, each anchored gate
* carries a level-1 tether at its own position (the room to move — in a
* barren system, the whole room). The linked systems' return gates flip
* their content record now (or on entry — the create() pass); their
* entities + tethers materialise when the player jumps there (the
* JUMP re-stages the scene onto the destination — jumpThroughGate).
* Idempotent throughout.
*/
_activateSystemJumpgates(sysId) {
const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
const keys = activationKeys(this.galaxy, sysId);
// Idempotent: only the NOT-yet-active keys count as new. Re-entering a
// system (a jump, a load, a refresh) re-runs this — the early return
// keeps it silent when everything is already online.
const fresh = keys.filter((k) => !this.activatedGates.has(k));
for (const k of keys) this.activatedGates.add(k);
if (fresh.length === 0) return;
// Current system — the gate entities wake + anchor their tethers.
for (const gt of this.systemGates ?? []) {
if (!gt.gate) continue;
if (!this.activatedGates.has(`${sysId}>${gt.gate.to}`)) continue;
gt.activate();
this.tetherField.add(`gate:${gt.gate.id}`, gt.x, gt.y, level, gt.gate.name ?? '');
}
// Linked systems — flip the cached content (ungenerated systems flip
// in the create() pass on entry; both paths are idempotent).
for (const [sid, content] of this.galaxy.contentCache) applyActivation(content, sid, this.activatedGates);
this.consoleToast(`JUMP GATES ONLINE — ${String(this.systemRecord?.name ?? sysId).toUpperCase()}`, {
glyph: '⌁',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
/** The completion ceremony: SFX, toast, the window repaint. */
_completeResearchFx(catId, id, node) {
this.playSfx('discovery'); // the 'something new is here' voice (data/sfx.json)
this.consoleToast(`RESEARCH COMPLETE — ${String(node?.label ?? id).toUpperCase()}`, {
glyph: '✓',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
this.researchWindow?.celebrate(catId, id); // the console's ignition (ring / pulse / flare) — queues if it's closed
this.researchWindow?.refresh();
this._questRefresh(); // a 'Research …' requirement may have just landed
}
/**
* Apply a tech's effects (node.effects, data/research/<cat>.json — or
* the SYSTEM category's live tree). `id` is the node's id (the key in the
* tree's `nodes` map — the node object itself carries no `id` field).
* Known shapes:
* tether { level: N } → the home world's tether field strengthens
* capability "flag" → a scene capability set (future systems read it)
* activateGates true → the SYSTEM category: the system's jump gates
* + the linked systems' return gates go ACTIVE
* Unknown shapes are logged and skipped — data can lead code a step.
*/
_applyResearchEffects(effects, node, id) {
if (!effects || typeof effects !== 'object') return;
for (const [type, spec] of Object.entries(effects)) {
if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) {
const lvl = Math.max(1, Math.floor(Number(spec.level)));
const homeId = this.tetherField.tethers[0]?.id ?? 'home';
this.tetherField.setLevel(homeId, lvl);
this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
} else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec);
} else if (type === 'activateGates' && spec) {
// SYSTEM category — the node id is "<systemId>_gates". The node object
// carries no `id` field (it's the key in the tree's `nodes` map), so
// `id` (passed in) is the source of truth — NOT `node.id`.
const sysId = systemIdOfGatesNode(id);
if (sysId) this._activateSystemJumpgates(sysId);
// (The 'systemComplete' milestone beat moved to
// celebrateDiscovery — it fires when this research BECOMES
// AVAILABLE, the chart-completing discovery, not on completion.)
// The 'Leaving the Nest' quest's BRIEFING (data/comms.json:
// trestle-brief-1/2/3 — the three back-to-back ack comms) — the
// HOME system's gates are the run's door out of the nest: the
// moment the player can jump out, Trestle keeps his starter-beat
// promise ('I'll ping you once you're ready'). It lands in front
// of the generic 'researchComplete' sign-off _completeResearch
// queues after the effects — the hub's FIFO plays the specific
// character beat first, then the system sign-off. _trestleBeats
// guards it (quest held + unclaimed) and the comm's once-ledger
// dedupes.
if (sysId && sysId === this.galaxy?.homeSystemId) {
this._trestleBeats();
}
} else {
console.warn(`[orbit] research: unknown effect ${type}`, spec, id);
}
}
}
// ------------------------------------------------------------ builds
/**
* Begin a build on the surface planet (the BUILD button → SurfaceScene
* → here). The BuildWindow only offers the button when the rules allow
* it; this is the scene-side enforcement — one build at a time, not
* already installed, research + planet gates, the full cost paid up
* front (minerals) — then the state runs the clock. The build's time
* base is the game-loop clock passed as `now` (global + monotonic, so
* a build keeps running if the player takes off mid-build).
*
* @param {string} planetName the surface planet (BuildState's record key)
* @param {string} buildId the build (data/builds.json → builds)
* @param {number} now the game-loop clock (ms)
* @returns {{ ok: boolean, reason?: string }} the refusal, if any
*/
beginBuild(planetName, buildId, now) {
const state = this.buildState;
const def = buildDefs()[buildId];
if (!state || !def) return { ok: false, reason: 'BUILD SYSTEM OFFLINE' };
if (state.getActive()) return { ok: false, reason: 'A BUILD IS ALREADY IN PROGRESS' };
// SHIP-scoped builds (targets: ["ship"] — the mining arms/storage) are
// installed ON THE SHIP: already built on ANY planet counts (the
// console shows them BUILT everywhere — no re-buy).
if (isShipScoped(def)) {
if (state.isBuiltAnywhere(buildId)) {
return { ok: false, reason: 'ALREADY INSTALLED ON THE SHIP' };
}
} else if (state.isBuilt(planetName, buildId)) {
return { ok: false, reason: 'ALREADY INSTALLED ON THIS WORLD' };
}
// research gate — the blueprint must be researched (the authoritative
// side; the research tree's unlocks.builds is the declaration side).
for (const req of def.requires ?? []) {
const i = req.indexOf('/');
if (i < 0) continue;
const cat = req.slice(0, i);
const node = req.slice(i + 1);
if (!this.researchState?.isUnlocked(cat, node)) {
return { ok: false, reason: `REQUIRES RESEARCH — ${String(node).toUpperCase()}` };
}
}
// planet gate — the world must hold the tether level the build needs
const needTether = def.planetRequires?.tetherLevel;
if (typeof needTether === 'number' && this.tetherLevelFor(planetName) < needTether) {
return { ok: false, reason: `REQUIRES A LEVEL-${needTether} TETHER ON THIS WORLD` };
}
// cost — the full amount, paid up front. The cost is a { res: amount }
// map (data/builds.json → cost): the planet builds pay MINERALS, the
// cargo bays pay CREDITS + MINERALS (trade income + the mining hold buy
// the expansion). Each line is checked + debited from its own wallet
// (ship.minerals / ship.credits).
const cost = def.cost ?? null;
if (cost && typeof cost === 'object') {
const mineralsCost = Number(cost.minerals ?? 0);
const creditsCost = Number(cost.credits ?? 0);
if (mineralsCost > 0) {
if (this.ship.minerals < mineralsCost) return { ok: false, reason: `NEED ${mineralsCost} MINERALS` };
this.ship.setMinerals(this.ship.minerals - mineralsCost);
this.refreshMineralHud();
}
if (creditsCost > 0) {
if (!this.ship.spendCredits(creditsCost)) return { ok: false, reason: `NEED ${creditsCost} CREDITS` };
this.refreshCreditsHud();
}
}
const durMs = Math.max(1, Number(def.duration ?? 0) * 1000);
state.start(planetName, buildId, durMs, now ?? this.time.now);
this.playSfx('construct'); // the power-up tick (the research voice)
return { ok: true };
}
/**
* A build's clock ran out (the SurfaceScene's tick → here): apply its
* effect to the world. The world change outlives the surface stay — a
* level-2 tether on the way back to space means a level-2 range.
*/
completeBuild(planetName, buildId) {
const def = buildDefs()[buildId];
if (def?.effects) this._applyBuildEffects(planetName, def.effects);
this._questRefresh(); // a 'Build a Level 2 Tether' requirement may have just landed
}
/**
* Apply a build's effects (data/builds.json → builds.<id>.effects).
* tether { level: N } → the world's tether strengthens to at least N
* (never a downgrade) — the ship's travel range widens with it
* capability "flag" → a scene capability set (future systems read it)
* Unknown shapes are logged and skipped — data can lead code a step.
*/
_applyBuildEffects(planetName, effects) {
if (!effects || typeof effects !== 'object') return;
for (const [type, spec] of Object.entries(effects)) {
if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) {
const lvl = Math.max(1, Math.floor(Number(spec.level)));
// The tether anchored on this world — the same lookup as
// tetherLevelFor (by label, or by anchor sitting on the world's
// center — the home tether is anchored at the origin with the
// home world's name).
const obj =
this.systemPlanets.find((p) => p.discoveryName === planetName) ??
this.systemStations.find((s) => s.discoveryName === planetName) ??
(planetName === this.planet?.discoveryName ? this.planet : null);
const t = this.tetherField?.tethers.find(
(tt) => tt.label === planetName ||
(obj && tt.x === obj.x && tt.y === obj.y),
);
if (t) {
if (t.level < lvl) {
this.tetherField.setLevel(t.id, lvl);
this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
} else if (obj && this.tetherField) {
// The world's FIRST tether (the tether-l1 build on a newly
// discovered world): anchor one on the world's center — the
// player-owned anchor model (the field is the player's, the
// world hosts it). Label = the world's name, so
// tetherLevelFor / the surface HUD / the build gates see it,
// and the save's tether list carries it with the run.
this.tetherField.add(`planet:${planetName}`, obj.x, obj.y, lvl, planetName);
this.consoleToast(`TETHER ANCHORED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
} else {
console.warn(`[orbit] build: world "${planetName}" not found — cannot anchor its tether`, spec);
}
} else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec);
} else if (type === 'mining' && spec && typeof spec === 'object') {
// SHIP upgrades (the mining arms/storage): effects.mining.rate =
// the ship's mining speed (minerals/s), effects.mining.capacity =
// the mineral hold's cap. MAX wins — never a downgrade.
this._applyMiningUpgrade(spec);
} else if (type === 'cargo' && spec && typeof spec === 'object') {
// SHIP upgrade (the cargo bay): effects.cargo.bays = the hold's bay
// count (the trade capacity). MAX wins — 4 → 8 → 12 → 16, never a
// downgrade.
this._applyCargoUpgrade(spec);
} else {
console.warn(`[orbit] build: unknown effect ${type}`, spec);
}
}
}
/**
* Apply a mining SHIP upgrade (a build's effects.mining, data/builds.json):
* `rate` → stats.miningSpeed (minerals/s — Mining multiplies the base
* economy rate by it), `capacity` → stats.mineralStorage (the hold's
* cap — ship.storageRoom / the corner HUD read n / cap). MAX wins: a
* slower arm or a smaller hold is never installed over a better one
* (build the advanced arm, then the improved — the ship keeps 2.0/s).
*/
_applyMiningUpgrade(spec) {
const ship = this.ship;
if (!ship?.stats) return;
if (Number.isFinite(Number(spec.rate))) {
const rate = Math.max(0, Number(spec.rate));
if (rate > (Number(ship.stats.miningSpeed) || 0)) {
ship.stats.miningSpeed = rate;
this.consoleToast(`MINING ARM UPGRADED — ${rate} PER SECOND`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
if (Number.isFinite(Number(spec.capacity))) {
const cap = Math.max(0, Math.round(Number(spec.capacity)));
if (cap > (Number(ship.stats.mineralStorage) || 0)) {
ship.stats.mineralStorage = cap;
this.refreshMineralHud(); // the corner readout is n / CAP — push the new cap
this.consoleToast(`MINERAL STORAGE EXPANDED — ${cap} CAPACITY`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
}
/**
* Apply a cargo SHIP upgrade (a build's effects.cargo,
* data/builds.json): `bays` = the hold's bay count (the trade capacity).
* MAX wins — a smaller hold is never installed over a bigger one (build
* the 16-bay, then a 12 — the ship keeps 16). Toasts the expansion.
*/
_applyCargoUpgrade(spec) {
const ship = this.ship;
if (!ship || typeof ship.setCargoHold !== 'function') return;
if (Number.isFinite(Number(spec.bays))) {
const bays = Math.max(0, Math.round(Number(spec.bays)));
if (bays > (Number(ship.stats?.cargoHold) || 0)) {
ship.setCargoHold(bays);
this.refreshCreditsHud(); // the hold is now worth more — keep the readout honest
this.consoleToast(`CARGO HOLD EXPANDED — ${bays} BAYS`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
}
/**
* The MAX effects.mining.capacity across a restore's build records
* (r.builds.built — planet → build ids — the same source
* _restoreMiningUpgrades reads once those records are back in
* buildState). applyRestore calls this to raise the hold cap BEFORE
* setMinerals, so a save holding more minerals than the base cap (250)
* survives the load / jump-cut instead of being clamped to 250. Returns
* the ship's current cap when no record expands it.
*/
_miningCapacityFromBuilds(builds) {
let cap = Number(this.ship?.stats?.mineralStorage) || 0;
const built = builds?.built;
if (built && typeof built === 'object') {
for (const ids of Object.values(built)) {
for (const id of Array.isArray(ids) ? ids : []) {
const c = Number(defById(id)?.effects?.mining?.capacity);
if (Number.isFinite(c) && c > cap) cap = c;
}
}
}
return cap;
}
/**
* Re-derive the ship's mining upgrades from the build RECORDS (the
* save carries the records, not the ship's derived stats): the MAX
* effects.mining.rate / capacity across every installed build (any
* planet). Idempotent + MAX-guarded — a fresh run has no mining
* records and keeps its base stats; a load / jump-cut restore re-asserts
* them. Runs in create(), right after the built tethers are
* re-anchored (same seam: world/ship state re-derived from the
* outliving build records).
*/
_restoreMiningUpgrades() {
if (!this.buildState || !this.ship?.stats) return;
let rate = 0;
let capacity = 0;
for (const ids of this.buildState.built.values()) {
for (const id of ids) {
const spec = defById(id)?.effects?.mining;
if (!spec) continue;
if (Number.isFinite(Number(spec.rate))) rate = Math.max(rate, Number(spec.rate));
if (Number.isFinite(Number(spec.capacity))) capacity = Math.max(capacity, Number(spec.capacity));
}
}
if (rate > (Number(this.ship.stats.miningSpeed) || 0)) this.ship.stats.miningSpeed = rate;
if (capacity > (Number(this.ship.stats.mineralStorage) || 0)) this.ship.stats.mineralStorage = capacity;
if (rate > 0 || capacity > 0) this.refreshMineralHud(); // the HUD was pushed with the FRESH hold earlier in create()
}
/** The RESEARCH slot's progress bar (update() calls it every frame). */
_deckResearchBar(time) {
const bar = this._researchDeckBar;
if (!bar) return;
const p = this.researchState?.progress(time);
const slot = this.actionBar?.slots?.find((s) => s.id === 'research');
if (!p || !slot) {
if (bar.g.visible) bar.g.setVisible(false);
if (bar.txt.visible) bar.txt.setVisible(false);
return;
}
const style = this.actionBar.style;
const bw = style.bw;
const bh = style.bh;
const x = slot.slot.x - bw / 2;
const y = slot.slot.y - bh / 2;
const accent = toColor(themeColor('neon', 0x00e5ff));
const g = bar.g;
g.setVisible(true);
g.clear();
g.fillStyle(0x060a10, 0.85);
g.fillRect(x + 8, y - 11, bw - 16, 5);
g.fillStyle(accent, 0.95);
g.fillRect(x + 8, y - 11, (bw - 16) * p.fraction, 5);
const txt = bar.txt;
txt.setVisible(true);
txt.setText(`RESEARCHING ${Math.round(p.fraction * 100)}%`);
txt.setColor(toCss(accent));
txt.setPosition(slot.slot.x, y - 15);
}
/**
* The SCAN button: fire the deep-scan pulse from the ship across the
* tether region. The sweep's TARGET SET is the discoverable objects
* inside the tether union (the wave is absorbed at the union boundary,
* so nothing beyond it is in range). One sweep at a time (busy guard);
* `enabled: false` in data/scan.json makes the button a no-op.
*/
startScan() {
if (!config.get('scan.enabled', true)) {
console.info('[orbit] scan disabled (data/scan.json)');
return;
}
if (this.scanPulse.busy) return; // a sweep is already out
this._tutScanPressed = true; // the tutorial's finger moves off SCAN now
// The comms reaction (data/comms.json: firstScan — oac-upgrade): the
// comm's own delayMs is the beat after this click; the busy() contract
// below (scanPulse.busy) holds it until the sweep settles.
this.comms?.trigger('firstScan');
const ox = this.ship.x;
const oy = this.ship.y;
const tethers = this.tetherField.tethers.map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
this.scanObjects = this.discoverableObjects()
.filter((o) => this.tetherField.contains(o.x, o.y))
.map((o) => ({ ...o, dist: Math.hypot(o.x - ox, o.y - oy), hit: false }));
this.scanPulse.begin(ox, oy, tethers, this.time.now, {
onEmit: () => {
this.playSfx('scan'); // the ping goes out
this.consoleToast('DEEP SCAN — SWEEPING TETHER REGION', {
glyph: '\u25c8',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: this.scanPulse.durationMs + 500,
});
},
onAbsorb: () => this.tetherField.excite(1.15), // the barrier shivers as the wave is swallowed
onComplete: () => this.finishScan(),
});
this.hideHint();
}
/** Per-frame: ring every in-region object the moment the front crosses it. */
updateScanHits(time) {
const r = this.scanPulse.radiusAt(time);
if (r <= 0) return;
for (const o of this.scanObjects) {
if (o.hit || r < o.dist) continue;
o.hit = true;
this.scanRippleObject(o);
}
}
/** The hit: the solid pulses in scale (sonar echo) + a shock ring at it. */
scanRippleObject(o) {
const solid = this.solids.find((s) => s.discoveryId === o.id) ?? this.planet;
const s0 = solid.scale;
this.tweens.add({
targets: solid,
scale: s0 * (1 + this.scanObjectScale),
duration: 150,
yoyo: true,
repeat: 1,
ease: 'Sine.easeOut',
});
this.scanPulse.ripple(o.x, o.y, o.radius, this.time.now);
}
/**
* The sweep finished — the seam for SCAN RESULTS (what a scan yields is
* the next instruction's call). For now: log the in-region objects and
* report the count in the console slot.
*/
/**
* The sweep finished — the scan's OUTPUT. For now this is the seam the
* results will grow out of (what a scan YIELDS is a later instruction's
* call): log the in-region objects + report the count, and — the part
* the player can act on — light up the SECONDARY COMPASS: every
* in-region object becomes a faint bearing signal on the ring around the
* ship (the compass itself drops any already discovered, and drops them
* the instant they get discovered). A fresh scan re-emits the set with a
* fresh clock (a re-ping).
*/
finishScan() {
const objs = this.scanObjects ?? [];
const hits = objs.filter((o) => o.hit).length;
console.info(`[orbit] scan complete — ${hits}/${objs.length} objects in tether range`, objs);
// The compass emission (fullMs + fadeMs of life; see updateSignalCompass).
this.scanReveal = { bornAt: this.time.now, ids: objs.map((o) => o.id) };
this.consoleToast(`SCAN COMPLETE — ${hits} OBJECT${hits === 1 ? '' : 'S'} DETECTED IN TETHER RANGE`, {
glyph: '\u25c8',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: 2600,
});
this.scanObjects = null;
}
/**
* The SECONDARY COMPASS, per frame (js/ui/SignalCompass.js): resolve the
* current scan emission into live bearing signals. A signal exists per
* in-region object that is still UNDISCOVERED; its strength follows the
* full→fade envelope (signalAlpha) from the emission's birth. Positions
* are resolved LIVE, so the bearings track both the ship and any drifting
* objects. Expired (fullMs + fadeMs) or discovered ⇒ dropped.
*/
updateSignalCompass(time) {
const compass = this.signalCompass;
if (!compass) return;
const sx = this.ship.x, sy = this.ship.y;
const reveal = this.scanReveal;
if (!reveal || !Array.isArray(reveal.ids)) {
compass.refresh([], time, sx, sy);
return;
}
const age = time - reveal.bornAt;
if (age >= this.signalFullMs + this.signalFadeMs) {
this.scanReveal = null; // the emission has fully faded
compass.refresh([], time, sx, sy);
return;
}
const alpha = signalAlpha(age, this.signalFullMs, this.signalFadeMs);
const sysId = this.systemRecord.id;
const objects = this.discoverableObjects(); // one build per frame
const signals = [];
for (const id of reveal.ids) {
if (this.discovery.isDiscovered(sysId, id)) continue; // found it → its signal is done
const o = objects.find((v) => v.id === id);
if (!o) continue;
signals.push({ id, x: o.x, y: o.y, alpha, color: o.color });
}
compass.refresh(signals, time, sx, sy);
}
/**
* The MENU button: fold the sub-bar up / fold it back down.
* (While the pop-up is open, the button closes the pop-up instead.)
*/
menuAction() {
if (config.get('save.subBar.enabled', true) !== true) return;
if (this.savePanel && this.savePanel.isOpen) {
this.savePanel.close();
return;
}
if (this.researchWindow && this.researchWindow.isOpen) return; // the console owns input
if (this.menuSubBar && this.menuSubBar.isOpen) {
this.menuSubBar.close();
return;
}
// Load Game is live only when the bank holds at least one save.
this.menuSubBar.setDisabled('load', !this.saveManager.hasAny());
this.menuSubBar.open();
}
/** The sub-bar's button presses (js/ui/MenuSubBar.js → onAction). */
subBarAction(id) {
if (id === 'save') {
this.savePanel.show('save');
return;
}
if (id === 'load') {
if (this.saveManager.hasAny()) this.savePanel.show('load');
return;
}
if (id === 'mainMenu') {
this.returnToMenu();
return;
}
}
/** ESC: topmost open thing first — confirm dialog → pop-up → research → mining → sub-bar. */
escAction() {
if (this._commInputBlocked()) return; // the comm owns the input (no skip)
// A seat assignment in the hand: ESC puts the skill back (the bar stops
// breathing) BEFORE it closes anything else — the handoff owns the screen.
if (this._skillAssign?.id) {
this.cancelSkillAssign();
return;
}
// The skill console is the topmost thing when it is up (depth 80).
if (this.skillWindow?.isOpen) {
this.skillWindow.close();
return;
}
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
return;
}
if (this.savePanel.isOpen) {
this.savePanel.close();
return;
}
}
// The research console (depth 80) — above everything else but the save UI.
if (this.researchWindow && this.researchWindow.isOpen) {
this.researchWindow.close();
return;
}
// The map console (depth 80) — the same contract. Its autopilot
// confirm sits on top: ESC cancels the dialog, not the map.
if (this.mapWindow && this.mapWindow.isOpen) {
if (this.mapWindow.dialog && this.mapWindow.dialog.isOpen) {
this.mapWindow.dialog.cancel();
return;
}
this.mapWindow.close();
return;
}
// The quests console (depth 80) — the same contract.
if (this.questWindow && this.questWindow.isOpen) {
this.questWindow.close();
return;
}
// The ship console (depth 80) — the same contract, and its bay-manifest
// confirm sits on top: ESC cancels the dialog, not the console.
if (this.shipWindow && this.shipWindow.isOpen) {
if (this.shipWindow.isDialogOpen) {
this.shipWindow.dialog.cancel();
return;
}
this.shipWindow.close();
return;
}
// The mining context menu is the topmost open thing after the save UI.
if (this.miningPopup && this.miningPopup.isOpen) {
this.miningPopup.close();
return;
}
// The comms panel (a planet/station click opened it).
if (this.commsPanel && this.commsPanel.isOpen) {
this.commsPanel.close();
return;
}
// Beam live (or arm extending) → break the mining (the beam retracts;
// the ship is free again — no fly).
if (this.mining && (this.mining.state === 'mining' || this.mining.state === 'extending')) {
this.mining.stop();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
}
/**
* Confirmed in-game load: the restore is staged in the shared registry
* (SavePanel.doLoad → prepareLoad) and the panel has already closed
* (onLoadComplete fires after the close animation). Restart this scene
* in place — the jump cut's seam (scene.restart reuses this instance)
* — so the fresh create() consumes the staged restore (consumeRestore →
* applyRestore). The restart's shutdown tears the sub-bar, the panel
* and any live comms down the same way a jump does.
*/
reloadRun() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.restart();
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.start('MenuScene');
}
// ------------------------------------------------------------------
// THE DEATH FLOW — the player's hull broke in combat
// ------------------------------------------------------------------
/**
* THE RESPAWN GATE (the skill seam): does the run bring the player back
* in their seat, or does the run END? The default stands — death ends the
* run and the Game Over beat owns the screen (js/ui/GameOverOverlay.js).
* A SKILL may override that: any LIVE skill (rank ≥ 1) whose effect
* declares `kind: 'respawnOnDeath'` would bring the player back instead.
* None is developed yet, so this is inert and always answers false.
* @returns {boolean} true = the old respawn placeholder runs
*/
_playerDeathRespawns() {
const ss = this.skillState;
if (ss?.ranks) {
for (const [id, rank] of ss.ranks) {
if (rank <= 0) continue;
const def = ss.defOf(id);
if (def?.effect?.kind === 'respawnOnDeath') return true;
}
}
return false;
}
/** Re-resolve the gate after a rank lands or is refunded (the seam reads
* the LIVE ledger, so a skill that overrides death stays in sync). */
_syncDeathFlowRespawn() {
if (this.combat) this.combat.playerRespawn = this._playerDeathRespawns();
}
/**
* THE BEAT (CombatSystem → onPlayerDown): the hull broke and the run is
* over — freeze the world (the update() loop's freeze picks up
* `this._gameOver` next frame: the ship stays down, the hostiles hold)
* and raise the Game Over overlay: the large semi-transparent headline
* centred, RETURN TO MAIN MENU / CONTINUE below it.
*/
_playerWentDown() {
if (this._gameOver) return; // one beat per death
// A live skill overrode the death flow — the player is coming back in
// their seat (the respawn placeholder runs), so there is no beat.
if (this.combat?.playerRespawn === true) return;
this._gameOver = true;
this.savePanel?.close(); // the deck's save doors fold away behind the beat
this.menuSubBar?.dismiss();
if (!this._gameOverOverlay) {
this._gameOverOverlay = new GameOverOverlay(this);
}
this._gameOverOverlay.show({ canContinue: this.saveManager.hasAny() });
}
/**
* THE CONTINUE DOOR (the Game Over beat's button): resume the NEWEST save
* in the bank — exactly the main menu's Continue (SaveManager.latest by
* savedAt → prepareLoad stages the restore in the shared registry) — then
* rebuild this scene in place so the fresh create() consumes it (the same
* seam as the in-game Load's reloadRun).
*/
continueFromGameOver() {
if (this._gameOverCut) return;
const latest = this.saveManager.latest();
if (!latest) {
this.consoleToast?.('NO SAVE ON FILE — CONTINUE IS DARK', { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
return;
}
try {
this._gameOverCut = true;
prepareLoad(this.registry, latest.record);
console.info(
`orbit — game over: resuming ${latest.record.galaxyName ?? 'the galaxy'} (slot ${latest.slot ?? 'auto'}, seed ${latest.record.seed})`,
);
this.scene.restart();
} catch (err) {
console.error(err);
this._gameOverCut = false;
this.playSfx?.('deconstruct'); // the refused-spend voice (the step-backwards)
this.consoleToast?.('SIGNAL LOST — COULD NOT RESUME THE SAVE', { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
}
}
/** The sub-bar's anchor: the MENU button's center x + width, and the
* deck's TOP EDGE as the resting line — the sub-bar rests on the bottom
* bar (the deck), not on the button (the buttons are inset below the
* bar's top rail, so anchoring to the button made the sub-bar spill
* over the deck's top edge). */
menuAnchor() {
const menuSlot = this.actionBar?.slots?.find((s) => s.id === 'menu');
if (menuSlot) {
return {
x: menuSlot.slot.x,
y: this.actionBar.rect.y, // deck top edge — the sub-bar's bottom edge sits on it
w: this.actionBar.style.bw,
};
}
// Deck disabled (dev): a virtual button at the bottom right.
return { x: this.scale.width - 96, y: this.scale.height - 58, w: 120 };
}
/**
* Apply a staged restore (js/save/SaveData.js → prepareLoad parked it):
* the ship back where it was — and with its hold full or empty as saved
* (ship.minerals — the upper-right mineral readout follows) — the saved
* tether field (the level-1 home tether is just a saved entry — the
* field is rebuilt from the record), and the saved session time.
* Discovery + galaxy are already restored in the registry (this scene's
* create() read them).
*/
/**
* The `starting` installs (data/builds.json → `starting`): rule-level
* pre-builds the player owns from the first frame — the home world's
* level-1 tether ("every world starts with its tether already in
* place"). 'home' is the seed-independent home key; resolve it to the
* home world's name (BuildState keys records by planet name — the same
* key the tether field and the surface scene use). Idempotent: create()
* seeds a fresh state, and applyRestore() re-seeds AFTER a load — a save
* captured before the seed existed (or by an older iteration) replaces
* the seeded state, and must not un-install the home world's starting
* tether. A player resuming a run always has L1 on home.
*/
_seedStartingBuilds() {
for (const [planet, build] of startingPairs()) {
// 'home' is the player's home world — the starting system's central
// body only. In every other system the central body is the star,
// whose origin tether is the scene's native set (added in create),
// not a build — so the seed does not apply there.
const name = planet === 'home'
? (this.isHomeSystem ? this.planet?.discoveryName : null)
: planet;
if (name) this.buildState.markBuilt(name, build);
}
}
/**
* Re-anchor the tethers the player has INSTALLED on this system's
* worlds (a build's `effects.tether`, data/builds.json) whenever the
* field is rebuilt to its native set — a jump cut stages an empty
* tether list (rec.tethers = []) and the scene restart re-forms home +
* the activated gates only, while the installed tethers (and their
* built records) outlive the cut. Idempotent: the installed level is
* the MAX of the world's tether builds (level-1 + level-2 ⇒ level 2);
* an existing tether is only ever strengthened, never created twice or
* downgraded.
*/
_rematerializeBuiltTethers() {
if (!this.buildState || !this.tetherField) return;
for (const [planetName, builds] of this.buildState.built.entries()) {
const world =
this.systemPlanets.find((p) => p.discoveryName === planetName) ??
this.systemStations.find((s) => s.discoveryName === planetName) ??
(planetName === this.planet?.discoveryName ? this.planet : null);
if (!world) continue; // another system's world — re-anchors on the way back
let level = 0;
for (const id of builds) {
const lvl = Number(defById(id)?.effects?.tether?.level);
if (Number.isFinite(lvl) && lvl > level) level = lvl;
}
if (!level) continue;
const t = this.tetherField.tethers.find(
(tt) => tt.label === planetName || (tt.x === world.x && tt.y === world.y),
);
if (!t) {
this.tetherField.add(`planet:${planetName}`, world.x, world.y, level, planetName);
} else if (t.level < level) {
this.tetherField.setLevel(t.id, level);
}
}
}
applyRestore(r) {
if (r.ship) {
this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0);
if (typeof r.ship.heading === 'number') this.ship.rotation = r.ship.heading;
// The hold — saves that predate minerals have no field (stay at 0);
// setMinerals clamps to the ship's capacity. The hold CAP's source
// of truth is the save's build records (effects.mining.capacity —
// restored into buildState below, re-asserted by
// _restoreMiningUpgrades after create), so raise it BEFORE the
// minerals land: a save with more minerals than the base cap (250)
// must not be clamped away.
if (typeof r.ship.minerals === 'number') {
const cap = this._miningCapacityFromBuilds(r.builds);
if (cap > (Number(this.ship.stats?.mineralStorage) || 0)) this.ship.stats.mineralStorage = cap;
this.ship.setMinerals(r.ship.minerals);
}
// The credit ledger (data/economy.json) — a save predating credits
// has no field; setCredits floors a missing/invalid value at 0.
this.ship.setCredits(r.ship.credits ?? 0);
// The unlocked cargo bays (ship.stats.cargoHold) — the run's ship
// starts with 0 (data/ship.json) and the bar scene's DONE press is
// what builds it out (onVignetteComplete); the hold is ship state,
// so the save carries it and the load restores it BEFORE the goods
// go back in. A save predating the field is the old-story era (the
// package landed on the capital landing and the ship's base hold
// was 4): fall back to "enough bays for the goods it carried" so a
// mid-delivery save's package survives the load.
if (typeof this.ship.setCargoHold === 'function') {
const carried = Array.isArray(r.ship.cargo) ? r.ship.cargo.length : 0;
const hold = typeof r.ship.cargoHold === 'number' ? r.ship.cargoHold : carried;
if (hold > (Number(this.ship.stats?.cargoHold) || 0)) this.ship.setCargoHold(hold);
}
// The trade-goods hold (data/economy.json → cargo) — a save predating
// cargo has no field; set() degrades to an empty hold (old saves load).
// Capacity is the restored stat above (0, 4, or an upgraded hold).
// cargoCosts is the parallel cost ledger (the credits/unit actually
// paid per bay, null where no cost is on file) — a save predating the
// record has no field, so every bay loads as "no cost on file" and
// its manifest prices exits against the catalog's own value.
if (this.ship.cargo) {
this.ship.cargo.set(
Array.isArray(r.ship.cargo) ? r.ship.cargo : [],
Array.isArray(r.ship.cargoCosts) ? r.ship.cargoCosts : null,
);
}
}
// The corner readouts (upper right) were built with the FRESH hold (0)
// earlier in create() — push the restored values through them. No-op
// when the save held nothing (set() skips unchanged values — no
// count-up, no flash), so mineral-less saves stay quiet.
this.refreshMineralHud();
this.refreshCreditsHud(); // same seam — the ledger line was set to the fresh 0 at build
if (Array.isArray(r.tethers) && r.tethers.length > 0) {
for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id);
for (const t of r.tethers) {
if (!t || typeof t.id !== 'string') continue;
this.tetherField.add(t.id, Number(t.x) || 0, Number(t.y) || 0, t.level ?? 1, t.label ?? '');
}
}
this.playTimeMs = Number(r.playTimeMs) || 0;
// Research — the unlocked set + the in-flight project (fresh clock).
if (r.research && this.researchState) {
try {
const fresh = ResearchState.fromJSON(r.research);
this.researchState.reset();
this.researchState.unlocked = fresh.unlocked;
this.researchState.restoreActive(r.research.active ?? null, this.time.now);
this.researchWindow?.refresh();
} catch (e) {
console.warn('[orbit] restore: research skipped', e);
}
}
// Builds — the installed set + the in-flight build (fresh clock — the
// game-loop clock: the build's time base, shared across scenes).
if (r.builds && this.buildState) {
try {
const fresh = new BuildState().fromJSON(r.builds);
// World state is keyed by the world's CANONICAL spelling. Saves
// written while the comms panel leaked its display casing into
// the landing handoff can carry uppercased planet keys —
// re-key them (merging into the canonical entry) so the surface
// and the tether field see the same world again.
const known = [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.discoveryName),
...(this.systemStations ?? []).map((s) => s.discoveryName),
...(this.tetherField?.tethers ?? []).map((t) => t.label),
];
for (const [k, v] of [...fresh.built.entries()]) {
const c = canonicalPlanetName(k, known);
if (c === k) continue;
fresh.built.delete(k);
const existing = fresh.built.get(c);
fresh.built.set(c, existing ? new Set([...existing, ...v]) : v);
}
this.buildState.reset();
this.buildState.built = fresh.built;
const activeSpec =
r.builds.active && typeof r.builds.active.planet === 'string'
? { ...r.builds.active, planet: canonicalPlanetName(r.builds.active.planet, known) }
: (r.builds.active ?? null);
this.buildState.restoreActive(activeSpec, this.game.loop.now);
} catch (e) {
console.warn('[orbit] restore: builds skipped', e);
}
}
// The starting installs are a RULE (data/builds.json → `starting`),
// not save data — the load above just replaced the seeded state, so
// re-assert them: the home world's level-1 tether is installed from
// the first frame, even on a run resumed from a save that predates
// the seed (or captured before it was recorded).
if (this.buildState) this._seedStartingBuilds();
// The camera was centered on the spawn — recentre on the restored ship.
this.cameras.main.setScroll(this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2);
this.playSfx('construct');
}
/**
* Drop the LAST entry's per-system effect layers (nebula / haze / void /
* storm / embers / wanderer) — the conditional ones, created just below
* in create(). A jump's cut is a scene RESTART that reuses this scene
* instance (v4: sys.shutdown + the 'shutdown' EVENT — the display list
* destroys its children, but this scene's refs + its update() calls
* survive, and our shutdown() method is never called), so without this
* a previous system's layers would keep running — and the habitable
* storm would keep playing thunder in a system with no storm. Safe to
* run over null refs (first boot) and over already-destroyed layers
* (the layers' destroy()s are idempotent, as is the shutdown() net
* below). create() calls this before building this system's set.
*/
_teardownEffectLayers() {
this.nebula?.destroy();
this.nebula = null;
this.haze?.destroy();
this.haze = null;
this.voidBase?.destroy();
this.voidBase = null;
this.storm?.destroy();
this.storm = null;
this.embers?.destroy();
this.embers = null;
this.wanderer?.destroy();
this.wanderer = null;
this.nebulaLightning?.destroy();
this.nebulaLightning = null;
}
shutdown() {
this.setMiningLoop(false); // the hum can't outlive the scene
this.stopGameMusic(); // neither can the soundtrack
this.starfield?.destroy();
this.engineFx?.destroy(); // the exhaust can't outlive the scene
this.nebula?.destroy();
this.haze?.destroy();
this.voidBase?.destroy();
this.storm?.destroy();
this.embers?.destroy();
this.wanderer?.destroy();
this.nebulaLightning?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();
this.combat?.destroy();
this.combatBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.miningPopup?.destroy();
this.commsPanel?.destroy();
this.researchWindow?.destroy(); // the console (video + UI)
this.mapWindow?.destroy(); // the map console (video + chart canvas)
this.shipWindow?.destroy(); // the ship console (video + telemetry plate)
this.creditsHud?.destroy();
this.mineralHud?.destroy();
this.mining?.destroy();
this.scanPulse?.destroy();
this.signalCompass?.destroy();
this.tetherField?.destroy();
}
}