/** * 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');