From 9e7d7c1257d0152637c6751a5df517e0c43a0746 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 8 Sep 2026 12:55:10 -0600 Subject: [PATCH] Add quest tracker HUD with priority system and live checklist - New QuestTrackerHud component slides in below the system dossier once it folds, showing active quests (main story first) with a live progress checklist under the priority quest - Priority quest: player can set/clear via row popup or console; auto-assigns to first active story quest; cleared state sticks until a grant or claim re-arms it - QuestWindow gains SET PRIORITY / CLEAR PRIORITY controls and openAt() to land on a specific quest (used by SHOW MORE / VIEW QUEST) - GameScene wires the tracker into the dossier lifecycle: hidden while expanded, slides out before expand, slides in on collapse; pointerdown contract prevents world clicks from leaking through rows and popups - SurfaceScene delegates priority changes to the sleeping GameScene's ledger - QuestState serializes/deserializes priority + cleared flag for save round-trips - quests.json gains a tracker section (title, maxVisible, slideMs, toast configs) - New dev/quest-tracker.test.mjs pins the full contract; dev/quests.test.mjs extended with priority and tracker-knob checks; system-hud test updated for the extra tracker elements --- README.md | 2 + data/quests.json | 19 + dev/quest-tracker.test.mjs | 867 +++++++++++++++++++++++++++++++++++++ dev/quests.test.mjs | 138 ++++++ dev/system-hud.test.mjs | 69 ++- js/quests/QuestState.js | 91 +++- js/scenes/GameScene.js | 143 +++++- js/scenes/SurfaceScene.js | 25 ++ js/ui/QuestTrackerHud.js | 645 +++++++++++++++++++++++++++ js/ui/QuestWindow.js | 114 ++++- 10 files changed, 2091 insertions(+), 22 deletions(-) create mode 100644 dev/quest-tracker.test.mjs create mode 100644 js/ui/QuestTrackerHud.js diff --git a/README.md b/README.md index 74fbff2..d504710 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,8 @@ node dev/world-names.test.mjs # world-name casing: display casing must never node dev/saves.test.mjs # the save bank + capture/restore hand-off (incl. builds) node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier) node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report +node dev/quest-tracker.test.mjs # the quest tracker HUD: slide in/out, live checklist, priority, SHOW MORE, the popup contract +node dev/quests.test.mjs # the quest ledger: grant/claim + the ONE priority (set/clear/sticks/auto-assign) + save round-trip node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files node dev/music.test.mjs # the shared music voice: guards + music.json contract ``` diff --git a/data/quests.json b/data/quests.json index ac0beda..b1918cc 100644 --- a/data/quests.json +++ b/data/quests.json @@ -58,6 +58,25 @@ "color": "#ff5c77", "text": "REQUIREMENTS INCOMPLETE — {missing}" }, + "tracker": { + "_comment": "The QUEST TRACKER HUD (js/ui/QuestTrackerHud.js) — the thin quest list that slides in from offscreen-left right below the system dossier ONCE the dossier has folded (auto-fold or the player's manual fold — first fold arms it; visible ⇔ dossier folded). ACTIVE = held + not claimed. Titles stack main-story-first then side-quests, up to maxVisible, the priority quest's checklist renders under its title and tracks LIVE (GameScene.questSnapshot); more than maxVisible → SHOW MORE (opens the QUESTS console on the first hidden quest). The ONE priority quest (js/quests/QuestState.js): the player sets/clears it (row popup + the console's SET PRIORITY), else the first active story quest is the priority; claiming it auto-advances to the next story/side quest. priorityToast/clearToast fire on set/clear (data colors: green = the tracker accent, dim = the cleared note).", + "enabled": true, + "title": "QUESTS", + "noneTracked": "NONE TRACKED", + "showMore": "SHOW MORE", + "maxVisible": 5, + "slideMs": 320, + "priorityToast": { + "glyph": "◆", + "color": "#67e863", + "text": "PRIORITY SET — {title}" + }, + "clearToast": { + "glyph": "·", + "color": "#7d92c4", + "text": "PRIORITY CLEARED" + } + }, "quests": [ { "id": "start_your_journey", diff --git a/dev/quest-tracker.test.mjs b/dev/quest-tracker.test.mjs new file mode 100644 index 0000000..bb75fd5 --- /dev/null +++ b/dev/quest-tracker.test.mjs @@ -0,0 +1,867 @@ +/** + * 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'); diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs index db1c146..2e92067 100644 --- a/dev/quests.test.mjs +++ b/dev/quests.test.mjs @@ -18,6 +18,12 @@ * - the quest ledger (js/quests/QuestState.js) behaves: idempotent * give/claim, claimed ⊆ granted, a save round-trip, and a * corrupted save degrades to an empty ledger (not a throw); + * - the PRIORITY (the tracker HUD's featured quest): set/clear, + * the auto-assignment (first active story quest), the cleared- + * flag sticks until a grant/claim, and the save round-trip; + * - the tracker HUD's knobs (quests.json → tracker: the green title, + * the NONE TRACKED line, the SHOW MORE label, the 5-row cap, the + * slide duration, the set/clear toasts); * - both command decks carry the QUESTS slot, right of SHIP. */ import { fileURLToPath } from 'node:url'; @@ -180,6 +186,138 @@ check('helpers: starterQuestIds() is exactly [the starter]', })()); } +// ---------------------------------------------------------------------- +// 6b. The quest ledger — the PRIORITY (the tracker HUD's featured quest) +// ---------------------------------------------------------------------- +{ + const st = new QuestState(); + st.give(starter.id); + check('priority: fresh — no priority', st.priority === null); + check('priority: setPriority() refuses a quest not held', st.setPriority('no_such_quest') === false); + check('priority: setPriority() sets a held quest', st.setPriority(starter.id) === true && st.priority === starter.id); + check('priority: setPriority() is idempotent for the current one', st.setPriority(starter.id) === false); + check('priority: resolve auto-assigns the single active quest', st.resolvePriority([starter.id]) === starter.id); + check('priority: resolve keeps a stored priority that is still active', st.resolvePriority([starter.id]) === starter.id); + check('priority: clearPriority() clears it', st.clearPriority() === true && st.priority === null && st.priorityCleared === true); + check('priority: clearPriority() with nothing set is a no-op', st.clearPriority() === false); + check('priority: a cleared priority sticks — resolve returns null', + st.resolvePriority([starter.id]) === null); + check('priority: a claimed priority drops off the ledger', (() => { + const st2 = new QuestState(); + st2.give(starter.id); + st2.setPriority(starter.id); + st2.claim(starter.id); + return st2.priority === null; + })()); + check('priority: setPriority() refuses a CLAIMED quest', (() => { + const st2 = new QuestState(); + st2.give(starter.id); + st2.claim(starter.id); + return st2.setPriority(starter.id) === false; + })()); +} +// Multi-quest behavior — the real data set has ONE quest today (side +// quests are deferred by design), so the ordering rules are pinned on a +// synthetic set: a second MAIN + one SIDE (config is a plain singleton — +// re-init for the test, restore after). +{ + const MAIN2 = { id: 'test_main_b', category: 'main', title: 'Test Main B', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 20, label: 'MINE 20' }], reward: { minerals: 20 } }; + const SIDE1 = { id: 'test_side_a', category: 'side', title: 'Test Side A', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 10, label: 'MINE 10' }], reward: { minerals: 10 } }; + const savedConfig = config.data; + config.init({ ...config.data, quests: { ...quests, quests: [...quests.quests, MAIN2, SIDE1] } }); + try { + const order = questDefs().map((q) => q.id); // [starter, MAIN2, SIDE1] — file order + const main = questDefs().filter((q) => q.category === 'main').map((q) => q.id); + const side = questDefs().filter((q) => q.category === 'side').map((q) => q.id); + const display = [...main, ...side]; + check('priority: display order is main story first, then side quests', + JSON.stringify(display) === JSON.stringify([order[0], order[1], order[2]])); + + const st = new QuestState(); + for (const id of display) st.give(id); + check('priority: resolve auto-assigns the FIRST active quest (display order)', + st.resolvePriority(display) === display[0] && st.priority === display[0]); + check('priority: resolve keeps a stored priority that is still active', + (st.setPriority(display[1]) === true && st.resolvePriority(display) === display[1])); + check('priority: the player can feature a SIDE quest over the story order', + (st.setPriority(side[0]) === true && st.resolvePriority(display) === side[0])); + + // A cleared priority STICKS (the auto-assignment stays off)... + check('priority: a cleared priority sticks — resolve returns null', + (st.clearPriority() === true && st.resolvePriority(display) === null)); + // ...until a new quest ARRIVES (give re-arms the auto-assignment) — + check('priority: a new grant re-arms the auto-assignment', (() => { + const fresh = new QuestState(); + fresh.give(display[0]); + fresh.resolvePriority([display[0]]); + fresh.clearPriority(); + if (fresh.resolvePriority([display[0]]) !== null) return false; // still cleared + fresh.give(display[1]); // a new quest arrives → re-armed + return fresh.priorityCleared === false && fresh.resolvePriority([display[0], display[1]]) === display[0]; + })()); + // ...or one is CLAIMED (claim re-arms it — the active set changed). + check('priority: claiming re-arms the auto-assignment (advances to the next)', (() => { + const st2 = new QuestState(); + for (const id of display) st2.give(id); + st2.resolvePriority(display); + st2.clearPriority(); + st2.claim(display[0]); // the first drops off the active set + return st2.resolvePriority([display[1], display[2]]) === display[1] && st2.priority === display[1]; + })()); + + // The save round-trip keeps the priority (and drops one that is no + // longer active — claimed or unknown). + const st3 = new QuestState(); + for (const id of display) st3.give(id); + st3.setPriority(side[0]); + const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st3.toJSON()))); + check('priority: save round-trip keeps the stored priority (a side quest)', + restored.priority === side[0] && restored.priorityCleared === false); + check('priority: a save pointing at a CLAIMED quest drops it', (() => { + const r = QuestState.fromJSON({ granted: [display[0]], claimed: [display[0]], priority: display[0] }); + return r.priority === null; + })()); + check('priority: a save pointing at an UNKNOWN quest drops it', (() => { + const r = QuestState.fromJSON({ granted: [display[0]], priority: 'no_such_quest' }); + return r.priority === null; + })()); + } finally { + config.init(savedConfig); // restore the real data set + } +} +{ + const st = new QuestState(); + st.give(starter.id); + st.resolvePriority([starter.id]); + st.clearPriority(); + const restoredClear = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON()))); + check('priority: save round-trip keeps the cleared flag', + restoredClear.priority === null && restoredClear.priorityCleared === true); + check('priority: a PRE-priority save (no field) restores as null', (() => { + const r = QuestState.fromJSON({ granted: [starter.id], claimed: [] }); + return r.priority === null && r.priorityCleared === false; + })()); +} + +// ---------------------------------------------------------------------- +// 6c. quests.json — the tracker HUD's knobs +// ---------------------------------------------------------------------- +{ + const t = quests.tracker ?? {}; + check('tracker: section present with the master switch', typeof t.enabled === 'boolean'); + check('tracker: enabled (the HUD is live)', t.enabled === true); + check('tracker: title + empty-state lines', typeof t.title === 'string' && t.title.length > 0 && typeof t.noneTracked === 'string' && t.noneTracked.length > 0); + check('tracker: SHOW MORE label', typeof t.showMore === 'string' && t.showMore.length > 0); + check('tracker: maxVisible is a sane cap (≥1)', Number.isInteger(t.maxVisible) && t.maxVisible >= 1); + check('tracker: slide duration configured', Number.isFinite(t.slideMs) && t.slideMs >= 80); + for (const key of ['priorityToast', 'clearToast']) { + check(`tracker: ${key} configured (glyph + text + color)`, + !!t[key] && typeof t[key].glyph === 'string' && typeof t[key].text === 'string' && hex.test(t[key].color ?? '')); + } + check('tracker: the PRIORITY accent is the tracker green (#67e863)', + (t.priorityToast?.color ?? '').toLowerCase() === '#67e863'); +} + // ---------------------------------------------------------------------- // 7. The command decks — the QUESTS slot, right of SHIP (both decks) // ---------------------------------------------------------------------- diff --git a/dev/system-hud.test.mjs b/dev/system-hud.test.mjs index d28dd19..9208fa7 100644 --- a/dev/system-hud.test.mjs +++ b/dev/system-hud.test.mjs @@ -36,10 +36,63 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Stub just enough of Phaser for the module-level class declarations ---- const ClassStub = class {}; +// A usable container (the QUEST tracker registers one in createSystemHud): +const ContainerStub = class { + constructor(scene, x = 0, y = 0) { + this.scene = scene; + this.x = x; + this.y = y; + this.visible = true; + this.childrenList = []; + } + add(o) { + if (Array.isArray(o)) o.forEach((c) => this.childrenList.push(c)); + else this.childrenList.push(o); + return this; + } + setVisible(v) { + this.visible = v; + return this; + } + setSize() { + return this; + } + setAlpha() { + return this; + } + setDepth() { + return this; + } + setScrollFactor() { + 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 */ + } + } + } + } +}; const PhaserStub = { Scene: ClassStub, Physics: { Arcade: { Sprite: ClassStub } }, - GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub }, + GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub }, Geom: { Rectangle: class {} }, Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } }, Math: { @@ -143,9 +196,19 @@ function makeScene() { texts.push(t); return t; }, + // The QUEST tracker (built by createSystemHud) registers its + // container + its hairline rule (NONE TRACKED state — no rows). + existing: (o) => o, + graphics: () => ({ + clear() {}, lineStyle() {}, lineBetween() {}, fillStyle() {}, fillRect() {}, + setScrollFactor() { return this; }, setDepth() { return this; }, + setPosition(x, y) { this.x = x; this.y = y; return this; }, + destroy() { this.destroyed = true; return this; }, + }), }, // 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)) { @@ -180,7 +243,9 @@ GameScene.prototype.createSystemHud.call(scene); const titleW = report.title.length * 9; const titleH = 19; -check('title + caret + one text per detail line', scene.texts.length === 2 + detailValues.length); +check('title + caret + one text per detail line (+ the tracker\u2019s own 2 texts)', + scene.texts.length === 2 + detailValues.length + 2 && + scene.questTracker !== undefined); check('the name starts empty (it decodes in)', scene.hudTitle.text === ''); check('details start empty (open by default, not pre-printed)', scene.hudDetail.every((d) => d.text.text === '')); check('the caret is the down triangle ▾, hidden at first', scene.hudCaret.text === '\u25be' && scene.hudCaret.alpha === 0); diff --git a/js/quests/QuestState.js b/js/quests/QuestState.js index 5dacc20..150fc19 100644 --- a/js/quests/QuestState.js +++ b/js/quests/QuestState.js @@ -8,10 +8,16 @@ * asteroids, the home world's tether standing, the discovery ledger. * Only the two things the run owns are state: * - * granted — which quest ids the player HOLDS (the starter quest is - * granted on the run's first boot; future quests are granted - * by whatever event issues them) - * claimed — which of the held quests have PAID OUT their reward + * granted — which quest ids the player HOLDS (the starter quest is + * granted on the run's first boot; future quests are granted + * by whatever event issues them) + * claimed — which of the held quests have PAID OUT their reward + * priority — the ONE priority quest the tracker HUD features (title + + * live checklist). The player sets/clears it; the ledger + * also auto-assigns it (see resolvePriority) so there is + * always a priority while an active story quest exists. + * cleared — the player CLEARED the priority; the auto-assignment is + * re-armed by any grant or claim (the active set changed). * * …and that is exactly what gets saved (SaveData capture/prepare), so a * save can never hand out a reward twice or un-grant a quest. @@ -44,6 +50,8 @@ export class QuestState { constructor() { this.granted = new Set(); this.claimed = new Set(); + this.priority = null; // the active quest id, or null + this.priorityCleared = false; // the player declined a priority (sticks until a grant/claim) } /** @@ -55,6 +63,7 @@ export class QuestState { if (!questDef(id)) return false; if (this.granted.has(id)) return false; this.granted.add(id); + this.priorityCleared = false; // a new quest re-arms the auto-priority return true; } @@ -76,11 +85,74 @@ export class QuestState { if (!this.granted.has(id)) return false; if (this.claimed.has(id)) return false; this.claimed.add(id); + if (this.priority === id) this.priority = null; // it can't feature itself + this.priorityCleared = false; // the active set changed → re-arm the auto-priority return true; } + // ------------------------------------------------------------ priority + + /** + * Set the priority quest (the tracker HUD's featured quest: title + + * checklist). Must be HELD and not yet CLAIMED. Idempotent — re-setting + * the current priority is a no-op. + * @returns {boolean} true when the priority CHANGED + */ + setPriority(id) { + if (typeof id !== 'string' || id === '') return false; + if (!this.granted.has(id) || this.claimed.has(id)) return false; + if (this.priority === id) return false; + this.priority = id; + this.priorityCleared = false; + return true; + } + + /** + * Clear the priority (the player's "no priority right now"). It STICKS + * — the auto-assignment stays off until a new quest is granted or one + * is claimed (either re-arms it, the active set changed). + * @returns {boolean} true when there WAS a priority to clear + */ + clearPriority() { + if (!this.priority) return false; + this.priority = null; + this.priorityCleared = true; + return true; + } + + /** + * Resolve the effective priority from the ACTIVE quests (held + not + * claimed) in DISPLAY order — main story first, then side quests + * (data/quests.json order within a category): + * - the stored priority when it is still active (the player's choice, + * or the last auto-assignment); + * - otherwise the FIRST active quest — unless the player cleared the + * priority (clearPriority), in which case null until a grant/claim; + * - otherwise null (nothing active). + * Auto-assignment is persisted on the ledger (idempotent), so the + * choice survives scene restarts + saves. + * @param {string[]} activeIds the active quest ids, display order + * @returns {string|null} + */ + resolvePriority(activeIds) { + const ids = Array.isArray(activeIds) ? activeIds.filter((x) => typeof x === 'string' && x !== '') : []; + if (this.priority && ids.includes(this.priority)) return this.priority; + if (this.priorityCleared) return null; + const first = ids[0] ?? null; + if (first) { + this.priority = first; + this.priorityCleared = false; + } + return first; + } + toJSON() { - return { granted: [...this.granted], claimed: [...this.claimed] }; + return { + granted: [...this.granted], + claimed: [...this.claimed], + priority: this.priority ?? null, + priorityCleared: this.priorityCleared === true, + }; } /** @@ -98,6 +170,15 @@ export class QuestState { for (const id of grant(data.claimed)) { if (st.granted.has(id)) st.claimed.add(id); // claimed ⊆ granted } + // The priority — a save predating it has no field (null). A saved id + // that is no longer held (or was claimed) is dropped rather than + // resurrected; the cleared flag survives as stored. + st.priority = + typeof data.priority === 'string' && data.priority !== '' && + st.granted.has(data.priority) && !st.claimed.has(data.priority) + ? data.priority + : null; + st.priorityCleared = data.priorityCleared === true; return st; } } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index fa6cdc4..0179137 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -34,6 +34,7 @@ import { CommsPanel } from '../ui/CommsPanel.js'; import { ResearchWindow } from '../ui/ResearchWindow.js'; import { MapWindow } from '../ui/MapWindow.js'; import { QuestWindow } from '../ui/QuestWindow.js'; +import { QuestTrackerHud } from '../ui/QuestTrackerHud.js'; import { QuestState, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js'; import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js'; import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js'; @@ -790,6 +791,8 @@ export class GameScene extends Phaser.Scene { this.questWindow = new QuestWindow(this, { getSnapshot: () => this.questSnapshot(), onClaim: (id) => this.claimQuest(id), + onSetPriority: (id) => this.setPriorityQuest(id), + getPriority: () => this.questState?.priority ?? null, onLocked: (tabId) => this.consoleToast( tabId === 'side' @@ -990,6 +993,31 @@ export class GameScene extends Phaser.Scene { this.toggleHud(); // the system name toggles the dossier details return; } + // The quest tracker (below the dossier, depth 30) — the mining- + // menu contract: while its row popup is up, a click inside the + // popup is a button's (its own pointerdown); the same-frame click + // on the row that OPENED it is swallowed; any other click closes + // the popup and is consumed. With the popup down, a click on the + // tracker's footprint is the tracker's (a row's own pointerdown + // opens the popup) — never a fly-here; a click its popup buttons + // just handled (they close it from their side first) is that + // click, never a world click. + if (this.questTracker && this.questTracker.shown) { + const tx = pointer.x; + const ty = pointer.y; + if (this.questTracker.popupOpen) { + if (this.questTracker.popupContains(tx, ty)) return; + if (this.questTracker.sourceContains(tx, ty)) { + if (this.questTracker.popupOpenedWithin(40)) return; // the opening click + this.questTracker.closePopup(); // a later deliberate re-click — fold it + return; + } + this.questTracker.closePopup(); + return; + } + if (this.questTracker.closedPopupAt(tx, ty)) return; + if (this.questTracker.contains(tx, ty)) return; + } // Mining pop-up OPEN: a click on one of its buttons is the // button's (its own pointerdown listener); a click ANYWHERE else @@ -1262,7 +1290,6 @@ export class GameScene extends Phaser.Scene { // dossier is pinned UI, so local space == screen space). this.hudTitleRect = { x: X - 4, y: y - 4, w: titleW + 24, h: titleH + 8 }; y += 26; - // --- The details (open by default) ---------------------------------- const detail = []; const line = (value, style) => { @@ -1297,6 +1324,26 @@ export class GameScene extends Phaser.Scene { settled: false, })), }; + + // ---- The QUEST TRACKER — the thin quest list right below the name -- + // Slides in from offscreen-left the moment the dossier has FOLDED + // (the one-shot auto-fold, or the player's manual fold — the first + // fold arms it; visible ⇔ the dossier is folded). It is a VIEW over + // the same live split as the console (questSnapshot — the checklist + // ticks over as research/mining/builds/discovery complete) and the + // same ledger (questState — the ONE priority quest). updateHud shows + // it when a collapse lands; startExpand slides it out first (make + // room); GameScene.update drives its per-frame poll. + this.questTracker = new QuestTrackerHud(this, { + anchorX: X, + anchorY: 40, // just below the dossier title (where the details start) + getSnapshot: () => this.questSnapshot(), + resolvePriority: (ids) => this.questState.resolvePriority(ids), + onSetPriority: (id) => this.setPriorityQuest(id), + onClearPriority: () => this.clearPriorityQuest(), + onViewQuest: (id) => this.openQuestAt(id), + onShowMore: (id) => this.openQuestAt(id), + }); } /** @@ -1344,6 +1391,10 @@ export class GameScene extends Phaser.Scene { if (tl.mode === 'collapse') { this.hudPhase = 'collapsed'; this.rotateCaret(true); // details gone → caret swings to point right + // The dossier has folded — the tracker slides in from offscreen + // left (the first fold arms it; a later fold re-shows it after + // startExpand slid it out to make room). + this.questTracker?.show(this.time.now); } else { this.hudPhase = 'expanded'; } @@ -1386,18 +1437,28 @@ export class GameScene extends Phaser.Scene { * arrival, just without the name (it never left). */ startExpand(t) { - this.hudPhase = 'constructing'; - this.playSfx('construct'); // the details type back in - this.rotateCaret(false); - this.hudTimeline = { - mode: 'expand', - lines: this.hudDetail.map((ln, i) => ({ - text: ln.text, - value: ln.value, - settled: false, - dec: new ScrambleDecode(ln.value, t + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)), - })), + const begin = (tt) => { + this.hudPhase = 'constructing'; + this.playSfx('construct'); // the details type back in + this.rotateCaret(false); + this.hudTimeline = { + mode: 'expand', + lines: this.hudDetail.map((ln, i) => ({ + text: ln.text, + value: ln.value, + settled: false, + dec: new ScrambleDecode(ln.value, tt + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)), + })), + }; }; + const trk = this.questTracker; + if (trk && (trk.isBusy() || trk.shown)) { + // Make room FIRST: the tracker slides offscreen-left, then the + // dossier unfolds (its lines would paint straight over the list). + trk.hide(this.time.now, () => begin(this.time.now)); + } else { + begin(t); + } } /** @@ -1428,6 +1489,7 @@ export class GameScene extends Phaser.Scene { */ toggleHud() { if (this.hudTimeline) return; + if (this.questTracker && this.questTracker.isBusy()) return; // a slide is in flight — let it land this.autoCollapseArmed = false; if (this.hudPhase === 'expanded') this.startCollapse(this.time.now); else if (this.hudPhase === 'collapsed') this.startExpand(this.time.now); @@ -1446,6 +1508,7 @@ export class GameScene extends Phaser.Scene { this.time.update(_time, delta); this.tweens.update(); this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle + this.questTracker?.update(_time); // the tracker: live checklist + priority this.ship.update(_time, delta); // Session time (saved with the game) — capped so a backgrounded tab // can't fast-forward it. @@ -2755,6 +2818,62 @@ export class GameScene extends Phaser.Scene { this.questWindow?.refresh(); } + /** + * SET PRIORITY — the tracker HUD's featured quest (the row popup's + * button, or the console's SET PRIORITY). The ledger is the source of + * truth (setPriority is idempotent + persists); the tracker and the + * console repaint on their next poll — the tracker's checklist moves + * to the newly featured quest LIVE. + */ + setPriorityQuest(id) { + if (!this.questState) return; + if (!this.questState.setPriority(id)) return; // not held, claimed, or already the priority + const def = questDef(id); + const t = config.get('quests.tracker.priorityToast', {}); + this.consoleToast( + String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(def?.title ?? id).toUpperCase()), + { glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)) }, + ); + this.questWindow?.refresh(); + } + + /** + * CLEAR PRIORITY — the player's "no priority right now" (the row + * popup's CLEAR PRIORITY). It STICKS on the ledger: the tracker shows + * plain titles again, and the auto-assignment re-arms only when a new + * quest is granted or one is claimed (the active set changed). + */ + clearPriorityQuest() { + if (!this.questState) return; + if (!this.questState.clearPriority()) return; // there was nothing to clear + const t = config.get('quests.tracker.clearToast', {}); + this.consoleToast(String(t.text ?? 'PRIORITY CLEARED'), { + glyph: t.glyph ?? '·', + glyphColor: t.color ? toCss(t.color) : toCss(themeColor('dim', 0x7d92c4)), + }); + this.questWindow?.refresh(); + } + + /** + * Open the QUESTS console ON A QUEST (the tracker's SHOW MORE — the + * first hidden quest — and VIEW QUEST): the deckAction('quests') close + * contract first, then the window lands on that quest (its tab active, + * its plate selected — QuestWindow.openAt). + */ + openQuestAt(questId) { + if (!this.questWindow) return; + if (this.questWindow.isOpen) { + this.questWindow.openAt(questId); + return; + } + if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close(); + if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close(); + if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close(); + if (this.mapWindow && this.mapWindow.isOpen) this.mapWindow.close(); + this.questWindow.openAt(questId); + this.questWindow.open(); + } + /** * A quest-relevant event happened (research completed, ore mined, a * build finished, a world discovered, a reward claimed). Announce each diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 42fbe14..b39e7f5 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -554,6 +554,8 @@ export class SurfaceScene extends Phaser.Scene { this.questWindow = new QuestWindow(this, { getSnapshot: () => this.gameScene?.questSnapshot?.() ?? null, onClaim: (id) => this.claimSurfaceQuest(id), + onSetPriority: (id) => this.setPrioritySurfaceQuest(id), + getPriority: () => this.gameScene?.questState?.priority ?? null, onLocked: (tabId) => this.consoleNote( tabId === 'side' @@ -710,6 +712,29 @@ export class SurfaceScene extends Phaser.Scene { } } + /** + * SET PRIORITY from the surface console — the ledger lives on the + * sleeping GameScene (the claimSurfaceQuest precedent), and the + * console note is ours (we are the scene the player sees). + */ + setPrioritySurfaceQuest(id) { + const g = this.gameScene; + if (!g?.questState) return; + if (!g.questState.setPriority(id)) return; // not held, claimed, or already the priority + let title = id; + try { + title = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.title ?? id; + } catch { + /* keep the id */ + } + const t = config.get('quests.tracker.priorityToast', {}); + this.consoleNote( + String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(title).toUpperCase()), + { glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : undefined }, + ); + this.questWindow?.refresh(); + } + /** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */ escAction() { if (this.buildWindow?.isOpen) { diff --git a/js/ui/QuestTrackerHud.js b/js/ui/QuestTrackerHud.js new file mode 100644 index 0000000..cbf2373 --- /dev/null +++ b/js/ui/QuestTrackerHud.js @@ -0,0 +1,645 @@ +/** + * QuestTrackerHud — the quest tracker that slides in right below the + * system dossier (top-left), the moment the dossier has folded: + * + * ┌ SYSTEM NAME ▸ (the dossier — folded to its title) + * │ + * │ QUESTS ▁▁▁▁▁ (green section title — always visible) + * │ ◆ START YOUR JOURNEY 75% (the ONE priority quest — its + * │ ✓ RESEARCH TETHER LEVEL 2 checklist renders UNDER its + * │ · MINE 200 MINERALS… — 120/200 title and tracks LIVE) + * │ · MINE THE BELT 33% (the rest: plain titles, main story + * │ …up to maxVisible … first, then side quests) + * │ [ SHOW MORE ] (only when MORE than maxVisible are active + * └ — opens the QUESTS console on the first + * hidden quest) + * + * Or, with nothing tracked: QUESTS / "NONE TRACKED" (grayed out). + * + * Lifecycle (driven by GameScene — see GameScene.updateHud / + * startExpand): the tracker is VISIBLE ⇔ the dossier is FOLDED, armed by + * the dossier's FIRST fold (the one-shot auto-fold, or the player's + * manual fold — either is the first fold). When the dossier re-opens the + * tracker SLIDES OFF (offscreen left) first, then the dossier expands; + * when the dossier folds again the tracker slides back in. The slide is + * a ~320 ms ease-out translate (data/quests.json → tracker.slideMs). + * + * The tracker is a VIEW — the same live split as the QUESTS console: + * the scene computes the snapshot (GameScene.questSnapshot — progress is + * LIVE: research, mined, tether, discovery) and owns the effects + * (set/clear the priority on the quest ledger, open the console). It + * polls every frame and repaints only when the fingerprint changes, so + * the checklist ticks over as tasks complete. + * + * The ONE priority quest (js/quests/QuestState.js): the player sets it + * (a row's popup → SET PRIORITY, or the console's SET PRIORITY) and + * clears it (row popup → CLEAR PRIORITY); otherwise the first active + * story quest is the priority (ledger auto-assignment), and claiming it + * auto-advances to the next story/side quest. + * + * A row click opens the row's POPUP (a small cut-corner panel, the + * mining menu's contract): a click on a button is the button's (Menu- + * Button's own pointerdown); any click elsewhere closes the popup and + * that click is consumed — the scene's pointerdown enforces the + * contract (GameScene: contains() / popupContains() below). + */ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toCss } from '../utils/Color.js'; +import { fontStack, themeColor } from '../utils/Theme.js'; +import { CyberShape } from './CyberShape.js'; +import { MenuButton } from './MenuButton.js'; + +const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; +const HEADER = fontStack('header', FONT_FALLBACK); +const BODY = fontStack('body', FONT_FALLBACK); + +/** The tracker's palette — green is the tracker's accent (data/quests.json). */ +const C = { + green: themeColor('green', 0x67e863), + ink: themeColor('ink', 0xeaf6ff), + dim: themeColor('dim', 0x7d92c4), + faint: themeColor('faint', 0x3d4c74), + panel: themeColor('panel', 0x0a1120), +}; + +/** Display order: main story first, then side quests, then anything else. */ +function catRank(q) { + return q?.category === 'main' ? 0 : q?.category === 'side' ? 1 : 2; +} + +/** + * The tracker's visible rows: the first `max` in display order — except + * the priority quest, which is ALWAYS in the list (its checklist is the + * point of the tracker): when it would sit past the cap it takes the + * last slot (original order is preserved). + */ +function pickVisible(active, priorityId, max) { + let vis = active.slice(0, max); + if (priorityId && !vis.some((q) => q.id === priorityId)) { + const p = active.find((q) => q.id === priorityId); + if (p) { + vis = active.slice(0, Math.max(0, max - 1)); + vis.push(p); + vis.sort((a, b) => active.indexOf(a) - active.indexOf(b)); + } + } + return vis; +} + +export class QuestTrackerHud { + /** + * @param {Phaser.Scene} scene the owning scene (GameScene — the tracker + * does not exist on the surface) + * @param {object} o + * @param {number} o.anchorX — the left edge (the dossier's X) + * @param {number} o.anchorY — the top edge (just below the dossier title) + * @param {() => object|null} o.getSnapshot — the live dossier snapshot + * (GameScene.questSnapshot; polled every frame) + * @param {(activeIds: string[]) => string|null} o.resolvePriority — + * the ledger's priority resolution (GameScene.questState.resolvePriority) + * @param {(id: string) => void} [o.onSetPriority] — SET PRIORITY pressed + * @param {() => void} [o.onClearPriority] — CLEAR PRIORITY pressed + * @param {(id: string) => void} [o.onViewQuest] — VIEW QUEST pressed + * @param {(id: string) => void} [o.onShowMore] — SHOW MORE pressed (the + * first hidden quest) + */ + constructor(scene, o = {}) { + this.scene = scene; + this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null; + this.resolvePriority = typeof o.resolvePriority === 'function' ? o.resolvePriority : null; + this.onSetPriority = typeof o.onSetPriority === 'function' ? o.onSetPriority : null; + this.onClearPriority = typeof o.onClearPriority === 'function' ? o.onClearPriority : null; + this.onViewQuest = typeof o.onViewQuest === 'function' ? o.onViewQuest : null; + this.onShowMore = typeof o.onShowMore === 'function' ? o.onShowMore : null; + + const cfg = config.section('quests.tracker', {}); + this.maxVisible = Math.max(1, Math.floor(cfg.maxVisible ?? 5)); + this.slideMs = Math.max(80, Math.floor(cfg.slideMs ?? 320)); + this.X = Math.max(8, Math.floor(o.anchorX ?? 16)); + this.Y0 = Math.max(30, Math.floor(o.anchorY ?? 40)); + this.rowW = 250; // the row plates' width (the percent sits at their right edge) + this._offX = -(this.rowW + 60); // offscreen-left (the slide's start/end) + + // Everything lives in ONE container — the slide is a single x + // translate of the container (at (0,0) at rest, so local == screen). + this.root = new Phaser.GameObjects.Container(scene, 0, 0); + scene.add.existing(this.root); // v4: a directly-constructed GameObject must register + this.root.setScrollFactor(0); // UI — pinned to the screen + this.root.setDepth(30); // with the dossier and the other corner readouts + this.root.setVisible(false); + + // --- The section title: green, ALWAYS there while the HUD exists --- + this.title = scene + .add.text(this.X, this.Y0, String(cfg.title ?? 'QUESTS').toUpperCase(), { + fontFamily: HEADER, + fontSize: '12px', + color: toCss(C.green), + fontStyle: 'bold', + letterSpacing: 3, + }) + .setOrigin(0, 0) + .setScrollFactor(0); + this.root.add(this.title); + this.rule = scene.add.graphics().setScrollFactor(0); + this.rule.clear(); + this.rule.lineStyle(1, C.green, 0.45); + this.rule.lineBetween(this.X, this.Y0 + 18, this.X + 54, this.Y0 + 18); + this.root.add(this.rule); + + // The dynamic content (rows / checklist / none / show-more) + the + // row popup — repainted on fingerprint change. + this._kids = []; // the current paint's scene objects (torn down on repaint) + this._rowRects = []; // { id, x, y, w, h } — the clickable rows + this._rowRectById = new Map(); + this._moreRect = null; // the SHOW MORE button's rect + this._popup = null; // { questId, rect, sourceRect, openedAt, btns, obj } + this._lastPopup = null; // { rect, at } — the just-closed popup (the same-frame guard) + this._fp = null; // the last painted fingerprint + this._priorityId = null; // the last resolved priority (for the popup) + this.state = 'hidden'; // hidden | showing | shown | hiding + this._destroyed = false; + + this.root.setPosition(this._offX, 0); // start offscreen-left + this.update(scene.time.now); // first paint (hidden — it slides in when the dossier folds) + } + + // ------------------------------------------------------------ lifecycle + /** Is a slide in flight? (the scene guards the dossier toggle on this) */ + isBusy() { + return this.state === 'showing' || this.state === 'hiding'; + } + + /** Occupies the screen (fully in, or still sliding in). */ + get shown() { + return this.state === 'shown' || this.state === 'showing'; + } + + /** Slide IN from offscreen-left (the dossier just folded). */ + show(now) { + if (this.state === 'shown' || this.state === 'showing') return; + this.closePopup(); + this.state = 'showing'; + this.root.setVisible(true); + this.root.setX(this._offX); + this._slide(0, () => { + this.state = 'shown'; + }); + } + + /** + * Slide OFF to offscreen-left (the dossier is about to re-open — make + * room BEFORE it unfolds). `onDone` fires when the slide lands (or + * immediately when already hidden) — the scene starts the dossier + * expand from there. + */ + hide(now, onDone) { + if (this.state === 'hidden') { + if (typeof onDone === 'function') onDone(); + return; + } + if (this.state === 'hiding') return; // the in-flight hide fires ITS onDone + this.closePopup(); + this.state = 'hiding'; + this._slide(this._offX, () => { + this.root.setVisible(false); + this.state = 'hidden'; + if (typeof onDone === 'function') onDone(); + }); + } + + /** One slide (a single x tween of the root container). */ + _slide(toX, after) { + // Kill any in-flight slide first (a show→hide race leaves two tweens + // fighting over the same x — killTweensOf is the codebase's pattern). + this.scene.tweens.killTweensOf(this.root); + this.scene.tweens.add({ + targets: this.root, + x: toX, + duration: this.slideMs, + ease: 'Sine.easeOut', + onComplete: () => { + if (typeof after === 'function') after(); + }, + }); + } + + // ------------------------------------------------------------ painting + /** + * Per-frame (GameScene.update): poll the live snapshot, resolve the + * priority, and repaint only when the fingerprint changes (a check + * completing, a percent ticking, the priority moving, a quest + * claimed/granted). The slide state is tween-driven, not polled. + */ + update(_time) { + let snap = null; + try { + snap = this.getSnapshot ? this.getSnapshot() : null; + } catch { + snap = null; + } + const quests = Array.isArray(snap?.quests) ? snap.quests : []; + // ACTIVE = held + not claimed, display order: main story first, then + // side quests (stable within a category — the data file's order). + const active = quests + .filter((q) => q && !q.claimed) + .sort((a, b) => catRank(a) - catRank(b)); + const ids = active.map((q) => q.id); + let priorityId = null; + if (this.resolvePriority) { + try { + priorityId = this.resolvePriority(ids) ?? null; + } catch { + priorityId = null; + } + } + const visible = pickVisible(active, priorityId, this.maxVisible); + const overflowFirst = active.length > visible.length ? active[visible.length] : null; + const fp = JSON.stringify({ + p: priorityId, + a: ids, + c: active.map((q) => { + const checks = q.checks ?? []; + return ( + checks.map((c) => (c?.done ? 1 : 0)).join('') + + '|' + + checks.map((c) => String(c?.detail ?? '')).join(',').slice(0, 40) + ); + }), + o: overflowFirst ? overflowFirst.id : null, + }); + if (fp === this._fp) return; + this._fp = fp; + this._priorityId = priorityId; + this._paint(active, visible, priorityId, overflowFirst); + } + + /** Tear down the dynamic content (the title + rule are permanent). */ + _teardown() { + for (const k of this._kids) { + try { + k.destroy(); + } catch { + /* already gone */ + } + } + this._kids = []; + this._rowRects = []; + this._rowRectById = new Map(); + this._moreRect = null; + this.closePopup(); + } + + _paint(active, visible, priorityId, overflowFirst) { + this._teardown(); + const s = this.scene; + const add = (obj) => { + this._kids.push(obj); + this.root.add(obj); + return obj; + }; + const cfg = config.section('quests.tracker', {}); + let y = this.Y0 + 32; + + if (active.length === 0) { + add( + s.add + .text(this.X, y, String(cfg.noneTracked ?? 'NONE TRACKED'), { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.faint), + letterSpacing: 2, + }) + .setOrigin(0, 0) + .setScrollFactor(0), + ); + return; + } + + for (const q of visible) { + const isPrio = q.id === priorityId; + const doneN = (q.checks ?? []).filter((c) => c?.done).length; + const total = (q.checks ?? []).length; + const pct = total > 0 ? Math.round((100 * doneN) / total) : 0; + const rx = this.X - 2; + const ry = y; + const rw = this.rowW; + const rh = 20; + + // The row plate (a VISIBLE object — the v4 hit-test rule) + the + // left tick (green = the priority quest, faint = the rest). + const plate = add(s.add.graphics().setScrollFactor(0)); + plate.clear(); + plate.fillStyle(C.panel, 0.35); + plate.fillRect(rx, ry, rw, rh); + plate.fillStyle(isPrio ? C.green : C.faint, isPrio ? 0.95 : 0.7); + plate.fillRect(rx, ry + 3, isPrio ? 2 : 1, rh - 6); + plate.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(rx, ry, rw, rh), + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + plate.on('pointerover', () => { + if (this.shown) this.scene.playSfx?.('ui_hover'); + }); + plate.on('pointerdown', () => { + if (!this.shown || this.isBusy()) return; + this.scene.playSfx?.('ui_click'); + this.openPopup(q, { x: rx, y: ry, w: rw, h: rh }); + }); + + add( + s.add + .text(this.X + 8, ry + 3, String(q.title ?? '').toUpperCase(), { + fontFamily: BODY, + fontSize: '12px', + color: toCss(isPrio ? C.ink : C.dim), + letterSpacing: 1, + }) + .setOrigin(0, 0) + .setScrollFactor(0), + ); + add( + s.add + .text(this.X + rw, ry + rh / 2, `${pct}%`, { + fontFamily: BODY, + fontSize: '11px', + color: toCss(isPrio ? C.green : C.dim), + letterSpacing: 1, + }) + .setOrigin(1, 0.5) + .setScrollFactor(0), + ); + const rect = { id: q.id, x: rx, y: ry, w: rw, h: rh }; + this._rowRects.push(rect); + this._rowRectById.set(q.id, rect); + y += rh + 6; + + // The priority quest's CHECKLIST — under its title, LIVE (the + // fingerprint repaints as checks complete): done = a green check + + // the dim label; open = a gray bullet, the label, and the detail. + if (isPrio) { + for (const c of q.checks ?? []) { + const label = String(c?.label ?? ''); + const detail = String(c?.detail ?? ''); + const line = c?.done + ? `✓ ${label}` + : detail && detail !== '??' + ? `· ${label} — ${detail}` + : `· ${label}`; + add( + s.add + .text(this.X + 14, y, line, { + fontFamily: BODY, + fontSize: '11px', + color: toCss(c?.done ? C.dim : C.faint), + letterSpacing: 0.5, + }) + .setOrigin(0, 0) + .setScrollFactor(0), + ); + y += 16; + } + y += 4; // a breath before the next quest + } + } + + // SHOW MORE — only when there is more beyond the cap (opens the + // QUESTS console on the first hidden quest — its tab is switched + // there, GameScene.openQuestAt → QuestWindow.openAt). + if (overflowFirst) { + const bw = 150; + const bh = 30; + const bx = this.X + bw / 2; + const by = y + bh / 2 + 4; + const btn = new MenuButton(s, bx, by, String(cfg.showMore ?? 'SHOW MORE'), () => { + this.onShowMore?.(overflowFirst.id); + }, { + width: bw, + fontSize: 11, + paddingX: 8, + paddingY: 6, + letterSpacing: 2, + upper: true, + screenFixed: true, + bgColor: C.panel, + hoverColor: C.panel, + textColor: toCss(C.ink), + }); + btn.style.stroke = C.dim; + btn.style.neon = C.green; + btn.paint('base'); + add(btn); + this._moreRect = { x: bx - bw / 2, y: by - bh / 2, w: bw, h: bh }; + y += bh + 8; + } + } + + // ------------------------------------------------------------ the row popup + /** + * The row's context popup (a click on a quest row): a small cut-corner + * panel to the right of the row with the quest's name and two buttons — + * SET PRIORITY (CLEAR PRIORITY, when this quest IS the priority) and + * VIEW QUEST (the QUESTS console on this quest). A click on a button + * is the button's (MenuButton's own pointerdown); any click ELSEWHERE + * closes the popup and is consumed (GameScene's pointerdown, via + * popupContains() below). + * @param {object} quest the snapshot quest (GameScene.questSnapshot shape) + * @param {{x:number,y:number,w:number,h:number}} rowRect the row clicked + */ + openPopup(quest, rowRect) { + if (!quest) return; + this.closePopup(); + const s = this.scene; + const isPrio = this._priorityId === quest.id; + const W = 212; + const mk = (label, onClick) => + new MenuButton(s, 0, 0, label, onClick, { + width: W - 24, + fontSize: 11, + paddingX: 8, + paddingY: 6, + letterSpacing: 1.5, + upper: true, + screenFixed: true, + bgColor: C.panel, + hoverColor: C.panel, + textColor: toCss(C.ink), + }); + const bPrio = mk(isPrio ? 'CLEAR PRIORITY' : 'SET PRIORITY', () => { + if (isPrio) this.onClearPriority?.(); + else this.onSetPriority?.(quest.id); + this.closePopup(); + }); + bPrio.style.stroke = isPrio ? C.faint : C.green; + bPrio.style.neon = isPrio ? C.dim : C.green; + bPrio.paint('base'); + const bView = mk('VIEW QUEST', () => { + this.onViewQuest?.(quest.id); + this.closePopup(); + }); + bView.style.stroke = C.dim; + bView.style.neon = C.green; + bView.paint('base'); + + const h1 = bPrio.style.height; + const h2 = bView.style.height; + const H = 26 + 6 + h1 + 6 + h2 + 8; + // The panel plate (a cut-corner plate — CyberShape at its centre). + const g = s.add.graphics().setScrollFactor(0); + g.clear(); + CyberShape.draw(g, W, H, { + notch: Math.min(12, H * 0.18), + fill: C.panel, + fillAlpha: 0.96, + stroke: isPrio ? C.faint : C.green, + strokeAlpha: 0.85, + lineWidth: 1.5, + glow: isPrio ? C.dim : C.green, + glowAlpha: 0.18, + }); + g.setPosition(W / 2, H / 2); + const header = s.add + .text(10, 8, String(quest.title ?? '').toUpperCase(), { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.dim), + letterSpacing: 1.5, + }) + .setOrigin(0, 0) + .setScrollFactor(0); + bPrio.setPosition(W / 2, 26 + h1 / 2); + bView.setPosition(W / 2, 26 + h1 + 6 + h2 / 2); + + // Placement: right of the row (falling back to its left when the + // right side runs off the screen edge). + const sw = s.scale.width; + let px = rowRect.x + rowRect.w + 14; + if (px + W > sw - 8) px = Math.max(8, rowRect.x - W - 14); + const py = Math.max(8, rowRect.y - 4); + + // ONE container — the popup slides/fades as a unit and is hit-tested + // as a whole (the scene's contract: inside = the popup's, outside = + // the close). + const cont = new Phaser.GameObjects.Container(s, px, py); + s.add.existing(cont); + cont.setScrollFactor(0); + cont.setDepth(31); // just above the tracker's own plate (30) + cont.add(g); + cont.add(header); + cont.add(bPrio); + cont.add(bView); + this._popup = { + questId: quest.id, + rect: { x: px, y: py, w: W, h: H }, + sourceRect: { ...rowRect }, // the row that opened it (the scene's same-click test) + openedAt: this.scene.time.now, + btns: [bPrio, bView], + obj: cont, + }; + this.scene.playSfx?.('ui_window'); + } + + /** Close the row popup (a no-op when it isn't up). */ + closePopup() { + const p = this._popup; + this._popup = null; + if (!p) return; + this._lastPopup = { rect: p.rect, at: this.scene.time.now }; // the same-frame guard + for (const b of p.btns) { + try { + b.destroy(); + } catch { + /* already gone */ + } + } + try { + p.obj.destroy(true); + } catch { + /* already gone */ + } + this.scene.playSfx?.('ui_close'); + } + + get popupOpen() { + return this._popup !== null; + } + + /** Is (px,py) on the open popup's plate? (the scene's pointerdown contract) */ + popupContains(px, py) { + const r = this._popup?.rect; + return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; + } + + /** + * Is (px,py) on the row that OPENED the popup? (The scene's contract: + * the opening click — same frame as openPopup — is swallowed; a later + * deliberate re-click on that row folds the popup.) + */ + sourceContains(px, py) { + const r = this._popup?.sourceRect; + return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; + } + + /** The popup was opened within `ms` of the scene's clock (same-frame test). */ + popupOpenedWithin(ms) { + const at = this._popup?.openedAt; + return typeof at === 'number' && (this.scene.time.now - at) < ms; + } + + /** + * Was the popup just closed (within one frame) and is (px,py) inside + * its former plate? (The scene's contract: a click the popup's OWN + * buttons just handled — they close it from their side first — is the + * popup's click, never a world click. The mining menu's guard.) + */ + closedPopupAt(px, py) { + const lp = this._lastPopup; + if (!lp || typeof lp.at !== 'number') return false; + if (this.scene.time.now - lp.at >= 40) return false; + const r = lp.rect; + return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; + } + + /** + * Is (px,py) inside the tracker's footprint — a row, the SHOW MORE + * button, or the open popup? (The scene's pointerdown: inside = the + * tracker's own click, never a fly-here; the mining menu's contract.) + */ + contains(px, py) { + if (this.popupContains(px, py)) return true; + for (const r of this._rowRects) { + if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return true; + } + const m = this._moreRect; + if (m && px >= m.x && px <= m.x + m.w && py >= m.y && py <= m.y + m.h) return true; + return false; + } + + /** The current resolved priority (the popup's SET/CLEAR choice). */ + get priorityId() { + return this._priorityId; + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + this.closePopup(); + this._teardown(); + try { + this.title.destroy(); + } catch { + /* already gone */ + } + try { + this.rule.destroy(); + } catch { + /* already gone */ + } + try { + this.root.destroy(true); + } catch { + /* already gone */ + } + } +} diff --git a/js/ui/QuestWindow.js b/js/ui/QuestWindow.js index d23431e..0bd6b0a 100644 --- a/js/ui/QuestWindow.js +++ b/js/ui/QuestWindow.js @@ -142,6 +142,11 @@ export class QuestWindow extends Phaser.GameObjects.Container { * (GameScene.questSnapshot; polled while open, repaint on change) * @param {(questId: string) => void} [o.onClaim] — CLAIM REWARD pressed * (the scene verifies + pays out) + * @param {(questId: string) => void} [o.onSetPriority] — SET PRIORITY + * pressed (the tracker HUD's featured quest; the scene sets it on the + * quest ledger + toasts — the tracker updates live) + * @param {() => (string|null)} [o.getPriority] — the current priority + * quest id (the detail plate's PRIORITY badge; a set/clear repaints) * @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit * (the scene toasts) */ @@ -154,12 +159,15 @@ export class QuestWindow extends Phaser.GameObjects.Container { this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null; this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : null; + this.onSetPriority = typeof o.onSetPriority === 'function' ? o.onSetPriority : null; + this.getPriority = typeof o.getPriority === 'function' ? o.getPriority : null; this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null; this.openState = 'closed'; // closed | opening | open | closing this.reveal = []; // { o, d, dur, mode, t0 } this.decodes = []; // { txt, dec } — polled ScrambleDecodes this.selected = null; // the selected quest id (the window's only state) + this._pendingSelect = null; // openAt's quest — the reveal lands on it this._activeTab = 'main'; this._tabs = []; this._rows = []; // the list's painted rows @@ -737,7 +745,13 @@ export class QuestWindow extends Phaser.GameObjects.Container { (q.checks ?? []).map((c) => (c.done ? 1 : 0)).join(''), (q.checks ?? []).map((c) => String(c.detail ?? '')).join('|').slice(0, 80), ]); - return JSON.stringify({ t: this._activeTab, s: this.selected, qs }); + let prio = null; + try { + prio = this.getPriority ? this.getPriority() : null; + } catch { + prio = null; + } + return JSON.stringify({ t: this._activeTab, s: this.selected, p: prio, qs }); } _teardownRows() { @@ -1024,6 +1038,62 @@ export class QuestWindow extends Phaser.GameObjects.Container { }); } this._claimMode = mode; + + // --- SET PRIORITY — the tracker HUD's featured quest (the green + // accent). This quest IS the priority → a static "◆ PRIORITY" badge + // (the player switches it by selecting ANOTHER quest's button); + // otherwise the SET PRIORITY button. A CLAIMED quest gets no + // priority control — it isn't active (the ledger's rule). + let priorityId = null; + try { + priorityId = this.getPriority ? this.getPriority() : null; + } catch { + priorityId = null; + } + if (!quest.claimed) { + const isPrio = priorityId === quest.id; + const pw = 200; + const px0 = claimX - 12 - pw; + const py0 = claimY; + const pg = add(s.graphics().setScrollFactor(0)); + pg.clear(); + panel(pg, px0, py0, pw, 34, { + notch: 8, + fill: isPrio ? 0x082010 : C.panel, + fillAlpha: isPrio ? 0.7 : 0.5, + stroke: C.green, + strokeAlpha: isPrio ? 1 : 0.7, + lineWidth: isPrio ? 2 : 1.5, + glow: C.green, + glowAlpha: isPrio ? 0.3 : 0.12, + }); + add( + s + .text(px0 + pw / 2, py0 + 17, isPrio ? '◆ PRIORITY' : 'SET PRIORITY', { + fontFamily: BODY, + fontSize: '12px', + color: toCss(isPrio ? C.green : C.ink), + letterSpacing: 2, + }) + .setOrigin(0.5) + .setScrollFactor(0), + ); + if (!isPrio) { + pg.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(px0, py0, pw, 34), + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + pg.on('pointerover', () => { + if (this.isOpen) this.sfx('ui_hover'); + }); + pg.on('pointerdown', () => { + if (!this.isOpen) return; + this.sfx('ui_click'); + this.onSetPriority?.(quest.id); + }); + } + } } _paintStatus() { @@ -1075,6 +1145,33 @@ export class QuestWindow extends Phaser.GameObjects.Container { this._startReveal(this.scene.time.now); } + /** + * Open on a SPECIFIC quest (the tracker HUD's SHOW MORE / VIEW QUEST): + * its tab becomes active and it becomes the selection — the reveal + * (or, when the console is already open, a repaint) lands on it. + */ + openAt(questId) { + if (typeof questId !== 'string' || questId === '') return; + let snap = this._snap; + if (!snap && typeof this.getSnapshot === 'function') { + try { + snap = this.getSnapshot(); + } catch { + snap = null; + } + } + const q = ((snap?.quests ?? [])).find((x) => x && x.id === questId) ?? null; + if (!q) return; + if (q.category) this._activeTab = q.category; + if (this.openState === 'open') { + this.selected = q.id; + this._snap = snap ?? this._snap; + this._paintAll(this._snap); // tab + list + plate follow the selection + } else { + this._pendingSelect = q.id; // the reveal (_startReveal) picks it up + } + } + close() { if (this.openState === 'closed' || this.openState === 'closing') return; this.openState = 'closing'; @@ -1118,8 +1215,19 @@ export class QuestWindow extends Phaser.GameObjects.Container { push(this.listCont, 380, 300); push(this.detailCont, 440, 300); this.reveal = list; - // re-select the first quest (fresh open) - this._selectFirstForTab(this._activeTab); + // the selection — a PENDING one (openAt — the tracker's SHOW MORE / + // VIEW QUEST lands here) beats the tab's first quest (fresh open). + if (typeof this._pendingSelect === 'string' && this._pendingSelect !== '') { + const id = this._pendingSelect; + this._pendingSelect = null; + const q = ((this._snap?.quests ?? [])).find((x) => x && x.id === id) ?? null; + if (q) { + if (q.category) this._activeTab = q.category; + this.selected = q.id; + } + } else { + this._selectFirstForTab(this._activeTab); + } // the glitch clock const g = config.get('quests.glitch', {}); const [ia, ib] = g.intervalMs ?? [4200, 9200];