/** * The 'Special Delivery' payoff seam test (dev tool, run with Node — no * browser): * * node dev/delivery-launch.test.mjs * * Two seams, pinned here: * * A — 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. The capital departure also records the * story trail (launchedFromCapital) — but NO LONGER sets the * destination (the route moved to the bar conversation — B). Pinned: * - 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 departure still records the trail (launchedFromCapital) * and refreshes the quest ledger — and sets NO destination (a * post-quest visit to the capital — a 'Pirate Problems' return leg — * leaves the player's destination alone); * - the defensive no-op (already at home) still leaves the arm in * place. * * B — the delivery's ROUTE (the destination → the home world): owned by * the quest's bar conversation (data/vignettes.json → * special_delivery_bar_meeting — the DONE press, * GameScene.onVignetteComplete's beat 3): it lands only while the quest * is HELD (granted, unclaimed — the scene's own gate already keeps it * playing only then, GameScene._specialDeliveryHeld re-reads it as * belt-and-braces). Pinned: * - the bar scene's DONE press routes home (and stands the hold + * handoff beats + the ledger refresh) while the quest is held; * - the same DONE press sets NO destination once the quest is claimed * (or was never granted, or belongs to another quest). * * (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, records the capital's trail without routing; GameScene. // onVignetteComplete routes the delivery (home) only while the quest is // held. 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; } // A — 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); } // A — 2: not delivered: no arm { const s = makeScene({ delivered: false }); s.onSurfaceDeparture('SomePlanet'); check('pre-delivery departure arms nothing', s.triggers.length === 0); } // A — 3: capital departure still records the trail — but NO LONGER // routes (the delivery's route moved to the bar conversation — B) { 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 trail', s.story.launchedFromCapital === true); check('capital departure sets no destination (the bar scene owns the route)', s.destination === undefined); check('capital departure still refreshes the quest ledger', s.questRefreshes === 1); } // A — 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); } // B — the bar scene's DONE press (GameScene.onVignetteComplete) is what // routes the delivery — and only while the quest is HELD. function makeBarScene({ granted, claimed }) { const s = makeScene({ delivered: false }); s.questState = { isGranted: (id) => id === 'special_delivery' && granted, isClaimed: (id) => id === 'special_delivery' && claimed, }; const items = []; s.ship = { stats: { cargoHold: 0 }, cargo: { items, room: 4, has: (id) => items.includes(id), add: (id) => { items.push(id); return 1; }, }, setCargoHold: (n) => { s.ship.stats.cargoHold = n; return n; }, }; s.refreshCreditsHud = () => {}; return s; } const barVig = { id: 'special_delivery_bar_meeting', location: 'bar', quest: 'special_delivery', step: 0, effects: { cargoHold: 4 }, lines: [], }; // B — 1: quest held (granted, unclaimed): the DONE press routes home, // alongside the hold + handoff beats { const s = makeBarScene({ granted: true, claimed: false }); const beat = s.onVignetteComplete(barVig); check('bar scene (quest held) stands the hold + handoff beats', beat.upgradedTo === 4 && beat.handoff === true); check('bar scene (quest held) routes home', JSON.stringify(s.destination) === JSON.stringify({ id: 'sys-home', via: 'home' })); check('bar scene (quest held) marks the routed beat', beat.routed === true); check('bar scene (quest held) refreshes the quest ledger', s.questRefreshes === 1); } // B — 2: quest claimed (the scene outlived the quest — belt-and-braces): // the beats still stand, but the destination is left alone { const s = makeBarScene({ granted: true, claimed: true }); const beat = s.onVignetteComplete(barVig); check('bar scene (quest claimed) still stands the beats', beat.upgradedTo === 4 && beat.handoff === true); check('bar scene (quest claimed) sets no destination', s.destination === undefined && beat.routed === false); } // B — 3: quest not granted (a drift the gate should have caught): same // no-routing answer { const s = makeBarScene({ granted: false, claimed: false }); s.onVignetteComplete(barVig); check('bar scene (quest not granted) sets no destination', s.destination === undefined); } // B — 4: a scene for ANOTHER quest never routes the delivery { const s = makeBarScene({ granted: true, claimed: false }); s.onVignetteComplete({ ...barVig, id: 'other_quest_bar', quest: 'pirate_problems' }); check('a non-delivery scene sets no destination', 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');