201 lines
11 KiB
JavaScript
201 lines
11 KiB
JavaScript
/**
|
|
* 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/<category>.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);
|
|
* - 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);
|
|
})());
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 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);
|