/** * EXPLORE bar test (dev tool, run with Node — no browser needed): * * node dev/explore.test.mjs * * The surface deck's Explore slot + the full-width EXPLORE drawer * (js/ui/ExploreBar.js item logic + js/ui/MenuSubBar.js's span/items * options, driven headlessly): * * - 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. * - WHERE — the clause matcher: world / planetClass / faction keys, * single + list values, case-insensitive; stations have no class * (`none`); neutral wild space reads `faction: 'neutral'`. * - 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 * right-aligned fit is unchanged (the menu drawer). * - RE-LABEL — MenuSubBar.setLabel (→ MenuButton.setLabel, the * ActionBar.setLabel mirror): the bar slot's LEAVE BAR swap — label + * finalLabel, the panel re-fits wider and back exactly, the hit rect * tracks the panel — and the drawer's other item stands down * (setDisabled) while the bar slot stays live (the setBarLocked * drawer-half). */ 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'; import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) 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 { worldContext, matchesWhere, resolveItems } = await import( pathToFileURL(join(__dirname, '../js/ui/ExploreBar.js')).href ); const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default; const { MenuSubBar } = await import(pathToFileURL(join(__dirname, '../js/ui/MenuSubBar.js')).href); const ids = (list) => list.map((i) => i.id); // =========================================================================== console.log('== DECK — the surface deck swapped SHIP for EXPLORE =='); { const SURFACE = config.get('actionbar.surface.buttons'); const IDS = SURFACE.map((b) => b.id).filter(Boolean); assert.equal(IDS.length, 6, 'the surface deck keeps six slots'); assert.ok(IDS.includes('explore'), 'the surface deck has the explore slot'); assert.ok(!IDS.includes('ship'), 'ship is off the surface deck'); assert.ok(IDS.includes('shop') && IDS.includes('menu'), 'shop + menu are still there'); // The flight deck is untouched (its ship slot stays — only planets changed). const FLIGHT = config.get('actionbar.buttons'); assert.ok(FLIGHT.map((b) => b.id).includes('ship'), 'the flight deck keeps its ship slot'); } // =========================================================================== console.log('== CONTEXT — world / planetClass / faction resolve from the run =='); { // A rocky world in neutral wild space. const rocky = worldContext( { isStation: false, name: 'Klaxoria' }, { systemPlanets: [{ discoveryName: 'Klaxoria', name: 'rocky' }], systemRecord: { faction: null } }, ); assert.equal(rocky.world, 'planet'); assert.equal(rocky.planetClass, 'rocky'); assert.equal(rocky.faction, 'neutral', 'a null faction reads neutral wild space'); // The same world inside an APEX-controlled system. const apex = worldContext( { isStation: false, name: 'Klaxoria' }, { systemPlanets: [{ discoveryName: 'Klaxoria', name: 'rocky' }], systemRecord: { faction: 'apex' } }, ); assert.equal(apex.faction, 'apex'); // The neutral capital world (the planet entity lowercases its class — // SystemGenerator's 'capitalNeutral' arrives as 'capitalneutral'; the // clause matcher is case-insensitive either way). const capital = worldContext( { isStation: false, name: 'Civitas' }, { systemPlanets: [{ discoveryName: 'Civitas', name: 'capitalneutral' }], systemRecord: { faction: null } }, ); assert.equal(capital.planetClass, 'capitalneutral'); // A deep-space station in a MINDWEAVE system — no planet class. const station = worldContext( { isStation: true, name: 'Hell\u2019s Hideout' }, { systemPlanets: [], systemRecord: { faction: 'mindweave' } }, ); assert.equal(station.world, 'station'); assert.equal(station.planetClass, null, 'stations carry no planet class'); assert.equal(station.faction, 'mindweave'); // The HOME world (gameScene.planet — not in systemPlanets). const home = worldContext( { isStation: false, name: 'Terra' }, { systemPlanets: [], planet: { discoveryName: 'Terra', name: 'terran' }, systemRecord: { faction: null } }, ); assert.equal(home.planetClass, 'terran', 'the home world resolves via gameScene.planet'); // 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'); } // =========================================================================== console.log('== ITEMS — Job Board everywhere, Bar on the neutral capital =='); { const mk = (cls, { isStation = false, faction = null } = {}) => worldContext( { isStation, name: 'World' }, { systemPlanets: isStation ? [] : [{ discoveryName: 'World', name: cls }], systemRecord: { faction }, }, ); // Job Board: every planet… for (const cls of ['terran', 'rocky', 'gas', 'ice', 'lava', 'arid', 'capitalApex']) { const live = ids(resolveItems(mk(cls))); assert.ok(live.includes('jobBoard'), `job board on a ${cls} world`); } // …and every station. 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'); // Bar: the neutral capital ONLY. assert.deepEqual(ids(resolveItems(mk('capitalNeutral'))), ['jobBoard', 'bar'], 'bar on the neutral capital'); for (const cls of ['rocky', 'terran', 'gas', 'ice', 'lava', 'arid', 'capitalApex', 'capitalMindweave', 'capitalZenith']) { assert.ok(!ids(resolveItems(mk(cls))).includes('bar'), `no bar on a ${cls} world`); } assert.ok(!ids(resolveItems(mk(null, { isStation: true }))).includes('bar'), 'no bar on a station'); // The neutral capital's system is still faction-neutral — the bar is about // the WORLD (planetClass), not the system's faction. assert.deepEqual(ids(resolveItems(mk('capitalNeutral', { faction: null }))), ['jobBoard', 'bar']); // Config shape: the bar's items are ordered (Job Board before Bar). const ORDER = ids(resolveItems(mk('capitalNeutral'))); assert.deepEqual(ORDER, ['jobBoard', 'bar'], 'the config order is the bar order'); } // =========================================================================== console.log('== WHERE — clause matching (single + list values, case-insensitive) =='); { const rocky = worldContext( { isStation: false, name: 'W' }, { systemPlanets: [{ discoveryName: 'W', name: 'rocky' }], systemRecord: { faction: null } }, ); const apex = worldContext( { isStation: false, name: 'W' }, { systemPlanets: [{ discoveryName: 'W', name: 'rocky' }], systemRecord: { faction: 'apex' } }, ); const station = worldContext( { isStation: true, name: 'S' }, { systemPlanets: [], systemRecord: { faction: 'zenith' } }, ); assert.ok(matchesWhere(null, rocky), 'no clause → everywhere'); assert.ok(matchesWhere({}, rocky), 'empty clause → everywhere'); assert.ok(matchesWhere({ world: 'planet' }, rocky)); assert.ok(!matchesWhere({ world: 'station' }, rocky)); assert.ok(matchesWhere({ world: 'station' }, station)); assert.ok(matchesWhere({ planetClass: 'rocky' }, rocky)); assert.ok(matchesWhere({ planetClass: ['rocky', 'ice'] }, rocky), 'list = any-match'); assert.ok(matchesWhere({ planetClass: 'ROCKY' }, rocky), 'case-insensitive'); assert.ok(!matchesWhere({ planetClass: 'capitalNeutral' }, rocky)); assert.ok(matchesWhere({ planetClass: 'none' }, station), 'stations match planetClass none'); assert.ok(!matchesWhere({ planetClass: 'rocky' }, station), '…but not a real class'); assert.ok(matchesWhere({ faction: 'neutral' }, rocky)); assert.ok(!matchesWhere({ faction: 'apex' }, rocky)); assert.ok(matchesWhere({ faction: 'apex' }, apex)); 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)); } // =========================================================================== console.log('== SPAN — MenuSubBar stretches across the deck (the shop bar\u2019s span) =='); { // The headless scene (the actionbar test's recipe) — just enough for the // drawer to construct, open, and drive its state machine. function stubObject(extra = {}) { return { 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() { return this; }, disableInteractive() { return this; }, on() { return this; }, once() { return this; }, off() { return this; }, // setText keeps .width fresh, like Phaser (MenuButton.setLabel // re-measures against it); a deterministic fake: 7 px per glyph. setText(s) { this.text = String(s); this.width = Math.max(20, String(s).length * 7); 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, }; } function makeScene() { return { scale: { width: 1280, height: 720 }, add: { existing: (o) => o, text: (x, y, str) => stubObject({ x, y, text: String(str), width: Math.max(20, String(str).length * 7) }), 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) => { 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, delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; }, }, textures: { exists: () => true }, }; } const ITEMS = config.get('actionbar.surface.explore.items'); assert.ok(Array.isArray(ITEMS) && ITEMS.length === 2, 'the config carries the explore items'); // FULL-SPAN — the deck's rect (the actionbar margins: left 20, right 20 → // a 1240 px span at 1280 wide) is the drawer's span, resting on the // deck's top rail (y = deck.rect.y). const deckRect = { x: 20, y: 720 - 12 - 56, w: 1280 - 40, h: 56 }; const fired = []; const spanBar = new MenuSubBar(makeScene(), { anchor: { x: 640, y: deckRect.y, w: 150 }, span: { left: deckRect.x, right: deckRect.x + deckRect.w }, items: ITEMS, onAction: (id) => fired.push(id), }); assert.equal(spanBar.W, deckRect.w, 'the bar spans the deck\u2019s full width'); assert.equal(spanBar.rect.x, 20, 'left edge on the deck\u2019s left edge'); assert.equal(spanBar.rect.x + spanBar.rect.w, 1260, 'right edge on the deck\u2019s right edge'); assert.equal(spanBar.y, deckRect.y, 'the bottom edge rests on the deck\u2019s top rail'); assert.deepEqual(spanBar.buttons.map((s) => s.id), ['jobBoard', 'bar'], 'the world\u2019s items, in config order'); assert.ok(spanBar.contains(640, deckRect.y - 20), 'a point inside the bar is inside the bar'); assert.ok(!spanBar.contains(640, deckRect.y + 10), 'a point over the deck is outside the bar'); // The open cycle: closed → opening → open (driven by update), and the // drawer fires its items (the scene's seam — exploreAction). assert.equal(spanBar.state, 'closed'); spanBar.open(); assert.ok(spanBar.isOpen, 'the bar is open while unfolding'); for (let t = 1000; t <= 1600; t += 50) spanBar.update(t, 50); assert.equal(spanBar.state, 'open', 'the unfold lands on open'); spanBar.fire('jobBoard'); spanBar.fire('bar'); assert.deepEqual(fired, ['jobBoard', 'bar'], 'presses fire the item ids'); spanBar.close(); assert.equal(spanBar.closing, true, 'the close animates'); for (let t = 1600; t <= 2000; t += 20) spanBar.update(t, 20); assert.equal(spanBar.state, 'closed', 'the fold lands on closed'); // RE-LABEL — the bar slot's LEAVE BAR (MenuSubBar.setLabel → // MenuButton.setLabel, the ActionBar.setLabel mirror): the label + // finalLabel swap (a re-open re-decodes to the held label), the panel // re-fits to the longer label — wider in, EXACTLY back out — and the // hit rect tracks the panel. The drawer's other item stands down // (setDisabled, the setBarLocked drawer-half) while the bar slot itself // stays live — it is the way out. spanBar.open(); for (let t = 2000; t <= 2800; t += 50) spanBar.update(t, 50); assert.equal(spanBar.state, 'open', 're-opened for the relabel pass'); const barSlot = spanBar.buttons.find((b) => b.id === 'bar'); const barBtn = barSlot.btn; const jobSlot = spanBar.buttons.find((b) => b.id === 'jobBoard'); const wBar = barBtn.style.width; assert.ok(wBar > 0, 'the bar button has a measured width'); assert.equal(spanBar.setLabel('bar', 'Leave Bar'), barSlot, 'setLabel returns the slot'); assert.equal(barBtn.labelText.text, 'LEAVE BAR', 'the button label is swapped'); assert.equal(barSlot.finalLabel, 'LEAVE BAR', 'finalLabel tracks the relabel (a re-open re-decodes to it)'); assert.ok(barBtn.style.width > wBar, 'the panel re-fits to the longer label'); assert.equal(barBtn._hit.width, barBtn.style.width, 'the hit rect tracks the panel'); assert.equal(barBtn._hit.x, -barBtn.style.width / 2, 'the hit rect stays centred on the slot'); // The round trip — back to BAR, the panel lands exactly where it was. assert.equal(spanBar.setLabel('bar', 'BAR').btn.style.width, wBar, 'relabeling back re-fits to the original width'); assert.equal(barSlot.finalLabel, 'BAR', 'finalLabel round-trips'); // No button for a missing id → null, no crash. assert.equal(spanBar.setLabel('nope', 'X'), null, 'unknown id → null'); // The drawer's lock half (setBarLocked): the OTHER item goes gray // (clicks swallowed) while the bar slot stays live — and back. spanBar.setDisabled('jobBoard', true); assert.equal(jobSlot.btn.disabled, true, 'the drawer item stands down'); assert.equal(barSlot.btn.disabled, false, 'the bar slot itself stays live (the way out)'); spanBar.setDisabled('jobBoard', false); assert.equal(jobSlot.btn.disabled, false, 'leaving the bar brings the item back'); // CLASSIC FIT (no span) — the menu drawer is unchanged: content width, // right-aligned over its button. const menuBar = new MenuSubBar(makeScene(), { anchor: { x: 1100, y: 600, w: 150 }, onAction: () => {}, }); assert.ok(menuBar.W <= 1280 - 24, 'the classic fit hugs its content'); assert.equal(menuBar.rect.x + menuBar.W, 1100 + 150 / 2, 'the bar\u2019s right edge on the button\u2019s right edge'); assert.deepEqual(menuBar.buttons.map((s) => s.id), ['save', 'load', 'mainMenu'], 'the config\u2019s default items'); } console.log('\nAll Explore-bar checks passed ✔');