145 lines
6.1 KiB
JavaScript
145 lines
6.1 KiB
JavaScript
/**
|
|
* 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');
|