1
0
Fork 0

Fixes and improvements

This commit is contained in:
Pabloader 2026-04-28 19:15:47 +00:00
parent 89651f6674
commit b75dc078f9
6 changed files with 133 additions and 34 deletions

View File

@ -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);

View File

@ -1,8 +1,18 @@
import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators';
import type { RPGActions, RPGVariables } from '../types';
interface EntityEvent<T = unknown> {
target: Entity;
data?: T;
}
interface WorldEvent<T = unknown> {
target: World;
data?: T;
}
type Class<T> = abstract new (...args: any[]) => T;
type EventHandler = (data: unknown) => void;
type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
export interface EvalContext {
self: Entity;
@ -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<string, Entity>();
readonly #handlers = new Map<string, Set<EventHandler>>();
readonly #globalHandlers = new Map<string, Set<EventHandler>>();
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
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<string, Set<EventHandler>>, key: string, handler: EventHandler): () => void {
#addHandler<T extends WorldEventHandler | EntityEventHandler>(map: Map<string, Set<T>>, 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);

View File

@ -243,8 +243,15 @@ export class DialogEngine {
async advance(nodeId?: string): Promise<DialogNodeView | null> {
let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined);
const visited = new Set<string>();
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;

View File

@ -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)

View File

@ -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({

View File

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