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

269 lines
11 KiB
TypeScript

// 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);
});
});