orbit/dev/quest-tracker.test.mjs

868 lines
34 KiB
JavaScript

/**
* 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');