diff --git a/assets/images/originals/capital-neutral-bar.png b/assets/images/originals/capital-neutral-bar.png new file mode 100644 index 0000000..4a07880 Binary files /dev/null and b/assets/images/originals/capital-neutral-bar.png differ diff --git a/assets/videos/capital-bar-neutral.mp4 b/assets/videos/capital-bar-neutral.mp4 new file mode 100644 index 0000000..91ee7f8 Binary files /dev/null and b/assets/videos/capital-bar-neutral.mp4 differ diff --git a/assets/videos/characters/planet-carol.mp4 b/assets/videos/characters/planet-carol.mp4 new file mode 100644 index 0000000..b6469e4 Binary files /dev/null and b/assets/videos/characters/planet-carol.mp4 differ diff --git a/assets/videos/characters/planet-trestle.mp4 b/assets/videos/characters/planet-trestle.mp4 new file mode 100644 index 0000000..3cdbdbc Binary files /dev/null and b/assets/videos/characters/planet-trestle.mp4 differ diff --git a/data/actionbar.json b/data/actionbar.json index fafcbd2..54956cc 100644 --- a/data/actionbar.json +++ b/data/actionbar.json @@ -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", diff --git a/data/landing.json b/data/landing.json index 99ee9b9..ec5ff24 100644 --- a/data/landing.json +++ b/data/landing.json @@ -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" } diff --git a/data/quests.json b/data/quests.json index a2aa99d..55d09d6 100644 --- a/data/quests.json +++ b/data/quests.json @@ -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" }, diff --git a/dev/explore.test.mjs b/dev/explore.test.mjs new file mode 100644 index 0000000..605d8cd --- /dev/null +++ b/dev/explore.test.mjs @@ -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 ✔'); diff --git a/dev/quest-window.test.mjs b/dev/quest-window.test.mjs new file mode 100644 index 0000000..1556b11 --- /dev/null +++ b/dev/quest-window.test.mjs @@ -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); diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs index dda7281..2e88447 100644 --- a/dev/quests.test.mjs +++ b/dev/quests.test.mjs @@ -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 ?? ''); diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 273f65e..2598001 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -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 ?? ''); })()); diff --git a/js/quests/QuestState.js b/js/quests/QuestState.js index 0876f2f..dc02862 100644 --- a/js/quests/QuestState.js +++ b/js/quests/QuestState.js @@ -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} 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. diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 724b530..ae68a87 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -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: [":"], // 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 diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 346ef6d..8a10a9c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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, quests: Array}|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. diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 493a00a..1d4689d 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -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