379 lines
19 KiB
JavaScript
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 ✔');
|