868 lines
34 KiB
JavaScript
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');
|