From 6b3c0c77a17d27249b89934991450e51db9c5714 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 30 Apr 2026 13:08:00 +0000 Subject: [PATCH] Namespace variables by component --- src/common/rpg/components/effect.ts | 2 +- src/common/rpg/components/equipment.ts | 8 ++- src/common/rpg/components/faction.ts | 79 ++++++++++++++++++++------ src/common/rpg/components/item.ts | 17 ++++-- src/common/rpg/components/stat.ts | 8 ++- src/common/rpg/components/variables.ts | 24 +++----- src/common/rpg/core/world.ts | 71 ++++++++++++++++++----- src/common/rpg/utils/variables.ts | 15 ++++- src/games/playground/index.tsx | 34 ++++++++--- test/common/rpg/cooldown.test.ts | 74 ++++++++++++------------ test/common/rpg/effect.test.ts | 4 +- test/common/rpg/equipment.test.ts | 22 +++---- test/common/rpg/experience.test.ts | 4 +- test/common/rpg/faction.test.ts | 6 +- test/common/rpg/inventory.test.ts | 2 +- test/common/rpg/quest.test.ts | 44 +++++++------- test/common/rpg/stat.test.ts | 22 +++++-- 17 files changed, 284 insertions(+), 152 deletions(-) diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index 19bf917..8a0cc94 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -66,8 +66,8 @@ export class Effect extends Component<{ const stat = this.entity.get(Stat, this.state.targetStat); if (stat) { stat.applyModifier(this.state.delta, this.state.targetField); + this.active = true; } - this.active = true; } override onRemove(): void { diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts index 13764a7..5c24958 100644 --- a/src/common/rpg/components/equipment.ts +++ b/src/common/rpg/components/equipment.ts @@ -45,9 +45,9 @@ export type SlotInput = export class Equipment extends Component { #cachedVars: RPGVariables | null = null; - constructor(slots: SlotInput[]) { + constructor(...slots: SlotInput[]) { const record: Record = {}; - for (const s of slots) { + for (const s of slots.flat()) { const slotName = typeof s === 'string' ? s : s.slotName; const type = typeof s === 'object' && s.type ? s.type : null; record[slotName] = { slotName, type, itemId: null, appliedEffectKeys: [] }; @@ -155,7 +155,9 @@ export class Equipment extends Component { const result: RPGVariables = {}; for (const { slotName, itemId } of Object.values(this.state.slots)) { - result[slotName] = itemId ?? ''; + if (itemId) { + result[slotName] = itemId; + } } this.#cachedVars = result; diff --git a/src/common/rpg/components/faction.ts b/src/common/rpg/components/faction.ts index 9ace038..b8eeafc 100644 --- a/src/common/rpg/components/faction.ts +++ b/src/common/rpg/components/faction.ts @@ -1,18 +1,23 @@ import { component } from "../core/registry"; import { Component, type Entity } from "../core/world"; -import { variable } from "../utils/decorators"; +import type { RPGVariables } from "../types"; +import { action } from "../utils/decorators"; // ── FactionMember ───────────────────────────────────────────────────────────── @component export class FactionMember extends Component<{ factionId: string }> { - @variable('.') readonly member: boolean = true; - constructor(factionId: string) { super({ factionId }); } get factionId(): string { return this.state.factionId; } + + override getVariables(): RPGVariables { + return { + [this.factionId]: true, + } + } } // ── Reputation ──────────────────────────────────────────────────────────────── @@ -23,31 +28,71 @@ export class Reputation extends Component<{ factionId: string; score: number }> super({ factionId, score }); } - @variable('.') get score(): number { return this.state.score; } + get score(): number { return this.state.score; } get factionId(): string { return this.state.factionId; } adjust(delta: number): void { this.state.score += delta; } + + override getVariables(): RPGVariables { + return { + [this.factionId]: this.score, + } + } +} + +//── FactionManager ──────────────────────────────────────────────────────────── + +@component +export class FactionManager extends Component<{}> { + constructor() { super({}); } + + @action + join(factionId: string): void { + Factions.join(this.entity, factionId); + } + + @action + leave(factionId: string): void { + Factions.leave(this.entity, factionId); + } + + @action + setReputation({ factionId, value }: { factionId: string, value: number }): void { + Factions.setReputation(this.entity, factionId, value); + } + + @action + adjustReputation({ factionId, value }: { factionId: string, value: number }): void { + Factions.adjustReputation(this.entity, factionId, value); + } } // ── Factions namespace ──────────────────────────────────────────────────────── -function memberKey(factionId: string): string { return `faction.${factionId}.member`; } -function repKey(factionId: string): string { return `faction.${factionId}.rep`; } - export namespace Factions { + function addFactionManager(entity: Entity) { + if (!entity.has(FactionManager)) { + entity.add(new FactionManager()); + } + } export function join(entity: Entity, factionId: string): void { - entity.add(memberKey(factionId), new FactionMember(factionId)); + addFactionManager(entity); + const existing = entity.get(FactionMember, (c) => c.factionId === factionId); + if (!existing) { + entity.add(new FactionMember(factionId)); + } } export function leave(entity: Entity, factionId: string): void { - entity.remove(memberKey(factionId)); + addFactionManager(entity); + entity.removeAll(FactionMember, (c) => c.factionId === factionId); } export function isMember(entity: Entity, factionId: string): boolean { - return entity.has(FactionMember, memberKey(factionId)); + return entity.has(FactionMember, (c) => c.factionId === factionId); } export function getFactions(entity: Entity): string[] { @@ -55,26 +100,26 @@ export namespace Factions { } export function getReputation(entity: Entity, factionId: string): number { - return entity.get(Reputation, repKey(factionId))?.score ?? 0; + return entity.get(Reputation, (c) => c.factionId === factionId)?.score ?? 0; } export function setReputation(entity: Entity, factionId: string, value: number): void { - const key = repKey(factionId); - const existing = entity.get(Reputation, key); + addFactionManager(entity); + const existing = entity.get(Reputation, (c) => c.factionId === factionId); if (existing) { existing.state.score = value; } else { - entity.add(key, new Reputation(factionId, value)); + entity.add(new Reputation(factionId, value)); } } export function adjustReputation(entity: Entity, factionId: string, delta: number): void { - const key = repKey(factionId); - const existing = entity.get(Reputation, key); + addFactionManager(entity); + const existing = entity.get(Reputation, (c) => c.factionId === factionId); if (existing) { existing.adjust(delta); } else { - entity.add(key, new Reputation(factionId, delta)); + entity.add(new Reputation(factionId, delta)); } } diff --git a/src/common/rpg/components/item.ts b/src/common/rpg/components/item.ts index 8b520ac..3bf269b 100644 --- a/src/common/rpg/components/item.ts +++ b/src/common/rpg/components/item.ts @@ -3,6 +3,7 @@ import { component } from "../core/registry"; import type { RPGAction } from "../types"; import { action, variable } from "../utils/decorators"; import { executeAction } from "../utils/variables"; +import { Equippable } from "./equipment"; interface ItemState { name: string; @@ -60,15 +61,21 @@ export namespace Items { maxStack?: number; description?: string; usable?: { actions: RPGAction[]; consumeOnUse?: boolean }; + equippable?: { slotType: string }; } export function register(world: World, id: string, name: string, options?: RegisterOptions) { const entity = world.createEntity(id); - entity.add('item', new Item(name, options?.description)); - if (options?.maxStack !== undefined) - entity.add('stackable', new Stackable(options.maxStack)); - if (options?.usable) - entity.add('usable', new Usable(options.usable.actions, options.usable.consumeOnUse)); + entity.add(new Item(name, options?.description)); + if (options?.maxStack !== undefined) { + entity.add(new Stackable(options.maxStack)); + } + if (options?.usable) { + entity.add(new Usable(options.usable.actions, options.usable.consumeOnUse)); + } + if (options?.equippable) { + entity.add(new Equippable(options.equippable.slotType)); + } return entity; } } diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts index ddc19fe..3f96c7d 100644 --- a/src/common/rpg/components/stat.ts +++ b/src/common/rpg/components/stat.ts @@ -75,9 +75,11 @@ export class Health extends Stat { @action override update(amount: number) { - super.update(amount); - if (this.value <= 0) { - this.kill(); + if (this.value > 0) { + super.update(amount); + if (this.value <= 0) { + this.kill(); + } } } } diff --git a/src/common/rpg/components/variables.ts b/src/common/rpg/components/variables.ts index 0043399..f11185f 100644 --- a/src/common/rpg/components/variables.ts +++ b/src/common/rpg/components/variables.ts @@ -8,10 +8,6 @@ interface Var { value: RPGVariables[string]; } -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. @@ -20,34 +16,32 @@ interface VariablesState { * (e.g. health, stats, slot definitions). */ @component -export class Variables extends Component { +export class Variables extends Component { constructor() { - super({ vars: {} }); + super({}); } override getVariables() { - return this.state.vars; + return this.state; } @action set({ key, value }: Var) { - const prev = this.state.vars[key]; - this.state.vars[key] = value; + const prev = this.state[key]; + this.state[key] = value; this.emit('set', { key, value, prev }); - return this.state.vars; } @action unset(key: string) { - const prev = this.state.vars[key]; - delete this.state.vars[key]; + const prev = this.state[key]; + delete this.state[key]; this.emit('unset', { key, prev }); - return this.state.vars; } @action - increment({ key, value }: Var) { - const currentValue = this.state.vars[key] ?? 0; + update({ key, value }: Var) { + const currentValue = this.state[key] ?? 0; if (typeof currentValue === 'number' && typeof value === 'number') { this.set({ key, value: currentValue + value }); } else { diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index d519a05..f472110 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -1,5 +1,6 @@ import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators'; import type { RPGActions, RPGVariables } from '../types'; +import { getComponentName } from './registry'; interface EntityEvent { target: Entity; @@ -24,9 +25,9 @@ export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter'); export abstract class Component> { entity!: Entity; - key!: string; private _state!: TState; + private _key!: string | symbol; constructor(state: TState) { this._state = state; @@ -35,8 +36,21 @@ export abstract class Component> { get state(): TState { return this._state; } protected set state(state: TState) { this._state = state; } + get key(): string { + return typeof this._key === 'symbol' + ? getComponentName(this.constructor) ?? this.constructor.name + : this._key; + } + set key(key: string | symbol) { this._key = key; } + protected emit(event: string, data?: unknown): void { - this.entity.emit(`${this.key}.${event}`, data); + const componentKey = this.key; + const componentName = getComponentName(this.constructor); + const key = componentName && componentName !== componentKey + ? `${componentName}(${componentKey})` + : componentKey; + + this.entity.emit(`${key}.${event}`, data); } onAdd(): void { } @@ -84,7 +98,7 @@ export abstract class System { type ComponentFilter = (component: T) => boolean; export class Entity { - readonly #components = new Map(); + readonly #components = new Map(); constructor( readonly id: string, @@ -95,7 +109,15 @@ export class Entity { return { self: this, world: this.world }; } - add>(key: string, component: T): T { + add>(component: T): T; + add>(key: string, component: T): T; + add>(keyOrComponent: string | T, comp?: T): T { + const key = typeof keyOrComponent === 'string' ? keyOrComponent : Symbol(); + const component = (keyOrComponent instanceof Component) ? keyOrComponent : comp; + + if (component == null) { + throw new Error(`Component must be an instance of Component`); + } const existing = this.#components.get(key); if (existing) existing.onRemove(); component.entity = this; @@ -143,20 +165,29 @@ export class Entity { has(key: string): boolean; has>(ctor: Class): boolean; has>(ctor: Class, key: string): boolean; - has>(ctorOrKey: Class | string, key?: string): boolean { - if (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey); - if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey; + has>(ctor: Class, filter: ComponentFilter): boolean; + has>(ctorOrKey: Class | string, key?: string | ComponentFilter): boolean { + if (typeof ctorOrKey === 'string') { + return this.#components.has(ctorOrKey); + } + if (typeof key === 'string') { + return this.#components.get(key) instanceof ctorOrKey; + } for (const c of this.#components.values()) { - if (c instanceof ctorOrKey) return true; + if (!(c instanceof ctorOrKey)) continue; + if (typeof key === 'function' && !key(c)) continue; + + return true; } return false; } remove(key: string): void; - remove>(ctor: Class): void; remove>(component: T): void; + remove>(ctor: Class): void; remove>(ctor: Class, key: string): void; - remove>(ctorOrKey: Class | T | string, key?: string): void { + remove>(ctor: Class, filter: ComponentFilter): void; + remove>(ctorOrKey: Class | T | string, key?: string | ComponentFilter): void { if (typeof ctorOrKey === 'string') { this.#removeByKey(ctorOrKey); return; @@ -165,16 +196,28 @@ export class Entity { this.#removeByKey(ctorOrKey.key); return; } - if (key !== undefined) { + if (typeof key === 'string') { if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key); return; } for (const [k, c] of this.#components) { - if (c instanceof ctorOrKey) { this.#removeByKey(k); return; } + if (!(c instanceof ctorOrKey)) continue; + if (typeof key === 'function' && !key(c)) continue; + + this.#removeByKey(k); return; } } - #removeByKey(key: string): void { + removeAll>(ctor: Class, filter?: ComponentFilter): void { + for (const [k, c] of this.#components) { + if (!(c instanceof ctor)) continue; + if (typeof filter === 'function' && !filter(c)) continue; + + this.#removeByKey(k); return; + } + } + + #removeByKey(key: string | symbol): void { const c = this.#components.get(key); if (c) { c.onRemove(); this.#components.delete(key); } } @@ -205,7 +248,7 @@ export class Entity { /** @internal */ [Symbol.iterator](): IterableIterator<[string, Component]> { - return this.#components.entries(); + return this.#components.values().map(c => [c.key, c]); } /** @internal */ diff --git a/src/common/rpg/utils/variables.ts b/src/common/rpg/utils/variables.ts index 24ae8b4..4caee27 100644 --- a/src/common/rpg/utils/variables.ts +++ b/src/common/rpg/utils/variables.ts @@ -1,6 +1,7 @@ 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"; export function resolveVariables(target: Entity | World): RPGVariables { const result: RPGVariables = {}; @@ -11,7 +12,12 @@ export function resolveVariables(target: Entity | World): RPGVariables { } } } else { - for (const [key, component] of target) { + for (const [componentKey, component] of target) { + const componentName = getComponentName(component.constructor); + const key = componentName && componentName !== componentKey + ? `${componentName}(${componentKey})` + : componentKey; + for (const [varKey, value] of Object.entries(component.getVariables())) { if (value != null) { if (varKey && varKey !== '.') { @@ -56,7 +62,12 @@ export function resolveActions(target: Entity | World): RPGActions { } } } else { - for (const [key, component] of target) { + for (const [componentKey, component] of target) { + const componentName = getComponentName(component.constructor); + const key = componentName && componentName !== componentKey + ? `${componentName}(${componentKey})` + : componentKey; + for (const [actionKey, fn] of Object.entries(component.getActions())) { result[`${key}.${actionKey}`] = fn; } diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index a370d04..3e1ac31 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,41 +1,57 @@ import { World } from "@common/rpg/core/world"; import { Inventory } from "@common/rpg/components/inventory"; -import { Health } from "@common/rpg/components/stat"; +import { Health, Stat } from "@common/rpg/components/stat"; import { Variables } from "@common/rpg/components/variables"; import { QuestLog } from "@common/rpg/components/questLog"; import { QuestSystem } from "@common/rpg/systems/quest"; import { Items } from "@common/rpg/components/item"; import { resolveVariables, resolveActions, executeAction } from "@common/rpg/utils/variables"; 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"; export default async function main() { const world = new World(); world.addSystem(new QuestSystem()); Items.register(world, 'helmet', 'Iron Helmet'); - Items.register(world, 'boots', 'Leather Boots', { maxStack: 2 }); + const boots = Items.register(world, 'boots', 'Leather Boots', { maxStack: 2, equippable: { slotType: 'feet' } }); + boots.add(new Effect({ + targetStat: 'agl', + delta: 10, + })); const player = world.createEntity('player'); - player.add('inventory', new Inventory(['head', 'legs'])); - player.add('health', new Health({value: 100, max: 100})); - player.add('vars', new Variables()); - player.add('quests', new QuestLog([{ + const inventory = player.add(new Inventory()); + player.add(new Equipment('head', { slotName: 'feet', type: 'feet' })); + player.add(new Health({ value: 100, max: 100 })); + player.add(new Variables()); + player.add(new QuestLog([{ id: 'test', description: 'Test quest', title: 'Test', stages: [], }])); + player.add('str', new Stat({ value: 100 })); + player.add('agl', new Stat({ value: 100 })); + + Factions.join(player, 'boobs'); + Factions.adjustReputation(player, 'guards', 10); + Factions.adjustReputation(player, 'bandits', -10); console.log(resolveVariables(world)); - player.get(Inventory)?.add({ itemId: 'helmet', amount: 1, slotId: 'head' }); + inventory.add({ itemId: 'helmet', amount: 1 }); + inventory.add({ itemId: 'boots', amount: 1 }); + inventory.equip('boots'); const vars = player.get(Variables)!; vars.set({ key: 'test', value: 'test' }); - await executeAction('player.quests.test.start', world); + await executeAction('player.QuestLog.test.start', world); console.log(resolveActions(world)); console.log(resolveVariables(world)); - console.log(Serialization.serialize(world)); + console.log(JSON.parse(Serialization.serialize(world))); } diff --git a/test/common/rpg/cooldown.test.ts b/test/common/rpg/cooldown.test.ts index 10d81d7..6317342 100644 --- a/test/common/rpg/cooldown.test.ts +++ b/test/common/rpg/cooldown.test.ts @@ -13,15 +13,15 @@ describe('Cooldown — initial state', () => { it('starts not ready', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(5)); - expect(e.get(Cooldown, 'cd')!.ready).toBeFalse(); + e.add(new Cooldown(5)); + expect(e.get(Cooldown)!.ready).toBeFalse(); }); it('remaining equals duration on creation', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(3)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(3)); + const cd = e.get(Cooldown)!; expect(cd.state.remaining).toBe(3); expect(cd.state.duration).toBe(3); }); @@ -31,8 +31,8 @@ describe('Cooldown — clear()', () => { it('marks as ready immediately', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(10)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(10)); + const cd = e.get(Cooldown)!; cd.clear(); expect(cd.ready).toBeTrue(); }); @@ -40,21 +40,21 @@ describe('Cooldown — clear()', () => { it("emits 'ready' event", () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(10)); + e.add(new Cooldown(10)); const events: unknown[] = []; - e.on('cd.ready', () => events.push(true)); - e.get(Cooldown, 'cd')!.clear(); + e.on('Cooldown.ready', () => events.push(true)); + e.get(Cooldown)!.clear(); expect(events.length).toBe(1); }); it('is no-op when already ready', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(1)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(1)); + const cd = e.get(Cooldown)!; cd.clear(); const events: unknown[] = []; - e.on('cd.ready', () => events.push(true)); + e.on('Cooldown.ready', () => events.push(true)); cd.clear(); expect(events.length).toBe(0); }); @@ -64,8 +64,8 @@ describe('Cooldown — reset()', () => { it('resets remaining to duration', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(5)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(5)); + const cd = e.get(Cooldown)!; cd.clear(); cd.reset(); expect(cd.ready).toBeFalse(); @@ -75,8 +75,8 @@ describe('Cooldown — reset()', () => { it('reset(duration) changes duration and remaining', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(5)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(5)); + const cd = e.get(Cooldown)!; cd.reset(10); expect(cd.state.duration).toBe(10); expect(cd.state.remaining).toBe(10); @@ -87,8 +87,8 @@ describe('Cooldown — update(dt)', () => { it('counts down remaining', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(5)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(5)); + const cd = e.get(Cooldown)!; cd.update(2); expect(cd.state.remaining).toBe(3); expect(cd.ready).toBeFalse(); @@ -97,8 +97,8 @@ describe('Cooldown — update(dt)', () => { it('becomes ready when remaining reaches zero', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(3)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(3)); + const cd = e.get(Cooldown)!; cd.update(3); expect(cd.ready).toBeTrue(); }); @@ -106,18 +106,18 @@ describe('Cooldown — update(dt)', () => { it("emits 'ready' when countdown completes", () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(2)); + e.add(new Cooldown(2)); const events: unknown[] = []; - e.on('cd.ready', () => events.push(true)); - e.get(Cooldown, 'cd')!.update(2); + e.on('Cooldown.ready', () => events.push(true)); + e.get(Cooldown)!.update(2); expect(events.length).toBe(1); }); it('does not go below zero', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(1)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(1)); + const cd = e.get(Cooldown)!; cd.update(100); expect(cd.state.remaining).toBe(0); }); @@ -125,11 +125,11 @@ describe('Cooldown — update(dt)', () => { it('is no-op when already ready', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(1)); - const cd = e.get(Cooldown, 'cd')!; + e.add(new Cooldown(1)); + const cd = e.get(Cooldown)!; cd.clear(); const events: unknown[] = []; - e.on('cd.ready', () => events.push(true)); + e.on('Cooldown.ready', () => events.push(true)); cd.update(1); expect(events.length).toBe(0); }); @@ -139,31 +139,31 @@ describe('CooldownSystem', () => { it('drives all cooldowns each tick', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(2)); + e.add(new Cooldown(2)); w.update(1); - expect(e.get(Cooldown, 'cd')!.state.remaining).toBe(1); + expect(e.get(Cooldown)!.state.remaining).toBe(1); }); it('marks cooldown ready after enough ticks', () => { const w = world(); const e = w.createEntity(); - e.add('cd', new Cooldown(2)); + e.add(new Cooldown(2)); const events: unknown[] = []; - e.on('cd.ready', () => events.push(true)); + e.on('Cooldown.ready', () => events.push(true)); w.update(1); w.update(1); expect(events.length).toBe(1); - expect(e.get(Cooldown, 'cd')!.ready).toBeTrue(); + expect(e.get(Cooldown)!.ready).toBeTrue(); }); it('handles multiple cooldowns on different entities', () => { const w = world(); const a = w.createEntity('a'); const b = w.createEntity('b'); - a.add('cd', new Cooldown(1)); - b.add('cd', new Cooldown(3)); + a.add(new Cooldown(1)); + b.add(new Cooldown(3)); w.update(2); - expect(a.get(Cooldown, 'cd')!.ready).toBeTrue(); - expect(b.get(Cooldown, 'cd')!.ready).toBeFalse(); + expect(a.get(Cooldown)!.ready).toBeTrue(); + expect(b.get(Cooldown)!.ready).toBeFalse(); }); }); diff --git a/test/common/rpg/effect.test.ts b/test/common/rpg/effect.test.ts index 9e9ef56..6a1bf42 100644 --- a/test/common/rpg/effect.test.ts +++ b/test/common/rpg/effect.test.ts @@ -69,9 +69,9 @@ describe('Effect — duration', () => { it('emits expired before removal', () => { const { w, e } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 1, duration: 1 })); + e.add(new Effect({ targetStat: 'str', delta: 1, duration: 1 })); const events: string[] = []; - e.on('fx.expired', () => events.push('expired')); + e.on('Effect.expired', () => events.push('expired')); w.update(1); expect(events).toEqual(['expired']); }); diff --git a/test/common/rpg/equipment.test.ts b/test/common/rpg/equipment.test.ts index dc3c588..a2c9546 100644 --- a/test/common/rpg/equipment.test.ts +++ b/test/common/rpg/equipment.test.ts @@ -12,10 +12,10 @@ function makeSword(w: World, id = 'sword') { return sword; } -function makePlayer(w: World, slots: ConstructorParameters[0] = [{ slotName: 'weapon', type: 'weapon' }]) { +function makePlayer(w: World, slots: ConstructorParameters[0] = { slotName: 'weapon', type: 'weapon' }) { const player = w.createEntity('player'); player.add('str', new Stat({ value: 10 })); - player.add('equipment', new Equipment(slots)); + player.add(new Equipment(slots)); return player; } @@ -61,7 +61,7 @@ describe('Equipment — equip', () => { const w = world(); makeSword(w); const player = w.createEntity('player'); - player.add('equipment', new Equipment(['slot1'])); // generic slot + player.add('equipment', new Equipment('slot1')); // generic slot expect(player.get(Equipment)!.equip({ slotName: 'slot1', itemId: 'sword' })).toBeTrue(); }); @@ -81,7 +81,7 @@ describe('Equipment — equip', () => { makeSword(w); const player = makePlayer(w); const events: unknown[] = []; - player.on('equipment.equip', ({ data }) => events.push(data)); + player.on('Equipment.equip', ({ data }) => events.push(data)); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]); }); @@ -165,7 +165,7 @@ describe('Equipment — unequip', () => { const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword' }); const events: unknown[] = []; - player.on('equipment.unequip', ({ data }) => events.push(data)); + player.on('Equipment.unequip', ({ data }) => events.push(data)); eq.unequip('weapon'); expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]); }); @@ -177,10 +177,10 @@ describe('Equipment — queries', () => { makeSword(w, 'sword1'); makeSword(w, 'sword2'); const player = w.createEntity('player'); - player.add('equipment', new Equipment([ + player.add('equipment', new Equipment( { slotName: 'main', type: 'weapon' }, { slotName: 'off', type: 'weapon' }, - ])); + )); const eq = player.get(Equipment)!; eq.equip({ slotName: 'main', itemId: 'sword1' }); eq.equip({ slotName: 'off', itemId: 'sword2' }); @@ -192,10 +192,10 @@ describe('Equipment — queries', () => { it('findCompatibleSlot prefers typed slot over generic', () => { const w = world(); const player = w.createEntity('player'); - player.add('equipment', new Equipment([ + player.add('equipment', new Equipment( 'generic', { slotName: 'weapon', type: 'weapon' }, - ])); + )); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBe('weapon'); }); @@ -203,7 +203,7 @@ describe('Equipment — queries', () => { it('findCompatibleSlot falls back to generic slot', () => { const w = world(); const player = w.createEntity('player'); - player.add('equipment', new Equipment(['generic'])); + player.add('equipment', new Equipment('generic')); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBe('generic'); }); @@ -211,7 +211,7 @@ describe('Equipment — queries', () => { it('findCompatibleSlot returns null when no compatible slot', () => { const w = world(); const player = w.createEntity('player'); - player.add('equipment', new Equipment([{ slotName: 'armor', type: 'armor' }])); + player.add('equipment', new Equipment({ slotName: 'armor', type: 'armor' })); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBeNull(); }); diff --git a/test/common/rpg/experience.test.ts b/test/common/rpg/experience.test.ts index 40e5d1f..fc0a475 100644 --- a/test/common/rpg/experience.test.ts +++ b/test/common/rpg/experience.test.ts @@ -47,7 +47,7 @@ describe('Experience — award XP (array spec)', () => { it("emits 'levelup' with prev and new level", () => { const { e, xp } = withXp([100]); const events: unknown[] = []; - e.on('xp.levelup', ({ data }) => events.push(data)); + e.on('Experience(xp).levelup', ({ data }) => events.push(data)); xp.award(100); expect(events).toEqual([{ level: 2, prev: 1 }]); }); @@ -62,7 +62,7 @@ describe('Experience — award XP (array spec)', () => { it("emits 'levelup' for each level gained", () => { const { e, xp } = withXp([100, 200]); const events: unknown[] = []; - e.on('xp.levelup', ({ data }) => events.push(data)); + e.on('Experience(xp).levelup', ({ data }) => events.push(data)); xp.award(300); // gains 2 levels expect(events.length).toBe(2); expect((events[0] as any).level).toBe(2); diff --git a/test/common/rpg/faction.test.ts b/test/common/rpg/faction.test.ts index d1fd167..e2dd7a6 100644 --- a/test/common/rpg/faction.test.ts +++ b/test/common/rpg/faction.test.ts @@ -39,7 +39,7 @@ describe('FactionMember', () => { const w = world(); const e = w.createEntity('player'); Factions.join(e, 'guards'); - expect(resolveVariables(w)['player.faction.guards.member']).toBeTrue(); + expect(resolveVariables(w)['player.FactionMember.guards']).toBeTrue(); }); }); @@ -85,7 +85,7 @@ describe('Reputation', () => { const w = world(); const e = w.createEntity(); Factions.setReputation(e, 'guards', 10); - const comp = e.get(Reputation, 'faction.guards.rep')!; + const comp = e.get(Reputation, (c) => c.factionId == 'guards')!; comp.adjust(30); expect(comp.score).toBe(40); }); @@ -94,7 +94,7 @@ describe('Reputation', () => { const w = world(); const e = w.createEntity('player'); Factions.setReputation(e, 'guards', 50); - expect(resolveVariables(w)['player.faction.guards.rep']).toBe(50); + expect(resolveVariables(w)['player.Reputation.guards']).toBe(50); }); }); diff --git a/test/common/rpg/inventory.test.ts b/test/common/rpg/inventory.test.ts index a3c5338..2497f8a 100644 --- a/test/common/rpg/inventory.test.ts +++ b/test/common/rpg/inventory.test.ts @@ -241,7 +241,7 @@ describe('Inventory — use', () => { player.add('str', new Stat({ value: 10 })); player.add('inv', new Inventory()); Items.register(w, 'potion', 'Health Potion', { - usable: { actions: [{ type: 'str.update', arg: 5 }], consumeOnUse: false }, + usable: { actions: [{ type: 'Stat(str).update', arg: 5 }], consumeOnUse: false }, }); player.get(Inventory)!.add({ itemId: 'potion', amount: 1 }); player.get(Inventory)!.use({ itemId: 'potion' }); diff --git a/test/common/rpg/quest.test.ts b/test/common/rpg/quest.test.ts index 8727b86..88b99cf 100644 --- a/test/common/rpg/quest.test.ts +++ b/test/common/rpg/quest.test.ts @@ -22,7 +22,7 @@ function simpleQuest(id = 'q1', actions: Quest['stages'][0]['actions'] = []): Qu stages: [{ id: 'stage0', description: 'Do the thing', - objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }], + objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], actions, }], }; @@ -38,13 +38,13 @@ function twoStageQuest(id = 'q2'): Quest { { id: 'stage0', description: 'Step 1', - objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'vars.step >= 1' }], + objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'Variables(vars).step >= 1' }], actions: [], }, { id: 'stage1', description: 'Step 2', - objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'vars.step >= 2' }], + objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables(vars).step >= 2' }], actions: [], }, ], @@ -54,7 +54,7 @@ function twoStageQuest(id = 'q2'): Quest { function makePlayer(w: World, quests: Quest[] = []) { const player = w.createEntity('player'); const vars = player.add('vars', new Variables()); - const questLog = player.add('questLog', new QuestLog(quests)); + const questLog = player.add(new QuestLog(quests)); return { player, vars, questLog }; } @@ -170,7 +170,7 @@ describe('QuestLog — events', () => { const w = world(); const { player, questLog } = makePlayer(w, [simpleQuest()]); const events: unknown[] = []; - player.on('questLog.started', ({ data }) => events.push(data)); + player.on('QuestLog.started', ({ data }) => events.push(data)); questLog.start('q1'); expect(events).toEqual([{ questId: 'q1' }]); }); @@ -179,7 +179,7 @@ describe('QuestLog — events', () => { const w = world(); const { player, questLog } = makePlayer(w, [simpleQuest()]); const events: unknown[] = []; - player.on('questLog.completed', ({ data }) => events.push(data)); + player.on('QuestLog.completed', ({ data }) => events.push(data)); questLog.start('q1'); questLog.complete('q1'); expect(events).toEqual([{ questId: 'q1' }]); @@ -189,7 +189,7 @@ describe('QuestLog — events', () => { const w = world(); const { player, questLog } = makePlayer(w, [simpleQuest()]); const events: unknown[] = []; - player.on('questLog.failed', ({ data }) => events.push(data)); + player.on('QuestLog.failed', ({ data }) => events.push(data)); questLog.start('q1'); questLog.fail('q1'); expect(events).toEqual([{ questId: 'q1' }]); @@ -199,7 +199,7 @@ describe('QuestLog — events', () => { const w = world(); const { player, questLog } = makePlayer(w, [simpleQuest()]); const events: unknown[] = []; - player.on('questLog.abandoned', ({ data }) => events.push(data)); + player.on('QuestLog.abandoned', ({ data }) => events.push(data)); questLog.start('q1'); questLog.abandon('q1'); expect(events).toEqual([{ questId: 'q1' }]); @@ -254,14 +254,14 @@ describe('QuestLog — availability', () => { it('quest with unsatisfied condition is not available', () => { const w = world(); - const quest: Quest = { ...simpleQuest(), conditions: ['vars.unlocked == true'] }; + const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).unlocked == true'] }; const { player, questLog } = makePlayer(w, [quest]); expect(questLog.isAvailable('q1', player.context)).toBeFalse(); }); it('quest with satisfied condition is available', () => { const w = world(); - const quest: Quest = { ...simpleQuest(), conditions: ['vars.unlocked == true'] }; + const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).unlocked == true'] }; const { player, vars, questLog } = makePlayer(w, [quest]); vars.set({ key: 'unlocked', value: true }); expect(questLog.isAvailable('q1', player.context)).toBeTrue(); @@ -315,7 +315,7 @@ describe('QuestLog — _advance', () => { const w = world(); const { player, questLog } = makePlayer(w, [twoStageQuest()]); const events: unknown[] = []; - player.on('questLog.stage', ({ data }) => events.push(data)); + player.on('QuestLog.stage', ({ data }) => events.push(data)); questLog.start('q2'); questLog._advance('q2'); expect((events[0] as any).questId).toBe('q2'); @@ -334,7 +334,7 @@ describe('QuestLog — _advance', () => { const w = world(); const { player, questLog } = makePlayer(w, [simpleQuest()]); const events: unknown[] = []; - player.on('questLog.completed', ({ data }) => events.push(data)); + player.on('QuestLog.completed', ({ data }) => events.push(data)); questLog.start('q1'); questLog._advance('q1'); expect(events).toEqual([{ questId: 'q1' }]); @@ -357,8 +357,8 @@ describe('Quests.validate', () => { }); it('passes when action type is in the known actions list', () => { - const quest = simpleQuest('q', [{ type: 'vars.set' }]); - const errors = Quests.validate(quest, ['vars.set']); + const quest = simpleQuest('q', [{ type: 'Variables(vars).set' }]); + const errors = Quests.validate(quest, ['Variables(vars).set']); expect(errors).toHaveLength(0); }); @@ -405,7 +405,7 @@ describe('QuestSystem — objective completion', () => { it('runs stage actions before advancing', () => { const w = world(); // action sets vars.reward = true on the player entity - const quest = simpleQuest('q1', [{ type: 'vars.set', arg: { key: 'reward', value: true } }]); + const quest = simpleQuest('q1', [{ type: 'Variables(vars).set', arg: { key: 'reward', value: true } }]); const { vars, questLog } = makePlayer(w, [quest]); questLog.start('q1'); @@ -413,7 +413,7 @@ describe('QuestSystem — objective completion', () => { w.update(1); expect(questLog.getState('q1')?.status).toBe('completed'); - expect(vars.state.vars['reward']).toBe(true); + expect(vars.state.reward).toBe(true); }); }); @@ -462,9 +462,9 @@ describe('QuestSystem — fail conditions', () => { stages: [{ id: 'stage0', description: 'Do it', - objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }], + objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], actions: [], - failConditions: ['vars.failed == true'], + failConditions: ['Variables(vars).failed == true'], }], }; const { vars, questLog } = makePlayer(w, [quest]); @@ -485,9 +485,9 @@ describe('QuestSystem — fail conditions', () => { stages: [{ id: 'stage0', description: 'Both', - objectives: [{ id: 'obj', description: 'Done?', condition: 'vars.done == true' }], + objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], actions: [], - failConditions: ['vars.done == true'], // same condition + failConditions: ['Variables(vars).done == true'], // same condition }], }; const { vars, questLog } = makePlayer(w, [quest]); @@ -508,11 +508,11 @@ describe('QuestSystem — multiple quests', () => { const q1: Quest = { id: 'q1', title: 'Q1', description: '', - stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'vars.done1 == true' }], actions: [] }], + stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).done1 == true' }], actions: [] }], }; const q2: Quest = { id: 'q2', title: 'Q2', description: '', - stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'vars.done2 == true' }], actions: [] }], + stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).done2 == true' }], actions: [] }], }; const log = player.add('questLog', new QuestLog([q1, q2])); diff --git a/test/common/rpg/stat.test.ts b/test/common/rpg/stat.test.ts index d5ed17d..02bc12d 100644 --- a/test/common/rpg/stat.test.ts +++ b/test/common/rpg/stat.test.ts @@ -116,7 +116,7 @@ describe('Stat — value / base / modifiers', () => { e.add('s', new Stat({ value: 10 })); const s = e.get(Stat, 's')!; const events: unknown[] = []; - e.on('s.set', ({ data }) => events.push(data)); + e.on('Stat(s).set', ({ data }) => events.push(data)); s.set(20); expect(events).toEqual([{ prev: 10, value: 20 }]); }); @@ -127,7 +127,7 @@ describe('Stat — value / base / modifiers', () => { e.add('s', new Stat({ value: 10, min: 0 })); const s = e.get(Stat, 's')!; const events: unknown[] = []; - e.on('s.set', ({ data }) => events.push(data)); + e.on('Stat(s).set', ({ data }) => events.push(data)); s.set(-100); // clamped to 0, still changes s.set(-200); // still 0, no change expect(events.length).toBe(1); @@ -150,7 +150,7 @@ describe('Health', () => { e.add('health', new Health({ value: 10, min: 0 })); const h = e.get(Health)!; const killed: unknown[] = []; - e.on('health.killed', () => killed.push(true)); + e.on('Health(health).killed', () => killed.push(true)); h.update(-10); expect(killed.length).toBe(1); expect(h.value).toBe(0); @@ -162,7 +162,7 @@ describe('Health', () => { e.add('health', new Health({ value: 50, min: 0 })); const h = e.get(Health)!; const killed: unknown[] = []; - e.on('health.killed', () => killed.push(true)); + e.on('Health(health).killed', () => killed.push(true)); h.kill(); expect(killed.length).toBe(1); expect(h.value).toBe(0); @@ -174,7 +174,19 @@ describe('Health', () => { e.add('health', new Health({ value: 10, min: 0 })); const h = e.get(Health)!; const killed: unknown[] = []; - e.on('health.killed', () => killed.push(true)); + e.on('Health(health).killed', () => killed.push(true)); + h.update(-999); + expect(killed.length).toBe(1); + }); + + it('kill() does not emit killed twice for already killed entity', () => { + const w = world(); + const e = w.createEntity(); + e.add('health', new Health({ value: 10, min: 0 })); + const h = e.get(Health)!; + const killed: unknown[] = []; + e.on('Health(health).killed', () => killed.push(true)); + h.update(-999); h.update(-999); expect(killed.length).toBe(1); });