diff --git a/assets/speech/trestle/q2-04.mp3 b/assets/speech/trestle/q2-04.mp3 new file mode 100644 index 0000000..755fa91 Binary files /dev/null and b/assets/speech/trestle/q2-04.mp3 differ diff --git a/assets/speech/trestle/q2-05.mp3 b/assets/speech/trestle/q2-05.mp3 new file mode 100644 index 0000000..a12fffc Binary files /dev/null and b/assets/speech/trestle/q2-05.mp3 differ diff --git a/data/comms.json b/data/comms.json index 1bb30de..90149d1 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: the 'Leaving the Nest' quest's arrival in a home-neighbor system (GameScene._trestleBeats on scene create; it in the played-ledger is the quest's 'Meet Trestle' witness). 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). 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.", @@ -207,7 +207,7 @@ "delayMs": 0 }, { - "$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system — 'the neighboring system' of the quest's final check) and the way to find it (the orange markers on the compass/map). The 'star cluster's capital' is the NEUTRAL CAPITAL (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js → _assignFactions): the scene acts on the acknowledgment — the moment the player clicks ACKNOWLEDGE, its destination is set to that system's capital world (the system's outermost planet) — 'sending the coordinates to your computer right now' (js/comms/CommsHub.js → onEnd, js/scenes/GameScene.js → _commEnded → setDestination — the route, the orange compass marker, and the map's route lane all follow from it). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).", + "$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system, waiting ON ITS CAPITAL WORLD — the quest's final check's destination) and the way to find it (the orange markers on the compass/map). The 'star cluster's capital' is the NEUTRAL CAPITAL (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js → _assignFactions): the scene acts on the acknowledgment — the moment the player clicks ACKNOWLEDGE, its destination is set to that system's capital world (the system's outermost planet) — 'sending the coordinates to your computer right now' (js/comms/CommsHub.js → onEnd, js/scenes/GameScene.js → _commEnded → setDestination — the route, the orange compass marker, and the map's route lane all follow from it). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).", "id": "trestle-brief-3", "from": "trestle", "type": "ack", @@ -218,14 +218,26 @@ "delayMs": 0 }, { - "$comment": "The 'Leaving the Nest' quest's ARRIVAL (trigger 'trestleArrival') — Trestle's side of the gate: plays when the player LANDS in a home-neighbor system (the jump destination; a loaded save parked there too) — the payoff of his briefing's last line ('we'll talk more when you get here'). Text-only (his voice for this beat isn't recorded yet — his recorded lines are the two starter clips + the q2-01/02/03 briefs). It in the run's comms played-ledger is the quest's 'Meet Trestle in the neighboring system' witness (GameScene._questCheckEval). Armed by GameScene._trestleBeats on scene create — AFTER the boot comms, so the jump-in landing doesn't lose it to the queue race.", - "id": "trestle-arrive", + "$comment": "The 'Leaving the Nest' quest's ARRIVAL, beat 1 of 2 (trigger 'trestleArrival') — Trestle's welcome ON the capital world (the neutral capital — the outermost world of the designated system, the destination trestle-brief-3's acknowledgment sets): the quest completes on the PLANET, not on entering the neighboring system — the payoff of his briefing's last line ('we'll talk more when you get here'). Armed the moment the ship reaches the capital world's keep-out rim (GameScene._checkDestinationReached), or by GameScene._trestleBeats on scene create for a loaded save parked there (AFTER the boot comms, so the jump-in landing doesn't lose it to the queue race); the comm's once-ledger + the quest guard keep it a one-shot. Both beats 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).", + "id": "trestle-arrive-1", "from": "trestle", "type": "ack", "trigger": "trestleArrival", - "text": "There he is. You left the nest, kid… not bad for a first jump. Out here the galaxy gets a lot bigger — and a lot stranger. You're on your own now. Well… not entirely. I'll be around. Go make something of it, kid.", + "text": "Welcome Kid, you made it! The capital world of this star system, and it's totally neutral. You're not going to find another capital planet in the galaxy that isn't under faction control... But we'll get to that later.", + "audio": "assets/speech/trestle/q2-04.mp3", "once": true, "delayMs": 1500 + }, + { + "$comment": "Arrival beat 2 of 2 (trigger 'trestleArrival') — queued right behind beat 1: the hub's FIFO plays it the moment beat 1 is acknowledged (delayMs 0). The rendezvous (Trestle + Carol at the bar on the third ringed city) and the 200-credit top-up (the quest's reward, claimed in the console).", + "id": "trestle-arrive-2", + "from": "trestle", + "type": "ack", + "trigger": "trestleArrival", + "text": "In the meantime, head on down here. You'll find me and Carol at the bar on the third ringed city. I'm dropping 200 credits into your account. Let's take a break, and then it's back to work.", + "audio": "assets/speech/trestle/q2-05.mp3", + "once": true, + "delayMs": 0 } ], "reservedTriggers": [ diff --git a/data/landing.json b/data/landing.json index b25e55f..99ee9b9 100644 --- a/data/landing.json +++ b/data/landing.json @@ -68,7 +68,7 @@ { "land": "ice-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "ice-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, { "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "lava-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, - { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, + { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "lava-shop-02.mp4" }, { "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "ice-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, { "land": "ice-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, diff --git a/data/quests.json b/data/quests.json index f921ad4..01a94c0 100644 --- a/data/quests.json +++ b/data/quests.json @@ -58,6 +58,12 @@ "color": "#ff5c77", "text": "REQUIREMENTS INCOMPLETE — {missing}" }, + "capitalDenyToast": { + "_comment": "The capital world's landing gate (js/scenes/GameScene.js → startLanding — _capitalLandingBlocked): the 'Leaving the Nest' reward must be CLAIMED before the player can land on the neutral capital world (the quest completes when the ship arrives at the planet; the claim is the last step before the world opens). A landing request before the claim is this flat denial on the console (the comms panel already closed — the toast is the ack; the ✗ mark reads the deny).", + "glyph": "✗", + "color": "#ff5c77", + "text": "REQUEST DENIED. PLEASE CLAIM YOUR QUEST REWARD FIRST." + }, "tracker": { "_comment": "The QUEST TRACKER HUD (js/ui/QuestTrackerHud.js) — the thin quest list that slides in from offscreen-left right below the system dossier ONCE the dossier has folded (auto-fold or the player's manual fold — first fold arms it; visible ⇔ dossier folded). ACTIVE = held + not claimed. Titles stack main-story-first then side-quests, up to maxVisible, the priority quest's checklist renders under its title and tracks LIVE (GameScene.questSnapshot); more than maxVisible → SHOW MORE (opens the QUESTS console on the first hidden quest). The ONE priority quest (js/quests/QuestState.js): the player sets/clears it (row popup + the console's SET PRIORITY), else the first active story quest is the priority; claiming it auto-advances to the next story/side quest. priorityToast/clearToast fire on set/clear (data colors: green = the tracker accent, dim = the cleared note).", "enabled": true, @@ -119,7 +125,7 @@ "title": "Leaving the Nest", "description": "The nest did its job — it kept you alive long enough to learn the sky. Now the sky is yours. Chart the whole system, light its gates, and come find me out past the gate. I'll be waiting.", "issuer": "Trestle", - "_comment": "The run's SECOND quest — the starter's follow-up (the 'Leaving the Nest' arc). It GRANTS the moment the starter quest is COMPLETED AND CLAIMED (unlocks.claimed — QuestState.isUnlockMet, armed by GameScene._grantUnlockedQuests on the claim itself + on scene create for loaded saves whose starter is already paid out). Trestle — the 'A Friend' behind the starter — is the issuer: his firstWorld pair (data/comms.json) promised the meeting. The four checks are the run's exit arc, witnessed by the run's COMMS played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run, so the witness survives reloads and a quit mid-sequence): the system's full NAV chart (the same set that gates the jumpgate research), the home system's gates ACTIVATED (the activateGates effect), Trestle's THREE-BRIEF sequence (trestle-brief-1/2/3, trigger 'trestleBrief' — armed when the home gates go online, GameScene._trestleBeats; ALL THREE heard = 'Talk to Trestle'), and his ARRIVAL beat when the player lands in a home-neighbor system (trestle-arrive, trigger 'trestleArrival'; 'Meet Trestle'). Both Trestle checks are read data-driven from the trigger's comms (GameScene._questCheckEval).", + "_comment": "The run's SECOND quest — the starter's follow-up (the 'Leaving the Nest' arc). It GRANTS the moment the starter quest is COMPLETED AND CLAIMED (unlocks.claimed — QuestState.isUnlockMet, armed by GameScene._grantUnlockedQuests on the claim itself + on scene create for loaded saves whose starter is already paid out). Trestle — the 'A Friend' behind the starter — is the issuer: his firstWorld pair (data/comms.json) promised the meeting. The four checks are the run's exit arc, witnessed by the run's COMMS played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run, so the witness survives reloads and a quit mid-sequence): the system's full NAV chart (the same set that gates the jumpgate research), the home system's gates ACTIVATED (the activateGates effect), Trestle's THREE-BRIEF sequence (trestle-brief-1/2/3, trigger 'trestleBrief' — armed when the home gates go online, GameScene._trestleBeats; ALL THREE heard = 'Talk to Trestle'), and his ARRIVAL PAIR when the player arrives at the destination planet — the NEUTRAL CAPITAL WORLD (trestle-arrive-1/2, trigger 'trestleArrival' — armed the moment the ship reaches the capital world, GameScene._checkDestinationReached, plus a scene-create arm for loaded saves; 'Meet Trestle'). Both Trestle checks are read data-driven from the trigger's comms (GameScene._questCheckEval).", "unlocks": { "_comment": "The grant condition: the listed quests' rewards CLAIMED (the ledger's paid-out set) — 'unlocked as soon as the first quest is completed and its reward collected'.", "claimed": ["start_your_journey"] @@ -128,7 +134,7 @@ { "type": "systemCharted", "system": "home", "label": "Discover all Planets, Space Stations and Jump Gates in the system" }, { "type": "gatesActivated", "system": "home", "label": "Activate System Jump Gates" }, { "type": "trestleTalked", "label": "Talk to Trestle" }, - { "type": "trestleMet", "label": "Meet Trestle in the neighboring system" } + { "type": "trestleMet", "label": "Meet Trestle on the capital world" } ], "reward": { "credits": 200, diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index d0dacd4..f3c16bd 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -16,7 +16,7 @@ * 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 (ack) on trestleArrival); + * trestle-arrive-1 → trestle-arrive-2 (ack) on trestleArrival); * - 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), @@ -77,7 +77,7 @@ 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 roster = listComms(cfg); -check('the roster holds exactly the shipped comms', roster.length === 19); +check('the roster holds exactly the shipped comms', roster.length === 20); const byId = Object.fromEntries(roster.map((c) => [c.id, c])); const intro = byId['oac-intro']; check( @@ -273,8 +273,9 @@ check('oac-quest-new: beats the grant SFX (delayMs > 0)', (byId['oac-quest-new'] // 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 -// the arrival (the player lands in the neighboring system; it in the -// ledger is the 'Meet Trestle' witness). +// the arrival pair (the player arrives at the destination planet — the +// neutral capital world; BOTH in the ledger are the 'Meet Trestle' +// witness). check( 'trestle-brief-1: the gates-online briefing, beat 1/3 (ack, trestleBrief, trestle, once)', !!byId['trestle-brief-1'] && @@ -312,16 +313,29 @@ check('trestle-brief-3: the coordinates/orange-marker brief (the brief, verbatim check('trestle-brief-3: speaks trestle/q2-03.mp3', byId['trestle-brief-3']?.audio === 'assets/speech/trestle/q2-03.mp3'); check('trestle-brief-3: the clip ships on disk', existsSync(join(root, byId['trestle-brief-3']?.audio ?? ''))); check( - 'trestle-arrive: the arrival (ack, trestleArrival, trestle, once, text-only)', - !!byId['trestle-arrive'] && - byId['trestle-arrive'].from === 'trestle' && - byId['trestle-arrive'].type === 'ack' && - byId['trestle-arrive'].trigger === 'trestleArrival' && - byId['trestle-arrive'].once === true, + 'trestle-arrive-1: the capital-world welcome, beat 1/2 (ack, trestleArrival, trestle, once)', + !!byId['trestle-arrive-1'] && + byId['trestle-arrive-1'].from === 'trestle' && + byId['trestle-arrive-1'].type === 'ack' && + byId['trestle-arrive-1'].trigger === 'trestleArrival' && + byId['trestle-arrive-1'].once === true, ); -check('trestle-arrive: the arrival brief (you left the nest)', byId['trestle-arrive']?.text.includes('You left the nest')); -check('trestle-arrive: no speech clip yet (the voice is text-only)', byId['trestle-arrive']?.audio === null || byId['trestle-arrive']?.audio === ''); -check('trestle-arrive: lands after the jump-in beat (delayMs > 0)', (byId['trestle-arrive']?.delayMs ?? 0) > 0); +check('trestle-arrive-1: the welcome brief (you made it)', byId['trestle-arrive-1']?.text.includes('you made it')); +check('trestle-arrive-1: speaks trestle/q2-04.mp3', byId['trestle-arrive-1']?.audio === 'assets/speech/trestle/q2-04.mp3'); +check('trestle-arrive-1: the clip ships on disk', existsSync(join(root, byId['trestle-arrive-1']?.audio ?? ''))); +check('trestle-arrive-1: lands after the arrival beat (delayMs > 0)', (byId['trestle-arrive-1']?.delayMs ?? 0) > 0); +check( + 'trestle-arrive-2: the rendezvous send-off, beat 2/2 (ack, trestleArrival, once, immediate)', + !!byId['trestle-arrive-2'] && + byId['trestle-arrive-2'].from === 'trestle' && + byId['trestle-arrive-2'].type === 'ack' && + byId['trestle-arrive-2'].trigger === 'trestleArrival' && + byId['trestle-arrive-2'].once === true && + (byId['trestle-arrive-2']?.delayMs ?? 0) === 0, +); +check('trestle-arrive-2: the bar/credits brief (Carol + the 200-credit top-up)', byId['trestle-arrive-2']?.text.includes('Carol') && byId['trestle-arrive-2']?.text.includes('200 credits')); +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 ?? ''))); check( 'tetherResearch arms the pair in config order (minerals → fly-mine)', @@ -329,7 +343,7 @@ check( ); check('questNew arms the quest-grant voice', commsForTrigger(cfg, 'questNew').length === 1 && commsForTrigger(cfg, 'questNew')[0].id === 'oac-quest-new'); check('trestleBrief arms the three-brief sequence in config order (back-to-back)', JSON.stringify(commsForTrigger(cfg, 'trestleBrief').map((c) => c.id)) === JSON.stringify(['trestle-brief-1', 'trestle-brief-2', 'trestle-brief-3'])); -check('trestleArrival arms the arrival', commsForTrigger(cfg, 'trestleArrival').length === 1 && commsForTrigger(cfg, 'trestleArrival')[0].id === 'trestle-arrive'); +check('trestleArrival arms the arrival pair in config order (back-to-back)', JSON.stringify(commsForTrigger(cfg, 'trestleArrival').map((c) => c.id)) === JSON.stringify(['trestle-arrive-1', 'trestle-arrive-2'])); check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluster').length === 1 && commsForTrigger(cfg, 'firstCluster')[0].id === 'oac-first-cluster'); check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComplete').length === 1 && commsForTrigger(cfg, 'researchComplete')[0].id === 'oac-research-complete'); check('systemComplete arms the milestone comm', commsForTrigger(cfg, 'systemComplete').length === 1 && commsForTrigger(cfg, 'systemComplete')[0].id === 'oac-system-complete'); @@ -406,9 +420,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 === 19 && ghostList[0].id === 'oac-intro'); +check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 20 && ghostList[0].id === 'oac-intro'); const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] }); -check('drop: duplicate ids (first wins)', dupList.length === 19 && dupList[0].id === 'oac-intro'); +check('drop: duplicate ids (first wins)', dupList.length === 20 && dupList[0].id === 'oac-intro'); warnOn = true; // ---------------------------------------------------------------------- diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs index 2f83003..830f007 100644 --- a/dev/quests.test.mjs +++ b/dev/quests.test.mjs @@ -92,12 +92,16 @@ check('quests: the SIDE tab is the standby socket', !!sideTab && sideTab.standby check('quests: the standby note is present', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0); // ---------------------------------------------------------------------- -// 4. The toasts — starter / ready / claim / claim-denied +// 4. The toasts — starter / ready / claim / claim-denied / capital-denied // ---------------------------------------------------------------------- -for (const key of ['starterToast', 'readyToast', 'claimToast', 'claimDenyToast']) { +for (const key of ['starterToast', 'readyToast', 'claimToast', 'claimDenyToast', 'capitalDenyToast']) { const t = quests[key]; check(`quests: ${key} configured (glyph + text)`, !!t && typeof t.glyph === 'string' && typeof t.text === 'string' && hex.test(t.color ?? '')); } +check( + 'quests: capitalDenyToast is the claim-gate denial (the brief, verbatim)', + quests.capitalDenyToast?.text === 'REQUEST DENIED. PLEASE CLAIM YOUR QUEST REWARD FIRST.', +); // ---------------------------------------------------------------------- // 5. The starter quest — well-formed, one starter, real checks, fair reward @@ -163,14 +167,13 @@ for (const c of starter?.checks ?? []) { 'Discover all Planets, Space Stations and Jump Gates in the system', 'Activate System Jump Gates', 'Talk to Trestle', - 'Meet Trestle in the neighboring system', + 'Meet Trestle on the capital world', ])); check('nest: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type))); const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c])); check('nest: systemCharted names the home system', byType?.systemCharted?.system === 'home'); check('nest: gatesActivated names the home system', byType?.gatesActivated?.system === 'home'); - check('nest: the reward is a positive integer (minerals)', Number.isInteger(q?.reward?.minerals) && q.reward.minerals > 0); - check('nest: the reward fits the ship\u2019s hold', q?.reward?.minerals <= (ship.stats?.mineralStorage ?? 0)); + check('nest: the reward is a positive integer (credits)', Number.isInteger(q?.reward?.credits) && q.reward.credits > 0); } // ---------------------------------------------------------------------- diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 316f331..29d9de0 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -131,7 +131,7 @@ export function captureState(scene, now) { // Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once` // comms the run has heard, so a load doesn't re-fire them and a new // game re-plays the welcome — INCLUDING Trestle's 'Leaving the Nest' - // beats: trestle-brief-1/2/3 + trestle-arrive, the witnesses of the + // beats: trestle-brief-1/2/3 + trestle-arrive-1/2, the witnesses of the // quest's two social checks, GameScene._trestleCheck). The ledger // lives in the registry // ('commsPlayed' — js/comms/CommsHub.js owns it). A save predating diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index f805bfa..37158e4 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -1562,13 +1562,13 @@ export class GameScene extends Phaser.Scene { // in-flight guards keep the pair a one-shot either way. if ((this.discoveredWorldCount?.() ?? 0) >= 1) this.comms.trigger('firstWorld'); // The 'Leaving the Nest' beats (Trestle's briefing + arrival — - // data/comms.json: trestle-brief-1/2/3 / trestle-arrive): the scene- - // create arm covers the loaded-save case — a save parked in the - // neighboring system (the arrival's witness already earned) or one - // whose home gates were already online (the briefing's) still gets - // its beats. _trestleBeats is the guard (quest held + unclaimed) and - // the comm's once-ledger dedupes the rest (a mid-sequence quit re- - // arms only the unplayed briefs). + // data/comms.json: trestle-brief-1/2/3 / trestle-arrive-1/2): the + // scene-create arm covers the loaded-save case — a save parked at + // the capital world (the arrival's witness) or one whose home gates + // were already online (the briefing's) still gets its beats. + // _trestleBeats is the guard (quest held + unclaimed) and the comm's + // once-ledger dedupes the rest (a mid-sequence quit re-arms only the + // unplayed beats). this._trestleBeats(); } @@ -2861,8 +2861,11 @@ export class GameScene extends Phaser.Scene { * The panel's button presses (CommsPanel → onAction). The panel is * already closed by the time this runs: * 'request-jump' — the gate window, REQUEST JUMP → the JUMP - * 'request-landing' — a settled world, standing > −4 → land - * 'land' — an unsettled world → land + * 'request-landing' — a settled world, standing > −4 → land (the + * capital world: startLanding's claim gate denies + * until 'Leaving the Nest' is claimed) + * 'land' — an unsettled world → land (same gate for the + * capital world, were it ever unsettled) * 'cancel' — just close (the console log is the ack) */ commsAction(id, target) { @@ -2885,6 +2888,26 @@ export class GameScene extends Phaser.Scene { console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`); } + /** + * The capital world's landing gate (data/quests.json → + * capitalDenyToast): true when the comms target is the NEUTRAL CAPITAL + * WORLD — the 'Leaving the Nest' quest's destination planet (the + * neutralCapitalTarget's world name, matched case-insensitively; the + * comms panel only opens on this system's objects, so a name hit IS the + * capital) — and the quest's reward is NOT CLAIMED yet + * (QuestState.isClaimed). Quests off (the config kill-switch) = the + * gate is off too: with no quest there is no claim to wait for. + * @param {object} target the comms panel's landing target (commsTargetFor) + * @returns {boolean} landing on this target is blocked + */ + _capitalLandingBlocked(target) { + if (config.get('quests.enabled', true) === false) return false; + if (this.questState?.isClaimed?.('leaving_the_nest')) return false; + const cap = String(this.galaxy?.neutralCapitalTarget?.()?.objectId ?? '').trim().toLowerCase(); + const name = String(target?.name ?? '').trim().toLowerCase(); + return cap !== '' && cap === name; + } + /** * LAND (commsAction) — the surface sequence: SurfaceScene starts ON * TOP of this scene, handed the world's planets.png sheet frame so it @@ -2899,6 +2922,25 @@ export class GameScene extends Phaser.Scene { console.info(`[orbit] comms: landing disabled for ${target.name}`); return; } + // The CAPITAL WORLD holds its door until the 'Leaving the Nest' + // reward is CLAIMED (the quest's 200 credits — the quest itself + // completes when the ship arrives at this planet; the claim is the + // last step before the world opens). A request before the claim is + // a flat denial on the console (the comms panel already closed — + // the toast is the ack). + if (this._capitalLandingBlocked(target)) { + const t = config.get('quests.capitalDenyToast', {}); + this.playSfx('ui_close'); + this.consoleToast( + String(t.text ?? 'REQUEST DENIED. PLEASE CLAIM YOUR QUEST REWARD FIRST.'), + { + glyph: t.glyph ?? '✗', + glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)), + durationMs: 3200, + }, + ); + return; + } // 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 @@ -3424,8 +3466,9 @@ export class GameScene extends Phaser.Scene { return this._trestleCheck('trestleBrief', label, 'TALKED'); } if (type === 'trestleMet') { - // His ARRIVAL beat (trigger 'trestleArrival' — the player lands in - // a home-neighbor system) in the ledger = met. + // His ARRIVAL PAIR (trigger 'trestleArrival' — the player arrives + // at the destination planet, the neutral capital world) in the + // ledger = met. return this._trestleCheck('trestleArrival', label, 'MET'); } return { label, done: false, detail: '??' }; @@ -3637,18 +3680,45 @@ export class GameScene extends Phaser.Scene { return fresh; } + /** + * Is the ship AT the NEUTRAL CAPITAL WORLD (the quest's destination + * planet — the outermost world of the designated neutral-capital + * system, Galaxy.neutralCapitalTarget, the destination trestle-brief-3's + * acknowledgment sends the player to)? The test is the same keep-out + * rim _checkDestinationReached uses for 'arrived at this object': the + * ship within rim + 40 px of the world's center. Returns the capital + * world's entity when at it, null otherwise (no galaxy / no target / + * another system / still in transit). + * @returns {object|null} the capital world entity, or null + */ + _atCapitalWorld() { + const t = this.galaxy?.neutralCapitalTarget?.(); + if (!t?.objectId || t.systemId !== this.systemRecord?.id) return null; + const cap = (this.systemPlanets ?? []).find((p) => p.discoveryId === t.objectId); + if (!cap || !this.ship) return null; + const keepout = + cap.minCenterDistance?.(this.ship.radius) ?? + (cap.radius + (cap.clearance ?? 0) + this.ship.radius); + const dist = Math.hypot(this.ship.x - cap.x, this.ship.y - cap.y); + return dist <= keepout + 40 ? cap : null; + } + /** * The 'Leaving the Nest' quest's Trestle beats (data/comms.json: - * the trestle-brief-1/2/3 sequence + trestle-arrive) — armed from + * the trestle-brief-1/2/3 sequence + the trestle-arrive-1/2 pair) — + * armed from * every point where their preconditions could have become true while * the quest is held and unclaimed: * - the BRIEFING (three back-to-back ack comms), when the home * system's jumpgates are LIVE — the moment the player can jump * out of the nest (the activateGates effect — _applyResearchEffects, * or already-live when the quest grants / a save loads); - * - the ARRIVAL, when the player is IN a home-neighbor system - * (Trestle's side of the gate — scene create on the jump-in or a - * loaded save parked there). + * - the ARRIVAL, when the player is AT the neutral capital world — + * the quest's destination planet: the quest completes on the + * PLANET, not on entering the neighboring system (the live flow + * earns it on the arrival moment, GameScene._checkDestinationReached; + * this scene-create arm covers a loaded save parked at the + * capital's rim). * The comm's once-ledger + the hub's in-flight guard keep each beat a * one-shot; the QUEST CHECKS are the authoritative witnesses (the * played-ledger, read by _trestleCheck), so a beat the player never @@ -3669,14 +3739,12 @@ export class GameScene extends Phaser.Scene { if (homeKeys.length && homeKeys.every((k) => this.activatedGates?.has?.(k))) { this.comms?.trigger('trestleBrief'); } - // The ARRIVAL — the player is IN a home-neighbor system (the gate's - // other side). Only home's gates lead out of the nest, so a neighbor - // is exactly 'the neighboring system'. - const cur = this.systemRecord?.id; - if (cur && cur !== homeId) { - const neighbors = this.galaxy?.jumpNetwork?.gates?.get(homeId) ?? []; - if (neighbors.includes(cur)) this.comms?.trigger('trestleArrival'); - } + // The ARRIVAL — the player is AT the capital world (Trestle's side + // of the gate, ON the planet he promised). Only arms when the ship + // is actually there — a jump-in landing in the system (scene create, + // gate-side) does not complete the quest; the arrival moment does + // (GameScene._checkDestinationReached). + if (this._atCapitalWorld()) this.comms?.trigger('trestleArrival'); } /** @@ -3692,7 +3760,8 @@ export class GameScene extends Phaser.Scene { * setDestination does the rest: the route is derived from it, the * orange compass marker points at the gate to fly, and the map's * route lane lights up — no extra input from the player. Arriving is - * 'Meet Trestle' (trestle-arrive plays on the jump-in). + * 'Meet Trestle' — the trestle-arrive-1/2 pair plays when the ship + * reaches the capital world (GameScene._checkDestinationReached). */ _commEnded(def) { if (!def || def.id !== 'trestle-brief-3') return; @@ -4169,6 +4238,14 @@ export class GameScene extends Phaser.Scene { String(config.get('gates.route.reachedToast', 'DESTINATION REACHED — {dest}')) .replace('{dest}', String(destName).toUpperCase()), ); + // The 'Leaving the Nest' ARRIVAL (data/comms.json: trestle-arrive-1/2, + // trigger 'trestleArrival') — the quest's last beat plays on the + // CAPITAL WORLD (the neutral capital — the destination trestle-brief-3's + // acknowledgment set), not on entering the system: this is Trestle's + // 'you made it' moment. The comm's once-ledger + _trestleBeats' quest + // guard keep it a one-shot; both in the played-ledger are the quest's + // 'Meet Trestle' witness (GameScene._questCheckEval). + if (this._atCapitalWorld()) this.comms?.trigger('trestleArrival'); } /**