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 { EffectSystem } from '@common/rpg/systems/effect'; function world() { const w = new World(); w.addSystem(new EffectSystem()); return w; } function withStat(value = 10, min?: number, max?: number) { const w = world(); const e = w.createEntity(); e.add('str', new Stat({ value, min, max })); return { w, e, stat: e.get(Stat, 'str')! }; } describe('Effect — onAdd / onRemove', () => { it('applies delta to stat on add', () => { const { e, stat } = withStat(10); e.add('fx', new Effect('str', 5)); expect(stat.value).toBe(15); }); it('reverts delta on remove', () => { const { e, stat } = withStat(10); e.add('fx', new Effect('str', 5)); e.remove('fx'); expect(stat.value).toBe(10); }); 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')); expect(s.max).toBe(30); e.remove('fx'); expect(s.max).toBe(20); }); it('active is true after add', () => { const { e } = withStat(10); e.add('fx', new Effect('str', 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(); }); }); describe('Effect — duration', () => { it('expires after duration ticks', async () => { const { w, e, stat } = withStat(10); e.add('fx', new Effect('str', 5, 'value', 2)); expect(stat.value).toBe(15); await w.update(1); expect(e.has(Effect)).toBeTrue(); await w.update(1); expect(e.has(Effect)).toBeFalse(); expect(stat.value).toBe(10); }); it('emits expired before removal', async () => { const { w, e } = withStat(10); e.add('fx', new Effect('str', 1, 'value', 1)); const events: string[] = []; e.on('fx.expired', () => events.push('expired')); await w.update(1); expect(events).toEqual(['expired']); }); it('reset() restarts timer', async () => { const { w, e, stat } = withStat(10); e.add('fx', new Effect('str', 5, 'value', 2)); await w.update(1.5); e.get(Effect, 'fx')!.reset(); await w.update(1.5); // would have expired without reset expect(e.has(Effect)).toBeTrue(); expect(stat.value).toBe(15); }); it('reset(duration) changes duration', async () => { const { w, e } = withStat(10); e.add('fx', new Effect('str', 5, 'value', 1)); e.get(Effect, 'fx')!.reset(10); await w.update(5); expect(e.has(Effect)).toBeTrue(); }); it('clear() immediately expires effect', async () => { const { w, e, stat } = withStat(10); e.add('fx', new Effect('str', 5, 'value', 100)); e.get(Effect, 'fx')!.clear(); await w.update(0.01); expect(e.has(Effect)).toBeFalse(); expect(stat.value).toBe(10); }); }); describe('Effect — permanent', () => { it('permanent effect is never removed by EffectSystem', async () => { const { w, e, stat } = withStat(10); e.add('fx', new Effect('str', 5)); await w.update(100); expect(e.has(Effect)).toBeTrue(); expect(stat.value).toBe(15); }); }); 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')); 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.remove('fx'); expect(stat.value).toBe(10); }); it('EffectSystem does not tick or remove onHit effects', async () => { const { w, e } = withStat(10); e.add('fx', new Effect('str', 5, 'value', 1, undefined, 'onHit')); await 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')); expect(e.get(Effect, 'fx')!.active).toBeFalse(); }); }); 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)); 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.remove('a'); expect(stat.value).toBe(17); }); });