import { Component, type EvalContext } from "../core/world"; import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types"; import { evaluateCondition } from "../utils/conditions"; import { component } from "../utils/decorators"; export type QuestStatus = 'inactive' | 'active' | 'completed' | 'failed'; export interface QuestRuntimeState { status: QuestStatus; stageIndex: number; } export interface QuestEntry { quest: Quest; state: QuestRuntimeState; } interface QuestLogState { quests: Record; runtimeStates: Record; } @component export class QuestLog extends Component { #cachedVars: RPGVariables | null = null; constructor(quests: Quest[] = []) { const questsRecord: Record = {}; const runtimeStates: Record = {}; for (const q of quests) { questsRecord[q.id] = q; runtimeStates[q.id] = { status: 'inactive', stageIndex: 0 }; } super({ quests: questsRecord, runtimeStates }); } addQuest(quest: Quest): void { if (this.state.quests[quest.id]) { console.warn(`[QuestLog] quest '${quest.id}' is already registered, ignoring duplicate`); return; } this.state.quests[quest.id] = quest; this.state.runtimeStates[quest.id] = { status: 'inactive', stageIndex: 0 }; } #invalidate() { this.#cachedVars = null; } #transition(op: string, questId: string, from: QuestStatus, to: QuestStatus, event: string): boolean { const runtimeState = this.state.runtimeStates[questId]; if (!runtimeState) { console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`); return false; } if (runtimeState.status !== from) { console.warn(`[QuestLog] ${op}: quest '${questId}' cannot transition from status '${runtimeState.status}'`); return false; } runtimeState.status = to; if (to === 'active' || to === 'inactive') runtimeState.stageIndex = 0; this.#invalidate(); this.emit(event, { questId }); return true; } start(questId: string): boolean { return this.#transition('start', questId, 'inactive', 'active', 'started'); } complete(questId: string): boolean { return this.#transition('complete', questId, 'active', 'completed', 'completed'); } fail(questId: string): boolean { return this.#transition('fail', questId, 'active', 'failed', 'failed'); } abandon(questId: string): boolean { return this.#transition('abandon', questId, 'active', 'inactive', 'abandoned'); } getState(questId: string): QuestRuntimeState | undefined { return this.state.runtimeStates[questId]; } getQuest(questId: string): Quest | undefined { return this.state.quests[questId]; } isAvailable(questId: string, ctx: EvalContext): boolean { const quest = this.state.quests[questId]; if (!quest) return false; if (!quest.conditions?.length) return true; return quest.conditions.every(c => evaluateCondition(c, ctx)); } getStage(questId: string): QuestStage | undefined { const quest = this.state.quests[questId]; const runtimeState = this.state.runtimeStates[questId]; if (!quest || runtimeState?.status !== 'active') return undefined; return quest.stages[runtimeState.stageIndex]; } getObjectiveProgress( questId: string, ctx: EvalContext, ): Array<{ id: string; description: string; done: boolean }> | undefined { const stage = this.getStage(questId); if (!stage) return undefined; return stage.objectives.map(obj => ({ id: obj.id, description: obj.description, done: evaluateCondition(obj.condition, ctx), })); } /** @internal used by QuestSystem */ *entries(): Generator<[string, QuestEntry], void, unknown> { for (const [id, quest] of Object.entries(this.state.quests)) { yield [id, { quest, state: this.state.runtimeStates[id]! }]; } } /** @internal called by QuestSystem after stage actions complete */ _advance(questId: string): void { const quest = this.state.quests[questId]; const runtimeState = this.state.runtimeStates[questId]; if (!quest || !runtimeState) return; this.#invalidate(); if (runtimeState.stageIndex + 1 < quest.stages.length) { runtimeState.stageIndex++; this.emit('stage', { questId, index: runtimeState.stageIndex, stage: quest.stages[runtimeState.stageIndex] }); } else { runtimeState.status = 'completed'; this.emit('completed', { questId }); } } override getActions() { const result = { ...super.getActions() }; for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) { if (runtimeState.status === 'inactive') { result[`${questId}.start`] = this.start.bind(this, questId); } else if (runtimeState.status === 'active') { result[`${questId}.complete`] = this.complete.bind(this, questId); result[`${questId}.fail`] = this.fail.bind(this, questId); result[`${questId}.abandon`] = this.abandon.bind(this, questId); } } return result; } override getVariables(): RPGVariables { if (this.#cachedVars) return this.#cachedVars; const result: RPGVariables = {}; for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) { result[`${questId}.status`] = runtimeState.status; result[`${questId}.stage`] = runtimeState.stageIndex; } this.#cachedVars = result; return result; } } export namespace Quests { export function validate(quest: unknown, actions: string[]): string[] { if (!isQuest(quest)) return ['invalid quest structure']; const errors: string[] = []; const actionSet = new Set(actions); for (const stage of quest.stages) { for (const action of stage.actions) { const type = typeof action === 'string' ? action : action.type; if (!actionSet.has(type)) { errors.push(`stage '${stage.id}': unknown action type '${type}'`); } } } return errors; } export function isValid(v: unknown, actions: string[]): v is Quest { return validate(v, actions).length === 0; } }