From 561ffb1d7ecb8be3807d35400985b719c582b82e Mon Sep 17 00:00:00 2001 From: Pabloader Date: Wed, 29 Apr 2026 18:12:40 +0000 Subject: [PATCH] Add stacking rules to effect --- src/common/rpg/TODO.md | 4 -- src/common/rpg/components/effect.ts | 58 +++++++++++++++------- src/common/rpg/systems/combat.ts | 10 +++- src/common/rpg/systems/effect.ts | 2 +- test/common/rpg/combat.test.ts | 74 +++++++++++++++++++++++++++-- test/common/rpg/effect.test.ts | 38 +++++++-------- test/common/rpg/equipment.test.ts | 14 +++--- 7 files changed, 148 insertions(+), 52 deletions(-) diff --git a/src/common/rpg/TODO.md b/src/common/rpg/TODO.md index 0eef0a7..ffec779 100644 --- a/src/common/rpg/TODO.md +++ b/src/common/rpg/TODO.md @@ -11,10 +11,6 @@ definition registry (neutral/friendly/hostile thresholds) pairs with this. ## Deferred Combat Features -### Effect stacking rules -Add `stack` (default) / `unique` / `replace` modes with a tag discriminator to -`Effect`. Prevents e.g. multiple poison stacks when the design calls for one. - ### Damage modifiers Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on the attacker/source that CombatSystem folds into the final damage value. diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index 2b8f193..19bf917 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -13,31 +13,56 @@ export class Effect extends Component<{ 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( - targetStat: string, - delta: number, - targetField?: 'value' | 'max' | 'min', - duration?: number, - condition?: string, - scope?: 'equip' | 'onHit', - ) { + constructor(opts: { + targetStat: string; + delta: number; + targetField?: 'value' | 'max' | 'min'; + duration?: number; + condition?: string; + scope?: 'equip' | 'onHit'; + stacking?: 'stack' | 'unique' | 'replace'; + tag?: string; + }) { super({ - targetStat, - targetField: targetField ?? 'value', - delta, - duration: duration ?? null, - remaining: duration ?? null, - condition: condition ?? null, - scope: scope ?? 'equip', + targetStat: opts.targetStat, + targetField: opts.targetField ?? 'value', + delta: opts.delta, + duration: opts.duration ?? null, + remaining: opts.duration ?? null, + condition: opts.condition ?? null, + scope: opts.scope ?? 'equip', + stacking: opts.stacking ?? 'stack', + tag: opts.tag ?? null, }); } override onAdd(): void { if (this.state.scope === 'onHit') return; + + const { stacking, tag } = this.state; + + if (tag != null && stacking !== 'stack') { + const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag); + + if (stacking === 'unique' && siblings.length > 0) { + // An effect with this tag is already active — discard the incoming one. + this.entity.remove(this.key); + return; + } + + if (stacking === 'replace') { + for (const old of siblings) { + this.entity.remove(old.key); + } + } + } + const stat = this.entity.get(Stat, this.state.targetStat); if (stat) { stat.applyModifier(this.state.delta, this.state.targetField); @@ -46,7 +71,7 @@ export class Effect extends Component<{ } override onRemove(): void { - if (this.state.scope === 'onHit') return; + if (!this.active) return; const stat = this.entity.get(Stat, this.state.targetStat); if (stat) { stat.removeModifier(this.state.delta, this.state.targetField); @@ -67,7 +92,6 @@ export class Effect extends Component<{ @action clear(): void { this.state.remaining = 0; - this.active = false; this.emit('expired'); } diff --git a/src/common/rpg/systems/combat.ts b/src/common/rpg/systems/combat.ts index 51d6992..9476d8e 100644 --- a/src/common/rpg/systems/combat.ts +++ b/src/common/rpg/systems/combat.ts @@ -55,7 +55,15 @@ export class CombatSystem extends System { const s = component.state; target.add( `__hit_${source.id}_${key}_${hitEffectCounter++}`, - new Effect(s.targetStat, s.delta, s.targetField, s.duration ?? undefined, s.condition ?? undefined), + 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, + }), ); } diff --git a/src/common/rpg/systems/effect.ts b/src/common/rpg/systems/effect.ts index 928f885..411ffd0 100644 --- a/src/common/rpg/systems/effect.ts +++ b/src/common/rpg/systems/effect.ts @@ -7,7 +7,7 @@ export class EffectSystem extends System { for (const [entity, key, effect] of world.query(Effect)) { effect.update(dt, entity.context); - if (!effect.active && effect.state.scope !== 'onHit') { + if (effect.state.remaining !== null && effect.state.remaining <= 0) { expired.push([entity, key]); } } diff --git a/test/common/rpg/combat.test.ts b/test/common/rpg/combat.test.ts index 7bbb757..bc8d5fb 100644 --- a/test/common/rpg/combat.test.ts +++ b/test/common/rpg/combat.test.ts @@ -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('health', -5, 'value', 10, undefined, 'onHit')); + sword.add('burn', new Effect({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10, scope: 'onHit' })); 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('str', -99, 'value', undefined, undefined, 'onHit')); + sword.add('drain', new Effect({ targetStat: 'str', delta: -99, targetField: 'value', scope: 'onHit' })); 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('health', -10, 'value', 2, undefined, 'onHit')); + sword.add('burn', new Effect({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2, scope: 'onHit' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); @@ -183,6 +183,74 @@ describe('CombatSystem — on-hit effects', () => { }); }); +describe('Effect stacking', () => { + it('stack: allows multiple effects with the same tag', () => { + const w = world(); + const target = w.createEntity('target'); + target.add('health', new Health({ value: 100, min: 0 })); + target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); + target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); + expect(target.getAll(Effect).length).toBe(2); + expect(target.get(Health)!.value).toBe(90); + }); + + it('unique: second effect with same tag is discarded', () => { + const w = world(); + const target = w.createEntity('target'); + target.add('health', new Health({ value: 100, min: 0 })); + target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); + target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); + expect(target.getAll(Effect).length).toBe(1); + expect(target.get(Health)!.value).toBe(95); + }); + + it('unique: effects with different tags both apply', () => { + const w = world(); + const target = w.createEntity('target'); + target.add('health', new Health({ value: 100, min: 0 })); + target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); + target.add('e2', new Effect({ targetStat: 'health', delta: -3, stacking: 'unique', tag: 'burn' })); + expect(target.getAll(Effect).length).toBe(2); + expect(target.get(Health)!.value).toBe(92); + }); + + it('replace: removes existing effect and applies new one', () => { + const w = world(); + const target = w.createEntity('target'); + target.add('health', new Health({ value: 100, min: 0 })); + target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'replace', tag: 'poison' })); + expect(target.get(Health)!.value).toBe(95); + target.add('e2', new Effect({ targetStat: 'health', delta: -10, stacking: 'replace', tag: 'poison' })); + expect(target.getAll(Effect).length).toBe(1); + expect(target.get(Health)!.value).toBe(90); // old -5 reversed, new -10 applied + }); + + it('no tag: unique mode does not enforce limits', () => { + const w = world(); + const target = w.createEntity('target'); + target.add('health', new Health({ value: 100, min: 0 })); + target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' })); + target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' })); + expect(target.getAll(Effect).length).toBe(2); + expect(target.get(Health)!.value).toBe(90); + }); + + it('unique onHit effect is discarded when same tag already on target', () => { + 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' })); + w.createEntity('attacker'); + const target = w.createEntity('target'); + target.add('health', new Health({ value: 100, min: 0 })); + target.add('pre', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'burn' })); + target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + w.update(1); + expect(target.getAll(Effect).length).toBe(1); // onHit copy discarded + expect(target.get(Health)!.value).toBe(95); + }); +}); + describe('CombatSystem — events', () => { it("emits 'hit' on target with attack info", () => { const w = world(); diff --git a/test/common/rpg/effect.test.ts b/test/common/rpg/effect.test.ts index 260625c..9e9ef56 100644 --- a/test/common/rpg/effect.test.ts +++ b/test/common/rpg/effect.test.ts @@ -20,13 +20,13 @@ function withStat(value = 10, min?: number, max?: number) { describe('Effect — onAdd / onRemove', () => { it('applies delta to stat on add', () => { const { e, stat } = withStat(10); - e.add('fx', new Effect('str', 5)); + e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); expect(stat.value).toBe(15); }); it('reverts delta on remove', () => { const { e, stat } = withStat(10); - e.add('fx', new Effect('str', 5)); + e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); e.remove('fx'); expect(stat.value).toBe(10); }); @@ -34,7 +34,7 @@ describe('Effect — onAdd / onRemove', () => { it('applies delta to max field', () => { const { e } = withStat(10, undefined, 20); const s = e.get(Stat, 'str')!; - e.add('fx', new Effect('str', 10, 'max')); + e.add('fx', new Effect({ targetStat: 'str', delta: 10, targetField: 'max' })); expect(s.max).toBe(30); e.remove('fx'); expect(s.max).toBe(20); @@ -42,21 +42,21 @@ describe('Effect — onAdd / onRemove', () => { it('active is true after add', () => { const { e } = withStat(10); - e.add('fx', new Effect('str', 1)); + e.add('fx', new Effect({ targetStat: 'str', delta: 1 })); expect(e.get(Effect, 'fx')!.active).toBeTrue(); }); it('no-op if target stat is missing', () => { const w = world(); const e = w.createEntity(); - expect(() => e.add('fx', new Effect('str', 5))).not.toThrow(); + expect(() => e.add('fx', new Effect({ targetStat: 'str', delta: 5 }))).not.toThrow(); }); }); describe('Effect — duration', () => { it('expires after duration ticks', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect('str', 5, 'value', 2)); + e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 })); expect(stat.value).toBe(15); w.update(1); @@ -69,7 +69,7 @@ describe('Effect — duration', () => { it('emits expired before removal', () => { const { w, e } = withStat(10); - e.add('fx', new Effect('str', 1, 'value', 1)); + e.add('fx', new Effect({ targetStat: 'str', delta: 1, duration: 1 })); const events: string[] = []; e.on('fx.expired', () => events.push('expired')); w.update(1); @@ -78,7 +78,7 @@ describe('Effect — duration', () => { it('reset() restarts timer', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect('str', 5, 'value', 2)); + e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 })); w.update(1.5); e.get(Effect, 'fx')!.reset(); w.update(1.5); // would have expired without reset @@ -88,7 +88,7 @@ describe('Effect — duration', () => { it('reset(duration) changes duration', () => { const { w, e } = withStat(10); - e.add('fx', new Effect('str', 5, 'value', 1)); + e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1 })); e.get(Effect, 'fx')!.reset(10); w.update(5); expect(e.has(Effect)).toBeTrue(); @@ -96,7 +96,7 @@ describe('Effect — duration', () => { it('clear() immediately expires effect', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect('str', 5, 'value', 100)); + e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 100 })); e.get(Effect, 'fx')!.clear(); w.update(0.01); expect(e.has(Effect)).toBeFalse(); @@ -107,7 +107,7 @@ describe('Effect — duration', () => { describe('Effect — permanent', () => { it('permanent effect is never removed by EffectSystem', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect('str', 5)); + e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); w.update(100); expect(e.has(Effect)).toBeTrue(); expect(stat.value).toBe(15); @@ -117,27 +117,27 @@ 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('str', 99, 'value', undefined, undefined, 'onHit')); + e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); expect(stat.value).toBe(10); }); it('onRemove is a no-op for onHit scope', () => { const { e, stat } = withStat(10); - e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); + e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); 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('str', 5, 'value', 1, undefined, 'onHit')); + e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1, scope: 'onHit' })); w.update(10); expect(e.has(Effect)).toBeTrue(); }); it('active stays false for onHit scope', () => { const { e } = withStat(10); - e.add('fx', new Effect('str', 5, 'value', undefined, undefined, 'onHit')); + e.add('fx', new Effect({ targetStat: 'str', delta: 5, scope: 'onHit' })); expect(e.get(Effect, 'fx')!.active).toBeFalse(); }); }); @@ -145,15 +145,15 @@ describe('Effect — scope: onHit', () => { describe('Effect — multiple effects on same stat', () => { it('multiple effects stack additively', () => { const { e, stat } = withStat(10); - e.add('a', new Effect('str', 3)); - e.add('b', new Effect('str', 7)); + e.add('a', new Effect({ targetStat: 'str', delta: 3 })); + e.add('b', new Effect({ targetStat: 'str', delta: 7 })); expect(stat.value).toBe(20); }); it('removing one effect reverts only that delta', () => { const { e, stat } = withStat(10); - e.add('a', new Effect('str', 3)); - e.add('b', new Effect('str', 7)); + e.add('a', new Effect({ targetStat: 'str', delta: 3 })); + e.add('b', new Effect({ targetStat: 'str', delta: 7 })); e.remove('a'); expect(stat.value).toBe(17); }); diff --git a/test/common/rpg/equipment.test.ts b/test/common/rpg/equipment.test.ts index 5367b54..dc3c588 100644 --- a/test/common/rpg/equipment.test.ts +++ b/test/common/rpg/equipment.test.ts @@ -91,7 +91,7 @@ describe('Equipment — equip-scope effects', () => { it('clones equip-scope Effect onto owner on equip', () => { const w = world(); const sword = makeSword(w); - sword.add('bonus', new Effect('str', 5)); // scope: equip (default) + sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); // scope: equip (default) const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15); @@ -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('str', 99, 'value', undefined, undefined, 'onHit')); + sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected @@ -110,8 +110,8 @@ describe('Equipment — equip-scope effects', () => { it('clones multiple equip effects', () => { const w = world(); const sword = makeSword(w); - sword.add('a', new Effect('str', 3)); - sword.add('b', new Effect('str', 7)); + sword.add('a', new Effect({ targetStat: 'str', delta: 3 })); + sword.add('b', new Effect({ targetStat: 'str', delta: 7 })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(20); @@ -120,8 +120,8 @@ describe('Equipment — equip-scope effects', () => { it('equip + onHit: only equip effects reach owner', () => { const w = world(); const sword = makeSword(w); - sword.add('passive', new Effect('str', 5)); - sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); + sword.add('passive', new Effect({ targetStat: 'str', delta: 5 })); + sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15); @@ -133,7 +133,7 @@ describe('Equipment — unequip', () => { it('unequip reverts cloned effects', () => { const w = world(); const sword = makeSword(w); - sword.add('bonus', new Effect('str', 5)); + sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); const player = makePlayer(w); const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword' });