diff --git a/src/common/rpg/components/questLog.ts b/src/common/rpg/components/questLog.ts index 21faf01..f2f3ae3 100644 --- a/src/common/rpg/components/questLog.ts +++ b/src/common/rpg/components/questLog.ts @@ -1,9 +1,9 @@ import { Component, type EvalContext } from "../core/world"; -import { isQuest, type Quest, type RPGVariables } from "../types"; +import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types"; import { evaluateCondition, parseCondition } from "../utils/conditions"; import { action } from "../utils/decorators"; -export type QuestStatus = 'inactive' | 'active' | 'completed'; +export type QuestStatus = 'inactive' | 'active' | 'completed' | 'failed'; export interface QuestRuntimeState { status: QuestStatus; @@ -46,11 +46,57 @@ export class QuestLog extends Component { return quest.conditions.every(c => evaluateCondition(parseCondition(c), ctx)); } + @action + fail(questId: string): boolean { + const entry = this.#quests.get(questId); + if (!entry || entry.state.status !== 'active') return false; + entry.state.status = 'failed'; + this.emit('failed', { questId }); + return true; + } + + @action + abandon(questId: string): boolean { + const entry = this.#quests.get(questId); + if (!entry || entry.state.status !== 'active') return false; + entry.state.status = 'inactive'; + entry.state.stageIndex = 0; + this.emit('abandoned', { questId }); + return true; + } + + getStage(questId: string): QuestStage | undefined { + const entry = this.#quests.get(questId); + if (!entry || entry.state.status !== 'active') return undefined; + return entry.quest.stages[entry.state.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(parseCondition(obj.condition), ctx), + })); + } + /** @internal used by QuestSystem */ entries(): IterableIterator<[string, QuestEntry]> { return this.#quests.entries(); } + /** @internal called by QuestSystem when a fail condition is met */ + _fail(questId: string): void { + const entry = this.#quests.get(questId); + if (!entry) return; + entry.state.status = 'failed'; + this.emit('failed', { questId }); + } + /** @internal called by QuestSystem after stage actions complete */ _advance(questId: string): void { const entry = this.#quests.get(questId); diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index ec69727..374ba92 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -1,8 +1,18 @@ import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators'; import type { RPGActions, RPGVariables } from '../types'; +interface EntityEvent { + target: Entity; + data?: T; +} +interface WorldEvent { + target: World; + data?: T; +} + type Class = abstract new (...args: any[]) => T; -type EventHandler = (data: unknown) => void; +type EntityEventHandler = (event: EntityEvent) => void; +type WorldEventHandler = (event: WorldEvent) => void; export interface EvalContext { self: Entity; @@ -17,8 +27,8 @@ export abstract class Component { this.entity.emit(`${this.key}.${event}`, data); } - onAdd(): void {} - onRemove(): void {} + onAdd(): void { } + onRemove(): void { } getVariables(): RPGVariables { const meta = (this.constructor as Function)[Symbol.metadata]; @@ -50,8 +60,8 @@ export abstract class Component { } export abstract class System { - onAdd(_world: World): void {} - onRemove(_world: World): void {} + onAdd(_world: World): void { } + onRemove(_world: World): void { } abstract update(world: World, dt: number): void; } @@ -61,7 +71,7 @@ export class Entity { constructor( readonly id: string, private readonly world: World, - ) {} + ) { } add(key: string, component: T): T { const existing = this.#components.get(key); @@ -132,15 +142,15 @@ export class Entity { this.world.emitGlobal(event, data); } - on(event: string, handler: EventHandler): () => void { + on(event: string, handler: EntityEventHandler): () => void { return this.world.on(this.id, event, handler); } - off(event: string, handler: EventHandler): void { + off(event: string, handler: EntityEventHandler): void { this.world.off(this.id, event, handler); } - once(event: string, handler: EventHandler): () => void { + once(event: string, handler: EntityEventHandler): () => void { return this.world.once(this.id, event, handler); } @@ -165,8 +175,8 @@ export class World { readonly globals: RPGVariables = {}; readonly #entities = new Map(); - readonly #handlers = new Map>(); - readonly #globalHandlers = new Map>(); + readonly #handlers = new Map>(); + readonly #globalHandlers = new Map>(); readonly #systems: System[] = []; #entityCounter = 0; @@ -205,33 +215,42 @@ export class World { removeSystem(system: System): void { const idx = this.#systems.indexOf(system); - if (idx !== -1) { this.#systems.splice(idx, 1); system.onRemove(this); } + if (idx !== -1) { + this.#systems.splice(idx, 1); + system.onRemove(this); + } } - tick(dt: number): void { + update(dt: number): void { for (const system of this.#systems) system.update(this, dt); } emit(entityId: string, event: string, data?: unknown): void { - this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h(data)); + const entity = this.getEntity(entityId); + if (!entity) return; + + this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({ + target: entity, + data, + })); } emitGlobal(event: string, data?: unknown): void { - this.#globalHandlers.get(event)?.forEach(h => h(data)); + this.#globalHandlers.get(event)?.forEach(h => h({ target: this, data })); } - on(event: string, handler: EventHandler): () => void; - on(entityId: string, event: string, handler: EventHandler): () => void; - on(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void { + on(event: string, handler: WorldEventHandler): () => void; + on(entityId: string, event: string, handler: EntityEventHandler): () => void; + on(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void { if (typeof arg2 === 'string') { return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!); } return this.#addHandler(this.#globalHandlers, arg1, arg2); } - off(event: string, handler: EventHandler): void; - off(entityId: string, event: string, handler: EventHandler): void; - off(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): void { + off(event: string, handler: WorldEventHandler): void; + off(entityId: string, event: string, handler: EntityEventHandler): void; + off(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): void { if (typeof arg2 === 'string') { this.#handlers.get(`${arg1}\0${arg2}`)?.delete(arg3!); } else { @@ -239,21 +258,21 @@ export class World { } } - once(event: string, handler: EventHandler): () => void; - once(entityId: string, event: string, handler: EventHandler): () => void; - once(arg1: string, arg2: EventHandler | string, arg3?: EventHandler): () => void { + once(event: string, handler: WorldEventHandler): () => void; + once(entityId: string, event: string, handler: EntityEventHandler): () => void; + once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void { if (typeof arg2 === 'string') { - const wrapped: EventHandler = data => { unsub(); arg3!(data); }; + const wrapped: EntityEventHandler = data => { unsub(); arg3!(data); }; const unsub = this.on(arg1, arg2, wrapped); return unsub; } const h = arg2; - const wrapped: EventHandler = data => { unsub(); h(data); }; + const wrapped: WorldEventHandler = data => { unsub(); h(data); }; const unsub = this.on(arg1, wrapped); return unsub; } - #addHandler(map: Map>, key: string, handler: EventHandler): () => void { + #addHandler(map: Map>, key: string, handler: T): () => void { if (!map.has(key)) map.set(key, new Set()); map.get(key)!.add(handler); return () => map.get(key)?.delete(handler); diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts index c83df93..4a1d97e 100644 --- a/src/common/rpg/dialog.ts +++ b/src/common/rpg/dialog.ts @@ -243,8 +243,15 @@ export class DialogEngine { async advance(nodeId?: string): Promise { let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined); + const visited = new Set(); while (targetId !== undefined) { + if (visited.has(targetId)) { + console.warn(`[DialogEngine] cycle detected at node '${targetId}', stopping`); + return null; + } + visited.add(targetId); + const node = this.nodeMap.get(targetId); if (!node) return null; diff --git a/src/common/rpg/systems/questSystem.ts b/src/common/rpg/systems/questSystem.ts index 004a362..6892c7a 100644 --- a/src/common/rpg/systems/questSystem.ts +++ b/src/common/rpg/systems/questSystem.ts @@ -24,7 +24,20 @@ export class QuestSystem extends System { if (state.status !== 'active') continue; const stage = quest.stages[state.stageIndex]; - if (!stage) continue; + if (!stage) { + console.warn(`[QuestSystem] quest '${questId}' is active but has no stage at index ${state.stageIndex}`); + continue; + } + + if (stage.failConditions?.length) { + const failed = stage.failConditions.some(c => + evaluateCondition(parseCondition(c), ctx) + ); + if (failed) { + questLog._fail(questId); + continue; + } + } const allDone = stage.objectives.every(obj => evaluateCondition(parseCondition(obj.condition), ctx) diff --git a/src/common/rpg/types.ts b/src/common/rpg/types.ts index b428eaa..ca150f1 100644 --- a/src/common/rpg/types.ts +++ b/src/common/rpg/types.ts @@ -55,6 +55,7 @@ const QuestStageScheme = Type.Object({ description: Type.String(), objectives: Type.Array(QuestObjectiveScheme), actions: Type.Array(RPGActionScheme), + failConditions: Type.Optional(Type.Array(Type.String())), }); const QuestScheme = Type.Object({ diff --git a/src/common/rpg/utils/variables.ts b/src/common/rpg/utils/variables.ts index f54a5d7..1417d98 100644 --- a/src/common/rpg/utils/variables.ts +++ b/src/common/rpg/utils/variables.ts @@ -29,7 +29,10 @@ export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[st const entityId = name.slice(1, dotIdx); const varName = name.slice(dotIdx + 1); const entity = ctx.world.getEntity(entityId); - if (!entity) return undefined; + if (!entity) { + console.warn(`[resolveVariable] entity '${entityId}' not found (referenced in '${name}')`); + return undefined; + } return resolveVariables(entity)[varName]; } // bare name → self entity @@ -53,14 +56,24 @@ export async function executeAction(action: RPGAction, ctx: EvalContext): Promis // @entityId.component.action → dispatch to another entity if (action.type.startsWith('@')) { const dotIdx = action.type.indexOf('.', 1); - if (dotIdx === -1) return; + if (dotIdx === -1) { + console.warn(`[executeAction] malformed cross-entity action '${action.type}': missing '.' after entity id`); + return; + } const entityId = action.type.slice(1, dotIdx); const found = ctx.world.getEntity(entityId); - if (!found) return; + if (!found) { + console.warn(`[executeAction] entity '${entityId}' not found (action '${action.type}')`); + return; + } entity = found; actionType = action.type.slice(dotIdx + 1); } const actions = resolveActions(entity); - return actions[actionType]?.(action.arg); + if (!(actionType in actions)) { + console.warn(`[executeAction] action '${actionType}' not found on entity '${entity.id}'`); + return; + } + return actions[actionType](action.arg); }