diff --git a/assets/speech/oac/enemies-defeated-01.mp3 b/assets/speech/oac/enemies-defeated-01.mp3 new file mode 100644 index 0000000..95b4048 Binary files /dev/null and b/assets/speech/oac/enemies-defeated-01.mp3 differ diff --git a/assets/speech/oac/enemies-defeated-02.mp3 b/assets/speech/oac/enemies-defeated-02.mp3 new file mode 100644 index 0000000..69851a7 Binary files /dev/null and b/assets/speech/oac/enemies-defeated-02.mp3 differ diff --git a/assets/speech/oac/enemies-defeated-03.mp3 b/assets/speech/oac/enemies-defeated-03.mp3 new file mode 100644 index 0000000..7a73647 Binary files /dev/null and b/assets/speech/oac/enemies-defeated-03.mp3 differ diff --git a/assets/speech/oac/enemies-detected-01.mp3 b/assets/speech/oac/enemies-detected-01.mp3 new file mode 100644 index 0000000..8d5c5e2 Binary files /dev/null and b/assets/speech/oac/enemies-detected-01.mp3 differ diff --git a/assets/speech/oac/enemies-detected-02.mp3 b/assets/speech/oac/enemies-detected-02.mp3 new file mode 100644 index 0000000..027c188 Binary files /dev/null and b/assets/speech/oac/enemies-detected-02.mp3 differ diff --git a/assets/speech/oac/enemies-detected-03.mp3 b/assets/speech/oac/enemies-detected-03.mp3 new file mode 100644 index 0000000..91b3f72 Binary files /dev/null and b/assets/speech/oac/enemies-detected-03.mp3 differ diff --git a/data/comms.json b/data/comms.json index 1c21dd7..3057231 100644 --- a/data/comms.json +++ b/data/comms.json @@ -1,5 +1,5 @@ { - "$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open), and oac-research-new — the 'new research available' beat that ANSWERS the sign-off: armed the moment the sign-off's voice has FINISHED (the hub's onEnd — GameScene._commEnded) and only if a tech is still ready to research that isn't yet (researchAnyAvailable — nothing in flight, a node whose requires are met); the hub's FIFO plays it right behind the sign-off, so the two voices never overlap; 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive-1 → trestle-arrive-2: the 'Leaving the Nest' quest's ARRIVAL PAIR on the NEUTRAL CAPITAL WORLD (the destination trestle-brief-3's acknowledgment sends the player to) — the quest completes on the PLANET, not on entering the neighboring system: armed the moment the ship reaches the capital world (GameScene._checkDestinationReached), plus a scene-create arm in GameScene._trestleBeats for a loaded save parked at the capital's rim. The hub's FIFO plays the pair back-to-back (each 'ack' holds until its own ACKNOWLEDGE); BOTH in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Meet Trestle' witness (GameScene._questCheckEval). 'deliveredLaunch' — carol-delivered-1 → carol-delivered-2 → carol-delivered-3 → oac-delivered-1: the 'Special Delivery' quest's PAYOFF — armed the moment the player LAUNCHES after the package's home-world sale (GameScene.onSurfaceDeparture: the run's delivery witness, story.delivered, stands when the shop's CONFIRM sells it — data/goods.json → special-package) — Carol's thanks + the trade sign-off, then OAC's confirmation that buy/sell codes are live on every world (the four 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps it a one-shot — a later re-arm is a no-op). Reserved for future comms: questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.", + "$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open), and oac-research-new — the 'new research available' beat that ANSWERS the sign-off: armed the moment the sign-off's voice has FINISHED (the hub's onEnd — GameScene._commEnded) and only if a tech is still ready to research that isn't yet (researchAnyAvailable — nothing in flight, a node whose requires are met); the hub's FIFO plays it right behind the sign-off, so the two voices never overlap; 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive-1 → trestle-arrive-2: the 'Leaving the Nest' quest's ARRIVAL PAIR on the NEUTRAL CAPITAL WORLD (the destination trestle-brief-3's acknowledgment sends the player to) — the quest completes on the PLANET, not on entering the neighboring system: armed the moment the ship reaches the capital world (GameScene._checkDestinationReached), plus a scene-create arm in GameScene._trestleBeats for a loaded save parked at the capital's rim. The hub's FIFO plays the pair back-to-back (each 'ack' holds until its own ACKNOWLEDGE); BOTH in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Meet Trestle' witness (GameScene._questCheckEval). 'deliveredLaunch' — carol-delivered-1 → carol-delivered-2 → carol-delivered-3 → oac-delivered-1: the 'Special Delivery' quest's PAYOFF — armed the moment the player LAUNCHES after the package's home-world sale (GameScene.onSurfaceDeparture: the run's delivery witness, story.delivered, stands when the shop's CONFIRM sells it — data/goods.json → special-package) — Carol's thanks + the trade sign-off, then OAC's confirmation that buy/sell codes are live on every world (the four 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps it a one-shot — a later re-arm is a no-op). 'combatStart' — oac-enemies-detected: the COMBAT-LIVE beat — the moment CombatSystem's active flag flips ON (GameScene._setCombatBar: the wave run's lead, before the first hostile lands, or the ?combat pack's spawn) — OAC's visual contact call (box + voice, no pause — it rides over the live fight); the voice rolls ONE of the three enemies-detected clips at random per arm (audioPool); once:false — re-arms on every combat start; 'combatEnd' — oac-enemies-defeated: the COMBAT-OVER beat — the moment the last hostile is down (the active flag flips OFF, GameScene._setCombatBar), 1 s after the final kill (delayMs 1000 — the last explosion reads first) — OAC's visual sign-off; the voice rolls ONE of the three enemies-defeated clips at random per arm (audioPool); once:false — re-arms on every combat end. Reserved for future comms: questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.", "enabled": true, "audio": { "$comment": "The comm's voice plays at its OWN volume (volume) — the manager's master is left untouched (in v4 `sound.setVolume` IS the master, so ducking it would quiet the voice too). While the voice talks, the other playing sounds (the game music) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends.", @@ -331,6 +331,36 @@ "audio": "assets/speech/trestle/trestle-q4-03.mp3", "once": true, "delayMs": 0 + }, + { + "$comment": "The COMBAT-LIVE beat (trigger 'combatStart' — GameScene._setCombatBar, the moment CombatSystem's active flag flips ON: the wave run's lead, BEFORE the first hostile lands, or the ?combat pack's spawn — the same flip the combat bar's rise reads). VISUAL: OAC's box reads the contact call with the voice, no pause — it rides over the live fight (the busy() contract only holds it while a modal owns the screen or an action like mining is out). once:false — re-arms on EVERY combat start (the played-ledger never bars it; the hub's in-flight guard keeps one voice per flip). audioPool: the voice rolls ONE of the three enemies-detected clips at RANDOM per arm (the hub's deferred load fetches only the chosen file — the same contract as oac-system-complete's pool).", + "id": "oac-enemies-detected", + "from": "oac", + "type": "visual", + "trigger": "combatStart", + "text": "Contact. Hostile signatures inbound. Weapons free.", + "audioPool": [ + "assets/speech/oac/enemies-detected-01.mp3", + "assets/speech/oac/enemies-detected-02.mp3", + "assets/speech/oac/enemies-detected-03.mp3" + ], + "once": false, + "delayMs": 0 + }, + { + "$comment": "The COMBAT-OVER beat (trigger 'combatEnd' — GameScene._setCombatBar, the moment CombatSystem's active flag flips OFF: the LAST hostile down, the run's force spent). VISUAL: OAC's box reads the sign-off with the voice, no pause. delayMs 1000: the beat lands 1 s after the final kill so the last explosion reads first (the same 'beat the SFX' pattern as the discovery-chime delays). once:false — re-arms on EVERY combat end. audioPool: the voice rolls ONE of the three enemies-defeated clips at RANDOM per arm.", + "id": "oac-enemies-defeated", + "from": "oac", + "type": "visual", + "trigger": "combatEnd", + "text": "All hostiles destroyed. The area is clear.", + "audioPool": [ + "assets/speech/oac/enemies-defeated-01.mp3", + "assets/speech/oac/enemies-defeated-02.mp3", + "assets/speech/oac/enemies-defeated-03.mp3" + ], + "once": false, + "delayMs": 1000 } ], "reservedTriggers": [ diff --git a/data/gates.json b/data/gates.json index fb7ee0b..a61a501 100644 --- a/data/gates.json +++ b/data/gates.json @@ -56,7 +56,8 @@ "jumpDelayMs": 420, "toast": "JUMP — {dest}", "dormantToast": "JUMP GATE DORMANT — CHART {system} TO ACTIVATE ITS JUMP GATES", - "miningToast": "CANNOT JUMP WHILE MINING" + "miningToast": "CANNOT JUMP WHILE MINING", + "combatToast": "CANNOT JUMP WHILE UNDER ATTACK" }, "route": { "_comment": "ROUTE (js/galaxy/Route.js + GameScene): the player's plotted course to a SET DESTINATION (SYSTEM tab of the MAP console). The run persists only the destination (systemId + objectId); the path from the current system is derived (planRoute — the jump network is a spanning tree, so it is the unique route). While a route is active, the compass marks the NEXT STOP in ORANGE (compassColor), replacing that object's ordinary arrow, and shows it even while still UNDISCOVERED (it is the thing to find): in an intermediate system the next stop is that system's jump gate toward the destination (typeLabel 'Route Gate'); in the DESTINATION system the next stop is the destination OBJECT itself (destTypeLabel) — the last hop is across the system to the world, and it stays orange until the ship actually reaches it (GameScene._checkDestinationReached fires reachedToast, then the route clears). The NEXT STOP in this system ALSO wears an in-world orange pulse the moment it is on screen (js/entities/RoutePulse.js — GameScene marks it each frame): the FULL pulse on the route GATE (pulse), the SAME pulse SUBDUED on the destination OBJECT in the destination system (objectPulse). A jump to any other system re-plots from the detour (replotToast). A destination with NO specific object clears the moment its system is entered. setToast fires when the destination is set ({dest}/{hops}).", diff --git a/data/landing.json b/data/landing.json index 20adc15..e1768a9 100644 --- a/data/landing.json +++ b/data/landing.json @@ -39,6 +39,12 @@ "greyed out in comms, same as a reputation ban)." ], "enabled": true, + "combatDenyToast": { + "_comment": "The COMBAT gate (js/scenes/GameScene.js → startLanding): a live fight (CombatSystem.active — a wave run in flight or hostiles up) holds the ship in the air — landing would sleep the flight scene under the wave and let the player hide on the surface, so a landing request mid-combat is a flat denial on the console (the comms panel already closed — the toast is the ack; the ✗ mark reads the deny). Same shape as the capital's claim gate (data/quests.json → capitalDenyToast).", + "glyph": "✗", + "color": "#ff5c77", + "text": "CANNOT LAND WHILE UNDER ATTACK" + }, "videoBase": "assets/videos/", "volume": 1, "_hud_comment": "The upper-left HUD while the surface clip loops: the planet's NAME (Ethnocentric, the SAME shade the menu's game title uses — data/menu.json → colors.title — with a black stroke behind it so it holds over any clip) and, beneath it in the body face (Centauri), the planet TYPE + TETHER LEVEL (\"Terran World, Tether Level 1\"). Both decode in with the shared scramble (the name first). marginX/marginY = corner offsets (px); name/line = font sizes, letter spacing (px), stroke color/thickness, and line color (the theme's dim by default).", diff --git a/data/quests.json b/data/quests.json index 77ea983..44081d0 100644 --- a/data/quests.json +++ b/data/quests.json @@ -172,7 +172,7 @@ } }, { - "_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: ENTERING the capital's system with the quest live, the ambush fires — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0), the wave ships materializing OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI (GameScene._maybeStartPirateAmbush fires the run); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it).", + "_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: with the quest live, the ambush fires at the MIDPOINT of the entry-jumpgate → capital-world stretch — the player ENTERS the capital's system at the gate (a full stretch from the world), and the raiders materialize once the ship is within HALF that gate-to-world distance of the capital (GameScene._maybeStartPirateAmbush: the entry gate is the destination's return gate for the last jump's source system — the restore's lastJumpFrom, staged in jumpThroughGate and saved in captureState — falling back to the gate nearest the spawn, then the spawn itself; the trigger is dist(gate, capital)/2 via JumpTravel.ambushTriggerDistance, and update() arms it the frame the ship crosses the halfway point) — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0), the wave ships materializing OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI (GameScene._maybeStartPirateAmbush fires the run); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it).", "id": "pirate_problems", "category": "main", "icon": "diamond", diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index e793433..abc7606 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -22,7 +22,10 @@ * carol-delivered-1 → carol-delivered-2 → carol-delivered-3 → * oac-delivered-1 (the 'Special Delivery' payoff — the launch back * into space after the package's home-world sale, ack) on - * deliveredLaunch, with Carol registered as a sender); + * deliveredLaunch, oac-enemies-detected (visual, once:false, + * audioPool) on combatStart, and oac-enemies-defeated (visual, + * once:false, audioPool, the 1 s delay) on combatEnd, with Carol + * registered as a sender); * - the reserved triggers are RESERVED (no comm uses them yet); * - normalizeComm drops what can't be shown (bad type / no trigger / * no text for a box / no speech for an audio comm / unknown sender), @@ -90,8 +93,9 @@ const roster = listComms(cfg); // The shipped roster count (the data pins it: a stray unvalidated comm // landing in data/comms.json changes the number and fails this line — // 25 pre-'Pirate Problems' + the trestleQ4 warning trio (trestle-q4-1/ -// 2/3, data/comms.json) = 28). -check('the roster holds exactly the shipped comms', roster.length === 28); +// 2/3, data/comms.json) + the combat pair (oac-enemies-detected / +// oac-enemies-defeated) = 30). +check('the roster holds exactly the shipped comms', roster.length === 30); const byId = Object.fromEntries(roster.map((c) => [c.id, c])); const intro = byId['oac-intro']; check( @@ -300,6 +304,75 @@ check('oac-quest-new: speaks oac/quest-new.mp3', byId['oac-quest-new']?.audio == check('oac-quest-new: the clip ships on disk', existsSync(join(root, byId['oac-quest-new']?.audio ?? ''))); check('oac-quest-new: beats the grant SFX (delayMs > 0)', (byId['oac-quest-new']?.delayMs ?? 0) > 0); +// The combat pair — OAC's contact call the moment the fight goes live +// and the sign-off the moment the last hostile is down (GameScene +// ._setCombatBar — the CombatSystem active-flag flips; both once:false, +// both voiced by an audioPool — the same contract as oac-system-complete). +check( + 'oac-enemies-detected: the combat-start call (visual, combatStart, oac, once:false)', + !!byId['oac-enemies-detected'] && + byId['oac-enemies-detected'].from === 'oac' && + byId['oac-enemies-detected'].type === 'visual' && + byId['oac-enemies-detected'].trigger === 'combatStart' && + byId['oac-enemies-detected'].once === false, +); +const detPool = byId['oac-enemies-detected']?.audioPool ?? []; +check( + 'oac-enemies-detected: the three detected clips (audioPool, no fixed audio)', + byId['oac-enemies-detected']?.audio === null && + JSON.stringify(detPool) === + JSON.stringify([ + 'assets/speech/oac/enemies-detected-01.mp3', + 'assets/speech/oac/enemies-detected-02.mp3', + 'assets/speech/oac/enemies-detected-03.mp3', + ]), +); +check('oac-enemies-detected: every pool clip ships on disk', detPool.every((f) => existsSync(join(root, f)))); +check('oac-enemies-detected: fires AT the fight (delayMs === 0)', (byId['oac-enemies-detected']?.delayMs ?? -1) === 0); +check( + 'oac-enemies-detected: a played call RE-arms (once:false — every combat start)', + commsForTrigger(cfg, 'combatStart', new Set(['oac-enemies-detected'])).length === 1, +); +check( + 'oac-enemies-defeated: the combat-end sign-off (visual, combatEnd, oac, once:false)', + !!byId['oac-enemies-defeated'] && + byId['oac-enemies-defeated'].from === 'oac' && + byId['oac-enemies-defeated'].type === 'visual' && + byId['oac-enemies-defeated'].trigger === 'combatEnd' && + byId['oac-enemies-defeated'].once === false, +); +const defPool = byId['oac-enemies-defeated']?.audioPool ?? []; +check( + 'oac-enemies-defeated: the three defeated clips (audioPool, no fixed audio)', + byId['oac-enemies-defeated']?.audio === null && + JSON.stringify(defPool) === + JSON.stringify([ + 'assets/speech/oac/enemies-defeated-01.mp3', + 'assets/speech/oac/enemies-defeated-02.mp3', + 'assets/speech/oac/enemies-defeated-03.mp3', + ]), +); +check('oac-enemies-defeated: every pool clip ships on disk', defPool.every((f) => existsSync(join(root, f)))); +check('oac-enemies-defeated: waits 1 s for the last kill to land (delayMs === 1000)', (byId['oac-enemies-defeated']?.delayMs ?? -1) === 1000); +check( + 'oac-enemies-defeated: a played sign-off RE-arms (once:false — every combat end)', + commsForTrigger(cfg, 'combatEnd', new Set(['oac-enemies-defeated'])).length === 1, +); +const detAssets = commAssets(cfg, byId['oac-enemies-detected']); +check( + 'oac-enemies-detected assets: the pool rolls to a shipped clip, keyed', + detAssets.video?.key === 'comms_vid_oac' && + detPool.includes(detAssets.audio?.url) && + detAssets.audio?.key === audioKeyFor(detAssets.audio.url), +); +const defAssets = commAssets(cfg, byId['oac-enemies-defeated']); +check( + 'oac-enemies-defeated assets: the pool rolls to a shipped clip, keyed', + defAssets.video?.key === 'comms_vid_oac' && + defPool.includes(defAssets.audio?.url) && + defAssets.audio?.key === audioKeyFor(defAssets.audio.url), +); + // The 'Leaving the Nest' beats — Trestle's THREE-BRIEF sequence (the // home gates go online; ALL THREE in the run's comms played-ledger are // the quest's 'Talk to Trestle' witness — GameScene._trestleCheck) and diff --git a/dev/jump-travel.test.mjs b/dev/jump-travel.test.mjs index 78318b3..de343a7 100644 --- a/dev/jump-travel.test.mjs +++ b/dev/jump-travel.test.mjs @@ -43,6 +43,7 @@ const check = (label, cond) => { // ---------------------------------------------------------------------- const gates = read('gates.json'); const tether = read('tether.json'); +const landing = read('landing.json'); const jump = gates.jump; check('jump: section present', !!jump && typeof jump === 'object'); @@ -55,13 +56,24 @@ check('jump: the toast templates keep their placeholders', typeof jump?.toast === 'string' && jump.toast.includes('{dest}') && typeof jump?.dormantToast === 'string' && jump.dormantToast.includes('{system}') && typeof jump?.miningToast === 'string' && jump.miningToast.length > 0); +check('jump: combatToast is the live-fight denial (non-empty string)', + typeof jump?.combatToast === 'string' && jump.combatToast.length > 0); check('jump: the gap keeps the ship outside the keepout with room to spare', Number(jump?.arrivalGap ?? -1) + Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0) > Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0)); +// The COMBAT gates (GameScene.jumpThroughGate / startLanding — a live +// fight, CombatSystem.active, denies the jump AND the landing: the run +// can't leave the system it's fighting in, and can't hide on a surface +// while the wave runs): the toasts' shapes. +check('landing: combatDenyToast configured (glyph + text + color)', + !!landing.combatDenyToast && typeof landing.combatDenyToast.glyph === 'string' && + typeof landing.combatDenyToast.text === 'string' && landing.combatDenyToast.text.length > 0 && + /^#[0-9a-fA-F]{6}$/.test(landing.combatDenyToast.color ?? '')); + // ---------------------------------------------------------------------- // The pure geometry (js/galaxy/JumpTravel.js) // ---------------------------------------------------------------------- -const { returnGateFor, arrivalPoint, jumpArrival } = await import('../js/galaxy/JumpTravel.js'); +const { returnGateFor, arrivalPoint, jumpArrival, ambushTriggerDistance } = await import('../js/galaxy/JumpTravel.js'); const contentA = { jumps: [ @@ -100,6 +112,23 @@ check('jumpArrival: { x, y, heading, gateId } for a tree-edge jump', check('jumpArrival: null for a one-way shortcut (the scene falls back to the origin)', jumpArrival(contentA, 'D', cfg) === null); +// The 'Pirate Problems' ambush's trigger — HALF the gate→capital stretch +// (GameScene._maybeStartPirateAmbush fires the wave once the ship is +// within that distance of the capital world: the midpoint of the +// jumpgate→world line). +const gate0 = { x: 1000, y: 2000 }; +const cap0 = { x: 4000, y: 6000 }; // 3000, 4000 apart → 5000 +check('ambushTriggerDistance: half the gate→world stretch (the ambush midpoint)', + ambushTriggerDistance(gate0, cap0) === 2500); +check('ambushTriggerDistance: symmetric (gate/world order is immaterial)', + ambushTriggerDistance(cap0, gate0) === 2500); +check('ambushTriggerDistance: null on a missing point (no gate / no capital)', + ambushTriggerDistance(null, cap0) === null && + ambushTriggerDistance(gate0, null) === null && + ambushTriggerDistance({ x: 1 }, { y: 1 }) === null); +check('ambushTriggerDistance: null on a zero stretch (gate on the world)', + ambushTriggerDistance({ x: 5, y: 5 }, { x: 5, y: 5 }) === null); + // ---------------------------------------------------------------------- // A REAL galaxy (bootstrap config the way main.js does, from the data dir) // ---------------------------------------------------------------------- diff --git a/js/galaxy/JumpTravel.js b/js/galaxy/JumpTravel.js index 209881b..9e41213 100644 --- a/js/galaxy/JumpTravel.js +++ b/js/galaxy/JumpTravel.js @@ -82,3 +82,26 @@ export function jumpArrival(content, fromId, cfg = {}) { if (!gate) return null; return { ...arrivalPoint(gate, cfg), gateId: gate.id }; } + +/** + * The 'Pirate Problems' ambush's TRIGGER DISTANCE — half the + * gate→capital-world stretch (GameScene._maybeStartPirateAmbush): the + * raiders materialise once the player is that far from the capital + * world, i.e. at the MIDPOINT of the entry-jumpgate → world line, so + * the conflict starts halfway to the capital from the jumpgate. + * + * @param {{x:number,y:number}|null} gate the entry gate (a content + * record, or any {x, y} anchor — the scene's spawn fallback) + * @param {{x:number,y:number}|null} world the capital world + * @returns {number|null} dist(gate, world) / 2, or null when either + * point is missing or the stretch is degenerate (zero) + */ +export function ambushTriggerDistance(gate, world) { + const gx = Number(gate?.x); + const gy = Number(gate?.y); + const wx = Number(world?.x); + const wy = Number(world?.y); + if (![gx, gy, wx, wy].every(Number.isFinite)) return null; + const d = Math.hypot(gx - wx, gy - wy); + return d > 0 ? d / 2 : null; +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 31d22bb..8143350 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -118,6 +118,17 @@ export function captureState(scene, now) { galaxyName: galaxy.name, currentSystemId: galaxy.currentSystemId, systemName: scene.systemRecord?.name ?? null, + // The last JUMP's source system (GameScene.lastJumpFromSystem — + // jumpThroughGate stages it into the restore record; the scene + // re-reads it from its own restore). The 'Pirate Problems' + // ambush's midpoint is measured from the ENTRY GATE + // (returnGateFor(content, lastJumpFrom) — + // GameScene._pirateAmbushEntryGate), so the field rides saves and a + // reload keeps the same midpoint. A save predating it has no field; + // the restore treats the absence as a fresh run (the ambush's + // nearest-gate fallback). + lastJumpFrom: + typeof scene.lastJumpFromSystem === 'string' ? scene.lastJumpFromSystem : null, ship: { x: Number(scene.ship.x), y: Number(scene.ship.y), diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 5c41ab5..83563ff 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -18,7 +18,7 @@ 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 } from '../galaxy/JumpTravel.js'; +import { jumpArrival, returnGateFor, ambushTriggerDistance } from '../galaxy/JumpTravel.js'; import { Starfield } from '../visuals/Starfield.js'; import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js'; import { NebulaLightning } from '../visuals/NebulaLightning.js'; @@ -448,6 +448,17 @@ export class GameScene extends Phaser.Scene { // 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 @@ -908,8 +919,11 @@ export class GameScene extends Phaser.Scene { // ---- 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 on the - // ENTER of the Neutral Capital's system — GameScene. + // (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 @@ -1782,15 +1796,19 @@ export class GameScene extends Phaser.Scene { // and the quest guard (_trestleQ4Beats) keeps the beats off the // player before the grant. this._trestleQ4Beats(); - // The 'Pirate Problems' AMBUSH — this is the ENTER of the Neutral - // Capital's system (the scene was just built for it): with the quest - // live and its combat step open, the pirate wave starts — its ships - // materialize OFF-SCREEN around the player, each from a random - // direction (CombatSystem._spawnWaveEnemy), and converge on their - // own chase AI. The other arm is the grant itself (a save whose - // grant landed while the player was already IN the system — the - // same method is a no-op there only when the quest isn't live or - // the wave already ran). + // 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(); } @@ -1845,11 +1863,20 @@ export class GameScene extends Phaser.Scene { * 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. * @param {boolean} active */ _setCombatBar(active) { if (this.actionBar) this.actionBar.setVisible(!active); if (this.combatBar) this.combatBar.setVisible(active); + if (active) this.comms?.trigger('combatStart'); + else this.comms?.trigger('combatEnd'); } /** @@ -2368,6 +2395,13 @@ export class GameScene extends Phaser.Scene { 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 @@ -2856,6 +2890,20 @@ export class GameScene extends Phaser.Scene { ); 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; + } // 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 — @@ -2881,6 +2929,14 @@ export class GameScene extends Phaser.Scene { 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; rec.ship = { x: arrival.x, @@ -3164,6 +3220,10 @@ export class GameScene extends Phaser.Scene { * '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) @@ -3171,7 +3231,7 @@ export class GameScene extends Phaser.Scene { 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 missing destination). + // (mining in progress, a live combat, a missing destination). const gt = target.gate?.entity; if (gt) this.jumpThroughGate(gt); return; @@ -3219,6 +3279,26 @@ export class GameScene extends Phaser.Scene { 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 @@ -4490,10 +4570,12 @@ export class GameScene extends Phaser.Scene { } 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) — the - // create() arm only covers the enter. _maybeStartPirateAmbush is - // no-op-gated (quest live + step open + system match + not already - // running/done), so re-calling it here is safe. + // 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(); } @@ -4665,23 +4747,30 @@ export class GameScene extends Phaser.Scene { /** * The 'Pirate Problems' AMBUSH — the quest's 'Defend your ship from - * pirates' step: when the player ENTERS the Neutral Capital's system - * (the gate-to-capital stretch — 'all bets are off' out here) with the - * quest live (held + unclaimed) and its combat step still open, the - * pirate wave starts — 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. Two arms call this: - * · create()'s tail — the ENTER of the system (the normal case); + * 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). + * 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. @@ -4698,12 +4787,55 @@ export class GameScene extends Phaser.Scene { 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 - if (this.galaxy?.neutralCapitalTarget?.()?.systemId !== this.systemRecord?.id) return; + 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