// Regression tests for the engine fixes (rehydration, loop guards, event cleanup, combat). // Each test below fails against the pre-fix engine. import { describe, it, expect } from 'bun:test'; import { World, Component, COMPONENT_KEY } from '@common/rpg/core/world'; import { Serialization } from '@common/rpg/core/serialization'; import { component, registerMigration, migrateState } from '@common/rpg/utils/decorators'; import { resolveVariables } from '@common/rpg/utils/variables'; import { Stat, Health } from '@common/rpg/components/stat'; import { Effect } from '@common/rpg/components/effect'; import { Inventory } from '@common/rpg/components/inventory'; import { Equipment } from '@common/rpg/components/equipment'; import { QuestLog } from '@common/rpg/components/questLog'; import { Experience } from '@common/rpg/components/experience'; import { Attacked, Damage, Defense, Crit } from '@common/rpg/components/combat'; import { CombatSystem } from '@common/rpg/systems/combat'; import { EffectSystem } from '@common/rpg/systems/effect'; const roundtrip = (w: World) => Serialization.deserialize(Serialization.serialize(w)) as World; function combatWorld() { const w = new World(); w.addSystem(new CombatSystem()); w.addSystem(new EffectSystem()); return w; } describe('fix: rehydration (#private + onAdd double-apply)', () => { it('deserialized Inventory/Equipment/QuestLog do not throw on use', () => { const w = new World(); w.createEntity('sword'); const p = w.createEntity('player'); p.add(new Inventory(5)); p.add(new Equipment('weapon')); p.add(new QuestLog()); const p2 = roundtrip(w).getEntity('player')!; expect(() => p2.get(Inventory)!.add({ itemId: 'sword', amount: 1 })).not.toThrow(); expect(() => p2.get(Inventory)!.getVariables()).not.toThrow(); expect(() => p2.get(Equipment)!.getVariables()).not.toThrow(); expect(() => p2.get(Equipment)!.getItemId('weapon')).not.toThrow(); expect(() => p2.get(QuestLog)!.getVariables()).not.toThrow(); expect(() => resolveVariables(p2)).not.toThrow(); }); it('cloned Inventory does not throw on use', () => { const w = new World(); w.createEntity('potion'); const a = w.createEntity('a'); a.add(new Inventory(3)); const b = w.cloneEntity(a, 'b'); expect(() => b.get(Inventory)!.add({ itemId: 'potion', amount: 1 })).not.toThrow(); }); it('deserialization does NOT double-apply an active Effect delta', () => { const w = new World(); const e = w.createEntity('e'); e.add(new Stat({ value: 100 }), 'hp'); e.add(new Effect({ targetKey: 'hp', delta: -10 })); // permanent modifier expect(e.get(Stat, 'hp')!.value).toBe(90); const e2 = roundtrip(w).getEntity('e')!; expect(e2.get(Stat, 'hp')!.value).toBe(90); // not 80 expect(e2.getAll(Effect).length).toBe(1); }); it('cloneEntity does NOT double-apply an active Effect delta', () => { const w = new World(); const src = w.createEntity('src'); src.add(new Stat({ value: 100 }), 'hp'); src.add(new Effect({ targetKey: 'hp', delta: -10 })); const clone = w.cloneEntity(src, 'clone'); expect(clone.get(Stat, 'hp')!.value).toBe(90); // not 80 }); it('a deserialized active Effect still reverses its delta when removed', () => { const w = new World(); const e = w.createEntity('e'); e.add(new Stat({ value: 100 }), 'hp'); e.add(new Effect({ targetKey: 'hp', delta: -10 }), 'fx'); const e2 = roundtrip(w).getEntity('e')!; e2.remove('fx'); expect(e2.get(Stat, 'hp')!.value).toBe(100); // active survived in state → reversal works }); }); describe('fix: Entity.removeAll removes ALL matches', () => { it('removes every matching component, not just the first', () => { const w = new World(); const e = w.createEntity(); e.add(new Stat({ value: 1 })); e.add(new Stat({ value: 2 })); e.add(new Stat({ value: 3 })); e.removeAll(Stat); expect(e.getAll(Stat).length).toBe(0); }); it('respects the filter and removes all that match it', () => { const w = new World(); const e = w.createEntity(); e.add(new Stat({ value: 5 })); e.add(new Stat({ value: 5 })); e.add(new Stat({ value: 9 })); e.removeAll(Stat, s => s.value === 5); expect(e.getAll(Stat).map(s => s.value)).toEqual([9]); }); }); describe('fix: cloneEntity preserves anonymous (symbol) keys', () => { it('does not collapse two anonymous components of the same type', () => { const w = new World(); const src = w.createEntity('src'); src.add(new Stat({ value: 7 })); src.add(new Stat({ value: 8 })); const clone = w.cloneEntity(src, 'clone'); const stats = clone.getAll(Stat); expect(stats.length).toBe(2); expect(stats.map(s => s.value).sort()).toEqual([7, 8]); for (const [, c] of clone) expect(typeof c[COMPONENT_KEY]).toBe('symbol'); }); it('preserves string keys on clone', () => { const w = new World(); const src = w.createEntity('src'); src.add(new Stat({ value: 42 }), 'str'); const clone = w.cloneEntity(src, 'clone'); expect(clone.get(Stat, 'str')!.value).toBe(42); }); }); describe('fix: Experience never infinite-loops on degenerate thresholds', () => { it('award terminates when a geometric threshold floors to 0', () => { const w = new World(); const xp = w.createEntity('p').add(new Experience({ base: 10, factor: 0.5 })); xp.award(25); // would hang pre-fix expect(xp.level).toBeGreaterThanOrEqual(1); expect(Number.isNaN(xp.progress)).toBeFalse(); expect(xp.progress).toBe(1); }); }); describe('fix: migration guards', () => { @component({ name: 'MigTestFixes', version: 2 }) class MigTestFixes extends Component<{ x: number }> { constructor() { super({ x: 0 }); } } it('throws (does not loop) on a non-advancing migration', () => { registerMigration('MigTestFixes', 0, 0, s => s); // toVersion does not advance expect(() => migrateState('MigTestFixes', { x: 0 }, 0)).toThrow(/advance|loop/i); }); it('rejects a save from a newer engine version', () => { const w = new World(); w.createEntity('e').add(new Stat({ value: 5 })); // Stat is version 0 const data = JSON.parse(Serialization.serialize(w)); data.entities[0].components[0].version = 99; // pretend it came from the future expect(() => Serialization.deserialize(JSON.stringify(data))).toThrow(/newer version/i); }); }); describe('fix: once() bookkeeping is scoped per event', () => { it('off() on one event does not leave the other event armed/disarmed wrongly', () => { const w = new World(); const e = w.createEntity('e'); const calls: unknown[] = []; const handler = ({ data }: { data?: unknown }) => calls.push(data); e.once('a', handler); e.once('b', handler); // same fn, different event e.off('a', handler); // must remove ONLY the 'a' registration e.emit('a', 1); // disarmed → no call e.emit('b', 2); // still armed → fires once expect(calls).toEqual([2]); }); it('the same handler fires once for each event it was registered on', () => { const w = new World(); const e = w.createEntity('e'); const calls: unknown[] = []; const handler = ({ data }: { data?: unknown }) => calls.push(data); e.once('a', handler); e.once('b', handler); e.emit('a', 1); e.emit('b', 2); e.emit('a', 3); // already consumed e.emit('b', 4); // already consumed expect(calls).toEqual([1, 2]); }); }); describe('fix: combat correctness', () => { it('stacks multiple defenses of the same type', () => { const w = combatWorld(); w.createEntity('sword').add(new Damage({ value: 20, damageType: 'physical' })); w.createEntity('a'); const t = w.createEntity('t'); t.add(new Health({ value: 100, min: 0 })); t.add(new Defense({ value: 5, damageType: 'physical' })); t.add(new Defense({ value: 3, damageType: 'physical' })); t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(t.get(Health)!.value).toBe(88); // 20 - (5 + 3) }); it('applies every Damage component on a multi-type weapon', () => { const w = combatWorld(); const sword = w.createEntity('sword'); sword.add(new Damage({ value: 10, damageType: 'physical' })); sword.add(new Damage({ value: 5, damageType: 'fire' })); w.createEntity('a'); const t = w.createEntity('t'); t.add(new Health({ value: 100, min: 0 })); const types: (string | undefined)[] = []; t.on('Combat.hit', ({ data }) => types.push((data as any).damageType)); t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(t.get(Health)!.value).toBe(85); // 10 + 5 expect(types.sort()).toEqual(['fire', 'physical']); }); it("Combat.hit reports the damage actually applied, not the pre-mitigation amount", () => { const w = combatWorld(); w.createEntity('sword').add(new Damage({ value: 100, damageType: 'physical' })); w.createEntity('a'); const t = w.createEntity('t'); t.add(new Health({ value: 30, min: 0 })); let reported = -1; t.on('Combat.hit', ({ data }) => { reported = (data as any).amount; }); t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(reported).toBe(30); // not 100 expect(t.get(Health)!.value).toBe(0); }); it('a crit value below 1 never reduces damage', () => { const w = combatWorld(); const sword = w.createEntity('sword'); sword.add(new Damage({ value: 20, damageType: 'physical' })); sword.add(new Crit({ value: 0.5, chance: 1 })); // always "crit", but multiplier < 1 w.createEntity('a'); const t = w.createEntity('t'); t.add(new Health({ value: 100, min: 0 })); t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(t.get(Health)!.value).toBe(80); // 20, not 10 }); it('damage variance can roll below the base value (symmetric)', () => { const w = combatWorld(); const sword = w.createEntity('sword'); sword.add(new Damage({ value: 20, damageType: 'physical', variance: 5 })); w.createEntity('a'); const t = w.createEntity('t'); t.add(new Health({ value: 1_000_000, min: 0 })); let min = Infinity, max = -Infinity; t.on('Combat.hit', ({ data }) => { const a = (data as any).amount as number; min = Math.min(min, a); max = Math.max(max, a); }); for (let i = 0; i < 300; i++) { t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); } expect(min).toBeLessThan(20); // pre-fix: variance only ever added expect(min).toBeGreaterThanOrEqual(15); // within [-variance, +variance] expect(max).toBeLessThanOrEqual(25); }); });