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('health', -5, 'value', 10, undefined, '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('str', -99, 'value', undefined, undefined, '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('health', -10, 'value', 2, undefined, '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('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); }); });