187 lines
6.6 KiB
TypeScript
187 lines
6.6 KiB
TypeScript
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<string, Quest>;
|
|
runtimeStates: Record<string, QuestRuntimeState>;
|
|
}
|
|
|
|
@component
|
|
export class QuestLog extends Component<QuestLogState> {
|
|
#cachedVars: RPGVariables | null = null;
|
|
|
|
constructor(quests: Quest[] = []) {
|
|
const questsRecord: Record<string, Quest> = {};
|
|
const runtimeStates: Record<string, QuestRuntimeState> = {};
|
|
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;
|
|
}
|
|
}
|