@gamerstake/game-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.testing-guide-summary.md +261 -0
  3. package/DEVELOPER_GUIDE.md +996 -0
  4. package/MANUAL_TESTING.md +369 -0
  5. package/QUICK_START.md +368 -0
  6. package/README.md +379 -0
  7. package/TESTING_OVERVIEW.md +378 -0
  8. package/dist/index.d.ts +1266 -0
  9. package/dist/index.js +1632 -0
  10. package/dist/index.js.map +1 -0
  11. package/examples/simple-game/README.md +176 -0
  12. package/examples/simple-game/client.ts +201 -0
  13. package/examples/simple-game/package.json +14 -0
  14. package/examples/simple-game/server.ts +233 -0
  15. package/jest.config.ts +39 -0
  16. package/package.json +54 -0
  17. package/src/core/GameLoop.ts +214 -0
  18. package/src/core/GameRules.ts +103 -0
  19. package/src/core/GameServer.ts +200 -0
  20. package/src/core/Room.ts +368 -0
  21. package/src/entities/Entity.ts +118 -0
  22. package/src/entities/Registry.ts +161 -0
  23. package/src/index.ts +51 -0
  24. package/src/input/Command.ts +41 -0
  25. package/src/input/InputQueue.ts +130 -0
  26. package/src/network/Network.ts +112 -0
  27. package/src/network/Snapshot.ts +59 -0
  28. package/src/physics/AABB.ts +104 -0
  29. package/src/physics/Movement.ts +124 -0
  30. package/src/spatial/Grid.ts +202 -0
  31. package/src/types/index.ts +117 -0
  32. package/src/types/protocol.ts +161 -0
  33. package/src/utils/Logger.ts +112 -0
  34. package/src/utils/RingBuffer.ts +116 -0
  35. package/tests/AABB.test.ts +38 -0
  36. package/tests/Entity.test.ts +35 -0
  37. package/tests/GameLoop.test.ts +58 -0
  38. package/tests/GameServer.test.ts +64 -0
  39. package/tests/Grid.test.ts +28 -0
  40. package/tests/InputQueue.test.ts +42 -0
  41. package/tests/Movement.test.ts +37 -0
  42. package/tests/Network.test.ts +39 -0
  43. package/tests/Registry.test.ts +36 -0
  44. package/tests/RingBuffer.test.ts +38 -0
  45. package/tests/Room.test.ts +80 -0
  46. package/tests/Snapshot.test.ts +19 -0
  47. package/tsconfig.json +28 -0
  48. package/tsup.config.ts +14 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Ring Buffer
3
+ *
4
+ * O(1) insert and remove, fixed capacity, no allocations in hot path.
5
+ * Used for input queues, position history, etc.
6
+ */
7
+
8
+ export class RingBuffer<T> {
9
+ private readonly buffer: (T | undefined)[];
10
+ private head = 0; // Next write position
11
+ private tail = 0; // Next read position
12
+ private count = 0;
13
+
14
+ constructor(private readonly capacity: number) {
15
+ this.buffer = new Array(capacity);
16
+ }
17
+
18
+ /**
19
+ * Add item to buffer. O(1)
20
+ * Returns false if buffer is full.
21
+ */
22
+ push(item: T): boolean {
23
+ if (this.count >= this.capacity) {
24
+ return false; // Full
25
+ }
26
+
27
+ this.buffer[this.head] = item;
28
+ this.head = (this.head + 1) % this.capacity;
29
+ this.count++;
30
+ return true;
31
+ }
32
+
33
+ /**
34
+ * Add item, overwriting oldest if full. O(1)
35
+ */
36
+ pushOverwrite(item: T): T | undefined {
37
+ let dropped: T | undefined;
38
+
39
+ if (this.count >= this.capacity) {
40
+ // Drop oldest
41
+ dropped = this.buffer[this.tail];
42
+ this.tail = (this.tail + 1) % this.capacity;
43
+ this.count--;
44
+ }
45
+
46
+ this.buffer[this.head] = item;
47
+ this.head = (this.head + 1) % this.capacity;
48
+ this.count++;
49
+
50
+ return dropped;
51
+ }
52
+
53
+ /**
54
+ * Remove and return oldest item. O(1)
55
+ */
56
+ shift(): T | undefined {
57
+ if (this.count === 0) {
58
+ return undefined;
59
+ }
60
+
61
+ const item = this.buffer[this.tail];
62
+ this.buffer[this.tail] = undefined; // Help GC
63
+ this.tail = (this.tail + 1) % this.capacity;
64
+ this.count--;
65
+
66
+ return item;
67
+ }
68
+
69
+ /**
70
+ * Peek at oldest item without removing. O(1)
71
+ */
72
+ peek(): T | undefined {
73
+ if (this.count === 0) {
74
+ return undefined;
75
+ }
76
+ return this.buffer[this.tail];
77
+ }
78
+
79
+ /**
80
+ * Get current size.
81
+ */
82
+ size(): number {
83
+ return this.count;
84
+ }
85
+
86
+ /**
87
+ * Check if empty.
88
+ */
89
+ isEmpty(): boolean {
90
+ return this.count === 0;
91
+ }
92
+
93
+ /**
94
+ * Check if full.
95
+ */
96
+ isFull(): boolean {
97
+ return this.count >= this.capacity;
98
+ }
99
+
100
+ /**
101
+ * Clear all items. O(1)
102
+ */
103
+ clear(): void {
104
+ // Don't need to clear array, just reset pointers
105
+ this.head = 0;
106
+ this.tail = 0;
107
+ this.count = 0;
108
+ }
109
+
110
+ /**
111
+ * Get capacity.
112
+ */
113
+ getCapacity(): number {
114
+ return this.capacity;
115
+ }
116
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { AABBCollision, type AABB } from '../src/physics/AABB.js';
3
+ import { Entity } from '../src/entities/Entity.js';
4
+
5
+ describe('AABBCollision', () => {
6
+ it('detects overlap between AABBs', () => {
7
+ const a: AABB = { x: 0, y: 0, width: 10, height: 10 };
8
+ const b: AABB = { x: 5, y: 0, width: 10, height: 10 };
9
+ const c: AABB = { x: 20, y: 0, width: 10, height: 10 };
10
+
11
+ expect(AABBCollision.overlaps(a, b)).toBe(true);
12
+ expect(AABBCollision.overlaps(a, c)).toBe(false);
13
+ });
14
+
15
+ it('checks point containment', () => {
16
+ const a: AABB = { x: 0, y: 0, width: 10, height: 10 };
17
+ expect(AABBCollision.containsPoint(a, 1, 1)).toBe(true);
18
+ expect(AABBCollision.containsPoint(a, 10, 10)).toBe(false);
19
+ });
20
+
21
+ it('creates AABB from entity', () => {
22
+ const entity = new Entity('e1', 5, 6);
23
+ const aabb = AABBCollision.fromEntity(entity, 10, 12);
24
+ expect(aabb).toEqual({ x: 5, y: 6, width: 10, height: 12 });
25
+ });
26
+
27
+ it('calculates overlap and distance', () => {
28
+ const a: AABB = { x: 0, y: 0, width: 10, height: 10 };
29
+ const b: AABB = { x: 4, y: 4, width: 10, height: 10 };
30
+
31
+ const overlap = AABBCollision.overlap(a, b);
32
+ expect(Math.abs(overlap.x)).toBeGreaterThan(0);
33
+ expect(Math.abs(overlap.y)).toBeGreaterThan(0);
34
+
35
+ const distance = AABBCollision.distance(a, b);
36
+ expect(distance).toBe(0);
37
+ });
38
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { Entity } from '../src/entities/Entity.js';
3
+
4
+ describe('Entity', () => {
5
+ it('updates position based on velocity and delta time', () => {
6
+ const entity = new Entity('e1', 0, 0);
7
+ entity.setVelocity(100, -50);
8
+ entity.updatePosition(1000); // 1 second
9
+
10
+ expect(entity.x).toBe(100);
11
+ expect(entity.y).toBe(-50);
12
+ expect(entity.dirty).toBe(true);
13
+ });
14
+
15
+ it('does not update position when velocity is zero', () => {
16
+ const entity = new Entity('e1', 10, 20);
17
+ entity.markClean();
18
+ entity.updatePosition(1000);
19
+
20
+ expect(entity.x).toBe(10);
21
+ expect(entity.y).toBe(20);
22
+ expect(entity.dirty).toBe(false);
23
+ });
24
+
25
+ it('sets position and velocity with dirty tracking', () => {
26
+ const entity = new Entity('e1', 0, 0);
27
+ entity.markClean();
28
+ entity.setPosition(5, 6);
29
+ expect(entity.dirty).toBe(true);
30
+
31
+ entity.markClean();
32
+ entity.setVelocity(1, 2);
33
+ expect(entity.dirty).toBe(true);
34
+ });
35
+ });
@@ -0,0 +1,58 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ jest,
6
+ beforeEach,
7
+ afterEach,
8
+ } from '@jest/globals';
9
+ import { GameLoop, type TickHandler } from '../src/core/GameLoop.js';
10
+
11
+ describe('GameLoop', () => {
12
+ let nowSpy: ReturnType<typeof jest.spyOn>;
13
+
14
+ beforeEach(() => {
15
+ jest.useFakeTimers();
16
+ nowSpy = jest
17
+ .spyOn(performance, 'now')
18
+ .mockImplementation(() => Date.now());
19
+ });
20
+
21
+ afterEach(() => {
22
+ nowSpy.mockRestore();
23
+ jest.useRealTimers();
24
+ });
25
+
26
+ it('runs tick handlers at a fixed rate', () => {
27
+ const loop = new GameLoop(1); // 1 TPS
28
+ const handler: TickHandler = { onTick: jest.fn() };
29
+
30
+ loop.addHandler(handler);
31
+ loop.start();
32
+
33
+ // First tick fires after ~2000ms due to drift compensation on start
34
+ jest.advanceTimersByTime(2000);
35
+
36
+ expect(handler.onTick).toHaveBeenCalledTimes(1);
37
+ expect(loop.isRunning()).toBe(true);
38
+
39
+ loop.stop();
40
+ expect(loop.isRunning()).toBe(false);
41
+ });
42
+
43
+ it('can remove handlers', () => {
44
+ const loop = new GameLoop(1);
45
+ const handler: TickHandler = { onTick: jest.fn() };
46
+
47
+ loop.addHandler(handler);
48
+ loop.start();
49
+ jest.advanceTimersByTime(2000);
50
+ expect(handler.onTick).toHaveBeenCalledTimes(1);
51
+
52
+ loop.removeHandler(handler);
53
+ jest.advanceTimersByTime(2000);
54
+ expect(handler.onTick).toHaveBeenCalledTimes(1);
55
+
56
+ loop.stop();
57
+ });
58
+ });
@@ -0,0 +1,64 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ jest,
6
+ beforeEach,
7
+ afterEach,
8
+ } from '@jest/globals';
9
+ import { GameServer } from '../src/core/GameServer.js';
10
+ import type { GameRules } from '../src/core/GameRules.js';
11
+ import { Entity } from '../src/entities/Entity.js';
12
+
13
+ describe('GameServer', () => {
14
+ let nowSpy: ReturnType<typeof jest.spyOn>;
15
+
16
+ beforeEach(() => {
17
+ jest.useFakeTimers();
18
+ nowSpy = jest
19
+ .spyOn(performance, 'now')
20
+ .mockImplementation(() => Date.now());
21
+ });
22
+
23
+ afterEach(() => {
24
+ nowSpy.mockRestore();
25
+ jest.useRealTimers();
26
+ });
27
+
28
+ it('creates and destroys rooms', () => {
29
+ const server = new GameServer();
30
+ const rules: GameRules<Entity> = {
31
+ onRoomCreated: jest.fn(),
32
+ onPlayerJoin: jest.fn(),
33
+ onPlayerLeave: jest.fn(),
34
+ onTick: jest.fn(),
35
+ onCommand: jest.fn(),
36
+ shouldEndRoom: jest.fn(() => false),
37
+ };
38
+
39
+ const room = server.createRoom('room-1', rules);
40
+ expect(server.hasRoom('room-1')).toBe(true);
41
+ expect(room.id).toBe('room-1');
42
+
43
+ const destroyed = server.destroyRoom('room-1');
44
+ expect(destroyed).toBe(true);
45
+ expect(server.hasRoom('room-1')).toBe(false);
46
+ });
47
+
48
+ it('throws when creating duplicate rooms', () => {
49
+ const server = new GameServer();
50
+ const rules: GameRules<Entity> = {
51
+ onRoomCreated: jest.fn(),
52
+ onPlayerJoin: jest.fn(),
53
+ onPlayerLeave: jest.fn(),
54
+ onTick: jest.fn(),
55
+ onCommand: jest.fn(),
56
+ shouldEndRoom: jest.fn(() => false),
57
+ };
58
+
59
+ server.createRoom('room-1', rules);
60
+ expect(() => server.createRoom('room-1', rules)).toThrow(
61
+ 'Room room-1 already exists',
62
+ );
63
+ });
64
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { Grid } from '../src/spatial/Grid.js';
3
+
4
+ describe('Grid', () => {
5
+ it('adds, moves, and removes entities', () => {
6
+ const grid = new Grid(10);
7
+ grid.addEntity('e1', 0, 0);
8
+ expect(grid.getEntityCount()).toBe(1);
9
+ expect(grid.getEntityCell('e1')).toBe('0,0');
10
+
11
+ const moved = grid.moveEntity('e1', 0, 0, 25, 0);
12
+ expect(moved).toBe(true);
13
+ expect(grid.getEntityCell('e1')).toBe('2,0');
14
+
15
+ grid.removeEntity('e1');
16
+ expect(grid.getEntityCount()).toBe(0);
17
+ });
18
+
19
+ it('returns nearby entities within range', () => {
20
+ const grid = new Grid(10);
21
+ grid.addEntity('a', 0, 0);
22
+ grid.addEntity('b', 15, 0); // cell 1,0
23
+ grid.addEntity('c', 50, 0); // cell 5,0
24
+
25
+ const nearby = grid.getNearbyEntities(0, 0, 1);
26
+ expect(nearby.sort()).toEqual(['a', 'b']);
27
+ });
28
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { InputQueue } from '../src/input/InputQueue.js';
3
+ import type { MoveCommand } from '../src/input/Command.js';
4
+
5
+ const move = (seq: number): MoveCommand => ({
6
+ seq,
7
+ type: 'move',
8
+ dir: { x: 1, y: 0 },
9
+ timestamp: seq,
10
+ });
11
+
12
+ describe('InputQueue', () => {
13
+ it('pushes and pops inputs per player', () => {
14
+ const queue = new InputQueue(10);
15
+ queue.push('p1', move(1));
16
+ queue.push('p1', move(2));
17
+
18
+ expect(queue.pop('p1')?.seq).toBe(1);
19
+ expect(queue.pop('p1')?.seq).toBe(2);
20
+ expect(queue.pop('p1')).toBeUndefined();
21
+ });
22
+
23
+ it('drops oldest when full', () => {
24
+ const queue = new InputQueue(2);
25
+ queue.push('p1', move(1));
26
+ queue.push('p1', move(2));
27
+ queue.push('p1', move(3)); // drop seq=1
28
+
29
+ const drained = queue.drain('p1').map((c) => c.seq);
30
+ expect(drained).toEqual([2, 3]);
31
+ });
32
+
33
+ it('drains and clears per player', () => {
34
+ const queue = new InputQueue(5);
35
+ queue.push('p1', move(1));
36
+ queue.push('p1', move(2));
37
+
38
+ const drained = queue.drain('p1').map((c) => c.seq);
39
+ expect(drained).toEqual([1, 2]);
40
+ expect(queue.size('p1')).toBe(0);
41
+ });
42
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { Movement } from '../src/physics/Movement.js';
3
+ import { Entity } from '../src/entities/Entity.js';
4
+
5
+ describe('Movement', () => {
6
+ it('integrates velocity into position', () => {
7
+ const entity = new Entity('e1', 0, 0);
8
+ entity.setVelocity(50, 100);
9
+ Movement.integrate(entity, 1000);
10
+ expect(entity.x).toBe(50);
11
+ expect(entity.y).toBe(100);
12
+ });
13
+
14
+ it('constrains entity to boundary', () => {
15
+ const entity = new Entity('e1', -5, 20);
16
+ const clamped = Movement.constrainToBoundary(entity, {
17
+ minX: 0,
18
+ maxX: 100,
19
+ minY: 0,
20
+ maxY: 50,
21
+ });
22
+
23
+ expect(clamped).toBe(true);
24
+ expect(entity.x).toBe(0);
25
+ expect(entity.y).toBe(20);
26
+ });
27
+
28
+ it('normalizes direction and computes velocity', () => {
29
+ const normalized = Movement.normalize(3, 4);
30
+ expect(normalized.x).toBeCloseTo(0.6);
31
+ expect(normalized.y).toBeCloseTo(0.8);
32
+
33
+ const velocity = Movement.velocityFromDirection(0, 2, 10);
34
+ expect(velocity.vx).toBe(0);
35
+ expect(velocity.vy).toBe(10);
36
+ });
37
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, jest } from '@jest/globals';
2
+ import type { Server, Socket } from 'socket.io';
3
+ import { Network } from '../src/network/Network.js';
4
+
5
+ describe('Network', () => {
6
+ it('registers sockets and sends events', () => {
7
+ const network = new Network();
8
+ const socket = { emit: jest.fn() } as unknown as Socket;
9
+
10
+ network.registerSocket('p1', socket);
11
+ network.sendTo('p1', { op: 'S_TEST', foo: 'bar' });
12
+
13
+ expect(socket.emit).toHaveBeenCalledWith('S_TEST', {
14
+ op: 'S_TEST',
15
+ foo: 'bar',
16
+ });
17
+ });
18
+
19
+ it('broadcasts to all and to room', () => {
20
+ const network = new Network();
21
+ const emit = jest.fn();
22
+ const roomEmit = jest.fn();
23
+
24
+ const io = {
25
+ emit,
26
+ to: jest.fn().mockReturnValue({ emit: roomEmit }),
27
+ } as unknown as Server;
28
+
29
+ network.setServer(io);
30
+ network.broadcast({ op: 'S_ALL', value: 1 });
31
+ network.broadcastToRoom('room-1', { op: 'S_ROOM', value: 2 });
32
+
33
+ expect(emit).toHaveBeenCalledWith('S_ALL', { op: 'S_ALL', value: 1 });
34
+ expect(roomEmit).toHaveBeenCalledWith('S_ROOM', {
35
+ op: 'S_ROOM',
36
+ value: 2,
37
+ });
38
+ });
39
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { Registry } from '../src/entities/Registry.js';
3
+ import { Entity } from '../src/entities/Entity.js';
4
+
5
+ describe('Registry', () => {
6
+ it('adds and removes entities', () => {
7
+ const registry = new Registry<Entity>();
8
+ const entity = new Entity('e1', 0, 0);
9
+
10
+ registry.add(entity);
11
+ expect(registry.has('e1')).toBe(true);
12
+
13
+ registry.remove('e1');
14
+ expect(registry.has('e1')).toBe(false);
15
+ });
16
+
17
+ it('tracks dirty entities and clears dirty flags', () => {
18
+ const registry = new Registry<Entity>();
19
+ const entity = new Entity('e1', 0, 0);
20
+ registry.add(entity);
21
+
22
+ const dirty = registry.getDirtyEntities();
23
+ expect(dirty.map((e) => e.id)).toEqual(['e1']);
24
+ expect(entity.dirty).toBe(false);
25
+ expect(registry.dirtyCount()).toBe(0);
26
+ });
27
+
28
+ it('filters entities by predicate', () => {
29
+ const registry = new Registry<Entity>();
30
+ registry.add(new Entity('e1', 0, 0));
31
+ registry.add(new Entity('e2', 10, 10));
32
+
33
+ const result = registry.filter((e) => e.x > 0);
34
+ expect(result.map((e) => e.id)).toEqual(['e2']);
35
+ });
36
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { RingBuffer } from '../src/utils/RingBuffer.js';
3
+
4
+ describe('RingBuffer', () => {
5
+ it('pushes and shifts items in order', () => {
6
+ const buffer = new RingBuffer<number>(3);
7
+ expect(buffer.push(1)).toBe(true);
8
+ expect(buffer.push(2)).toBe(true);
9
+ expect(buffer.push(3)).toBe(true);
10
+ expect(buffer.push(4)).toBe(false);
11
+
12
+ expect(buffer.shift()).toBe(1);
13
+ expect(buffer.shift()).toBe(2);
14
+ expect(buffer.shift()).toBe(3);
15
+ expect(buffer.shift()).toBeUndefined();
16
+ });
17
+
18
+ it('overwrites oldest when full', () => {
19
+ const buffer = new RingBuffer<number>(2);
20
+ buffer.push(10);
21
+ buffer.push(20);
22
+
23
+ const dropped = buffer.pushOverwrite(30);
24
+ expect(dropped).toBe(10);
25
+ expect(buffer.shift()).toBe(20);
26
+ expect(buffer.shift()).toBe(30);
27
+ });
28
+
29
+ it('clears state without reallocating', () => {
30
+ const buffer = new RingBuffer<number>(2);
31
+ buffer.push(1);
32
+ buffer.push(2);
33
+ buffer.clear();
34
+ expect(buffer.size()).toBe(0);
35
+ expect(buffer.isEmpty()).toBe(true);
36
+ expect(buffer.shift()).toBeUndefined();
37
+ });
38
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, jest } from '@jest/globals';
2
+ import { Room } from '../src/core/Room.js';
3
+ import type { GameRules } from '../src/core/GameRules.js';
4
+ import { Entity } from '../src/entities/Entity.js';
5
+ import type { MoveCommand } from '../src/input/Command.js';
6
+
7
+ const move = (seq: number): MoveCommand => ({
8
+ seq,
9
+ type: 'move',
10
+ dir: { x: 1, y: 0 },
11
+ timestamp: seq,
12
+ });
13
+
14
+ describe('Room', () => {
15
+ it('adds and removes players', () => {
16
+ const rules: GameRules<Entity> = {
17
+ onRoomCreated: jest.fn(),
18
+ onPlayerJoin: jest.fn(),
19
+ onPlayerLeave: jest.fn(),
20
+ onTick: jest.fn(),
21
+ onCommand: jest.fn(),
22
+ shouldEndRoom: jest.fn(() => false),
23
+ };
24
+
25
+ const room = new Room('room-1', rules, { cellSize: 10 });
26
+ const player = new Entity('p1', 0, 0);
27
+
28
+ room.addPlayer(player);
29
+ expect(rules.onPlayerJoin).toHaveBeenCalledWith(room, player);
30
+ expect(room.getRegistry().has('p1')).toBe(true);
31
+
32
+ room.removePlayer('p1');
33
+ expect(rules.onPlayerLeave).toHaveBeenCalledWith(room, 'p1');
34
+ expect(room.getRegistry().has('p1')).toBe(false);
35
+ expect(room.getGrid().getEntityCount()).toBe(0);
36
+ });
37
+
38
+ it('processes input commands on tick', () => {
39
+ const rules: GameRules<Entity> = {
40
+ onRoomCreated: jest.fn(),
41
+ onPlayerJoin: jest.fn(),
42
+ onPlayerLeave: jest.fn(),
43
+ onTick: jest.fn(),
44
+ onCommand: jest.fn(),
45
+ shouldEndRoom: jest.fn(() => false),
46
+ };
47
+
48
+ const room = new Room('room-1', rules);
49
+ const player = new Entity('p1', 0, 0);
50
+ room.addPlayer(player);
51
+ room.queueInput('p1', move(1));
52
+
53
+ room.onTick(1, 16);
54
+
55
+ expect(rules.onCommand).toHaveBeenCalledTimes(1);
56
+ expect(rules.onTick).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it('creates snapshots and clears dirty entities', () => {
60
+ const rules: GameRules<Entity> = {
61
+ onRoomCreated: jest.fn(),
62
+ onPlayerJoin: jest.fn(),
63
+ onPlayerLeave: jest.fn(),
64
+ onTick: jest.fn(),
65
+ onCommand: jest.fn(),
66
+ shouldEndRoom: jest.fn(() => false),
67
+ };
68
+
69
+ const room = new Room('room-1', rules);
70
+ const player = new Entity('p1', 0, 0);
71
+ room.addPlayer(player);
72
+
73
+ const snapshot = room.getSnapshot();
74
+ expect(snapshot.entities).toHaveLength(1);
75
+
76
+ const delta = room.getDeltaSnapshot();
77
+ expect(delta.entities).toHaveLength(1);
78
+ expect(room.getRegistry().dirtyCount()).toBe(0);
79
+ });
80
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { Snapshot } from '../src/network/Snapshot.js';
3
+ import { Entity } from '../src/entities/Entity.js';
4
+
5
+ describe('Snapshot', () => {
6
+ it('creates full and delta snapshots', () => {
7
+ const e1 = new Entity('e1', 1, 2);
8
+ const e2 = new Entity('e2', 3, 4);
9
+ e2.setVelocity(5, 6);
10
+
11
+ const full = Snapshot.create(10, [e1, e2]);
12
+ expect(full.tick).toBe(10);
13
+ expect(full.entities).toHaveLength(2);
14
+
15
+ const delta = Snapshot.createDelta(11, [e2]);
16
+ expect(delta.tick).toBe(11);
17
+ expect(delta.entities).toEqual([{ id: 'e2', x: 3, y: 4, vx: 5, vy: 6 }]);
18
+ });
19
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "module": "ESNext",
6
+ "lib": ["ES2022"],
7
+ "moduleResolution": "bundler",
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true,
17
+ "outDir": "./dist",
18
+ "rootDir": "./src",
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noImplicitReturns": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "allowUnreachableCode": false,
24
+ "allowUnusedLabels": false
25
+ },
26
+ "include": ["src/**/*"],
27
+ "exclude": ["node_modules", "dist", "tests"]
28
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ splitting: false,
10
+ treeshake: true,
11
+ minify: false,
12
+ target: 'es2022',
13
+ outDir: 'dist',
14
+ });