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:
parent
a2d211a981
commit
3fdd825570
|
|
@ -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 ✔');
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue