diff --git a/data/comms.json b/data/comms.json index 3057231..4c02735 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). 'combatStart' — oac-enemies-detected: the COMBAT-LIVE beat — the moment CombatSystem's active flag flips ON (GameScene._setCombatBar: the wave run's lead, before the first hostile lands, or the ?combat pack's spawn) — OAC's visual contact call (box + voice, no pause — it rides over the live fight); the voice rolls ONE of the three enemies-detected clips at random per arm (audioPool); once:false — re-arms on every combat start; 'combatEnd' — oac-enemies-defeated: the COMBAT-OVER beat — the moment the last hostile is down (the active flag flips OFF, GameScene._setCombatBar), 1 s after the final kill (delayMs 1000 — the last explosion reads first) — OAC's visual sign-off; the voice rolls ONE of the three enemies-defeated clips at random per arm (audioPool); once:false — re-arms on every combat end. Reserved for future comms: questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.", + "$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. 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.", @@ -332,6 +332,28 @@ "once": true, "delayMs": 0 }, + { + "$comment": "The 'Pirate Problems' quest's PAYOFF, beat 1 of 2 (trigger 'trestleQ4Payoff') — Trestle's sign-off on the run's pirate arc, armed the moment the quest becomes COMPLETE + UNCLAIMED (all its checks standing — the capital's arrival, story.capitalReturned, the last to land — the reward claimable), from GameScene._questRefresh's once-per-quest READY pass (the same moment the QUEST READY toast + chime fire); it lands a beat after that announcement settles (delayMs); the hub's FIFO chains beat 2 behind it (each 'ack' holding until its own ACKNOWLEDGE), and the run's comms played-ledger (registry 'commsPlayed') keeps the pair a one-shot — a save loaded with the quest complete + unclaimed hears the unplayed beats on that load.", + "id": "trestle-q4-4", + "from": "trestle", + "type": "ack", + "trigger": "trestleQ4Payoff", + "text": "Not bad kid. You made quick work of those pirates. There are many more where those came from. I'm unlocking the job boards for you. From patrols to bounty hunts and escorts, it will get you the exposure you need to become a threat to be reckoned with.", + "audio": "assets/speech/trestle/trestle-q4-04.mp3", + "once": true, + "delayMs": 1500 + }, + { + "$comment": "Payoff beat 2 of 2 (trigger 'trestleQ4Payoff') — the open horizon: the neutral star systems' job boards (patrols, bounty hunts, escorts) + the cargo play, and the level-5 hook (the run's next door — the ship's class + the factions' turn). Queued behind beat 1: the FIFO starts it the moment beat 1 is acknowledged (delayMs 0).", + "id": "trestle-q4-5", + "from": "trestle", + "type": "ack", + "trigger": "trestleQ4Payoff", + "text": "Take some of these jobs on to help fight the pirate threat in the neutral star systems, and run a few cargo jobs. Earn enough experience to reach level 5 and I'll reach back out to you. We'll pick a class for your ship and talk about the factions controlling the rest of the galaxy then. Good luck!", + "audio": "assets/speech/trestle/trestle-q4-05.mp3", + "once": true, + "delayMs": 0 + }, { "$comment": "The COMBAT-LIVE beat (trigger 'combatStart' — GameScene._setCombatBar, the moment CombatSystem's active flag flips ON: the wave run's lead, BEFORE the first hostile lands, or the ?combat pack's spawn — the same flip the combat bar's rise reads). VISUAL: OAC's box reads the contact call with the voice, no pause — it rides over the live fight (the busy() contract only holds it while a modal owns the screen or an action like mining is out). once:false — re-arms on EVERY combat start (the played-ledger never bars it; the hub's in-flight guard keeps one voice per flip). audioPool: the voice rolls ONE of the three enemies-detected clips at RANDOM per arm (the hub's deferred load fetches only the chosen file — the same contract as oac-system-complete's pool).", "id": "oac-enemies-detected", diff --git a/data/landing.json b/data/landing.json index ee86035..f47bea9 100644 --- a/data/landing.json +++ b/data/landing.json @@ -89,8 +89,8 @@ ], "stationVideos": [ - { "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": "ss-shop-01.mp4" }, { "land": "ss-land-02.mp4", "surface": "ss-surface-02.mp4", "takeoff": "ss-takeoff-02.mp4", "shop": "ss-shop-02.mp4" }, + { "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": "ss-shop-01.mp4" }, { "land": "ss-land-03.mp4", "surface": "ss-surface-03.mp4", "takeoff": "ss-takeoff-03.mp4", "shop": "ss-shop-03.mp4" } ] } diff --git a/data/quests.json b/data/quests.json index dfb8b55..f5f082c 100644 --- a/data/quests.json +++ b/data/quests.json @@ -171,7 +171,7 @@ } }, { - "_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: with the quest live, the ambush fires at the MIDPOINT of the entry-jumpgate → capital-world stretch — the player ENTERS the capital's system at the gate (a full stretch from the world), and the raiders materialize once the ship is within HALF that gate-to-world distance of the capital (GameScene._maybeStartPirateAmbush: the entry gate is the destination's return gate for the last jump's source system — the restore's lastJumpFrom, staged in jumpThroughGate and saved in captureState — falling back to the gate nearest the spawn, then the spawn itself; the trigger is dist(gate, capital)/2 via JumpTravel.ambushTriggerDistance, and update() arms it the frame the ship crosses the halfway point) — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0), the wave ships materializing OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI (GameScene._maybeStartPirateAmbush fires the run); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it).", + "_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: with the quest live, the ambush fires at the MIDPOINT of the entry-jumpgate → capital-world stretch — the player ENTERS the capital's system at the gate (a full stretch from the world), and the raiders materialize once the ship is within HALF that gate-to-world distance of the capital (GameScene._maybeStartPirateAmbush: the entry gate is the destination's return gate for the last jump's source system — the restore's lastJumpFrom, staged in jumpThroughGate and saved in captureState — falling back to the gate nearest the spawn, then the spawn itself; the trigger is dist(gate, capital)/2 via JumpTravel.ambushTriggerDistance, and update() arms it the frame the ship crosses the halfway point) — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0), the wave ships materializing OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI (GameScene._maybeStartPirateAmbush fires the run); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it). The PAYOFF: on the quest's READY moment — all checks standing, the reward claimable (GameScene._questRefresh's once-per-quest announcement, the same moment the QUEST READY toast + chime fire) — the quest's payoffTrigger arms Trestle's two-beat sign-off (data/comms.json → trigger 'trestleQ4Payoff' — trestle-q4-4/5 on trestle-q4-04/05.mp3: the job boards' unlock — patrols, bounty hunts, escorts — + the level-5 hook, the run's next door; the comms' once-ledger keeps the pair a one-shot, a loaded save hears the unplayed beats).", "id": "pirate_problems", "category": "main", "icon": "diamond", @@ -182,6 +182,7 @@ "claimed": ["special_delivery"], "acked": ["carol-delivered-1", "carol-delivered-2", "carol-delivered-3", "oac-delivered-1"] }, + "payoffTrigger": "trestleQ4Payoff", "checks": [ { "type": "trestleTalked", diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index abc7606..c10931b 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -93,9 +93,10 @@ const roster = listComms(cfg); // The shipped roster count (the data pins it: a stray unvalidated comm // landing in data/comms.json changes the number and fails this line — // 25 pre-'Pirate Problems' + the trestleQ4 warning trio (trestle-q4-1/ -// 2/3, data/comms.json) + the combat pair (oac-enemies-detected / -// oac-enemies-defeated) = 30). -check('the roster holds exactly the shipped comms', roster.length === 30); +// 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) = 32). +check('the roster holds exactly the shipped comms', roster.length === 32); const byId = Object.fromEntries(roster.map((c) => [c.id, c])); const intro = byId['oac-intro']; check( @@ -504,6 +505,44 @@ check( 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 ?? ''))); +// The 'Pirate Problems' payoff — the moment the quest becomes COMPLETE +// + UNCLAIMED (all its checks standing, the reward claimable), +// Trestle's two-beat sign-off on the run's pirate arc: the job-board +// unlock (patrols, bounty hunts, escorts) + the level-5 hook. Armed by +// GameScene._questRefresh's once-per-quest READY pass (the same moment +// the QUEST READY toast + chime fire); the comms' once-ledger keeps the +// pair a one-shot (a loaded save hears the unplayed beats). +check( + 'trestle-q4-4: the payoff beat 1/2 (ack, trestleQ4Payoff, trestle, once)', + !!byId['trestle-q4-4'] && + byId['trestle-q4-4'].from === 'trestle' && + byId['trestle-q4-4'].type === 'ack' && + byId['trestle-q4-4'].trigger === 'trestleQ4Payoff' && + byId['trestle-q4-4'].once === true, +); +check( + 'trestle-q4-4: the job-board brief (the brief, verbatim)', + byId['trestle-q4-4']?.text === 'Not bad kid. You made quick work of those pirates. There are many more where those came from. I\'m unlocking the job boards for you. From patrols to bounty hunts and escorts, it will get you the exposure you need to become a threat to be reckoned with.', +); +check('trestle-q4-4: speaks trestle-q4-04.mp3', byId['trestle-q4-4']?.audio === 'assets/speech/trestle/trestle-q4-04.mp3'); +check('trestle-q4-4: the clip ships on disk', existsSync(join(root, byId['trestle-q4-4']?.audio ?? ''))); +check('trestle-q4-4: lands after the READY toast settles (delayMs > 0)', (byId['trestle-q4-4']?.delayMs ?? 0) > 0); +check( + 'trestle-q4-5: the payoff beat 2/2 (ack, trestleQ4Payoff, trestle, once, immediate)', + !!byId['trestle-q4-5'] && + byId['trestle-q4-5'].from === 'trestle' && + byId['trestle-q4-5'].type === 'ack' && + byId['trestle-q4-5'].trigger === 'trestleQ4Payoff' && + byId['trestle-q4-5'].once === true && + (byId['trestle-q4-5']?.delayMs ?? 0) === 0, +); +check( + 'trestle-q4-5: the level-5 brief (the brief, verbatim)', + byId['trestle-q4-5']?.text === 'Take some of these jobs on to help fight the pirate threat in the neutral star systems, and run a few cargo jobs. Earn enough experience to reach level 5 and I\'ll reach back out to you. We\'ll pick a class for your ship and talk about the factions controlling the rest of the galaxy then. Good luck!', +); +check('trestle-q4-5: speaks trestle-q4-05.mp3', byId['trestle-q4-5']?.audio === 'assets/speech/trestle/trestle-q4-05.mp3'); +check('trestle-q4-5: the clip ships on disk', existsSync(join(root, byId['trestle-q4-5']?.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']), @@ -521,6 +560,20 @@ check( JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch').map((c) => c.id)) === JSON.stringify(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']), ); +check( + 'trestleQ4Payoff arms the payoff pair in config order (back-to-back)', + JSON.stringify(commsForTrigger(cfg, 'trestleQ4Payoff').map((c) => c.id)) === + JSON.stringify(['trestle-q4-4', 'trestle-q4-5']), +); +check( + 'a played trestle-q4-4 does not re-arm its half (the other still does)', + JSON.stringify(commsForTrigger(cfg, 'trestleQ4Payoff', new Set(['trestle-q4-4'])).map((c) => c.id)) === + JSON.stringify(['trestle-q4-5']), +); +check( + 'a fully played payoff pair re-arms nothing (the one-shot)', + commsForTrigger(cfg, 'trestleQ4Payoff', new Set(['trestle-q4-4', 'trestle-q4-5'])).length === 0, +); 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)) === diff --git a/dev/quest-payoff.test.mjs b/dev/quest-payoff.test.mjs new file mode 100644 index 0000000..0dca9e9 --- /dev/null +++ b/dev/quest-payoff.test.mjs @@ -0,0 +1,144 @@ +/** + * The 'Pirate Problems' payoff seam test (dev tool, run with Node — no + * browser): + * + * node dev/quest-payoff.test.mjs + * + * The seam (GameScene._questRefresh — the once-per-quest READY pass): + * the moment a quest becomes COMPLETE + UNCLAIMED (all its checks + * standing, the reward claimable), the scene announces it (the READY + * chime + the console's toast) AND arms the quest's PAYOFF comms — the + * trigger is the quest's own (data/quests.json → the quest's + * payoffTrigger → data/comms.json). For 'Pirate Problems' (Trestle's + * warning heard, the ambush cleared, the capital's return — the reward + * on the table), that is trigger 'trestleQ4Payoff': the trestle-q4-4 → + * trestle-q4-5 ack pair (the job-board unlock + the level-5 hook). + * Pinned: + * - a complete + unclaimed 'pirate_problems' arms its payoff trigger + * exactly once (the trigger name is the DATA's — + * pirate_problems.payoffTrigger, not a scene-side string); + * - the once-per-quest announcement keeps a second arm out (the hub's + * played-ledger is the cross-save layer — this is the scene's); + * - a complete + CLAIMED quest, an incomplete quest, and a quest with + * no payoffTrigger (the starter) all arm nothing; + * - a mixed ledger arms exactly the one trigger. + * + * (The config side of the pair — the roster, the order, the verbatim + * text, the clips on disk — is pinned in dev/comms.test.mjs; the + * quest's wiring, in dev/quests.test.mjs.) + */ +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import fs from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +class RectStub { + constructor(x = 0, y = 0, w = 0, h = 0) { this.x = x; this.y = y; this.width = w; this.height = h; } + contains(px, py) { return px >= this.x && px <= this.x + this.width && py >= this.y && py <= this.y + this.height; } + static Contains(r, px, py) { return r.contains(px, py); } +} +class ContainerStub { + constructor(scene) { this.scene = scene; this.childrenList = []; } + add() { return this; } setVisible() { return this; } setAlpha() { return this; } + setScale() { return this; } setDepth() { return this; } setScrollFactor() { return this; } + setPosition() { return this; } destroy() { return this; } +} +const ClassStub = class {}; +const PhaserStub = { + Scene: ClassStub, + Physics: { Arcade: { Sprite: ClassStub } }, + GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub, Text: ClassStub }, + Geom: { Rectangle: RectStub }, + Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).slice(1), 16) }) } }, + Filters: { Controller: class {} }, + Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } }, + Math: { Linear: (a, b, t) => a + (b - a) * t, FloatBetween: (a, b) => a, Angle: { Wrap: (a) => a }, Clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)) }, + BlendModes: { ADD: 2 }, +}; +globalThis.window = { Phaser: PhaserStub }; + +const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href); +const dataDir = join(root, 'data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +const { GameScene } = await import(pathToFileURL(join(root, 'js/scenes/GameScene.js')).href); +const { questDef } = await import(pathToFileURL(join(root, 'js/quests/QuestState.js')).href); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +function makeScene(quests) { + const scene = Object.create(GameScene.prototype); + scene.triggers = []; + scene.comms = { trigger: (n) => scene.triggers.push(n) }; + scene.questState = {}; + scene._questAnnounced = new Set(); + scene.questSnapshot = () => ({ categories: [], quests }); + scene.playSfx = () => {}; + scene.consoleToast = () => {}; + scene.questWindow = null; + return scene; +} + +const q = (id, complete, claimed) => ({ id, title: id, complete, claimed, checks: [] }); + +// the data's own trigger name — the seam reads it off the quest, and the +// test asserts the same read (a scene-side string would still pass the +// arming, but not this: the expectation is the quest's own field) +const EXPECTED = String(questDef('pirate_problems')?.payoffTrigger ?? '').trim(); +check('the data declares the payoff trigger (pirate_problems.payoffTrigger)', EXPECTED.length > 0); + +// 1 — complete + unclaimed: the READY moment arms the payoff, once +{ + const s = makeScene([q('pirate_problems', true, false)]); + s._questRefresh(); + check('complete + unclaimed arms the quest\'s payoff trigger', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED])); + s._questRefresh(); + check('the once-per-quest announcement keeps a second arm out', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED])); +} + +// 2 — complete + claimed: no READY moment, no arm +{ + const s = makeScene([q('pirate_problems', true, true)]); + s._questRefresh(); + check('complete + claimed arms nothing', s.triggers.length === 0); +} + +// 3 — incomplete: the reward is not on the table yet +{ + const s = makeScene([q('pirate_problems', false, false)]); + s._questRefresh(); + check('an incomplete quest arms nothing', s.triggers.length === 0); +} + +// 4 — a quest with no payoffTrigger (the starter): the READY moment +// stays silent (the chime + the toast are all it gets) +{ + const s = makeScene([q('start_your_journey', true, false)]); + s._questRefresh(); + check('a quest without a payoffTrigger arms nothing', s.triggers.length === 0); +} + +// 5 — mixed ledger: exactly the one trigger +{ + const s = makeScene([ + q('start_your_journey', true, false), + q('pirate_problems', true, false), + q('leaving_the_nest', false, false), + ]); + s._questRefresh(); + check('a mixed ledger arms exactly the one trigger', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED])); +} + +if (failures > 0) { console.error(`\n${failures} quest-payoff check(s) failed`); process.exit(1); } +console.log('\nquest-payoff: all checks passed'); diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs index 54f4492..ca54c5f 100644 --- a/dev/quests.test.mjs +++ b/dev/quests.test.mjs @@ -280,6 +280,19 @@ for (const c of starter?.checks ?? []) { check('pirates: the trestleQ4 beats are from Trestle, type ack, once', q4.every((c) => c.from === 'trestle' && c.type === 'ack' && c.once === true)); check('pirates: the trestleQ4 beats carry text + a real audio file on disk', q4.every((c) => typeof c.text === 'string' && c.text.length > 0 && typeof c.audio === 'string' && c.audio.length > 0 && fs.existsSync(join(root, c.audio)))); + // The PAYOFF — the moment the quest becomes COMPLETE + UNCLAIMED (all + // checks standing, the reward on the table), Trestle's two-beat + // sign-off (the job boards' unlock + the level-5 hook): the trigger is + // the quest's own (payoffTrigger — GameScene._questRefresh's + // once-per-quest READY pass arms it, the same moment the QUEST READY + // toast + chime fire; the comms' once-ledger keeps the pair a + // one-shot across saves). + check('pirates: the quest declares a payoff comm trigger', typeof q?.payoffTrigger === 'string' && q.payoffTrigger.length > 0); + const payoff = (comms?.comms ?? []).filter((c) => c.trigger === q?.payoffTrigger); + check('pirates: the payoff trigger holds exactly two comms', payoff.length === 2); + check('pirates: the payoff beats are from Trestle, type ack, once', payoff.every((c) => c.from === 'trestle' && c.type === 'ack' && c.once === true)); + check('pirates: the payoff beats carry text + a real audio file on disk', + payoff.every((c) => typeof c.text === 'string' && c.text.length > 0 && typeof c.audio === 'string' && c.audio.length > 0 && fs.existsSync(join(root, c.audio)))); // The COMBAT step — the pirate_ambush wave script (data/combat.json): // ONE rolling wave of SIX frame-0 raiders (EnemyTypes: raider = the // sheet's frame 0), three at a time, each kill feeding a replacement. diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 1130249..8420260 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -5839,8 +5839,14 @@ export class GameScene extends Phaser.Scene { /** * A quest-relevant event happened (research completed, ore mined, a * build finished, a world discovered, a reward claimed). Announce each - * quest that just became claimable ONCE (the console's voice), and kick - * the open console's poll so it repaints without waiting the interval. + * quest that just became claimable ONCE (the console's voice) and arm + * the quest's PAYOFF comms on that same READY moment (the mission + * complete, the reward on the table — data/quests.json → the quest's + * payoffTrigger → data/comms.json; 'Pirate Problems' declares + * 'trestleQ4Payoff': Trestle's two-beat sign-off, trestle-q4-4/5 on + * the job boards + the level-5 hook — the comms' once-ledger keeps a + * loaded save's re-arm honest, the unplayed beats only), and kick the + * open console's poll so it repaints without waiting the interval. */ _questRefresh() { if (!this.questState) return; @@ -5850,6 +5856,12 @@ export class GameScene extends Phaser.Scene { if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) { this._questAnnounced.add(q.id); this.playSfx('discovery'); // the READY chime — the 'something new is here' voice, once per quest + // The quest's PAYOFF comms — the trigger is the quest's own + // (data/quests.json → payoffTrigger), so a quest with no + // payoff stays silent; the hub's FIFO orders the beats and the + // comms' played-ledger keeps the chain a one-shot. + const trig = String(questDef(q.id)?.payoffTrigger ?? '').trim(); + if (trig) this.comms?.trigger(trig); const t = config.get('quests.readyToast', {}); this.consoleToast( String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')