orbit/dev/explore.test.mjs

379 lines
19 KiB
JavaScript

/**
* 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 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'`;
* 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
* 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');
assert.equal(rocky.isHomeSystem, false, 'a plain world is not the home system');
// 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 },
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 except home, Bar on the neutral capital ==');
{
const mk = (cls, { isStation = false, faction = null, home = false } = {}) =>
worldContext(
{ isStation, name: 'World' },
{
systemPlanets: isStation ? [] : [{ discoveryName: 'World', name: cls }],
systemRecord: { faction },
isHomeSystem: home,
},
);
// 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');
// …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');
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));
// 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');
}
// ===========================================================================
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 ✔');