From 8ff7f0ee1258412f8af482e168172d34024ac577 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 13 Sep 2026 15:29:21 -0600 Subject: [PATCH] Add 'Special Delivery' payoff comms chain on surface departure - Register Carol as a comm sender with her video clip and role - Add four ack beats (carol-delivered-1/2/3, oac-delivered-1) under the new 'deliveredLaunch' trigger, fired when the player launches after selling the package - Arm the chain in GameScene.onSurfaceDeparture once story.delivered is set, with the once-ledger keeping it a one-shot - Update comms tests to cover Carol's registration, the four-beat sequence ordering, and re-arm deduplication --- data/comms.json | 52 ++++++++++++++++++++++- dev/comms.test.mjs | 94 ++++++++++++++++++++++++++++++++++++++++-- js/scenes/GameScene.js | 44 +++++++++++++++----- 3 files changed, 174 insertions(+), 16 deletions(-) diff --git a/data/comms.json b/data/comms.json index 90149d1..9c7d757 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); '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). Reserved for future comms: researchNew, 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); '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: researchNew, 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.", @@ -18,6 +18,12 @@ "label": "TRESTLE", "role": "OUT-OF-SYSTEM MENTOR", "video": "assets/videos/characters/trestle.mp4" + }, + "carol": { + "$comment": "Carol — the neutral capital's courier, the 'Special Delivery' quest's issuer (data/quests.json → special_delivery.issuer). Same box, same contracts; the clip + speech are hers (the speech key is folder-scoped, so her carol-q3-*.mp3 never collide with OAC's). The 'Special Delivery' payoff (trigger 'deliveredLaunch') is the first comm from her: the launch back into space after the package's home-world sale.", + "label": "CAROL", + "role": "COURIER", + "video": "assets/videos/characters/carol.mp4" } }, "comms": [ @@ -238,6 +244,50 @@ "audio": "assets/speech/trestle/q2-05.mp3", "once": true, "delayMs": 0 + }, + { + "$comment": "The 'Special Delivery' quest's PAYOFF, beat 1 of 4 (trigger 'deliveredLaunch') — the launch back into space AFTER the package's home-world sale: GameScene.onSurfaceDeparture arms it when the run's delivery witness (story.delivered) stands — the shop's CONFIRM of the special-package sale set it (data/goods.json → special-package; the sale is the quest's step 3, not this comm). Carol's bar line set it up — 'After you've done that, launch back into space and I'll contact you' (data/vignettes.json → special_delivery_bar_meeting). It lands a beat after the take-off settles (delayMs); the hub's FIFO chains the other three back-to-back, each 'ack' holding until its own ACKNOWLEDGE, and the run's comms played-ledger (registry 'commsPlayed') keeps the whole chain a one-shot — any later re-arm is a no-op.", + "id": "carol-delivered-1", + "from": "carol", + "type": "ack", + "trigger": "deliveredLaunch", + "text": "Thank you. I appreciate you taking care of that for me.", + "audio": "assets/speech/carol/carol-q3-05.mp3", + "once": true, + "delayMs": 1500 + }, + { + "$comment": "Payoff beat 2 of 4 (trigger 'deliveredLaunch') — the trade sign-off: the shops' trading stood unlocked since the capital's landing (the run's gate — GameScene.isTradeUnlocked — is already open; the line is the story's word on it). Queued behind beat 1: the FIFO starts it the moment beat 1 is acknowledged (delayMs 0).", + "id": "carol-delivered-2", + "from": "carol", + "type": "ack", + "trigger": "deliveredLaunch", + "text": "All shops in the galaxy are now fully unlocked for you. Trade is an important an often lucrative way to make money in the galaxy.", + "audio": "assets/speech/carol/carol-q3-06.mp3", + "once": true, + "delayMs": 0 + }, + { + "$comment": "Payoff beat 3 of 4 (trigger 'deliveredLaunch') — the open-ended game's hint: risk and route/planet-type yield are the play the delivery ends the tutorial for. Queued behind beat 2 (delayMs 0).", + "id": "carol-delivered-3", + "from": "carol", + "type": "ack", + "trigger": "deliveredLaunch", + "text": "The higher the risk, the higher the rewards. Long routes and different planet types have different yields on products. I'll leave it to you to figure out the nuance.", + "audio": "assets/speech/carol/carol-q3-07.mp3", + "once": true, + "delayMs": 0 + }, + { + "$comment": "Payoff beat 4 of 4 (trigger 'deliveredLaunch') — OAC's confirmation on the delivery: the buy/sell codes are live on every world (the trade gate's truth) and credits are the currency from here on (the 200-credit top-up the bar beat set up). Queued behind beat 3 (delayMs 0).", + "id": "oac-delivered-1", + "from": "oac", + "type": "ack", + "trigger": "deliveredLaunch", + "text": "I've confirmed. We have buy and sell codes on all worlds in the galaxy. This is a big deal. We can make real credits now.", + "audio": "assets/speech/oac/oac-q3-01.mp3", + "once": true, + "delayMs": 0 } ], "reservedTriggers": [ diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index f3c16bd..d508a29 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -16,7 +16,11 @@ * trestle-meetup → trestle-quest on firstWorld, * trestle-brief-1 → trestle-brief-2 → trestle-brief-3 (the * 'Leaving the Nest' three-brief sequence, ack) on trestleBrief, - * trestle-arrive-1 → trestle-arrive-2 (ack) on trestleArrival); + * trestle-arrive-1 → trestle-arrive-2 (ack) on trestleArrival, + * 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); * - 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), @@ -76,8 +80,12 @@ check('OAC is registered (label + role + clip)', !!oac && oac.label === 'O.A.C.' const trestle = charEntry(cfg, 'trestle'); check('Trestle is registered (label + role + his own clip)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role && trestle.video === 'assets/videos/characters/trestle.mp4'); +const carol = charEntry(cfg, 'carol'); +check('Carol is registered (label + role + her own clip)', !!carol && carol.label === 'CAROL' && !!carol.role && carol.video === 'assets/videos/characters/carol.mp4'); +check('Carol\'s space clip ships on disk', existsSync(join(root, 'assets/videos/characters/carol.mp4'))); + const roster = listComms(cfg); -check('the roster holds exactly the shipped comms', roster.length === 20); +check('the roster holds exactly the shipped comms', roster.length === 24); const byId = Object.fromEntries(roster.map((c) => [c.id, c])); const intro = byId['oac-intro']; check( @@ -337,6 +345,70 @@ check('trestle-arrive-2: the bar/credits brief (Carol + the 200-credit top-up)', check('trestle-arrive-2: speaks trestle/q2-05.mp3', byId['trestle-arrive-2']?.audio === 'assets/speech/trestle/q2-05.mp3'); check('trestle-arrive-2: the clip ships on disk', existsSync(join(root, byId['trestle-arrive-2']?.audio ?? ''))); +// The 'Special Delivery' payoff — the launch back into space after the +// package's home-world sale (GameScene.onSurfaceDeparture arms +// 'deliveredLaunch' when the run's delivery witness, story.delivered, +// stands; the comms' once-ledger keeps the four-beat chain a one-shot — +// a later re-arm is a no-op). Carol's thanks + trade sign-off, then +// OAC's confirmation that the buy/sell codes are live on every world. +check( + 'carol-delivered-1: the thanks, beat 1/4 (ack, deliveredLaunch, carol, once)', + !!byId['carol-delivered-1'] && + byId['carol-delivered-1'].from === 'carol' && + byId['carol-delivered-1'].type === 'ack' && + byId['carol-delivered-1'].trigger === 'deliveredLaunch' && + byId['carol-delivered-1'].once === true, +); +check('carol-delivered-1: the thanks (verbatim)', byId['carol-delivered-1']?.text === 'Thank you. I appreciate you taking care of that for me.'); +check('carol-delivered-1: speaks carol/q3-05.mp3', byId['carol-delivered-1']?.audio === 'assets/speech/carol/carol-q3-05.mp3'); +check('carol-delivered-1: the clip ships on disk', existsSync(join(root, byId['carol-delivered-1']?.audio ?? ''))); +check('carol-delivered-1: lands after the take-off settles (delayMs > 0)', (byId['carol-delivered-1']?.delayMs ?? 0) > 0); +check( + 'carol-delivered-2: the trade sign-off, beat 2/4 (ack, deliveredLaunch, once, immediate)', + !!byId['carol-delivered-2'] && + byId['carol-delivered-2'].from === 'carol' && + byId['carol-delivered-2'].type === 'ack' && + byId['carol-delivered-2'].trigger === 'deliveredLaunch' && + byId['carol-delivered-2'].once === true && + (byId['carol-delivered-2']?.delayMs ?? 0) === 0, +); +check( + 'carol-delivered-2: the unlock brief (the brief, verbatim)', + byId['carol-delivered-2']?.text === 'All shops in the galaxy are now fully unlocked for you. Trade is an important an often lucrative way to make money in the galaxy.', +); +check('carol-delivered-2: speaks carol/q3-06.mp3', byId['carol-delivered-2']?.audio === 'assets/speech/carol/carol-q3-06.mp3'); +check('carol-delivered-2: the clip ships on disk', existsSync(join(root, byId['carol-delivered-2']?.audio ?? ''))); +check( + 'carol-delivered-3: the risk/yield hint, beat 3/4 (ack, deliveredLaunch, once, immediate)', + !!byId['carol-delivered-3'] && + byId['carol-delivered-3'].from === 'carol' && + byId['carol-delivered-3'].type === 'ack' && + byId['carol-delivered-3'].trigger === 'deliveredLaunch' && + byId['carol-delivered-3'].once === true && + (byId['carol-delivered-3']?.delayMs ?? 0) === 0, +); +check( + 'carol-delivered-3: the nuance brief (the brief, verbatim)', + byId['carol-delivered-3']?.text === 'The higher the risk, the higher the rewards. Long routes and different planet types have different yields on products. I\'ll leave it to you to figure out the nuance.', +); +check('carol-delivered-3: speaks carol/q3-07.mp3', byId['carol-delivered-3']?.audio === 'assets/speech/carol/carol-q3-07.mp3'); +check('carol-delivered-3: the clip ships on disk', existsSync(join(root, byId['carol-delivered-3']?.audio ?? ''))); +check( + 'oac-delivered-1: the buy/sell confirmation, beat 4/4 (ack, deliveredLaunch, oac, once, immediate)', + !!byId['oac-delivered-1'] && + byId['oac-delivered-1'].from === 'oac' && + byId['oac-delivered-1'].type === 'ack' && + byId['oac-delivered-1'].trigger === 'deliveredLaunch' && + byId['oac-delivered-1'].once === true && + (byId['oac-delivered-1']?.delayMs ?? 0) === 0, +); +check( + 'oac-delivered-1: the codes brief (the brief, verbatim)', + byId['oac-delivered-1']?.text === "I've confirmed. We have buy and sell codes on all worlds in the galaxy. This is a big deal. We can make real credits now.", +); +check('oac-delivered-1: speaks oac/q3-01.mp3', byId['oac-delivered-1']?.audio === 'assets/speech/oac/oac-q3-01.mp3'); +check('oac-delivered-1: the clip ships on disk', existsSync(join(root, byId['oac-delivered-1']?.audio ?? ''))); + check( 'tetherResearch arms the pair in config order (minerals → fly-mine)', JSON.stringify(commsForTrigger(cfg, 'tetherResearch').map((c) => c.id)) === JSON.stringify(['oac-minerals', 'oac-fly-mine']), @@ -349,6 +421,20 @@ check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComple check('systemComplete arms the milestone comm', commsForTrigger(cfg, 'systemComplete').length === 1 && commsForTrigger(cfg, 'systemComplete')[0].id === 'oac-system-complete'); check('minerals200 arms the 200-mineral beat', commsForTrigger(cfg, 'minerals200').length === 1 && commsForTrigger(cfg, 'minerals200')[0].id === 'oac-tether2'); check('tetherL2Launch arms the launch-back pair in config order', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch').map((c) => c.id)) === JSON.stringify(['oac-relaunch', 'oac-explore'])); +check( + 'deliveredLaunch arms the payoff chain in config order (Carol ×3 → OAC)', + JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch').map((c) => c.id)) === + JSON.stringify(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']), +); +check( + 'a played carol-delivered-1 does not re-arm (the rest of the chain still does)', + JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch', new Set(['carol-delivered-1'])).map((c) => c.id)) === + JSON.stringify(['carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']), +); +check( + 'a fully played payoff chain re-arms nothing (the one-shot)', + commsForTrigger(cfg, 'deliveredLaunch', new Set(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1'])).length === 0, +); check('firstWorld arms the trestle pair in config order', JSON.stringify(commsForTrigger(cfg, 'firstWorld').map((c) => c.id)) === JSON.stringify(['trestle-meetup', 'trestle-quest'])); check( @@ -420,9 +506,9 @@ warnOn = true; // unknown senders are dropped by the ROSTER (listComms), not normalize const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] }; const ghostList = listComms(withGhost); -check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 20 && ghostList[0].id === 'oac-intro'); +check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 24 && ghostList[0].id === 'oac-intro'); const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] }); -check('drop: duplicate ids (first wins)', dupList.length === 20 && dupList[0].id === 'oac-intro'); +check('drop: duplicate ids (first wins)', dupList.length === 24 && dupList[0].id === 'oac-intro'); warnOn = true; // ---------------------------------------------------------------------- diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index ba33b3d..48d0b7c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -3088,9 +3088,12 @@ export class GameScene extends Phaser.Scene { // built out (the scene's effects) and the // special package lands in the hold; // · onSurfaceDeparture — SurfaceScene.finishTakeoff (a real - // launch): the capital launch sets the - // destination back to the HOME WORLD (the - // delivery's route — the compass points it); + // launch): after the package's sale it + // arms the 'deliveredLaunch' comms chain + // (Carol ×3 → OAC's sign-off, the payoff); + // the capital launch sets the destination + // back to the HOME WORLD (the delivery's + // route — the compass points it); // · onQuestPackageSold — the shop's CONFIRM of the package's sale: // saleable on the home world ONLY (TradeGrid's // gate), at zero credits — that sale is @@ -3226,17 +3229,36 @@ export class GameScene extends Phaser.Scene { /** * The surface TAKE OFF seam (SurfaceScene.finishTakeoff → here). - * 'Special Delivery' step 3's leg: launching off the NEUTRAL CAPITAL - * (the bar is done, the package is aboard) records the launch in the - * story ledger and sets the run's destination back to the HOME WORLD - * (the delivery's route — the compass marks it, and the REACHED toast - * fires on the home world's arrival via _checkDestinationReached). - * A launch off any other world is a no-op — the quest's auto-course - * earns itself only from the capital's departure. + * Two beats ride on it: + * + * 1 — the 'Special Delivery' PAYOFF (data/comms.json → + * 'deliveredLaunch'): the launch back into space AFTER the + * package's home-world sale — Carol's bar line set it up ('After + * you've done that, launch back into space and I'll contact you', + * data/vignettes.json → special_delivery_bar_meeting). Witnessed by + * the run's story ledger (story.delivered — set by the shop's + * CONFIRM of the package's sale, onQuestPackageSold), armed here + * on any departure once it stands; the comms' once-ledger keeps + * the four-beat chain (Carol ×3 → OAC's sign-off) a one-shot and + * any later re-arm a no-op. Queued while this scene still owns the + * run — the hub plays the chain once the flight world is back + * (the FIFO, each ack holding until its own ACKNOWLEDGE). + * + * 2 — 'Special Delivery' step 3's leg: launching off the NEUTRAL + * CAPITAL (the bar is done, the package is aboard) records the + * launch in the story ledger and sets the run's destination back + * to the HOME WORLD (the delivery's route — the compass marks it, + * and the REACHED toast fires on the home world's arrival via + * _checkDestinationReached). A launch off any other world is a + * no-op for this leg — the quest's auto-course earns itself only + * from the capital's departure. */ onSurfaceDeparture(worldName) { + if (this.story?.delivered === true) { + this.comms?.trigger('deliveredLaunch'); // the payoff chain — the once-ledger dedupes + } if (!this.story || !this.isCapitalWorld(worldName)) return; - this.story.launchedFromCapital = true; + this.story.launchedFromCapital = true; // the leg is made this._questRefresh(); // the delivery leg may have just completed const homeId = this.galaxy?.homeSystemId; if (!homeId || this.systemRecord?.id === homeId) return; // already home (defensive)