import { describe, it, expect } from 'bun:test'; import { World } from '@common/rpg/core/world'; 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 { CombatSystem } from '@common/rpg/systems/combat'; import { EffectSystem } from '@common/rpg/systems/effect'; function world() { const w = new World(); w.addSystem(new CombatSystem()); w.addSystem(new EffectSystem()); return w; } describe('CombatSystem — damage', () => { it('reduces target health by damage value', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 20, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(80); }); it('defense on target reduces damage', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 20, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('armor', new Defense({ value: 8, damageType: 'physical' })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(88); }); it('defense only applies to matching damage type', () => { const w = world(); const spell = w.createEntity('spell'); spell.add('dmg', new Damage({ value: 20, damageType: 'fire' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('armor', new Defense({ value: 8, damageType: 'physical' })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'spell' })); w.update(1); expect(target.get(Health)!.value).toBe(80); }); it('minDamage is enforced when defense exceeds damage', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 5, damageType: 'physical', minDamage: 3 })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('armor', new Defense({ value: 10, damageType: 'physical' })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(97); }); it('null sourceId falls back to Damage on attacker', () => { const w = world(); const attacker = w.createEntity('attacker'); attacker.add('dmg', new Damage({ value: 15, damageType: 'physical' })); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: null })); w.update(1); expect(target.get(Health)!.value).toBe(85); }); it('multiple attacks in one tick accumulate', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); w.createEntity('a'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' })); target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(80); }); it('Attacked components are removed after processing', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); w.createEntity('a'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(target.has(Attacked)).toBeFalse(); }); it('missing attacker entity skips attack gracefully', () => { const w = world(); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'ghost', sourceId: null })); w.update(1); expect(target.get(Health)!.value).toBe(100); }); it('missing source entity skips attack gracefully', () => { const w = world(); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'gone' })); w.update(1); expect(target.get(Health)!.value).toBe(100); }); it('source with no Damage component skips attack gracefully', () => { const w = world(); w.createEntity('attacker'); w.createEntity('empty_source'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' })); w.update(1); expect(target.get(Health)!.value).toBe(100); }); it('target with no Health component is skipped gracefully', () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); // should not throw }); }); describe('CombatSystem — on-hit effects', () => { it('onHit effect is applied to target on hit', () => { 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' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(85); // 100 - 10 dmg - 5 burn modifier expect(target.getAll(Effect).length).toBe(1); }); it('onHit effect on weapon does not affect weapon itself', () => { const w = world(); 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' })); expect(sword.get(Stat, 'str')!.value).toBe(50); }); it('onHit effect expires on target after duration', () => { 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' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); const afterHit = target.get(Health)!.value; // 85 w.update(2); // burn expires expect(target.getAll(Effect).length).toBe(0); expect(target.get(Health)!.value).toBe(afterHit + 10); // modifier reverted }); }); 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(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 10, damageType: 'fire' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); const hits: unknown[] = []; target.on('hit', ({ data }) => hits.push(data)); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(hits.length).toBe(1); expect((hits[0] as any).damageType).toBe('fire'); expect((hits[0] as any).amount).toBe(10); expect((hits[0] as any).attackerId).toBe('attacker'); expect((hits[0] as any).sourceId).toBe('sword'); }); it("emits 'kill' when target health reaches zero", () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 999, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 50, min: 0 })); const kills: unknown[] = []; target.on('kill', ({ data }) => kills.push(data)); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(kills.length).toBe(1); }); it("does not emit 'kill' when target survives", () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); const kills: unknown[] = []; target.on('kill', ({ data }) => kills.push(data)); target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(kills.length).toBe(0); }); it("emits 'hit' per attack when multiple attacks land", () => { const w = world(); const sword = w.createEntity('sword'); sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); w.createEntity('a'); const target = w.createEntity('target'); target.add('health', new Health({ value: 100, min: 0 })); const hits: unknown[] = []; target.on('hit', ({ data }) => hits.push(data)); target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' })); target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(hits.length).toBe(2); }); });