288 lines
9.1 KiB
TypeScript
288 lines
9.1 KiB
TypeScript
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(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(new Counter(5), 'counter');
|
|
expect(e.get<Counter>('counter')?.n).toBe(5);
|
|
});
|
|
|
|
it('get by class', () => {
|
|
const world = new World();
|
|
const e = world.createEntity();
|
|
e.add(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(new Counter(1), 'a');
|
|
e.add(new Counter(2), 'b');
|
|
expect(e.get(Counter, 'b')?.n).toBe(2);
|
|
});
|
|
|
|
it('get by class and filter', () => {
|
|
const world = new World();
|
|
const e = world.createEntity();
|
|
e.add(new Counter(10));
|
|
e.add(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(new Counter(1));
|
|
e.add(new Counter(2));
|
|
e.add(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(new Counter(), 'c');
|
|
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(new R(), '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(new Ev('a'), 'k');
|
|
e.add(new Ev('b'), 'k');
|
|
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(c, 'mykey');
|
|
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(orig, 'c');
|
|
const clone = e.clone(orig, 'd');
|
|
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(a, 'b');
|
|
expect(added).toEqual([true]);
|
|
});
|
|
});
|
|
|
|
describe('World — cloneEntity', () => {
|
|
it('produces independent deep copy', () => {
|
|
const world = new World();
|
|
const src = world.createEntity('src');
|
|
src.add(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(new Counter());
|
|
b.add(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(new Counter());
|
|
a.add(new Tag());
|
|
b.add(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([]);
|
|
});
|
|
});
|