Compare commits
2 Commits
942de11dde
...
8ff7f0ee12
| Author | SHA1 | Date |
|---|---|---|
|
|
8ff7f0ee12 | |
|
|
f38b3c0b86 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* The 'Special Delivery' payoff seam test (dev tool, run with Node — no
|
||||
* browser):
|
||||
*
|
||||
* node dev/delivery-launch.test.mjs
|
||||
*
|
||||
* The launch-after-sale beat (data/comms.json → trigger 'deliveredLaunch'
|
||||
* — carol-delivered-1/2/3 → oac-delivered-1, the four ack comms the
|
||||
* player gets when they LAUNCH after selling the special package on the
|
||||
* home world): the scene-side seam is GameScene.onSurfaceDeparture
|
||||
* (SurfaceScene.finishTakeoff → it), which arms the chain when the run's
|
||||
* delivery witness (story.delivered — set by the shop's CONFIRM of the
|
||||
* package's sale, GameScene.onQuestPackageSold) stands. The comms' own
|
||||
* once-ledger (js/comms/CommsHub.js) keeps the chain a one-shot, so the
|
||||
* seam may arm on every post-delivery departure — a later re-arm is a
|
||||
* no-op. Pinned here:
|
||||
* - a post-delivery departure arms 'deliveredLaunch' exactly once
|
||||
* (from any world — the sale's world is the home world, not the
|
||||
* capital);
|
||||
* - a pre-delivery departure arms nothing;
|
||||
* - the capital leg still stands alongside the new arm (the departure
|
||||
* records launchedFromCapital, routes the compass home, and refreshes
|
||||
* the quest ledger — the pre-existing behavior this beat rides on);
|
||||
* - the defensive no-op (already at home) still leaves the arm in place.
|
||||
* (The config side of the same beat — the roster, the chain's order, and
|
||||
* the verbatim text — is pinned in dev/comms.test.mjs.)
|
||||
*/
|
||||
// GameScene.onSurfaceDeparture arms 'deliveredLaunch' when story.delivered
|
||||
// stands, keeps the capital leg, and stays quiet otherwise.
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { 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);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; };
|
||||
|
||||
function makeScene({ delivered, capital = true }) {
|
||||
const scene = Object.create(GameScene.prototype);
|
||||
scene.triggers = [];
|
||||
scene.comms = { trigger: (n) => { scene.triggers.push(n); } };
|
||||
scene.galaxy = capital
|
||||
? { homeSystemId: 'sys-home', neutralCapitalTarget: () => ({ systemId: 'sys-cap', objectId: 'CapitalWorld' }) }
|
||||
: { homeSystemId: 'sys-home', neutralCapitalTarget: () => ({ systemId: 'sys-cap', objectId: 'CapitalWorld' }) };
|
||||
scene.systemRecord = { id: 'sys-elsewhere' };
|
||||
scene.story = delivered ? { delivered: true, launchedFromCapital: false } : { delivered: false, launchedFromCapital: false };
|
||||
scene.setDestination = (id, via) => { scene.destination = { id, via }; };
|
||||
scene.questRefreshes = 0;
|
||||
scene._questRefresh = () => { scene.questRefreshes++; };
|
||||
return scene;
|
||||
}
|
||||
|
||||
// 1 — post-delivery departure (any world): the payoff chain arms
|
||||
{
|
||||
const s = makeScene({ delivered: true });
|
||||
s.onSurfaceDeparture('SomePlanet');
|
||||
check('delivered departure arms deliveredLaunch', JSON.stringify(s.triggers) === JSON.stringify(['deliveredLaunch']));
|
||||
check('delivered departure (non-capital) does not claim the capital leg', s.story.launchedFromCapital === false);
|
||||
}
|
||||
|
||||
// 2 — not delivered: no arm
|
||||
{
|
||||
const s = makeScene({ delivered: false });
|
||||
s.onSurfaceDeparture('SomePlanet');
|
||||
check('pre-delivery departure arms nothing', s.triggers.length === 0);
|
||||
}
|
||||
|
||||
// 3 — capital leg still stands (the old behavior, alongside the new arm)
|
||||
{
|
||||
const s = makeScene({ delivered: true });
|
||||
s.onSurfaceDeparture('CapitalWorld');
|
||||
check('capital departure still arms deliveredLaunch (once)', JSON.stringify(s.triggers) === JSON.stringify(['deliveredLaunch']));
|
||||
check('capital departure still records the leg', s.story.launchedFromCapital === true);
|
||||
check('capital departure still routes home', JSON.stringify(s.destination) === JSON.stringify({ id: 'sys-home', via: 'home' }));
|
||||
check('capital departure still refreshes the quest ledger', s.questRefreshes === 1);
|
||||
}
|
||||
|
||||
// 4 — already home: the arm still fires (the player left home), the leg is a no-op
|
||||
{
|
||||
const s = makeScene({ delivered: true });
|
||||
s.systemRecord = { id: 'sys-home' };
|
||||
s.onSurfaceDeparture('CapitalWorld');
|
||||
check('home-departure arms deliveredLaunch', JSON.stringify(s.triggers) === JSON.stringify(['deliveredLaunch']));
|
||||
check('home-departure sets no destination (defensive no-op)', s.destination === undefined);
|
||||
}
|
||||
|
||||
if (failures > 0) { console.error(`\n${failures} delivery-launch check(s) failed`); process.exit(1); }
|
||||
console.log('\ndelivery-launch: all checks passed');
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue