/** * Quest tracker HUD test (dev tool, run with Node — no browser): * * node dev/quest-tracker.test.mjs * * Runs the REAL tracker (js/ui/QuestTrackerHud.js), the REAL MenuButton, * the REAL quest ledger (js/quests/QuestState.js) and the REAL * GameScene dossier lifecycle (createSystemHud / updateHud / * startExpand / startCollapse / toggleHud / questSnapshot / * setPriorityQuest / clearPriorityQuest / claimQuest) against stubbed * scene plumbing (tweens run instantly, time is manual — the * system-hud.test.mjs pattern), and pins the tracker's contract: * * - the HUD is HIDDEN until the dossier has FOLDED (auto-fold OR the * player's manual fold — the first fold arms it): it slides in from * offscreen-left (a single x tween, ~320 ms per quests.json); * - when the dossier RE-OPENS the tracker slides out FIRST (make room), * then the dossier unfolds — and a toggle mid-slide is ignored; * - always-on green "QUESTS" title (the #67e863 accent), grayed-out * "NONE TRACKED" when no quests are tracked; * - active = held + not claimed; titles stack MAIN STORY first, then * side quests, capped at 5 (quests.json → tracker.maxVisible) with a * SHOW MORE button (the first hidden quest) when there are more — * and the priority quest is ALWAYS in the list (it takes the last * slot when it would sit past the cap); * - the ONE priority quest (the ledger's resolvePriority): auto = the * first active story quest; the player's set/clear sticks (cleared * stays off until a grant/claim); claiming advances to the next; * its checklist renders UNDER its title and tracks LIVE (the real * GameScene.questSnapshot — research / mined / tether / discovery); * - a row click opens the row popup (SET PRIORITY / CLEAR PRIORITY + * VIEW QUEST); the button clicks fire the scene's actions; the * mining-menu click contract (contains / popupContains / * sourceContains / closedPopupAt) holds; * - the scene's SET PRIORITY / CLEAR PRIORITY toast + the console's * SET PRIORITY seam (QuestWindow.openAt lands on the quest). */ 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)); // --- Stub just enough of Phaser for the class declarations + the tracker ---- class ContainerStub { constructor(scene, x = 0, y = 0) { this.scene = scene; this.x = x; this.y = y; this.alpha = 1; this.visible = true; this.scale = 1; 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; } setScale(s) { this.scale = s; return this; } setDepth() { return this; } setScrollFactor() { return this; } setSize(w, h) { this.width = w; this.height = h; return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } setX(x) { this.x = x; return this; } destroy(recursive) { if (this.destroyed) return; this.destroyed = true; if (recursive) { for (const c of this.childrenList) { try { c.destroy?.(); } catch { /* already gone */ } } } } } class RectStub { constructor(x, y, width, height) { this.x = x; this.y = y; this.width = width; this.height = height; } 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); } } 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(v.slice(1), 16) }) } }, Math: { Linear: (a, b, t) => a + (b - a) * t, FloatBetween: (a, b) => a + Math.random() * (b - a), Angle: { Wrap: (a) => a }, Clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)), }, BlendModes: { ADD: 2 }, }; globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this // --- Load the real config (data/*.json) into the config singleton ---------- const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); 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 { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); const { formatSystemReport } = await import(pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href); const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href); const { QuestState, questDef, questDefs } = await import( pathToFileURL(join(__dirname, '../js/quests/QuestState.js')).href ); const { QuestTrackerHud } = await import( pathToFileURL(join(__dirname, '../js/ui/QuestTrackerHud.js')).href ); const { MenuButton } = await import(pathToFileURL(join(__dirname, '../js/ui/MenuButton.js')).href); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // --- Stub scene plumbing (the system-hud pattern, + the tracker's needs) --- class FakeText { constructor(x, y, str, style) { this.x = x; this.y = y; this.text = str; this.depth = 0; this.alpha = 1; this.angle = 0; this.color = style?.color ?? null; this.style = style ?? null; } get width() { return this.text.length * 9; // deterministic fake metrics } get height() { return 19; } setText(s) { this.text = s; return this; } setColor(c) { this.color = c; return this; } setOrigin() { return this; } setScrollFactor() { return this; } setBlendMode() { return this; } setDepth(d) { this.depth = d; return this; } setAlpha(a) { this.alpha = a; return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } destroy() { this.destroyed = true; return this; } } class FakeGraphics { constructor() { this.x = 0; this.y = 0; this.visible = true; this.alpha = 1; this.handlers = {}; this.input = null; } on(ev, fn) { (this.handlers[ev] ??= []).push(fn); return this; } emit(ev) { for (const fn of this.handlers[ev] ?? []) fn(); return this; } fire(ev) { for (const fn of this.handlers[ev] ?? []) fn(); return this; } setInteractive(o) { this.input = o; return this; } clear() { return this; } lineStyle() { return this; } fillStyle() { return this; } fillRect() { return this; } fillPoints() { return this; } strokePoints() { return this; } strokeRect() { return this; } lineBetween() { return this; } setVisible(v) { this.visible = v; return this; } setAlpha(a) { this.alpha = a; return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } setScrollFactor() { return this; } setDepth() { return this; } destroy() { this.destroyed = true; return this; } } class FakeRect { constructor(x, y, w, h, color, alpha) { this.x = x; this.y = y; this.w = w; this.h = h; this.color = color; this.alpha = alpha; } setOrigin() { return this; } setScrollFactor() { return this; } setX(v) { this.x = v; return this; } setAlpha(a) { this.alpha = a; return this; } destroy() { this.destroyed = true; return this; } } function makeAdd() { const texts = []; const glist = []; const factory = (obj, list) => { const orig = obj.destroy.bind(obj); obj.destroy = () => { const i = list.indexOf(obj); if (i >= 0) list.splice(i, 1); return orig(); }; return obj; }; return { texts, glist, text: (x, y, str, style) => { const t = factory(new FakeText(x, y, str, style), texts); texts.push(t); return t; }, graphics: () => { const g = factory(new FakeGraphics(), glist); glist.push(g); return g; }, rectangle: (x, y, w, h, color, alpha) => new FakeRect(x, y, w, h, color, alpha), circle: (x, y, radius, color, alpha) => ({ x, y, radius, fill: color, alpha, setStrokeStyle() { return this; }, setDepth() { return this; }, setScale() { return this; }, setAlpha(a) { this.alpha = a; return this; }, destroy() { this.destroyed = true; return this; }, }), existing: (o) => o, }; } function makeScene() { const add = makeAdd(); const scene = { time: { now: 0, // Deferred (not run): toasts live for the assertion window, the // button press-release stays pending (harmless in the harness). delayedCall: (_ms, _fn) => ({ remove() {} }), }, // Tweens run INSTANTLY in the harness: land the final value now. tweens: { killTweensOf: () => {}, add(opts) { const targets = Array.isArray(opts.targets) ? opts.targets : [opts.targets]; for (const [k, v] of Object.entries(opts)) { if (k === 'targets' || k === 'duration' || k === 'ease' || k === 'yoyo' || k === 'onComplete') continue; for (const tg of targets) tg[k] = v; } if (typeof opts.onComplete === 'function') opts.onComplete(); return { remove() {} }; }, }, add, scale: { width: 1280, height: 720 }, played: [], playSfx(name) { this.played.push(name); }, }; return scene; } // The dossier's real galaxy + report (the same as the system-hud test). const galaxy = Galaxy.create('tracker-hud-test'); const rec = galaxy.currentSystem(); const content = galaxy.ensureContent(rec.id); const report = formatSystemReport(content); // =========================================================================== // 1) Component: hidden at first, the green QUESTS title, NONE TRACKED // =========================================================================== { const scene = makeScene(); scene.time.now = 1000; const trk = new QuestTrackerHud(scene, { anchorX: 16, anchorY: 40, getSnapshot: () => null, resolvePriority: () => null, }); const offX = -(trk.rowW + 60); check('fresh tracker: HIDDEN, parked offscreen-left', trk.state === 'hidden' && trk.root.visible === false && trk.root.x === offX); check('fresh tracker: isBusy() false, shown false', trk.isBusy() === false && trk.shown === false); const title = scene.add.texts.find((t) => t.text === 'QUESTS'); check('the QUESTS title is there, at the anchor, GREEN (#67e863)', !!title && title.x === 16 && title.y === 40 && title.color === '#67e863'); check('nothing tracked yet → grayed-out NONE TRACKED', scene.add.texts.some((t) => t.text === 'NONE TRACKED' && t.color === '#3d4c74')); // show → slide IN (instant tween) → shown at x=0, visible. trk.show(scene.time.now); check('show(): slides in — visible at x=0, state shown', trk.shown === true && trk.root.visible === true && trk.root.x === 0); check('show(): not busy once landed', trk.isBusy() === false); // hide → slide OFF → hidden + invisible + onDone (the scene chains the // dossier expand off it). let hiddenCb = false; trk.hide(scene.time.now, () => { hiddenCb = true; }); check('hide(): slides off — state hidden, root hidden, onDone fired', trk.state === 'hidden' && trk.root.visible === false && hiddenCb === true); let hiddenAgain = false; trk.hide(scene.time.now, () => { hiddenAgain = true; }); check('hide() while already hidden fires onDone immediately (no hang)', hiddenAgain === true); trk.destroy(); } // =========================================================================== // 2) Component: live snapshot — the priority checklist, the % readout, // main-before-side, the 5-row cap + SHOW MORE, claimed drop-offs // =========================================================================== { // Synthetic quest set (valid ids — the ledger validates against // data/quests.json): 2 main + 2 side, all granted, one claimed. const saved = config.data; const mkQ = (id, category, title, done, claimed) => ({ id, category, title, issuer: 'TEST', description: 'x', icon: 'diamond', checks: ['R', 'M', 'T', 'D'].map((c, i) => ({ type: 'mineralsMined', amount: 1, label: `CHECK ${c}`, done: i < done })), reward: { minerals: 1 }, claimed, }); config.init({ ...configData, quests: { ...configData.quests, quests: [ mkQ('test_main_b', 'main', 'Main B', 1, false), mkQ('test_main_a', 'main', 'Main A', 4, false), mkQ('test_side_b', 'side', 'Side B', 0, false), mkQ('test_side_a', 'side', 'Side A', 2, true), ], }, }); const scene = makeScene(); scene.time.now = 2000; const ledger = new QuestState(); ledger.give('test_main_b'); ledger.give('test_main_a'); ledger.give('test_side_b'); ledger.give('test_side_a'); ledger.claim('test_side_a'); // claimed in the snapshot → drops off // The snapshot = the shape of GameScene.questSnapshot, over the granted set. const snap = { categories: [{ id: 'main', label: 'MAIN STORY' }, { id: 'side', label: 'SIDE QUESTS' }], quests: [ { id: 'test_main_b', category: 'main', title: 'Main B', checks: [{ label: 'CHECK R', done: true, detail: 'DONE' }, { label: 'CHECK M', done: false, detail: '2/4' }, { label: 'CHECK T', done: false, detail: '2/4' }, { label: 'CHECK D', done: false, detail: '2/4' }], claimed: false, complete: false, progress: '1/4' }, { id: 'test_main_a', category: 'main', title: 'Main A', checks: ['R', 'M', 'T', 'D'].map((c) => ({ label: `CHECK ${c}`, done: true, detail: 'DONE' })), claimed: false, complete: true, progress: '4/4' }, { id: 'test_side_b', category: 'side', title: 'Side B', checks: ['R', 'M', 'T', 'D'].map((c) => ({ label: `CHECK ${c}`, done: false, detail: '0/4' })), claimed: false, complete: false, progress: '0/4' }, { id: 'test_side_a', category: 'side', title: 'Side A', checks: ['R', 'M'].map((c) => ({ label: `CHECK ${c}`, done: true, detail: 'DONE' })), claimed: true, complete: false, progress: '2/4' }, ], }; const trk = new QuestTrackerHud(scene, { anchorX: 16, anchorY: 40, getSnapshot: () => snap, resolvePriority: (ids) => ledger.resolvePriority(ids), }); trk.show(scene.time.now); trk.update(scene.time.now); const titleOf = (name) => scene.add.texts.find((t) => t.text === name); check('main story FIRST in the stack (Main B, Main A — side last)', titleOf('MAIN B') && titleOf('MAIN A') && titleOf('SIDE B') && titleOf('MAIN B').y < titleOf('MAIN A').y && titleOf('MAIN A').y < titleOf('SIDE B').y); check('the CLAIMED quest drops off the list (Side A — claimed)', titleOf('SIDE A') === undefined); check('the % readout sits at the row\u2019s right edge (1/4 → 25%)', (() => { const pct = scene.add.texts.find((t) => t.text === '25%'); const row0 = trk._rowRects[0]; return !!pct && pct.x === 16 + trk.rowW && pct.y === row0.y + 10 && row0.id === 'test_main_b'; })()); check('auto-priority = the first active story quest (Main B)', ledger.priority === 'test_main_b'); const checklistRows = scene.add.texts.filter((t) => /^✓|^·/.test(String(t.text).trim().slice(0, 1)) && t.x === 30); check('the priority checklist renders UNDER its title (4 lines, indented)', checklistRows.length === 4 && checklistRows.every((t) => t.x === 30)); check('a completed check reads ✓ + the dim label', scene.add.texts.some((t) => t.text.startsWith('✓') && t.color === '#7d92c4')); check('an open check reads a gray bullet + label + the detail', scene.add.texts.some((t) => t.text.startsWith('·') && t.text.includes('CHECK M') && t.color === '#3d4c74')); check('non-priority rows carry NO checklist (only the priority does)', scene.add.texts.filter((t) => /^✓|^·/.test(String(t.text).trim().slice(0, 1)) && t.x === 30).length === 4); // The player's SET PRIORITY moves the checklist (live repaint). ledger.setPriority('test_main_a'); trk.update(scene.time.now); check('SET PRIORITY (Main A) → its checklist renders (4 ✓)', ledger.priority === 'test_main_a' && scene.add.texts.filter((t) => /^✓/.test(String(t.text).trim().slice(0, 1)) && t.x === 30).length === 4); // CLEAR — the checklist goes away (plain titles again)… ledger.clearPriority(); trk.update(scene.time.now); check('CLEAR PRIORITY → no checklist anywhere (plain titles)', ledger.priority === null && ledger.priorityCleared === true && !scene.add.texts.some((t) => /^✓|^·/.test(String(t.text).trim().slice(0, 1)) && t.x === 30)); // …and it STICKS: the next poll does NOT re-arm (no grant/claim since). trk.update(scene.time.now); check('the clear sticks across polls (auto-assign stays off)', ledger.priority === null); trk.destroy(); config.init(saved); } // =========================================================================== // 3) Component: the 5-row cap, SHOW MORE, the priority always in list // =========================================================================== { // Six active quests (3 main, 3 side) — more than the maxVisible cap (5). const savedConfig = config.data; config.init({ ...configData, quests: { ...configData.quests, tracker: { ...configData.quests.tracker, maxVisible: 5 }, }, }); const scene = makeScene(); scene.time.now = 3000; const mk = (id, cat, title) => ({ id, category: cat, title, checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' }); const snap = { categories: [], quests: [ mk('m1', 'main', 'M1'), mk('m2', 'main', 'M2'), mk('m3', 'main', 'M3'), mk('s1', 'side', 'S1'), mk('s2', 'side', 'S2'), mk('s3', 'side', 'S3'), ], }; const seenShowMore = []; const trk = new QuestTrackerHud(scene, { anchorX: 16, anchorY: 40, getSnapshot: () => snap, resolvePriority: (ids) => ids[0] ?? null, // auto = first active (M1) onShowMore: (id) => seenShowMore.push(id), }); trk.show(scene.time.now); trk.update(scene.time.now); const titles = ['M1', 'M2', 'M3', 'S1', 'S2', 'S3'].map((n) => scene.add.texts.find((t) => t.text === n)); check('more than 5 active → exactly FIVE titles listed', titles.filter(Boolean).length === 5 && titles[0] && titles[1] && titles[2] && titles[3] && (titles[4] !== undefined)); check('the cap is MAIN-first: M1 M2 M3 S1 + the last slot', titles[0].y < titles[1].y && titles[1].y < titles[2].y && titles[2].y < titles[3].y && titles[3].y < (titles[4]?.y ?? Infinity)); check('a SHOW MORE button is present (a MenuButton with a rect area)', trk._moreRect !== null && titles[4] !== undefined); check('the hidden quest is S3 (6th) — the one SHOW MORE targets', trk._rowRects.length === 5 && !trk._rowRects.some((r) => r.id === 's3')); // SHOW MORE clicked (its MenuButton's stored pointerdown) → the scene // action fires with the first hidden quest (s3). const moreG = trk._kids.find((k) => k instanceof MenuButton); check('the SHOW MORE button is a real MenuButton', moreG !== undefined); moreG?.panel?.fire?.('pointerdown'); check('SHOW MORE → onShowMore(first hidden quest = s3)', seenShowMore.length === 1 && seenShowMore[0] === 's3'); trk.destroy(); // The priority beyond the cap (s3) is ALWAYS in the list (last slot) — // a fresh scene (the previous tracker's rows would share the text list). const scene2 = makeScene(); scene2.time.now = 3000; const trk2 = new QuestTrackerHud(scene2, { anchorX: 16, anchorY: 40, getSnapshot: () => snap, resolvePriority: () => 's3', // the player featured the 6th quest }); trk2.show(scene2.time.now); trk2.update(scene2.time.now); const t2 = ['M1', 'M2', 'M3', 'S1', 'S3'].map((n) => scene2.add.texts.find((t) => t.text === n && t.x === 16 + 8)); check('a priority past the cap takes the LAST slot (S3 in, S2 out)', t2.every(Boolean) && !scene2.add.texts.some((t) => t.text === 'S2' && t.x === 16 + 8)); trk2.destroy(); config.init(savedConfig); } // =========================================================================== // 4) Component: the row popup — SET/CLEAR PRIORITY + VIEW QUEST + the // click contract (contains / popupContains / source / closedAt) // =========================================================================== { const scene = makeScene(); scene.time.now = 4000; const snap = { categories: [], quests: [{ id: 'q1', category: 'main', title: 'Only Quest', checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' }], }; const events = []; let prioNow = null; // the ledger's effective priority (the player cleared it) const trk = new QuestTrackerHud(scene, { anchorX: 16, anchorY: 40, getSnapshot: () => snap, resolvePriority: () => prioNow, onSetPriority: (id) => events.push(['set', id]), onClearPriority: () => events.push(['clear']), onViewQuest: (id) => events.push(['view', id]), }); trk.show(scene.time.now); trk.update(scene.time.now); const row = trk._rowRects[0]; const plate = trk._kids.find((k) => k instanceof FakeGraphics); check('the row is a hit target (an interactive plate with a rect area)', !!plate?.input?.hitArea && plate.input.hitArea.contains(row.x + 5, row.y + 5)); // Click the row → the popup opens. plate.fire('pointerdown'); check('a row click opens the popup (SET PRIORITY + VIEW QUEST — this row is NOT the priority)', trk.popupOpen === true && scene.add.texts.some((t) => t.text === 'SET PRIORITY') && scene.add.texts.some((t) => t.text === 'VIEW QUEST') && !scene.add.texts.some((t) => t.text === 'CLEAR PRIORITY')); const pop = trk._popup; check('the popup sits right of the row (same band, clear of it)', pop.rect.x >= row.x + row.w && Math.abs(pop.rect.y - row.y) <= 10); check('contract: the popup plate contains its own point', trk.popupContains(pop.rect.x + 5, pop.rect.y + 5) === true); check('contract: the SOURCE row contains the opening click', trk.sourceContains(row.x + 5, row.y + 5) === true); check('contract: contains() covers row + popup (the tracker footprint)', trk.contains(row.x + 5, row.y + 5) === true && trk.contains(pop.rect.x + 5, pop.rect.y + 5) === true); check('contract: far away is NOT in the footprint', trk.contains(1200, 600) === false); check('same-frame: the popup was opened "now" (the opening-click test)', trk.popupOpenedWithin(40) === true); // VIEW QUEST (the button's own pointerdown) → the action + the popup closes. const bView = trk._popup.btns.find((b) => b.labelText?.text === 'VIEW QUEST'); check('VIEW QUEST is a real MenuButton', bView instanceof MenuButton); bView?.panel?.fire?.('pointerdown'); check('VIEW QUEST → onViewQuest(q1) + the popup closes', events.length === 1 && events[0][0] === 'view' && events[0][1] === 'q1' && trk.popupOpen === false); check('contract: a point in the just-closed plate is the same-frame guard', trk.closedPopupAt(pop.rect.x + 5, pop.rect.y + 5) === true); // SET PRIORITY (re-open the popup). events.length = 0; plate.fire('pointerdown'); const bSet = trk._popup.btns.find((b) => b.labelText?.text === 'SET PRIORITY'); bSet?.panel?.fire?.('pointerdown'); check('SET PRIORITY → onSetPriority(q1) + closes', events.length === 1 && events[0][0] === 'set' && events[0][1] === 'q1' && trk.popupOpen === false); // Now the quest IS the priority (the ledger agreed) → the popup offers // CLEAR PRIORITY instead (the live repaint). prioNow = 'q1'; trk.update(scene.time.now); plate.fire('pointerdown'); const bClear = trk._popup.btns.find((b) => b.labelText?.text === 'CLEAR PRIORITY'); check('when the row IS the priority, the popup offers CLEAR PRIORITY (not SET)', bClear !== undefined && !trk._popup.btns.some((b) => b.labelText?.text === 'SET PRIORITY')); events.length = 0; bClear?.panel?.fire?.('pointerdown'); check('CLEAR PRIORITY → onClearPriority() + closes', events.length === 1 && events[0][0] === 'clear' && trk.popupOpen === false); trk.destroy(); } // =========================================================================== // 5) Integration: the real dossier lifecycle drives the tracker // =========================================================================== function makeGameScene() { const s = makeScene(); const proto = GameScene.prototype; const scene = Object.assign(Object.create(proto), { ...s, systemRecord: rec, systemContent: content, galaxy, // The ledger (real) + the live snapshot inputs (the scene's effects). questState: new QuestState(), totalMined: 0, researchState: { isUnlocked: () => false }, tetherLevelFor: () => 0, discovery: null, homeWorldName: rec.name, // The toast/text plumbing the real consoleToast + claimQuest touch. mineralHud: { set() {} }, refreshMineralHud() {}, ship: { addMinerals() {} }, consoleToastG: null, // The SFX + audio fields (the real playSfxOn path). playedSfx: [], }); scene.questState.give(questDefs().find((q) => q.starter === true)?.id); return scene; } const STEP = 16; const step = (scene, from, to) => { for (let t = from; t <= to; t += STEP) GameScene.prototype.updateHud.call(scene, t); }; const T0 = 100000; { const s = makeGameScene(); GameScene.prototype.createSystemHud.call(s); const trk = s.questTracker; check('createSystemHud builds the tracker (right below the name)', !!trk && trk.X === 16 && trk.Y0 === 40); check('before the first fold: the tracker is HIDDEN (dossier open)', trk.state === 'hidden' && trk.root.visible === false); // Arrive (open by default) — the tracker must NOT show yet. step(s, T0, T0 + 4000); check('dossier arrived OPEN — tracker still hidden', s.hudPhase === 'expanded' && trk.state === 'hidden'); // The one-shot 10 s auto-fold → the tracker slides IN when it lands. step(s, T0 + 4000, T0 + 13000); check('the 10 s auto-fold fired (dossier collapsed)', s.hudPhase === 'collapsed'); check('…and the tracker SLID IN (shown, visible, at rest)', trk.state === 'shown' && trk.root.visible === true && trk.root.x === 0); check('the starter quest auto-features as the priority (the checklist is up)', s.questState.priority === questDefs().find((q) => q.starter === true)?.id && trk._rowRects.length === 1); const starterId = questDefs().find((q) => q.starter === true)?.id; const snapCheckStates = (sc) => (sc.questSnapshot()?.quests ?? []).find((q) => q.id === starterId)?.checks ?? []; check('the checklist lines read the LIVE snapshot (all open at 0%)', ['Research Tether Level 2', 'Mine 200 Minerals from asteroids', 'Build a Level 2 Tether on your Home world', 'Discover another planet or space station'].every( (l) => s.add.texts.some((t) => t.x === 30 && t.text.includes(l)), ) && s.add.texts.every((t) => t.x !== 30 || /^·/.test(t.text.trim().slice(0, 1)))); // RE-OPEN (the player toggles): the tracker slides out FIRST, then the // dossier unfolds. s.time.now = T0 + 13000; GameScene.prototype.toggleHud.call(s); check('a re-open slides the tracker out FIRST (hidden before the expand runs)', trk.state === 'hidden' && trk.root.visible === false); check('…then the dossier expands (constructing → expanded)', s.hudPhase === 'constructing' || s.hudPhase === 'expanded'); step(s, T0 + 13000, T0 + 16000); check('re-opened: the dossier is open again, the tracker stays hidden', s.hudPhase === 'expanded' && trk.state === 'hidden'); // FOLD again → the tracker slides back IN. s.time.now = T0 + 16000; GameScene.prototype.toggleHud.call(s); step(s, T0 + 16000, T0 + 19000); check('a later fold re-shows the tracker (visible ⇔ folded)', s.hudPhase === 'collapsed' && trk.state === 'shown' && trk.root.visible === true); // A toggle MID-SLIDE is ignored (the busy guard). trk.state = 'hiding'; // simulate an in-flight slide (the harness tweens are instant) const tlBefore = s.hudTimeline; const phaseBefore = s.hudPhase; s.time.now = T0 + 19000; GameScene.prototype.toggleHud.call(s); check('a toggle while a slide is in flight is ignored (no timeline, phase intact)', s.hudTimeline === tlBefore && s.hudPhase === phaseBefore); trk.state = 'shown'; // (restore the settled state) // LIVE checklist: the real questSnapshot ticks the checks over. const checksBefore = snapCheckStates(s); s.researchState = { isUnlocked: () => true }; s.totalMined = 200; s.tetherLevelFor = () => 2; s.discoveredWorldCount = () => 1; // (shadow the prototype — the 4th check) s.questTracker.update(s.time.now); const checksAfter = snapCheckStates(s); check('the checklist tracks LIVE: 0/4 → 4/4 as the run progresses', checksBefore.every((c) => c.done === false) && checksAfter.every((c) => c.done === true)); check('the % readout hits 100%', s.add.texts.some((t) => t.text === '100%')); // The scene's priority actions (real setPriorityQuest / clearPriorityQuest). const saved = config.data; const MAIN2 = { id: 'test_main_b', category: 'main', title: 'Test Main B', issuer: 'TEST', description: 'x', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 1, label: 'MINE 1' }], reward: { minerals: 1 } }; const SIDE1 = { id: 'test_side_a', category: 'side', title: 'Test Side A', issuer: 'TEST', description: 'x', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 1, label: 'MINE 1' }], reward: { minerals: 1 } }; config.init({ ...config.data, quests: { ...config.data.quests, quests: [...config.data.quests.quests, MAIN2, SIDE1] } }); try { s.questState.give('test_side_a'); s.questTracker.update(s.time.now); check('the new side quest appears in the stack (main-first, side after)', trk._rowRects.length === 2 && trk._rowRects[0].id === starterId && trk._rowRects[1].id === 'test_side_a'); s.time.now += 100; GameScene.prototype.setPriorityQuest.call(s, 'test_side_a'); check('SET PRIORITY (scene) → the ledger features the SIDE quest', s.questState.priority === 'test_side_a'); s.questTracker.update(s.time.now); check('…and the checklist moves to it (the tracker is a live view)', trk._rowRects.length === 2 && trk._priorityId === 'test_side_a'); check('the set toast fired (PRIORITY SET — TEST SIDE A)', s.add.texts.some((t) => t.text === 'PRIORITY SET — TEST SIDE A')); s.time.now += 100; GameScene.prototype.clearPriorityQuest.call(s); check('CLEAR PRIORITY (scene) → the ledger clears it (the flag sticks)', s.questState.priority === null && s.questState.priorityCleared === true); s.questTracker.update(s.time.now); check('…and the checklist is gone (plain titles again)', trk._priorityId === null); check('the clear toast fired (PRIORITY CLEARED)', s.add.texts.some((t) => t.text === 'PRIORITY CLEARED')); } finally { config.init(saved); } // CLAIM (real claimQuest) — the claimed priority DROPS off the ledger // and the auto-assignment re-arms (the cleared flag is lifted). s.questState.setPriority(starterId); // re-feature it (it is still active) const snap = s.questSnapshot(); const q = snap.quests.find((x) => x.id === starterId); check('the starter quest is COMPLETE now (all 4 checks live-done)', q?.complete === true); GameScene.prototype.claimQuest.call(s, starterId); check('CLAIM (scene) → the ledger claims it (no double-pay)', s.questState.isClaimed(starterId) === true); check('…the claimed priority drops off the ledger (auto re-arms: cleared flag lifted)', s.questState.priority === null && s.questState.priorityCleared === false); s.questTracker.update(s.time.now); check('…the tracker shows the claim state (the row drops off — no active quest left)', trk._rowRects.length === 0 || trk._rowRects.every((r) => r.id !== starterId)); s.questTracker.destroy(); } // =========================================================================== // 6) A MANUAL fold before the 10 s mark ALSO arms the tracker (first fold) // =========================================================================== { const s = makeGameScene(); GameScene.prototype.createSystemHud.call(s); const trk = s.questTracker; step(s, T0, T0 + 4000); // arrive, open by default check('arrived open — tracker hidden', s.hudPhase === 'expanded' && trk.state === 'hidden'); // The player folds at 8 s (before the auto-fold window)… s.time.now = T0 + 8000; GameScene.prototype.toggleHud.call(s); let foldedAt = null; for (let t = T0 + 8000; t <= T0 + 11000; t += STEP) { if (foldedAt === null && s.hudPhase === 'collapsed') foldedAt = t; GameScene.prototype.updateHud.call(s, t); } check('the manual 8 s fold landed (before the 10 s auto-fold)', foldedAt !== null && foldedAt < T0 + 10000); check('…and the tracker SLID IN (the first fold armed it)', trk.state === 'shown' && trk.root.visible === true); // …and the one-shot auto-fold stays cancelled (the player already folded). step(s, T0 + 11000, T0 + 14000); check('the cancelled auto-fold does not re-fold a re-opened dossier', s.autoCollapseArmed === false); s.questTracker.destroy(); } // =========================================================================== // 7) QuestWindow.openAt — the SHOW MORE / VIEW QUEST target (the seam) // =========================================================================== { // Pin the openAt selection logic on the REAL QuestWindow class shape // (a lightweight probe — the full window needs textures/videos the // harness does not carry; openAt is pure selection + repaint). const { QuestWindow } = await import(pathToFileURL(join(__dirname, '../js/ui/QuestWindow.js')).href); check('QuestWindow exposes openAt (the console lands on a quest)', typeof QuestWindow.prototype.openAt === 'function'); check('QuestWindow accepts the priority seam (onSetPriority/getPriority)', /onSetPriority/.test(QuestWindow.toString()) && /getPriority/.test(QuestWindow.toString())); } if (failures > 0) { console.error(`\n${failures} quest-tracker test(s) FAILED`); process.exit(1); } console.log('\nquest-tracker: all checks passed');