684 lines
43 KiB
JavaScript
684 lines
43 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 LIVE (the board’s accepted contracts —
|
||
* data/sidequests.json), 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 'Leaving the Nest' quest (the starter's follow-up) is
|
||
* well-formed: its four checks (the system's full nav chart, the
|
||
* home gates activated, Trestle's two story beats), its unlock
|
||
* requirement (the starter's reward CLAIMED), and its reward 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, a corrupted
|
||
* save degrades to an empty ledger (not a throw), and the unlock
|
||
* chain (grantUnlocked arms the follow-up exactly when its
|
||
* prerequisite's reward is claimed);
|
||
* - the ledger's COMPLETED set (the monotonic checklist witness):
|
||
* questCheckKey's stable identity (label/key-order independent),
|
||
* markCheck idempotency + detail refresh, done ⊆ granted, the
|
||
* save round-trip, and old/malformed saves degrading (not throwing);
|
||
* - 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 (flight: right of SHIP;
|
||
* surface: right of EXPLORE — the surface deck's SHIP slot became
|
||
* EXPLORE, the full-width service drawer).
|
||
*/
|
||
import { fileURLToPath, pathToFileURL } 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(pathToFileURL(join(root, 'js/config/Config.js')).href);
|
||
config.init({ quests, ship });
|
||
const { QuestState, questCheckKey, questDefs, questDef, starterQuestIds } = await import(
|
||
pathToFileURL(join(root, 'js/quests/QuestState.js')).href
|
||
);
|
||
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 + Side Quests (both live — the side
|
||
// tab lists the board's accepted contracts, data/sidequests.json)
|
||
// ----------------------------------------------------------------------
|
||
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 live (the board’s accepted contracts)', !!sideTab && sideTab.standby !== true && typeof sideTab.label === 'string' && hex.test(sideTab.accent ?? ''));
|
||
check('quests: the standby note is present (a standby tab, if ever authored again, toasts it)', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0);
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 4. The toasts — starter / ready / claim / claim-denied / capital-denied
|
||
// ----------------------------------------------------------------------
|
||
for (const key of ['starterToast', 'readyToast', 'claimToast', 'claimDenyToast', 'capitalDenyToast']) {
|
||
const t = quests[key];
|
||
check(`quests: ${key} configured (glyph + text)`, !!t && typeof t.glyph === 'string' && typeof t.text === 'string' && hex.test(t.color ?? ''));
|
||
}
|
||
check(
|
||
'quests: capitalDenyToast is the claim-gate denial (the brief, verbatim)',
|
||
quests.capitalDenyToast?.text === 'REQUEST DENIED. PLEASE CLAIM YOUR QUEST REWARD FIRST.',
|
||
);
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet', 'capitalLanded', 'cargoUpgraded', 'packageDelivered', 'vignettePlayed', 'combatCleared', 'capitalReturned']);
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 5b. The 'Leaving the Nest' quest — the starter's follow-up chain
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const q = (quests.quests ?? []).find((x) => x.id === 'leaving_the_nest');
|
||
check('nest: the quest is defined', !!q);
|
||
check('nest: sits in the MAIN story', q?.category === 'main');
|
||
check('nest: has id/title/description/issuer', typeof q?.id === 'string' && typeof q?.title === 'string' && typeof q?.description === 'string' && typeof q?.issuer === 'string');
|
||
check('nest: icon names a research icon', typeof q?.icon === 'string' && q.icon.length > 0);
|
||
check('nest: NOT a starter (it grants by its unlock requirement)', q?.starter !== true);
|
||
// The grant condition — the starter's reward CLAIMED ('unlocked as
|
||
// soon as the first quest is completed and its reward collected').
|
||
check('nest: unlocks on the starter quest being claimed', JSON.stringify(q?.unlocks?.claimed) === JSON.stringify([starter.id]));
|
||
check('nest: the starter id in unlocks.claimed is a defined quest', (q?.unlocks?.claimed ?? []).every((id) => questDef(id) !== null));
|
||
// The four checks, in order — the run's exit arc.
|
||
const labels = (q?.checks ?? []).map((c) => c.label);
|
||
check('nest: the four checklist lines (the brief, verbatim)', JSON.stringify(labels) === JSON.stringify([
|
||
'Discover all Planets, Space Stations and Jump Gates in the system',
|
||
'Activate System Jump Gates',
|
||
'Talk to Trestle',
|
||
'Meet Trestle on the capital world',
|
||
]));
|
||
check('nest: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
|
||
const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c]));
|
||
check('nest: systemCharted names the home system', byType?.systemCharted?.system === 'home');
|
||
check('nest: gatesActivated names the home system', byType?.gatesActivated?.system === 'home');
|
||
check('nest: the reward is a positive integer (credits)', Number.isInteger(q?.reward?.credits) && q.reward.credits > 0);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 5c. The 'Special Delivery' quest — the nest quest's follow-up chain
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const goods = read('goods.json');
|
||
const q = (quests.quests ?? []).find((x) => x.id === 'special_delivery');
|
||
check('delivery: the quest is defined', !!q);
|
||
check('delivery: sits in the MAIN story', q?.category === 'main');
|
||
check('delivery: has id/title/description/issuer', typeof q?.id === 'string' && typeof q?.title === 'string' && typeof q?.description === 'string' && typeof q?.issuer === 'string');
|
||
check('delivery: icon names a research icon', typeof q?.icon === 'string' && q.icon.length > 0);
|
||
check('delivery: NOT a starter (it grants by its unlock requirement)', q?.starter !== true);
|
||
// The grant condition — 'Leaving the Nest' CLAIMED (the run's chain).
|
||
check('delivery: unlocks on leaving_the_nest being claimed', JSON.stringify(q?.unlocks?.claimed) === JSON.stringify(['leaving_the_nest']));
|
||
check('delivery: the id in unlocks.claimed is a defined quest', (q?.unlocks?.claimed ?? []).every((id) => questDef(id) !== null));
|
||
// The three checks, in order — the bar, the hold, the delivery.
|
||
check('delivery: the three checklist lines (the brief, verbatim)', JSON.stringify((q?.checks ?? []).map((c) => c.label)) === JSON.stringify([
|
||
"Meet with Trestle and Carol at the neutral capital's bar",
|
||
'Upgrade Ship to hold Cargo',
|
||
'Deliver Package for Carol',
|
||
]));
|
||
check('delivery: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
|
||
const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c]));
|
||
check('delivery: the three check types (vignettePlayed, cargoUpgraded, packageDelivered)',
|
||
['vignettePlayed', 'cargoUpgraded', 'packageDelivered'].every((t) => !!byType?.[t]));
|
||
// The bar step's witness — the VIGNETTE scene (data/vignettes.json): its id
|
||
// resolves to a scene bound to the bar, so the scene's DONE press is what
|
||
// completes the step (VignetteHub marks the ledger; _questCheckEval reads it).
|
||
check('delivery: the bar step names a shipped bar scene (vignettes.json → id + location bar)', (() => {
|
||
const vcfg = read('vignettes.json');
|
||
const id = byType?.vignettePlayed?.id;
|
||
const scene = (vcfg?.vignettes ?? []).find((v) => v?.id === id);
|
||
return typeof id === 'string' && id !== '' && !!scene && String(scene.location).toLowerCase() === 'bar';
|
||
})());
|
||
check('delivery: the reward is 200 credits', Number.isInteger(q?.reward?.credits) && q.reward.credits === 200);
|
||
// The package good — real, on the sheet's spare cell, and NON-TRADEABLE
|
||
// (no shop stocks or buys it; the SELL grid refuses to queue it).
|
||
const pkgId = q?.packageGood;
|
||
const pkg = (goods.goods ?? []).find((x) => x.id === pkgId);
|
||
check('delivery: packageGood names a catalog good', typeof pkgId === 'string' && pkgId.length > 0 && !!pkg);
|
||
check('delivery: the package is the "Special Package"', pkg?.name === 'Special Package');
|
||
check('delivery: the package rides on sheet frame 114', pkg?.icon === 114);
|
||
check('delivery: the package is NOT tradeable (the shops refuse it — home world excepted)', pkg?.tradeable === false);
|
||
check('delivery: the package sells for zero credits (the home-world sale IS the delivery)',
|
||
pkg?.sellFor === 0);
|
||
check('delivery: the package has no producer/demand (the economy does not price it)',
|
||
Array.isArray(pkg?.producer) && pkg.producer.length === 0 && Array.isArray(pkg?.demand) && pkg.demand.length === 0);
|
||
// The cargo step's WITNESS — the package ABOARD (check.id = the package
|
||
// good): it only fits once the hold is upgraded (the run's ship starts
|
||
// with cargoHold 0, data/ship.json), and the bar scene's DONE press is
|
||
// what upgrades + loads it (onVignetteComplete) — the parcel aboard is
|
||
// the upgrade's proof (GameScene._questCheckEval reads the check's id).
|
||
check('delivery: the cargo step stands on the package (check.id = packageGood)',
|
||
byType?.cargoUpgraded?.id === pkgId && typeof pkgId === 'string' && pkgId.length > 0);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 5d. The 'Pirate Problems' quest — the delivery quest's follow-up chain
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const comms = read('comms.json');
|
||
const combat = read('combat.json');
|
||
const q = (quests.quests ?? []).find((x) => x.id === 'pirate_problems');
|
||
check('pirates: the quest is defined', !!q);
|
||
check('pirates: sits in the MAIN story', q?.category === 'main');
|
||
check('pirates: has id/title/description/issuer', typeof q?.id === 'string' && typeof q?.title === 'string' && typeof q?.description === 'string' && typeof q?.issuer === 'string');
|
||
check('pirates: icon names a research icon', typeof q?.icon === 'string' && q.icon.length > 0);
|
||
check('pirates: NOT a starter (it grants by its unlock requirement)', q?.starter !== true);
|
||
// The grant condition — 'Special Delivery' CLAIMED AND its payoff chain
|
||
// ACKed (unlocks.acked — the comms acked-ledger, GameScene._commsAcked;
|
||
// the scene supplies the set to QuestState.isUnlockMet's gates).
|
||
check('pirates: unlocks on special_delivery being claimed', JSON.stringify(q?.unlocks?.claimed) === JSON.stringify(['special_delivery']));
|
||
check('pirates: the id in unlocks.claimed is a defined quest', (q?.unlocks?.claimed ?? []).every((id) => questDef(id) !== null));
|
||
const PAYOFF = ['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1'];
|
||
check('pirates: waits on the deliveredLaunch payoff chain being ACKed (unlocks.acked)', JSON.stringify(q?.unlocks?.acked) === JSON.stringify(PAYOFF));
|
||
const commById = Object.fromEntries((comms?.comms ?? []).map((c) => [c.id, c]));
|
||
check('pirates: every acked comm exists in data/comms.json', PAYOFF.every((id) => !!commById[id]));
|
||
check('pirates: the acked comms are all type "ack" (the press is the witness)', PAYOFF.every((id) => commById[id]?.type === 'ack'));
|
||
check('pirates: the acked comms are the deliveredLaunch chain', PAYOFF.every((id) => commById[id]?.trigger === 'deliveredLaunch'));
|
||
// The three checks, in order — speak, defend, return.
|
||
check('pirates: the three checklist lines (the brief, verbatim)', JSON.stringify((q?.checks ?? []).map((c) => c.label)) === JSON.stringify([
|
||
'Speak with Trestle',
|
||
'Defend your ship from pirates',
|
||
'Return to the Neutral Capital World',
|
||
]));
|
||
check('pirates: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
|
||
const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c]));
|
||
// The TALK step — the trestleQ4 trigger's roster (data/comms.json), the
|
||
// same _trestleCheck seam as 'Leaving the Nest': all three beats in the
|
||
// run's played-ledger = done. The check's trigger parameter keeps the
|
||
// two quests' Trestle lines apart.
|
||
check('pirates: the talk step rides the trestleQ4 trigger', byType?.trestleTalked?.trigger === 'trestleQ4');
|
||
const q4 = (comms?.comms ?? []).filter((c) => c.trigger === 'trestleQ4');
|
||
check('pirates: the trestleQ4 trigger holds exactly three comms', q4.length === 3);
|
||
check('pirates: the trestleQ4 beats are from Trestle, type ack, once', q4.every((c) => c.from === 'trestle' && c.type === 'ack' && c.once === true));
|
||
check('pirates: the trestleQ4 beats carry text + a real audio file on disk',
|
||
q4.every((c) => typeof c.text === 'string' && c.text.length > 0 && typeof c.audio === 'string' && c.audio.length > 0 && fs.existsSync(join(root, c.audio))));
|
||
// The PAYOFF — the moment the quest becomes COMPLETE + UNCLAIMED (all
|
||
// checks standing, the reward on the table), Trestle's two-beat
|
||
// sign-off (the job boards' unlock + the level-5 hook): the trigger is
|
||
// the quest's own (payoffTrigger — GameScene._questRefresh's
|
||
// once-per-quest READY pass arms it, the same moment the QUEST READY
|
||
// toast + chime fire; the comms' once-ledger keeps the pair a
|
||
// one-shot across saves).
|
||
check('pirates: the quest declares a payoff comm trigger', typeof q?.payoffTrigger === 'string' && q.payoffTrigger.length > 0);
|
||
const payoff = (comms?.comms ?? []).filter((c) => c.trigger === q?.payoffTrigger);
|
||
check('pirates: the payoff trigger holds exactly two comms', payoff.length === 2);
|
||
check('pirates: the payoff beats are from Trestle, type ack, once', payoff.every((c) => c.from === 'trestle' && c.type === 'ack' && c.once === true));
|
||
check('pirates: the payoff beats carry text + a real audio file on disk',
|
||
payoff.every((c) => typeof c.text === 'string' && c.text.length > 0 && typeof c.audio === 'string' && c.audio.length > 0 && fs.existsSync(join(root, c.audio))));
|
||
// The COMBAT step — the pirate_ambush wave script (data/combat.json):
|
||
// ONE rolling wave of SIX frame-0 raiders (EnemyTypes: raider = the
|
||
// sheet's frame 0), three at a time, each kill feeding a replacement.
|
||
const script = byType?.combatCleared?.script;
|
||
check('pirates: the combat step names the pirate_ambush script', script === 'pirate_ambush');
|
||
const waves = combat?.waves?.scripts?.[script];
|
||
check('pirates: the pirate_ambush script exists in data/combat.json', Array.isArray(waves) && waves.length > 0);
|
||
check('pirates: ONE wave (the brief: a single rolling wave)', Array.isArray(waves) && waves.length === 1);
|
||
check('pirates: the wave is rolling', waves?.[0]?.kind === 'rolling');
|
||
check('pirates: SIX frame-0 raiders (type raider, count 6)', waves?.[0]?.type === 'raider' && waves?.[0]?.count === 6);
|
||
check('pirates: the wave rolls three at a time (maxConcurrent 3, replacePerKill 1)', waves?.[0]?.maxConcurrent === 3 && waves?.[0]?.replacePerKill === 1);
|
||
check('pirates: the raider enemy type exists in data/combat.json', !!combat?.enemyTypes?.raider);
|
||
// The RETURN step — the capital's arrival witness (story.capitalReturned,
|
||
// GameScene._maybeWitnessCapitalReturn; the destination the grant sets
|
||
// is the neutral capital — the same world _atCapitalWorld reads).
|
||
check('pirates: the return step is the capitalReturned check', byType?.capitalReturned?.type === 'capitalReturned');
|
||
check('pirates: the reward is 200 credits', Number.isInteger(q?.reward?.credits) && q.reward.credits === 200);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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));
|
||
}
|
||
|
||
// The UNLOCK CHAIN — the 'Leaving the Nest' grant requirement:
|
||
// - unclaimed starter → nothing new grants;
|
||
// - claimed starter → exactly [leaving_the_nest] grants;
|
||
// - idempotent (a re-call is a no-op).
|
||
{
|
||
const st = new QuestState();
|
||
st.give(starter.id);
|
||
check('unlock: fresh — the follow-up is NOT granted (starter unclaimed)',
|
||
st.grantUnlocked().length === 0 && !st.isGranted('leaving_the_nest'));
|
||
check('unlock: the starter\u2019s claim arms the follow-up', (() => {
|
||
st.claim(starter.id);
|
||
return JSON.stringify(st.grantUnlocked()) === JSON.stringify(['leaving_the_nest']);
|
||
})());
|
||
check('unlock: the follow-up is now held', st.isGranted('leaving_the_nest'));
|
||
check('unlock: grantUnlocked() is idempotent', st.grantUnlocked().length === 0);
|
||
// The SECOND link of the run's chain — 'Leaving the Nest' claimed arms
|
||
// 'Special Delivery' (exactly one new grant, idempotent on re-call).
|
||
check('unlock: leaving_the_nest\u2019s claim arms special_delivery', (() => {
|
||
st.claim('leaving_the_nest');
|
||
return JSON.stringify(st.grantUnlocked()) === JSON.stringify(['special_delivery']);
|
||
})());
|
||
check('unlock: the delivery quest is now held', st.isGranted('special_delivery'));
|
||
check('unlock: a re-call with nothing new is a no-op', st.grantUnlocked().length === 0);
|
||
// The THIRD link — 'Pirate Problems' waits on BOTH special_delivery's
|
||
// claim AND its payoff chain being ACKed (unlocks.acked — the comms
|
||
// acked-ledger; the pure ledger can't see the registry, so the scene
|
||
// supplies the set via the `gates` argument — GameScene passes
|
||
// { acked: this._commsAcked() }): the grant lands on whichever of the
|
||
// two conditions completes last (claimQuest's pass or _commEnded's).
|
||
const PAYOFF = ['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1'];
|
||
check('unlock: special_delivery\u2019s claim ALONE does not arm pirate_problems (the payoff chain must be ACKed)', (() => {
|
||
st.claim('special_delivery');
|
||
return st.grantUnlocked().length === 0 && !st.isGranted('pirate_problems');
|
||
})());
|
||
check('unlock: the payoff chain\u2019s final ACK arms pirate_problems (gates.acked, a Set)', (() => {
|
||
const acked = new Set(PAYOFF);
|
||
return JSON.stringify(st.grantUnlocked({ acked })) === JSON.stringify(['pirate_problems']) && st.isGranted('pirate_problems');
|
||
})());
|
||
check('unlock: a re-call after the acked grant is a no-op', st.grantUnlocked({ acked: new Set(PAYOFF) }).length === 0);
|
||
check('unlock: a PARTIAL ack chain does not arm pirate_problems', (() => {
|
||
const st2 = new QuestState();
|
||
st2.give(starter.id); st2.claim(starter.id);
|
||
st2.give('leaving_the_nest'); st2.claim('leaving_the_nest');
|
||
st2.give('special_delivery'); st2.claim('special_delivery');
|
||
return st2.grantUnlocked({ acked: new Set(['carol-delivered-1', 'oac-delivered-1']) }).length === 0 && !st2.isGranted('pirate_problems');
|
||
})());
|
||
check('unlock: gates.acked accepts an array too (the same gate)', (() => {
|
||
const st2 = new QuestState();
|
||
st2.give(starter.id); st2.claim(starter.id);
|
||
st2.give('leaving_the_nest'); st2.claim('leaving_the_nest');
|
||
st2.give('special_delivery'); st2.claim('special_delivery');
|
||
return JSON.stringify(st2.grantUnlocked({ acked: PAYOFF })) === JSON.stringify(['pirate_problems']);
|
||
})());
|
||
check('unlock: an acked chain of the WRONG comms does not arm pirate_problems', (() => {
|
||
const st2 = new QuestState();
|
||
st2.give(starter.id); st2.claim(starter.id);
|
||
st2.give('leaving_the_nest'); st2.claim('leaving_the_nest');
|
||
st2.give('special_delivery'); st2.claim('special_delivery');
|
||
return st2.grantUnlocked({ acked: new Set(['trestle-brief-1', 'trestle-brief-2', 'trestle-brief-3', 'trestle-arrive-1']) }).length === 0;
|
||
})());
|
||
check('unlock: a pre-follow-up save (no leaving_the_nest) still loads', (() => {
|
||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [starter.id] });
|
||
return r.isGranted(starter.id) && r.isClaimed(starter.id) && !r.isGranted('leaving_the_nest');
|
||
})());
|
||
check('unlock: a save claiming a quest it does not hold drops the claim', (() => {
|
||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: ['leaving_the_nest'] });
|
||
return r.isGranted(starter.id) && !r.isClaimed('leaving_the_nest') && !r.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);
|
||
})());
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 6a. The quest ledger — the COMPLETED STEPS (the monotonic witness)
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
// questCheckKey — the step's stable identity: type + params in sorted
|
||
// order, the label and the data's key order never counting.
|
||
const tether2 = { type: 'tetherBuilt', planet: 'home', level: 2, label: 'Build a Level 2 Tether on your Home world' };
|
||
check('checkKey: type + params, label ignored',
|
||
questCheckKey(tether2) === 'level=2|planet=home|type=tetherBuilt');
|
||
check('checkKey: key order in the data never counts',
|
||
questCheckKey({ label: 'REWORDED', level: 2, planet: 'home', type: 'tetherBuilt' }) === questCheckKey(tether2));
|
||
check('checkKey: a different parameter is a different step',
|
||
questCheckKey({ ...tether2, level: 3 }) !== questCheckKey(tether2) &&
|
||
questCheckKey({ ...tether2, planet: 'elsewhere' }) !== questCheckKey(tether2));
|
||
check('checkKey: degrades to a key (never empty)', typeof questCheckKey(null) === 'string' && questCheckKey(null).length > 0);
|
||
|
||
const key = questCheckKey(tether2);
|
||
const st = new QuestState();
|
||
check('completed: markCheck() refuses a quest not held',
|
||
st.markCheck(starter.id, key, 'L2/L2') === false && !st.isCheckDone(starter.id, key));
|
||
check('completed: markCheck() records a step on a held quest',
|
||
(st.give(starter.id) === true && st.markCheck(starter.id, key, 'L2/L2') === true && st.isCheckDone(starter.id, key)));
|
||
check('completed: the detail line is kept for the dossier', st.checkDetail(starter.id, key) === 'L2/L2');
|
||
check('completed: markCheck() is idempotent',
|
||
st.markCheck(starter.id, key, 'L2/L2') === false && st.isCheckDone(starter.id, key));
|
||
check('completed: a re-mark refreshes the detail (the live line while it holds)',
|
||
(st.markCheck(starter.id, key, 'L3/L2') === false && st.checkDetail(starter.id, key) === 'L3/L2'));
|
||
check('completed: a different step stays open',
|
||
(() => { const k3 = questCheckKey({ ...tether2, level: 3 }); return !st.isCheckDone(starter.id, k3); })());
|
||
check('completed: markCheck() ignores an unknown quest', st.markCheck('no_such_quest', key, '') === false);
|
||
check('completed: markCheck() ignores an empty step key',
|
||
st.markCheck(starter.id, '', '') === false && !st.isCheckDone(starter.id, ''));
|
||
|
||
// The save round-trip — the witness rides the save. This is the bug
|
||
// this is the fix for: the home-tether step stands AFTER the ship
|
||
// leaves the home system (where the live read can no longer see it).
|
||
const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())));
|
||
check('completed: save round-trip keeps the done set + the detail line',
|
||
restored.isCheckDone(starter.id, key) && restored.checkDetail(starter.id, key) === 'L3/L2');
|
||
check('completed: toJSON() serializes the done set',
|
||
(() => { const j = st.toJSON(); return Array.isArray(j.done) && j.done.some((e) => e.quest === starter.id && e.check === key && e.detail === 'L3/L2'); })());
|
||
|
||
// Old saves — a record predating the field loads with an empty done
|
||
// set (the live evaluation stands alone, exactly as before).
|
||
check('completed: a PRE-completed-set save (no done field) loads empty',
|
||
(() => { const r = QuestState.fromJSON({ granted: [starter.id], claimed: [] }); return !r.isCheckDone(starter.id, key); })());
|
||
// done ⊆ granted — a step of a quest the save does not hold is dropped
|
||
// (never resurrected).
|
||
check('completed: a done step of a NON-held quest is dropped',
|
||
(() => {
|
||
const r = QuestState.fromJSON({ granted: [], claimed: [], done: [{ quest: starter.id, check: key, detail: 'L2/L2' }] });
|
||
return !r.isCheckDone(starter.id, key);
|
||
})());
|
||
// Malformed entries degrade — never throw, never record a step.
|
||
check('completed: malformed done entries degrade (no throw)',
|
||
(() => {
|
||
try {
|
||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [], done: [{ quest: 'x' }, 'junk', null, { quest: starter.id, check: '' }] });
|
||
return !r.isCheckDone(starter.id, key);
|
||
} catch { return false; }
|
||
})());
|
||
check('completed: a non-string detail degrades to the empty line',
|
||
(() => {
|
||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [], done: [{ quest: starter.id, check: key, detail: 42 }] });
|
||
return r.isCheckDone(starter.id, key) && r.checkDetail(starter.id, key) === '';
|
||
})());
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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 the starter + the
|
||
// 'Leaving the Nest' follow-up 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); // 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));
|
||
|
||
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');
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 6d. quests.json → tutorial — the starter quest's POINTING FINGER
|
||
// (data the beat table in js/quests/TutorialGuide.js stands on)
|
||
// ----------------------------------------------------------------------
|
||
{
|
||
const tut = quests.tutorial ?? {};
|
||
check('tutorial: section present with the master switch', typeof tut.enabled === 'boolean');
|
||
check('tutorial: enabled (the finger is live)', tut.enabled === true);
|
||
// The two deck beats name REAL live slots on the flight deck (a typo
|
||
// here would be a highlight that never lands on a button).
|
||
const flightSlots = (actionbar.buttons ?? []).filter((b) => typeof b.id === 'string' && b.id.length > 0).map((b) => b.id);
|
||
check('tutorial: the SCAN beat names a real flight-deck slot', flightSlots.includes(tut.scan?.slot));
|
||
check('tutorial: the RESEARCH beat names a real flight-deck slot', flightSlots.includes(tut.research?.slot));
|
||
check('tutorial: the two deck beats point at different slots', tut.scan?.slot !== tut.research?.slot);
|
||
// The console beat points at the SAME node the starter quest's research
|
||
// check demands (the arc's goal) — the finger and the quest can't drift.
|
||
const researchCheck = (starter?.checks ?? []).find((c) => c.type === 'research');
|
||
check('tutorial: the console beat matches the starter research check\u2019s node',
|
||
!!researchCheck && tut.console?.category === researchCheck.category && tut.console?.node === researchCheck.node);
|
||
// …and that node is REAL in its category's static tree (the highlight
|
||
// would never land otherwise — the research.json registry knows which
|
||
// categories are file trees).
|
||
if (tut.console?.category && tut.console?.node) {
|
||
const tree = read(`research/${tut.console.category}.json`);
|
||
check('tutorial: the node exists in data/research/' + tut.console.category + '.json',
|
||
!!tree?.nodes && typeof tree.nodes[tut.console.node] === 'object');
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 7. The command decks — the QUESTS slot (flight: right of SHIP,
|
||
// surface: right of EXPLORE — the surface deck's SHIP slot became
|
||
// the EXPLORE drawer, so the flight deck keeps its SHIP slot)
|
||
// ----------------------------------------------------------------------
|
||
for (const [deck, slots, anchor] of [
|
||
['flight', actionbar.buttons ?? [], 'ship'],
|
||
['surface', actionbar.surface?.buttons ?? [], 'explore'],
|
||
]) {
|
||
const iAnchor = slots.findIndex((s) => s.id === anchor);
|
||
check(`actionbar (${deck}): the QUESTS slot sits right of the ${anchor.toUpperCase()} slot`,
|
||
iAnchor >= 0 && slots[iAnchor + 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);
|