/** * Quests data-layer test (dev tool, run with Node — no browser): * * node dev/quests.test.mjs * * Pins the contract behind the QUESTS console (the deck's QUESTS button * — right of SHIP, flight deck + surface deck → js/ui/QuestWindow.js): * - quests.json is registered in data/manifest.json; * - the window's knobs are present (title, the 2:3 video feed — the * same dimensions as the research feed, sweep/glitch, the two * category tabs with SIDE marked standby, the toasts); * - the starter quest is well-formed: Main Story, one starter flag, * a non-empty checklist of KNOWN requirement types, a reward that * fits the ship's hold; * - the 'research' check points at a REAL node of its category's tree * (data/research/.json — the check would never complete * otherwise); * - the quest ledger (js/quests/QuestState.js) behaves: idempotent * give/claim, claimed ⊆ granted, a save round-trip, and a * corrupted save degrades to an empty ledger (not a throw); * - the PRIORITY (the tracker HUD's featured quest): set/clear, * the auto-assignment (first active story quest), the cleared- * flag sticks until a grant/claim, and the save round-trip; * - the tracker HUD's knobs (quests.json → tracker: the green title, * the NONE TRACKED line, the SHOW MORE label, the 5-row cap, the * slide duration, the set/clear toasts); * - both command decks carry the QUESTS slot, right of SHIP. */ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); const dataDir = join(root, 'data'); const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8')); const manifest = read('manifest.json'); const quests = read('quests.json'); const actionbar = read('actionbar.json'); const ship = read('ship.json'); // The real code (config singleton → the quest helpers + the ledger). const { config } = await import(join(root, 'js/config/Config.js')); config.init({ quests, ship }); const { QuestState, questDefs, questDef, starterQuestIds } = await import( join(root, 'js/quests/QuestState.js') ); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; const hex = /^#[0-9a-fA-F]{6}$/; // ---------------------------------------------------------------------- // 1. Manifest registration // ---------------------------------------------------------------------- check('manifest registers quests.json', manifest.files.includes('quests.json')); // ---------------------------------------------------------------------- // 2. quests.json — the window's knobs // ---------------------------------------------------------------------- check('quests: master switch present', typeof quests.enabled === 'boolean'); check('quests: enabled (the console is live)', quests.enabled === true); check('quests: title present', typeof quests.title === 'string' && quests.title.length > 0); check('quests: video file named', typeof quests.video?.file === 'string' && quests.video.file.length > 0); check('quests: video aspect is 2:3 (the research feed\u2019s dimensions)', JSON.stringify(quests.video?.aspect) === JSON.stringify([2, 3])); check('quests: sweep configured (interval + duration ranges)', Array.isArray(quests.sweep?.intervalMs) && quests.sweep.intervalMs.length === 2 && Array.isArray(quests.sweep?.durationMs) && quests.sweep.durationMs.length === 2); check('quests: glitch configured', typeof quests.glitch?.enabled === 'boolean' && Array.isArray(quests.glitch?.intervalMs) && Array.isArray(quests.glitch?.slices)); // ---------------------------------------------------------------------- // 3. The category tabs — Main Story live, Side Quests the standby socket // ---------------------------------------------------------------------- const tabs = quests.tabs ?? []; check('quests: two category tabs', tabs.length === 2); const mainTab = tabs.find((t) => t.id === 'main'); const sideTab = tabs.find((t) => t.id === 'side'); check('quests: the MAIN tab is live', !!mainTab && mainTab.standby !== true && typeof mainTab.label === 'string' && hex.test(mainTab.accent ?? '')); check('quests: the SIDE tab is the standby socket', !!sideTab && sideTab.standby === true && typeof sideTab.label === 'string' && hex.test(sideTab.accent ?? '')); check('quests: the standby note is present', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0); // ---------------------------------------------------------------------- // 4. The toasts — starter / ready / claim / claim-denied // ---------------------------------------------------------------------- for (const key of ['starterToast', 'readyToast', 'claimToast', 'claimDenyToast']) { const t = quests[key]; check(`quests: ${key} configured (glyph + text)`, !!t && typeof t.glyph === 'string' && typeof t.text === 'string' && hex.test(t.color ?? '')); } // ---------------------------------------------------------------------- // 5. The starter quest — well-formed, one starter, real checks, fair reward // ---------------------------------------------------------------------- check('quests: at least one quest defined', Array.isArray(quests.quests) && quests.quests.length >= 1); const starters = (quests.quests ?? []).filter((q) => q.starter === true); check('quests: exactly ONE starter quest', starters.length === 1); const starter = starters[0]; check('starter: sits in the MAIN story', starter?.category === 'main'); check('starter: has id/title/description/issuer', typeof starter?.id === 'string' && typeof starter?.title === 'string' && typeof starter?.description === 'string' && typeof starter?.issuer === 'string'); check('starter: icon names a research icon', typeof starter?.icon === 'string' && starter.icon.length > 0); check('starter: a non-empty checklist', Array.isArray(starter?.checks) && starter.checks.length >= 1); check('starter: every check has a type + a player-facing label', (starter?.checks ?? []).every((c) => typeof c.type === 'string' && typeof c.label === 'string' && c.label.length > 0)); check('starter: reward is a positive integer (minerals)', Number.isInteger(starter?.reward?.minerals) && starter.reward.minerals > 0); check('starter: reward fits the ship\u2019s hold', starter.reward.minerals <= (ship.stats?.mineralStorage ?? 0)); // The requirement types the scene evaluates (GameScene._questCheckEval) // — a typo here would be a checklist row that never completes. const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld']); check('starter: every check type is a known live check', (starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type))); // Each check shape — the parameters _questCheckEval reads. for (const c of starter?.checks ?? []) { if (c.type === 'research') { check(`starter check "${c.label}": names category + node`, typeof c.category === 'string' && typeof c.node === 'string'); // The node must exist in its category\u2019s tree — otherwise the // requirement could never read DONE (the research.json registry // knows which categories are static file trees). const tree = read(`research/${c.category}.json`); check(`starter check "${c.label}": the node exists in data/research/${c.category}.json`, !!tree?.nodes && typeof tree.nodes[c.node] === 'object'); } else if (c.type === 'mineralsMined') { check(`starter check "${c.label}": a positive integer amount`, Number.isInteger(c.amount) && c.amount > 0); } else if (c.type === 'tetherBuilt') { check(`starter check "${c.label}": planet + level set`, (c.planet === 'home' || typeof c.planet === 'string') && Number.isInteger(c.level) && c.level >= 1); } else if (c.type === 'discoveredWorld') { check(`starter check "${c.label}": a positive integer count`, Number.isInteger(c.count) && c.count >= 1); } } // ---------------------------------------------------------------------- // 6. The quest ledger — QuestState (give/claim/save-restore) // ---------------------------------------------------------------------- check('helpers: questDefs() sees the defined quests', questDefs().length === (quests.quests ?? []).length); check('helpers: questDef() finds the starter by id', questDef(starter.id)?.title === starter.title); check('helpers: questDef() is null for an unknown id', questDef('no_such_quest') === null); check('helpers: starterQuestIds() is exactly [the starter]', JSON.stringify(starterQuestIds()) === JSON.stringify([starter.id])); { const st = new QuestState(); check('ledger: fresh — nothing granted, nothing claimed', !st.isGranted(starter.id) && !st.isClaimed(starter.id)); check('ledger: give() grants a known quest', st.give(starter.id) === true); check('ledger: give() is idempotent', st.give(starter.id) === false); check('ledger: give() ignores an unknown id', st.give('no_such_quest') === false && !st.isGranted('no_such_quest')); check('ledger: claim() pays out once', st.claim(starter.id) === true && st.isClaimed(starter.id)); check('ledger: claim() is idempotent', st.claim(starter.id) === false); const fresh = new QuestState(); check('ledger: claim() refuses a quest not held', fresh.claim(starter.id) === false && !fresh.isClaimed(starter.id)); } // The save round-trip (SaveData captures toJSON(), prepareLoad reads fromJSON). { const st = new QuestState(); st.give(starter.id); st.claim(starter.id); const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON()))); check('ledger: save round-trip keeps granted + claimed', restored.isGranted(starter.id) && restored.isClaimed(starter.id)); } // A corrupted save degrades — never throws, never pays out a quest the // player does not hold. { check('ledger: fromJSON(malformed) → empty ledger', (() => { try { const st = QuestState.fromJSON({ granted: 'x', claimed: [starter.id] }); return !st.isGranted(starter.id) && !st.isClaimed(starter.id); } catch { return false; } })()); check('ledger: fromJSON drops ids the data no longer defines', (() => { const st = QuestState.fromJSON({ granted: ['no_such_quest'], claimed: ['no_such_quest'] }); return st.granted.size === 0 && st.claimed.size === 0; })()); check('ledger: fromJSON keeps claimed ⊆ granted', (() => { const st = QuestState.fromJSON({ granted: [starter.id], claimed: [] }); return st.isGranted(starter.id) && !st.isClaimed(starter.id); })()); } // ---------------------------------------------------------------------- // 6b. The quest ledger — the PRIORITY (the tracker HUD's featured quest) // ---------------------------------------------------------------------- { const st = new QuestState(); st.give(starter.id); check('priority: fresh — no priority', st.priority === null); check('priority: setPriority() refuses a quest not held', st.setPriority('no_such_quest') === false); check('priority: setPriority() sets a held quest', st.setPriority(starter.id) === true && st.priority === starter.id); check('priority: setPriority() is idempotent for the current one', st.setPriority(starter.id) === false); check('priority: resolve auto-assigns the single active quest', st.resolvePriority([starter.id]) === starter.id); check('priority: resolve keeps a stored priority that is still active', st.resolvePriority([starter.id]) === starter.id); check('priority: clearPriority() clears it', st.clearPriority() === true && st.priority === null && st.priorityCleared === true); check('priority: clearPriority() with nothing set is a no-op', st.clearPriority() === false); check('priority: a cleared priority sticks — resolve returns null', st.resolvePriority([starter.id]) === null); check('priority: a claimed priority drops off the ledger', (() => { const st2 = new QuestState(); st2.give(starter.id); st2.setPriority(starter.id); st2.claim(starter.id); return st2.priority === null; })()); check('priority: setPriority() refuses a CLAIMED quest', (() => { const st2 = new QuestState(); st2.give(starter.id); st2.claim(starter.id); return st2.setPriority(starter.id) === false; })()); } // Multi-quest behavior — the real data set has ONE quest today (side // quests are deferred by design), so the ordering rules are pinned on a // synthetic set: a second MAIN + one SIDE (config is a plain singleton — // re-init for the test, restore after). { const MAIN2 = { id: 'test_main_b', category: 'main', title: 'Test Main B', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 20, label: 'MINE 20' }], reward: { minerals: 20 } }; const SIDE1 = { id: 'test_side_a', category: 'side', title: 'Test Side A', 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: { ...quests, quests: [...quests.quests, MAIN2, SIDE1] } }); try { const order = questDefs().map((q) => q.id); // [starter, MAIN2, SIDE1] — file order const main = questDefs().filter((q) => q.category === 'main').map((q) => q.id); const side = questDefs().filter((q) => q.category === 'side').map((q) => q.id); const display = [...main, ...side]; check('priority: display order is main story first, then side quests', JSON.stringify(display) === JSON.stringify([order[0], order[1], order[2]])); const st = new QuestState(); for (const id of display) st.give(id); check('priority: resolve auto-assigns the FIRST active quest (display order)', st.resolvePriority(display) === display[0] && st.priority === display[0]); check('priority: resolve keeps a stored priority that is still active', (st.setPriority(display[1]) === true && st.resolvePriority(display) === display[1])); check('priority: the player can feature a SIDE quest over the story order', (st.setPriority(side[0]) === true && st.resolvePriority(display) === side[0])); // A cleared priority STICKS (the auto-assignment stays off)... check('priority: a cleared priority sticks — resolve returns null', (st.clearPriority() === true && st.resolvePriority(display) === null)); // ...until a new quest ARRIVES (give re-arms the auto-assignment) — check('priority: a new grant re-arms the auto-assignment', (() => { const fresh = new QuestState(); fresh.give(display[0]); fresh.resolvePriority([display[0]]); fresh.clearPriority(); if (fresh.resolvePriority([display[0]]) !== null) return false; // still cleared fresh.give(display[1]); // a new quest arrives → re-armed return fresh.priorityCleared === false && fresh.resolvePriority([display[0], display[1]]) === display[0]; })()); // ...or one is CLAIMED (claim re-arms it — the active set changed). check('priority: claiming re-arms the auto-assignment (advances to the next)', (() => { const st2 = new QuestState(); for (const id of display) st2.give(id); st2.resolvePriority(display); st2.clearPriority(); st2.claim(display[0]); // the first drops off the active set return st2.resolvePriority([display[1], display[2]]) === display[1] && st2.priority === display[1]; })()); // The save round-trip keeps the priority (and drops one that is no // longer active — claimed or unknown). const st3 = new QuestState(); for (const id of display) st3.give(id); st3.setPriority(side[0]); const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st3.toJSON()))); check('priority: save round-trip keeps the stored priority (a side quest)', restored.priority === side[0] && restored.priorityCleared === false); check('priority: a save pointing at a CLAIMED quest drops it', (() => { const r = QuestState.fromJSON({ granted: [display[0]], claimed: [display[0]], priority: display[0] }); return r.priority === null; })()); check('priority: a save pointing at an UNKNOWN quest drops it', (() => { const r = QuestState.fromJSON({ granted: [display[0]], priority: 'no_such_quest' }); return r.priority === null; })()); } finally { config.init(savedConfig); // restore the real data set } } { const st = new QuestState(); st.give(starter.id); st.resolvePriority([starter.id]); st.clearPriority(); const restoredClear = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON()))); check('priority: save round-trip keeps the cleared flag', restoredClear.priority === null && restoredClear.priorityCleared === true); check('priority: a PRE-priority save (no field) restores as null', (() => { const r = QuestState.fromJSON({ granted: [starter.id], claimed: [] }); return r.priority === null && r.priorityCleared === false; })()); } // ---------------------------------------------------------------------- // 6c. quests.json — the tracker HUD's knobs // ---------------------------------------------------------------------- { const t = quests.tracker ?? {}; check('tracker: section present with the master switch', typeof t.enabled === 'boolean'); check('tracker: enabled (the HUD is live)', t.enabled === true); check('tracker: title + empty-state lines', typeof t.title === 'string' && t.title.length > 0 && typeof t.noneTracked === 'string' && t.noneTracked.length > 0); check('tracker: SHOW MORE label', typeof t.showMore === 'string' && t.showMore.length > 0); check('tracker: maxVisible is a sane cap (≥1)', Number.isInteger(t.maxVisible) && t.maxVisible >= 1); check('tracker: slide duration configured', Number.isFinite(t.slideMs) && t.slideMs >= 80); for (const key of ['priorityToast', 'clearToast']) { check(`tracker: ${key} configured (glyph + text + color)`, !!t[key] && typeof t[key].glyph === 'string' && typeof t[key].text === 'string' && hex.test(t[key].color ?? '')); } check('tracker: the PRIORITY accent is the tracker green (#67e863)', (t.priorityToast?.color ?? '').toLowerCase() === '#67e863'); } // ---------------------------------------------------------------------- // 7. The command decks — the QUESTS slot, right of SHIP (both decks) // ---------------------------------------------------------------------- for (const [deck, slots] of [ ['flight', actionbar.buttons ?? []], ['surface', actionbar.surface?.buttons ?? []], ]) { const iShip = slots.findIndex((s) => s.id === 'ship'); check(`actionbar (${deck}): the QUESTS slot sits right of the SHIP slot`, iShip >= 0 && slots[iShip + 1]?.id === 'quests'); check(`actionbar (${deck}): the QUESTS slot carries a label + accent`, (() => { const q = slots.find((s) => s.id === 'quests'); return q && typeof q.label === 'string' && q.label.length > 0 && hex.test(q.accent ?? ''); })()); } console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);