1
0
Fork 0
tsgames/src/common/rpg/components/questLog.ts

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;
}
}