Add Pirate Problems quest payoff comms and trigger arming

- Wire GameScene._questRefresh to arm the quest's own `payoffTrigger` on the once-per-quest READY moment, so Trestle's two-beat sign-off (job boards + level-5 hook) plays when 'Pirate Problems' becomes complete + unclaimed.
- Add trestle-q4-4/5 ack comms (data/comms.json) and declare `payoffTrigger: "trestleQ4Payoff"` on the quest (data/quests.json).
- Reorder stationVideos in data/landing.json to put ss-02 before ss-01.
- Extend dev/comms.test.mjs roster count and add payoff pair checks; add new dev/quest-payoff.test.mjs pinning the arming seam; extend dev/quests.test.mjs with payoff wiring checks.
This commit is contained in:
Brian Fertig 2026-09-19 19:49:30 -06:00
parent d9ba476d57
commit 4595950fb9
7 changed files with 253 additions and 8 deletions

File diff suppressed because one or more lines are too long

View File

@ -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" }
]
}

View File

@ -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",

View File

@ -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)) ===

144
dev/quest-payoff.test.mjs Normal file
View File

@ -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');

View File

@ -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.

View File

@ -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')