From 3fdd825570aea3bdf8750647635c6b4a1337a1cd Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 11 Sep 2026 23:31:54 -0600 Subject: [PATCH] Lock the deck while the shop is up: only LEAVE SHOP stays live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the shop clip is on screen (the SHOP slot reads LEAVE SHOP), the command deck's other slots — Build, Ship, Quests, Take Off, Menu — go standby: dim, no hover, no press, clicks swallowed — the same inert language as the 'ack' comm's deck hold. The press that opened the shop is the only way out; the slots re-arm on LEAVE SHOP (and if the shop clip fails to load). js/ui/ActionBar.js setSlotEnabled(id, on) — per-slot lock: arms/ disarms one slot (input off, standby paint); setEnabled (deck-wide standby) keeps each slot's flag in sync and still owns input over a per-slot unlock; setHover/press guard on it; setLabel no longer repaints a locked slot live js/scenes/SurfaceScene setDeckLocked(locked) — lock everything but SHOP on enter (toggleShop), release on leave (leaveShop) + the broken-clip fallback dev/actionbar.test.mjs new — slot lock / deck lock / standby-priority / label-kept-locked, headless docs/ECONOMY.md §12 the deck-lock bullet All headless suites that passed before still pass (the five pre-existing failures — music/quest-tracker/system-hud/galaxy-labels/jumpgate — import SurfaceScene, which extends Phaser.Scene that the headless stub doesn't provide; they fail identically without this change). --- dev/actionbar.test.mjs | 250 ++++++++++++++++++++++++++++++++++++++ docs/ECONOMY.md | 6 + js/scenes/SurfaceScene.js | 20 +++ js/ui/ActionBar.js | 47 ++++++- 4 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 dev/actionbar.test.mjs diff --git a/dev/actionbar.test.mjs b/dev/actionbar.test.mjs new file mode 100644 index 0000000..a23ed4a --- /dev/null +++ b/dev/actionbar.test.mjs @@ -0,0 +1,250 @@ +/** + * ActionBar test (dev tool, run with Node — no browser needed): + * + * node --import ./dev/phaser-loader.mjs dev/actionbar.test.mjs + * + * The command deck's PER-SLOT lock (js/ui/ActionBar.js → setSlotEnabled), + * the "shop is up" state: every slot but SHOP goes standby (dim, inert — + * no hover, no press, clicks swallowed) and only LEAVE SHOP is the live + * button on the deck. Verified: + * + * - CONSTRUCT — the surface deck builds all six live slots. + * - SLOT LOCK — setSlotEnabled(id, false) arms the slot inert (input + * disabled, press blocked, standby paint) and (id, true) re-arms it. + * - DECK LOCK — the SurfaceScene recipe: lock everything but 'shop' → + * only the shop slot fires onAction; unlock brings the rest back. + * - STANDBY PRIORITY — the deck-wide comm hold (setEnabled(false)) + * owns input: a per-slot unlock can't re-open a slot underneath it. + * - LABEL + LOCK — relabeling a locked slot keeps its standby paint. + * + * A fake Scene provides the add.* factories + tweens/time as no-ops; the + * phaser stub (dev/phaser-stub.mjs) provides the Container the bar + * extends. + */ + +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 Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default; +const { ActionBar } = await import(pathToFileURL(join(__dirname, '../js/ui/ActionBar.js')).href); + +const SURFACE = config.get('actionbar.surface.buttons'); +assert.ok(Array.isArray(SURFACE) && SURFACE.length >= 6, 'surface deck has six slots'); +const IDS = SURFACE.map((b) => b.id).filter(Boolean); +assert.ok(IDS.includes('shop') && IDS.includes('menu'), 'surface deck has the shop + menu slots'); + +// --------------------------------------------------------------------------- +// A headless Scene: just enough for the bar to CONSTRUCT + drive the slot +// state machine. `setInteractive`/`disableInteractive` on the slot panels +// record the input state, so the test can assert which slots are armed. +// --------------------------------------------------------------------------- +function stubObject(extra = {}) { + const o = { + 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(opts) { this._interactive = opts; return this; }, + disableInteractive() { this._interactive = null; return this; }, + setDisplaySize() { return this; }, + on() { return this; }, + once() { return this; }, + off() { return this; }, + setText(s) { this.text = String(s); 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, + }; + return o; +} + +function makeScene() { + const scene = { + scale: { width: 1280, height: 720 }, + add: { + existing: (o) => o, + text: (x, y, str) => stubObject({ x, y, text: String(str) }), + 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) => { + // Apply the target values immediately (the real tween eases to the + // same place) so hover punches / boot flicker end at their finals. + 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, + // Run the callback immediately: press()'s 130 ms press-restore and + // boot()'s end-of-boot burst are the only scheduled calls — both are + // no-op-safe, and running them keeps the state machine honest. + delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; }, + }, + textures: { exists: () => true }, // canvasTexture caches → no DOM canvas + }; + return scene; +} + +function makeBar() { + const pressed = []; + const scene = makeScene(); + const bar = new ActionBar(scene, { + onAction: (id) => pressed.push(id), + buttons: SURFACE, + }); + return { scene, bar, pressed }; +} + +const slot = (bar, id) => bar.slots.find((s) => s.id === id); +const armed = (s) => Boolean(s.panel._interactive); + +// =========================================================================== +console.log('== CONSTRUCT — the surface deck builds all six live slots =='); +{ + const { bar } = makeBar(); + assert.equal(bar.slots.length, 6, 'six slots built'); + for (const id of IDS) { + const s = slot(bar, id); + assert.ok(s, `slot '${id}' exists`); + assert.ok(s.live, `slot '${id}' is live`); + assert.ok(armed(s), `slot '${id}' starts armed (interactive)`); + assert.equal(s.disabled, undefined, `slot '${id}' is not locked at construction`); + } +} + +// =========================================================================== +console.log('== SLOT LOCK — setSlotEnabled arms/disarms one slot =='); +{ + const { bar, pressed } = makeBar(); + const s = slot(bar, 'build'); + + bar.setSlotEnabled('build', false); + assert.equal(s.disabled, true, 'the slot is flagged disabled'); + assert.equal(armed(s), false, 'the slot is disarmed (input off)'); + assert.equal(s.label.color, '#5a6478', 'the slot paints standby (grayed label)'); + bar.press(s); // a stray click over the dead slot + assert.equal(pressed.length, 0, 'pressing a locked slot fires no onAction'); + + // Idempotent: a second lock is a no-op (no re-paint churn, state kept). + bar.setSlotEnabled('build', false); + assert.equal(s.disabled, true, 're-locking is a no-op'); + + bar.setSlotEnabled('build', true); + assert.equal(s.disabled, false, 'the slot is un-flagged'); + assert.ok(armed(s), 'the slot is re-armed (input on)'); + assert.notEqual(s.label.color, '#5a6478', 'the slot paints base again'); + bar.press(s); + assert.deepEqual(pressed, ['build'], 'pressing an unlocked slot fires onAction'); + + // Unknown ids are a safe no-op. + assert.equal(bar.setSlotEnabled('nope', false), null, 'unknown id → null, no crash'); +} + +// =========================================================================== +console.log('== DECK LOCK — while the shop is up, only the shop slot is live =='); +{ + const { bar, pressed } = makeBar(); + // The SurfaceScene recipe: lock every live slot except 'shop'. + const lock = (locked) => { + for (const s of bar.slots) { + if (!s.live || s.id === 'shop') continue; + bar.setSlotEnabled(s.id, !locked); + } + }; + + lock(true); + assert.ok(armed(slot(bar, 'shop')), 'the SHOP slot stays armed (the way out)'); + for (const id of IDS.filter((x) => x !== 'shop')) { + const s = slot(bar, id); + assert.equal(armed(s), false, `slot '${id}' is disarmed while the shop is up`); + } + // The deck's presses: only the shop slot can get through. + bar.press(slot(bar, 'build')); + bar.press(slot(bar, 'takeoff')); + bar.press(slot(bar, 'menu')); + assert.equal(pressed.length, 0, 'locked slots swallow the press'); + bar.press(slot(bar, 'shop')); + assert.deepEqual(pressed, ['shop'], 'LEAVE SHOP still fires onAction'); + + lock(false); + for (const id of IDS.filter((x) => x !== 'shop')) { + assert.ok(armed(slot(bar, id)), `slot '${id}' is re-armed after unlock`); + } + pressed.length = 0; + bar.press(slot(bar, 'menu')); + assert.deepEqual(pressed, ['menu'], 'the deck is fully live again'); +} + +// =========================================================================== +console.log('== STANDBY PRIORITY — the comm hold owns input over a slot unlock =='); +{ + const { bar } = makeBar(); + const s = slot(bar, 'build'); + bar.setEnabled(false); // the 'ack' comm's hold: the whole deck goes inert + for (const id of IDS) assert.equal(armed(slot(bar, id)), false, `slot '${id}' inert under deck standby`); + + // A per-slot unlock must NOT re-open a slot underneath the deck hold. + bar.setSlotEnabled('build', true); + assert.equal(armed(s), false, 'the slot stays disarmed (deck standby wins)'); + assert.equal(s.disabled, false, '…but its per-slot flag is cleared (the deck, not the slot, owns it)'); + + bar.setEnabled(true); // the comm releases + assert.ok(armed(s), 'the slot re-arms when the deck hold lifts'); + for (const id of IDS) assert.ok(armed(slot(bar, id)), `slot '${id}' re-armed with the deck`); +} + +// =========================================================================== +console.log('== LABEL + LOCK — relabeling a locked slot keeps the standby paint =='); +{ + const { bar } = makeBar(); + const s = slot(bar, 'menu'); + bar.setSlotEnabled('menu', false); + bar.setLabel('menu', 'Menu 2'); + assert.equal(s.label.text, 'MENU 2', 'the label text updates'); + assert.equal(s.label.color, '#5a6478', 'the standby paint survives the relabel'); + bar.setSlotEnabled('menu', true); + assert.notEqual(s.label.color, '#5a6478', 'unlocking paints the base label back'); +} + +console.log('\nAll ActionBar checks passed ✔'); diff --git a/docs/ECONOMY.md b/docs/ECONOMY.md index aeb0c05..b408a98 100644 --- a/docs/ECONOMY.md +++ b/docs/ECONOMY.md @@ -342,6 +342,12 @@ of UI as the Menu drawer. The screen stays open on the shop video so the clip pl the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this visit. The bar is **extended** past a hairline divider to carry the live **NET** readout — one glance sees the tabs *and* the pending trade's credit delta. +- **Deck lock (while the shop is up)** — the command deck's other slots (Build, Ship, + Quests, Take Off, Menu) go **standby**: dim, no hover, no press — the same inert + language as the comm-hold pause (`ActionBar.setSlotEnabled`). **LEAVE SHOP is the only + live button on the deck**; the press that opened the shop is the only way out. They + re-arm when you leave. (`ActionBar.setSlotEnabled` + `SurfaceScene.setDeckLocked`; the + deck-wide comm hold still owns input if it fires mid-shop.) - **Confirm / Clear** — the same `MenuButton` style, in the center column between the panels; Confirm is **disabled** (grayed) while the trade is invalid (can't go negative, hold would overflow), and both appear once you start picking. diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index b5bca94..cfbd68d 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -287,6 +287,7 @@ export class SurfaceScene extends Phaser.Scene { this.shopVideo = null; this.surfaceVideo?.resume(); // v4 quirk: play() no-ops a clip that has played before this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP + this.setDeckLocked(false); // the deck was locked to the shop — release it this.consoleNote('SHOP CLIP UNAVAILABLE'); }); } @@ -298,6 +299,7 @@ export class SurfaceScene extends Phaser.Scene { this.shopVideo.resume(); this.shopMode = true; this.actionBar?.setLabel('shop', this.shopLabel(true)); // the slot now reads LEAVE SHOP + this.setDeckLocked(true); // the shop is up — the deck's other slots go standby this.buildShopView(); // the buy/sell UI over the clip (js/ui/shop/ShopView.js) } @@ -316,6 +318,24 @@ export class SurfaceScene extends Phaser.Scene { this.shopVideo?.setVisible(false); // its frozen last frame would cover the surface this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate) this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP + this.setDeckLocked(false); // the shop is down — the deck's other slots come back + } + + /** + * DECK LOCK — while the shop clip is up, the deck's slots go standby + * (dim, inert: no hover, no press) EXCEPT the SHOP slot — LEAVE SHOP + * is the only live button on the deck, the same one-live-control + * language as the 'ack' comm's hold (ActionBar.setEnabled). Re-enables + * everything on unlock; the shop slot is untouched in both directions + * (it's the toggle that owns the lock). Idempotent — safe on re-entry. + */ + setDeckLocked(locked) { + const bar = this.actionBar; + if (!bar || !Array.isArray(bar.slots)) return; + for (const s of bar.slots) { + if (!s.live || s.id === 'shop') continue; // SHOP is the way out + bar.setSlotEnabled(s.id, !locked); + } } /** diff --git a/js/ui/ActionBar.js b/js/ui/ActionBar.js index 72ecc72..8503d41 100644 --- a/js/ui/ActionBar.js +++ b/js/ui/ActionBar.js @@ -359,6 +359,8 @@ export class ActionBar extends Phaser.GameObjects.Container { * reserved sockets — so the ACKNOWLEDGE button is the only live * control on the screen while the comm holds. Idempotent (a no-op * when the state is unchanged) — the scene may call it every frame. + * Sets each slot's `disabled` flag too, so a per-slot lock (see + * setSlotEnabled) and the deck-wide standby can't disagree. */ setEnabled(on) { const want = on !== false; @@ -366,6 +368,7 @@ export class ActionBar extends Phaser.GameObjects.Container { this.enabled = want; for (const s of this.slots) { if (!s.live) continue; + s.disabled = !want; if (want) { s.panel.setInteractive(this.slotHit()); // re-arm (v4: flips input.enabled) } else { @@ -383,6 +386,41 @@ export class ActionBar extends Phaser.GameObjects.Container { } } + /** + * setSlotEnabled(id, on) — the DECK-LOCK state (one slot stays live, + * the rest go standby — e.g. the surface deck while the shop clip is + * up: every slot but SHOP is inert, and LEAVE SHOP is the only way + * back). Same standby language as setEnabled: no hover, no press, + * clicks swallowed, dim like the reserved sockets. Idempotent. + * Returns the slot, or null if id has no live slot. + */ + setSlotEnabled(id, on) { + const s = (this.slots ?? []).find((sl) => sl.id === id); + if (!s || !s.live || this.dead) return null; + const want = on !== false; + if ((s.disabled !== true) === want) return s; // already in the wanted state + s.disabled = !want; + if (want) { + // Re-arm — unless the DECK is in standby (setEnabled(false)): the + // deck-wide hold owns input (the 'ack' comm's contract), so a + // per-slot unlock must not re-open a slot underneath it. + if (this.enabled !== false) s.panel.setInteractive(this.slotHit()); + this.paintSlot(s, 'base'); + } else { + s.hoverOn = false; // hover is over (the paint below is the truth) + s.sweep.setAlpha(0); + if (s._sweep) { + s._sweep.remove(); + s._sweep = null; + } + if (s._scaleTw) s._scaleTw.stop(); // the hover scale punch is a tween + s.slot.setScale(1); // land any in-flight hover scale + s.panel.disableInteractive(); // no more pointer events, cursor reset + this.paintSlot(s, 'disabled'); + } + return s; + } + /** Repaint a slot for a visual state: 'base' | 'hover' | 'press' | 'disabled'. */ paintSlot(s, state) { const st = this.style; @@ -454,7 +492,7 @@ export class ActionBar extends Phaser.GameObjects.Container { } setHover(s, on) { - if (!s.live || this.dead || this.enabled === false) return; // standby: inert + if (!s.live || this.dead || this.enabled === false || s.disabled === true) return; // standby: inert if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice) s.hoverOn = on; this.paintSlot(s, on ? 'hover' : 'base'); @@ -488,7 +526,7 @@ export class ActionBar extends Phaser.GameObjects.Container { /** Click feedback: white flash + scale punch, then restore. Fires onAction. */ press(s) { - if (!s.live || s.pressing || this.dead || this.enabled === false) return; // standby: inert + if (!s.live || s.pressing || this.dead || this.enabled === false || s.disabled === true) return; // standby: inert // SCAN plays its own sonar ping (the scene's onAction) — no double tick. if (s.id !== 'scan') this.scene.playSfx?.('ui_click'); s.pressing = true; @@ -532,8 +570,9 @@ export class ActionBar extends Phaser.GameObjects.Container { const k = Math.max(10, (s.fontSize * max) / s.label.width); for (const o of [s.label, s.ghostCyan, s.ghostMagenta]) o?.setFontSize(`${k}px`); } - // Re-assert the current visual state (hover keeps the white label). - this.paintSlot(s, s.hoverOn ? 'hover' : 'base'); + // Re-assert the current visual state (hover keeps the white label; + // a locked slot keeps its standby paint — setSlotEnabled's truth). + this.paintSlot(s, s.disabled === true ? 'disabled' : (s.hoverOn ? 'hover' : 'base')); return s; }