diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index fc3bdf7..b955c03 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -17,6 +17,8 @@ interface InventoryState { @component export class Inventory extends Component { + #cachedVars: RPGVariables | null = null; + constructor(slotDefs: Array) { super({ slots: slotDefs.map(def => { @@ -45,6 +47,7 @@ export class Inventory extends Component { @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 { @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 { } 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 { } } } + this.#cachedVars = result; return result; } } diff --git a/src/common/rpg/components/questLog.ts b/src/common/rpg/components/questLog.ts index 66383d4..ef5f0ef 100644 --- a/src/common/rpg/components/questLog.ts +++ b/src/common/rpg/components/questLog.ts @@ -22,6 +22,8 @@ interface QuestLogState { @component export class QuestLog extends Component { + #cachedVars: RPGVariables | null = null; + constructor(quests: Quest[] = []) { const questsRecord: Record = {}; const runtimeStates: Record = {}; @@ -41,6 +43,8 @@ export class QuestLog extends Component { 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 { } 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 { } /** @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 { 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 { } 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; } } diff --git a/src/common/rpg/components/variables.ts b/src/common/rpg/components/variables.ts index 532915f..0043399 100644 --- a/src/common/rpg/components/variables.ts +++ b/src/common/rpg/components/variables.ts @@ -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` when the shape is fixed at compile time + * (e.g. health, stats, slot definitions). + */ @component export class Variables extends Component { constructor() { diff --git a/src/common/rpg/core/registry.ts b/src/common/rpg/core/registry.ts index 6c47b96..3d9f945 100644 --- a/src/common/rpg/core/registry.ts +++ b/src/common/rpg/core/registry.ts @@ -1,17 +1,29 @@ import type { Component } from './world'; type ComponentConstructor = abstract new (...args: any[]) => Component; -type ComponentDecorator = (target: ComponentConstructor, ctx: ClassDecoratorContext) => void; +type MigrationFn = (state: Record) => Record; -const registry = new Map(); +interface ComponentMeta { + ctor: ComponentConstructor; + version: number; +} + +interface MigrationEntry { + toVersion: number; + fn: MigrationFn; +} + +const registry = new Map(); const reverseRegistry = new Map(); +/** migrations[name][fromVersion] → { toVersion, fn } */ +const migrations = new Map>(); -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, + fromVersion: number, +): Record { + 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); } diff --git a/src/common/rpg/core/serialization.ts b/src/common/rpg/core/serialization.ts index 7c4e2e7..654c573 100644 --- a/src/common/rpg/core/serialization.ts +++ b/src/common/rpg/core/serialization.ts @@ -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; entityCounter: number; entities: EntityData[]; @@ -31,7 +36,8 @@ function serializeComponent(component: Component): 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 { - 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, 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; - (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; + (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); } diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 7193133..1052959 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -199,6 +199,7 @@ export class World { readonly #entities = new Map(); readonly #handlers = new Map>(); readonly #globalHandlers = new Map>(); + readonly #onceWrappers = new Map(); readonly #systems: System[] = []; #entityCounter = 0; @@ -243,13 +244,39 @@ export class World { } } - *query>(ctor: Class): 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; + (clone as unknown as { state: unknown }).state = structuredClone(component[COMPONENT_STATE]()); + target.add(key, clone); + } + return target; + } + + query>(ctor: Class): Generator<[Entity, string, T]>; + query, B extends Component>(ctorA: Class, ctorB: Class): Generator<[Entity, A, B]>; + query, B extends Component, C extends Component>(ctorA: Class, ctorB: Class, ctorC: Class): Generator<[Entity, A, B, C]>; + *query(...ctors: Class>[]): Generator { + 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( @@ -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; +} diff --git a/src/common/rpg/utils/conditions.ts b/src/common/rpg/utils/conditions.ts index 43ebcc6..c8aa9a0 100644 --- a/src/common/rpg/utils/conditions.ts +++ b/src/common/rpg/utils/conditions.ts @@ -12,33 +12,47 @@ export interface ParsedCondition { value?: ConditionValue; } +const parseCache = new Map(); + 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) { + result = { variable: s, negate: false }; + } else { + const variable = s.slice(0, spaceIdx); + const rest = s.slice(spaceIdx + 1).trim(); - const spaceIdx = s.indexOf(' '); - if (spaceIdx === -1) - return { variable: s, negate: false }; + 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}"`); - const variable = s.slice(0, spaceIdx); - const rest = s.slice(spaceIdx + 1).trim(); + const [, operator, rawValue] = opMatch; + let value: ConditionValue; + if (rawValue === 'null') value = null; + else if (rawValue === 'true') value = true; + else if (rawValue === 'false') value = false; + else if (!isNaN(Number(rawValue))) value = Number(rawValue); + else value = rawValue.replace(/^['"]|['"]$/g, ''); - if (rest === 'null') return { variable, negate: false, operator: 'null' }; - if (rest === '~null') return { variable, negate: false, operator: '~null' }; + result = { variable, negate: false, operator: operator as ConditionOperator, value }; + } + } + } - const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/); - if (!opMatch) throw new Error(`Invalid condition: "${s}"`); - - const [, operator, rawValue] = opMatch; - let value: ConditionValue; - if (rawValue === 'null') value = null; - else if (rawValue === 'true') value = true; - else if (rawValue === 'false') value = false; - else if (!isNaN(Number(rawValue))) value = Number(rawValue); - else value = rawValue.replace(/^['"]|['"]$/g, ''); - - return { variable, negate: false, operator: operator as ConditionOperator, value }; + parseCache.set(s, result); + return result; } function evalParsed({ negate, operator, value }: ParsedCondition, val: RPGVariables[string]): boolean { diff --git a/src/common/rpg/utils/variables.ts b/src/common/rpg/utils/variables.ts index 3ee1fd6..e4c52ee 100644 --- a/src/common/rpg/utils/variables.ts +++ b/src/common/rpg/utils/variables.ts @@ -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); }