Add Explore drawer with Bar service and monotonic quest checklist steps
- Replace the surface deck's SHIP slot with an EXPLORE drawer that folds up a full-width secondary bar (MenuSubBar span mode) carrying per-world service items resolved by a `where` clause matcher (world / planetClass / faction, case-insensitive, list any-match) - Add the neutral capital's BAR clip toggle: swaps the surface loop for the world's bar video, relabels the slot to LEAVE BAR, and locks every other deck + drawer button until pressed back - Record quest checklist steps as DONE on the ledger (QuestState.completed set) so a step that stands on the home world keeps standing after the ship leaves; save/load round-trips the witness and old saves degrade cleanly - QuestWindow fresh open now lands on the run's priority quest (tracker HUD seam) with its tab activated, falling back to the active tab's first quest when no priority is set - MenuButton.setLabel re-fits the panel and hit rect in place for the LEAVE BAR relabel; ExploreBar.js provides the pure worldContext / matchesWhere / resolveItems item logic - Add dev tests: explore.test.mjs (deck, items, where-clause, span, relabel) and quest-window.test.mjs (open landing + priority), plus updates to quests/research-builds tests for the new surface deck layout
This commit is contained in:
parent
1e3beccb22
commit
fb4c8d1d3c
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -20,15 +20,22 @@
|
|||
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
||||
],
|
||||
"surface": {
|
||||
"_comment": "Deck layout for the surface (SurfaceScene): `research` is replaced by `shop` (toggles the world's shop clip — while the clip is up the slot relabels to `labelActive`, and pressing it swaps back) and `takeoff` sits to the left of `menu`. SurfaceScene passes this list to ActionBar via its `buttons` option; everything else (height, colors, animation) is shared with the flight deck.",
|
||||
"_comment": "Deck layout for the surface (SurfaceScene): `research` is replaced by `shop` (toggles the world's shop clip — while the clip is up the slot relabels to `labelActive`, and pressing it swaps back), `explore` folds up the world's service drawer (see `explore` below), and `takeoff` sits to the left of `menu`. SurfaceScene passes this list to ActionBar via its `buttons` option; everything else (height, colors, animation) is shared with the flight deck.",
|
||||
"buttons": [
|
||||
{ "id": "shop", "label": "Shop", "labelActive": "Leave Shop", "accent": "#00e5ff" },
|
||||
{ "id": "build", "label": "Build", "accent": "#ffc94d" },
|
||||
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
|
||||
{ "id": "explore", "label": "Explore", "accent": "#7ce8a4" },
|
||||
{ "id": "quests", "label": "Quests", "accent": "#ffc94d" },
|
||||
{ "id": "takeoff", "label": "Take Off", "accent": "#7ce8a4" },
|
||||
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
||||
]
|
||||
],
|
||||
"explore": {
|
||||
"_comment": "The EXPLORE drawer — the full-width secondary bar (the deck's own left→right span, resting on its top rail — the shop tab bar's recipe) that folds up from the surface deck's Explore slot. SurfaceScene renders it with js/ui/MenuSubBar.js in span mode; the item LOGIC is js/ui/ExploreBar.js (worldContext / matchesWhere / resolveItems). `items` is the ordered button list: `id` (fired via onAction — the behavior lands later), `label`, `labelActive` (the label while the service is up — BAR relabels to LEAVE BAR while its clip loops, and is the only live button on the surface until pressed), `accent`, and an optional `where` clause — EVERY key it names must match the world the player is standing on for the button to appear. Keys: `world` ('planet' | 'station'); `planetClass` (the planet's class — 'terran' | 'rocky' | 'gas' | 'ice' | 'lava' | 'arid' | 'capitalNeutral' | 'capitalApex' | 'capitalMindweave' | 'capitalZenith', per data/planets.json → frames; 'none' on stations); `faction` (the faction controlling this system — 'apex' | 'mindweave' | 'zenith', or 'neutral' = wild space; per data/factions.json). A value may be a single entry or a list (any-match). No `where` = every planet and station (Job Board). The bar's clip is data/landing.json → videos[frame].bar (the neutral capital's capital-bar-neutral.mp4).",
|
||||
"items": [
|
||||
{ "id": "jobBoard", "label": "Job Board", "accent": "#ffc94d" },
|
||||
{ "id": "bar", "label": "Bar", "labelActive": "Leave Bar", "accent": "#00e5ff", "where": { "planetClass": "capitalNeutral" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"panelTop": "#101c36",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
"`shop` — the looping clip the SHOP button swaps in over the surface",
|
||||
" clip (press SHOP again to swap back). null/missing = no shop",
|
||||
" on that world (a console note instead of a clip).",
|
||||
"`bar` — the looping clip the EXPLORE drawer's BAR button (the neutral",
|
||||
" capital) swaps in over the surface clip (press LEAVE BAR to",
|
||||
" swap back). null/missing = no bar on that world.",
|
||||
"`takeoff` — the full-screen one-shot clip played on TAKE OFF, before the",
|
||||
" flight world resumes. A null/missing entry takes off instantly.",
|
||||
"",
|
||||
|
|
@ -73,7 +76,7 @@
|
|||
{ "land": "ice-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
|
||||
{ "land": "ice-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
|
||||
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
|
||||
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4" },
|
||||
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4", "bar": "capital-bar-neutral.mp4" },
|
||||
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
|
||||
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
|
||||
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_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.",
|
||||
"_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 evaluated LIVE from the run (research, lifetime minerals mined from asteroids, the home tether's level, discovery) and, once a step reads DONE, it is recorded on the ledger and stays done (monotonic — the home tether's step keeps standing after the ship leaves the home system). js/quests/QuestState.js tracks who holds which quest, which reward has been claimed, and which steps have stood — and that is what gets saved.",
|
||||
"enabled": true,
|
||||
"title": "OPERATIONS DOSSIER",
|
||||
"meta": "MISSION LOG // ACTIVE CONTRACTS",
|
||||
|
|
@ -106,7 +106,7 @@
|
|||
"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).",
|
||||
"_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 evaluated LIVE (GameScene._questCheckEval): '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). Once a step reads DONE it is RECORDED on the ledger (QuestState's completed set — rides record.quests) and stays done: monotonic, the check keeps standing even where the live read can no longer see it (the home tether's standing once the ship has left the home system).",
|
||||
"checks": [
|
||||
{ "type": "research", "category": "exploration", "node": "tether_l2", "label": "Research Tether Level 2" },
|
||||
{ "type": "mineralsMined", "amount": 200, "label": "Mine 200 Minerals from asteroids" },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
/**
|
||||
* EXPLORE bar test (dev tool, run with Node — no browser needed):
|
||||
*
|
||||
* node --import ./dev/phaser-loader.mjs dev/explore.test.mjs
|
||||
*
|
||||
* The surface deck's Explore slot + the full-width EXPLORE drawer
|
||||
* (js/ui/ExploreBar.js item logic + js/ui/MenuSubBar.js's span/items
|
||||
* options, driven headlessly):
|
||||
*
|
||||
* - DECK — the surface deck swapped SHIP for EXPLORE (six slots, the
|
||||
* flight deck's ship slot untouched).
|
||||
* - ITEMS — data/actionbar.json → surface.explore.items: Job Board on
|
||||
* EVERY planet and station (no `where`), Bar only on the neutral
|
||||
* capital (`planetClass: capitalNeutral`), and nothing extra on a
|
||||
* rocky world or a station.
|
||||
* - WHERE — the clause matcher: world / planetClass / faction keys,
|
||||
* single + list values, case-insensitive; stations have no class
|
||||
* (`none`); neutral wild space reads `faction: 'neutral'`.
|
||||
* - SPAN — MenuSubBar in span mode stretches across the deck's
|
||||
* left→right edges, resting on its top rail (the shop tab bar's
|
||||
* span), buttons packed at the left; without span the classic
|
||||
* right-aligned fit is unchanged (the menu drawer).
|
||||
* - RE-LABEL — MenuSubBar.setLabel (→ MenuButton.setLabel, the
|
||||
* ActionBar.setLabel mirror): the bar slot's LEAVE BAR swap — label +
|
||||
* finalLabel, the panel re-fits wider and back exactly, the hit rect
|
||||
* tracks the panel — and the drawer's other item stands down
|
||||
* (setDisabled) while the bar slot stays live (the setBarLocked
|
||||
* drawer-half).
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --- Load the real config (data/*.json) into the config singleton ------
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const fs = await import('node:fs');
|
||||
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 { worldContext, matchesWhere, resolveItems } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/ui/ExploreBar.js')).href
|
||||
);
|
||||
const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default;
|
||||
const { MenuSubBar } = await import(pathToFileURL(join(__dirname, '../js/ui/MenuSubBar.js')).href);
|
||||
|
||||
const ids = (list) => list.map((i) => i.id);
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== DECK — the surface deck swapped SHIP for EXPLORE ==');
|
||||
{
|
||||
const SURFACE = config.get('actionbar.surface.buttons');
|
||||
const IDS = SURFACE.map((b) => b.id).filter(Boolean);
|
||||
assert.equal(IDS.length, 6, 'the surface deck keeps six slots');
|
||||
assert.ok(IDS.includes('explore'), 'the surface deck has the explore slot');
|
||||
assert.ok(!IDS.includes('ship'), 'ship is off the surface deck');
|
||||
assert.ok(IDS.includes('shop') && IDS.includes('menu'), 'shop + menu are still there');
|
||||
// The flight deck is untouched (its ship slot stays — only planets changed).
|
||||
const FLIGHT = config.get('actionbar.buttons');
|
||||
assert.ok(FLIGHT.map((b) => b.id).includes('ship'), 'the flight deck keeps its ship slot');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== CONTEXT — world / planetClass / faction resolve from the run ==');
|
||||
{
|
||||
// A rocky world in neutral wild space.
|
||||
const rocky = worldContext(
|
||||
{ isStation: false, name: 'Klaxoria' },
|
||||
{ systemPlanets: [{ discoveryName: 'Klaxoria', name: 'rocky' }], systemRecord: { faction: null } },
|
||||
);
|
||||
assert.equal(rocky.world, 'planet');
|
||||
assert.equal(rocky.planetClass, 'rocky');
|
||||
assert.equal(rocky.faction, 'neutral', 'a null faction reads neutral wild space');
|
||||
|
||||
// The same world inside an APEX-controlled system.
|
||||
const apex = worldContext(
|
||||
{ isStation: false, name: 'Klaxoria' },
|
||||
{ systemPlanets: [{ discoveryName: 'Klaxoria', name: 'rocky' }], systemRecord: { faction: 'apex' } },
|
||||
);
|
||||
assert.equal(apex.faction, 'apex');
|
||||
|
||||
// The neutral capital world (the planet entity lowercases its class —
|
||||
// SystemGenerator's 'capitalNeutral' arrives as 'capitalneutral'; the
|
||||
// clause matcher is case-insensitive either way).
|
||||
const capital = worldContext(
|
||||
{ isStation: false, name: 'Civitas' },
|
||||
{ systemPlanets: [{ discoveryName: 'Civitas', name: 'capitalneutral' }], systemRecord: { faction: null } },
|
||||
);
|
||||
assert.equal(capital.planetClass, 'capitalneutral');
|
||||
|
||||
// A deep-space station in a MINDWEAVE system — no planet class.
|
||||
const station = worldContext(
|
||||
{ isStation: true, name: 'Hell\u2019s Hideout' },
|
||||
{ systemPlanets: [], systemRecord: { faction: 'mindweave' } },
|
||||
);
|
||||
assert.equal(station.world, 'station');
|
||||
assert.equal(station.planetClass, null, 'stations carry no planet class');
|
||||
assert.equal(station.faction, 'mindweave');
|
||||
|
||||
// The HOME world (gameScene.planet — not in systemPlanets).
|
||||
const home = worldContext(
|
||||
{ isStation: false, name: 'Terra' },
|
||||
{ systemPlanets: [], planet: { discoveryName: 'Terra', name: 'terran' }, systemRecord: { faction: null } },
|
||||
);
|
||||
assert.equal(home.planetClass, 'terran', 'the home world resolves via gameScene.planet');
|
||||
|
||||
// No game scene at all (defensive) — the context still forms.
|
||||
const bare = worldContext({ isStation: false, name: 'X' }, null);
|
||||
assert.equal(bare.planetClass, null);
|
||||
assert.equal(bare.faction, 'neutral');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== ITEMS — Job Board everywhere, Bar on the neutral capital ==');
|
||||
{
|
||||
const mk = (cls, { isStation = false, faction = null } = {}) =>
|
||||
worldContext(
|
||||
{ isStation, name: 'World' },
|
||||
{
|
||||
systemPlanets: isStation ? [] : [{ discoveryName: 'World', name: cls }],
|
||||
systemRecord: { faction },
|
||||
},
|
||||
);
|
||||
|
||||
// Job Board: every planet…
|
||||
for (const cls of ['terran', 'rocky', 'gas', 'ice', 'lava', 'arid', 'capitalApex']) {
|
||||
const live = ids(resolveItems(mk(cls)));
|
||||
assert.ok(live.includes('jobBoard'), `job board on a ${cls} world`);
|
||||
}
|
||||
// …and every station.
|
||||
assert.ok(ids(resolveItems(mk(null, { isStation: true }))).includes('jobBoard'), 'job board on a station');
|
||||
// …and inside faction space.
|
||||
assert.ok(ids(resolveItems(mk('rocky', { faction: 'zenith' }))).includes('jobBoard'), 'job board in zenith space');
|
||||
|
||||
// Bar: the neutral capital ONLY.
|
||||
assert.deepEqual(ids(resolveItems(mk('capitalNeutral'))), ['jobBoard', 'bar'], 'bar on the neutral capital');
|
||||
for (const cls of ['rocky', 'terran', 'gas', 'ice', 'lava', 'arid', 'capitalApex', 'capitalMindweave', 'capitalZenith']) {
|
||||
assert.ok(!ids(resolveItems(mk(cls))).includes('bar'), `no bar on a ${cls} world`);
|
||||
}
|
||||
assert.ok(!ids(resolveItems(mk(null, { isStation: true }))).includes('bar'), 'no bar on a station');
|
||||
// The neutral capital's system is still faction-neutral — the bar is about
|
||||
// the WORLD (planetClass), not the system's faction.
|
||||
assert.deepEqual(ids(resolveItems(mk('capitalNeutral', { faction: null }))), ['jobBoard', 'bar']);
|
||||
|
||||
// Config shape: the bar's items are ordered (Job Board before Bar).
|
||||
const ORDER = ids(resolveItems(mk('capitalNeutral')));
|
||||
assert.deepEqual(ORDER, ['jobBoard', 'bar'], 'the config order is the bar order');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== WHERE — clause matching (single + list values, case-insensitive) ==');
|
||||
{
|
||||
const rocky = worldContext(
|
||||
{ isStation: false, name: 'W' },
|
||||
{ systemPlanets: [{ discoveryName: 'W', name: 'rocky' }], systemRecord: { faction: null } },
|
||||
);
|
||||
const apex = worldContext(
|
||||
{ isStation: false, name: 'W' },
|
||||
{ systemPlanets: [{ discoveryName: 'W', name: 'rocky' }], systemRecord: { faction: 'apex' } },
|
||||
);
|
||||
const station = worldContext(
|
||||
{ isStation: true, name: 'S' },
|
||||
{ systemPlanets: [], systemRecord: { faction: 'zenith' } },
|
||||
);
|
||||
|
||||
assert.ok(matchesWhere(null, rocky), 'no clause → everywhere');
|
||||
assert.ok(matchesWhere({}, rocky), 'empty clause → everywhere');
|
||||
assert.ok(matchesWhere({ world: 'planet' }, rocky));
|
||||
assert.ok(!matchesWhere({ world: 'station' }, rocky));
|
||||
assert.ok(matchesWhere({ world: 'station' }, station));
|
||||
assert.ok(matchesWhere({ planetClass: 'rocky' }, rocky));
|
||||
assert.ok(matchesWhere({ planetClass: ['rocky', 'ice'] }, rocky), 'list = any-match');
|
||||
assert.ok(matchesWhere({ planetClass: 'ROCKY' }, rocky), 'case-insensitive');
|
||||
assert.ok(!matchesWhere({ planetClass: 'capitalNeutral' }, rocky));
|
||||
assert.ok(matchesWhere({ planetClass: 'none' }, station), 'stations match planetClass none');
|
||||
assert.ok(!matchesWhere({ planetClass: 'rocky' }, station), '…but not a real class');
|
||||
assert.ok(matchesWhere({ faction: 'neutral' }, rocky));
|
||||
assert.ok(!matchesWhere({ faction: 'apex' }, rocky));
|
||||
assert.ok(matchesWhere({ faction: 'apex' }, apex));
|
||||
assert.ok(matchesWhere({ faction: ['apex', 'zenith'] }, apex), 'faction lists any-match');
|
||||
assert.ok(matchesWhere({ world: 'planet', faction: 'apex', planetClass: 'rocky' }, apex), 'AND across keys');
|
||||
assert.ok(!matchesWhere({ world: 'planet', faction: 'apex', planetClass: 'ice' }, apex));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== SPAN — MenuSubBar stretches across the deck (the shop bar\u2019s span) ==');
|
||||
{
|
||||
// The headless scene (the actionbar test's recipe) — just enough for the
|
||||
// drawer to construct, open, and drive its state machine.
|
||||
function stubObject(extra = {}) {
|
||||
return {
|
||||
x: 0, y: 0, width: 60, height: 18, alpha: 1,
|
||||
setOrigin() { return this; },
|
||||
setAlpha(a) { this.alpha = a; return this; },
|
||||
setScale() { return this; },
|
||||
setTint() { return this; },
|
||||
setPosition(x, y) { this.x = x; this.y = y; return this; },
|
||||
setStrokeStyle() { return this; },
|
||||
setSize(w, h) { this.width = w; this.height = h; return this; },
|
||||
setBlendMode() { return this; },
|
||||
setScrollFactor() { return this; },
|
||||
setDepth() { return this; },
|
||||
setInteractive() { return this; },
|
||||
disableInteractive() { return this; },
|
||||
on() { return this; },
|
||||
once() { return this; },
|
||||
off() { return this; },
|
||||
// setText keeps .width fresh, like Phaser (MenuButton.setLabel
|
||||
// re-measures against it); a deterministic fake: 7 px per glyph.
|
||||
setText(s) { this.text = String(s); this.width = Math.max(20, String(s).length * 7); return this; },
|
||||
setColor(c) { this.color = c; return this; },
|
||||
setFontSize() { return this; },
|
||||
clear() { return this; },
|
||||
fillStyle() { return this; },
|
||||
lineStyle() { return this; },
|
||||
fillPoints() { return this; },
|
||||
strokePoints() { return this; },
|
||||
fillRect() { return this; },
|
||||
strokeRect() { return this; },
|
||||
lineBetween() { return this; },
|
||||
destroy() { this.destroyed = true; },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
function makeScene() {
|
||||
return {
|
||||
scale: { width: 1280, height: 720 },
|
||||
add: {
|
||||
existing: (o) => o,
|
||||
text: (x, y, str) => stubObject({ x, y, text: String(str), width: Math.max(20, String(str).length * 7) }),
|
||||
image: (x, y, key) => stubObject({ x, y, key }),
|
||||
graphics: () => stubObject({}),
|
||||
rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||||
tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||||
circle: (x, y, r) => stubObject({ x, y, radius: r }),
|
||||
},
|
||||
tweens: {
|
||||
add: (t) => {
|
||||
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
|
||||
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
|
||||
else if (t?.targets) apply(t.targets);
|
||||
return { destroy() {}, stop() {} };
|
||||
},
|
||||
killTweensOf() {},
|
||||
},
|
||||
time: {
|
||||
now: 1000,
|
||||
delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; },
|
||||
},
|
||||
textures: { exists: () => true },
|
||||
};
|
||||
}
|
||||
|
||||
const ITEMS = config.get('actionbar.surface.explore.items');
|
||||
assert.ok(Array.isArray(ITEMS) && ITEMS.length === 2, 'the config carries the explore items');
|
||||
|
||||
// FULL-SPAN — the deck's rect (the actionbar margins: left 20, right 20 →
|
||||
// a 1240 px span at 1280 wide) is the drawer's span, resting on the
|
||||
// deck's top rail (y = deck.rect.y).
|
||||
const deckRect = { x: 20, y: 720 - 12 - 56, w: 1280 - 40, h: 56 };
|
||||
const fired = [];
|
||||
const spanBar = new MenuSubBar(makeScene(), {
|
||||
anchor: { x: 640, y: deckRect.y, w: 150 },
|
||||
span: { left: deckRect.x, right: deckRect.x + deckRect.w },
|
||||
items: ITEMS,
|
||||
onAction: (id) => fired.push(id),
|
||||
});
|
||||
assert.equal(spanBar.W, deckRect.w, 'the bar spans the deck\u2019s full width');
|
||||
assert.equal(spanBar.rect.x, 20, 'left edge on the deck\u2019s left edge');
|
||||
assert.equal(spanBar.rect.x + spanBar.rect.w, 1260, 'right edge on the deck\u2019s right edge');
|
||||
assert.equal(spanBar.y, deckRect.y, 'the bottom edge rests on the deck\u2019s top rail');
|
||||
assert.deepEqual(spanBar.buttons.map((s) => s.id), ['jobBoard', 'bar'], 'the world\u2019s items, in config order');
|
||||
assert.ok(spanBar.contains(640, deckRect.y - 20), 'a point inside the bar is inside the bar');
|
||||
assert.ok(!spanBar.contains(640, deckRect.y + 10), 'a point over the deck is outside the bar');
|
||||
|
||||
// The open cycle: closed → opening → open (driven by update), and the
|
||||
// drawer fires its items (the scene's seam — exploreAction).
|
||||
assert.equal(spanBar.state, 'closed');
|
||||
spanBar.open();
|
||||
assert.ok(spanBar.isOpen, 'the bar is open while unfolding');
|
||||
for (let t = 1000; t <= 1600; t += 50) spanBar.update(t, 50);
|
||||
assert.equal(spanBar.state, 'open', 'the unfold lands on open');
|
||||
spanBar.fire('jobBoard');
|
||||
spanBar.fire('bar');
|
||||
assert.deepEqual(fired, ['jobBoard', 'bar'], 'presses fire the item ids');
|
||||
spanBar.close();
|
||||
assert.equal(spanBar.closing, true, 'the close animates');
|
||||
for (let t = 1600; t <= 2000; t += 20) spanBar.update(t, 20);
|
||||
assert.equal(spanBar.state, 'closed', 'the fold lands on closed');
|
||||
|
||||
// RE-LABEL — the bar slot's LEAVE BAR (MenuSubBar.setLabel →
|
||||
// MenuButton.setLabel, the ActionBar.setLabel mirror): the label +
|
||||
// finalLabel swap (a re-open re-decodes to the held label), the panel
|
||||
// re-fits to the longer label — wider in, EXACTLY back out — and the
|
||||
// hit rect tracks the panel. The drawer's other item stands down
|
||||
// (setDisabled, the setBarLocked drawer-half) while the bar slot itself
|
||||
// stays live — it is the way out.
|
||||
spanBar.open();
|
||||
for (let t = 2000; t <= 2800; t += 50) spanBar.update(t, 50);
|
||||
assert.equal(spanBar.state, 'open', 're-opened for the relabel pass');
|
||||
const barSlot = spanBar.buttons.find((b) => b.id === 'bar');
|
||||
const barBtn = barSlot.btn;
|
||||
const jobSlot = spanBar.buttons.find((b) => b.id === 'jobBoard');
|
||||
const wBar = barBtn.style.width;
|
||||
assert.ok(wBar > 0, 'the bar button has a measured width');
|
||||
assert.equal(spanBar.setLabel('bar', 'Leave Bar'), barSlot, 'setLabel returns the slot');
|
||||
assert.equal(barBtn.labelText.text, 'LEAVE BAR', 'the button label is swapped');
|
||||
assert.equal(barSlot.finalLabel, 'LEAVE BAR', 'finalLabel tracks the relabel (a re-open re-decodes to it)');
|
||||
assert.ok(barBtn.style.width > wBar, 'the panel re-fits to the longer label');
|
||||
assert.equal(barBtn._hit.width, barBtn.style.width, 'the hit rect tracks the panel');
|
||||
assert.equal(barBtn._hit.x, -barBtn.style.width / 2, 'the hit rect stays centred on the slot');
|
||||
// The round trip — back to BAR, the panel lands exactly where it was.
|
||||
assert.equal(spanBar.setLabel('bar', 'BAR').btn.style.width, wBar, 'relabeling back re-fits to the original width');
|
||||
assert.equal(barSlot.finalLabel, 'BAR', 'finalLabel round-trips');
|
||||
// No button for a missing id → null, no crash.
|
||||
assert.equal(spanBar.setLabel('nope', 'X'), null, 'unknown id → null');
|
||||
// The drawer's lock half (setBarLocked): the OTHER item goes gray
|
||||
// (clicks swallowed) while the bar slot stays live — and back.
|
||||
spanBar.setDisabled('jobBoard', true);
|
||||
assert.equal(jobSlot.btn.disabled, true, 'the drawer item stands down');
|
||||
assert.equal(barSlot.btn.disabled, false, 'the bar slot itself stays live (the way out)');
|
||||
spanBar.setDisabled('jobBoard', false);
|
||||
assert.equal(jobSlot.btn.disabled, false, 'leaving the bar brings the item back');
|
||||
|
||||
// CLASSIC FIT (no span) — the menu drawer is unchanged: content width,
|
||||
// right-aligned over its button.
|
||||
const menuBar = new MenuSubBar(makeScene(), {
|
||||
anchor: { x: 1100, y: 600, w: 150 },
|
||||
onAction: () => {},
|
||||
});
|
||||
assert.ok(menuBar.W <= 1280 - 24, 'the classic fit hugs its content');
|
||||
assert.equal(menuBar.rect.x + menuBar.W, 1100 + 150 / 2, 'the bar\u2019s right edge on the button\u2019s right edge');
|
||||
assert.deepEqual(menuBar.buttons.map((s) => s.id), ['save', 'load', 'mainMenu'], 'the config\u2019s default items');
|
||||
}
|
||||
|
||||
console.log('\nAll Explore-bar checks passed ✔');
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
/**
|
||||
* QuestWindow open-selection test (dev tool, run with Node — no browser):
|
||||
*
|
||||
* node dev/quest-window.test.mjs
|
||||
*
|
||||
* The deck's QUESTS button opens the console (js/ui/QuestWindow.js) with
|
||||
* a FRESH-open default: the detail plate lands on the run's PRIORITY
|
||||
* quest — the one the tracker HUD features, the story quest the player
|
||||
* is actively engaged in (QuestState.resolvePriority, the tracker's
|
||||
* own seam — GameScene/SurfaceScene wire it in as o.resolvePriority)
|
||||
* with its tab activated. Pins the contract:
|
||||
*
|
||||
* - questOpenLanding (pure): resolves over the ACTIVE quests
|
||||
* (held + unclaimed) via the ledger's resolver — a resolver that
|
||||
* names a claimed/unknown quest or throws degrades to null (the
|
||||
* tab's first quest stands in), and with no resolver nothing lands;
|
||||
* - the REAL QuestWindow (a stubbed scene — the quest-tracker.test.mjs
|
||||
* pattern) — a fresh open() lands on the player's SET priority (a
|
||||
* side-tab priority activates the side tab), on the AUTO priority
|
||||
* (the first active story quest) when the player set none, and
|
||||
* falls back to the active tab's first quest when the priority is
|
||||
* CLEARED (the pre-change default — claimed quests included, the
|
||||
* window never lands on nothing);
|
||||
* - openAt() (the tracker's SHOW MORE / VIEW QUEST) always beats the
|
||||
* priority — an explicit target lands where it is asked.
|
||||
*/
|
||||
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));
|
||||
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
|
||||
|
||||
// --- Stub just enough of Phaser for the window's class + build pass -----
|
||||
class ContainerStub {
|
||||
constructor(scene, x = 0, y = 0) {
|
||||
this.scene = scene;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.alpha = 1;
|
||||
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;
|
||||
}
|
||||
setAlpha(a) {
|
||||
this.alpha = a;
|
||||
return this;
|
||||
}
|
||||
setDepth() {
|
||||
return this;
|
||||
}
|
||||
setScrollFactor() {
|
||||
return this;
|
||||
}
|
||||
setPosition(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
destroy(recursive) {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
if (recursive) for (const c of this.childrenList) c.destroy?.();
|
||||
}
|
||||
}
|
||||
class RectStub {
|
||||
constructor(x, y, w, h) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
}
|
||||
contains(px, py) {
|
||||
return px >= this.x && px <= this.x + this.width && py >= this.y && py <= this.y + this.height;
|
||||
}
|
||||
}
|
||||
class GameObjectStub {
|
||||
constructor() {
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.alpha = 1;
|
||||
this.visible = true;
|
||||
this.handlers = {};
|
||||
}
|
||||
setScrollFactor() {
|
||||
return this;
|
||||
}
|
||||
setDepth() {
|
||||
return this;
|
||||
}
|
||||
setAlpha(a) {
|
||||
this.alpha = a;
|
||||
return this;
|
||||
}
|
||||
setVisible(v) {
|
||||
this.visible = v;
|
||||
return this;
|
||||
}
|
||||
setOrigin() {
|
||||
return this;
|
||||
}
|
||||
setBlendMode() {
|
||||
return this;
|
||||
}
|
||||
setPosition(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
setDisplaySize() {
|
||||
return this;
|
||||
}
|
||||
setColor(c) {
|
||||
this.color = c;
|
||||
return this;
|
||||
}
|
||||
setText(s) {
|
||||
this.text = s;
|
||||
return this;
|
||||
}
|
||||
setVolume() {
|
||||
return this;
|
||||
}
|
||||
setLoop() {
|
||||
return this;
|
||||
}
|
||||
on(ev, fn) {
|
||||
(this.handlers[ev] ??= []).push(fn);
|
||||
return this;
|
||||
}
|
||||
resume() {
|
||||
return this;
|
||||
}
|
||||
pause() {
|
||||
return this;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class FakeGraphics extends GameObjectStub {
|
||||
clear() {
|
||||
return this;
|
||||
}
|
||||
lineStyle() {
|
||||
return this;
|
||||
}
|
||||
fillStyle() {
|
||||
return this;
|
||||
}
|
||||
fillRect() {
|
||||
return this;
|
||||
}
|
||||
fillPoints() {
|
||||
return this;
|
||||
}
|
||||
strokePoints() {
|
||||
return this;
|
||||
}
|
||||
strokeRect() {
|
||||
return this;
|
||||
}
|
||||
lineBetween() {
|
||||
return this;
|
||||
}
|
||||
fillCircle() {
|
||||
return this;
|
||||
}
|
||||
fillTriangle() {
|
||||
return this;
|
||||
}
|
||||
strokeCircle() {
|
||||
return this;
|
||||
}
|
||||
setInteractive(o) {
|
||||
this.input = o;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class FakeText extends GameObjectStub {
|
||||
constructor(x, y, str, style) {
|
||||
super();
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.text = str;
|
||||
this.color = style?.color ?? null;
|
||||
}
|
||||
}
|
||||
const PhaserStub = {
|
||||
GameObjects: { Container: ContainerStub, Text: FakeText, Image: GameObjectStub },
|
||||
Geom: { Rectangle: RectStub },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||||
BlendModes: { ADD: 2 },
|
||||
Math: { FloatBetween: (a, b) => a + Math.random() * (b - a) },
|
||||
};
|
||||
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
|
||||
globalThis.document = {
|
||||
// the scanline/sweep tiles (canvas generation) — the fakes don't paint
|
||||
createElement: () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
fillStyle: null,
|
||||
fillRect() {},
|
||||
createLinearGradient: () => ({ addColorStop() {} }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// --- Load the real config (data/*.json) into the config singleton --------
|
||||
const { config } = await import(file('config/Config.js'));
|
||||
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 { QuestState, questCheckKey } = await import(file('quests/QuestState.js'));
|
||||
const { QuestWindow, questOpenLanding } = await import(file('ui/QuestWindow.js'));
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
// A stub scene with just enough plumbing for the window's build + open.
|
||||
function makeScene(o = {}) {
|
||||
const add = {
|
||||
existing() {},
|
||||
graphics: () => new FakeGraphics(),
|
||||
text: (x, y, str, style) => new FakeText(x, y, str, style),
|
||||
circle: () => new GameObjectStub(),
|
||||
tileSprite: () => new GameObjectStub(),
|
||||
image: () => new GameObjectStub(),
|
||||
video: () => new GameObjectStub(),
|
||||
};
|
||||
return {
|
||||
add,
|
||||
scale: { width: 1280, height: 800 },
|
||||
time: { now: 100000, delayedCall() {} },
|
||||
tweens: { add(t) { t.onComplete?.(); return {}; } },
|
||||
textures: { exists: () => false, addCanvas() {} },
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. questOpenLanding — the pure fresh-open resolver
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
const Q = (id, category, over = {}) => ({ id, category, claimed: false, complete: false, checks: [{ done: false }], ...over });
|
||||
const active = [Q('a_main', 'main'), Q('b_main', 'main'), Q('c_side', 'side')];
|
||||
const allClaimed = active.map((q) => ({ ...q, claimed: true }));
|
||||
|
||||
check('landing: no quests → null', questOpenLanding([], (ids) => ids[0]) === null);
|
||||
check('landing: undefined → null', questOpenLanding(undefined, (ids) => ids[0]) === null);
|
||||
check('landing: nothing ACTIVE (all claimed) → null',
|
||||
questOpenLanding(allClaimed, (ids) => 'a_main') === null);
|
||||
check('landing: the resolver\u2019s pick lands (id + its category)',
|
||||
(() => {
|
||||
const r = questOpenLanding(active, (ids) => 'b_main');
|
||||
return r && r.id === 'b_main' && r.category === 'main';
|
||||
})());
|
||||
check('landing: a SIDE pick activates the side tab',
|
||||
(() => { const r = questOpenLanding(active, (ids) => 'c_side'); return r && r.id === 'c_side' && r.category === 'side'; })());
|
||||
check('landing: claimed quests are never offered (no active set → the resolver is never consulted)',
|
||||
(() => {
|
||||
let seen = 'unset';
|
||||
const r = questOpenLanding(allClaimed, (ids) => { seen = ids; return 'a_main'; });
|
||||
return r === null && seen === 'unset';
|
||||
})());
|
||||
check('landing: a pick of a CLAIMED-only quest degrades to null',
|
||||
(() => {
|
||||
const mixed = [Q('a_main', 'main', { claimed: true }), Q('b_main', 'main')];
|
||||
const r = questOpenLanding(mixed, (ids) => 'a_main');
|
||||
return r === null;
|
||||
})());
|
||||
check('landing: a pick the data does not define degrades to null',
|
||||
questOpenLanding(active, (ids) => 'no_such_quest') === null);
|
||||
check('landing: a throwing resolver degrades to null (never a crash)',
|
||||
(() => {
|
||||
try {
|
||||
return questOpenLanding(active, () => { throw new Error('boom'); }) === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})());
|
||||
check('landing: no resolver (the scene did not wire it) → null (the tab\u2019s first stands in)',
|
||||
questOpenLanding(active, null) === null && questOpenLanding(active) === null);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 2. The REAL window — a fresh open lands on the run's priority quest
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
// The real data's three main quests + a synthetic side quest (the
|
||||
// player can feature it — the window must land there on ITS tab).
|
||||
const SIDE_X = { id: 'win_side_x', category: 'side', title: 'Test Side X', 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: { ...configData.quests, quests: [...(configData.quests?.quests ?? []), SIDE_X] } });
|
||||
try {
|
||||
const SNAPSHOT = (over = {}) => ({
|
||||
categories: [
|
||||
{ id: 'main', label: 'Main Story' },
|
||||
{ id: 'side', label: 'Side Quests' },
|
||||
],
|
||||
quests: [
|
||||
{ id: 'start_your_journey', category: 'main', title: 'START YOUR JOURNEY', claimed: false, complete: false, checks: [{ done: false }] },
|
||||
{ id: 'leaving_the_nest', category: 'main', title: 'LEAVING THE NEST', claimed: false, complete: false, checks: [{ done: false }] },
|
||||
{ id: 'win_side_x', category: 'side', title: 'TEST SIDE X', claimed: false, complete: false, checks: [{ done: true }] },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
const SIDE_KEY = questCheckKey({ type: 'mineralsMined', amount: 10, label: 'MINE 10' });
|
||||
const claimSide = (st) => {
|
||||
st.markCheck('win_side_x', SIDE_KEY, 'synthetic');
|
||||
st.claim('win_side_x'); // a REAL claim (all its checks are done)
|
||||
};
|
||||
const make = (ledger, snap) => {
|
||||
const w = new QuestWindow(makeScene(), {
|
||||
getSnapshot: () => snap ?? SNAPSHOT(),
|
||||
resolvePriority: (ids) => ledger.resolvePriority(ids),
|
||||
getPriority: () => ledger.priority ?? null,
|
||||
});
|
||||
return w;
|
||||
};
|
||||
const ledger = (fns) => {
|
||||
const st = new QuestState();
|
||||
st.give('start_your_journey');
|
||||
st.give('leaving_the_nest');
|
||||
st.give('win_side_x');
|
||||
fns?.(st);
|
||||
return st;
|
||||
};
|
||||
|
||||
// The player's SET priority — the story quest they are engaged in —
|
||||
// lands on a fresh open, ahead of the data-order first quest.
|
||||
{
|
||||
const st = ledger((x) => x.setPriority('leaving_the_nest'));
|
||||
const w = make(st);
|
||||
w.open();
|
||||
check('window: a fresh open lands on the player\u2019s SET priority quest',
|
||||
w.selected === 'leaving_the_nest' && w.openState === 'open');
|
||||
check('window: the priority\u2019s tab is active', w._activeTab === 'main');
|
||||
w.destroy();
|
||||
}
|
||||
|
||||
// A SIDE-tab priority — the window follows it onto its own tab.
|
||||
{
|
||||
const st = ledger((x) => x.setPriority('win_side_x'));
|
||||
const w = make(st);
|
||||
w.open();
|
||||
check('window: a side-quest priority lands on the SIDE tab',
|
||||
w.selected === 'win_side_x' && w._activeTab === 'side');
|
||||
w.destroy();
|
||||
}
|
||||
|
||||
// No priority set — the ledger's AUTO-assignment (the first active
|
||||
// story quest, the tracker's own rule) is the landing quest.
|
||||
{
|
||||
const st = ledger();
|
||||
const w = make(st);
|
||||
w.open();
|
||||
check('window: no priority — the AUTO priority (first active story quest) lands',
|
||||
w.selected === 'start_your_journey' && w._activeTab === 'main');
|
||||
w.destroy();
|
||||
}
|
||||
|
||||
// A CLEARED priority — the player declined a featured quest: the
|
||||
// pre-change default stands (the active tab's first quest).
|
||||
{
|
||||
const st = ledger((x) => x.clearPriority());
|
||||
const w = make(st);
|
||||
w.open();
|
||||
check('window: a CLEARED priority falls back to the tab\u2019s first quest (the old default)',
|
||||
w.selected === 'start_your_journey' && w._activeTab === 'main');
|
||||
w.destroy();
|
||||
}
|
||||
|
||||
// openAt() (the tracker's SHOW MORE / VIEW QUEST) beats the priority.
|
||||
{
|
||||
const st = ledger((x) => x.setPriority('leaving_the_nest'));
|
||||
const w = make(st);
|
||||
w.openAt('win_side_x');
|
||||
w.open();
|
||||
check('window: openAt\u2019s explicit target beats the priority',
|
||||
w.selected === 'win_side_x' && w._activeTab === 'side');
|
||||
w.destroy();
|
||||
}
|
||||
|
||||
// A CLAIMED quest is not active — the resolver walks the active
|
||||
// set and the window lands on the next engaged quest (never the
|
||||
// claimed one).
|
||||
{
|
||||
const st = ledger((x) => {
|
||||
x.setPriority('win_side_x');
|
||||
claimSide(x); // the claim drops its own priority + auto-assigns
|
||||
});
|
||||
const snap = SNAPSHOT({
|
||||
quests: [
|
||||
{ id: 'start_your_journey', category: 'main', claimed: false, complete: false, checks: [{ done: false }] },
|
||||
{ id: 'leaving_the_nest', category: 'main', claimed: false, complete: false, checks: [{ done: false }] },
|
||||
{ id: 'win_side_x', category: 'side', claimed: true, complete: true, checks: [{ done: true }] },
|
||||
],
|
||||
});
|
||||
const w = make(st, snap);
|
||||
w.open();
|
||||
check('window: a CLAIMED quest is never the landing (the priority advances)',
|
||||
w.selected === 'start_your_journey' && w._activeTab === 'main');
|
||||
w.destroy();
|
||||
}
|
||||
|
||||
// Nothing active (every held quest claimed) — the fallback is the
|
||||
// tab's first quest (the pre-change behavior; the window shows
|
||||
// something, never a dead plate).
|
||||
{
|
||||
const st = ledger((x) => claimSide(x));
|
||||
const snap = SNAPSHOT({
|
||||
quests: [
|
||||
{ id: 'start_your_journey', category: 'main', claimed: true, complete: true, checks: [{ done: true }] },
|
||||
{ id: 'leaving_the_nest', category: 'main', claimed: true, complete: true, checks: [{ done: true }] },
|
||||
{ id: 'win_side_x', category: 'side', claimed: true, complete: true, checks: [{ done: true }] },
|
||||
],
|
||||
});
|
||||
const w = make(st, snap);
|
||||
w.open();
|
||||
check('window: nothing active — the tab\u2019s first quest stands (no dead plate)',
|
||||
w.selected === 'start_your_journey' && w._activeTab === 'main');
|
||||
w.destroy();
|
||||
}
|
||||
} finally {
|
||||
config.init(savedConfig); // restore the real data set
|
||||
}
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -25,13 +25,19 @@
|
|||
* save degrades to an empty ledger (not a throw), and the unlock
|
||||
* chain (grantUnlocked arms the follow-up exactly when its
|
||||
* prerequisite's reward is claimed);
|
||||
* - the ledger's COMPLETED set (the monotonic checklist witness):
|
||||
* questCheckKey's stable identity (label/key-order independent),
|
||||
* markCheck idempotency + detail refresh, done ⊆ granted, the
|
||||
* save round-trip, and old/malformed saves degrading (not throwing);
|
||||
* - 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.
|
||||
* - both command decks carry the QUESTS slot (flight: right of SHIP;
|
||||
* surface: right of EXPLORE — the surface deck's SHIP slot became
|
||||
* EXPLORE, the full-width service drawer).
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
|
@ -50,7 +56,7 @@ 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(
|
||||
const { QuestState, questCheckKey, questDefs, questDef, starterQuestIds } = await import(
|
||||
join(root, 'js/quests/QuestState.js')
|
||||
);
|
||||
let failures = 0;
|
||||
|
|
@ -295,6 +301,74 @@ check('helpers: starterQuestIds() is exactly [the starter]',
|
|||
})());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 6a. The quest ledger — the COMPLETED STEPS (the monotonic witness)
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
// questCheckKey — the step's stable identity: type + params in sorted
|
||||
// order, the label and the data's key order never counting.
|
||||
const tether2 = { type: 'tetherBuilt', planet: 'home', level: 2, label: 'Build a Level 2 Tether on your Home world' };
|
||||
check('checkKey: type + params, label ignored',
|
||||
questCheckKey(tether2) === 'level=2|planet=home|type=tetherBuilt');
|
||||
check('checkKey: key order in the data never counts',
|
||||
questCheckKey({ label: 'REWORDED', level: 2, planet: 'home', type: 'tetherBuilt' }) === questCheckKey(tether2));
|
||||
check('checkKey: a different parameter is a different step',
|
||||
questCheckKey({ ...tether2, level: 3 }) !== questCheckKey(tether2) &&
|
||||
questCheckKey({ ...tether2, planet: 'elsewhere' }) !== questCheckKey(tether2));
|
||||
check('checkKey: degrades to a key (never empty)', typeof questCheckKey(null) === 'string' && questCheckKey(null).length > 0);
|
||||
|
||||
const key = questCheckKey(tether2);
|
||||
const st = new QuestState();
|
||||
check('completed: markCheck() refuses a quest not held',
|
||||
st.markCheck(starter.id, key, 'L2/L2') === false && !st.isCheckDone(starter.id, key));
|
||||
check('completed: markCheck() records a step on a held quest',
|
||||
(st.give(starter.id) === true && st.markCheck(starter.id, key, 'L2/L2') === true && st.isCheckDone(starter.id, key)));
|
||||
check('completed: the detail line is kept for the dossier', st.checkDetail(starter.id, key) === 'L2/L2');
|
||||
check('completed: markCheck() is idempotent',
|
||||
st.markCheck(starter.id, key, 'L2/L2') === false && st.isCheckDone(starter.id, key));
|
||||
check('completed: a re-mark refreshes the detail (the live line while it holds)',
|
||||
(st.markCheck(starter.id, key, 'L3/L2') === false && st.checkDetail(starter.id, key) === 'L3/L2'));
|
||||
check('completed: a different step stays open',
|
||||
(() => { const k3 = questCheckKey({ ...tether2, level: 3 }); return !st.isCheckDone(starter.id, k3); })());
|
||||
check('completed: markCheck() ignores an unknown quest', st.markCheck('no_such_quest', key, '') === false);
|
||||
check('completed: markCheck() ignores an empty step key',
|
||||
st.markCheck(starter.id, '', '') === false && !st.isCheckDone(starter.id, ''));
|
||||
|
||||
// The save round-trip — the witness rides the save. This is the bug
|
||||
// this is the fix for: the home-tether step stands AFTER the ship
|
||||
// leaves the home system (where the live read can no longer see it).
|
||||
const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())));
|
||||
check('completed: save round-trip keeps the done set + the detail line',
|
||||
restored.isCheckDone(starter.id, key) && restored.checkDetail(starter.id, key) === 'L3/L2');
|
||||
check('completed: toJSON() serializes the done set',
|
||||
(() => { const j = st.toJSON(); return Array.isArray(j.done) && j.done.some((e) => e.quest === starter.id && e.check === key && e.detail === 'L3/L2'); })());
|
||||
|
||||
// Old saves — a record predating the field loads with an empty done
|
||||
// set (the live evaluation stands alone, exactly as before).
|
||||
check('completed: a PRE-completed-set save (no done field) loads empty',
|
||||
(() => { const r = QuestState.fromJSON({ granted: [starter.id], claimed: [] }); return !r.isCheckDone(starter.id, key); })());
|
||||
// done ⊆ granted — a step of a quest the save does not hold is dropped
|
||||
// (never resurrected).
|
||||
check('completed: a done step of a NON-held quest is dropped',
|
||||
(() => {
|
||||
const r = QuestState.fromJSON({ granted: [], claimed: [], done: [{ quest: starter.id, check: key, detail: 'L2/L2' }] });
|
||||
return !r.isCheckDone(starter.id, key);
|
||||
})());
|
||||
// Malformed entries degrade — never throw, never record a step.
|
||||
check('completed: malformed done entries degrade (no throw)',
|
||||
(() => {
|
||||
try {
|
||||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [], done: [{ quest: 'x' }, 'junk', null, { quest: starter.id, check: '' }] });
|
||||
return !r.isCheckDone(starter.id, key);
|
||||
} catch { return false; }
|
||||
})());
|
||||
check('completed: a non-string detail degrades to the empty line',
|
||||
(() => {
|
||||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [], done: [{ quest: starter.id, check: key, detail: 42 }] });
|
||||
return r.isCheckDone(starter.id, key) && r.checkDetail(starter.id, key) === '';
|
||||
})());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 6b. The quest ledger — the PRIORITY (the tracker HUD's featured quest)
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -429,15 +503,17 @@ check('helpers: starterQuestIds() is exactly [the starter]',
|
|||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 7. The command decks — the QUESTS slot, right of SHIP (both decks)
|
||||
// 7. The command decks — the QUESTS slot (flight: right of SHIP,
|
||||
// surface: right of EXPLORE — the surface deck's SHIP slot became
|
||||
// the EXPLORE drawer, so the flight deck keeps its SHIP slot)
|
||||
// ----------------------------------------------------------------------
|
||||
for (const [deck, slots] of [
|
||||
['flight', actionbar.buttons ?? []],
|
||||
['surface', actionbar.surface?.buttons ?? []],
|
||||
for (const [deck, slots, anchor] of [
|
||||
['flight', actionbar.buttons ?? [], 'ship'],
|
||||
['surface', actionbar.surface?.buttons ?? [], 'explore'],
|
||||
]) {
|
||||
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');
|
||||
const iAnchor = slots.findIndex((s) => s.id === anchor);
|
||||
check(`actionbar (${deck}): the QUESTS slot sits right of the ${anchor.toUpperCase()} slot`,
|
||||
iAnchor >= 0 && slots[iAnchor + 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 ?? '');
|
||||
|
|
|
|||
|
|
@ -301,11 +301,12 @@ check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.sc
|
|||
check('actionbar: RGB pull-apart configured (offsets + alphas)', typeof actionbar.animation?.rgb?.idleOffset === 'number' && typeof actionbar.animation?.rgb?.burstOffset === 'number' && typeof actionbar.animation?.rgb?.idleAlpha === 'number' && typeof actionbar.animation?.rgb?.burstAlpha === 'number');
|
||||
check('actionbar: sane geometry (height 40–200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
|
||||
|
||||
// the surface deck (SurfaceScene) — Shop for Research, Build, Take Off
|
||||
// the surface deck (SurfaceScene) — Shop for Research, Explore for
|
||||
// Ship (the full-width service drawer), 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, Quests, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', 'quests', '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: surface slot ids in order (Shop, Build, Explore, Quests, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'explore', 'quests', 'takeoff', 'menu']));
|
||||
check('actionbar: the QUESTS slot is on the surface deck (right of EXPLORE)', (() => { const iExp = sSlots.findIndex((s) => s.id === 'explore'); return iExp >= 0 && sSlots[iExp + 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 ?? ''); })());
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,23 @@
|
|||
* 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:
|
||||
* A quest's REQUIREMENTS 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. Once a step reads DONE, though, it is
|
||||
* RECORDED here (the completed set) and stays DONE — the live read is no
|
||||
* longer its source of truth (a step that stands on the home world keeps
|
||||
* standing after the ship leaves the home system; a check is monotonic,
|
||||
* it can never un-complete). What the ledger owns:
|
||||
*
|
||||
* granted — which quest ids the player HOLDS (the starter quest is
|
||||
* granted on the run's first boot; the run's chain quests are
|
||||
* granted by their unlock requirement — see isUnlockMet / grantUnlocked)
|
||||
* claimed — which of the held quests have PAID OUT their reward
|
||||
* done — the checklist steps that have read DONE (keyed by quest id
|
||||
* + the step's stable identity, questCheckKey) with the
|
||||
* detail line they carried when they stood (the dossier's
|
||||
* display); done ⊆ granted, like claimed
|
||||
* 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
|
||||
|
|
@ -20,10 +27,31 @@
|
|||
* 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.
|
||||
* save can never hand out a reward twice, un-grant a quest, or
|
||||
* un-complete a step across a scene restart or a save/load.
|
||||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
|
||||
/**
|
||||
* A checklist step's STABLE identity (data/quests.json → quests[].checks[]):
|
||||
* its type + parameters, in sorted key order — independent of the
|
||||
* player-facing label and of the data's key order. The ledger's completed
|
||||
* set is keyed by it (with the quest id), so a step is recognized across
|
||||
* saves even if its label is reworded; label/_comment never count.
|
||||
* @param {object} check a quests.json checks[] entry
|
||||
* @returns {string} the step's key (never empty)
|
||||
*/
|
||||
export function questCheckKey(check) {
|
||||
const obj = check && typeof check === 'object' ? check : {};
|
||||
const parts = [];
|
||||
for (const k of Object.keys(obj).sort()) {
|
||||
if (k === 'label' || k === '_comment') continue;
|
||||
const v = obj[k];
|
||||
parts.push(`${k}=${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`);
|
||||
}
|
||||
return parts.length ? parts.join('|') : 'unknown';
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} every quest defined (data/quests.json → quests) */
|
||||
export function questDefs() {
|
||||
const list = config.get('quests.quests', []);
|
||||
|
|
@ -65,6 +93,7 @@ export class QuestState {
|
|||
constructor() {
|
||||
this.granted = new Set();
|
||||
this.claimed = new Set();
|
||||
this.done = new Map(); // "questId\u0000checkKey" → the detail line it stood with
|
||||
this.priority = null; // the active quest id, or null
|
||||
this.priorityCleared = false; // the player declined a priority (sticks until a grant/claim)
|
||||
}
|
||||
|
|
@ -123,6 +152,52 @@ export class QuestState {
|
|||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ completed steps
|
||||
|
||||
/**
|
||||
* Record a checklist step as DONE — the monotonic witness (the scene's
|
||||
* snapshot calls it the moment a step reads DONE, GameScene.
|
||||
* _questCheckView). Idempotent — re-marking keeps the step done and
|
||||
* refreshes its detail line while the live read still holds it. Only
|
||||
* HELD quests can carry completed steps (done ⊆ granted, the same
|
||||
* invariant as claimed ⊆ granted).
|
||||
* @param {string} questId a granted quest id
|
||||
* @param {string} checkKey the step's stable identity (questCheckKey)
|
||||
* @param {string} [detail] the dossier line the step stood with (e.g. "L2/L2")
|
||||
* @returns {boolean} true when NEWLY completed
|
||||
*/
|
||||
markCheck(questId, checkKey, detail) {
|
||||
const k = this._doneKey(questId, checkKey);
|
||||
if (k === null) return false;
|
||||
const d = typeof detail === 'string' ? detail : '';
|
||||
if (this.done.has(k)) {
|
||||
if (this.done.get(k) !== d) this.done.set(k, d);
|
||||
return false;
|
||||
}
|
||||
this.done.set(k, d);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Has this quest's checklist step already stood? (the dossier reads it done) */
|
||||
isCheckDone(questId, checkKey) {
|
||||
const k = this._doneKey(questId, checkKey);
|
||||
return k !== null && this.done.has(k);
|
||||
}
|
||||
|
||||
/** The detail line the step stood with (the dossier's display) — or null. */
|
||||
checkDetail(questId, checkKey) {
|
||||
const k = this._doneKey(questId, checkKey);
|
||||
return k === null ? null : (this.done.get(k) ?? null);
|
||||
}
|
||||
|
||||
/** The completed set's composite key (quest + step) — null when invalid. */
|
||||
_doneKey(questId, checkKey) {
|
||||
if (typeof questId !== 'string' || questId === '') return null;
|
||||
if (typeof checkKey !== 'string' || checkKey === '') return null;
|
||||
if (!questDef(questId) || !this.granted.has(questId)) return null;
|
||||
return `${questId}\u0000${checkKey}`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ priority
|
||||
|
||||
/**
|
||||
|
|
@ -183,6 +258,10 @@ export class QuestState {
|
|||
return {
|
||||
granted: [...this.granted],
|
||||
claimed: [...this.claimed],
|
||||
done: [...this.done].map(([k, detail]) => {
|
||||
const [questId, checkKey] = k.split('\u0000');
|
||||
return { quest: questId, check: checkKey, detail };
|
||||
}),
|
||||
priority: this.priority ?? null,
|
||||
priorityCleared: this.priorityCleared === true,
|
||||
};
|
||||
|
|
@ -203,6 +282,20 @@ export class QuestState {
|
|||
for (const id of grant(data.claimed)) {
|
||||
if (st.granted.has(id)) st.claimed.add(id); // claimed ⊆ granted
|
||||
}
|
||||
// The completed steps (a save predating them has no field → the live
|
||||
// evaluation stands alone, exactly as before — old saves load). Only
|
||||
// steps of HELD quests survive (done ⊆ granted — markCheck enforces
|
||||
// it); a malformed entry degrades rather than throws, and the first
|
||||
// record of a step wins (the detail is a display nicety).
|
||||
if (Array.isArray(data.done)) {
|
||||
for (const e of data.done) {
|
||||
if (!e || typeof e.quest !== 'string' || e.quest === '') continue;
|
||||
if (typeof e.check !== 'string' || e.check === '') continue;
|
||||
const d = typeof e.detail === 'string' ? e.detail : '';
|
||||
if (st.isCheckDone(e.quest, e.check)) continue;
|
||||
st.markCheck(e.quest, e.check, d);
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@
|
|||
* research: ResearchState.toJSON() | null,
|
||||
* builds: BuildState.toJSON() | null,
|
||||
* quests: QuestState.toJSON() | null, // held + claimed quest ids
|
||||
* // + the completed checklist
|
||||
* // steps (monotonic witness)
|
||||
* totalMined: number, // lifetime asteroid mining
|
||||
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
||||
* story: { capitalLanded, launchedFromCapital, lastLandedWorld, delivered },
|
||||
|
|
@ -125,11 +127,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 — the run's mission ledger: which quests are HELD (granted),
|
||||
// which rewards have been CLAIMED, and which checklist STEPS have read
|
||||
// DONE (the completed set — the monotonic witness: a step that stands
|
||||
// on the home world keeps standing after the ship leaves the home
|
||||
// system; the rest of the progress is computed live from the run's
|
||||
// state, GameScene.questSnapshot). A save predating quests has no
|
||||
// field (and a save predating the completed set has no done field —
|
||||
// the live evaluation stands alone, exactly as before); the restore
|
||||
// stages null and the starter quest is granted onto the fresh ledger
|
||||
// (old saves load).
|
||||
quests: scene.questState ? scene.questState.toJSON() : null,
|
||||
// Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once`
|
||||
// comms the run has heard, so a load doesn't re-fire them and a new
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ 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 { QuestState, questCheckKey, 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';
|
||||
|
|
@ -1121,12 +1121,17 @@ export class GameScene extends Phaser.Scene {
|
|||
// 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,
|
||||
// A quest's progress is evaluated 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).
|
||||
// ledger) and, once a step reads DONE, it is RECORDED on the ledger
|
||||
// (QuestState's completed set — monotonic: the step keeps standing
|
||||
// after the live read can no longer see it, e.g. the home tether's
|
||||
// standing once the ship has left the home system). The ledger
|
||||
// itself — which quests are held, which rewards have been claimed,
|
||||
// and which steps have stood — 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();
|
||||
|
|
@ -1138,6 +1143,10 @@ export class GameScene extends Phaser.Scene {
|
|||
onClaim: (id) => this.claimQuest(id),
|
||||
onSetPriority: (id) => this.setPriorityQuest(id),
|
||||
getPriority: () => this.questState?.priority ?? null,
|
||||
// The FRESH open (the deck's QUESTS button) lands on the run's
|
||||
// PRIORITY quest — the one the tracker HUD features (the story
|
||||
// quest the player is actively engaged in): the tracker's own seam.
|
||||
resolvePriority: (ids) => this.questState?.resolvePriority(ids) ?? null,
|
||||
onLocked: (tabId) =>
|
||||
this.consoleToast(
|
||||
tabId === 'side'
|
||||
|
|
@ -3514,9 +3523,13 @@ export class GameScene extends Phaser.Scene {
|
|||
* 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.
|
||||
* asteroid mining, the home tether's standing, the discovery ledger —
|
||||
* and made MONOTONIC by the ledger's completed set: a step that has
|
||||
* read DONE is recorded and stays DONE (the witness rides the save),
|
||||
* even where the live read can no longer see it (the home tether's
|
||||
* standing once the ship has left the home system). 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() {
|
||||
|
|
@ -3531,7 +3544,8 @@ export class GameScene extends Phaser.Scene {
|
|||
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 checks = (Array.isArray(def.checks) ? def.checks : [])
|
||||
.map((c) => this._questCheckView(def.id, c));
|
||||
const doneCount = checks.filter((c) => c.done).length;
|
||||
const complete = checks.length > 0 && doneCount === checks.length;
|
||||
quests.push({
|
||||
|
|
@ -3551,6 +3565,36 @@ export class GameScene extends Phaser.Scene {
|
|||
return { categories, quests };
|
||||
}
|
||||
|
||||
/**
|
||||
* One checklist row for the dossier: the step's LIVE evaluation
|
||||
* (_questCheckEval) made MONOTONIC by the quest ledger's completed
|
||||
* set (QuestState). A step that reads DONE is recorded on the ledger
|
||||
* (markCheck — with its detail line, refreshed while the live read
|
||||
* still holds it) and from then on the RECORD is its source of truth:
|
||||
* the check stands even where the live read can no longer see it (the
|
||||
* home world's tether standing once the ship has left the home
|
||||
* system), and a completed step can never read open again. The ledger
|
||||
* is the run's — it rides the saves (record.quests), so the standing
|
||||
* survives scene restarts + loads.
|
||||
* @returns {{label: string, done: boolean, detail: string}}
|
||||
*/
|
||||
_questCheckView(questId, check) {
|
||||
const live = this._questCheckEval(check);
|
||||
const key = questCheckKey(check);
|
||||
if (live.done) {
|
||||
this.questState.markCheck(questId, key, live.detail); // the witness (idempotent)
|
||||
return live;
|
||||
}
|
||||
if (this.questState.isCheckDone(questId, key)) {
|
||||
return {
|
||||
label: live.label,
|
||||
done: true,
|
||||
detail: this.questState.checkDetail(questId, key) ?? live.detail,
|
||||
};
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
/**
|
||||
* One checklist requirement, evaluated LIVE (data/quests.json →
|
||||
* quests[].checks[].type). Pure reads — no effects here.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { playSfxOn } from '../utils/Sfx.js';
|
|||
import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js';
|
||||
import { ActionBar } from '../ui/ActionBar.js';
|
||||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||
import { resolveItems, worldContext } from '../ui/ExploreBar.js';
|
||||
import { SavePanel } from '../ui/SavePanel.js';
|
||||
import { SaveManager } from '../save/SaveManager.js';
|
||||
import { BuildWindow } from '../ui/BuildWindow.js';
|
||||
|
|
@ -38,8 +39,11 @@ const DOUBLE_CLICK_MS = 350;
|
|||
* 2. SURFACE — the clip is swapped for the looping world clip (`surface`),
|
||||
* and the command deck appears: the GameScene deck with Research
|
||||
* replaced by SHOP (swaps the loop for the world's `shop` clip —
|
||||
* e.g. terran-shop-01.mp4 — press again to swap back) and TAKE OFF
|
||||
* left of MENU (data/actionbar.json → surface.buttons). Menu keeps the GameScene's
|
||||
* e.g. terran-shop-01.mp4 — press again to swap back; on the neutral
|
||||
* capital the EXPLORE drawer's BAR slot does the same with the world's
|
||||
* `bar` clip — capital-bar-neutral.mp4 — LEAVE BAR to swap back, and
|
||||
* while the bar clip loops it is the ONLY live button on the surface)
|
||||
* and TAKE OFF left of MENU (data/actionbar.json → surface.buttons). Menu keeps the GameScene's
|
||||
* save sub-bar — Save/Load operate on the paused GameScene's state
|
||||
* (SavePanel `stateScene`). While the clip loops, the planet's name
|
||||
* decodes in at the upper-left (Ethnocentric, the menu title's shade,
|
||||
|
|
@ -103,6 +107,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.hudT0 = null;
|
||||
this.actionBar = null;
|
||||
this.menuSubBar = null;
|
||||
this.exploreSubBar = null; // the EXPLORE drawer (the deck's full-width service bar)
|
||||
this.savePanel = null;
|
||||
this.saveManager = null;
|
||||
this.landKey = null;
|
||||
|
|
@ -113,6 +118,9 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.shopKey = null;
|
||||
this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed)
|
||||
this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it)
|
||||
this.barKey = null;
|
||||
this.barVideo = null; // the BAR clip (EXPLORE → Bar, neutral capital; created on first press, then paused/resumed)
|
||||
this.barMode = false; // true while the bar clip is on screen (BAR/LEAVE BAR toggles it)
|
||||
this.shopView = null; // the buy/sell UI over the clip (js/ui/shop/ShopView.js); null when out of the shop
|
||||
this.shopHud = null; // the buy/sell screen's corner readout (upper right): this scene's CreditsHud + MineralHud pair
|
||||
this.noteG = null; // console-note glyphs (consoleNote)
|
||||
|
|
@ -135,14 +143,17 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.surfaceKey = `${this.clipKeyPrefix}loop_${this.planetFrame}`;
|
||||
this.takeoffKey = `${this.clipKeyPrefix}takeoff_${this.planetFrame}`;
|
||||
this.shopKey = `${this.clipKeyPrefix}shop_${this.planetFrame}`;
|
||||
this.barKey = `${this.clipKeyPrefix}bar_${this.planetFrame}`;
|
||||
this.landUrl = this.resolveUrl(entry.land ?? null);
|
||||
this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
|
||||
this.takeoffUrl = this.resolveUrl(entry.takeoff ?? null);
|
||||
this.shopUrl = this.resolveUrl(entry.shop ?? null);
|
||||
this.barUrl = this.resolveUrl(entry.bar ?? null);
|
||||
if (this.landUrl) this.load.video(this.landKey, this.landUrl);
|
||||
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
|
||||
if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl);
|
||||
if (this.shopUrl) this.load.video(this.shopKey, this.shopUrl);
|
||||
if (this.barUrl) this.load.video(this.barKey, this.barUrl);
|
||||
|
||||
// The surface's music loop (data/music.json → frames[planets.png frame]
|
||||
// for a planet, → stationFrames[spacestations.png frame] for a station)
|
||||
|
|
@ -389,6 +400,132 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
return inShop ? String(active) : String(base);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Stage 2c — the bar clip (EXPLORE → Bar swaps the loop, and back)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* BAR — the neutral capital's service point (the EXPLORE drawer's BAR
|
||||
* slot, data/actionbar.json → surface.explore.items): swap the looping
|
||||
* surface clip for this world's bar clip (data/landing.json →
|
||||
* videos[frame].bar — capital-bar-neutral.mp4 on the neutral capital);
|
||||
* the next press (LEAVE BAR) swaps back. The same shape as the shop
|
||||
* toggle: the surface clip is PAUSED, not destroyed (it resumes exactly
|
||||
* where it left off), the bar clip is created on the first press (the
|
||||
* file was preloaded) and then just paused/resumed, and the drawer's BAR
|
||||
* slot relabels to `labelActive` ("Leave Bar") and back. The difference
|
||||
* is the LOCK: the bar's way-out lives on the SECONDARY bar, so while
|
||||
* the clip is up EVERY button on the primary deck and every OTHER
|
||||
* drawer item goes standby — LEAVE BAR is the only live control on the
|
||||
* surface (setBarLocked). The drawer itself stays up — it is the home
|
||||
* of the way-out — and outside-clicks / ESC can't fold it down (see
|
||||
* onPointerDown / escAction). A world whose frame has no bar clip
|
||||
* (data/landing.json → bar null) gets a console note instead.
|
||||
*/
|
||||
toggleBar() {
|
||||
if (this.phase !== 'surface') return; // no deck, no drawer, no bar
|
||||
if (this.barMode) {
|
||||
this.leaveBar();
|
||||
return;
|
||||
}
|
||||
if (!this.hasVideo(this.barKey)) {
|
||||
this.consoleNote('NO BAR ON THIS WORLD');
|
||||
return;
|
||||
}
|
||||
if (!this.barVideo) {
|
||||
// First press of this stay: bring the clip up (only the <video>
|
||||
// element is new — the file was preloaded).
|
||||
const s = this.add.video(0, 0, this.barKey).setOrigin(0.5).setDepth(10);
|
||||
this.attachClip(s);
|
||||
s.setLoop(true);
|
||||
this.barVideo = s;
|
||||
s.on('error', () => {
|
||||
// Broken clip — drop back to the surface loop, don't strand the view.
|
||||
this.barMode = false;
|
||||
this.destroyVideo(this.barVideo);
|
||||
this.barVideo = null;
|
||||
this.surfaceVideo?.resume(); // v4 quirk: play() no-ops a clip that has played before
|
||||
this.exploreSubBar?.setLabel('bar', this.barLabel(false)); // the slot is back to BAR
|
||||
this.setBarLocked(false); // the deck + drawer were locked to the bar — release
|
||||
this.consoleNote('BAR CLIP UNAVAILABLE');
|
||||
});
|
||||
}
|
||||
this.barVideo.setVisible(true); // may be hidden from a previous visit
|
||||
this.surfaceVideo?.pause(); // the loop pauses where it is (resume() brings it back)
|
||||
// resume(), not play(): the first start is a no-op-safe superset (it
|
||||
// falls through to the first-play path), and on a RE-entry the clip
|
||||
// has played before, where play() would silently no-op.
|
||||
this.barVideo.resume();
|
||||
this.barMode = true;
|
||||
this.exploreSubBar?.setLabel('bar', this.barLabel(true)); // the slot now reads LEAVE BAR
|
||||
this.setBarLocked(true); // the bar is up — every OTHER button goes standby
|
||||
}
|
||||
|
||||
/**
|
||||
* The second BAR press — back to the looping surface clip.
|
||||
* v4 quirk: for a clip that has already played once, `play()` is a
|
||||
* no-op (its createPlayPromise gate only fires on the first start) —
|
||||
* `resume()` (setPaused(false)) is what actually restarts it. And a
|
||||
* paused <video> keeps showing its last frame, so the bar clip —
|
||||
* painted over the surface loop — must be hidden, not just paused.
|
||||
*/
|
||||
leaveBar() {
|
||||
this.barMode = false;
|
||||
this.barVideo?.pause();
|
||||
this.barVideo?.setVisible(false); // its frozen last frame would cover the surface
|
||||
this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate)
|
||||
this.exploreSubBar?.setLabel('bar', this.barLabel(false)); // the slot is back to BAR
|
||||
this.setBarLocked(false); // the bar is down — the deck + drawer come back
|
||||
}
|
||||
|
||||
/**
|
||||
* BAR LOCK — while the bar clip is up, EVERY button on the primary deck
|
||||
* (all six live slots — the way-out is not on the deck) and every item
|
||||
* on the secondary drawer EXCEPT BAR go standby (dim, inert: no hover,
|
||||
* no press — the deck's per-slot language, ActionBar.setSlotEnabled;
|
||||
* the drawer's, MenuSubBar.setDisabled). LEAVE BAR is the only live
|
||||
* control on the surface — the same one-live-control language as the
|
||||
* shop's lock (the one difference: over there the way-out IS the shop
|
||||
* slot, over here it is the drawer's bar slot). The shop slot re-arms
|
||||
* through its own gate (isShopUnlocked — true on the neutral capital,
|
||||
* where the bar lives; the gate is the belt-and-braces pass). The bar
|
||||
* slot itself is untouched in both directions (it's the toggle that
|
||||
* owns the lock). Idempotent — safe on re-entry.
|
||||
*/
|
||||
setBarLocked(locked) {
|
||||
const deck = this.actionBar;
|
||||
if (deck && Array.isArray(deck.slots)) {
|
||||
for (const s of deck.slots) {
|
||||
if (!s.live) continue;
|
||||
if (s.id === 'shop') {
|
||||
deck.setSlotEnabled(s.id, this.gameScene?.isShopUnlocked?.() === true);
|
||||
} else {
|
||||
deck.setSlotEnabled(s.id, !locked);
|
||||
}
|
||||
}
|
||||
}
|
||||
const drawer = this.exploreSubBar;
|
||||
if (drawer && Array.isArray(drawer.buttons)) {
|
||||
for (const b of drawer.buttons) {
|
||||
if (b.id === 'bar') continue; // BAR/LEAVE BAR is the way out
|
||||
drawer.setDisabled(b.id, locked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The BAR slot's label for the given state (data/actionbar.json →
|
||||
* surface.explore.items: `label` out, `labelActive` in), with plain
|
||||
* fallbacks if the config entry is missing.
|
||||
*/
|
||||
barLabel(inBar) {
|
||||
const items = config.get('actionbar.surface.explore.items');
|
||||
const it = Array.isArray(items) ? items.find((x) => x && x.id === 'bar') : null;
|
||||
const active = it?.labelActive ?? 'Leave Bar';
|
||||
const base = it?.label ?? 'Bar';
|
||||
return inBar ? String(active) : String(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* The BUY/SELL UI over the shop clip (js/ui/shop/ShopView.js). Built once
|
||||
* per shop visit (a fresh session each time you land — the session-local
|
||||
|
|
@ -820,6 +957,34 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
stateScene: this.gameScene ?? this,
|
||||
});
|
||||
|
||||
// The EXPLORE drawer (the deck's EXPLORE slot — where SHIP used to
|
||||
// sit): the full-width secondary bar (the shop tab bar's span — the
|
||||
// deck's own left→right edges, resting on its top rail) the same
|
||||
// MenuSubBar drawer carries in span mode, with this world's service
|
||||
// buttons: data/actionbar.json → surface.explore.items, each item's
|
||||
// optional `where` clause (world / planetClass / faction) resolved
|
||||
// against THIS world by js/ui/ExploreBar.js — Job Board on every
|
||||
// planet and station, Bar on the neutral capital, and whatever the
|
||||
// config adds per class / faction later. The buttons' behavior lands
|
||||
// later (exploreAction is the seam — BAR is live, the rest land later). A
|
||||
// world with no matching items
|
||||
// gets no drawer (the slot is a no-op with a console note).
|
||||
const deck = this.actionBar;
|
||||
const exploreItems = resolveItems(
|
||||
worldContext({ isStation: this.isStation, name: this.planetName }, this.gameScene),
|
||||
);
|
||||
if (deck && exploreItems.length > 0) {
|
||||
const exploreSlot = deck.slots.find((s) => s.id === 'explore');
|
||||
this.exploreSubBar = new MenuSubBar(this, {
|
||||
anchor: exploreSlot
|
||||
? { x: exploreSlot.slot.x, y: deck.rect.y, w: deck.style.bw }
|
||||
: { x: this.scale.width / 2, y: this.scale.height - 58, w: 120 },
|
||||
span: deck.rect ? { left: deck.rect.x, right: deck.rect.x + deck.rect.w } : null,
|
||||
items: exploreItems,
|
||||
onAction: (id) => this.exploreAction(id),
|
||||
});
|
||||
}
|
||||
|
||||
// The BUILD console (the deck's BUILD slot) — the passive view over
|
||||
// gameScene.buildState (the run's build records + the single
|
||||
// in-progress build): the same split as the research console (state
|
||||
|
|
@ -852,6 +1017,10 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
onClaim: (id) => this.claimSurfaceQuest(id),
|
||||
onSetPriority: (id) => this.setPrioritySurfaceQuest(id),
|
||||
getPriority: () => this.gameScene?.questState?.priority ?? null,
|
||||
// The FRESH open (the deck's QUESTS button) lands on the run's
|
||||
// PRIORITY quest — the one the tracker HUD features (the same seam
|
||||
// as the flight deck's window, over the sleeping GameScene's ledger).
|
||||
resolvePriority: (ids) => this.gameScene?.questState?.resolvePriority(ids) ?? null,
|
||||
onLocked: (tabId) =>
|
||||
this.consoleNote(
|
||||
tabId === 'side'
|
||||
|
|
@ -875,8 +1044,9 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
* The deck's button presses (ActionBar → onAction). SHOP swaps the
|
||||
* looping surface clip for the world's shop clip (and back); BUILD
|
||||
* opens the build console (the world's buildable items —
|
||||
* data/builds.json); SHIP is a seam for the surface economy; TAKE OFF
|
||||
* goes back to the ship; MENU is the save door.
|
||||
* data/builds.json); EXPLORE folds up the full-width service drawer
|
||||
* (data/actionbar.json → surface.explore.items); TAKE OFF goes back to
|
||||
* the ship; MENU is the save door.
|
||||
*/
|
||||
deckAction(id) {
|
||||
// While a build runs, the deck is locked (one at a time —
|
||||
|
|
@ -903,6 +1073,10 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.toggleShop();
|
||||
return;
|
||||
}
|
||||
if (id === 'explore') {
|
||||
this.toggleExplore();
|
||||
return;
|
||||
}
|
||||
if (id === 'menu') {
|
||||
this.menuAction();
|
||||
return;
|
||||
|
|
@ -925,11 +1099,59 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.menuSubBar.close();
|
||||
return;
|
||||
}
|
||||
this.exploreSubBar?.close?.(); // one drawer at a time (the menu's is opening)
|
||||
// Load Game is live only when the bank holds at least one save.
|
||||
this.menuSubBar.setDisabled('load', !this.saveManager.hasAny());
|
||||
this.menuSubBar.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* The EXPLORE button: fold the full-width service drawer up / fold it
|
||||
* back down (the shop tab bar's span, the same MenuSubBar drawer in
|
||||
* span mode). One drawer at a time — the menu's folds down if it is up.
|
||||
* While the shop clip is up the slot is deck-locked anyway (see
|
||||
* setDeckLocked), so the two never fight.
|
||||
*/
|
||||
toggleExplore() {
|
||||
const bar = this.exploreSubBar;
|
||||
if (!bar) {
|
||||
this.consoleNote('NOTHING TO EXPLORE HERE');
|
||||
return;
|
||||
}
|
||||
if (bar.isOpen) {
|
||||
bar.close();
|
||||
return;
|
||||
}
|
||||
if (bar.closing) return; // let the fold finish — the press can re-open
|
||||
this.menuSubBar?.close?.(); // one drawer at a time
|
||||
bar.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* The EXPLORE drawer's button presses (MenuSubBar → onAction). The
|
||||
* items are the world's service points (data/actionbar.json →
|
||||
* surface.explore.items — Job Board everywhere, Bar on the neutral
|
||||
* capital). BAR swaps the surface loop for the world's bar clip and
|
||||
* back (toggleBar — the drawer relabels to LEAVE BAR, and every other
|
||||
* button on the deck + drawer goes standby until pressed). While the
|
||||
* bar clip is up a press on any OTHER item is refused (belt-and-braces
|
||||
* — the lock already stands the items down); unhandled items are still
|
||||
* acknowledged on the console (the deck's unhandled-action voice) while
|
||||
* their behavior lands.
|
||||
*/
|
||||
exploreAction(id) {
|
||||
if (this.barMode && id !== 'bar') {
|
||||
this.consoleNote('LEAVE THE BAR FIRST');
|
||||
this.playSfx('ui_close');
|
||||
return;
|
||||
}
|
||||
if (id === 'bar') {
|
||||
this.toggleBar();
|
||||
return;
|
||||
}
|
||||
console.info(`[orbit] surface explore: ${id} — ${this.planetName}`);
|
||||
}
|
||||
|
||||
/** The sub-bar's button presses (MenuSubBar → onAction). */
|
||||
subBarAction(id) {
|
||||
if (id === 'save') {
|
||||
|
|
@ -1031,8 +1253,9 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.questWindow?.refresh();
|
||||
}
|
||||
|
||||
/** ESC: build console → quests console → set-destination → confirm dialog → save pop-up → sub-bar. */
|
||||
/** ESC: build console → quests console → set-destination → confirm dialog → save pop-up → explore drawer → sub-bar. */
|
||||
escAction() {
|
||||
if (this.barMode) return; // the drawer's LEAVE BAR is the only way out of the bar
|
||||
if (this.buildWindow?.isOpen) {
|
||||
this.buildWindow.close();
|
||||
return;
|
||||
|
|
@ -1055,6 +1278,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
}
|
||||
if (this.exploreSubBar && this.exploreSubBar.isOpen) this.exploreSubBar.close();
|
||||
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||
}
|
||||
|
||||
|
|
@ -1067,6 +1291,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
takeOff() {
|
||||
this.savePanel?.close();
|
||||
this.menuSubBar?.dismiss();
|
||||
this.exploreSubBar?.dismiss();
|
||||
if (this.takeoffVideo) return; // the take-off clip is already in flight
|
||||
if (this.hasVideo(this.takeoffKey)) {
|
||||
this.playTakeoff();
|
||||
|
|
@ -1095,6 +1320,11 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.shopVideo = null;
|
||||
}
|
||||
this.shopMode = false;
|
||||
if (this.barVideo) {
|
||||
this.destroyVideo(this.barVideo); // same — the take-off clip owns the screen
|
||||
this.barVideo = null;
|
||||
}
|
||||
this.barMode = false;
|
||||
|
||||
const v = this.add.video(0, 0, this.takeoffKey).setOrigin(0.5).setDepth(20);
|
||||
this.attachClip(v);
|
||||
|
|
@ -1181,6 +1411,12 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.menuSubBar.close();
|
||||
return;
|
||||
}
|
||||
if (this.exploreSubBar?.isOpen) {
|
||||
if (this.exploreSubBar.contains(pointer.x, pointer.y)) return;
|
||||
if (this.barMode) return; // the bar holds the drawer up — LEAVE BAR is the way out
|
||||
this.exploreSubBar.close();
|
||||
return;
|
||||
}
|
||||
if (this.actionBar?.contains(pointer.x, pointer.y)) return;
|
||||
// A click on the surface itself — the seam for surface interactions.
|
||||
}
|
||||
|
|
@ -1291,6 +1527,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.tweens.update();
|
||||
this.actionBar?.update(time, delta);
|
||||
this.menuSubBar?.update(time, delta);
|
||||
this.exploreSubBar?.update(time, delta);
|
||||
this.savePanel?.update(time);
|
||||
this._setDestDialog?.update(time); // the SET DESTINATION confirm
|
||||
this.updateHud(time); // the upper-left name/type decode (surface stage only)
|
||||
|
|
@ -1321,11 +1558,14 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.destroyVideo(this.surfaceVideo);
|
||||
this.destroyVideo(this.takeoffVideo);
|
||||
this.destroyVideo(this.shopVideo);
|
||||
this.destroyVideo(this.barVideo);
|
||||
this.landVideo = null;
|
||||
this.surfaceVideo = null;
|
||||
this.takeoffVideo = null;
|
||||
this.shopVideo = null;
|
||||
this.barVideo = null;
|
||||
this.shopMode = false;
|
||||
this.barMode = false;
|
||||
if (Array.isArray(this.noteG)) {
|
||||
for (const g of this.noteG) g.destroy();
|
||||
this.noteG = null;
|
||||
|
|
@ -1341,9 +1581,11 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.buildWindow = null;
|
||||
this.actionBar?.destroy();
|
||||
this.menuSubBar?.destroy();
|
||||
this.exploreSubBar?.destroy();
|
||||
this.savePanel?.destroy();
|
||||
this.actionBar = null;
|
||||
this.menuSubBar = null;
|
||||
this.exploreSubBar = null;
|
||||
this.savePanel = null;
|
||||
this.backdrop?.destroy();
|
||||
this.backdrop = null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { config } from '../config/Config.js';
|
||||
|
||||
/**
|
||||
* The surface deck's EXPLORE drawer — its item logic (pure data, headless-
|
||||
* testable: dev/explore.test.mjs). SurfaceScene renders the drawer with the
|
||||
* shared MenuSubBar in full-span mode (js/ui/MenuSubBar.js — the full-width
|
||||
* secondary bar, the shop tab bar's span); this module decides WHICH items
|
||||
* that drawer carries on a given world.
|
||||
*
|
||||
* data/actionbar.json → surface.explore.items is the ordered button list.
|
||||
* Each item may carry an optional `where` clause — EVERY key it names must
|
||||
* match the world the player is standing on for the button to appear:
|
||||
*
|
||||
* world 'planet' | 'station' — what the surface is (a deep-space
|
||||
* station vs a planet).
|
||||
* planetClass the planet's class — 'terran' | 'rocky' | 'gas' | 'ice' |
|
||||
* 'lava' | 'arid' | 'capitalneutral' | 'capitalapex' |
|
||||
* 'capitalmindweave' | 'capitalzenith' (data/planets.json →
|
||||
* frames, lowercased by the planet entity — matching is
|
||||
* case-insensitive); stations carry none ('none' — so a
|
||||
* clause like `planetClass: 'capitalneutral'` never fires
|
||||
* on a station).
|
||||
* faction the faction controlling this system — 'apex' | 'mindweave' |
|
||||
* 'zenith', or 'neutral' (wild space; data/factions.json).
|
||||
*
|
||||
* A value may be a single entry or a list (any-match, case-insensitive).
|
||||
* An item with no `where` shows everywhere — the Job Board, which is on
|
||||
* every planet and station.
|
||||
*
|
||||
* const ctx = worldContext(surface, gameScene);
|
||||
* const items = resolveItems(ctx); // the drawer's live buttons
|
||||
*/
|
||||
|
||||
/**
|
||||
* The world context a `where` clause matches against.
|
||||
*
|
||||
* @param {object} surface — the landing handoff (SurfaceScene.init):
|
||||
* { isStation: boolean, name: string } — the world's kind + proper name
|
||||
* (the same name the surface HUD decodes in).
|
||||
* @param {object|null} gameScene — the paused flight scene beneath: its
|
||||
* systemPlanets / planet (home world) carry the class, its systemRecord
|
||||
* carries the faction (Galaxy's faction pass — data/factions.json).
|
||||
* @returns {{world: string, planetClass: string|null, faction: string}}
|
||||
*/
|
||||
export function worldContext(surface = {}, gameScene = null) {
|
||||
const name = String(surface.name ?? '').trim();
|
||||
const same = (o) => o && String(o.discoveryName ?? '').trim() === name;
|
||||
const match = (list) => (Array.isArray(list) ? list : []).find(same);
|
||||
|
||||
let planetClass = null;
|
||||
if (surface.isStation !== true) {
|
||||
// A PLANTED world: the matched entity (a system planet, or the home
|
||||
// world) carries its kind — the Planet entity's `name` arg IS the
|
||||
// class ('terran'/'rocky'/…/'capitalneutral', data/planets.json →
|
||||
// frames, lowercased), `.class` when a future entity prefers that
|
||||
// property (matching is case-insensitive).
|
||||
const p =
|
||||
match(gameScene?.systemPlanets) ??
|
||||
(gameScene?.planet && same(gameScene.planet) ? gameScene.planet : null);
|
||||
if (p) {
|
||||
const raw = p.class ?? p.name ?? null;
|
||||
if (raw != null && String(raw).trim() !== '') planetClass = String(raw).trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
const faction = gameScene?.systemRecord?.faction ?? null;
|
||||
return {
|
||||
world: surface.isStation === true ? 'station' : 'planet',
|
||||
planetClass,
|
||||
// null = neutral wild space (the faction pass leaves it null) — the
|
||||
// clause reads it as 'neutral'.
|
||||
faction: faction != null ? String(faction).trim().toLowerCase() : 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an item's optional `where` clause match the context? (AND semantics
|
||||
* across keys — every key named must match.) No clause → shown everywhere.
|
||||
*/
|
||||
export function matchesWhere(where, ctx) {
|
||||
if (where == null || typeof where !== 'object') return true;
|
||||
const norm = (v) => (v == null ? 'none' : String(v).trim().toLowerCase());
|
||||
const c = ctx ?? {};
|
||||
for (const key of Object.keys(where)) {
|
||||
const have = norm(c[key]);
|
||||
const want = (Array.isArray(where[key]) ? where[key] : [where[key]]).map(norm);
|
||||
if (!want.includes(have)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer's live items for a context: the config's ordered list with
|
||||
* every item whose `where` clause doesn't match dropped (order kept).
|
||||
* `items` defaults to data/actionbar.json → surface.explore.items.
|
||||
*/
|
||||
export function resolveItems(ctx, items) {
|
||||
const list = Array.isArray(items)
|
||||
? items
|
||||
: config.get('actionbar.surface.explore.items');
|
||||
return (Array.isArray(list) ? list : []).filter(
|
||||
(it) => it && typeof it === 'object' && matchesWhere(it.where, ctx),
|
||||
);
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ export class MenuButton extends Phaser.GameObjects.Container {
|
|||
// never renders. Verified Sept 2026 against lib/phaser.min.js (4.2.1 Giedi).
|
||||
this.scene.add.existing(this);
|
||||
|
||||
this.style = { width, height, notch, fill, hoverFill, stroke, neon, neon2, ink, inkCss, labelText, textStyle };
|
||||
this.style = { width, height, notch, fill, hoverFill, stroke, neon, neon2, ink, inkCss, labelText, textStyle, paddingX, minWidth: width };
|
||||
this.hoverOn = false;
|
||||
this.pressing = false;
|
||||
this.disabled = false;
|
||||
|
|
@ -108,10 +108,14 @@ export class MenuButton extends Phaser.GameObjects.Container {
|
|||
this.paint('base');
|
||||
|
||||
// Hit-test the whole rect with an explicit area (independent of the
|
||||
// Graphics' draw state, so repainting never breaks interaction).
|
||||
// Graphics' draw state, so repainting never breaks interaction). The
|
||||
// rect is kept (this._hit) so setLabel() can re-fit it IN PLACE (the
|
||||
// input manager reads the live rect on every click — re-arming
|
||||
// setInteractive would be the v4-fragile path; mutating is not).
|
||||
this._hit = new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height);
|
||||
this.panel.setInteractive({
|
||||
useHandCursor: true,
|
||||
hitArea: new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height),
|
||||
hitArea: this._hit,
|
||||
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
||||
});
|
||||
this.panel.on('pointerover', () => this.hover(true));
|
||||
|
|
@ -134,6 +138,41 @@ export class MenuButton extends Phaser.GameObjects.Container {
|
|||
this.paint(on ? 'disabled' : (this.hoverOn ? 'hover' : 'base'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Relabel the button in place and re-fit its panel (the ActionBar.setLabel
|
||||
* mirror — the surface deck's EXPLORE drawer's BAR slot reads LEAVE BAR
|
||||
* while the bar clip is up, data/actionbar.json → labelActive): the main
|
||||
* text + both RGB ghosts take the new string, the panel re-fits to the
|
||||
* label (never narrower than its first fit — style.minWidth), the hit
|
||||
* rect is re-fitted in place (the live rect the input manager reads), and
|
||||
* the current visual state is re-asserted. The button stays centred on
|
||||
* its slot, so the drawer's row layout is untouched. A no-op when the
|
||||
* label is unchanged.
|
||||
*/
|
||||
setLabel(text) {
|
||||
const s = this.style;
|
||||
const t = String(text ?? '').toUpperCase();
|
||||
if (t === s.labelText) return this;
|
||||
s.labelText = t;
|
||||
this.labelText.setText(t);
|
||||
this.ghostCyan.setText(t);
|
||||
this.ghostMagenta.setText(t);
|
||||
// Re-measure against the label text object (Phaser keeps .width fresh
|
||||
// after setText — ActionBar.setLabel's same assumption).
|
||||
const textW = Number(this.labelText.width) || 0;
|
||||
const width = Math.max(s.minWidth, textW + s.paddingX * 2);
|
||||
if (width !== s.width) {
|
||||
s.width = width;
|
||||
if (this._hit) {
|
||||
this._hit.x = -width / 2;
|
||||
this._hit.width = width;
|
||||
}
|
||||
}
|
||||
this.setSize(width, s.height);
|
||||
this.paint(this.disabled ? 'disabled' : this.hoverOn ? 'hover' : 'base');
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Repaint the panel for a visual state: 'base' | 'hover' | 'disabled'. */
|
||||
paint(state) {
|
||||
const s = this.style;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
|
|||
* const bar = new MenuSubBar(scene, { anchor: {x, y, w}, onAction });
|
||||
* bar.open(); bar.close(); bar.setDisabled('load', true);
|
||||
* bar.contains(px, py); bar.update(time, delta); bar.destroy();
|
||||
*
|
||||
* The surface deck's EXPLORE drawer reuses the same drawer in FULL-SPAN
|
||||
* mode (js/scenes/SurfaceScene.js): `span: { left, right }` stretches the
|
||||
* bar across that screen span (the command deck's own left→right edges —
|
||||
* the shop tab bar's span) instead of hugging its buttons, with the
|
||||
* buttons packed at the bar's LEFT edge and the rest console space; and
|
||||
* `items` replaces the config's Save/Load/Main Menu list with the drawer's
|
||||
* own buttons (data/actionbar.json → surface.explore.items, resolved per
|
||||
* world by js/ui/ExploreBar.js). Without `span` the drawer keeps its
|
||||
* classic button-hugging, right-aligned fit (the Menu button's drawer).
|
||||
*/
|
||||
export class MenuSubBar extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
|
|
@ -51,6 +61,11 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
|
|||
* anchor: { x, y, w } — the menu button's center x + width (screen px),
|
||||
* y = the RESTING LINE (the command deck's top edge —
|
||||
* the bar's bottom edge sits on it, above the button)
|
||||
* span?: { left, right } — FULL-SPAN mode: the bar stretches across that
|
||||
* screen span (the deck's left→right edges) with the
|
||||
* buttons packed at the left (the EXPLORE drawer)
|
||||
* items?: { id, label, accent }[] — the drawer's button list (overrides
|
||||
* the config's Save/Load/Main Menu items)
|
||||
* onAction?: (id) => void
|
||||
* }
|
||||
*/
|
||||
|
|
@ -84,13 +99,20 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
|
|||
// cover them (the body is redrawn every frame while it unfolds).
|
||||
// The button row width is measured up-front (throw-away texts) so the
|
||||
// scan tile can be sized correctly from the start.
|
||||
const items = Array.isArray(cfg.items) && cfg.items.length
|
||||
? cfg.items
|
||||
: [
|
||||
{ id: 'save', label: 'Save Game', accent: '#00e5ff' },
|
||||
{ id: 'load', label: 'Load Game', accent: '#ffc94d' },
|
||||
{ id: 'mainMenu', label: 'Return to Main Menu', accent: '#ff2d6f' },
|
||||
];
|
||||
// `o.items` — the caller's own button list (same shape as cfg.items):
|
||||
// the surface deck's EXPLORE drawer brings the world's service items
|
||||
// (data/actionbar.json → surface.explore.items, resolved per world by
|
||||
// js/ui/ExploreBar.js). The config's Save/Load/Main Menu items are
|
||||
// the default when the caller brings none (the Menu button's drawer).
|
||||
const items = Array.isArray(o.items) && o.items.length
|
||||
? o.items
|
||||
: Array.isArray(cfg.items) && cfg.items.length
|
||||
? cfg.items
|
||||
: [
|
||||
{ id: 'save', label: 'Save Game', accent: '#00e5ff' },
|
||||
{ id: 'load', label: 'Load Game', accent: '#ffc94d' },
|
||||
{ id: 'mainMenu', label: 'Return to Main Menu', accent: '#ff2d6f' },
|
||||
];
|
||||
const btnCfg = cfg.button ?? {};
|
||||
const sceneW = scene.scale.width;
|
||||
|
||||
|
|
@ -126,9 +148,20 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
|
|||
|
||||
const btns = items.map((it) => mkBtn(it));
|
||||
const widths = btns.map((b) => b.style.width);
|
||||
let W = this.pad * 2 + widths.reduce((a, b) => a + b, 0) + this.gap * (widths.length - 1);
|
||||
W = Math.min(W, sceneW - 24);
|
||||
this.W = W;
|
||||
const contentW = this.pad * 2 + widths.reduce((a, b) => a + b, 0) + this.gap * (widths.length - 1);
|
||||
// FULL-SPAN mode (o.span = { left, right }): the bar stretches across
|
||||
// that screen span (the command deck's left→right edges — the shop
|
||||
// tab bar's span) instead of hugging its buttons; the buttons keep
|
||||
// packing from the bar's left edge, the rest is console space.
|
||||
const span =
|
||||
o.span &&
|
||||
Number.isFinite(o.span.left) &&
|
||||
Number.isFinite(o.span.right) &&
|
||||
o.span.right > o.span.left
|
||||
? { left: o.span.left, right: o.span.right }
|
||||
: null;
|
||||
this.span = span;
|
||||
this.W = span ? span.right - span.left : Math.min(contentW, sceneW - 24);
|
||||
|
||||
// ---- the body (redrawn each frame while it unfolds) ---------------
|
||||
this.bodyG = scene.add.graphics().setScrollFactor(0);
|
||||
|
|
@ -179,10 +212,16 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
|
|||
// swallow every world click in their band (and the bar starts closed).
|
||||
this.buttons.forEach((s) => setInteractiveEnabled(s.btn.panel, false));
|
||||
// ---- the anchor: right-aligned over the menu button, bottom edge on
|
||||
// the resting line (the deck's top edge — the bar sits on the bar)
|
||||
// the resting line (the deck's top edge — the bar sits on the bar);
|
||||
// full-span mode centres on the span instead (the EXPLORE drawer).
|
||||
const a = o.anchor ?? { x: sceneW / 2, y: scene.scale.height - 60, w: 120 };
|
||||
let cx = a.x + a.w / 2 - this.W / 2; // bar's right edge on the button's right edge
|
||||
cx = Phaser.Math.Clamp(cx, this.W / 2 + 12, sceneW - this.W / 2 - 12);
|
||||
let cx;
|
||||
if (this.span) {
|
||||
cx = (this.span.left + this.span.right) / 2;
|
||||
} else {
|
||||
cx = a.x + a.w / 2 - this.W / 2; // bar's right edge on the button's right edge
|
||||
cx = Phaser.Math.Clamp(cx, this.W / 2 + 12, sceneW - this.W / 2 - 12);
|
||||
}
|
||||
const seamY = a.y - 0; // the bar's bottom edge sits ON the deck's top edge
|
||||
this.seamY = seamY;
|
||||
this.setPosition(cx, seamY);
|
||||
|
|
@ -277,6 +316,22 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
|
|||
if (s) s.btn.setDisabled(!!on);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relabel a sub-bar button in place (the ActionBar.setLabel mirror — the
|
||||
* surface deck's EXPLORE drawer's BAR slot reads LEAVE BAR while the bar
|
||||
* clip is up, routed through MenuButton.setLabel: the panel re-fits to
|
||||
* the longer label and stays centred on its slot). finalLabel tracks the
|
||||
* new text, so a re-open re-decodes to it. Returns the slot, or null if
|
||||
* id has no button.
|
||||
*/
|
||||
setLabel(id, text) {
|
||||
const s = (this.buttons ?? []).find((b) => b.id === id);
|
||||
if (!s) return null;
|
||||
s.btn.setLabel(text);
|
||||
s.finalLabel = String(text ?? '').toUpperCase();
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Is (px, py) — screen coords — over the bar? */
|
||||
contains(px, py) {
|
||||
const r = this.rect;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,13 @@
|
|||
* tether's standing, the discovery ledger) and polled here every few
|
||||
* hundred ms; the reward is paid out by the scene too (GameScene.
|
||||
* claimQuest — it verifies the checklist before handing out the minerals).
|
||||
* The window's only state is the selected quest.
|
||||
* The window's only state is the selected quest — and a FRESH open (the
|
||||
* deck's QUESTS button) lands on the run's PRIORITY quest: the one the
|
||||
* tracker HUD features, the story quest the player is actively engaged
|
||||
* in (QuestState.resolvePriority — the same seam the tracker uses), its
|
||||
* tab activated; with no priority the active tab's first quest stands
|
||||
* in. openAt() (the tracker's SHOW MORE / VIEW QUEST) always lands
|
||||
* where it is asked to.
|
||||
*
|
||||
* Cyberpunk dressing in the Research/Map idiom: cut-corner plates
|
||||
* (CyberShape), decode text (ScrambleDecode), a crawling sweep band, an
|
||||
|
|
@ -132,6 +138,34 @@ function wrapText(str, maxWidth) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The quest a FRESH open (the deck's QUESTS button) should land on:
|
||||
* the run's PRIORITY quest — the one the tracker HUD features, the story
|
||||
* quest the player is actively engaged in — resolved over the ACTIVE
|
||||
* quests (held + unclaimed) exactly the way the tracker does
|
||||
* (QuestState.resolvePriority via the scene seam); null when the run
|
||||
* has none (the tab's first quest stands in — _selectFirstForTab).
|
||||
* Pure — the poll's snapshot + the ledger's resolver, nothing else.
|
||||
* @param {Array<object>} quests the snapshot's quests (GameScene.questSnapshot)
|
||||
* @param {(activeIds: string[]) => (string|null)} [resolvePriority]
|
||||
* @returns {{id: string, category: string}|null}
|
||||
*/
|
||||
export function questOpenLanding(quests, resolvePriority) {
|
||||
const active = Array.isArray(quests) ? quests.filter((q) => q && !q.claimed) : [];
|
||||
if (active.length === 0) return null;
|
||||
let id = null;
|
||||
if (typeof resolvePriority === 'function') {
|
||||
try {
|
||||
id = resolvePriority(active.map((q) => q.id)) ?? null;
|
||||
} catch {
|
||||
id = null;
|
||||
}
|
||||
}
|
||||
if (!id) return null;
|
||||
const q = active.find((x) => x.id === id) ?? null;
|
||||
return q ? { id: q.id, category: q.category ?? 'main' } : null;
|
||||
}
|
||||
|
||||
export class QuestWindow extends Phaser.GameObjects.Container {
|
||||
static VIDEO_KEY = 'quest_feed';
|
||||
|
||||
|
|
@ -147,6 +181,11 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
* 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 {(activeIds: string[]) => (string|null)} [o.resolvePriority] —
|
||||
* the ledger's priority resolution (GameScene.questState
|
||||
* .resolvePriority, the tracker's seam): the FRESH open (the deck's
|
||||
* QUESTS button) lands on the resolved priority quest — the story
|
||||
* quest the player is actively engaged in — and activates its tab
|
||||
* @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit
|
||||
* (the scene toasts)
|
||||
*/
|
||||
|
|
@ -161,6 +200,7 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
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.resolvePriority = typeof o.resolvePriority === 'function' ? o.resolvePriority : null;
|
||||
this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null;
|
||||
|
||||
this.openState = 'closed'; // closed | opening | open | closing
|
||||
|
|
@ -1216,17 +1256,35 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
push(this.detailCont, 440, 300);
|
||||
this.reveal = list;
|
||||
// the selection — a PENDING one (openAt — the tracker's SHOW MORE /
|
||||
// VIEW QUEST lands here) beats the tab's first quest (fresh open).
|
||||
// VIEW QUEST lands here) beats the run's PRIORITY quest (the tracker
|
||||
// HUD's featured quest — the story quest the player is actively
|
||||
// engaged in: the deck's QUESTS button lands on it, its tab active),
|
||||
// which beats the active tab's first quest (the fresh-open default).
|
||||
let snap = this._snap;
|
||||
if (!snap && typeof this.getSnapshot === 'function') {
|
||||
try {
|
||||
snap = this.getSnapshot();
|
||||
} catch {
|
||||
snap = null;
|
||||
}
|
||||
}
|
||||
if (snap) this._snap = snap; // fresh data for the selection + the paint
|
||||
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;
|
||||
const q = ((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);
|
||||
const land = questOpenLanding(snap?.quests ?? [], this.resolvePriority);
|
||||
if (land) {
|
||||
this._activeTab = land.category;
|
||||
this.selected = land.id;
|
||||
} else {
|
||||
this._selectFirstForTab(this._activeTab);
|
||||
}
|
||||
}
|
||||
// the glitch clock
|
||||
const g = config.get('quests.glitch', {});
|
||||
|
|
|
|||
Loading…
Reference in New Issue