orbit/dev/quests.test.mjs

684 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);