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

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('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>('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([]);
});
});