/** * QuestWindow open-selection test (dev tool, run with Node — no browser): * * node dev/quest-window.test.mjs * * The deck's QUESTS button opens the console (js/ui/QuestWindow.js) with * a FRESH-open default: the detail plate lands on the run's PRIORITY * quest — the one the tracker HUD features, the story quest the player * is actively engaged in (QuestState.resolvePriority, the tracker's * own seam — GameScene/SurfaceScene wire it in as o.resolvePriority) * with its tab activated. Pins the contract: * * - questOpenLanding (pure): resolves over the ACTIVE quests * (held + unclaimed) via the ledger's resolver — a resolver that * names a claimed/unknown quest or throws degrades to null (the * tab's first quest stands in), and with no resolver nothing lands; * - the REAL QuestWindow (a stubbed scene — the quest-tracker.test.mjs * pattern) — a fresh open() lands on the player's SET priority (a * side-tab priority activates the side tab), on the AUTO priority * (the first active story quest) when the player set none, and * falls back to the active tab's first quest when the priority is * CLEARED (the pre-change default — claimed quests included, the * window never lands on nothing); * - openAt() (the tracker's SHOW MORE / VIEW QUEST) always beats the * priority — an explicit target lands where it is asked. */ 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 file = (p) => pathToFileURL(join(__dirname, '../js', p)).href; // --- Stub just enough of Phaser for the window's class + build pass ----- class ContainerStub { constructor(scene, x = 0, y = 0) { this.scene = scene; this.x = x; this.y = y; this.alpha = 1; this.visible = true; this.childrenList = []; } add(o) { if (Array.isArray(o)) o.forEach((c) => this.childrenList.push(c)); else this.childrenList.push(o); return this; } setVisible(v) { this.visible = v; return this; } setAlpha(a) { this.alpha = a; return this; } setDepth() { return this; } setScrollFactor() { return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } destroy(recursive) { if (this.destroyed) return; this.destroyed = true; if (recursive) for (const c of this.childrenList) c.destroy?.(); } } class RectStub { constructor(x, y, w, h) { 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; } } class GameObjectStub { constructor() { this.x = 0; this.y = 0; this.alpha = 1; this.visible = true; this.handlers = {}; } setScrollFactor() { return this; } setDepth() { return this; } setAlpha(a) { this.alpha = a; return this; } setVisible(v) { this.visible = v; return this; } setOrigin() { return this; } setBlendMode() { return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } setDisplaySize() { return this; } setColor(c) { this.color = c; return this; } setText(s) { this.text = s; return this; } setVolume() { return this; } setLoop() { return this; } on(ev, fn) { (this.handlers[ev] ??= []).push(fn); return this; } resume() { return this; } pause() { return this; } destroy() { this.destroyed = true; return this; } } class FakeGraphics extends GameObjectStub { clear() { return this; } lineStyle() { return this; } fillStyle() { return this; } fillRect() { return this; } fillPoints() { return this; } strokePoints() { return this; } strokeRect() { return this; } lineBetween() { return this; } fillCircle() { return this; } fillTriangle() { return this; } strokeCircle() { return this; } setInteractive(o) { this.input = o; return this; } } class FakeText extends GameObjectStub { constructor(x, y, str, style) { super(); this.x = x; this.y = y; this.text = str; this.color = style?.color ?? null; } } const PhaserStub = { GameObjects: { Container: ContainerStub, Text: FakeText, Image: GameObjectStub }, Geom: { Rectangle: RectStub }, Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } }, BlendModes: { ADD: 2 }, Math: { FloatBetween: (a, b) => a + Math.random() * (b - a) }, }; globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this globalThis.document = { // the scanline/sweep tiles (canvas generation) — the fakes don't paint createElement: () => ({ width: 0, height: 0, getContext: () => ({ fillStyle: null, fillRect() {}, createLinearGradient: () => ({ addColorStop() {} }), }), }), }; // --- Load the real config (data/*.json) into the config singleton -------- const { config } = await import(file('config/Config.js')); const dataDir = join(__dirname, '../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 { QuestState, questCheckKey } = await import(file('quests/QuestState.js')); const { QuestWindow, questOpenLanding } = await import(file('ui/QuestWindow.js')); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // A stub scene with just enough plumbing for the window's build + open. function makeScene(o = {}) { const add = { existing() {}, graphics: () => new FakeGraphics(), text: (x, y, str, style) => new FakeText(x, y, str, style), circle: () => new GameObjectStub(), tileSprite: () => new GameObjectStub(), image: () => new GameObjectStub(), video: () => new GameObjectStub(), }; return { add, scale: { width: 1280, height: 800 }, time: { now: 100000, delayedCall() {} }, tweens: { add(t) { t.onComplete?.(); return {}; } }, textures: { exists: () => false, addCanvas() {} }, ...o, }; } // ---------------------------------------------------------------------- // 1. questOpenLanding — the pure fresh-open resolver // ---------------------------------------------------------------------- { const Q = (id, category, over = {}) => ({ id, category, claimed: false, complete: false, checks: [{ done: false }], ...over }); const active = [Q('a_main', 'main'), Q('b_main', 'main'), Q('c_side', 'side')]; const allClaimed = active.map((q) => ({ ...q, claimed: true })); check('landing: no quests → null', questOpenLanding([], (ids) => ids[0]) === null); check('landing: undefined → null', questOpenLanding(undefined, (ids) => ids[0]) === null); check('landing: nothing ACTIVE (all claimed) → null', questOpenLanding(allClaimed, (ids) => 'a_main') === null); check('landing: the resolver\u2019s pick lands (id + its category)', (() => { const r = questOpenLanding(active, (ids) => 'b_main'); return r && r.id === 'b_main' && r.category === 'main'; })()); check('landing: a SIDE pick activates the side tab', (() => { const r = questOpenLanding(active, (ids) => 'c_side'); return r && r.id === 'c_side' && r.category === 'side'; })()); check('landing: claimed quests are never offered (no active set → the resolver is never consulted)', (() => { let seen = 'unset'; const r = questOpenLanding(allClaimed, (ids) => { seen = ids; return 'a_main'; }); return r === null && seen === 'unset'; })()); check('landing: a pick of a CLAIMED-only quest degrades to null', (() => { const mixed = [Q('a_main', 'main', { claimed: true }), Q('b_main', 'main')]; const r = questOpenLanding(mixed, (ids) => 'a_main'); return r === null; })()); check('landing: a pick the data does not define degrades to null', questOpenLanding(active, (ids) => 'no_such_quest') === null); check('landing: a throwing resolver degrades to null (never a crash)', (() => { try { return questOpenLanding(active, () => { throw new Error('boom'); }) === null; } catch { return false; } })()); check('landing: no resolver (the scene did not wire it) → null (the tab\u2019s first stands in)', questOpenLanding(active, null) === null && questOpenLanding(active) === null); } // ---------------------------------------------------------------------- // 2. The REAL window — a fresh open lands on the run's priority quest // ---------------------------------------------------------------------- { // The real data's three main quests + a synthetic side quest (the // player can feature it — the window must land there on ITS tab). const SIDE_X = { id: 'win_side_x', category: 'side', title: 'Test Side X', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 10, label: 'MINE 10' }], reward: { minerals: 10 } }; const savedConfig = config.data; config.init({ ...config.data, quests: { ...configData.quests, quests: [...(configData.quests?.quests ?? []), SIDE_X] } }); try { const SNAPSHOT = (over = {}) => ({ categories: [ { id: 'main', label: 'Main Story' }, { id: 'side', label: 'Side Quests' }, ], quests: [ { id: 'start_your_journey', category: 'main', title: 'START YOUR JOURNEY', claimed: false, complete: false, checks: [{ done: false }] }, { id: 'leaving_the_nest', category: 'main', title: 'LEAVING THE NEST', claimed: false, complete: false, checks: [{ done: false }] }, { id: 'win_side_x', category: 'side', title: 'TEST SIDE X', claimed: false, complete: false, checks: [{ done: true }] }, ], ...over, }); const SIDE_KEY = questCheckKey({ type: 'mineralsMined', amount: 10, label: 'MINE 10' }); const claimSide = (st) => { st.markCheck('win_side_x', SIDE_KEY, 'synthetic'); st.claim('win_side_x'); // a REAL claim (all its checks are done) }; const make = (ledger, snap) => { const w = new QuestWindow(makeScene(), { getSnapshot: () => snap ?? SNAPSHOT(), resolvePriority: (ids) => ledger.resolvePriority(ids), getPriority: () => ledger.priority ?? null, }); return w; }; const ledger = (fns) => { const st = new QuestState(); st.give('start_your_journey'); st.give('leaving_the_nest'); st.give('win_side_x'); fns?.(st); return st; }; // The player's SET priority — the story quest they are engaged in — // lands on a fresh open, ahead of the data-order first quest. { const st = ledger((x) => x.setPriority('leaving_the_nest')); const w = make(st); w.open(); check('window: a fresh open lands on the player\u2019s SET priority quest', w.selected === 'leaving_the_nest' && w.openState === 'open'); check('window: the priority\u2019s tab is active', w._activeTab === 'main'); w.destroy(); } // A SIDE-tab priority — the window follows it onto its own tab. { const st = ledger((x) => x.setPriority('win_side_x')); const w = make(st); w.open(); check('window: a side-quest priority lands on the SIDE tab', w.selected === 'win_side_x' && w._activeTab === 'side'); w.destroy(); } // No priority set — the ledger's AUTO-assignment (the first active // story quest, the tracker's own rule) is the landing quest. { const st = ledger(); const w = make(st); w.open(); check('window: no priority — the AUTO priority (first active story quest) lands', w.selected === 'start_your_journey' && w._activeTab === 'main'); w.destroy(); } // A CLEARED priority — the player declined a featured quest: the // pre-change default stands (the active tab's first quest). { const st = ledger((x) => x.clearPriority()); const w = make(st); w.open(); check('window: a CLEARED priority falls back to the tab\u2019s first quest (the old default)', w.selected === 'start_your_journey' && w._activeTab === 'main'); w.destroy(); } // openAt() (the tracker's SHOW MORE / VIEW QUEST) beats the priority. { const st = ledger((x) => x.setPriority('leaving_the_nest')); const w = make(st); w.openAt('win_side_x'); w.open(); check('window: openAt\u2019s explicit target beats the priority', w.selected === 'win_side_x' && w._activeTab === 'side'); w.destroy(); } // A CLAIMED quest is not active — the resolver walks the active // set and the window lands on the next engaged quest (never the // claimed one). { const st = ledger((x) => { x.setPriority('win_side_x'); claimSide(x); // the claim drops its own priority + auto-assigns }); const snap = SNAPSHOT({ quests: [ { id: 'start_your_journey', category: 'main', claimed: false, complete: false, checks: [{ done: false }] }, { id: 'leaving_the_nest', category: 'main', claimed: false, complete: false, checks: [{ done: false }] }, { id: 'win_side_x', category: 'side', claimed: true, complete: true, checks: [{ done: true }] }, ], }); const w = make(st, snap); w.open(); check('window: a CLAIMED quest is never the landing (the priority advances)', w.selected === 'start_your_journey' && w._activeTab === 'main'); w.destroy(); } // Nothing active (every held quest claimed) — the fallback is the // tab's first quest (the pre-change behavior; the window shows // something, never a dead plate). { const st = ledger((x) => claimSide(x)); const snap = SNAPSHOT({ quests: [ { id: 'start_your_journey', category: 'main', claimed: true, complete: true, checks: [{ done: true }] }, { id: 'leaving_the_nest', category: 'main', claimed: true, complete: true, checks: [{ done: true }] }, { id: 'win_side_x', category: 'side', claimed: true, complete: true, checks: [{ done: true }] }, ], }); const w = make(st, snap); w.open(); check('window: nothing active — the tab\u2019s first quest stands (no dead plate)', w.selected === 'start_your_journey' && w._activeTab === 'main'); w.destroy(); } } finally { config.init(savedConfig); // restore the real data set } } console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);