155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import { describe, it, expect } from 'bun:test';
|
|
import { World } from '@common/rpg/core/world';
|
|
import { Stat } from '@common/rpg/components/stat';
|
|
import { Effect, EffectTemplate } 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({ targetStat: 'str', delta: 5 }));
|
|
expect(stat.value).toBe(15);
|
|
});
|
|
|
|
it('reverts delta on remove', () => {
|
|
const { e, stat } = withStat(10);
|
|
e.add('fx', new Effect({ targetStat: 'str', delta: 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({ targetStat: 'str', delta: 10, targetField: '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({ targetStat: 'str', delta: 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({ targetStat: 'str', delta: 5 }))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Effect — duration', () => {
|
|
it('expires after duration ticks', () => {
|
|
const { w, e, stat } = withStat(10);
|
|
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
|
|
expect(stat.value).toBe(15);
|
|
|
|
w.update(1);
|
|
expect(e.has(Effect)).toBeTrue();
|
|
|
|
w.update(1);
|
|
expect(e.has(Effect)).toBeFalse();
|
|
expect(stat.value).toBe(10);
|
|
});
|
|
|
|
it('emits expired before removal', () => {
|
|
const { w, e } = withStat(10);
|
|
e.add(new Effect({ targetStat: 'str', delta: 1, duration: 1 }));
|
|
const events: string[] = [];
|
|
e.on('Effect.expired', () => events.push('expired'));
|
|
w.update(1);
|
|
expect(events).toEqual(['expired']);
|
|
});
|
|
|
|
it('reset() restarts timer', () => {
|
|
const { w, e, stat } = withStat(10);
|
|
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
|
|
w.update(1.5);
|
|
e.get(Effect, 'fx')!.reset();
|
|
w.update(1.5); // would have expired without reset
|
|
expect(e.has(Effect)).toBeTrue();
|
|
expect(stat.value).toBe(15);
|
|
});
|
|
|
|
it('reset(duration) changes duration', () => {
|
|
const { w, e } = withStat(10);
|
|
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1 }));
|
|
e.get(Effect, 'fx')!.reset(10);
|
|
w.update(5);
|
|
expect(e.has(Effect)).toBeTrue();
|
|
});
|
|
|
|
it('clear() immediately expires effect', () => {
|
|
const { w, e, stat } = withStat(10);
|
|
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 100 }));
|
|
e.get(Effect, 'fx')!.clear();
|
|
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', () => {
|
|
const { w, e, stat } = withStat(10);
|
|
e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
|
|
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 EffectTemplate({ targetStat: 'str', delta: 99 }));
|
|
expect(stat.value).toBe(10);
|
|
});
|
|
|
|
it('onRemove is a no-op for onHit scope', () => {
|
|
const { e, stat } = withStat(10);
|
|
e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 }));
|
|
e.remove('fx');
|
|
expect(stat.value).toBe(10);
|
|
});
|
|
|
|
it('EffectSystem does not tick or remove onHit effects', () => {
|
|
const { w, e } = withStat(10);
|
|
e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 5, duration: 1 }));
|
|
w.update(10);
|
|
expect(e.has(EffectTemplate)).toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('Effect — multiple effects on same stat', () => {
|
|
it('multiple effects stack additively', () => {
|
|
const { e, stat } = withStat(10);
|
|
e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
|
|
e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
|
|
expect(stat.value).toBe(20);
|
|
});
|
|
|
|
it('removing one effect reverts only that delta', () => {
|
|
const { e, stat } = withStat(10);
|
|
e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
|
|
e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
|
|
e.remove('a');
|
|
expect(stat.value).toBe(17);
|
|
});
|
|
});
|