248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
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', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(80);
|
|
});
|
|
|
|
it('defense on target reduces damage', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(88);
|
|
});
|
|
|
|
it('defense only applies to matching damage type', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(80);
|
|
});
|
|
|
|
it('minDamage is enforced when defense exceeds damage', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(97);
|
|
});
|
|
|
|
it('null sourceId falls back to Damage on attacker', async () => {
|
|
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 }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(85);
|
|
});
|
|
|
|
it('multiple attacks in one tick accumulate', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(80);
|
|
});
|
|
|
|
it('Attacked components are removed after processing', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.has(Attacked)).toBeFalse();
|
|
});
|
|
|
|
it('missing attacker entity skips attack gracefully', async () => {
|
|
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 }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(100);
|
|
});
|
|
|
|
it('missing source entity skips attack gracefully', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(100);
|
|
});
|
|
|
|
it('source with no Damage component skips attack gracefully', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(target.get(Health)!.value).toBe(100);
|
|
});
|
|
|
|
it('target with no Health component is skipped gracefully', async () => {
|
|
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' }));
|
|
await w.update(1); // should not throw
|
|
});
|
|
});
|
|
|
|
describe('CombatSystem — on-hit effects', () => {
|
|
it('onHit effect is applied to target on hit', async () => {
|
|
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' }));
|
|
await 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', async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
const afterHit = target.get(Health)!.value; // 85
|
|
await 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", async () => {
|
|
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' }));
|
|
await 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", async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(kills.length).toBe(1);
|
|
});
|
|
|
|
it("does not emit 'kill' when target survives", async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(kills.length).toBe(0);
|
|
});
|
|
|
|
it("emits 'hit' per attack when multiple attacks land", async () => {
|
|
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' }));
|
|
await w.update(1);
|
|
expect(hits.length).toBe(2);
|
|
});
|
|
});
|