diff --git a/src/common/rpg/components/combat.ts b/src/common/rpg/components/combat.ts index 57888cb..fd25e43 100644 --- a/src/common/rpg/components/combat.ts +++ b/src/common/rpg/components/combat.ts @@ -1,5 +1,5 @@ -import { component } from "../core/registry"; import { Component } from "../core/world"; +import { component } from "../utils/decorators"; import { Stat } from "./stat"; @component diff --git a/src/common/rpg/components/cooldown.ts b/src/common/rpg/components/cooldown.ts index 0e8c6bf..a1e5cab 100644 --- a/src/common/rpg/components/cooldown.ts +++ b/src/common/rpg/components/cooldown.ts @@ -1,6 +1,5 @@ -import { component } from "../core/registry"; import { Component } from "../core/world"; -import { action, variable } from "../utils/decorators"; +import { action, component, variable } from "../utils/decorators"; @component export class Cooldown extends Component<{ diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index 8a0cc94..d87ceb7 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -1,7 +1,6 @@ -import { component } from "../core/registry"; import { Component, type EvalContext } from "../core/world"; import { evaluateCondition } from "../utils/conditions"; -import { action, variable } from "../utils/decorators"; +import { action, component, variable } from "../utils/decorators"; import { Stat } from "./stat"; @component diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts index 26f0182..17ba671 100644 --- a/src/common/rpg/components/equipment.ts +++ b/src/common/rpg/components/equipment.ts @@ -1,9 +1,7 @@ -import { component } from "../core/registry"; import { Component } from "../core/world"; -import { action } from "../utils/decorators"; import type { RPGVariables } from "../types"; +import { action, component } from "../utils/decorators"; import { Effect } from "./effect"; -import { Damage } from "./combat"; // ── Equippable ──────────────────────────────────────────────────────────────── diff --git a/src/common/rpg/components/experience.ts b/src/common/rpg/components/experience.ts index 8c1b6e7..32dcd8d 100644 --- a/src/common/rpg/components/experience.ts +++ b/src/common/rpg/components/experience.ts @@ -1,6 +1,5 @@ -import { component } from "../core/registry"; import { Component } from "../core/world"; -import { action, variable } from "../utils/decorators"; +import { action, component, variable } from "../utils/decorators"; /** * How much XP is required to advance from level N to N+1. diff --git a/src/common/rpg/components/faction.ts b/src/common/rpg/components/faction.ts index b8eeafc..795211d 100644 --- a/src/common/rpg/components/faction.ts +++ b/src/common/rpg/components/faction.ts @@ -1,7 +1,6 @@ -import { component } from "../core/registry"; import { Component, type Entity } from "../core/world"; import type { RPGVariables } from "../types"; -import { action } from "../utils/decorators"; +import { action, component } from "../utils/decorators"; // ── FactionMember ───────────────────────────────────────────────────────────── diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index ee33b78..5b6730b 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -1,10 +1,9 @@ -import type { InventorySlotInput, RPGVariables, SlotId } from "../types"; -import { action } from "../utils/decorators"; import { Component, type EvalContext } from "../core/world"; -import { component } from "../core/registry"; -import { Stackable, Usable } from "./item"; -import { Equipment, Equippable } from "./equipment"; +import type { InventorySlotInput, RPGVariables, SlotId } from "../types"; +import { action, component } from "../utils/decorators"; import { resolveVariables } from "../utils/variables"; +import { Equipment, Equippable } from "./equipment"; +import { Stackable, Usable } from "./item"; interface SlotRecord { slotId: SlotId; diff --git a/src/common/rpg/components/item.ts b/src/common/rpg/components/item.ts index 3bf269b..08c4eaa 100644 --- a/src/common/rpg/components/item.ts +++ b/src/common/rpg/components/item.ts @@ -1,7 +1,6 @@ import { Component, type EvalContext, type World } from "../core/world"; -import { component } from "../core/registry"; import type { RPGAction } from "../types"; -import { action, variable } from "../utils/decorators"; +import { action, component, variable } from "../utils/decorators"; import { executeAction } from "../utils/variables"; import { Equippable } from "./equipment"; diff --git a/src/common/rpg/components/position.ts b/src/common/rpg/components/position.ts new file mode 100644 index 0000000..ed760c7 --- /dev/null +++ b/src/common/rpg/components/position.ts @@ -0,0 +1,9 @@ +import { Component } from "../core/world"; +import { component } from "../utils/decorators"; + +@component({ variables: ['x', 'y', 'z'] }) +export class Position extends Component<{ x: number, y: number, z: number }> { + constructor(x = 0, y = 0, z = 0) { + super({ x, y, z }); + } +} \ No newline at end of file diff --git a/src/common/rpg/components/questLog.ts b/src/common/rpg/components/questLog.ts index e0b5e3a..36d3f5c 100644 --- a/src/common/rpg/components/questLog.ts +++ b/src/common/rpg/components/questLog.ts @@ -1,7 +1,7 @@ import { Component, type EvalContext } from "../core/world"; -import { component } from "../core/registry"; 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'; diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts index 3f96c7d..cedc723 100644 --- a/src/common/rpg/components/stat.ts +++ b/src/common/rpg/components/stat.ts @@ -1,6 +1,5 @@ -import { action, variable } from "../utils/decorators"; import { Component } from "../core/world"; -import { component } from "../core/registry"; +import { action, component, variable } from "../utils/decorators"; interface StatState { base: number; diff --git a/src/common/rpg/components/variables.ts b/src/common/rpg/components/variables.ts index f11185f..1597a21 100644 --- a/src/common/rpg/components/variables.ts +++ b/src/common/rpg/components/variables.ts @@ -1,7 +1,6 @@ -import type { RPGVariables } from "../types"; -import { action } from "../utils/decorators"; import { Component } from "../core/world"; -import { component } from "../core/registry"; +import type { RPGVariables } from "../types"; +import { action, component } from "../utils/decorators"; interface Var { key: string; diff --git a/src/common/rpg/core/registry.ts b/src/common/rpg/core/registry.ts deleted file mode 100644 index 3d9f945..0000000 --- a/src/common/rpg/core/registry.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Component } from './world'; - -type ComponentConstructor = abstract new (...args: any[]) => Component; -type MigrationFn = (state: Record) => Record; - -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, version: number): void { - registry.set(name, { ctor, version }); - reverseRegistry.set(ctor, name); -} - -export function getComponentMeta(name: string): ComponentMeta | undefined { - return registry.get(name); -} - -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( - nameOrTargetOrOptions: string | ComponentConstructor | ComponentOptions, - ctx?: ClassDecoratorContext, -): void | ComponentDecorator { - if (typeof nameOrTargetOrOptions === 'string') { - const name = nameOrTargetOrOptions; - return (target: ComponentConstructor) => register(name, target, 0); - } - 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 1ada8bd..15db427 100644 --- a/src/common/rpg/core/serialization.ts +++ b/src/common/rpg/core/serialization.ts @@ -1,5 +1,5 @@ import { World, Entity, Component, WORLD_ENTITY_COUNTER } from './world'; -import { getComponentMeta, getComponentName, migrateState } from './registry'; +import { getComponentMeta, getComponentName, migrateState } from '../utils/decorators'; /** Increment this when the WorldData/EntityData structure itself changes incompatibly. */ const SCHEMA_VERSION = 1; diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index f472110..1ba7b9b 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -1,6 +1,5 @@ -import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators'; import type { RPGActions, RPGVariables } from '../types'; -import { getComponentName } from './registry'; +import { ACTION_KEYS, getComponentName, STATE_KEYS, VARIABLE_KEYS } from '../utils/decorators'; interface EntityEvent { target: Entity; @@ -59,12 +58,22 @@ export abstract class Component> { getVariables(): RPGVariables { const meta = (this.constructor as Function)[Symbol.metadata]; const keys = meta?.[VARIABLE_KEYS] as Map | undefined; - if (!keys) return {}; + const stateKeys = meta?.[STATE_KEYS] as Set | undefined; const vars: RPGVariables = {}; - for (const [methodKey, exportName] of keys) { - const v = (this as Record)[String(methodKey)]; - if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') { - vars[exportName] = v; + if (keys) { + for (const [methodKey, exportName] of keys) { + const v = (this as Record)[String(methodKey)]; + if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') { + vars[exportName] = v; + } + } + } + if (stateKeys) { + for (const key of stateKeys) { + const v = (this._state as Record)[String(key)]; + if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') { + vars[key] = v; + } } } return vars; diff --git a/src/common/rpg/utils/decorators.ts b/src/common/rpg/utils/decorators.ts index 1bca716..fc4f2cd 100644 --- a/src/common/rpg/utils/decorators.ts +++ b/src/common/rpg/utils/decorators.ts @@ -1,3 +1,4 @@ +import type { Component } from "../core/world"; import type { RPGVariables } from "../types"; // TC39 stage-3 decorators use Symbol.metadata; polyfill for runtimes that lack it (e.g. Bun). @@ -5,6 +6,7 @@ import type { RPGVariables } from "../types"; export const ACTION_KEYS = Symbol('rpg.actions'); export const VARIABLE_KEYS = Symbol('rpg.variables'); +export const STATE_KEYS = Symbol('rpg.state_variables'); export function action unknown>( target: T, @@ -12,18 +14,23 @@ export function action unknown>( ): T { const prev = context.metadata[ACTION_KEYS] as Set | undefined; context.metadata[ACTION_KEYS] = new Set(prev).add(context.name); - return function(this: any, arg?: any, ctx?: any) { + return function (this: any, arg?: any, ctx?: any) { return target.call(this, arg, ctx ?? this.context); } as unknown as T; } type VariableContext = | ClassFieldDecoratorContext - | ClassGetterDecoratorContext; + | ClassGetterDecoratorContext + | ClassDecoratorContext; -function registerVariable(context: VariableContext, exportName: string): void { +function registerVariable(context: VariableContext, exportName: string, name?: string): void { const prev = context.metadata[VARIABLE_KEYS] as Map | undefined; - context.metadata[VARIABLE_KEYS] = new Map(prev).set(context.name, exportName); + context.metadata[VARIABLE_KEYS] = new Map(prev).set(name ?? context.name ?? exportName, exportName); +} +function registerStateVariable(context: VariableContext, name: string): void { + const prev = context.metadata[STATE_KEYS] as Set | undefined; + context.metadata[STATE_KEYS] = new Set(prev).add(name); } export function variable(name: string): (target: undefined | (() => T), context: VariableContext) => void; @@ -41,3 +48,107 @@ export function variable( } registerVariable(context!, String(context!.name)); } +type ComponentConstructor = abstract new (...args: any[]) => Component; +type MigrationFn = (state: Record) => Record; + +interface ComponentMeta { + ctor: ComponentConstructor; + version: number; +} + +interface MigrationEntry { + toVersion: number; + fn: MigrationFn; +} + +const registry = new Map>(); +const reverseRegistry = new Map, string>(); +/** migrations[name][fromVersion] → { toVersion, fn } */ +const migrations = new Map>(); + +function register(name: string, ctor: ComponentConstructor, version: number): void { + registry.set(name, { ctor, version }); + reverseRegistry.set(ctor, name); +} + +export function getComponentMeta(name: string): ComponentMeta | undefined { + return registry.get(name); +} + +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; + variables?: (keyof T)[]; +} + +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( + nameOrTargetOrOptions: string | ComponentConstructor | ComponentOptions, + ctx?: ClassDecoratorContext, +): void | ComponentDecorator { + if (typeof nameOrTargetOrOptions === 'string') { + const name = nameOrTargetOrOptions; + return (target: ComponentConstructor) => register(name, target, 0); + } + if (typeof nameOrTargetOrOptions === 'object') { + const { name, version = 0, variables = [] } = nameOrTargetOrOptions; + return (target: ComponentConstructor, ctx: ClassDecoratorContext) => { + register(name ?? String(ctx.name), target, version); + + for (const key of variables) { + registerStateVariable(ctx, String(key)); + } + } + } + // Used as bare @component + register(String(ctx!.name), nameOrTargetOrOptions, 0); +} diff --git a/src/common/rpg/utils/variables.ts b/src/common/rpg/utils/variables.ts index 4caee27..d6af5b1 100644 --- a/src/common/rpg/utils/variables.ts +++ b/src/common/rpg/utils/variables.ts @@ -1,7 +1,7 @@ +import type { Entity, EvalContext } from "../core/world"; +import { World } from "../core/world"; import type { RPGAction, RPGActions, RPGVariables } from "../types"; -import { isEvalContext, World } from "../core/world"; -import type { EvalContext, Entity } from "../core/world"; -import { getComponentName } from "../core/registry"; +import { getComponentName } from "./decorators"; export function resolveVariables(target: Entity | World): RPGVariables { const result: RPGVariables = {}; diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 3e1ac31..ea7f71b 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -10,6 +10,7 @@ import { Serialization } from "@common/rpg/core/serialization"; import { Factions } from "@common/rpg/components/faction"; import { Effect } from "@common/rpg/components/effect"; import { Equipment } from "@common/rpg/components/equipment"; +import { Position } from "@common/rpg/components/position"; export default async function main() { const world = new World(); @@ -35,6 +36,7 @@ export default async function main() { }])); player.add('str', new Stat({ value: 100 })); player.add('agl', new Stat({ value: 100 })); + player.add(new Position(42, 69)); Factions.join(player, 'boobs'); Factions.adjustReputation(player, 'guards', 10);