import { describe, it, expect } from 'bun:test'; import { World } from '@common/rpg/core/world'; import { Stat } from '@common/rpg/components/stat'; import { Effect } from '@common/rpg/components/effect'; import { Equipment, Equippable } from '@common/rpg/components/equipment'; function world() { return new World(); } function makeSword(w: World, id = 'sword') { const sword = w.createEntity(id); sword.add('equippable', new Equippable('weapon')); return sword; } function makePlayer(w: World, slots: ConstructorParameters[0] = [{ slotName: 'weapon', type: 'weapon' }]) { const player = w.createEntity('player'); player.add('str', new Stat({ value: 10 })); player.add('equipment', new Equipment(slots)); return player; } describe('Equipment — equip', () => { it('equips item into a typed slot', () => { const w = world(); makeSword(w); const player = makePlayer(w); const result = player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(result).toBeTrue(); expect(player.get(Equipment)!.getItem('weapon')).toBe('sword'); }); it('returns false for unknown slot', () => { const w = world(); makeSword(w); const player = makePlayer(w); expect(player.get(Equipment)!.equip({ slotName: 'head', itemId: 'sword' })).toBeFalse(); }); it('returns false for missing item entity', () => { const w = world(); const player = makePlayer(w); expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'ghost' })).toBeFalse(); }); it('returns false when item has no Equippable component', () => { const w = world(); w.createEntity('rock'); // no Equippable const player = makePlayer(w); expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'rock' })).toBeFalse(); }); it('returns false when slot type does not match Equippable.slotType', () => { const w = world(); const helmet = w.createEntity('helmet'); helmet.add('equippable', new Equippable('armor')); const player = makePlayer(w); // slot type = 'weapon' expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'helmet' })).toBeFalse(); }); it('generic slot (no type) accepts any Equippable', () => { const w = world(); makeSword(w); const player = w.createEntity('player'); player.add('equipment', new Equipment(['slot1'])); // generic slot expect(player.get(Equipment)!.equip({ slotName: 'slot1', itemId: 'sword' })).toBeTrue(); }); it('equipping occupied slot auto-unequips first', () => { const w = world(); makeSword(w, 'sword1'); makeSword(w, 'sword2'); const player = makePlayer(w); const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword1' }); eq.equip({ slotName: 'weapon', itemId: 'sword2' }); expect(eq.getItem('weapon')).toBe('sword2'); }); it("emits 'equip' event", () => { const w = world(); makeSword(w); const player = makePlayer(w); const events: unknown[] = []; player.on('equipment.equip', ({ data }) => events.push(data)); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]); }); }); describe('Equipment — equip-scope effects', () => { it('clones equip-scope Effect onto owner on equip', () => { const w = world(); const sword = makeSword(w); sword.add('bonus', new Effect('str', 5)); // scope: equip (default) const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15); }); it('does NOT clone onHit-scope Effect onto owner on equip', () => { const w = world(); const sword = makeSword(w); sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected expect(player.getAll(Effect).length).toBe(0); }); it('clones multiple equip effects', () => { const w = world(); const sword = makeSword(w); sword.add('a', new Effect('str', 3)); sword.add('b', new Effect('str', 7)); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(20); }); it('equip + onHit: only equip effects reach owner', () => { const w = world(); const sword = makeSword(w); sword.add('passive', new Effect('str', 5)); sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15); expect(player.getAll(Effect).length).toBe(1); }); }); describe('Equipment — unequip', () => { it('unequip reverts cloned effects', () => { const w = world(); const sword = makeSword(w); sword.add('bonus', new Effect('str', 5)); const player = makePlayer(w); const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15); eq.unequip('weapon'); expect(player.get(Stat, 'str')!.value).toBe(10); }); it('unequip clears slot itemId', () => { const w = world(); makeSword(w); const player = makePlayer(w); const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword' }); eq.unequip('weapon'); expect(eq.getItem('weapon')).toBeNull(); }); it('unequip returns false on empty slot', () => { const w = world(); const player = makePlayer(w); expect(player.get(Equipment)!.unequip('weapon')).toBeFalse(); }); it("unequip emits 'unequip' event", () => { const w = world(); makeSword(w); const player = makePlayer(w); const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword' }); const events: unknown[] = []; player.on('equipment.unequip', ({ data }) => events.push(data)); eq.unequip('weapon'); expect(events).toEqual([{ slotName: 'weapon', itemId: 'sword' }]); }); }); describe('Equipment — queries', () => { it('getEquipped returns all filled slots', () => { const w = world(); makeSword(w, 'sword1'); makeSword(w, 'sword2'); const player = w.createEntity('player'); player.add('equipment', new Equipment([ { slotName: 'main', type: 'weapon' }, { slotName: 'off', type: 'weapon' }, ])); const eq = player.get(Equipment)!; eq.equip({ slotName: 'main', itemId: 'sword1' }); eq.equip({ slotName: 'off', itemId: 'sword2' }); const equipped = eq.getEquipped(); expect(equipped.length).toBe(2); expect(equipped.map(e => e.slotName).sort()).toEqual(['main', 'off']); }); it('findCompatibleSlot prefers typed slot over generic', () => { const w = world(); const player = w.createEntity('player'); player.add('equipment', new Equipment([ 'generic', { slotName: 'weapon', type: 'weapon' }, ])); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBe('weapon'); }); it('findCompatibleSlot falls back to generic slot', () => { const w = world(); const player = w.createEntity('player'); player.add('equipment', new Equipment(['generic'])); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBe('generic'); }); it('findCompatibleSlot returns null when no compatible slot', () => { const w = world(); const player = w.createEntity('player'); player.add('equipment', new Equipment([{ slotName: 'armor', type: 'armor' }])); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBeNull(); }); });