diff --git a/assets/videos/jobboard.mp4 b/assets/videos/jobboard.mp4 new file mode 100644 index 0000000..296420f Binary files /dev/null and b/assets/videos/jobboard.mp4 differ diff --git a/data/actionbar.json b/data/actionbar.json index 17397ec..d132ba5 100644 --- a/data/actionbar.json +++ b/data/actionbar.json @@ -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" } } ] } diff --git a/dev/explore.test.mjs b/dev/explore.test.mjs index d26bb1a..600831c 100644 --- a/dev/explore.test.mjs +++ b/dev/explore.test.mjs @@ -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'); } // =========================================================================== diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 83563ff..5826d30 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 0f750f8..bf9e588 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -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 diff --git a/js/ui/ExploreBar.js b/js/ui/ExploreBar.js index 055f1c4..acfdb97 100644 --- a/js/ui/ExploreBar.js +++ b/js/ui/ExploreBar.js @@ -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; }