335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
/**
|
|
* 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<object>} 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>|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;
|
|
}
|
|
}
|