/** * The quest ledger — the run's quest STATE (pure; the data is * data/quests.json). * * A quest's REQUIREMENTS are computed LIVE from the run when the scene * snapshots the dossier (GameScene.questSnapshot): research progress, * lifetime minerals mined from asteroids, the home world's tether * standing, the discovery ledger. Once a step reads DONE, though, it is * RECORDED here (the completed set) and stays DONE — the live read is no * longer its source of truth (a step that stands on the home world keeps * standing after the ship leaves the home system; a check is monotonic, * it can never un-complete). What the ledger owns: * * granted — which quest ids the player HOLDS (the starter quest is * granted on the run's first boot; the run's chain quests are * granted by their unlock requirement — see isUnlockMet / grantUnlocked) * claimed — which of the held quests have PAID OUT their reward * done — the checklist steps that have read DONE (keyed by quest id * + the step's stable identity, questCheckKey) with the * detail line they carried when they stood (the dossier's * display); done ⊆ granted, like claimed * priority — the ONE priority quest the tracker HUD features (title + * live checklist). The player sets/clears it; the ledger * also auto-assigns it (see resolvePriority) so there is * always a priority while an active story quest exists. * cleared — the player CLEARED the priority; the auto-assignment is * re-armed by any grant or claim (the active set changed). * * …and that is exactly what gets saved (SaveData capture/prepare), so a * save can never hand out a reward twice, un-grant a quest, or * un-complete a step across a scene restart or a save/load. */ import { config } from '../config/Config.js'; /** * A checklist step's STABLE identity (data/quests.json → quests[].checks[]): * its type + parameters, in sorted key order — independent of the * player-facing label and of the data's key order. The ledger's completed * set is keyed by it (with the quest id), so a step is recognized across * saves even if its label is reworded; label/_comment never count. * @param {object} check a quests.json checks[] entry * @returns {string} the step's key (never empty) */ export function questCheckKey(check) { const obj = check && typeof check === 'object' ? check : {}; const parts = []; for (const k of Object.keys(obj).sort()) { if (k === 'label' || k === '_comment') continue; const v = obj[k]; parts.push(`${k}=${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`); } return parts.length ? parts.join('|') : 'unknown'; } /** @returns {Array} every quest defined (data/quests.json → quests) */ export function questDefs() { const list = config.get('quests.quests', []); return Array.isArray(list) ? list : []; } /** @returns {object|null} the quest definition with this id (or null) */ export function questDef(id) { return questDefs().find((q) => q && q.id === id) ?? null; } /** @returns {string[]} the ids granted on a FRESH run (quests.json → starter) */ export function starterQuestIds() { return questDefs() .filter((q) => q && q.starter === true) .map((q) => q.id); } /** * The quest's unlock requirement (data/quests.json → quests[].unlocks): * claimed: [ids] — the quests whose rewards must be CLAIMED (the * ledger's paid-out set) before this one is granted. * acked: [ids] — the COMMS the run must have ACKed (their ids in the * run's comms acked-ledger — registry 'commsAcked', * CommsHub's onAck hook owns it) before this one is * granted. The acknowledgment itself, not the start * (the played-ledger marks on the comm's START — a * chain's final beat can have started and still be * un-ACKed; 'the payoff comms are DONE' = ACKed). * Absent/empty = no requirement (the quest grants on first sight — the * boot's own are flagged starter:true instead). Pure: data + the ledger * + the caller's `gates` (the scene supplies the acked set — GameScene. * _grantUnlockedQuests passes { acked: this._commsAcked() }); a call * with no gates fails any quest carrying an acked requirement. * @returns {boolean} */ export function isUnlockMet(state, id, gates = {}) { const def = questDef(id); if (!def || !state) return false; const req = Array.isArray(def?.unlocks?.claimed) ? def.unlocks.claimed : []; if (!req.every((x) => typeof x === 'string' && x !== '' && state.claimed.has(x))) return false; const ack = Array.isArray(def?.unlocks?.acked) ? def.unlocks.acked : []; if (!ack.length) return true; const have = gates?.acked instanceof Set ? gates.acked : new Set( Array.isArray(gates?.acked) ? gates.acked.filter((x) => typeof x === 'string') : [], ); return ack.every((x) => typeof x === 'string' && x !== '' && have.has(x)); } /** * A held-quest ledger. The ids come from data/quests.json (validated * against it on load — a save pointing at a removed quest just drops it). */ export class QuestState { constructor() { this.granted = new Set(); this.claimed = new Set(); this.done = new Map(); // "questId\u0000checkKey" → the detail line it stood with this.priority = null; // the active quest id, or null this.priorityCleared = false; // the player declined a priority (sticks until a grant/claim) } /** * Grant a quest (idempotent — re-granting one already held is a no-op). * Unknown ids are ignored. @returns {boolean} true when NEWLY granted */ give(id) { if (typeof id !== 'string' || id === '') return false; if (!questDef(id)) return false; if (this.granted.has(id)) return false; this.granted.add(id); this.priorityCleared = false; // a new quest re-arms the auto-priority return true; } isGranted(id) { return this.granted.has(id); } /** * Grant every not-yet-held quest whose unlock requirement (unlocks. * claimed + unlocks.acked — isUnlockMet, the scene's `gates.acked` set * supplying the acked half) is met. The starter quests are the boot's * own (grant via starterQuestIds on a fresh ledger); this is the RUN's * chain — the claim that pays one quest is what arms the next (the * scene calls it on the claim + on scene create for loaded saves + on * the comm's ACK when the acked half completes last). give() is * idempotent, so re-calls are silent no-ops. * @param {{acked?: (Set|string[])}} [gates] scene-side gates * @returns {string[]} the NEWLY granted ids (data order) */ grantUnlocked(gates = {}) { const fresh = []; for (const def of questDefs()) { if (!def || this.granted.has(def.id) || def.starter === true) continue; if (isUnlockMet(this, def.id, gates) && this.give(def.id)) fresh.push(def.id); } return fresh; } isClaimed(id) { return this.claimed.has(id); } /** * Mark a quest's reward as paid out (idempotent). Only quests that are * held + fully satisfied should be claimed — the satisfaction is the * scene's call (GameScene.claimQuest checks the live snapshot). * @returns {boolean} true when NEWLY claimed */ claim(id) { if (!this.granted.has(id)) return false; if (this.claimed.has(id)) return false; this.claimed.add(id); if (this.priority === id) this.priority = null; // it can't feature itself this.priorityCleared = false; // the active set changed → re-arm the auto-priority return true; } // ------------------------------------------------------ completed steps /** * Record a checklist step as DONE — the monotonic witness (the scene's * snapshot calls it the moment a step reads DONE, GameScene. * _questCheckView). Idempotent — re-marking keeps the step done and * refreshes its detail line while the live read still holds it. Only * HELD quests can carry completed steps (done ⊆ granted, the same * invariant as claimed ⊆ granted). * @param {string} questId a granted quest id * @param {string} checkKey the step's stable identity (questCheckKey) * @param {string} [detail] the dossier line the step stood with (e.g. "L2/L2") * @returns {boolean} true when NEWLY completed */ markCheck(questId, checkKey, detail) { const k = this._doneKey(questId, checkKey); if (k === null) return false; const d = typeof detail === 'string' ? detail : ''; if (this.done.has(k)) { if (this.done.get(k) !== d) this.done.set(k, d); return false; } this.done.set(k, d); return true; } /** Has this quest's checklist step already stood? (the dossier reads it done) */ isCheckDone(questId, checkKey) { const k = this._doneKey(questId, checkKey); return k !== null && this.done.has(k); } /** The detail line the step stood with (the dossier's display) — or null. */ checkDetail(questId, checkKey) { const k = this._doneKey(questId, checkKey); return k === null ? null : (this.done.get(k) ?? null); } /** The completed set's composite key (quest + step) — null when invalid. */ _doneKey(questId, checkKey) { if (typeof questId !== 'string' || questId === '') return null; if (typeof checkKey !== 'string' || checkKey === '') return null; if (!questDef(questId) || !this.granted.has(questId)) return null; return `${questId}\u0000${checkKey}`; } // ------------------------------------------------------------ priority /** * Set the priority quest (the tracker HUD's featured quest: title + * checklist). Must be HELD and not yet CLAIMED. Idempotent — re-setting * the current priority is a no-op. * @returns {boolean} true when the priority CHANGED */ setPriority(id) { if (typeof id !== 'string' || id === '') return false; if (!this.granted.has(id) || this.claimed.has(id)) return false; if (this.priority === id) return false; this.priority = id; this.priorityCleared = false; return true; } /** * Clear the priority (the player's "no priority right now"). It STICKS * — the auto-assignment stays off until a new quest is granted or one * is claimed (either re-arms it, the active set changed). * @returns {boolean} true when there WAS a priority to clear */ clearPriority() { if (!this.priority) return false; this.priority = null; this.priorityCleared = true; return true; } /** * Resolve the effective priority from the ACTIVE quests (held + not * claimed) in DISPLAY order — main story first, then side quests * (data/quests.json order within a category): * - the stored priority when it is still active (the player's choice, * or the last auto-assignment); * - otherwise the FIRST active quest — unless the player cleared the * priority (clearPriority), in which case null until a grant/claim; * - otherwise null (nothing active). * Auto-assignment is persisted on the ledger (idempotent), so the * choice survives scene restarts + saves. * @param {string[]} activeIds the active quest ids, display order * @returns {string|null} */ resolvePriority(activeIds) { const ids = Array.isArray(activeIds) ? activeIds.filter((x) => typeof x === 'string' && x !== '') : []; if (this.priority && ids.includes(this.priority)) return this.priority; if (this.priorityCleared) return null; const first = ids[0] ?? null; if (first) { this.priority = first; this.priorityCleared = false; } return first; } toJSON() { return { granted: [...this.granted], claimed: [...this.claimed], done: [...this.done].map(([k, detail]) => { const [questId, checkKey] = k.split('\u0000'); return { quest: questId, check: checkKey, detail }; }), priority: this.priority ?? null, priorityCleared: this.priorityCleared === true, }; } /** * Restore a ledger from a save record (SaveData prepareLoad). A * malformed record yields an empty ledger rather than a throw — quests * are a progression nicety, not a crash vector. */ static fromJSON(data) { const st = new QuestState(); if (!data || typeof data !== 'object') return st; const grant = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : []); for (const id of grant(data.granted)) { if (questDef(id)) st.granted.add(id); } for (const id of grant(data.claimed)) { if (st.granted.has(id)) st.claimed.add(id); // claimed ⊆ granted } // The completed steps (a save predating them has no field → the live // evaluation stands alone, exactly as before — old saves load). Only // steps of HELD quests survive (done ⊆ granted — markCheck enforces // it); a malformed entry degrades rather than throws, and the first // record of a step wins (the detail is a display nicety). if (Array.isArray(data.done)) { for (const e of data.done) { if (!e || typeof e.quest !== 'string' || e.quest === '') continue; if (typeof e.check !== 'string' || e.check === '') continue; const d = typeof e.detail === 'string' ? e.detail : ''; if (st.isCheckDone(e.quest, e.check)) continue; st.markCheck(e.quest, e.check, d); } } // The priority — a save predating it has no field (null). A saved id // that is no longer held (or was claimed) is dropped rather than // resurrected; the cleared flag survives as stored. st.priority = typeof data.priority === 'string' && data.priority !== '' && st.granted.has(data.priority) && !st.claimed.has(data.priority) ? data.priority : null; st.priorityCleared = data.priorityCleared === true; return st; } }