Add quests console with live progress tracking and claimable rewards

- Introduce QuestState ledger (granted/claimed ids) persisted via SaveData, with safe fromJSON validation that drops unknown ids and enforces claimed ⊆ granted
- Define the starter quest "Start Your Journey" in data/quests.json with four live checks: research node, lifetime asteroid mining, home tether level, and discovered world count
- Add totalMined counter tracked on each ore event to support the mining requirement independently of cargo hold
- Build QuestWindow (full-screen console at depth 80) with video feed, category tabs (Main Story live, Side Quests standby), quest list, detail plate with checklist and CLAIM REWARD action
- Wire QUESTS slot into both flight and surface command decks right of SHIP; SurfaceScene delegates snapshot/claim to the sleeping GameScene while rendering its own console notes
- Grant starter quest on first boot with delayed toast; announce "QUEST READY" once per quest when all checks complete
- Update research-builds tests for seven-slot deck layout; add dev/quests.test.mjs covering data contract, ledger behavior, save round-trip, and deck slot placement
This commit is contained in:
Brian Fertig 2026-09-08 09:32:12 -06:00
parent b1ae21e95e
commit ce26b38df9
12 changed files with 2119 additions and 11 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/videos/quests.mp4 Normal file

Binary file not shown.

View File

@ -1,5 +1,5 @@
{
"_comment": "COMMAND DECK — the cyberpunk bar across the bottom of the screen (rendered by js/ui/ActionBar.js). Six evenly spaced slots: `buttons` is the ORDERED list — give a slot an `id` (the action it will fire) + `label` + `accent`, or leave both `null` for a reserved slot. Buttons currently fire `onAction` (see GameScene); the behavior behind them comes next. Everything visual is procedural (no image assets) and tunable here: layout in `height`/`margin`/`padding`/`button`, palette in `colors`, CRT scanlines in `scanline`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the RGB pull-apart).",
"_comment": "COMMAND DECK — the cyberpunk bar across the bottom of the screen (rendered by js/ui/ActionBar.js). Seven evenly spaced slots: `buttons` is the ORDERED list — give a slot an `id` (the action it will fire) + `label` + `accent`, or leave both `null` for a reserved slot. Buttons currently fire `onAction` (see GameScene); the behavior behind them comes next. Everything visual is procedural (no image assets) and tunable here: layout in `height`/`margin`/`padding`/`button`, palette in `colors`, CRT scanlines in `scanline`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the RGB pull-apart).",
"enabled": true,
"height": 56,
"margin": { "left": 20, "right": 20, "bottom": 12 },
@ -15,6 +15,7 @@
{ "id": "research", "label": "Research", "accent": "#00e5ff" },
{ "id": "scan", "label": "Scan", "accent": "#ffc94d" },
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
{ "id": "quests", "label": "Quests", "accent": "#ffc94d" },
{ "id": "map", "label": "Map", "accent": "#c084fc" },
{ "id": null, "label": null },
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
@ -25,6 +26,7 @@
{ "id": "shop", "label": "Shop", "labelActive": "Leave Shop", "accent": "#00e5ff" },
{ "id": "build", "label": "Build", "accent": "#ffc94d" },
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
{ "id": "quests", "label": "Quests", "accent": "#ffc94d" },
{ "id": null, "label": null },
{ "id": "takeoff", "label": "Take Off", "accent": "#7ce8a4" },
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }

View File

@ -26,6 +26,7 @@
"signalCompass.json",
"save.json",
"mineralhud.json",
"map.json"
"map.json",
"quests.json"
]
}

83
data/quests.json Normal file
View File

@ -0,0 +1,83 @@
{
"_comment": "The QUESTS console (deck slot right of SHIP, main deck + surface deck). The window mirrors the RESEARCH/MAP consoles (js/ui/QuestWindow.js): a 2:3 archive feed (assets/videos/quests.mp4 — the same aspect as assets/videos/research.mp4; NO SIGNAL plate until the clip is authored) on the left, and on the right the category tabs (Main Story / Side Quests — side quests are not defined yet: the tab is a STANDBY socket), a THIN list of active quests, and the big detail plate below it: title, issuer, description, the requirement checklist, and the reward, with the CLAIM action. A quest's progress is computed LIVE from the run (research, lifetime minerals mined from asteroids, the home tether's level, discovery) — js/quests/QuestState.js only tracks who holds which quest and whether its reward has been claimed, and that is what gets saved.",
"enabled": true,
"title": "OPERATIONS DOSSIER",
"meta": "MISSION LOG // ACTIVE CONTRACTS",
"video": {
"file": "quests.mp4",
"_comment": "The archive feed (assets/videos/). quests.mp4 mirrors the 2:3 aspect of research.mp4 — fit the same way.",
"aspect": [2, 3]
},
"sweep": {
"_comment": "The band that crawls down the feed (the research/map console's sweep, tuned for this window).",
"enabled": true,
"intervalMs": [5200, 9800],
"durationMs": [1400, 2200]
},
"glitch": {
"enabled": true,
"intervalMs": [4200, 9200],
"durationMs": [110, 260],
"slices": [1, 4],
"maxOffset": 9
},
"tabs": [
{
"id": "main",
"label": "Main Story",
"accent": "#00e5ff",
"_comment": "The campaign's spine. Live — the starter quest is granted on the run's first boot."
},
{
"id": "side",
"label": "Side Quests",
"accent": "#ffc94d",
"standby": true,
"_comment": "The socket for side quests — deferred by design. Pressing it toasts the standby note until side quests are authored (quests.json → quests, category 'side')."
}
],
"sideStandbyNote": "SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET",
"starterToast": {
"_comment": "The one-shot note when the starter quest lands on a fresh ledger (GameScene.create).",
"glyph": "◆",
"color": "#ffc94d",
"text": "NEW QUEST — {title}"
},
"readyToast": {
"glyph": "◆",
"color": "#ffc94d",
"text": "QUEST READY — {title} · CLAIM ITS REWARD"
},
"claimToast": {
"glyph": "✓",
"color": "#67e863",
"text": "REWARD CLAIMED — +{n} MINERALS"
},
"claimDenyToast": {
"glyph": "✗",
"color": "#ff5c77",
"text": "REQUIREMENTS INCOMPLETE — {missing}"
},
"quests": [
{
"id": "start_your_journey",
"category": "main",
"starter": true,
"icon": "diamond",
"title": "Start Your Journey",
"description": "The universe is cold, and it ain't gonna do you any favors kid. The sooner you discover that, the sooner you'll be able to start taking care of yourself. And what better way to discover the universe, than to discover the universe.",
"issuer": "A Friend",
"_comment": "The first quest, granted on the run's first boot (js/quests/QuestState.js → starters). Its checklist is the run's opening arc — research, mine, build, discover. The checks are LIVE: 'research' resolves against data/research/exploration.json (the Tether Level 2 node), 'mineralsMined' against the run's lifetime asteroid mining (the Mining sequence's onOre), 'tetherBuilt' against the home world's standing tether level, 'discoveredWorld' against the discovery ledger (any discovered world besides the home).",
"checks": [
{ "type": "research", "category": "exploration", "node": "tether_l2", "label": "Research Tether Level 2" },
{ "type": "mineralsMined", "amount": 200, "label": "Mine 200 Minerals from asteroids" },
{ "type": "tetherBuilt", "planet": "home", "level": 2, "label": "Build a Level 2 Tether on your Home world" },
{ "type": "discoveredWorld", "count": 1, "label": "Discover another planet or space station" }
],
"reward": {
"minerals": 200,
"_comment": "The completion reward — 200 Minerals, claimed via the CLAIM REWARD action (fits the ship's hold: ship.stats.mineralStorage)."
}
}
]
}

200
dev/quests.test.mjs Normal file
View File

@ -0,0 +1,200 @@
/**
* Quests data-layer test (dev tool, run with Node no browser):
*
* node dev/quests.test.mjs
*
* Pins the contract behind the QUESTS console (the deck's QUESTS button
* right of SHIP, flight deck + surface deck js/ui/QuestWindow.js):
* - quests.json is registered in data/manifest.json;
* - the window's knobs are present (title, the 2:3 video feed the
* same dimensions as the research feed, sweep/glitch, the two
* category tabs with SIDE marked standby, the toasts);
* - the starter quest is well-formed: Main Story, one starter flag,
* a non-empty checklist of KNOWN requirement types, a reward that
* fits the ship's hold;
* - the 'research' check points at a REAL node of its category's tree
* (data/research/<category>.json the check would never complete
* otherwise);
* - 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);
* - both command decks carry the QUESTS slot, right of SHIP.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const dataDir = join(root, 'data');
const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
const manifest = read('manifest.json');
const quests = read('quests.json');
const actionbar = read('actionbar.json');
const ship = read('ship.json');
// The real code (config singleton → the quest helpers + the ledger).
const { config } = await import(join(root, 'js/config/Config.js'));
config.init({ quests, ship });
const { QuestState, questDefs, questDef, starterQuestIds } = await import(
join(root, 'js/quests/QuestState.js')
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const hex = /^#[0-9a-fA-F]{6}$/;
// ----------------------------------------------------------------------
// 1. Manifest registration
// ----------------------------------------------------------------------
check('manifest registers quests.json', manifest.files.includes('quests.json'));
// ----------------------------------------------------------------------
// 2. quests.json — the window's knobs
// ----------------------------------------------------------------------
check('quests: master switch present', typeof quests.enabled === 'boolean');
check('quests: enabled (the console is live)', quests.enabled === true);
check('quests: title present', typeof quests.title === 'string' && quests.title.length > 0);
check('quests: video file named', typeof quests.video?.file === 'string' && quests.video.file.length > 0);
check('quests: video aspect is 2:3 (the research feed\u2019s dimensions)',
JSON.stringify(quests.video?.aspect) === JSON.stringify([2, 3]));
check('quests: sweep configured (interval + duration ranges)',
Array.isArray(quests.sweep?.intervalMs) && quests.sweep.intervalMs.length === 2 &&
Array.isArray(quests.sweep?.durationMs) && quests.sweep.durationMs.length === 2);
check('quests: glitch configured', typeof quests.glitch?.enabled === 'boolean' &&
Array.isArray(quests.glitch?.intervalMs) && Array.isArray(quests.glitch?.slices));
// ----------------------------------------------------------------------
// 3. The category tabs — Main Story live, Side Quests the standby socket
// ----------------------------------------------------------------------
const tabs = quests.tabs ?? [];
check('quests: two category tabs', tabs.length === 2);
const mainTab = tabs.find((t) => t.id === 'main');
const sideTab = tabs.find((t) => t.id === 'side');
check('quests: the MAIN tab is live', !!mainTab && mainTab.standby !== true && typeof mainTab.label === 'string' && hex.test(mainTab.accent ?? ''));
check('quests: the SIDE tab is the standby socket', !!sideTab && sideTab.standby === true && typeof sideTab.label === 'string' && hex.test(sideTab.accent ?? ''));
check('quests: the standby note is present', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0);
// ----------------------------------------------------------------------
// 4. The toasts — starter / ready / claim / claim-denied
// ----------------------------------------------------------------------
for (const key of ['starterToast', 'readyToast', 'claimToast', 'claimDenyToast']) {
const t = quests[key];
check(`quests: ${key} configured (glyph + text)`, !!t && typeof t.glyph === 'string' && typeof t.text === 'string' && hex.test(t.color ?? ''));
}
// ----------------------------------------------------------------------
// 5. The starter quest — well-formed, one starter, real checks, fair reward
// ----------------------------------------------------------------------
check('quests: at least one quest defined', Array.isArray(quests.quests) && quests.quests.length >= 1);
const starters = (quests.quests ?? []).filter((q) => q.starter === true);
check('quests: exactly ONE starter quest', starters.length === 1);
const starter = starters[0];
check('starter: sits in the MAIN story', starter?.category === 'main');
check('starter: has id/title/description/issuer', typeof starter?.id === 'string' && typeof starter?.title === 'string' && typeof starter?.description === 'string' && typeof starter?.issuer === 'string');
check('starter: icon names a research icon', typeof starter?.icon === 'string' && starter.icon.length > 0);
check('starter: a non-empty checklist', Array.isArray(starter?.checks) && starter.checks.length >= 1);
check('starter: every check has a type + a player-facing label',
(starter?.checks ?? []).every((c) => typeof c.type === 'string' && typeof c.label === 'string' && c.label.length > 0));
check('starter: reward is a positive integer (minerals)',
Number.isInteger(starter?.reward?.minerals) && starter.reward.minerals > 0);
check('starter: reward fits the ship\u2019s hold',
starter.reward.minerals <= (ship.stats?.mineralStorage ?? 0));
// The requirement types the scene evaluates (GameScene._questCheckEval)
// — a typo here would be a checklist row that never completes.
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld']);
check('starter: every check type is a known live check',
(starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
// Each check shape — the parameters _questCheckEval reads.
for (const c of starter?.checks ?? []) {
if (c.type === 'research') {
check(`starter check "${c.label}": names category + node`, typeof c.category === 'string' && typeof c.node === 'string');
// The node must exist in its category\u2019s tree — otherwise the
// requirement could never read DONE (the research.json registry
// knows which categories are static file trees).
const tree = read(`research/${c.category}.json`);
check(`starter check "${c.label}": the node exists in data/research/${c.category}.json`,
!!tree?.nodes && typeof tree.nodes[c.node] === 'object');
} else if (c.type === 'mineralsMined') {
check(`starter check "${c.label}": a positive integer amount`, Number.isInteger(c.amount) && c.amount > 0);
} else if (c.type === 'tetherBuilt') {
check(`starter check "${c.label}": planet + level set`,
(c.planet === 'home' || typeof c.planet === 'string') && Number.isInteger(c.level) && c.level >= 1);
} else if (c.type === 'discoveredWorld') {
check(`starter check "${c.label}": a positive integer count`, Number.isInteger(c.count) && c.count >= 1);
}
}
// ----------------------------------------------------------------------
// 6. The quest ledger — QuestState (give/claim/save-restore)
// ----------------------------------------------------------------------
check('helpers: questDefs() sees the defined quests', questDefs().length === (quests.quests ?? []).length);
check('helpers: questDef() finds the starter by id', questDef(starter.id)?.title === starter.title);
check('helpers: questDef() is null for an unknown id', questDef('no_such_quest') === null);
check('helpers: starterQuestIds() is exactly [the starter]',
JSON.stringify(starterQuestIds()) === JSON.stringify([starter.id]));
{
const st = new QuestState();
check('ledger: fresh — nothing granted, nothing claimed', !st.isGranted(starter.id) && !st.isClaimed(starter.id));
check('ledger: give() grants a known quest', st.give(starter.id) === true);
check('ledger: give() is idempotent', st.give(starter.id) === false);
check('ledger: give() ignores an unknown id', st.give('no_such_quest') === false && !st.isGranted('no_such_quest'));
check('ledger: claim() pays out once', st.claim(starter.id) === true && st.isClaimed(starter.id));
check('ledger: claim() is idempotent', st.claim(starter.id) === false);
const fresh = new QuestState();
check('ledger: claim() refuses a quest not held', fresh.claim(starter.id) === false && !fresh.isClaimed(starter.id));
}
// The save round-trip (SaveData captures toJSON(), prepareLoad reads fromJSON).
{
const st = new QuestState();
st.give(starter.id);
st.claim(starter.id);
const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())));
check('ledger: save round-trip keeps granted + claimed',
restored.isGranted(starter.id) && restored.isClaimed(starter.id));
}
// A corrupted save degrades — never throws, never pays out a quest the
// player does not hold.
{
check('ledger: fromJSON(malformed) → empty ledger', (() => {
try {
const st = QuestState.fromJSON({ granted: 'x', claimed: [starter.id] });
return !st.isGranted(starter.id) && !st.isClaimed(starter.id);
} catch { return false; }
})());
check('ledger: fromJSON drops ids the data no longer defines', (() => {
const st = QuestState.fromJSON({ granted: ['no_such_quest'], claimed: ['no_such_quest'] });
return st.granted.size === 0 && st.claimed.size === 0;
})());
check('ledger: fromJSON keeps claimed ⊆ granted', (() => {
const st = QuestState.fromJSON({ granted: [starter.id], claimed: [] });
return st.isGranted(starter.id) && !st.isClaimed(starter.id);
})());
}
// ----------------------------------------------------------------------
// 7. The command decks — the QUESTS slot, right of SHIP (both decks)
// ----------------------------------------------------------------------
for (const [deck, slots] of [
['flight', actionbar.buttons ?? []],
['surface', actionbar.surface?.buttons ?? []],
]) {
const iShip = slots.findIndex((s) => s.id === 'ship');
check(`actionbar (${deck}): the QUESTS slot sits right of the SHIP slot`,
iShip >= 0 && slots[iShip + 1]?.id === 'quests');
check(`actionbar (${deck}): the QUESTS slot carries a label + accent`, (() => {
const q = slots.find((s) => s.id === 'quests');
return q && typeof q.label === 'string' && q.label.length > 0 && hex.test(q.accent ?? '');
})());
}
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -284,13 +284,17 @@ check('builds._template.repeatable is a boolean', typeof bt.repeatable === 'bool
check('builds._template.requires is an array', Array.isArray(bt.requires));
// ----------------------------------------------------------------------
// 6. Command deck — six evenly spaced slots, right order
// 6. Command deck — seven evenly spaced slots, right order
// (Quests joined the flight deck to the right of Ship — the
// mission dossier; the surface deck carries the same slot.)
// ----------------------------------------------------------------------
const slots = actionbar.buttons ?? [];
check('actionbar: exactly six slots', slots.length === 6);
check('actionbar: slot ids in order (Research, Scan, Ship, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'map', null, 'menu']));
check('actionbar: labels (Research / Scan / Ship / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Map', null, 'Menu']));
check('actionbar: the MAP slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'map'; })());
check('actionbar: exactly seven slots', slots.length === 7);
check('actionbar: slot ids in order (Research, Scan, Ship, Quests, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'quests', 'map', null, 'menu']));
check('actionbar: labels (Research / Scan / Ship / Quests / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Quests', 'Map', null, 'Menu']));
check('actionbar: the QUESTS slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'quests'; })());
check('actionbar: the QUESTS slot carries a label + accent', (() => { const q = slots.find((s) => s.id === 'quests'); return q && typeof q.label === 'string' && hex.test(q.accent ?? ''); })());
check('actionbar: the MAP slot sits right of the QUESTS slot', (() => { const iQ = slots.findIndex((s) => s.id === 'quests'); return iQ >= 0 && slots[iQ + 1]?.id === 'map'; })());
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? '')));
check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null));
check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number');
@ -299,8 +303,9 @@ check('actionbar: sane geometry (height 40200 px)', typeof actionbar.height =
// the surface deck (SurfaceScene) — Shop for Research, Build, Take Off
const sSlots = actionbar.surface?.buttons ?? [];
check('actionbar: surface deck has six slots', sSlots.length === 6);
check('actionbar: surface slot ids in order (Shop, Build, Ship, ·, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', null, 'takeoff', 'menu']));
check('actionbar: surface deck has seven slots', sSlots.length === 7);
check('actionbar: surface slot ids in order (Shop, Build, Ship, Quests, ·, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', 'quests', null, 'takeoff', 'menu']));
check('actionbar: the QUESTS slot is on the surface deck (right of SHIP)', (() => { const iShip = sSlots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && sSlots[iShip + 1]?.id === 'quests'; })());
check('actionbar: the BUILD slot is on the surface deck', sSlots.some((s) => s.id === 'build'));
check('actionbar: the BUILD slot carries a label + accent', (() => { const b = sSlots.find((s) => s.id === 'build'); return b && typeof b.label === 'string' && hex.test(b.accent ?? ''); })());

103
js/quests/QuestState.js Normal file
View File

@ -0,0 +1,103 @@
/**
* The quest ledger the run's quest STATE (pure; the data is
* data/quests.json).
*
* A quest's REQUIREMENTS are not tracked here they are computed LIVE
* from the run when the scene snapshots the dossier (GameScene.
* questSnapshot): research progress, lifetime minerals mined from
* 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
*
* 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.
*/
import { config } from '../config/Config.js';
/** @returns {Array<object>} every quest defined (data/quests.json → quests) */
export function questDefs() {
const list = config.get('quests.quests', []);
return Array.isArray(list) ? list : [];
}
/** @returns {object|null} the quest definition with this id (or null) */
export function questDef(id) {
return questDefs().find((q) => q && q.id === id) ?? null;
}
/** @returns {string[]} the ids granted on a FRESH run (quests.json → starter) */
export function starterQuestIds() {
return questDefs()
.filter((q) => q && q.starter === true)
.map((q) => q.id);
}
/**
* A held-quest ledger. The ids come from data/quests.json (validated
* against it on load a save pointing at a removed quest just drops it).
*/
export class QuestState {
constructor() {
this.granted = new Set();
this.claimed = new Set();
}
/**
* Grant a quest (idempotent re-granting one already held is a no-op).
* Unknown ids are ignored. @returns {boolean} true when NEWLY granted
*/
give(id) {
if (typeof id !== 'string' || id === '') return false;
if (!questDef(id)) return false;
if (this.granted.has(id)) return false;
this.granted.add(id);
return true;
}
isGranted(id) {
return this.granted.has(id);
}
isClaimed(id) {
return this.claimed.has(id);
}
/**
* Mark a quest's reward as paid out (idempotent). Only quests that are
* held + fully satisfied should be claimed the satisfaction is the
* scene's call (GameScene.claimQuest checks the live snapshot).
* @returns {boolean} true when NEWLY claimed
*/
claim(id) {
if (!this.granted.has(id)) return false;
if (this.claimed.has(id)) return false;
this.claimed.add(id);
return true;
}
toJSON() {
return { granted: [...this.granted], claimed: [...this.claimed] };
}
/**
* Restore a ledger from a save record (SaveData prepareLoad). A
* malformed record yields an empty ledger rather than a throw quests
* are a progression nicety, not a crash vector.
*/
static fromJSON(data) {
const st = new QuestState();
if (!data || typeof data !== 'object') return st;
const grant = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : []);
for (const id of grant(data.granted)) {
if (questDef(id)) st.granted.add(id);
}
for (const id of grant(data.claimed)) {
if (st.granted.has(id)) st.claimed.add(id); // claimed ⊆ granted
}
return st;
}
}

View File

@ -27,6 +27,8 @@
* // the bright lanes)
* research: ResearchState.toJSON() | null,
* builds: BuildState.toJSON() | null,
* quests: QuestState.toJSON() | null, // held + claimed quest ids
* totalMined: number, // lifetime asteroid mining
* playTimeMs }
*
* captureState(scene) GameScene record (the Save panel calls it)
@ -46,6 +48,7 @@ import { config } from '../config/Config.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Reputation } from '../reputation/Reputation.js';
import { QuestState } from '../quests/QuestState.js';
import { SAVE_FORMAT, SaveManager } from './SaveManager.js';
export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
@ -110,6 +113,16 @@ export function captureState(scene, now) {
builds: scene.buildState
? scene.buildState.toJSON(buildNow)
: null,
// Quests — the run's mission ledger: which quests are HELD (granted)
// and which rewards have been CLAIMED. Progress itself is NOT saved —
// it is computed live from the run's state (GameScene.questSnapshot).
// A save predating quests has no field; the restore stages null and
// the starter quest is granted onto the fresh ledger (old saves load).
quests: scene.questState ? scene.questState.toJSON() : null,
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
// requirement's input — the hold is cargo, this is the record).
// A save predating it has no field; the restore stages 0.
totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 0)),
// The SYSTEM research category's activation keys (which jump gates —
// incl. other systems' return gates — the run has activated; the
// per-run world state GameScene keeps in its registry-backed set).
@ -196,6 +209,14 @@ export function prepareLoad(registry, record) {
? { systemId: record.destination.systemId, objectId: record.destination.objectId ?? null }
: null,
);
// The quest ledger (a save predating quests has no field → null — the
// fresh run's GameScene starts an empty ledger and grants the starter
// quest; old saves load). The validation inside fromJSON keeps a
// corrupted ledger from breaking the load (claimed ⊆ granted, ids must
// exist in data/quests.json).
registry.set('quests', record.quests ? QuestState.fromJSON(record.quests) : null);
// Lifetime asteroid mining (a save predating it has no field → 0).
registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0)));
registry.set(PENDING_RESTORE_KEY, {
ship: record.ship,
tethers: Array.isArray(record.tethers) ? record.tethers : [],
@ -229,6 +250,8 @@ export function resetRunState(registry) {
registry.set('usedGates', null);
registry.set('destination', null);
registry.set('routeNotice', null);
registry.set('quests', null);
registry.set('totalMined', null);
registry.set(PENDING_RESTORE_KEY, null);
}

View File

@ -33,6 +33,8 @@ import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
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 { QuestState, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
import { planRoute } from '../galaxy/Route.js';
@ -201,6 +203,20 @@ export class GameScene extends Phaser.Scene {
}
}
// The quests console's mission feed (data/quests.json → video): a muted
// 2:3 loop behind the QUESTS window — the same dimensions as the
// research feed. Same contract — a missing file just leaves the
// window's NO SIGNAL plate up.
if (config.get('quests.enabled', true)) {
const questVideo = String(config.get('quests.video.file') ?? '');
if (questVideo) {
const url = /^(https?:)?\/\//.test(questVideo) || questVideo.startsWith('assets/')
? questVideo
: `assets/videos/${questVideo}`;
this.load.video(QuestWindow.VIDEO_KEY, url);
}
}
// The build console's feed (data/builds.json → video): a muted 2:3
// loop behind the BUILD window on a planet surface (SurfaceScene's
// BuildWindow reads the shared cache). A missing file just leaves the
@ -656,7 +672,14 @@ export class GameScene extends Phaser.Scene {
this.mining = new Mining(this, {
onPhase: (p) => this.onMiningPhase(p),
onEvent: (name, data) => this.onMiningEvent(name, data),
onOre: () => this.refreshMineralHud(), // live: the hold fills as the rock shrinks
onOre: (gained) => {
this.refreshMineralHud(); // live: the hold fills as the rock shrinks
// Lifetime asteroid mining — the quest ledger's input (the
// 'Mine 200 Minerals from asteroids' requirement counts this,
// not the hold — the hold is cargo, this is the record).
this.totalMined = (this.totalMined ?? 0) + Math.max(0, Number(gained) || 0);
this._questRefresh();
},
});
this.miningPopup = new MiningPopup(this, {
onAction: (id, rock) => this.miningAction(id, rock),
@ -742,6 +765,44 @@ export class GameScene extends Phaser.Scene {
},
),
});
// ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
// left the 2:3 mission feed (assets/videos/quests.mp4 — the same
// dimensions as the research feed), right the MAIN STORY / SIDE QUESTS
// tabs (side is a STANDBY socket — side quests are deferred by design),
// a THIN list of the active quests, and the big detail plate below:
// name, issuer, description, the requirement checklist, and the reward
// with the CLAIM action. The surface deck's QUESTS slot opens the same
// window over the paused GameScene's state (SurfaceScene.buildDeck).
// A quest's progress is computed LIVE (questSnapshot below — research,
// lifetime asteroid mining, the home tether's standing, the discovery
// ledger); the ledger itself — which quests are held and which rewards
// have been claimed — is the QuestState in the shared registry (like
// discovery/research) and rides the saves (record.quests, plus the
// lifetime counter record.totalMined the mining requirement reads).
this.questState = this.registry.get('quests') ?? null;
if (!this.questState) {
this.questState = new QuestState();
this.registry.set('quests', this.questState);
}
this._questAnnounced = new Set(); // the 'QUEST READY' toasts, once each
this.questWindow = new QuestWindow(this, {
getSnapshot: () => this.questSnapshot(),
onClaim: (id) => this.claimQuest(id),
onLocked: (tabId) =>
this.consoleToast(
tabId === 'side'
? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET')
: 'NO SIGNAL',
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
),
});
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
// requirement's input — the hold is cargo, this is the ledger). Registry
// -backed like discovery: a load stages the saved value (prepareLoad),
// a fresh run starts at zero, and the New Game reset clears it.
this.totalMined = Math.max(0, Math.round(Number(this.registry.get('totalMined')) || 0));
// deck progress bar — drawn over the RESEARCH button while a project runs
this._researchDeckBar = {
g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false),
@ -782,6 +843,34 @@ export class GameScene extends Phaser.Scene {
this.applyRestore(this._pendingRestore);
this._pendingRestore = null;
}
// The starter quest — granted ONCE onto a fresh ledger (a loaded save's
// ledger already holds whatever was granted back then — and give() is
// idempotent, so a jump-cut restart that rebuilds this scene off the
// registry's ledger stays silent). It is the run's opening arc: the
// checklist (data/quests.json) is the first loop — research, mine,
// build, discover.
if (config.get('quests.enabled', true) !== false) {
for (const id of starterQuestIds()) {
if (this.questState.give(id)) {
const def = questDef(id);
this.playSfx('discovery'); // the 'something new is here' voice
// The toast lands AFTER the opening 'DISCOVERED — HOME WORLD'
// toast (one toast slot — the later call replaces the earlier):
// ~2.4s is that toast's life, so +0.3s keeps the order readable.
this.time.delayedCall(2700, () => {
this.consoleToast(
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
.replace('{title}', String(def?.title ?? id).toUpperCase()),
{
glyph: config.get('quests.starterToast.glyph', '◆'),
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
});
}
}
}
// The field's native set (home + the activated gates) is above; the
// tethers the player INSTALLED on this system's other worlds are
// world state (built records) — re-anchor them on their worlds, since
@ -878,6 +967,10 @@ export class GameScene extends Phaser.Scene {
if (this.researchWindow && this.researchWindow.isOpen) return;
// The map console (depth 80) — the same full-screen contract.
if (this.mapWindow && this.mapWindow.isOpen) return;
// The quests console (depth 80) — the same full-screen contract.
if (this.questWindow && this.questWindow.isOpen) return;
// The quests console (depth 80) — the same full-screen contract.
if (this.questWindow && this.questWindow.isOpen) return;
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
// its buttons — the buttons fire on their own pointerdown). ANY
// click OUTSIDE — world, deck, HUD, compass — folds it back down
@ -1374,6 +1467,7 @@ export class GameScene extends Phaser.Scene {
}
this.researchWindow?.update(_time); // the console's living details (open state)
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
// Builds: a build started on a surface keeps running in space if the
// player took off mid-build (game-loop clock — see beginBuild); when
@ -2387,6 +2481,7 @@ export class GameScene extends Phaser.Scene {
glyphColor: toCss(neon),
durationMs: 2400,
});
this._questRefresh(); // a 'Discover another world' requirement may have just landed
}
showTargetMarker(x, y) {
@ -2435,6 +2530,18 @@ export class GameScene extends Phaser.Scene {
this.researchWindow?.open();
return;
}
// The quests console (full-screen, depth 80) — the deck's third console.
if (id === 'quests') {
if (this.questWindow && this.questWindow.isOpen) {
this.questWindow.close();
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();
this.questWindow?.open();
return;
}
// The map console (full-screen, depth 80) — the deck's other console.
if (id === 'map') {
if (this.mapWindow && this.mapWindow.isOpen) {
@ -2458,6 +2565,225 @@ export class GameScene extends Phaser.Scene {
console.info(`[orbit] command deck: ${id}`);
}
// ------------------------------------------------------------ quests
/**
* The live dossier snapshot (QuestWindow polls it while open):
* the categories (tabs) + every HELD quest with its checklist
* evaluated against the run RIGHT NOW research unlocks, lifetime
* asteroid mining, the home tether's standing, the discovery ledger.
* The window is a pure view over this; the scene owns the effects
* (claimQuest) the same split as the research/map consoles.
* @returns {{categories: Array<object>, quests: Array<object>}|null}
*/
questSnapshot() {
if (!this.questState) return null;
const tabs = config.get('quests.tabs', []);
const categories = (Array.isArray(tabs) ? tabs : []).map((c) => ({
id: String(c?.id ?? ''),
label: String(c?.label ?? c?.id ?? ''),
standby: c?.standby === true,
accent: c?.accent ?? null,
}));
const quests = [];
for (const def of questDefs()) {
if (!def || !this.questState.isGranted(def.id)) continue;
const checks = (Array.isArray(def.checks) ? def.checks : []).map((c) => this._questCheckEval(c));
const doneCount = checks.filter((c) => c.done).length;
const complete = checks.length > 0 && doneCount === checks.length;
quests.push({
id: def.id,
category: def.category ?? 'main',
title: def.title ?? def.id,
description: def.description ?? '',
issuer: def.issuer ?? '',
icon: def.icon ?? 'diamond',
checks,
reward: this._questRewardLabel(def),
complete,
claimed: this.questState.isClaimed(def.id),
progress: `${doneCount}/${checks.length}`,
});
}
return { categories, quests };
}
/**
* One checklist requirement, evaluated LIVE (data/quests.json
* quests[].checks[].type). Pure reads no effects here.
* research the node is unlocked (data/research/<cat>.json)
* mineralsMined lifetime asteroid mining >= amount (this.totalMined)
* tetherBuilt the world's tether standing >= level ('home' = the home world)
* discoveredWorld discovered planets/stations (not the home world) >= count
*/
_questCheckEval(check) {
const label = String(check?.label ?? '');
const type = String(check?.type ?? '');
if (type === 'research') {
const cat = String(check.category ?? '');
const node = String(check.node ?? '');
const done = !!this.researchState?.isUnlocked?.(cat, node);
return { label, done, detail: done ? 'RESEARCHED' : 'PENDING' };
}
if (type === 'mineralsMined') {
const amount = Math.max(0, Math.floor(Number(check.amount) || 0));
const got = Math.min(amount, Math.floor(this.totalMined ?? 0));
const done = amount > 0 && got >= amount;
return { label, done, detail: `${got}/${amount}` };
}
if (type === 'tetherBuilt') {
const level = Math.max(1, Math.floor(Number(check.level) || 1));
const name = check.planet === 'home' ? this.homeWorldName : String(check.planet ?? '');
const have = Math.max(0, this.tetherLevelFor(name) ?? 0);
const done = have >= level;
return { label, done, detail: `L${have}/L${level}` };
}
if (type === 'discoveredWorld') {
const count = Math.max(1, Math.floor(Number(check.count) || 1));
const have = Math.min(count, this.discoveredWorldCount());
const done = have >= count;
return { label, done, detail: `${have}/${count}` };
}
return { label, done: false, detail: '??' };
}
/**
* How many DISCOVERED worlds are planets or space stations (the
* 'Discover another planet or space station' requirement). The home
* world never counts (it is the origin, not a discovery), and neither
* do asteroid clusters / jump gates (the requirement is a WORLD or a
* STATION). The ledger is per-system (Discovery), so the count walks
* every system the player has found something in: the current system's
* objects are the scene's own entities; other systems resolve their
* ids against the generated content (galaxy.contentOf deterministic,
* cached once generated).
*/
discoveredWorldCount() {
const disc = this.discovery;
if (!disc || !disc.bySystem) return 0;
const cur = this.systemRecord?.id ?? null;
let n = 0;
for (const [sysId, ids] of disc.bySystem) {
let planetIds = null;
let stationIds = null;
if (sysId === cur) {
planetIds = new Set((this.systemPlanets ?? []).map((p) => p.discoveryId));
stationIds = new Set((this.systemStations ?? []).map((st) => st.discoveryId));
} else if (this.galaxy && typeof this.galaxy.contentOf === 'function') {
let content = null;
try {
content = this.galaxy.contentOf(sysId);
} catch {
content = null;
}
planetIds = new Set(((content?.planets ?? []).map((r) => r?.name)).filter(Boolean));
stationIds = new Set(
((content?.settlements ?? []).filter((s) => s?.anchor?.type === 'space').map((s) => s?.id)).filter(Boolean),
);
}
if (!planetIds && !stationIds) continue;
for (const id of ids) {
if (!id || id === 'home') continue;
if (planetIds.has(id) || stationIds.has(id)) n++;
}
}
return n;
}
/** The reward line for a quest def (data/quests.json → reward object). */
_questRewardLabel(def) {
const rw = def?.reward;
if (rw && typeof rw === 'object') {
const parts = [];
if (typeof rw.minerals === 'number') parts.push(`${rw.minerals} MINERALS`);
if (typeof rw.credits === 'number') parts.push(`${rw.credits} CREDITS`);
return parts.length ? parts.join(' · ') : '';
}
return String(rw ?? '');
}
/**
* The CLAIM REWARD action (QuestWindow's button here). The window
* only offers the button when the checklist reads complete; this is
* the belt-and-braces pass re-evaluates the live checklist and pays
* the reward exactly once (the ledger's claim() is idempotent, so a
* double-press or a re-open can never pay twice). The scene owns the
* effect (the minerals land in the hold); the ledger owns the record.
*/
claimQuest(id) {
if (!this.questState || !this.questState.isGranted(id)) return;
if (this.questState.isClaimed(id)) {
this.questWindow?.refresh();
return;
}
const def = questDef(id);
if (!def) return;
const snap = this.questSnapshot();
const q = snap?.quests?.find((x) => x.id === id);
if (!q || !q.complete) {
// Requirements still open — a refusal, in the console's voice.
const missing = (q?.checks ?? []).filter((c) => !c.done).map((c) => c.label.toUpperCase());
const t = config.get('quests.claimDenyToast', {});
this.consoleToast(
String(t.text ?? 'REQUIREMENTS INCOMPLETE — {missing}').replace('{missing}', (missing.slice(0, 2).join(' · ') || 'REQUIREMENTS')),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
},
);
this.questWindow?.refresh();
return;
}
if (!this.questState.claim(id)) return; // someone beat us to it — idempotent
// The reward: minerals into the hold (clamped to the ship's storage —
// addMinerals hands back what fit, so a full hold just wastes the
// excess, the game's rule for mined ore).
const rw = def.reward ?? {};
if (typeof rw.minerals === 'number' && this.ship) {
this.ship.addMinerals(rw.minerals);
this.refreshMineralHud();
}
this.playSfx('construct'); // the power-up tick — the reward lands
const t = config.get('quests.claimToast', {});
this.consoleToast(
String(t.text ?? 'REWARD CLAIMED — +{n} MINERALS').replace('{n}', String(rw.minerals ?? 0)),
{
glyph: t.glyph ?? '✓',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
},
);
this.questWindow?.refresh();
}
/**
* A quest-relevant event happened (research completed, ore mined, a
* build finished, a world discovered, a reward claimed). Announce each
* quest that just became claimable ONCE (the console's voice), and kick
* the open console's poll so it repaints without waiting the interval.
*/
_questRefresh() {
if (!this.questState) return;
const snap = this.questSnapshot();
if (snap) {
for (const q of snap.quests) {
if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) {
this._questAnnounced.add(q.id);
const t = config.get('quests.readyToast', {});
this.consoleToast(
String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')
.replace('{title}', String(q.title).toUpperCase()),
{
glyph: t.glyph ?? '◆',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
}
}
}
this.questWindow?.refresh();
}
/**
* The GALAXY tab's live snapshot (js/ui/GalaxyView.js renders it, polled
* the same 500 ms the system chart is js/ui/MapWindow.js). Pure data
@ -3013,6 +3339,7 @@ export class GameScene extends Phaser.Scene {
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
this.researchWindow?.refresh();
this._questRefresh(); // a 'Research …' requirement may have just landed
}
/**
@ -3121,6 +3448,7 @@ export class GameScene extends Phaser.Scene {
completeBuild(planetName, buildId) {
const def = buildDefs()[buildId];
if (def?.effects) this._applyBuildEffects(planetName, def.effects);
this._questRefresh(); // a 'Build a Level 2 Tether' requirement may have just landed
}
/**
@ -3463,6 +3791,11 @@ export class GameScene extends Phaser.Scene {
this.mapWindow.close();
return;
}
// The quests console (depth 80) — the same contract.
if (this.questWindow && this.questWindow.isOpen) {
this.questWindow.close();
return;
}
// The mining context menu is the topmost open thing after the save UI.
if (this.miningPopup && this.miningPopup.isOpen) {
this.miningPopup.close();

View File

@ -10,6 +10,7 @@ import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { buildDefs } from '../research/ResearchModel.js';
/**
@ -543,6 +544,24 @@ export class SurfaceScene extends Phaser.Scene {
})
: null;
// The QUESTS console (the deck's QUESTS slot — right of SHIP): the
// SAME window as the flight deck's (js/ui/QuestWindow.js), opened
// over the SLEEPING GameScene's dossier — the same split as the build
// console (state + effects on the GameScene, the view here). The
// snapshot/claim delegates to the game scene; the console note is
// ours (we are the scene the player sees).
this.questWindow = new QuestWindow(this, {
getSnapshot: () => this.gameScene?.questSnapshot?.() ?? null,
onClaim: (id) => this.claimSurfaceQuest(id),
onLocked: (tabId) =>
this.consoleNote(
tabId === 'side'
? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET')
: 'NO SIGNAL',
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
),
});
// ESC — topmost open thing first (mirror of GameScene.escAction).
// (The pointerdown binding lives in create() — it must also be
// live on the landing stage, before the deck exists.)
@ -573,6 +592,14 @@ export class SurfaceScene extends Phaser.Scene {
this.buildWindow?.open();
return;
}
if (id === 'quests') {
if (this.questWindow?.isOpen) {
this.questWindow.close();
} else {
this.questWindow?.open();
}
return;
}
if (id === 'shop') {
this.toggleShop();
return;
@ -652,12 +679,46 @@ export class SurfaceScene extends Phaser.Scene {
playSfxOn(this, name, o);
}
/** ESC: build console → confirm dialog → save pop-up → sub-bar. */
/**
* The CLAIM REWARD action from the surface console the effect lands
* on the SLEEPING GameScene (the quest ledger + the ship's hold are
* its; the beginBuild precedent), and the console note is ours (we are
* the scene the player sees a sleeping scene's toasts don't animate).
*/
claimSurfaceQuest(id) {
const g = this.gameScene;
if (!g?.claimQuest) return;
const before = g.questState?.isClaimed(id) ?? false;
g.claimQuest(id);
const now = g.questState?.isClaimed(id) ?? false;
if (!before && now) {
const t = config.get('quests.claimToast', {});
this.consoleNote(String(t.text ?? 'REWARD CLAIMED'), {
glyph: t.glyph ?? '✓',
glyphColor: t.color ? toCss(t.color) : undefined,
});
} else if (before !== now) {
// Still open — the refusal, in the console's voice (the game
// scene's own toast is behind the sleeping scene — this is the one
// the player reads).
const t = config.get('quests.claimDenyToast', {});
this.consoleNote(
String(t.text ?? 'REQUIREMENTS INCOMPLETE').replace('{missing}', 'REQUIREMENTS'),
{ glyph: t.glyph ?? '✗', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
}
/** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */
escAction() {
if (this.buildWindow?.isOpen) {
this.buildWindow.close();
return;
}
if (this.questWindow?.isOpen) {
this.questWindow.close();
return;
}
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
@ -769,6 +830,7 @@ export class SurfaceScene extends Phaser.Scene {
this.lastClickT = now;
if (this.buildWindow?.isOpen) return; // the console owns the click
if (this.questWindow?.isOpen) return; // the quests console owns the click
if (this.savePanel?.isOpen) return; // the modal scrim owns the click
if (this.menuSubBar?.isOpen) {
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
@ -905,6 +967,7 @@ export class SurfaceScene extends Phaser.Scene {
}
}
this.buildWindow?.update(time);
this.questWindow?.update(time);
}
shutdown() {

1295
js/ui/QuestWindow.js Normal file

File diff suppressed because it is too large Load Diff