Add special-delivery launch comms assets and seam test

- Stage the payoff audio (carol q3 05-07, oac q3 01) and video assets
  (arid-surface-02, carol character) backing the launch-after-sale beat.
- Add dev/delivery-launch.test.mjs pinning GameScene.onSurfaceDeparture:
  arms 'deliveredLaunch' once on post-delivery departure from any world,
  stays quiet pre-delivery, preserves the capital leg (launchedFromCapital,
  home routing, quest refresh), and keeps the arm firing on the defensive
  already-home no-op.
This commit is contained in:
Brian Fertig 2026-09-13 15:29:00 -06:00
parent 942de11dde
commit f38b3c0b86
7 changed files with 127 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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