451 lines
15 KiB
JavaScript
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);
|