orbit/js/quests/QuestState.js

185 lines
6.8 KiB
JavaScript

/**
* The quest ledger — the run's quest STATE (pure; the data is
* data/quests.json).
*
* A quest's REQUIREMENTS are not tracked here — they 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.
* Only the two things the run owns are state:
*
* granted — which quest ids the player HOLDS (the starter quest is
* granted on the run's first boot; future quests are granted
* by whatever event issues them)
* claimed — which of the held quests have PAID OUT their reward
* 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 or un-grant a quest.
*/
import { config } from '../config/Config.js';
/** @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);
}
/**
* 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.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);
}
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;
}
// ------------------------------------------------------------ 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],
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 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;
}
}