Add isHomeSystem where-clause to hide Job Board in the home system

- The player's home system is safe space (no enemies), so no work should be posted on its board.
- worldContext now exposes isHomeSystem from GameScene; matchesWhere handles boolean values with list any-match.
- data/actionbar.json gives jobBoard a { isHomeSystem: false } clause instead of showing everywhere.
- explore.test.mjs covers home/non-home worlds, stations, and boolean-clause matching.
- Added the jobboard.mp4 bar clip asset; updated SurfaceScene/GameScene comments to reflect the new rule.
This commit is contained in:
Brian Fertig 2026-09-17 18:35:52 -06:00
parent 5890e93cde
commit 919bcc4c4e
6 changed files with 75 additions and 22 deletions

BIN
assets/videos/jobboard.mp4 Normal file

Binary file not shown.

View File

@ -30,9 +30,9 @@
{ "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).",
"_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); `isHomeSystem` (boolean — true in the player's home / starting system; `{ isHomeSystem: false }` = every system EXCEPT the home). A value may be a single entry or a list (any-match). No `where` = every planet and station; the Job Board carries `{ isHomeSystem: false }` — it's on every planet and station EXCEPT the player's home system. DESIGN NOTE: the home system is safe space — enemies should NEVER be placed there, so there is no work to post on its 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": "jobBoard", "label": "Job Board", "accent": "#ffc94d", "where": { "isHomeSystem": false } },
{ "id": "bar", "label": "Bar", "labelActive": "Leave Bar", "accent": "#00e5ff", "where": { "planetClass": "capitalNeutral" } }
]
}

View File

@ -10,12 +10,14 @@
* - 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.
* EVERY planet and station EXCEPT the player's home system
* (`isHomeSystem: false` — safe space, no work posted there), 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'`.
* (`none`); neutral wild space reads `faction: 'neutral'`;
* boolean keys (isHomeSystem) compare directly (a list any-matches).
* - 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
@ -82,6 +84,7 @@ console.log('== CONTEXT — world / planetClass / faction resolve from the run =
assert.equal(rocky.world, 'planet');
assert.equal(rocky.planetClass, 'rocky');
assert.equal(rocky.faction, 'neutral', 'a null faction reads neutral wild space');
assert.equal(rocky.isHomeSystem, false, 'a plain world is not the home system');
// The same world inside an APEX-controlled system.
const apex = worldContext(
@ -111,25 +114,33 @@ console.log('== CONTEXT — world / planetClass / faction resolve from the run =
// The HOME world (gameScene.planet — not in systemPlanets).
const home = worldContext(
{ isStation: false, name: 'Terra' },
{ systemPlanets: [], planet: { discoveryName: 'Terra', name: 'terran' }, systemRecord: { faction: null } },
{
systemPlanets: [],
planet: { discoveryName: 'Terra', name: 'terran' },
systemRecord: { faction: null },
isHomeSystem: true,
},
);
assert.equal(home.planetClass, 'terran', 'the home world resolves via gameScene.planet');
assert.equal(home.isHomeSystem, true, 'the home system reads isHomeSystem true');
// 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');
assert.equal(bare.isHomeSystem, false, 'no scene → not the home system');
}
// ===========================================================================
console.log('== ITEMS — Job Board everywhere, Bar on the neutral capital ==');
console.log('== ITEMS — Job Board everywhere except home, Bar on the neutral capital ==');
{
const mk = (cls, { isStation = false, faction = null } = {}) =>
const mk = (cls, { isStation = false, faction = null, home = false } = {}) =>
worldContext(
{ isStation, name: 'World' },
{
systemPlanets: isStation ? [] : [{ discoveryName: 'World', name: cls }],
systemRecord: { faction },
isHomeSystem: home,
},
);
@ -142,6 +153,12 @@ console.log('== ITEMS — Job Board everywhere, Bar on the neutral capital ==');
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');
// …but NEVER in the player's home system (safe space — the Job Board's
// `isHomeSystem: false` clause): the home world AND any other world in
// the home system (a second planet or a station there).
assert.ok(!ids(resolveItems(mk('terran', { home: true }))).includes('jobBoard'), 'no job board on the home world');
assert.ok(!ids(resolveItems(mk('rocky', { home: true }))).includes('jobBoard'), 'no job board on a home-system planet');
assert.ok(!ids(resolveItems(mk(null, { isStation: true, home: true }))).includes('jobBoard'), 'no job board on a home-system station');
// Bar: the neutral capital ONLY.
assert.deepEqual(ids(resolveItems(mk('capitalNeutral'))), ['jobBoard', 'bar'], 'bar on the neutral capital');
@ -191,6 +208,20 @@ console.log('== WHERE — clause matching (single + list values, case-insensitiv
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));
// Boolean keys (isHomeSystem): direct comparison, a list any-matches,
// AND-able with string keys.
const home = worldContext(
{ isStation: false, name: 'H' },
{ systemPlanets: [{ discoveryName: 'H', name: 'terran' }], systemRecord: { faction: null }, isHomeSystem: true },
);
assert.ok(matchesWhere({ isHomeSystem: false }, rocky), 'isHomeSystem false matches a non-home world');
assert.ok(!matchesWhere({ isHomeSystem: false }, home), '…but not the home system (the Job Board\u2019s clause)');
assert.ok(matchesWhere({ isHomeSystem: true }, home), 'isHomeSystem true matches the home system');
assert.ok(!matchesWhere({ isHomeSystem: true }, rocky), '…and not elsewhere');
assert.ok(matchesWhere({ isHomeSystem: [true, false] }, home), 'boolean lists any-match');
assert.ok(matchesWhere({ world: 'planet', isHomeSystem: true }, home), 'AND across string + boolean keys');
assert.ok(!matchesWhere({ world: 'station', isHomeSystem: true }, home), '…one failing key fails the clause');
}
// ===========================================================================

View File

@ -966,6 +966,11 @@ export class GameScene extends Phaser.Scene {
// ?combat boot (the tuning sandbox): the hostile pack around the
// ship. The main game's combat arrives with the quest's ambush
// (the wave run above) — the boot just adds the practice pack.
// NOTE — the player's home system is SAFE SPACE: enemies should
// NEVER be placed there. This dev boot is the one place they could
// land in it, so don't run ?combat at home, and keep that rule for
// any new enemy placement (the quest ambush already targets the
// Neutral Capital, never home).
if (this._wantsCombatDemo()) {
this.combat.spawnPack(this.ship.x, this.ship.y);
// SETTLE WINDOW — the ship's start position is finalized AFTER

View File

@ -1104,9 +1104,10 @@ export class SurfaceScene extends Phaser.Scene {
// deck's own left→right edges, resting on its top rail) the same
// MenuSubBar drawer carries in span mode, with this world's service
// buttons: data/actionbar.json → surface.explore.items, each item's
// optional `where` clause (world / planetClass / faction) resolved
// against THIS world by js/ui/ExploreBar.js — Job Board on every
// planet and station, Bar on the neutral capital, and whatever the
// optional `where` clause (world / planetClass / faction / isHomeSystem)
// resolved against THIS world by js/ui/ExploreBar.js — Job Board on
// every planet and station EXCEPT the player's home system (safe space),
// Bar on the neutral capital, and whatever the
// config adds per class / faction later. The buttons' behavior lands
// later (exploreAction is the seam — BAR is live, the rest land later). A
// world with no matching items
@ -1286,8 +1287,8 @@ export class SurfaceScene extends Phaser.Scene {
/**
* The EXPLORE drawer's button presses (MenuSubBar → onAction). The
* items are the world's service points (data/actionbar.json →
* surface.explore.items — Job Board everywhere, Bar on the neutral
* capital). BAR swaps the surface loop for the world's bar clip and
* surface.explore.items — Job Board everywhere except the home system,
* Bar on the neutral capital). BAR swaps the surface loop for the world's bar clip and
* back (toggleBar — the drawer relabels to LEAVE BAR, and every other
* button on the deck + drawer goes standby until pressed). While the
* bar clip is up a press on any OTHER item is refused (belt-and-braces

View File

@ -22,10 +22,16 @@ import { config } from '../config/Config.js';
* on a station).
* faction the faction controlling this system — 'apex' | 'mindweave' |
* 'zenith', or 'neutral' (wild space; data/factions.json).
* isHomeSystem boolean — true in the player's home (starting) system
* (GameScene.isHomeSystem). A clause of booleans compares
* directly (a list any-matches): `{ isHomeSystem: false }`
* = every system EXCEPT the home.
*
* A value may be a single entry or a list (any-match, case-insensitive).
* An item with no `where` shows everywhere — the Job Board, which is on
* every planet and station.
* A value may be a single entry or a list (any-match; strings are
* case-insensitive). No `where` = every world. The Job Board carries
* `{ isHomeSystem: false }` — every planet and station EXCEPT the
* player's home system (safe space — enemies should never be placed
* there, so there's no work to post on its board).
*
* const ctx = worldContext(surface, gameScene);
* const items = resolveItems(ctx); // the drawer's live buttons
@ -39,8 +45,9 @@ import { config } from '../config/Config.js';
* (the same name the surface HUD decodes in).
* @param {object|null} gameScene — the paused flight scene beneath: its
* systemPlanets / planet (home world) carry the class, its systemRecord
* carries the faction (Galaxy's faction pass — data/factions.json).
* @returns {{world: string, planetClass: string|null, faction: string}}
* carries the faction (Galaxy's faction pass — data/factions.json), and
* its isHomeSystem marks the player's home (starting) system.
* @returns {{world: string, planetClass: string|null, faction: string, isHomeSystem: boolean}}
*/
export function worldContext(surface = {}, gameScene = null) {
const name = String(surface.name ?? '').trim();
@ -69,21 +76,30 @@ export function worldContext(surface = {}, gameScene = null) {
// null = neutral wild space (the faction pass leaves it null) — the
// clause reads it as 'neutral'.
faction: faction != null ? String(faction).trim().toLowerCase() : 'neutral',
// The player's home (starting) system — safe space (enemies should
// never be placed there); the Job Board's clause keeps it out.
isHomeSystem: gameScene?.isHomeSystem === true,
};
}
/**
* Does an item's optional `where` clause match the context? (AND semantics
* across keys — every key named must match.) No clause → shown everywhere.
* String values normalize case-insensitively; boolean values (the
* isHomeSystem key) compare directly, a list any-matching.
*/
export function matchesWhere(where, ctx) {
if (where == null || typeof where !== 'object') return true;
const norm = (v) => (v == null ? 'none' : String(v).trim().toLowerCase());
const c = ctx ?? {};
for (const key of Object.keys(where)) {
const have = norm(c[key]);
const want = (Array.isArray(where[key]) ? where[key] : [where[key]]).map(norm);
if (!want.includes(have)) return false;
const want = Array.isArray(where[key]) ? where[key] : [where[key]];
if (want.every((v) => typeof v === 'boolean')) {
if (!want.includes(Boolean(c[key]))) return false;
} else {
const have = norm(c[key]);
if (!want.map(norm).includes(have)) return false;
}
}
return true;
}