diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index d87ceb7..ff6dd2c 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -1,30 +1,24 @@ import { Component, type EvalContext } from "../core/world"; import { evaluateCondition } from "../utils/conditions"; -import { action, component, variable } from "../utils/decorators"; +import { action, component, ComponentTag, tag, variable } from "../utils/decorators"; import { Stat } from "./stat"; -@component -export class Effect extends Component<{ +abstract class BaseEffect extends Component<{ targetStat: string; // component key, e.g. 'health' targetField: 'value' | 'max' | 'min'; delta: number; duration: number | null; // null = permanent until removed remaining: number | null; // countdown in seconds; null for condition-based/permanent condition: string | null; // keep effect while true; remove when it becomes false - scope: 'equip' | 'onHit'; // 'equip' = live modifier on owner; 'onHit' = template applied to attack target stacking: 'stack' | 'unique' | 'replace'; tag: string | null; // discriminator for stacking; null = no stacking enforcement }> { - /** True while the effect's delta is applied to the target stat. */ - @variable('.') active: boolean = false; - constructor(opts: { targetStat: string; delta: number; targetField?: 'value' | 'max' | 'min'; duration?: number; condition?: string; - scope?: 'equip' | 'onHit'; stacking?: 'stack' | 'unique' | 'replace'; tag?: string; }) { @@ -35,15 +29,19 @@ export class Effect extends Component<{ duration: opts.duration ?? null, remaining: opts.duration ?? null, condition: opts.condition ?? null, - scope: opts.scope ?? 'equip', stacking: opts.stacking ?? 'stack', tag: opts.tag ?? null, }); } +} + +@tag(ComponentTag.Equippable) +@component +export class Effect extends BaseEffect { + /** True while the effect's delta is applied to the target stat. */ + @variable('.') active: boolean = false; override onAdd(): void { - if (this.state.scope === 'onHit') return; - const { stacking, tag } = this.state; if (tag != null && stacking !== 'stack') { @@ -95,7 +93,6 @@ export class Effect extends Component<{ } update(dt: number, ctx: EvalContext): void { - if (this.state.scope === 'onHit') return; if (this.state.remaining != null) { this.state.remaining -= dt; if (this.state.remaining <= 0) { @@ -110,3 +107,7 @@ export class Effect extends Component<{ // permanent effect (no duration, no condition): nothing to do } } + +@component +export class EffectTemplate extends BaseEffect { +} \ No newline at end of file diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts index 17ba671..f8b0e59 100644 --- a/src/common/rpg/components/equipment.ts +++ b/src/common/rpg/components/equipment.ts @@ -1,6 +1,6 @@ import { Component } from "../core/world"; import type { RPGVariables } from "../types"; -import { action, component } from "../utils/decorators"; +import { action, component, ComponentTag, getComponentTags } from "../utils/decorators"; import { Effect } from "./effect"; // ── Equippable ──────────────────────────────────────────────────────────────── @@ -114,7 +114,7 @@ export class Equipment extends Component { let id = 0; for (const [key, component] of itemEntity) { - if (!(component instanceof Effect) || component.state.scope === 'onHit') continue; + if (!getComponentTags(component).has(ComponentTag.Equippable)) continue; const effectKey = `__equip_${slotName}_${key}_${id++}`; this.entity.clone(effectKey, component); diff --git a/src/common/rpg/systems/combat.ts b/src/common/rpg/systems/combat.ts index 9476d8e..80c5d1d 100644 --- a/src/common/rpg/systems/combat.ts +++ b/src/common/rpg/systems/combat.ts @@ -1,5 +1,5 @@ import { Attacked, Damage, Defense } from "../components/combat"; -import { Effect } from "../components/effect"; +import { Effect, EffectTemplate } from "../components/effect"; import { Health } from "../components/stat"; import { System, World } from "../core/world"; @@ -51,20 +51,21 @@ export class CombatSystem extends System { // Apply on-hit effects from source onto target for (const [key, component] of source) { - if (!(component instanceof Effect) || component.state.scope !== 'onHit') continue; - const s = component.state; - target.add( - `__hit_${source.id}_${key}_${hitEffectCounter++}`, - new Effect({ - targetStat: s.targetStat, - delta: s.delta, - targetField: s.targetField, - duration: s.duration ?? undefined, - condition: s.condition ?? undefined, - stacking: s.stacking, - tag: s.tag ?? undefined, - }), - ); + if (component instanceof EffectTemplate) { + const s = component.state; + target.add( + `__hit_${source.id}_${key}_${hitEffectCounter++}`, + new Effect({ + targetStat: s.targetStat, + delta: s.delta, + targetField: s.targetField, + duration: s.duration ?? undefined, + condition: s.condition ?? undefined, + stacking: s.stacking, + tag: s.tag ?? undefined, + }), + ); + } } damageSum += damageAmount; diff --git a/src/common/rpg/utils/decorators.ts b/src/common/rpg/utils/decorators.ts index fc4f2cd..198b5fd 100644 --- a/src/common/rpg/utils/decorators.ts +++ b/src/common/rpg/utils/decorators.ts @@ -61,22 +61,44 @@ interface MigrationEntry { fn: MigrationFn; } +export enum ComponentTag { + /** Equipment system clones component with this tag onto the owner when equipped. */ + Equippable, +} + const registry = new Map>(); -const reverseRegistry = new Map, string>(); +const nameRegistry = new Map, string>(); +const tagsRegistry = new Map, Set>(); /** 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); + nameRegistry.set(ctor, name); +} + +function registerTags(ctor: ComponentConstructor, tags: Iterable): void { + const set = tagsRegistry.get(ctor) ?? new Set(); + for (const tag of tags) { + set.add(tag); + } + tagsRegistry.set(ctor, set); } export function getComponentMeta(name: string): ComponentMeta | undefined { return registry.get(name); } -export function getComponentName(ctor: Function): string | undefined { - return reverseRegistry.get(ctor as ComponentConstructor); +export function getComponentName(ctor: Function | Component): string | undefined { + return nameRegistry.get( + (typeof ctor === 'function' ? ctor : ctor.constructor) as ComponentConstructor + ); +} + +export function getComponentTags(ctor: Function | Component): Set { + return new Set(tagsRegistry.get( + (typeof ctor === 'function' ? ctor : ctor.constructor) as ComponentConstructor + ) ?? []); } /** @@ -152,3 +174,9 @@ export function component( // Used as bare @component register(String(ctx!.name), nameOrTargetOrOptions, 0); } + +export function tag(...tags: ComponentTag[]): ComponentDecorator { + return (target: ComponentConstructor) => { + registerTags(target, tags); + } +} diff --git a/test/common/rpg/combat.test.ts b/test/common/rpg/combat.test.ts index bc8d5fb..4e0e403 100644 --- a/test/common/rpg/combat.test.ts +++ b/test/common/rpg/combat.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect } from 'bun:test'; -import { World } from '@common/rpg/core/world'; +import { Attacked, Damage, Defense } from '@common/rpg/components/combat'; +import { Effect, EffectTemplate } from '@common/rpg/components/effect'; import { Health, Stat } from '@common/rpg/components/stat'; -import { Damage, Defense, Attacked } from '@common/rpg/components/combat'; -import { Effect } from '@common/rpg/components/effect'; +import { World } from '@common/rpg/core/world'; import { CombatSystem } from '@common/rpg/systems/combat'; import { EffectSystem } from '@common/rpg/systems/effect'; +import { describe, expect, it } from 'bun:test'; function world() { const w = new World(); @@ -147,7 +147,7 @@ describe('CombatSystem — on-hit effects', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); - sword.add('burn', new Effect({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10, scope: 'onHit' })); + sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10 })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); @@ -162,7 +162,7 @@ describe('CombatSystem — on-hit effects', () => { const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add('str', new Stat({ value: 50 })); - sword.add('drain', new Effect({ targetStat: 'str', delta: -99, targetField: 'value', scope: 'onHit' })); + sword.add('drain', new EffectTemplate({ targetStat: 'str', delta: -99, targetField: 'value' })); expect(sword.get(Stat, 'str')!.value).toBe(50); }); @@ -170,7 +170,7 @@ describe('CombatSystem — on-hit effects', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); - sword.add('burn', new Effect({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2, scope: 'onHit' })); + sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2 })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); @@ -239,7 +239,7 @@ describe('Effect stacking', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 0, damageType: 'physical' })); - sword.add('burn', new Effect({ targetStat: 'health', delta: -5, duration: 10, scope: 'onHit', stacking: 'unique', tag: 'burn' })); + sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, duration: 10, stacking: 'unique', tag: 'burn' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); diff --git a/test/common/rpg/effect.test.ts b/test/common/rpg/effect.test.ts index 6a1bf42..94ecc0c 100644 --- a/test/common/rpg/effect.test.ts +++ b/test/common/rpg/effect.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { World } from '@common/rpg/core/world'; import { Stat } from '@common/rpg/components/stat'; -import { Effect } from '@common/rpg/components/effect'; +import { Effect, EffectTemplate } from '@common/rpg/components/effect'; import { EffectSystem } from '@common/rpg/systems/effect'; function world() { @@ -117,28 +117,22 @@ describe('Effect — permanent', () => { describe('Effect — scope: onHit', () => { it('onAdd is a no-op for onHit scope', () => { const { e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); + e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 })); expect(stat.value).toBe(10); }); it('onRemove is a no-op for onHit scope', () => { const { e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); + e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 })); e.remove('fx'); expect(stat.value).toBe(10); }); it('EffectSystem does not tick or remove onHit effects', () => { const { w, e } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1, scope: 'onHit' })); + e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 5, duration: 1 })); w.update(10); - expect(e.has(Effect)).toBeTrue(); - }); - - it('active stays false for onHit scope', () => { - const { e } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5, scope: 'onHit' })); - expect(e.get(Effect, 'fx')!.active).toBeFalse(); + expect(e.has(EffectTemplate)).toBeTrue(); }); }); diff --git a/test/common/rpg/equipment.test.ts b/test/common/rpg/equipment.test.ts index a2c9546..092e107 100644 --- a/test/common/rpg/equipment.test.ts +++ b/test/common/rpg/equipment.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { World } from '@common/rpg/core/world'; import { Stat } from '@common/rpg/components/stat'; -import { Effect } from '@common/rpg/components/effect'; +import { Effect, EffectTemplate } from '@common/rpg/components/effect'; import { Equipment, Equippable } from '@common/rpg/components/equipment'; function world() { return new World(); } @@ -100,7 +100,7 @@ describe('Equipment — equip-scope effects', () => { it('does NOT clone onHit-scope Effect onto owner on equip', () => { const w = world(); const sword = makeSword(w); - sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); + sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected @@ -121,7 +121,7 @@ describe('Equipment — equip-scope effects', () => { const w = world(); const sword = makeSword(w); sword.add('passive', new Effect({ targetStat: 'str', delta: 5 })); - sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); + sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15);