murow 0.0.1
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.
- package/README.md +61 -0
- package/dist/core/binary-codec/binary-codec.d.ts +159 -0
- package/dist/core/binary-codec/binary-codec.js +336 -0
- package/dist/core/binary-codec/index.d.ts +1 -0
- package/dist/core/binary-codec/index.js +1 -0
- package/dist/core/events/event-system.d.ts +71 -0
- package/dist/core/events/event-system.js +88 -0
- package/dist/core/events/index.d.ts +1 -0
- package/dist/core/events/index.js +1 -0
- package/dist/core/fixed-ticker/fixed-ticker.d.ts +105 -0
- package/dist/core/fixed-ticker/fixed-ticker.js +91 -0
- package/dist/core/fixed-ticker/index.d.ts +1 -0
- package/dist/core/fixed-ticker/index.js +1 -0
- package/dist/core/generate-id/generate-id.d.ts +21 -0
- package/dist/core/generate-id/generate-id.js +25 -0
- package/dist/core/generate-id/index.d.ts +1 -0
- package/dist/core/generate-id/index.js +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.js +8 -0
- package/dist/core/lerp/index.d.ts +1 -0
- package/dist/core/lerp/index.js +1 -0
- package/dist/core/lerp/lerp.d.ts +40 -0
- package/dist/core/lerp/lerp.js +42 -0
- package/dist/core/navmesh/index.d.ts +1 -0
- package/dist/core/navmesh/index.js +1 -0
- package/dist/core/navmesh/navmesh.d.ts +116 -0
- package/dist/core/navmesh/navmesh.js +666 -0
- package/dist/core/pooled-codec/index.d.ts +1 -0
- package/dist/core/pooled-codec/index.js +1 -0
- package/dist/core/pooled-codec/pooled-codec.d.ts +140 -0
- package/dist/core/pooled-codec/pooled-codec.js +213 -0
- package/dist/core/prediction/index.d.ts +1 -0
- package/dist/core/prediction/index.js +1 -0
- package/dist/core/prediction/prediction.d.ts +64 -0
- package/dist/core/prediction/prediction.js +90 -0
- package/dist/core.esm.js +1 -0
- package/dist/core.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +18 -0
- package/dist/protocol/index.d.ts +43 -0
- package/dist/protocol/index.js +43 -0
- package/dist/protocol/intent/index.d.ts +39 -0
- package/dist/protocol/intent/index.js +38 -0
- package/dist/protocol/intent/intent-registry.d.ts +54 -0
- package/dist/protocol/intent/intent-registry.js +73 -0
- package/dist/protocol/intent/intent.d.ts +12 -0
- package/dist/protocol/intent/intent.js +1 -0
- package/dist/protocol/snapshot/index.d.ts +44 -0
- package/dist/protocol/snapshot/index.js +43 -0
- package/dist/protocol/snapshot/snapshot-codec.d.ts +48 -0
- package/dist/protocol/snapshot/snapshot-codec.js +56 -0
- package/dist/protocol/snapshot/snapshot-registry.d.ts +100 -0
- package/dist/protocol/snapshot/snapshot-registry.js +136 -0
- package/dist/protocol/snapshot/snapshot.d.ts +19 -0
- package/dist/protocol/snapshot/snapshot.js +30 -0
- package/package.json +54 -0
- package/src/core/binary-codec/README.md +60 -0
- package/src/core/binary-codec/binary-codec.test.ts +300 -0
- package/src/core/binary-codec/binary-codec.ts +430 -0
- package/src/core/binary-codec/index.ts +1 -0
- package/src/core/events/README.md +47 -0
- package/src/core/events/event-system.test.ts +243 -0
- package/src/core/events/event-system.ts +140 -0
- package/src/core/events/index.ts +1 -0
- package/src/core/fixed-ticker/README.md +77 -0
- package/src/core/fixed-ticker/fixed-ticker.test.ts +151 -0
- package/src/core/fixed-ticker/fixed-ticker.ts +158 -0
- package/src/core/fixed-ticker/index.ts +1 -0
- package/src/core/generate-id/README.md +18 -0
- package/src/core/generate-id/generate-id.test.ts +79 -0
- package/src/core/generate-id/generate-id.ts +37 -0
- package/src/core/generate-id/index.ts +1 -0
- package/src/core/index.ts +8 -0
- package/src/core/lerp/README.md +79 -0
- package/src/core/lerp/index.ts +1 -0
- package/src/core/lerp/lerp.test.ts +90 -0
- package/src/core/lerp/lerp.ts +42 -0
- package/src/core/navmesh/README.md +124 -0
- package/src/core/navmesh/index.ts +1 -0
- package/src/core/navmesh/navmesh.test.ts +344 -0
- package/src/core/navmesh/navmesh.ts +850 -0
- package/src/core/pooled-codec/README.md +70 -0
- package/src/core/pooled-codec/index.ts +1 -0
- package/src/core/pooled-codec/pooled-codec.test.ts +349 -0
- package/src/core/pooled-codec/pooled-codec.ts +239 -0
- package/src/core/prediction/README.md +64 -0
- package/src/core/prediction/index.ts +1 -0
- package/src/core/prediction/prediction.test.ts +422 -0
- package/src/core/prediction/prediction.ts +101 -0
- package/src/index.ts +20 -0
- package/src/protocol/README.md +310 -0
- package/src/protocol/index.ts +44 -0
- package/src/protocol/intent/index.ts +40 -0
- package/src/protocol/intent/intent-registry.test.ts +237 -0
- package/src/protocol/intent/intent-registry.ts +88 -0
- package/src/protocol/intent/intent.ts +12 -0
- package/src/protocol/snapshot/index.ts +45 -0
- package/src/protocol/snapshot/snapshot-codec.test.ts +138 -0
- package/src/protocol/snapshot/snapshot-codec.ts +71 -0
- package/src/protocol/snapshot/snapshot-registry.test.ts +302 -0
- package/src/protocol/snapshot/snapshot-registry.ts +162 -0
- package/src/protocol/snapshot/snapshot.test.ts +76 -0
- package/src/protocol/snapshot/snapshot.ts +41 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base interface for all game intents.
|
|
3
|
+
*
|
|
4
|
+
* Intents represent player or AI actions that need to be processed by the game simulation.
|
|
5
|
+
* They are timestamped with a tick number for deterministic replay and synchronization.
|
|
6
|
+
*/
|
|
7
|
+
export interface Intent {
|
|
8
|
+
/** The game tick at which this intent should be processed */
|
|
9
|
+
tick: number;
|
|
10
|
+
/** Numeric identifier for the intent type (used for codec lookup) */
|
|
11
|
+
kind: number;
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot system for server-to-client state updates.
|
|
3
|
+
*
|
|
4
|
+
* Snapshots are delta updates that contain:
|
|
5
|
+
* 1. Server tick number
|
|
6
|
+
* 2. Partial state updates (only what changed)
|
|
7
|
+
*
|
|
8
|
+
* They need to be:
|
|
9
|
+
* 1. Encoded efficiently (binary)
|
|
10
|
+
* 2. Sent over network
|
|
11
|
+
* 3. Decoded on the client
|
|
12
|
+
* 4. Merged into client state
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { Snapshot, SnapshotCodec, applySnapshot } from './protocol/snapshot';
|
|
17
|
+
* import { PooledCodec } from '../core/pooled-codec';
|
|
18
|
+
* import { BinaryCodec } from '../core/binary-codec';
|
|
19
|
+
*
|
|
20
|
+
* interface GameState {
|
|
21
|
+
* players: Record<number, { x: number; y: number }>;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* // 1. Create codec once (reuse this instance)
|
|
25
|
+
* const snapshotCodec = new SnapshotCodec<GameState>(
|
|
26
|
+
* new PooledCodec({ players: // ... your schema })
|
|
27
|
+
* );
|
|
28
|
+
*
|
|
29
|
+
* // 2. Server: Encode snapshot
|
|
30
|
+
* const snapshot: Snapshot<GameState> = {
|
|
31
|
+
* tick: 100,
|
|
32
|
+
* updates: { players: { 1: { x: 5, y: 10 } } }
|
|
33
|
+
* };
|
|
34
|
+
* const buf = snapshotCodec.encode(snapshot);
|
|
35
|
+
*
|
|
36
|
+
* // 3. Client: Decode and apply
|
|
37
|
+
* const snapshot = snapshotCodec.decode(buf);
|
|
38
|
+
* applySnapshot(clientState, snapshot);
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export type { Snapshot } from "./snapshot";
|
|
43
|
+
export { applySnapshot } from "./snapshot";
|
|
44
|
+
export { SnapshotCodec } from "./snapshot-codec";
|
|
45
|
+
export { SnapshotRegistry } from "./snapshot-registry";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { SnapshotCodec } from "./snapshot-codec";
|
|
3
|
+
import { Snapshot } from "./snapshot";
|
|
4
|
+
import { Codec } from "./snapshot-codec";
|
|
5
|
+
|
|
6
|
+
// Simple mock codec for testing
|
|
7
|
+
class MockCodec<T> implements Codec<T> {
|
|
8
|
+
encode(value: T): Uint8Array {
|
|
9
|
+
const json = JSON.stringify(value);
|
|
10
|
+
return new TextEncoder().encode(json);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
decode(buf: Uint8Array): T {
|
|
14
|
+
const json = new TextDecoder().decode(buf);
|
|
15
|
+
return JSON.parse(json);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TestState {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
health?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("SnapshotCodec", () => {
|
|
26
|
+
it("should encode and decode a snapshot", () => {
|
|
27
|
+
const codec = new SnapshotCodec<TestState>(new MockCodec());
|
|
28
|
+
const snapshot: Snapshot<TestState> = {
|
|
29
|
+
tick: 100,
|
|
30
|
+
updates: { x: 10, y: 20 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const buf = codec.encode(snapshot);
|
|
34
|
+
const decoded = codec.decode(buf);
|
|
35
|
+
|
|
36
|
+
expect(decoded.tick).toBe(100);
|
|
37
|
+
expect(decoded.updates.x).toBe(10);
|
|
38
|
+
expect(decoded.updates.y).toBe(20);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should handle partial updates", () => {
|
|
42
|
+
const codec = new SnapshotCodec<TestState>(new MockCodec());
|
|
43
|
+
const snapshot: Snapshot<TestState> = {
|
|
44
|
+
tick: 50,
|
|
45
|
+
updates: { x: 5 },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const buf = codec.encode(snapshot);
|
|
49
|
+
const decoded = codec.decode(buf);
|
|
50
|
+
|
|
51
|
+
expect(decoded.tick).toBe(50);
|
|
52
|
+
expect(decoded.updates.x).toBe(5);
|
|
53
|
+
expect(decoded.updates.y).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should handle large tick numbers", () => {
|
|
57
|
+
const codec = new SnapshotCodec<TestState>(new MockCodec());
|
|
58
|
+
const snapshot: Snapshot<TestState> = {
|
|
59
|
+
tick: 4294967295, // Max u32
|
|
60
|
+
updates: { x: 1, y: 2 },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const buf = codec.encode(snapshot);
|
|
64
|
+
const decoded = codec.decode(buf);
|
|
65
|
+
|
|
66
|
+
expect(decoded.tick).toBe(4294967295);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should preserve tick in binary format", () => {
|
|
70
|
+
const codec = new SnapshotCodec<TestState>(new MockCodec());
|
|
71
|
+
const snapshot: Snapshot<TestState> = {
|
|
72
|
+
tick: 12345,
|
|
73
|
+
updates: { x: 1 },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const buf = codec.encode(snapshot);
|
|
77
|
+
|
|
78
|
+
// First 4 bytes should be the tick (little-endian)
|
|
79
|
+
const tick = new DataView(buf.buffer, buf.byteOffset).getUint32(0, true);
|
|
80
|
+
expect(tick).toBe(12345);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should handle empty updates", () => {
|
|
84
|
+
const codec = new SnapshotCodec<TestState>(new MockCodec());
|
|
85
|
+
const snapshot: Snapshot<TestState> = {
|
|
86
|
+
tick: 100,
|
|
87
|
+
updates: {},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const buf = codec.encode(snapshot);
|
|
91
|
+
const decoded = codec.decode(buf);
|
|
92
|
+
|
|
93
|
+
expect(decoded.tick).toBe(100);
|
|
94
|
+
expect(decoded.updates).toEqual({});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should handle nested state", () => {
|
|
98
|
+
interface NestedState {
|
|
99
|
+
player: {
|
|
100
|
+
position: { x: number; y: number };
|
|
101
|
+
health: number;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const codec = new SnapshotCodec<NestedState>(new MockCodec());
|
|
106
|
+
const snapshot: Snapshot<NestedState> = {
|
|
107
|
+
tick: 200,
|
|
108
|
+
updates: {
|
|
109
|
+
player: {
|
|
110
|
+
position: { x: 10, y: 20 },
|
|
111
|
+
health: 80,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const buf = codec.encode(snapshot);
|
|
117
|
+
const decoded = codec.decode(buf);
|
|
118
|
+
|
|
119
|
+
expect(decoded.tick).toBe(200);
|
|
120
|
+
expect(decoded.updates.player?.position.x).toBe(10);
|
|
121
|
+
expect(decoded.updates.player?.position.y).toBe(20);
|
|
122
|
+
expect(decoded.updates.player?.health).toBe(80);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should round-trip multiple times", () => {
|
|
126
|
+
const codec = new SnapshotCodec<TestState>(new MockCodec());
|
|
127
|
+
const original: Snapshot<TestState> = {
|
|
128
|
+
tick: 999,
|
|
129
|
+
updates: { x: 123, y: 456, health: 78 },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < 10; i++) {
|
|
133
|
+
const buf = codec.encode(original);
|
|
134
|
+
const decoded = codec.decode(buf);
|
|
135
|
+
expect(decoded).toEqual(original);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Snapshot } from "./snapshot";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic codec interface (users import from core/pooled-codec)
|
|
5
|
+
*/
|
|
6
|
+
export interface Codec<T> {
|
|
7
|
+
encode(value: T): Uint8Array;
|
|
8
|
+
decode(buf: Uint8Array): T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Codec for encoding/decoding snapshots with binary serialization.
|
|
13
|
+
*
|
|
14
|
+
* Users instantiate this once with a PooledCodec for their state schema.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { SnapshotCodec } from './protocol/snapshot';
|
|
19
|
+
* import { PooledCodec } from './core/pooled-codec';
|
|
20
|
+
* import { BinaryCodec } from './core/binary-codec';
|
|
21
|
+
*
|
|
22
|
+
* interface GameState {
|
|
23
|
+
* players: Record<number, { x: number; y: number }>;
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* const snapshotCodec = new SnapshotCodec<GameState>(
|
|
27
|
+
* new PooledCodec({ players: // schema })
|
|
28
|
+
* );
|
|
29
|
+
*
|
|
30
|
+
* // Encode/decode
|
|
31
|
+
* const buf = snapshotCodec.encode(snapshot);
|
|
32
|
+
* const decoded = snapshotCodec.decode(buf);
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export class SnapshotCodec<T> {
|
|
36
|
+
/**
|
|
37
|
+
* @param updatesCodec Codec for encoding/decoding the state updates
|
|
38
|
+
*/
|
|
39
|
+
constructor(private updatesCodec: Codec<Partial<T>>) {}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Encode a snapshot into binary format.
|
|
43
|
+
* Format: [tick: u32][updates: encoded by updatesCodec]
|
|
44
|
+
*/
|
|
45
|
+
encode(snapshot: Snapshot<T>): Uint8Array {
|
|
46
|
+
const updatesBytes = this.updatesCodec.encode(snapshot.updates);
|
|
47
|
+
const buf = new Uint8Array(4 + updatesBytes.length);
|
|
48
|
+
|
|
49
|
+
// Encode tick (4 bytes, little-endian)
|
|
50
|
+
new DataView(buf.buffer).setUint32(0, snapshot.tick, true);
|
|
51
|
+
|
|
52
|
+
// Encode updates
|
|
53
|
+
buf.set(updatesBytes, 4);
|
|
54
|
+
|
|
55
|
+
return buf;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decode binary data into a snapshot.
|
|
60
|
+
*/
|
|
61
|
+
decode(buf: Uint8Array): Snapshot<T> {
|
|
62
|
+
// Decode tick (first 4 bytes)
|
|
63
|
+
const tick = new DataView(buf.buffer, buf.byteOffset).getUint32(0, true);
|
|
64
|
+
|
|
65
|
+
// Decode updates (remaining bytes)
|
|
66
|
+
const updatesBytes = buf.subarray(4);
|
|
67
|
+
const updates = this.updatesCodec.decode(updatesBytes);
|
|
68
|
+
|
|
69
|
+
return { tick, updates };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { SnapshotRegistry } from "./snapshot-registry";
|
|
3
|
+
import { Snapshot } from "./snapshot";
|
|
4
|
+
|
|
5
|
+
// Mock codec for testing
|
|
6
|
+
class MockCodec<T> {
|
|
7
|
+
encode(value: T): Uint8Array {
|
|
8
|
+
const json = JSON.stringify(value);
|
|
9
|
+
return new TextEncoder().encode(json);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
decode(buf: Uint8Array): T {
|
|
13
|
+
const json = new TextDecoder().decode(buf);
|
|
14
|
+
return JSON.parse(json);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Test update types
|
|
19
|
+
interface PlayerUpdate {
|
|
20
|
+
players: Array<{ entityId: number; x: number; y: number }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ScoreUpdate {
|
|
24
|
+
score: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ProjectileUpdate {
|
|
28
|
+
projectiles: Array<{ id: number; x: number; y: number }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type GameUpdate = PlayerUpdate | ScoreUpdate | ProjectileUpdate;
|
|
32
|
+
|
|
33
|
+
describe("SnapshotRegistry", () => {
|
|
34
|
+
let registry: SnapshotRegistry<GameUpdate>;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
registry = new SnapshotRegistry<GameUpdate>();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("register", () => {
|
|
41
|
+
it("should register a codec for a snapshot type", () => {
|
|
42
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
43
|
+
expect(registry.has("players")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should throw error when registering duplicate type", () => {
|
|
47
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
48
|
+
expect(() => registry.register("players", new MockCodec<PlayerUpdate>())).toThrow(
|
|
49
|
+
'Snapshot type "players" is already registered'
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should allow registering multiple types", () => {
|
|
54
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
55
|
+
registry.register("score", new MockCodec<ScoreUpdate>());
|
|
56
|
+
registry.register("projectiles", new MockCodec<ProjectileUpdate>());
|
|
57
|
+
|
|
58
|
+
expect(registry.has("players")).toBe(true);
|
|
59
|
+
expect(registry.has("score")).toBe(true);
|
|
60
|
+
expect(registry.has("projectiles")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("encode", () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
67
|
+
registry.register("score", new MockCodec<ScoreUpdate>());
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should encode a snapshot with type ID", () => {
|
|
71
|
+
const snapshot: Snapshot<PlayerUpdate> = {
|
|
72
|
+
tick: 100,
|
|
73
|
+
updates: {
|
|
74
|
+
players: [
|
|
75
|
+
{ entityId: 1, x: 10, y: 20 },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const buf = registry.encode("players", snapshot);
|
|
81
|
+
|
|
82
|
+
expect(buf).toBeInstanceOf(Uint8Array);
|
|
83
|
+
expect(buf.length).toBeGreaterThan(5); // type(1) + tick(4) + data
|
|
84
|
+
expect(buf[0]).toBe(0); // First registered type gets ID 0
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should encode different types with different type IDs", () => {
|
|
88
|
+
const playerSnapshot: Snapshot<PlayerUpdate> = {
|
|
89
|
+
tick: 100,
|
|
90
|
+
updates: { players: [{ entityId: 1, x: 10, y: 20 }] },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const scoreSnapshot: Snapshot<ScoreUpdate> = {
|
|
94
|
+
tick: 101,
|
|
95
|
+
updates: { score: 50 },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const buf1 = registry.encode("players", playerSnapshot);
|
|
99
|
+
const buf2 = registry.encode("score", scoreSnapshot);
|
|
100
|
+
|
|
101
|
+
expect(buf1[0]).toBe(0); // players = ID 0
|
|
102
|
+
expect(buf2[0]).toBe(1); // score = ID 1
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should throw error when encoding unregistered type", () => {
|
|
106
|
+
const snapshot: Snapshot<ProjectileUpdate> = {
|
|
107
|
+
tick: 100,
|
|
108
|
+
updates: { projectiles: [] },
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
expect(() => registry.encode("projectiles", snapshot)).toThrow(
|
|
112
|
+
'No codec registered for snapshot type "projectiles"'
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should preserve tick number in encoding", () => {
|
|
117
|
+
const snapshot: Snapshot<ScoreUpdate> = {
|
|
118
|
+
tick: 12345,
|
|
119
|
+
updates: { score: 100 },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const buf = registry.encode("score", snapshot);
|
|
123
|
+
|
|
124
|
+
// Tick is at bytes 1-4 (after type ID)
|
|
125
|
+
const tick = new DataView(buf.buffer, buf.byteOffset + 1).getUint32(0, true);
|
|
126
|
+
expect(tick).toBe(12345);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("decode", () => {
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
133
|
+
registry.register("score", new MockCodec<ScoreUpdate>());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should decode a snapshot and return type", () => {
|
|
137
|
+
const original: Snapshot<PlayerUpdate> = {
|
|
138
|
+
tick: 100,
|
|
139
|
+
updates: {
|
|
140
|
+
players: [
|
|
141
|
+
{ entityId: 1, x: 10, y: 20 },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const buf = registry.encode("players", original);
|
|
147
|
+
const { type, snapshot } = registry.decode(buf);
|
|
148
|
+
|
|
149
|
+
expect(type).toBe("players");
|
|
150
|
+
expect(snapshot.tick).toBe(100);
|
|
151
|
+
expect(snapshot.updates).toEqual(original.updates);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should decode different snapshot types correctly", () => {
|
|
155
|
+
const playerSnapshot: Snapshot<PlayerUpdate> = {
|
|
156
|
+
tick: 100,
|
|
157
|
+
updates: { players: [{ entityId: 1, x: 10, y: 20 }] },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const scoreSnapshot: Snapshot<ScoreUpdate> = {
|
|
161
|
+
tick: 101,
|
|
162
|
+
updates: { score: 50 },
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const buf1 = registry.encode("players", playerSnapshot);
|
|
166
|
+
const buf2 = registry.encode("score", scoreSnapshot);
|
|
167
|
+
|
|
168
|
+
const decoded1 = registry.decode(buf1);
|
|
169
|
+
const decoded2 = registry.decode(buf2);
|
|
170
|
+
|
|
171
|
+
expect(decoded1.type).toBe("players");
|
|
172
|
+
expect(decoded1.snapshot.updates).toEqual(playerSnapshot.updates);
|
|
173
|
+
|
|
174
|
+
expect(decoded2.type).toBe("score");
|
|
175
|
+
expect(decoded2.snapshot.updates).toEqual(scoreSnapshot.updates);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should throw error when decoding unknown type ID", () => {
|
|
179
|
+
const buf = new Uint8Array([99, 0, 0, 0, 100]); // Unknown type ID 99
|
|
180
|
+
|
|
181
|
+
expect(() => registry.decode(buf)).toThrow("Unknown snapshot type ID: 99");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("round-trip encoding/decoding", () => {
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
188
|
+
registry.register("score", new MockCodec<ScoreUpdate>());
|
|
189
|
+
registry.register("projectiles", new MockCodec<ProjectileUpdate>());
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should preserve data through encode/decode cycle", () => {
|
|
193
|
+
const original: Snapshot<PlayerUpdate> = {
|
|
194
|
+
tick: 500,
|
|
195
|
+
updates: {
|
|
196
|
+
players: [
|
|
197
|
+
{ entityId: 1, x: 100, y: 200 },
|
|
198
|
+
{ entityId: 2, x: 300, y: 400 },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const buf = registry.encode("players", original);
|
|
204
|
+
const { type, snapshot } = registry.decode(buf);
|
|
205
|
+
|
|
206
|
+
expect(type).toBe("players");
|
|
207
|
+
expect(snapshot).toEqual(original);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should handle multiple round-trips", () => {
|
|
211
|
+
const original: Snapshot<ScoreUpdate> = {
|
|
212
|
+
tick: 1000,
|
|
213
|
+
updates: { score: 99999 },
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < 10; i++) {
|
|
217
|
+
const buf = registry.encode("score", original);
|
|
218
|
+
const { type, snapshot } = registry.decode(buf);
|
|
219
|
+
expect(type).toBe("score");
|
|
220
|
+
expect(snapshot).toEqual(original);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should handle arrays in updates", () => {
|
|
225
|
+
const original: Snapshot<ProjectileUpdate> = {
|
|
226
|
+
tick: 250,
|
|
227
|
+
updates: {
|
|
228
|
+
projectiles: [
|
|
229
|
+
{ id: 1, x: 10, y: 20 },
|
|
230
|
+
{ id: 2, x: 30, y: 40 },
|
|
231
|
+
{ id: 3, x: 50, y: 60 },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const buf = registry.encode("projectiles", original);
|
|
237
|
+
const { type, snapshot } = registry.decode(buf);
|
|
238
|
+
|
|
239
|
+
expect(type).toBe("projectiles");
|
|
240
|
+
expect(snapshot.updates).toEqual(original.updates);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("getTypes", () => {
|
|
245
|
+
it("should return empty array when no types registered", () => {
|
|
246
|
+
expect(registry.getTypes()).toEqual([]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should return all registered types", () => {
|
|
250
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
251
|
+
registry.register("score", new MockCodec<ScoreUpdate>());
|
|
252
|
+
|
|
253
|
+
const types = registry.getTypes();
|
|
254
|
+
expect(types).toContain("players");
|
|
255
|
+
expect(types).toContain("score");
|
|
256
|
+
expect(types.length).toBe(2);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("integration scenarios", () => {
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
registry.register("players", new MockCodec<PlayerUpdate>());
|
|
263
|
+
registry.register("score", new MockCodec<ScoreUpdate>());
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should allow sending only specific updates", () => {
|
|
267
|
+
// Server only sends player updates this tick
|
|
268
|
+
const playerBuf = registry.encode("players", {
|
|
269
|
+
tick: 100,
|
|
270
|
+
updates: { players: [{ entityId: 1, x: 5, y: 10 }] },
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Next tick, only send score
|
|
274
|
+
const scoreBuf = registry.encode("score", {
|
|
275
|
+
tick: 101,
|
|
276
|
+
updates: { score: 100 },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Client can decode both
|
|
280
|
+
const decoded1 = registry.decode(playerBuf);
|
|
281
|
+
const decoded2 = registry.decode(scoreBuf);
|
|
282
|
+
|
|
283
|
+
expect(decoded1.type).toBe("players");
|
|
284
|
+
expect(decoded2.type).toBe("score");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should maintain type safety with unions", () => {
|
|
288
|
+
const snapshot: Snapshot<PlayerUpdate> = {
|
|
289
|
+
tick: 100,
|
|
290
|
+
updates: { players: [{ entityId: 1, x: 5, y: 10 }] },
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const buf = registry.encode("players", snapshot);
|
|
294
|
+
const { type, snapshot: decoded } = registry.decode(buf);
|
|
295
|
+
|
|
296
|
+
// Type narrowing based on type field
|
|
297
|
+
if (type === "players") {
|
|
298
|
+
expect(decoded.updates.players).toBeDefined();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|