1
0
Fork 0
tsgames/test/common/rpg/combat.test.ts

316 lines
14 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', () => {
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);
});
});