269 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|