/** * The 'Pirate Problems' payoff seam test (dev tool, run with Node — no * browser): * * node dev/quest-payoff.test.mjs * * The seam (GameScene._questRefresh — the once-per-quest READY pass): * the moment a quest becomes COMPLETE + UNCLAIMED (all its checks * standing, the reward claimable), the scene announces it (the READY * chime + the console's toast) AND arms the quest's PAYOFF comms — the * trigger is the quest's own (data/quests.json → the quest's * payoffTrigger → data/comms.json). For 'Pirate Problems' (Trestle's * warning heard, the ambush cleared, the capital's return — the reward * on the table), that is trigger 'trestleQ4Payoff': the trestle-q4-4 → * trestle-q4-5 ack pair (the job-board unlock + the level-5 hook). * Pinned: * - a complete + unclaimed 'pirate_problems' arms its payoff trigger * exactly once (the trigger name is the DATA's — * pirate_problems.payoffTrigger, not a scene-side string); * - the once-per-quest announcement keeps a second arm out (the hub's * played-ledger is the cross-save layer — this is the scene's); * - a complete + CLAIMED quest, an incomplete quest, and a quest with * no payoffTrigger (the starter) all arm nothing; * - a mixed ledger arms exactly the one trigger. * * (The config side of the pair — the roster, the order, the verbatim * text, the clips on disk — is pinned in dev/comms.test.mjs; the * quest's wiring, in dev/quests.test.mjs.) */ import { pathToFileURL, 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); const { questDef } = await import(pathToFileURL(join(root, 'js/quests/QuestState.js')).href); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; function makeScene(quests) { const scene = Object.create(GameScene.prototype); scene.triggers = []; scene.comms = { trigger: (n) => scene.triggers.push(n) }; scene.questState = {}; scene._questAnnounced = new Set(); scene.questSnapshot = () => ({ categories: [], quests }); scene.playSfx = () => {}; scene.consoleToast = () => {}; scene.questWindow = null; return scene; } const q = (id, complete, claimed) => ({ id, title: id, complete, claimed, checks: [] }); // the data's own trigger name — the seam reads it off the quest, and the // test asserts the same read (a scene-side string would still pass the // arming, but not this: the expectation is the quest's own field) const EXPECTED = String(questDef('pirate_problems')?.payoffTrigger ?? '').trim(); check('the data declares the payoff trigger (pirate_problems.payoffTrigger)', EXPECTED.length > 0); // 1 — complete + unclaimed: the READY moment arms the payoff, once { const s = makeScene([q('pirate_problems', true, false)]); s._questRefresh(); check('complete + unclaimed arms the quest\'s payoff trigger', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED])); s._questRefresh(); check('the once-per-quest announcement keeps a second arm out', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED])); } // 2 — complete + claimed: no READY moment, no arm { const s = makeScene([q('pirate_problems', true, true)]); s._questRefresh(); check('complete + claimed arms nothing', s.triggers.length === 0); } // 3 — incomplete: the reward is not on the table yet { const s = makeScene([q('pirate_problems', false, false)]); s._questRefresh(); check('an incomplete quest arms nothing', s.triggers.length === 0); } // 4 — a quest with no payoffTrigger (the starter): the READY moment // stays silent (the chime + the toast are all it gets) { const s = makeScene([q('start_your_journey', true, false)]); s._questRefresh(); check('a quest without a payoffTrigger arms nothing', s.triggers.length === 0); } // 5 — mixed ledger: exactly the one trigger { const s = makeScene([ q('start_your_journey', true, false), q('pirate_problems', true, false), q('leaving_the_nest', false, false), ]); s._questRefresh(); check('a mixed ledger arms exactly the one trigger', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED])); } if (failures > 0) { console.error(`\n${failures} quest-payoff check(s) failed`); process.exit(1); } console.log('\nquest-payoff: all checks passed');