RPG engine improvements: caching, cloning, versioning, query overloads
- Condition parse cache: each unique string parsed once (Map<string, ParsedCondition>)
- executeAction: throw on missing type/malformed action/missing action; warn-only on
destroyed entity (legitimate mid-flight case)
- World.query() overloads for 2 and 3 component types: query(A, B) → [Entity, A, B]
- World.once()/off() consistency: #onceWrappers map lets off(event, original) correctly
remove handlers registered via once()
- World.cloneEntity(source, newId?): deep-clones all components via structuredClone,
onAdd() fires normally on the new entity
- isEntityContext() type guard alongside isEvalContext()
- Inventory.getVariables() cache: invalidated on add()/remove()
- QuestLog.getVariables() cache: invalidated on any status transition or _advance()
- QuestLog.entries() explicit Generator return type
- Variables JSDoc: clarifies when to use Variables vs typed Component<TState>
- @component({ name?, version? }) object overload in registry
- registerMigration(name, from, to, fn): chained migrations run automatically on
deserialize when saved version < current version
- Serialization wire format: ComponentData carries version, WorldData carries
schemaVersion; mismatched schemaVersion throws a descriptive error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
066271205a
commit
b866e0a5de
|
|
@ -17,6 +17,8 @@ interface InventoryState {
|
|||
|
||||
@component
|
||||
export class Inventory extends Component<InventoryState> {
|
||||
#cachedVars: RPGVariables | null = null;
|
||||
|
||||
constructor(slotDefs: Array<InventorySlotInput>) {
|
||||
super({
|
||||
slots: slotDefs.map(def => {
|
||||
|
|
@ -45,6 +47,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
|
||||
@action
|
||||
add({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean {
|
||||
this.#cachedVars = null;
|
||||
if (amount < 0) return false;
|
||||
if (amount === 0) return true;
|
||||
|
||||
|
|
@ -105,6 +108,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
|
||||
@action
|
||||
remove({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean {
|
||||
this.#cachedVars = null;
|
||||
if (amount < 0) return false;
|
||||
if (amount === 0) return true;
|
||||
|
||||
|
|
@ -193,6 +197,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
}
|
||||
|
||||
override getVariables(): RPGVariables {
|
||||
if (this.#cachedVars) return this.#cachedVars;
|
||||
const result: RPGVariables = {};
|
||||
for (const [itemId, amount] of this.getItems()) {
|
||||
result[itemId] = amount;
|
||||
|
|
@ -203,6 +208,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
}
|
||||
}
|
||||
}
|
||||
this.#cachedVars = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ interface QuestLogState {
|
|||
|
||||
@component
|
||||
export class QuestLog extends Component<QuestLogState> {
|
||||
#cachedVars: RPGVariables | null = null;
|
||||
|
||||
constructor(quests: Quest[] = []) {
|
||||
const questsRecord: Record<string, Quest> = {};
|
||||
const runtimeStates: Record<string, QuestRuntimeState> = {};
|
||||
|
|
@ -41,6 +43,8 @@ export class QuestLog extends Component<QuestLogState> {
|
|||
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) {
|
||||
|
|
@ -53,6 +57,7 @@ export class QuestLog extends Component<QuestLogState> {
|
|||
}
|
||||
runtimeState.status = to;
|
||||
if (to === 'active' || to === 'inactive') runtimeState.stageIndex = 0;
|
||||
this.#invalidate();
|
||||
this.emit(event, { questId });
|
||||
return true;
|
||||
}
|
||||
|
|
@ -105,7 +110,7 @@ export class QuestLog extends Component<QuestLogState> {
|
|||
}
|
||||
|
||||
/** @internal used by QuestSystem */
|
||||
*entries(): Generator<[string, QuestEntry]> {
|
||||
*entries(): Generator<[string, QuestEntry], void, unknown> {
|
||||
for (const [id, quest] of Object.entries(this.state.quests)) {
|
||||
yield [id, { quest, state: this.state.runtimeStates[id]! }];
|
||||
}
|
||||
|
|
@ -116,6 +121,7 @@ export class QuestLog extends Component<QuestLogState> {
|
|||
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] });
|
||||
|
|
@ -140,11 +146,13 @@ export class QuestLog extends Component<QuestLogState> {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ interface VariablesState {
|
|||
vars: RPGVariables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic runtime key-value store set by dialog actions, quest scripts, and game events —
|
||||
* values whose keys are only known at runtime or come from data files.
|
||||
*
|
||||
* Prefer a typed `Component<TState>` when the shape is fixed at compile time
|
||||
* (e.g. health, stats, slot definitions).
|
||||
*/
|
||||
@component
|
||||
export class Variables extends Component<VariablesState> {
|
||||
constructor() {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,29 @@
|
|||
import type { Component } from './world';
|
||||
|
||||
type ComponentConstructor = abstract new (...args: any[]) => Component<any>;
|
||||
type ComponentDecorator = (target: ComponentConstructor, ctx: ClassDecoratorContext) => void;
|
||||
type MigrationFn = (state: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
const registry = new Map<string, ComponentConstructor>();
|
||||
interface ComponentMeta {
|
||||
ctor: ComponentConstructor;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface MigrationEntry {
|
||||
toVersion: number;
|
||||
fn: MigrationFn;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ComponentMeta>();
|
||||
const reverseRegistry = new Map<ComponentConstructor, string>();
|
||||
/** migrations[name][fromVersion] → { toVersion, fn } */
|
||||
const migrations = new Map<string, Map<number, MigrationEntry>>();
|
||||
|
||||
function register(name: string, ctor: ComponentConstructor): void {
|
||||
registry.set(name, ctor);
|
||||
function register(name: string, ctor: ComponentConstructor, version: number): void {
|
||||
registry.set(name, { ctor, version });
|
||||
reverseRegistry.set(ctor, name);
|
||||
}
|
||||
|
||||
export function getComponentClass(name: string): ComponentConstructor | undefined {
|
||||
export function getComponentMeta(name: string): ComponentMeta | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
|
|
@ -19,15 +31,70 @@ export function getComponentName(ctor: Function): string | undefined {
|
|||
return reverseRegistry.get(ctor as ComponentConstructor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a migration that upgrades component state from `fromVersion` to `toVersion`.
|
||||
* Migrations are chained automatically: registering 0→1 and 1→2 handles saves at version 0.
|
||||
*/
|
||||
export function registerMigration(
|
||||
name: string,
|
||||
fromVersion: number,
|
||||
toVersion: number,
|
||||
fn: MigrationFn,
|
||||
): void {
|
||||
if (!migrations.has(name)) migrations.set(name, new Map());
|
||||
migrations.get(name)!.set(fromVersion, { toVersion, fn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all registered migrations to bring `state` from `fromVersion` up to the current
|
||||
* registered version. Returns the migrated state (may be the same object if no migrations ran).
|
||||
*/
|
||||
export function migrateState(
|
||||
name: string,
|
||||
state: Record<string, unknown>,
|
||||
fromVersion: number,
|
||||
): Record<string, unknown> {
|
||||
const meta = registry.get(name);
|
||||
if (!meta) return state;
|
||||
const chain = migrations.get(name);
|
||||
if (!chain) return state;
|
||||
let current = fromVersion;
|
||||
let s = state;
|
||||
while (current < meta.version) {
|
||||
const entry = chain.get(current);
|
||||
if (!entry) throw new Error(
|
||||
`[registry] No migration for '${name}' from version ${current} to ${meta.version}. ` +
|
||||
`Register one with registerMigration('${name}', ${current}, ...).`
|
||||
);
|
||||
s = entry.fn(s);
|
||||
current = entry.toVersion;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface ComponentOptions {
|
||||
name?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
type ComponentDecorator = (target: ComponentConstructor, ctx: ClassDecoratorContext) => void;
|
||||
|
||||
export function component(target: ComponentConstructor, ctx: ClassDecoratorContext): void;
|
||||
export function component(name: string): ComponentDecorator;
|
||||
export function component(options: ComponentOptions): ComponentDecorator;
|
||||
export function component(
|
||||
nameOrTarget: string | ComponentConstructor,
|
||||
nameOrTargetOrOptions: string | ComponentConstructor | ComponentOptions,
|
||||
ctx?: ClassDecoratorContext,
|
||||
): void | ComponentDecorator {
|
||||
if (typeof nameOrTarget === 'string') {
|
||||
const name = nameOrTarget;
|
||||
return (target: ComponentConstructor) => register(name, target);
|
||||
if (typeof nameOrTargetOrOptions === 'string') {
|
||||
const name = nameOrTargetOrOptions;
|
||||
return (target: ComponentConstructor) => register(name, target, 0);
|
||||
}
|
||||
register(String(ctx!.name), nameOrTarget);
|
||||
if (typeof nameOrTargetOrOptions === 'object') {
|
||||
const { name, version = 0 } = nameOrTargetOrOptions;
|
||||
return (target: ComponentConstructor, ctx: ClassDecoratorContext) =>
|
||||
register(name ?? String(ctx.name), target, version);
|
||||
}
|
||||
// Used as bare @component
|
||||
register(String(ctx!.name), nameOrTargetOrOptions, 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { World, Entity, Component, COMPONENT_STATE, WORLD_ENTITY_COUNTER } from './world';
|
||||
import { getComponentClass, getComponentName } from './registry';
|
||||
import { getComponentMeta, getComponentName, migrateState } from './registry';
|
||||
|
||||
/** Increment this when the WorldData/EntityData structure itself changes incompatibly. */
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
interface ComponentData {
|
||||
type: 'component';
|
||||
name: string;
|
||||
key: string;
|
||||
version: number;
|
||||
state: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +20,7 @@ interface EntityData {
|
|||
|
||||
interface WorldData {
|
||||
type: 'world';
|
||||
schemaVersion: number;
|
||||
globals: Record<string, string | number | boolean | undefined>;
|
||||
entityCounter: number;
|
||||
entities: EntityData[];
|
||||
|
|
@ -31,7 +36,8 @@ function serializeComponent(component: Component<any>): ComponentData {
|
|||
`Add @component to the class declaration.`
|
||||
);
|
||||
}
|
||||
return { type: 'component', name, key: component.key, state: component[COMPONENT_STATE]() };
|
||||
const meta = getComponentMeta(name)!;
|
||||
return { type: 'component', name, key: component.key, version: meta.version, state: component[COMPONENT_STATE]() };
|
||||
}
|
||||
|
||||
function serializeEntity(entity: Entity): EntityData {
|
||||
|
|
@ -49,6 +55,7 @@ function serializeWorld(world: World): WorldData {
|
|||
}
|
||||
return {
|
||||
type: 'world',
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
globals: { ...world.globals },
|
||||
entityCounter: world[WORLD_ENTITY_COUNTER],
|
||||
entities,
|
||||
|
|
@ -56,15 +63,21 @@ function serializeWorld(world: World): WorldData {
|
|||
}
|
||||
|
||||
function deserializeComponent(data: ComponentData): Component<any> {
|
||||
const ComponentClass = getComponentClass(data.name);
|
||||
if (!ComponentClass) {
|
||||
const meta = getComponentMeta(data.name);
|
||||
if (!meta) {
|
||||
throw new Error(`Unknown component '${data.name}'. Ensure it is imported so @component runs.`);
|
||||
}
|
||||
|
||||
const savedVersion = data.version ?? 0;
|
||||
const state = savedVersion < meta.version
|
||||
? migrateState(data.name, data.state as Record<string, unknown>, savedVersion)
|
||||
: data.state;
|
||||
|
||||
// Bypass constructor: create a bare instance and restore state directly.
|
||||
// This is safe because constructors must only call super(state) — all
|
||||
// initialization logic goes in onAdd(), which entity.add() calls after this.
|
||||
const instance = Object.create(ComponentClass.prototype) as Component<any>;
|
||||
(instance as unknown as { state: unknown }).state = data.state;
|
||||
// Safe because constructors must only call super(state) — all initialization
|
||||
// logic goes in onAdd(), which entity.add() calls after this.
|
||||
const instance = Object.create(meta.ctor.prototype) as Component<any>;
|
||||
(instance as unknown as { state: unknown }).state = state;
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +90,12 @@ function deserializeEntity(data: EntityData, world: World): Entity {
|
|||
}
|
||||
|
||||
function deserializeWorld(data: WorldData): World {
|
||||
if (data.schemaVersion !== SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`Unsupported save format: schema version ${data.schemaVersion}, ` +
|
||||
`expected ${SCHEMA_VERSION}. The save file is incompatible with this version of the engine.`
|
||||
);
|
||||
}
|
||||
const world = new World();
|
||||
Object.assign(world.globals, data.globals);
|
||||
world[WORLD_ENTITY_COUNTER] = data.entityCounter;
|
||||
|
|
@ -98,7 +117,6 @@ export namespace Serialization {
|
|||
switch (data.type) {
|
||||
case 'world': return deserializeWorld(data);
|
||||
case 'entity': {
|
||||
// A standalone entity needs a world to live in.
|
||||
const world = new World();
|
||||
return deserializeEntity(data, world);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ export class World {
|
|||
readonly #entities = new Map<string, Entity>();
|
||||
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
|
||||
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
|
||||
readonly #onceWrappers = new Map<Function, Function>();
|
||||
readonly #systems: System[] = [];
|
||||
#entityCounter = 0;
|
||||
|
||||
|
|
@ -243,12 +244,38 @@ export class World {
|
|||
}
|
||||
}
|
||||
|
||||
*query<T extends Component<any>>(ctor: Class<T>): Generator<[Entity, string, T]> {
|
||||
for (const entity of this.#entities.values()) {
|
||||
for (const [key, component] of entity) {
|
||||
if (component instanceof ctor) {
|
||||
yield [entity, key, component as T]
|
||||
/**
|
||||
* Create a new entity whose components are deep copies of `source`'s components.
|
||||
* The clone is immediately live in the world and `onAdd()` fires for each component.
|
||||
* @param source - Entity to clone.
|
||||
* @param newId - ID for the clone; follows the same rules as {@link createEntity}.
|
||||
*/
|
||||
cloneEntity(source: Entity, newId?: string): Entity {
|
||||
const target = this.createEntity(newId);
|
||||
for (const [key, component] of source) {
|
||||
const clone = Object.create(component.constructor.prototype) as Component<any>;
|
||||
(clone as unknown as { state: unknown }).state = structuredClone(component[COMPONENT_STATE]());
|
||||
target.add(key, clone);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
query<T extends Component<any>>(ctor: Class<T>): Generator<[Entity, string, T]>;
|
||||
query<A extends Component<any>, B extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>): Generator<[Entity, A, B]>;
|
||||
query<A extends Component<any>, B extends Component<any>, C extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>, ctorC: Class<C>): Generator<[Entity, A, B, C]>;
|
||||
*query(...ctors: Class<Component<any>>[]): Generator<unknown[]> {
|
||||
const entities = this.#entities;
|
||||
if (ctors.length === 1) {
|
||||
const ctor = ctors[0];
|
||||
for (const entity of entities.values()) {
|
||||
for (const [key, component] of entity) {
|
||||
if (component instanceof ctor) yield [entity, key, component];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const entity of entities.values()) {
|
||||
const components = ctors.map(ctor => entity.get(ctor));
|
||||
if (components.every(c => c !== undefined)) yield [entity, ...components];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -293,9 +320,13 @@ export class World {
|
|||
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!);
|
||||
const handler = (this.#onceWrappers.get(arg3!) ?? arg3!) as EntityEventHandler;
|
||||
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(handler);
|
||||
this.#onceWrappers.delete(arg3!);
|
||||
} else {
|
||||
this.#globalHandlers.get(arg1)?.delete(arg2);
|
||||
const handler = (this.#onceWrappers.get(arg2) ?? arg2) as WorldEventHandler;
|
||||
this.#globalHandlers.get(arg1)?.delete(handler);
|
||||
this.#onceWrappers.delete(arg2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -303,14 +334,17 @@ export class World {
|
|||
once(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
||||
once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
||||
if (typeof arg2 === 'string') {
|
||||
const wrapped: EntityEventHandler = data => { unsub(); arg3!(data); };
|
||||
const original = arg3!;
|
||||
const wrapped: EntityEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
||||
this.#onceWrappers.set(original, wrapped);
|
||||
const unsub = this.on(arg1, arg2, wrapped);
|
||||
return unsub;
|
||||
return () => { this.#onceWrappers.delete(original); unsub(); };
|
||||
}
|
||||
const h = arg2;
|
||||
const wrapped: WorldEventHandler = data => { unsub(); h(data); };
|
||||
const original = arg2;
|
||||
const wrapped: WorldEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
||||
this.#onceWrappers.set(original, wrapped);
|
||||
const unsub = this.on(arg1, wrapped);
|
||||
return unsub;
|
||||
return () => { this.#onceWrappers.delete(original); unsub(); };
|
||||
}
|
||||
|
||||
#addHandler<T extends WorldEventHandler | EntityEventHandler>(
|
||||
|
|
@ -336,3 +370,8 @@ export function isEvalContext(v: unknown): v is EvalContext {
|
|||
)
|
||||
&& (v as EvalContext).world instanceof World;
|
||||
}
|
||||
|
||||
/** Narrows an {@link EvalContext} to one where `self` is an `Entity`, not a `World`. */
|
||||
export function isEntityContext(ctx: EvalContext): ctx is { self: Entity; world: World } {
|
||||
return ctx.self instanceof Entity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,21 +12,29 @@ export interface ParsedCondition {
|
|||
value?: ConditionValue;
|
||||
}
|
||||
|
||||
const parseCache = new Map<string, ParsedCondition>();
|
||||
|
||||
export function parseCondition(s: RPGCondition): ParsedCondition {
|
||||
const cached = parseCache.get(s);
|
||||
if (cached) return cached;
|
||||
let result: ParsedCondition;
|
||||
|
||||
// ~variable — falsy check, nothing else allowed
|
||||
if (s.startsWith('~') && !s.includes(' '))
|
||||
return { variable: s.slice(1), negate: true };
|
||||
|
||||
if (s.startsWith('~') && !s.includes(' ')) {
|
||||
result = { variable: s.slice(1), negate: true };
|
||||
} else {
|
||||
const spaceIdx = s.indexOf(' ');
|
||||
if (spaceIdx === -1)
|
||||
return { variable: s, negate: false };
|
||||
|
||||
if (spaceIdx === -1) {
|
||||
result = { variable: s, negate: false };
|
||||
} else {
|
||||
const variable = s.slice(0, spaceIdx);
|
||||
const rest = s.slice(spaceIdx + 1).trim();
|
||||
|
||||
if (rest === 'null') return { variable, negate: false, operator: 'null' };
|
||||
if (rest === '~null') return { variable, negate: false, operator: '~null' };
|
||||
|
||||
if (rest === 'null') {
|
||||
result = { variable, negate: false, operator: 'null' };
|
||||
} else if (rest === '~null') {
|
||||
result = { variable, negate: false, operator: '~null' };
|
||||
} else {
|
||||
const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/);
|
||||
if (!opMatch) throw new Error(`Invalid condition: "${s}"`);
|
||||
|
||||
|
|
@ -38,7 +46,13 @@ export function parseCondition(s: RPGCondition): ParsedCondition {
|
|||
else if (!isNaN(Number(rawValue))) value = Number(rawValue);
|
||||
else value = rawValue.replace(/^['"]|['"]$/g, '');
|
||||
|
||||
return { variable, negate: false, operator: operator as ConditionOperator, value };
|
||||
result = { variable, negate: false, operator: operator as ConditionOperator, value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseCache.set(s, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean {
|
||||
|
|
|
|||
|
|
@ -77,8 +77,7 @@ export async function executeAction(action: RPGAction, ctx: EvalContext | Contex
|
|||
ctx = ctx.context;
|
||||
}
|
||||
if (!action.type) {
|
||||
console.warn(`[executeAction] action missing 'type' property`);
|
||||
return;
|
||||
throw new TypeError(`[executeAction] action object is missing a 'type' property`);
|
||||
}
|
||||
let entity = ctx.self;
|
||||
let actionType = action.type;
|
||||
|
|
@ -87,12 +86,12 @@ export async function executeAction(action: RPGAction, ctx: EvalContext | Contex
|
|||
if (action.type.startsWith('@')) {
|
||||
const dotIdx = action.type.indexOf('.', 1);
|
||||
if (dotIdx === -1) {
|
||||
console.warn(`[executeAction] malformed cross-entity action '${action.type}': missing '.' after entity id`);
|
||||
return;
|
||||
throw new Error(`[executeAction] malformed cross-entity action '${action.type}': missing '.' after entity id`);
|
||||
}
|
||||
const entityId = action.type.slice(1, dotIdx);
|
||||
const found = ctx.world.getEntity(entityId);
|
||||
if (!found) {
|
||||
// Entity may have been destroyed legitimately — warn and skip rather than throw.
|
||||
console.warn(`[executeAction] entity '${entityId}' not found (action '${action.type}')`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -102,8 +101,7 @@ export async function executeAction(action: RPGAction, ctx: EvalContext | Contex
|
|||
|
||||
const actions = resolveActions(entity);
|
||||
if (!(actionType in actions)) {
|
||||
console.warn(`[executeAction] action '${actionType}' not found on entity '${entity.id}'`);
|
||||
return;
|
||||
throw new Error(`[executeAction] action '${actionType}' not found on entity '${entity instanceof World ? 'world' : entity.id}'`);
|
||||
}
|
||||
return actions[actionType](action.arg, ctx);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue