Add 'Special Delivery' payoff comms chain on surface departure

- Register Carol as a comm sender with her video clip and role
- Add four ack beats (carol-delivered-1/2/3, oac-delivered-1) under the new 'deliveredLaunch' trigger, fired when the player launches after selling the package
- Arm the chain in GameScene.onSurfaceDeparture once story.delivered is set, with the once-ledger keeping it a one-shot
- Update comms tests to cover Carol's registration, the four-beat sequence ordering, and re-arm deduplication
This commit is contained in:
Brian Fertig 2026-09-13 15:29:21 -06:00
parent f38b3c0b86
commit 8ff7f0ee12
3 changed files with 174 additions and 16 deletions

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,11 @@
* trestle-meetup → trestle-quest on firstWorld,
* trestle-brief-1 → trestle-brief-2 → trestle-brief-3 (the
* 'Leaving the Nest' three-brief sequence, ack) on trestleBrief,
* trestle-arrive-1 → trestle-arrive-2 (ack) on trestleArrival);
* trestle-arrive-1 → trestle-arrive-2 (ack) on trestleArrival,
* carol-delivered-1 → carol-delivered-2 → carol-delivered-3 →
* oac-delivered-1 (the 'Special Delivery' payoff — the launch back
* into space after the package's home-world sale, ack) on
* deliveredLaunch, with Carol registered as a sender);
* - the reserved triggers are RESERVED (no comm uses them yet);
* - normalizeComm drops what can't be shown (bad type / no trigger /
* no text for a box / no speech for an audio comm / unknown sender),
@ -76,8 +80,12 @@ check('OAC is registered (label + role + clip)', !!oac && oac.label === 'O.A.C.'
const trestle = charEntry(cfg, 'trestle');
check('Trestle is registered (label + role + his own clip)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role && trestle.video === 'assets/videos/characters/trestle.mp4');
const carol = charEntry(cfg, 'carol');
check('Carol is registered (label + role + her own clip)', !!carol && carol.label === 'CAROL' && !!carol.role && carol.video === 'assets/videos/characters/carol.mp4');
check('Carol\'s space clip ships on disk', existsSync(join(root, 'assets/videos/characters/carol.mp4')));
const roster = listComms(cfg);
check('the roster holds exactly the shipped comms', roster.length === 20);
check('the roster holds exactly the shipped comms', roster.length === 24);
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
const intro = byId['oac-intro'];
check(
@ -337,6 +345,70 @@ check('trestle-arrive-2: the bar/credits brief (Carol + the 200-credit top-up)',
check('trestle-arrive-2: speaks trestle/q2-05.mp3', byId['trestle-arrive-2']?.audio === 'assets/speech/trestle/q2-05.mp3');
check('trestle-arrive-2: the clip ships on disk', existsSync(join(root, byId['trestle-arrive-2']?.audio ?? '')));
// The 'Special Delivery' payoff — the launch back into space after the
// package's home-world sale (GameScene.onSurfaceDeparture arms
// 'deliveredLaunch' when the run's delivery witness, story.delivered,
// stands; the comms' once-ledger keeps the four-beat chain a one-shot —
// a later re-arm is a no-op). Carol's thanks + trade sign-off, then
// OAC's confirmation that the buy/sell codes are live on every world.
check(
'carol-delivered-1: the thanks, beat 1/4 (ack, deliveredLaunch, carol, once)',
!!byId['carol-delivered-1'] &&
byId['carol-delivered-1'].from === 'carol' &&
byId['carol-delivered-1'].type === 'ack' &&
byId['carol-delivered-1'].trigger === 'deliveredLaunch' &&
byId['carol-delivered-1'].once === true,
);
check('carol-delivered-1: the thanks (verbatim)', byId['carol-delivered-1']?.text === 'Thank you. I appreciate you taking care of that for me.');
check('carol-delivered-1: speaks carol/q3-05.mp3', byId['carol-delivered-1']?.audio === 'assets/speech/carol/carol-q3-05.mp3');
check('carol-delivered-1: the clip ships on disk', existsSync(join(root, byId['carol-delivered-1']?.audio ?? '')));
check('carol-delivered-1: lands after the take-off settles (delayMs > 0)', (byId['carol-delivered-1']?.delayMs ?? 0) > 0);
check(
'carol-delivered-2: the trade sign-off, beat 2/4 (ack, deliveredLaunch, once, immediate)',
!!byId['carol-delivered-2'] &&
byId['carol-delivered-2'].from === 'carol' &&
byId['carol-delivered-2'].type === 'ack' &&
byId['carol-delivered-2'].trigger === 'deliveredLaunch' &&
byId['carol-delivered-2'].once === true &&
(byId['carol-delivered-2']?.delayMs ?? 0) === 0,
);
check(
'carol-delivered-2: the unlock brief (the brief, verbatim)',
byId['carol-delivered-2']?.text === 'All shops in the galaxy are now fully unlocked for you. Trade is an important an often lucrative way to make money in the galaxy.',
);
check('carol-delivered-2: speaks carol/q3-06.mp3', byId['carol-delivered-2']?.audio === 'assets/speech/carol/carol-q3-06.mp3');
check('carol-delivered-2: the clip ships on disk', existsSync(join(root, byId['carol-delivered-2']?.audio ?? '')));
check(
'carol-delivered-3: the risk/yield hint, beat 3/4 (ack, deliveredLaunch, once, immediate)',
!!byId['carol-delivered-3'] &&
byId['carol-delivered-3'].from === 'carol' &&
byId['carol-delivered-3'].type === 'ack' &&
byId['carol-delivered-3'].trigger === 'deliveredLaunch' &&
byId['carol-delivered-3'].once === true &&
(byId['carol-delivered-3']?.delayMs ?? 0) === 0,
);
check(
'carol-delivered-3: the nuance brief (the brief, verbatim)',
byId['carol-delivered-3']?.text === 'The higher the risk, the higher the rewards. Long routes and different planet types have different yields on products. I\'ll leave it to you to figure out the nuance.',
);
check('carol-delivered-3: speaks carol/q3-07.mp3', byId['carol-delivered-3']?.audio === 'assets/speech/carol/carol-q3-07.mp3');
check('carol-delivered-3: the clip ships on disk', existsSync(join(root, byId['carol-delivered-3']?.audio ?? '')));
check(
'oac-delivered-1: the buy/sell confirmation, beat 4/4 (ack, deliveredLaunch, oac, once, immediate)',
!!byId['oac-delivered-1'] &&
byId['oac-delivered-1'].from === 'oac' &&
byId['oac-delivered-1'].type === 'ack' &&
byId['oac-delivered-1'].trigger === 'deliveredLaunch' &&
byId['oac-delivered-1'].once === true &&
(byId['oac-delivered-1']?.delayMs ?? 0) === 0,
);
check(
'oac-delivered-1: the codes brief (the brief, verbatim)',
byId['oac-delivered-1']?.text === "I've confirmed. We have buy and sell codes on all worlds in the galaxy. This is a big deal. We can make real credits now.",
);
check('oac-delivered-1: speaks oac/q3-01.mp3', byId['oac-delivered-1']?.audio === 'assets/speech/oac/oac-q3-01.mp3');
check('oac-delivered-1: the clip ships on disk', existsSync(join(root, byId['oac-delivered-1']?.audio ?? '')));
check(
'tetherResearch arms the pair in config order (minerals → fly-mine)',
JSON.stringify(commsForTrigger(cfg, 'tetherResearch').map((c) => c.id)) === JSON.stringify(['oac-minerals', 'oac-fly-mine']),
@ -349,6 +421,20 @@ check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComple
check('systemComplete arms the milestone comm', commsForTrigger(cfg, 'systemComplete').length === 1 && commsForTrigger(cfg, 'systemComplete')[0].id === 'oac-system-complete');
check('minerals200 arms the 200-mineral beat', commsForTrigger(cfg, 'minerals200').length === 1 && commsForTrigger(cfg, 'minerals200')[0].id === 'oac-tether2');
check('tetherL2Launch arms the launch-back pair in config order', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch').map((c) => c.id)) === JSON.stringify(['oac-relaunch', 'oac-explore']));
check(
'deliveredLaunch arms the payoff chain in config order (Carol ×3 → OAC)',
JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch').map((c) => c.id)) ===
JSON.stringify(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']),
);
check(
'a played carol-delivered-1 does not re-arm (the rest of the chain still does)',
JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch', new Set(['carol-delivered-1'])).map((c) => c.id)) ===
JSON.stringify(['carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']),
);
check(
'a fully played payoff chain re-arms nothing (the one-shot)',
commsForTrigger(cfg, 'deliveredLaunch', new Set(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1'])).length === 0,
);
check('firstWorld arms the trestle pair in config order', JSON.stringify(commsForTrigger(cfg, 'firstWorld').map((c) => c.id)) === JSON.stringify(['trestle-meetup', 'trestle-quest']));
check(
@ -420,9 +506,9 @@ warnOn = true;
// unknown senders are dropped by the ROSTER (listComms), not normalize
const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] };
const ghostList = listComms(withGhost);
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 20 && ghostList[0].id === 'oac-intro');
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 24 && ghostList[0].id === 'oac-intro');
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
check('drop: duplicate ids (first wins)', dupList.length === 20 && dupList[0].id === 'oac-intro');
check('drop: duplicate ids (first wins)', dupList.length === 24 && dupList[0].id === 'oac-intro');
warnOn = true;
// ----------------------------------------------------------------------

View File

@ -3088,9 +3088,12 @@ export class GameScene extends Phaser.Scene {
// built out (the scene's effects) and the
// special package lands in the hold;
// · onSurfaceDeparture — SurfaceScene.finishTakeoff (a real
// launch): the capital launch sets the
// destination back to the HOME WORLD (the
// delivery's route — the compass points it);
// launch): after the package's sale it
// arms the 'deliveredLaunch' comms chain
// (Carol ×3 → OAC's sign-off, the payoff);
// the capital launch sets the destination
// back to the HOME WORLD (the delivery's
// route — the compass points it);
// · onQuestPackageSold — the shop's CONFIRM of the package's sale:
// saleable on the home world ONLY (TradeGrid's
// gate), at zero credits — that sale is
@ -3226,17 +3229,36 @@ export class GameScene extends Phaser.Scene {
/**
* The surface TAKE OFF seam (SurfaceScene.finishTakeoff → here).
* 'Special Delivery' step 3's leg: launching off the NEUTRAL CAPITAL
* (the bar is done, the package is aboard) records the launch in the
* story ledger and sets the run's destination back to the HOME WORLD
* (the delivery's route — the compass marks it, and the REACHED toast
* fires on the home world's arrival via _checkDestinationReached).
* A launch off any other world is a no-op — the quest's auto-course
* earns itself only from the capital's departure.
* Two beats ride on it:
*
* 1 — the 'Special Delivery' PAYOFF (data/comms.json →
* 'deliveredLaunch'): the launch back into space AFTER the
* package's home-world sale — Carol's bar line set it up ('After
* you've done that, launch back into space and I'll contact you',
* data/vignettes.json → special_delivery_bar_meeting). Witnessed by
* the run's story ledger (story.delivered — set by the shop's
* CONFIRM of the package's sale, onQuestPackageSold), armed here
* on any departure once it stands; the comms' once-ledger keeps
* the four-beat chain (Carol ×3 → OAC's sign-off) a one-shot and
* any later re-arm a no-op. Queued while this scene still owns the
* run — the hub plays the chain once the flight world is back
* (the FIFO, each ack holding until its own ACKNOWLEDGE).
*
* 2 — 'Special Delivery' step 3's leg: launching off the NEUTRAL
* CAPITAL (the bar is done, the package is aboard) records the
* launch in the story ledger and sets the run's destination back
* to the HOME WORLD (the delivery's route — the compass marks it,
* and the REACHED toast fires on the home world's arrival via
* _checkDestinationReached). A launch off any other world is a
* no-op for this leg — the quest's auto-course earns itself only
* from the capital's departure.
*/
onSurfaceDeparture(worldName) {
if (this.story?.delivered === true) {
this.comms?.trigger('deliveredLaunch'); // the payoff chain — the once-ledger dedupes
}
if (!this.story || !this.isCapitalWorld(worldName)) return;
this.story.launchedFromCapital = true;
this.story.launchedFromCapital = true; // the leg is made
this._questRefresh(); // the delivery leg may have just completed
const homeId = this.galaxy?.homeSystemId;
if (!homeId || this.systemRecord?.id === homeId) return; // already home (defensive)