diff --git a/assets/speech/neutral/transport-01.mp3 b/assets/speech/neutral/transport-01.mp3 new file mode 100644 index 0000000..ad0b778 Binary files /dev/null and b/assets/speech/neutral/transport-01.mp3 differ diff --git a/assets/speech/neutral/transport-02.mp3 b/assets/speech/neutral/transport-02.mp3 new file mode 100644 index 0000000..f339725 Binary files /dev/null and b/assets/speech/neutral/transport-02.mp3 differ diff --git a/assets/speech/neutral/transport-03.mp3 b/assets/speech/neutral/transport-03.mp3 new file mode 100644 index 0000000..9306cee Binary files /dev/null and b/assets/speech/neutral/transport-03.mp3 differ diff --git a/assets/speech/neutral/transport-success-01.mp3 b/assets/speech/neutral/transport-success-01.mp3 new file mode 100644 index 0000000..614417a Binary files /dev/null and b/assets/speech/neutral/transport-success-01.mp3 differ diff --git a/assets/speech/neutral/transport-success-02.mp3 b/assets/speech/neutral/transport-success-02.mp3 new file mode 100644 index 0000000..1dc0468 Binary files /dev/null and b/assets/speech/neutral/transport-success-02.mp3 differ diff --git a/assets/speech/neutral/transport-success-03.mp3 b/assets/speech/neutral/transport-success-03.mp3 new file mode 100644 index 0000000..90ff709 Binary files /dev/null and b/assets/speech/neutral/transport-success-03.mp3 differ diff --git a/assets/speech/oac/escort-wait.mp3 b/assets/speech/oac/escort-wait.mp3 new file mode 100644 index 0000000..108b5a1 Binary files /dev/null and b/assets/speech/oac/escort-wait.mp3 differ diff --git a/assets/videos/allies/neutral-transport.mp4 b/assets/videos/allies/neutral-transport.mp4 new file mode 100644 index 0000000..ff22eef Binary files /dev/null and b/assets/videos/allies/neutral-transport.mp4 differ diff --git a/data/comms.json b/data/comms.json index 8c735d2..0095637 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). 'trestleQ4' — trestle-q4-1 → trestle-q4-2 → trestle-q4-3: the 'Pirate Problems' quest's WARNING TRIO — armed the moment the quest GRANTS (GameScene._questStartBeats — the grant gate: 'Special Delivery' claimed AND its deliveredLaunch chain fully ACKed, data/quests.json → pirate_problems.unlocks), the destination already set to the Neutral Capital world; each 'ack' holds until its own ACKNOWLEDGE, so the warning always lands before the jump into the capital's system (the ambush's midpoint); ALL THREE in the run's comms played-ledger (registry 'commsPlayed') are the quest's 'Speak with Trestle' witness (GameScene._questCheckEval). 'trestleQ4Payoff' — trestle-q4-4 → trestle-q4-5: the 'Pirate Problems' quest's PAYOFF — armed the moment the quest becomes COMPLETE + UNCLAIMED (all its checks standing, the reward claimable — GameScene._questRefresh's once-per-quest READY pass, the same moment the QUEST READY toast + chime fire; the last step to stand is the capital's arrival, story.capitalReturned): the job-board unlock (patrols, bounty hunts, escorts) + the level-5 hook (the open game's next door); the two 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps the pair a one-shot — a later re-arm is a no-op (a save loaded with the quest complete + unclaimed hears the unplayed beats on that load). '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; 'pirateAttack' — pirate-attack: the PIRATE'S OWN attack line — the moment a comming hostile (data/combat.json → enemyTypes.raider.comms) flies on-screen (a 50% roll per pirate, js/combat/CombatSystem.js) or a new pirate is sent into a rolling wave (a 20% roll per arrival) — the box's clip is the pirate's face (assets/videos/enemies/pirate.mp4) with the voice rolling ONE of the four attack clips at random per arm (audioPool); 'pirateDefeat' — pirate-defeat: the PIRATE'S OWN defeat line — a 20% roll per pirate killed, the box's clip the dying pirate (assets/videos/enemies/pirate-defeat.mp4) with the voice rolling ONE of the four defeat clips at random per arm (audioPool); 'pirateBountyAttack' — pirate-bounty-attack: the BOUNTY HUNT's MARK's attack line — GameScene._fireBountyEngagements arms it the moment the wanted leader stands up its fight (the dossier's frame at ×2, data/sidequests.json → bounty) — the box's clip the bounty clip (assets/videos/enemies/pirate-bounty.mp4) with the voice rolling ONE of the four bounty clips at random per arm (audioPool). All three once:false — re-arming is the hub's in-flight guard's job (one comm in the queue at a time, never a barrage). 'neutralScan' — neutral-scan: the NEUTRAL PILOT's SCAN line — the moment a pilot flies ON SCREEN while the encounter is neutral-only (at least one escort in, zero hostiles anywhere — js/combat/CombatSystem.js, once per encounter) — the box's clip is the pilot (assets/videos/allies/neutral-pilot.mp4) with the voice rolling ONE of the three scan clips at random per arm (audioPool); 'neutralScanComplete' — neutral-scan-complete: the PILOT's SCAN-COMPLETE line — the moment a pilot BREAKS OFF (escort window lapsed, peeling off screen) in a neutral-only encounter (js/combat/CombatSystem.js, once per encounter, on the first pilot to break) — the same face with the voice rolling ONE of the three scan-complete clips at random per arm (audioPool). Both once:false (the encounter-level marks in CombatSystem keep them to one per encounter; the hub's in-flight guard keeps one in the queue at a time). 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). 'trestleQ4' — trestle-q4-1 → trestle-q4-2 → trestle-q4-3: the 'Pirate Problems' quest's WARNING TRIO — armed the moment the quest GRANTS (GameScene._questStartBeats — the grant gate: 'Special Delivery' claimed AND its deliveredLaunch chain fully ACKed, data/quests.json → pirate_problems.unlocks), the destination already set to the Neutral Capital world; each 'ack' holds until its own ACKNOWLEDGE, so the warning always lands before the jump into the capital's system (the ambush's midpoint); ALL THREE in the run's comms played-ledger (registry 'commsPlayed') are the quest's 'Speak with Trestle' witness (GameScene._questCheckEval). 'trestleQ4Payoff' — trestle-q4-4 → trestle-q4-5: the 'Pirate Problems' quest's PAYOFF — armed the moment the quest becomes COMPLETE + UNCLAIMED (all its checks standing, the reward claimable — GameScene._questRefresh's once-per-quest READY pass, the same moment the QUEST READY toast + chime fire; the last step to stand is the capital's arrival, story.capitalReturned): the job-board unlock (patrols, bounty hunts, escorts) + the level-5 hook (the open game's next door); the two 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps the pair a one-shot — a later re-arm is a no-op (a save loaded with the quest complete + unclaimed hears the unplayed beats on that load). '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; 'pirateAttack' — pirate-attack: the PIRATE'S OWN attack line — the moment a comming hostile (data/combat.json → enemyTypes.raider.comms) flies on-screen (a 50% roll per pirate, js/combat/CombatSystem.js) or a new pirate is sent into a rolling wave (a 20% roll per arrival) — the box's clip is the pirate's face (assets/videos/enemies/pirate.mp4) with the voice rolling ONE of the four attack clips at random per arm (audioPool); 'pirateDefeat' — pirate-defeat: the PIRATE'S OWN defeat line — a 20% roll per pirate killed, the box's clip the dying pirate (assets/videos/enemies/pirate-defeat.mp4) with the voice rolling ONE of the four defeat clips at random per arm (audioPool); 'pirateBountyAttack' — pirate-bounty-attack: the BOUNTY HUNT's MARK's attack line — GameScene._fireBountyEngagements arms it the moment the wanted leader stands up its fight (the dossier's frame at ×2, data/sidequests.json → bounty) — the box's clip the bounty clip (assets/videos/enemies/pirate-bounty.mp4) with the voice rolling ONE of the four bounty clips at random per arm (audioPool). All three once:false — re-arming is the hub's in-flight guard's job (one comm in the queue at a time, never a barrage). 'neutralScan' — neutral-scan: the NEUTRAL PILOT's SCAN line — the moment a pilot flies ON SCREEN while the encounter is neutral-only (at least one escort in, zero hostiles anywhere — js/combat/CombatSystem.js, once per encounter) — the box's clip is the pilot (assets/videos/allies/neutral-pilot.mp4) with the voice rolling ONE of the three scan clips at random per arm (audioPool); 'neutralScanComplete' — neutral-scan-complete: the PILOT's SCAN-COMPLETE line — the moment a pilot BREAKS OFF (escort window lapsed, peeling off screen) in a neutral-only encounter (js/combat/CombatSystem.js, once per encounter, on the first pilot to break) — the same face with the voice rolling ONE of the three scan-complete clips at random per arm (audioPool). Both once:false (the encounter-level marks in CombatSystem keep them to one per encounter; the hub's in-flight guard keeps one in the queue at a time). 'escortDeny' — oac-escort-wait: the ESCORT contract's DENIAL — armed the moment the player is TURNED AWAY by the escort's guards: a JUMP through a gate while the convoy is linked/setting down (GameScene.jumpThroughGate's escort hold — the convoy + gate not all in view, or the convoy on its touchdown), or a LANDING on the contract's destination world while the transport is still out (GameScene.startLanding's escort door — the world holds until the transport has REACHED it, phase 'delivered', or has been DESTROYED, phase 'failed', the landing being the failure's reset remedy). OAC's VISUAL box + the escort-wait voice (assets/speech/oac/escort-wait.mp3), no pause — the denial rides over the scene's own flat-denial toast (the panel is already closed — the box IS the ack). once:false — re-arms on every denial (the hub's in-flight guard + idempotent trigger keep a barrage of denied presses to a single voice). 'escortLink' — neutral-transport-link: the TRANSPORT CAPTAIN's LINK greeting — the moment the convoy LINKS at the contract's first waypoint (the ship reaches the rendezvous radius and the convoy stands up on the wing — GameScene.updateEscort, phase 'rendezvous' → 'transit', the escortLinkToast + 'construct' SFX beat) — the box's clip is the transport (assets/videos/allies/neutral-transport.mp4) with the voice rolling ONE of the three transport clips at random per arm (audioPool); no pause — the greeting rides over the link. once:false (the phase transition is a one-shot per contract — the hub's in-flight guard keeps the queue clean). 'escortDelivered' — neutral-transport-delivery: the TRANSPORT CAPTAIN's DELIVERY thanks — the moment the convoy BREAKS OFF to land beside the destination world (the delivery witness stands — destination + convoy on screen, the sky clear — GameScene.updateEscort, phase 'transit' → 'delivering', the escortArrivingToast beat; the convoy flies in and fades out, the seal lands on the fade's close) — the same face with the voice rolling ONE of the three transport-success clips at random per arm (audioPool); no pause — the thanks rides over the touchdown. once:false (the phase transition is a one-shot per contract — the hub's in-flight guard keeps the queue clean). 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.", @@ -48,6 +48,12 @@ "label": "NEUTRAL PILOT", "role": "CARGO RUNNER — UNDER SCAN", "video": "assets/videos/allies/neutral-pilot.mp4" + }, + "transport_captain": { + "$comment": "The TRANSPORT CAPTAIN (data/sidequests.json → escort — the contract's transport ship the player escorts: its convoy stands at the rendezvous, links on the wing, and sets down at the destination world). The escort's two ally comms' face (triggers 'escortLink' — the convoy's LINK at the first waypoint — and 'escortDelivered' — the convoy's delivery, the touchdown on the destination world's surface): the box's clip is assets/videos/allies/neutral-transport.mp4 — played muted, once (the voice is the rolled speech clip, never the video).", + "label": "TRANSPORT CAPTAIN", + "role": "CARGO TRANSPORT — UNDER ESCORT", + "video": "assets/videos/allies/neutral-transport.mp4" } }, "comms": [ @@ -485,6 +491,47 @@ ], "once": false, "delayMs": 0 + }, + { + "$comment": "The ESCORT contract's DENIAL (trigger 'escortDeny' — GameScene's escort guards: jumpThroughGate's hold (the convoy + gate not all in view while the convoy is linked, or the convoy on its touchdown) and startLanding's door (the destination world holds while the transport is still out — phase 'rendezvous'/'transit'/'delivering'; 'failed' never blocks — that landing is the reset's remedy)). VISUAL: OAC's box + the escort-wait voice, no pause — the denial rides over the scene's own flat-denial toast (the comms panel is already closed — the box IS the ack). once:false — re-arms on EVERY denial; the hub's trigger idempotency + in-flight guard keep a barrage of denied presses to a single voice.", + "id": "oac-escort-wait", + "from": "oac", + "type": "visual", + "trigger": "escortDeny", + "text": "Our access is being denied. We need to wait for the transport ship.", + "audio": "assets/speech/oac/escort-wait.mp3", + "once": false, + "delayMs": 0 + }, + { + "$comment": "The TRANSPORT CAPTAIN's LINK greeting (trigger 'escortLink' — GameScene.updateEscort's rendezvous link: the ship reaches the convoy's standing point at the contract's FIRST WAYPOINT, the convoy stands up on the wing, phase 'rendezvous' → 'transit' — the escortLinkToast + 'construct' SFX beat). VISUAL: the transport's face (assets/videos/allies/neutral-transport.mp4 — played once, muted) with the greeting while the voice rolls ONE of the three transport clips at RANDOM per arm (audioPool — the hub's deferred load fetches only the chosen file); no pause — the greeting rides over the link (the player is in the scene, the convoy just formed). once:false — the phase transition is a one-shot per contract (a saved/reloaded mid-transit escort never re-fires it; the hub's in-flight guard keeps the queue clean).", + "id": "neutral-transport-link", + "from": "transport_captain", + "type": "visual", + "trigger": "escortLink", + "text": "Good to see you. Appreciate you escorting us.", + "audioPool": [ + "assets/speech/neutral/transport-01.mp3", + "assets/speech/neutral/transport-02.mp3", + "assets/speech/neutral/transport-03.mp3" + ], + "once": false, + "delayMs": 0 + }, + { + "$comment": "The TRANSPORT CAPTAIN's DELIVERY thanks (trigger 'escortDelivered' — GameScene.updateEscort's delivery: the destination world AND the convoy stand on screen with the sky clear, the convoy breaks off to land beside the destination planet/station and fades out, phase 'transit' → 'delivering' — the escortArrivingToast beat; the seal + payout land on the fade's close, _sealEscortDelivery). VISUAL: the same face (assets/videos/allies/neutral-transport.mp4 — played once, muted) with the thanks while the voice rolls ONE of the three transport-success clips at RANDOM per arm (audioPool — the hub's deferred load fetches only the chosen file); no pause — the thanks rides over the touchdown (the surviving escorts + the transport fly in beside the world). once:false — the phase transition is a one-shot per contract (a saved/reloaded escort never re-fires it; the hub's in-flight guard keeps the queue clean).", + "id": "neutral-transport-delivery", + "from": "transport_captain", + "type": "visual", + "trigger": "escortDelivered", + "text": "We're set down beside the surface, safe and sound. Thank you for the escort — you had our backs the whole lane.", + "audioPool": [ + "assets/speech/neutral/transport-success-01.mp3", + "assets/speech/neutral/transport-success-02.mp3", + "assets/speech/neutral/transport-success-03.mp3" + ], + "once": false, + "delayMs": 0 } ], "reservedTriggers": [ diff --git a/data/sidequests.json b/data/sidequests.json index da8a18d..d4aa5b7 100644 --- a/data/sidequests.json +++ b/data/sidequests.json @@ -737,7 +737,7 @@ "color": "#67e863", "text": "PATROL ROUTE COMPLETE — ITS REWARD IS READY TO CLAIM" }, - "_commentEscort": "The ESCORT contract's face (js/ui/JobBoardWindow.js + GameScene.updateEscort). escortActive = the row's live badge once the convoy links (the patrol's 'active' slot, re-labelled). escortRouteProgress = the row's progress line: the rendezvous leg, then the lane. escortLinkToast = the convoy forming on the player's wing. escortArrivedToast = the destination reached with the convoy intact. escortFailToast = the transport lost — the reset's call. escortResetToast = the landing's reset (a fresh convoy stands). escortRendezvousToast = the pre-link pointing.", + "_commentEscort": "The ESCORT contract's face (js/ui/JobBoardWindow.js + GameScene.updateEscort). escortActive = the row's live badge once the convoy links (the patrol's 'active' slot, re-labelled). escortRouteProgress = the row's progress line: the rendezvous leg, then the lane. escortLinkToast = the convoy forming on the player's wing. escortArrivingToast = the destination reached with the convoy intact (the delivery begins). escortArrivedToast = the touchdown closed (the contract completes). escortFailToast = the transport lost — the reset's call. escortResetToast = the landing's reset (a fresh convoy stands). escortRendezvousToast = the pre-link pointing. escortJumpHoldToast = the jump's flat denial while the convoy is linked but OUT OF VIEW (transport + its surviving escorts + the gate must all be on screen before the player jumps — GameScene.jumpThroughGate's escort guard). escortDeliveringToast = the jump's flat denial while the convoy is on its touchdown (hold until it lands). escortLandingDenyToast = the destination world's door (GameScene.startLanding's escort guard) — the planet/station the convoy is being delivered to holds its landing until the transport has REACHED it (phase 'delivered') or been DESTROYED (phase 'failed' — the landing's reset remedy). Every escort denial (jump hold + landing door) also arms OAC's 'escortDeny' visual comm (data/comms.json → oac-escort-wait — assets/speech/oac/escort-wait.mp3, the denial's voice).", "escortActive": "CONVOY LINKED", "escortRouteProgress": "LANE — {visited}/{total} SECTORS CROSSED", "escortRendezvousToast": { @@ -770,6 +770,21 @@ "color": "#7ce8a4", "text": "CONTRACT RESET — A FRESH CONVOY STANDS AT THE RENDEZVOUS" }, + "escortJumpHoldToast": { + "glyph": "⚓", + "color": "#ff5c77", + "text": "CONVOY OUT OF VIEW — BRING THE TRANSPORT, ITS ESCORTS AND THE GATE INTO VIEW TO JUMP" + }, + "escortDeliveringToast": { + "glyph": "✈", + "color": "#7ce8a4", + "text": "CONVOY SETTING DOWN — HOLD POSITION UNTIL TOUCHDOWN" + }, + "escortLandingDenyToast": { + "glyph": "✗", + "color": "#ff5c77", + "text": "ACCESS DENIED — THE CONVOY'S DESTINATION HOLDS UNTIL THE TRANSPORT LANDS" + }, "_commentBounty": "The BOUNTY HUNT's face (js/ui/JobBoardWindow.js + GameScene.updateBountyHunt). NOTE THE SILENCE: nothing here names the leg that holds the mark or a picket wave — the chain is hidden until it fires, so these lines only ever speak of what the player has already flown into. bountyLockBadge = the greyed row's plate (and the detail's lock line) under categories.bounty.minLevel; bountyAcceptDenyToast = the scene's refusal when a locked contract is pressed anyway.", "bountyLockBadge": "LOCKED — REQUIRES LEVEL {level}", "bountyLockSub": "BRING YOUR RECORD UP TO LEVEL {level} AND THE BOARD WILL LIST YOU", diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index 50e1c30..63b7aeb 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -111,9 +111,46 @@ const roster = listComms(cfg); // 2/3, data/comms.json) + the payoff pair (trestle-q4-4/5, the // quest-complete sign-off) + the combat pair (oac-enemies-detected / // oac-enemies-defeated) + the pirate trio (pirate-attack / pirate-defeat / -// pirate-bounty-attack — the comming type's own voice, the mark's too) = 35). -check('the roster holds exactly the shipped comms', roster.length === 37); +// pirate-bounty-attack — the comming type's own voice, the mark's too) +// + the neutral pilot's pair (neutral-scan / neutral-scan-complete) +// + the escort denial (oac-escort-wait) + the transport captain's pair +// (neutral-transport-link / neutral-transport-delivery) = 40). +check('the roster holds exactly the shipped comms', roster.length === 40); const byId = Object.fromEntries(roster.map((c) => [c.id, c])); +const transportCaptain = charEntry(cfg, 'transport_captain'); +check('the transport captain is registered (label + role + the transport clip)', !!transportCaptain && transportCaptain.label === 'TRANSPORT CAPTAIN' && !!transportCaptain.role && transportCaptain.video === 'assets/videos/allies/neutral-transport.mp4'); +check('the transport clip ships on disk', existsSync(join(root, 'assets/videos/allies/neutral-transport.mp4'))); +const tLink = byId['neutral-transport-link']; +check( + 'neutral-transport-link: the captain link greeting (visual, escortLink, transport_captain, re-arms)', + !!tLink && tLink.from === 'transport_captain' && tLink.type === 'visual' && tLink.trigger === 'escortLink' && tLink.once === false, +); +check('neutral-transport-link: the greeting', /good to see you/i.test(tLink?.text ?? '') && /appreciate you escorting us/i.test(tLink?.text ?? '')); +check('neutral-transport-link: the pool is the three transport clips', (tLink?.audioPool ?? []).length === 3 && tLink?.audioPool.every((f) => /^assets\/speech\/neutral\/transport-0[123]\.mp3$/.test(f))); +check('neutral-transport-link: the pool ships on disk', (tLink?.audioPool ?? []).every((f) => existsSync(join(root, f)))); +check('neutral-transport-link: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortLink', new Set(['neutral-transport-link'])).length === 1); +const tDeliv = byId['neutral-transport-delivery']; +check( + 'neutral-transport-delivery: the captain delivery thanks (visual, escortDelivered, transport_captain, re-arms)', + !!tDeliv && tDeliv.from === 'transport_captain' && tDeliv.type === 'visual' && tDeliv.trigger === 'escortDelivered' && tDeliv.once === false, +); +check('neutral-transport-delivery: the thanks', tDeliv?.text.includes('Thank you for the escort')); +check('neutral-transport-delivery: the pool is the three transport-success clips', (tDeliv?.audioPool ?? []).length === 3 && tDeliv?.audioPool.every((f) => /^assets\/speech\/neutral\/transport-success-0[123]\.mp3$/.test(f))); +check('neutral-transport-delivery: the pool ships on disk', (tDeliv?.audioPool ?? []).every((f) => existsSync(join(root, f)))); +check('neutral-transport-delivery: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortDelivered', new Set(['neutral-transport-delivery'])).length === 1); +const escortWait = byId['oac-escort-wait']; +check( + 'oac-escort-wait: the escort denial (visual, escortDeny, OAC, re-arms)', + !!escortWait && + escortWait.from === 'oac' && + escortWait.type === 'visual' && + escortWait.trigger === 'escortDeny' && + escortWait.once === false, +); +check('oac-escort-wait: the words, verbatim', escortWait?.text === 'Our access is being denied. We need to wait for the transport ship.'); +check('oac-escort-wait: the escort-wait voice', escortWait?.audio === 'assets/speech/oac/escort-wait.mp3'); +check('oac-escort-wait: the voice ships on disk', existsSync(join(root, 'assets/speech/oac/escort-wait.mp3'))); +check('oac-escort-wait: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortDeny', new Set(['oac-escort-wait'])).length === 1); const intro = byId['oac-intro']; check( 'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)', diff --git a/dev/escort-flow.test.mjs b/dev/escort-flow.test.mjs index 0ef8966..db6ed3d 100644 --- a/dev/escort-flow.test.mjs +++ b/dev/escort-flow.test.mjs @@ -163,6 +163,8 @@ function makeScene(systemId) { const scene = Object.create(GameScene.prototype); scene.playSfx = () => {}; scene.consoleToast = () => {}; + scene._fired = []; + scene.comms = { trigger: (n) => scene._fired.push(n) }; scene.time = { now: 0 }; scene.galaxy = galaxy; scene.systemRecord = { ...rec }; @@ -318,6 +320,7 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) == scene.escortDirector.nextComp = null; scene.updateEscort(); assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked'); + assert.ok(scene._fired.includes('escortLink'), "the TRANSPORT CAPTAIN's link greeting arms (the convoy stands at the first waypoint)"); const transport = scene._escortTransport; assert.ok(transport, 'the transport is flying'); @@ -330,6 +333,7 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) == assert.equal(routeFail.escort.phase, 'failed', 'the transport loss FAILS the contract'); assert.ok(!scene._escortTransport, '…the convoy breaks off (the reference is cleared)'); assert.ok(scene.combat.allies.every((a) => a.destroyed), '…cleanly (every convoy craft stands down)'); + assert.ok(!scene._fired.includes('escortDelivered'), '…and the lost convoy never arms the delivery thanks (no thanks for a lost run)'); // THE REMEDY — the player LANDS (startLanding's hook → _escortResetForLanding). scene._escortResetForLanding(); @@ -347,10 +351,133 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) == scene.escortDirector.nextComp = null; scene.updateEscort(); assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the retry LINKS (the convoy forms again)'); + assert.ok(scene._fired.filter((n) => n === 'escortLink').length >= 2, '…the greeting RE-arms on the retry (once:false — a fresh link is a fresh word)'); assert.ok(scene._escortTransport && !scene._escortTransport.dead, '…a fresh transport, flying'); assert.equal(scene._escortTransport.hull, scene._escortTransport.maxHull, '…at full armor'); } +// =========================================================================== +console.log('== THE WITNESS GATES — the convoy + gate on screen before the jump; the convoy on screen before the delivery =='); +{ + const scene = makeScene('sys-home'); + const st = scene.sideQuestState; + const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } }; + st.accept(JOB); + st.setActive(JOB); + scene._establishEscort(JOB, posting); + const wp = st.routeFor(JOB).waypoints[0]; + scene.ship.x = wp[0]; scene.ship.y = wp[1]; + scene.escortDirector.nextComp = null; + scene.updateEscort(); + assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked (the convoy stands)'); + // Centre the camera on the ship (the convoy stands on its wing — near). + scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2; + scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2; + + const GATE = { x: scene.ship.x + 200, y: scene.ship.y, bound: 90, radius: 40 }; + assert.equal(scene._escortJumpHold(GATE), null, 'convoy + gate on screen → the jump is FREE'); + + GATE.x += 5000; // the gate flies off-screen + assert.equal(scene._escortJumpHold(GATE), 'hidden', 'gate OFF SCREEN → the jump HOLDS'); + GATE.x = scene.ship.x + 200; // the gate back in view… + for (const a of scene._escortConvoyCrafts()) { a.x += 5000; a.y -= 5000; } // …the convoy off + assert.equal(scene._escortJumpHold(GATE), 'hidden', 'convoy OFF SCREEN → the jump HOLDS'); + for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // …the convoy back in view + assert.equal(scene._escortJumpHold(GATE), null, 'convoy + gate in view again → the jump is free'); + + // A LOST escort still holds: the SURVIVING convoy must be witnessed. + const crew = scene.combat.allies.filter((a) => a._escortCrew); + assert.ok(crew.length >= 1, 'an escort fighter stands'); + crew[0].x += 5000; // one escort out of view + assert.equal(scene._escortJumpHold(GATE), 'hidden', 'a convoy craft out of view → the jump holds'); + crew[0].x -= 5000; + crew[0].dead = true; // …or lost (the surviving convoy is the witness) + assert.equal(scene._escortJumpHold(GATE), null, 'the LOST escort is out of the witness (only the survivors stand)'); + crew[0].dead = false; + + // THE DELIVERY GATE — a jump's scene restart (tear the sprites, the + // convoy re-forms on the wing at the ship's new position), then the + // destination world in view + the sky clear, but the convoy OFF SCREEN: + // the delivery HOLDS (it witnesses its own landing). + scene.combat._teardownConvoy(); + scene._escortTransport = null; + scene.systemRecord = { ...DEST_REC }; + scene.systemContent = galaxy.ensureContent(DEST.destSystemId); + scene.ship.x = DEST_OBJ.x; scene.ship.y = DEST_OBJ.y; + scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2; + scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2; + scene.combat.enemies.push({ dead: false }); // a pirate still rides the lane (the sky is not clear) + scene.combat.active = true; + scene.updateEscort(); + assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the convoy re-forms on the wing after the jump (the sky is not clear, so the delivery holds)'); + for (const e of scene.combat.enemies) e.dead = true; // the sky clears + scene.combat.active = false; + scene.combat.waveRun = { state: 'done' }; + for (const a of scene._escortConvoyCrafts()) { a.x += 5000; a.y -= 5000; } // the convoy out of view + scene.updateEscort(); + assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'world on screen + sky clear, convoy OFF SCREEN → the delivery HOLDS'); + for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // the convoy back in view + scene.updateEscort(); + assert.equal(st.routeFor(JOB).escort.phase, 'delivering', 'world + convoy on screen + sky clear → the DELIVERY begins'); + assert.ok(scene._fired.includes('escortDelivered'), "the TRANSPORT CAPTAIN's delivery thanks arms (the convoy breaks off to the surface)"); + + // …and the jump holds for the whole touchdown (no skipping the landing). + assert.equal(scene._escortJumpHold(GATE), 'delivering', 'delivering → the jump HOLDS until the touchdown closes'); + scene.time.now = 5000; // the delivery's first landing frame (stamps the start) + scene.updateEscort(); + scene.time.now = 9000; // approach + fade complete + scene.updateEscort(); + assert.equal(st.routeFor(JOB).escort.phase, 'delivered', '…the contract delivers'); + assert.equal(scene._escortJumpHold(GATE), null, 'delivered → the jump is free again'); +} + +// =========================================================================== +console.log('== THE DESTINATION’S DOOR — the landing holds until the transport has reached it (or is lost) =='); +{ + const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } }; + + // RENDEZVOUS (the convoy hasn't even linked — the transport is still at + // the origin) — the player ahead of the job in the destination system. + { + const scene = makeScene('sys-home'); + const st = scene.sideQuestState; + st.accept(JOB); + st.setActive(JOB); + scene._establishEscort(JOB, posting); + assert.equal(st.routeFor(JOB).escort.phase, 'rendezvous', 'pre-link'); + scene.systemRecord = { ...DEST_REC }; // the player is in the destination system + scene.systemContent = galaxy.ensureContent(DEST.destSystemId); + assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'rendezvous: the destination door HOLDS (the transport has not reached it)'); + assert.equal(scene._escortLandingBlocked({ name: 'Some Other World' }), false, '…any OTHER world is free'); + assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name.toUpperCase() }), true, '…casing-independent (the panel’s display casing)'); + } + + // TRANSIT → DELIVERING (the touchdown in flight) → DELIVERED / FAILED. + { + const scene = makeScene('sys-home'); + const st = scene.sideQuestState; + st.accept(JOB); + st.setActive(JOB); + scene._establishEscort(JOB, posting); + const wp = st.routeFor(JOB).waypoints[0]; + scene.ship.x = wp[0]; scene.ship.y = wp[1]; + scene.escortDirector.nextComp = null; + scene.updateEscort(); + assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked'); + scene.systemRecord = { ...DEST_REC }; + scene.systemContent = galaxy.ensureContent(DEST.destSystemId); + assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'transit: the destination door HOLDS'); + assert.equal(scene._escortLandingBlocked({ key: DEST.destObjectId }), true, '…the world’s key (its settlement id) is the same door'); + const route = st.routeFor(JOB); + route.escort.phase = 'delivering'; + assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'delivering: the door HOLDS until the touchdown closes'); + route.escort.phase = 'delivered'; + assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), false, 'delivered: the transport has REACHED it — the door OPENS'); + route.escort.phase = 'failed'; + assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), false, 'failed: the transport is LOST — the door opens (the landing IS the reset’s remedy)'); + } +} + // =========================================================================== console.log('== THE BOARD — all TWENTY escort dossiers stand and render =='); { @@ -381,4 +508,4 @@ console.log('== THE BOARD — all TWENTY escort dossiers stand and render =='); assert.equal(seen.size, 20, '…all twenty distinct (no two collapse to one row)'); } -console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the failure (loss→land→reset→retry), the board (20 dossiers)'); +console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the witness gates (convoy + gate on screen before the jump; the convoy on screen before the landing), the destination’s door (no landing until the transport reaches it or is lost), the failure (loss→land→reset→retry), the board (20 dossiers)'); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index bcbc14f..42a01a2 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -3370,6 +3370,31 @@ export class GameScene extends Phaser.Scene { ); 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 @@ -3894,6 +3919,29 @@ export class GameScene extends Phaser.Scene { ); 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 @@ -5728,6 +5776,100 @@ export class GameScene extends Phaser.Scene { 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 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 @@ -5928,6 +6070,7 @@ export class GameScene extends Phaser.Scene { 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'), @@ -5956,17 +6099,19 @@ export class GameScene extends Phaser.Scene { if (this._escortLaneAllowed()) this._fireEscortWave(id, route); if (esc.destSystemId === this.systemRecord.id) { const obj = this._escortDestinationObject(route); - // THE WITNESS — the destination world is VISIBLE ON SCREEN (the player - // has flown it into view, not merely near it) 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._escortFieldClear()) { + // 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'),