import { describe, it, expect, mock } from 'bun:test'; import { World, Entity, Component } from '@common/rpg/core/world'; class Counter extends Component<{ n: number }> { constructor(n = 0) { super({ n }); } get n() { return this.state.n; } inc() { this.state.n++; } } class Tag extends Component<{}> { constructor() { super({}); } } describe('World — entity management', () => { it('creates entity with auto id', () => { const world = new World(); const e = world.createEntity(); expect(e.id).toMatch(/^entity_\d+$/); }); it('creates entity with explicit id', () => { const world = new World(); const e = world.createEntity('player'); expect(e.id).toBe('player'); }); it('creates entity with template id', () => { const world = new World(); const a = world.createEntity('enemy_*'); const b = world.createEntity('enemy_*'); expect(a.id).not.toBe(b.id); expect(a.id).toMatch(/^enemy_\d+$/); }); it('throws on duplicate entity id', () => { const world = new World(); world.createEntity('dup'); expect(() => world.createEntity('dup')).toThrow(); }); it('getEntity returns entity or undefined', () => { const world = new World(); world.createEntity('e'); expect(world.getEntity('e')).toBeInstanceOf(Entity); expect(world.getEntity('missing')).toBeUndefined(); }); it('destroyEntity removes entity and its components', () => { const world = new World(); const e = world.createEntity('e'); const removed: string[] = []; class Tracked extends Component<{}> { constructor() { super({}); } override onRemove() { removed.push('ok'); } } e.add('t', new Tracked()); world.destroyEntity(e); expect(world.getEntity('e')).toBeUndefined(); expect(removed).toEqual(['ok']); }); it('iterates all entities', () => { const world = new World(); world.createEntity('a'); world.createEntity('b'); const ids = [...world].map(e => e.id); expect(ids).toContain('a'); expect(ids).toContain('b'); }); }); describe('Entity — components', () => { it('add / get by key', () => { const world = new World(); const e = world.createEntity(); e.add('counter', new Counter(5)); expect(e.get('counter')?.n).toBe(5); }); it('get by class', () => { const world = new World(); const e = world.createEntity(); e.add('c', new Counter(3)); expect(e.get(Counter)?.n).toBe(3); }); it('get by class and key', () => { const world = new World(); const e = world.createEntity(); e.add('a', new Counter(1)); e.add('b', new Counter(2)); expect(e.get(Counter, 'b')?.n).toBe(2); }); it('get by class and filter', () => { const world = new World(); const e = world.createEntity(); e.add('a', new Counter(10)); e.add('b', new Counter(20)); expect(e.get(Counter, c => c.n === 20)?.n).toBe(20); }); it('getAll returns all matching components', () => { const world = new World(); const e = world.createEntity(); e.add('a', new Counter(1)); e.add('b', new Counter(2)); e.add('t', new Tag()); const all = e.getAll(Counter); expect(all.length).toBe(2); expect(all.map(c => c.n).sort()).toEqual([1, 2]); }); it('has by key / by class / by class+key', () => { const world = new World(); const e = world.createEntity(); e.add('c', new Counter()); expect(e.has('c')).toBeTrue(); expect(e.has('missing')).toBeFalse(); expect(e.has(Counter)).toBeTrue(); expect(e.has(Tag)).toBeFalse(); expect(e.has(Counter, 'c')).toBeTrue(); expect(e.has(Counter, 'missing')).toBeFalse(); }); it('remove by key calls onRemove', () => { const world = new World(); const e = world.createEntity(); const removed: boolean[] = []; class R extends Component<{}> { constructor() { super({}); } override onRemove() { removed.push(true); } } e.add('r', new R()); e.remove('r'); expect(removed).toEqual([true]); expect(e.has('r')).toBeFalse(); }); it('remove by component instance', () => { const world = new World(); const e = world.createEntity(); const c = new Counter(); e.add('c', c); e.remove(c); expect(e.has('c')).toBeFalse(); }); it('add over existing key calls onRemove on old', () => { const world = new World(); const e = world.createEntity(); const events: string[] = []; class Ev extends Component<{ id: string }> { constructor(id: string) { super({ id }); } override onAdd() { events.push(`add:${this.state.id}`); } override onRemove() { events.push(`remove:${this.state.id}`); } } e.add('k', new Ev('a')); e.add('k', new Ev('b')); expect(events).toEqual(['add:a', 'remove:a', 'add:b']); }); it('component.entity and component.key are set on add', () => { const world = new World(); const e = world.createEntity('me'); const c = new Counter(); e.add('mykey', c); expect(c.entity).toBe(e); expect(c.key).toBe('mykey'); }); }); describe('Entity — clone', () => { it('clones component state deeply', () => { const world = new World(); const e = world.createEntity(); const orig = new Counter(7); e.add('c', orig); const clone = e.clone('d', orig); clone.inc(); expect(orig.n).toBe(7); expect(clone.n).toBe(8); }); it('clone fires onAdd', () => { const world = new World(); const e = world.createEntity(); const added: boolean[] = []; class A extends Component<{}> { constructor() { super({}); } override onAdd() { added.push(true); } } const a = new A(); e.add('a', a); added.length = 0; e.clone('b', a); expect(added).toEqual([true]); }); }); describe('World — cloneEntity', () => { it('produces independent deep copy', () => { const world = new World(); const src = world.createEntity('src'); src.add('c', new Counter(5)); const copy = world.cloneEntity(src, 'copy'); copy.get(Counter)!.inc(); expect(src.get(Counter)!.n).toBe(5); expect(copy.get(Counter)!.n).toBe(6); }); }); describe('World — query', () => { it('single-component query yields matching entities', () => { const world = new World(); const a = world.createEntity('a'); const b = world.createEntity('b'); world.createEntity('c'); a.add('c', new Counter()); b.add('c', new Counter()); const found = [...world.query(Counter)].map(([e]) => e.id); expect(found.sort()).toEqual(['a', 'b']); }); it('multi-component query requires all', () => { const world = new World(); const a = world.createEntity('a'); const b = world.createEntity('b'); a.add('c', new Counter()); a.add('t', new Tag()); b.add('c', new Counter()); const found = [...world.query(Counter, Tag)].map(([e]) => e.id); expect(found).toEqual(['a']); }); }); describe('Entity — events', () => { it('emit / on fires handler', () => { const world = new World(); const e = world.createEntity(); const received: unknown[] = []; e.on('boom', ({ data }) => received.push(data)); e.emit('boom', 42); expect(received).toEqual([42]); }); it('off unsubscribes handler', () => { const world = new World(); const e = world.createEntity(); const received: unknown[] = []; const handler = ({ data }: { data?: unknown }) => received.push(data); e.on('x', handler); e.off('x', handler); e.emit('x', 1); expect(received).toEqual([]); }); it('once fires exactly once', () => { const world = new World(); const e = world.createEntity(); const received: unknown[] = []; e.once('x', ({ data }) => received.push(data)); e.emit('x', 1); e.emit('x', 2); expect(received).toEqual([1]); }); it('on returns unsubscribe function', () => { const world = new World(); const e = world.createEntity(); const received: unknown[] = []; const unsub = e.on('x', ({ data }) => received.push(data)); unsub(); e.emit('x', 1); expect(received).toEqual([]); }); it('destroying entity removes all event handlers', () => { const world = new World(); const e = world.createEntity('e'); const received: unknown[] = []; e.on('x', ({ data }) => received.push(data)); world.destroyEntity(e); // no error, handler just never fires expect(received).toEqual([]); }); });