diff --git a/assets/speech/carol/carol-q3-05.mp3 b/assets/speech/carol/carol-q3-05.mp3 new file mode 100644 index 0000000..bd13a6d Binary files /dev/null and b/assets/speech/carol/carol-q3-05.mp3 differ diff --git a/assets/speech/carol/carol-q3-06.mp3 b/assets/speech/carol/carol-q3-06.mp3 new file mode 100644 index 0000000..9007b49 Binary files /dev/null and b/assets/speech/carol/carol-q3-06.mp3 differ diff --git a/assets/speech/carol/carol-q3-07.mp3 b/assets/speech/carol/carol-q3-07.mp3 new file mode 100644 index 0000000..6f6627c Binary files /dev/null and b/assets/speech/carol/carol-q3-07.mp3 differ diff --git a/assets/speech/oac/oac-q3-01.mp3 b/assets/speech/oac/oac-q3-01.mp3 new file mode 100644 index 0000000..d8b5c18 Binary files /dev/null and b/assets/speech/oac/oac-q3-01.mp3 differ diff --git a/assets/videos/arid-surface-02.mp4 b/assets/videos/arid-surface-02.mp4 new file mode 100644 index 0000000..7472f03 Binary files /dev/null and b/assets/videos/arid-surface-02.mp4 differ diff --git a/assets/videos/characters/carol.mp4 b/assets/videos/characters/carol.mp4 new file mode 100644 index 0000000..a3dfea1 Binary files /dev/null and b/assets/videos/characters/carol.mp4 differ diff --git a/dev/delivery-launch.test.mjs b/dev/delivery-launch.test.mjs new file mode 100644 index 0000000..87f9829 --- /dev/null +++ b/dev/delivery-launch.test.mjs @@ -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');