10633 lines
516 KiB
JavaScript
10633 lines
516 KiB
JavaScript
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,
|
||
gateLockCopy,
|
||
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 ACCEPT gates: the LEVEL gate (data/sidequests.json →
|
||
// categories.bounty.minLevel — the hunt's 2) and the GATE ACCESS gate
|
||
// (the posting's gateLevel — its target system behind a faction gate). 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 — the
|
||
// level gate's (bountyAcceptDenyToast) or the gate's (the system named —
|
||
// *GateDenyToast) — 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) {
|
||
// The GATE ACCESS lock (a faction board's hunt / rescue behind a
|
||
// hostile neighbor — the posting's gateLevel) speaks the GATE's own
|
||
// words, naming the system the player needs access to FIRST (data →
|
||
// ui.{bounty,escort}GateDenyToast); the level gate keeps its own.
|
||
const gateLocked = gate.gateLevel > 0 && gate.gateLevel >= gate.minLevel;
|
||
const deny = gateLocked ? gateLockCopy(posting, 'deny') : bountyDenyCopy();
|
||
this.consoleToast(String(deny?.text ?? 'COMMISSION REFUSED'), {
|
||
glyph: deny?.glyph || '\u2717',
|
||
glyphColor: toCss(deny?.color || '#ff5c77'),
|
||
});
|
||
return { ok: false, locked: true, gateLocked, 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();
|
||
}
|
||
}
|