Lock the deck while the shop is up: only LEAVE SHOP stays live

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).
This commit is contained in:
Brian Fertig 2026-09-11 23:31:54 -06:00
parent a2d211a981
commit 3fdd825570
4 changed files with 319 additions and 4 deletions

250
dev/actionbar.test.mjs Normal file
View File

@ -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 ✔');

View File

@ -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.

View File

@ -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);
}
}
/**

View File

@ -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;
}