orbit/dev/quest-window.test.mjs

451 lines
15 KiB
JavaScript

/**
* QuestWindow open-selection test (dev tool, run with Node — no browser):
*
* node dev/quest-window.test.mjs
*
* The deck's QUESTS button opens the console (js/ui/QuestWindow.js) with
* a FRESH-open default: the detail plate lands on the run's PRIORITY
* quest — the one the tracker HUD features, the story quest the player
* is actively engaged in (QuestState.resolvePriority, the tracker's
* own seam — GameScene/SurfaceScene wire it in as o.resolvePriority)
* with its tab activated. Pins the contract:
*
* - questOpenLanding (pure): resolves over the ACTIVE quests
* (held + unclaimed) via the ledger's resolver — a resolver that
* names a claimed/unknown quest or throws degrades to null (the
* tab's first quest stands in), and with no resolver nothing lands;
* - the REAL QuestWindow (a stubbed scene — the quest-tracker.test.mjs
* pattern) — a fresh open() lands on the player's SET priority (a
* side-tab priority activates the side tab), on the AUTO priority
* (the first active story quest) when the player set none, and
* falls back to the active tab's first quest when the priority is
* CLEARED (the pre-change default — claimed quests included, the
* window never lands on nothing);
* - openAt() (the tracker's SHOW MORE / VIEW QUEST) always beats the
* priority — an explicit target lands where it is asked.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
// --- Stub just enough of Phaser for the window's class + build pass -----
class ContainerStub {
constructor(scene, x = 0, y = 0) {
this.scene = scene;
this.x = x;
this.y = y;
this.alpha = 1;
this.visible = true;
this.childrenList = [];
}
add(o) {
if (Array.isArray(o)) o.forEach((c) => this.childrenList.push(c));
else this.childrenList.push(o);
return this;
}
setVisible(v) {
this.visible = v;
return this;
}
setAlpha(a) {
this.alpha = a;
return this;
}
setDepth() {
return this;
}
setScrollFactor() {
return this;
}
setPosition(x, y) {
this.x = x;
this.y = y;
return this;
}
destroy(recursive) {
if (this.destroyed) return;
this.destroyed = true;
if (recursive) for (const c of this.childrenList) c.destroy?.();
}
}
class RectStub {
constructor(x, y, w, h) {
this.x = x;
this.y = y;
this.width = w;
this.height = h;
}
contains(px, py) {
return px >= this.x && px <= this.x + this.width && py >= this.y && py <= this.y + this.height;
}
}
class GameObjectStub {
constructor() {
this.x = 0;
this.y = 0;
this.alpha = 1;
this.visible = true;
this.handlers = {};
}
setScrollFactor() {
return this;
}
setDepth() {
return this;
}
setAlpha(a) {
this.alpha = a;
return this;
}
setVisible(v) {
this.visible = v;
return this;
}
setOrigin() {
return this;
}
setBlendMode() {
return this;
}
setPosition(x, y) {
this.x = x;
this.y = y;
return this;
}
setDisplaySize() {
return this;
}
setColor(c) {
this.color = c;
return this;
}
setText(s) {
this.text = s;
return this;
}
setVolume() {
return this;
}
setLoop() {
return this;
}
on(ev, fn) {
(this.handlers[ev] ??= []).push(fn);
return this;
}
resume() {
return this;
}
pause() {
return this;
}
destroy() {
this.destroyed = true;
return this;
}
}
class FakeGraphics extends GameObjectStub {
clear() {
return this;
}
lineStyle() {
return this;
}
fillStyle() {
return this;
}
fillRect() {
return this;
}
fillPoints() {
return this;
}
strokePoints() {
return this;
}
strokeRect() {
return this;
}
lineBetween() {
return this;
}
fillCircle() {
return this;
}
fillTriangle() {
return this;
}
strokeCircle() {
return this;
}
setInteractive(o) {
this.input = o;
return this;
}
}
class FakeText extends GameObjectStub {
constructor(x, y, str, style) {
super();
this.x = x;
this.y = y;
this.text = str;
this.color = style?.color ?? null;
}
}
const PhaserStub = {
GameObjects: { Container: ContainerStub, Text: FakeText, Image: GameObjectStub },
Geom: { Rectangle: RectStub },
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
BlendModes: { ADD: 2 },
Math: { FloatBetween: (a, b) => a + Math.random() * (b - a) },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
globalThis.document = {
// the scanline/sweep tiles (canvas generation) — the fakes don't paint
createElement: () => ({
width: 0,
height: 0,
getContext: () => ({
fillStyle: null,
fillRect() {},
createLinearGradient: () => ({ addColorStop() {} }),
}),
}),
};
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(file('config/Config.js'));
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 { QuestState, questCheckKey } = await import(file('quests/QuestState.js'));
const { QuestWindow, questOpenLanding } = await import(file('ui/QuestWindow.js'));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// A stub scene with just enough plumbing for the window's build + open.
function makeScene(o = {}) {
const add = {
existing() {},
graphics: () => new FakeGraphics(),
text: (x, y, str, style) => new FakeText(x, y, str, style),
circle: () => new GameObjectStub(),
tileSprite: () => new GameObjectStub(),
image: () => new GameObjectStub(),
video: () => new GameObjectStub(),
};
return {
add,
scale: { width: 1280, height: 800 },
time: { now: 100000, delayedCall() {} },
tweens: { add(t) { t.onComplete?.(); return {}; } },
textures: { exists: () => false, addCanvas() {} },
...o,
};
}
// ----------------------------------------------------------------------
// 1. questOpenLanding — the pure fresh-open resolver
// ----------------------------------------------------------------------
{
const Q = (id, category, over = {}) => ({ id, category, claimed: false, complete: false, checks: [{ done: false }], ...over });
const active = [Q('a_main', 'main'), Q('b_main', 'main'), Q('c_side', 'side')];
const allClaimed = active.map((q) => ({ ...q, claimed: true }));
check('landing: no quests → null', questOpenLanding([], (ids) => ids[0]) === null);
check('landing: undefined → null', questOpenLanding(undefined, (ids) => ids[0]) === null);
check('landing: nothing ACTIVE (all claimed) → null',
questOpenLanding(allClaimed, (ids) => 'a_main') === null);
check('landing: the resolver\u2019s pick lands (id + its category)',
(() => {
const r = questOpenLanding(active, (ids) => 'b_main');
return r && r.id === 'b_main' && r.category === 'main';
})());
check('landing: a SIDE pick activates the side tab',
(() => { const r = questOpenLanding(active, (ids) => 'c_side'); return r && r.id === 'c_side' && r.category === 'side'; })());
check('landing: claimed quests are never offered (no active set → the resolver is never consulted)',
(() => {
let seen = 'unset';
const r = questOpenLanding(allClaimed, (ids) => { seen = ids; return 'a_main'; });
return r === null && seen === 'unset';
})());
check('landing: a pick of a CLAIMED-only quest degrades to null',
(() => {
const mixed = [Q('a_main', 'main', { claimed: true }), Q('b_main', 'main')];
const r = questOpenLanding(mixed, (ids) => 'a_main');
return r === null;
})());
check('landing: a pick the data does not define degrades to null',
questOpenLanding(active, (ids) => 'no_such_quest') === null);
check('landing: a throwing resolver degrades to null (never a crash)',
(() => {
try {
return questOpenLanding(active, () => { throw new Error('boom'); }) === null;
} catch {
return false;
}
})());
check('landing: no resolver (the scene did not wire it) → null (the tab\u2019s first stands in)',
questOpenLanding(active, null) === null && questOpenLanding(active) === null);
}
// ----------------------------------------------------------------------
// 2. The REAL window — a fresh open lands on the run's priority quest
// ----------------------------------------------------------------------
{
// The real data's three main quests + a synthetic side quest (the
// player can feature it — the window must land there on ITS tab).
const SIDE_X = { id: 'win_side_x', category: 'side', title: 'Test Side X', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 10, label: 'MINE 10' }], reward: { minerals: 10 } };
const savedConfig = config.data;
config.init({ ...config.data, quests: { ...configData.quests, quests: [...(configData.quests?.quests ?? []), SIDE_X] } });
try {
const SNAPSHOT = (over = {}) => ({
categories: [
{ id: 'main', label: 'Main Story' },
{ id: 'side', label: 'Side Quests' },
],
quests: [
{ id: 'start_your_journey', category: 'main', title: 'START YOUR JOURNEY', claimed: false, complete: false, checks: [{ done: false }] },
{ id: 'leaving_the_nest', category: 'main', title: 'LEAVING THE NEST', claimed: false, complete: false, checks: [{ done: false }] },
{ id: 'win_side_x', category: 'side', title: 'TEST SIDE X', claimed: false, complete: false, checks: [{ done: true }] },
],
...over,
});
const SIDE_KEY = questCheckKey({ type: 'mineralsMined', amount: 10, label: 'MINE 10' });
const claimSide = (st) => {
st.markCheck('win_side_x', SIDE_KEY, 'synthetic');
st.claim('win_side_x'); // a REAL claim (all its checks are done)
};
const make = (ledger, snap) => {
const w = new QuestWindow(makeScene(), {
getSnapshot: () => snap ?? SNAPSHOT(),
resolvePriority: (ids) => ledger.resolvePriority(ids),
getPriority: () => ledger.priority ?? null,
});
return w;
};
const ledger = (fns) => {
const st = new QuestState();
st.give('start_your_journey');
st.give('leaving_the_nest');
st.give('win_side_x');
fns?.(st);
return st;
};
// The player's SET priority — the story quest they are engaged in —
// lands on a fresh open, ahead of the data-order first quest.
{
const st = ledger((x) => x.setPriority('leaving_the_nest'));
const w = make(st);
w.open();
check('window: a fresh open lands on the player\u2019s SET priority quest',
w.selected === 'leaving_the_nest' && w.openState === 'open');
check('window: the priority\u2019s tab is active', w._activeTab === 'main');
w.destroy();
}
// A SIDE-tab priority — the window follows it onto its own tab.
{
const st = ledger((x) => x.setPriority('win_side_x'));
const w = make(st);
w.open();
check('window: a side-quest priority lands on the SIDE tab',
w.selected === 'win_side_x' && w._activeTab === 'side');
w.destroy();
}
// No priority set — the ledger's AUTO-assignment (the first active
// story quest, the tracker's own rule) is the landing quest.
{
const st = ledger();
const w = make(st);
w.open();
check('window: no priority — the AUTO priority (first active story quest) lands',
w.selected === 'start_your_journey' && w._activeTab === 'main');
w.destroy();
}
// A CLEARED priority — the player declined a featured quest: the
// pre-change default stands (the active tab's first quest).
{
const st = ledger((x) => x.clearPriority());
const w = make(st);
w.open();
check('window: a CLEARED priority falls back to the tab\u2019s first quest (the old default)',
w.selected === 'start_your_journey' && w._activeTab === 'main');
w.destroy();
}
// openAt() (the tracker's SHOW MORE / VIEW QUEST) beats the priority.
{
const st = ledger((x) => x.setPriority('leaving_the_nest'));
const w = make(st);
w.openAt('win_side_x');
w.open();
check('window: openAt\u2019s explicit target beats the priority',
w.selected === 'win_side_x' && w._activeTab === 'side');
w.destroy();
}
// A CLAIMED quest is not active — the resolver walks the active
// set and the window lands on the next engaged quest (never the
// claimed one).
{
const st = ledger((x) => {
x.setPriority('win_side_x');
claimSide(x); // the claim drops its own priority + auto-assigns
});
const snap = SNAPSHOT({
quests: [
{ id: 'start_your_journey', category: 'main', claimed: false, complete: false, checks: [{ done: false }] },
{ id: 'leaving_the_nest', category: 'main', claimed: false, complete: false, checks: [{ done: false }] },
{ id: 'win_side_x', category: 'side', claimed: true, complete: true, checks: [{ done: true }] },
],
});
const w = make(st, snap);
w.open();
check('window: a CLAIMED quest is never the landing (the priority advances)',
w.selected === 'start_your_journey' && w._activeTab === 'main');
w.destroy();
}
// Nothing active (every held quest claimed) — the fallback is the
// tab's first quest (the pre-change behavior; the window shows
// something, never a dead plate).
{
const st = ledger((x) => claimSide(x));
const snap = SNAPSHOT({
quests: [
{ id: 'start_your_journey', category: 'main', claimed: true, complete: true, checks: [{ done: true }] },
{ id: 'leaving_the_nest', category: 'main', claimed: true, complete: true, checks: [{ done: true }] },
{ id: 'win_side_x', category: 'side', claimed: true, complete: true, checks: [{ done: true }] },
],
});
const w = make(st, snap);
w.open();
check('window: nothing active — the tab\u2019s first quest stands (no dead plate)',
w.selected === 'start_your_journey' && w._activeTab === 'main');
w.destroy();
}
} finally {
config.init(savedConfig); // restore the real data set
}
}
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);