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.
Files changed (103) hide show
  1. package/README.md +61 -0
  2. package/dist/core/binary-codec/binary-codec.d.ts +159 -0
  3. package/dist/core/binary-codec/binary-codec.js +336 -0
  4. package/dist/core/binary-codec/index.d.ts +1 -0
  5. package/dist/core/binary-codec/index.js +1 -0
  6. package/dist/core/events/event-system.d.ts +71 -0
  7. package/dist/core/events/event-system.js +88 -0
  8. package/dist/core/events/index.d.ts +1 -0
  9. package/dist/core/events/index.js +1 -0
  10. package/dist/core/fixed-ticker/fixed-ticker.d.ts +105 -0
  11. package/dist/core/fixed-ticker/fixed-ticker.js +91 -0
  12. package/dist/core/fixed-ticker/index.d.ts +1 -0
  13. package/dist/core/fixed-ticker/index.js +1 -0
  14. package/dist/core/generate-id/generate-id.d.ts +21 -0
  15. package/dist/core/generate-id/generate-id.js +25 -0
  16. package/dist/core/generate-id/index.d.ts +1 -0
  17. package/dist/core/generate-id/index.js +1 -0
  18. package/dist/core/index.d.ts +8 -0
  19. package/dist/core/index.js +8 -0
  20. package/dist/core/lerp/index.d.ts +1 -0
  21. package/dist/core/lerp/index.js +1 -0
  22. package/dist/core/lerp/lerp.d.ts +40 -0
  23. package/dist/core/lerp/lerp.js +42 -0
  24. package/dist/core/navmesh/index.d.ts +1 -0
  25. package/dist/core/navmesh/index.js +1 -0
  26. package/dist/core/navmesh/navmesh.d.ts +116 -0
  27. package/dist/core/navmesh/navmesh.js +666 -0
  28. package/dist/core/pooled-codec/index.d.ts +1 -0
  29. package/dist/core/pooled-codec/index.js +1 -0
  30. package/dist/core/pooled-codec/pooled-codec.d.ts +140 -0
  31. package/dist/core/pooled-codec/pooled-codec.js +213 -0
  32. package/dist/core/prediction/index.d.ts +1 -0
  33. package/dist/core/prediction/index.js +1 -0
  34. package/dist/core/prediction/prediction.d.ts +64 -0
  35. package/dist/core/prediction/prediction.js +90 -0
  36. package/dist/core.esm.js +1 -0
  37. package/dist/core.js +1 -0
  38. package/dist/index.d.ts +16 -0
  39. package/dist/index.js +18 -0
  40. package/dist/protocol/index.d.ts +43 -0
  41. package/dist/protocol/index.js +43 -0
  42. package/dist/protocol/intent/index.d.ts +39 -0
  43. package/dist/protocol/intent/index.js +38 -0
  44. package/dist/protocol/intent/intent-registry.d.ts +54 -0
  45. package/dist/protocol/intent/intent-registry.js +73 -0
  46. package/dist/protocol/intent/intent.d.ts +12 -0
  47. package/dist/protocol/intent/intent.js +1 -0
  48. package/dist/protocol/snapshot/index.d.ts +44 -0
  49. package/dist/protocol/snapshot/index.js +43 -0
  50. package/dist/protocol/snapshot/snapshot-codec.d.ts +48 -0
  51. package/dist/protocol/snapshot/snapshot-codec.js +56 -0
  52. package/dist/protocol/snapshot/snapshot-registry.d.ts +100 -0
  53. package/dist/protocol/snapshot/snapshot-registry.js +136 -0
  54. package/dist/protocol/snapshot/snapshot.d.ts +19 -0
  55. package/dist/protocol/snapshot/snapshot.js +30 -0
  56. package/package.json +54 -0
  57. package/src/core/binary-codec/README.md +60 -0
  58. package/src/core/binary-codec/binary-codec.test.ts +300 -0
  59. package/src/core/binary-codec/binary-codec.ts +430 -0
  60. package/src/core/binary-codec/index.ts +1 -0
  61. package/src/core/events/README.md +47 -0
  62. package/src/core/events/event-system.test.ts +243 -0
  63. package/src/core/events/event-system.ts +140 -0
  64. package/src/core/events/index.ts +1 -0
  65. package/src/core/fixed-ticker/README.md +77 -0
  66. package/src/core/fixed-ticker/fixed-ticker.test.ts +151 -0
  67. package/src/core/fixed-ticker/fixed-ticker.ts +158 -0
  68. package/src/core/fixed-ticker/index.ts +1 -0
  69. package/src/core/generate-id/README.md +18 -0
  70. package/src/core/generate-id/generate-id.test.ts +79 -0
  71. package/src/core/generate-id/generate-id.ts +37 -0
  72. package/src/core/generate-id/index.ts +1 -0
  73. package/src/core/index.ts +8 -0
  74. package/src/core/lerp/README.md +79 -0
  75. package/src/core/lerp/index.ts +1 -0
  76. package/src/core/lerp/lerp.test.ts +90 -0
  77. package/src/core/lerp/lerp.ts +42 -0
  78. package/src/core/navmesh/README.md +124 -0
  79. package/src/core/navmesh/index.ts +1 -0
  80. package/src/core/navmesh/navmesh.test.ts +344 -0
  81. package/src/core/navmesh/navmesh.ts +850 -0
  82. package/src/core/pooled-codec/README.md +70 -0
  83. package/src/core/pooled-codec/index.ts +1 -0
  84. package/src/core/pooled-codec/pooled-codec.test.ts +349 -0
  85. package/src/core/pooled-codec/pooled-codec.ts +239 -0
  86. package/src/core/prediction/README.md +64 -0
  87. package/src/core/prediction/index.ts +1 -0
  88. package/src/core/prediction/prediction.test.ts +422 -0
  89. package/src/core/prediction/prediction.ts +101 -0
  90. package/src/index.ts +20 -0
  91. package/src/protocol/README.md +310 -0
  92. package/src/protocol/index.ts +44 -0
  93. package/src/protocol/intent/index.ts +40 -0
  94. package/src/protocol/intent/intent-registry.test.ts +237 -0
  95. package/src/protocol/intent/intent-registry.ts +88 -0
  96. package/src/protocol/intent/intent.ts +12 -0
  97. package/src/protocol/snapshot/index.ts +45 -0
  98. package/src/protocol/snapshot/snapshot-codec.test.ts +138 -0
  99. package/src/protocol/snapshot/snapshot-codec.ts +71 -0
  100. package/src/protocol/snapshot/snapshot-registry.test.ts +302 -0
  101. package/src/protocol/snapshot/snapshot-registry.ts +162 -0
  102. package/src/protocol/snapshot/snapshot.test.ts +76 -0
  103. package/src/protocol/snapshot/snapshot.ts +41 -0
@@ -0,0 +1,310 @@
1
+ # Protocol Layer
2
+
3
+ Minimalist primitives for networked multiplayer games. Just intents and snapshots - you handle the rest.
4
+
5
+ ## What You Get
6
+
7
+ 1. **IntentRegistry** - Register, encode, decode intents with zero allocations
8
+ 2. **Snapshot<T>** - Type-safe delta updates
9
+ 3. **applySnapshot()** - Deep merge snapshot updates into state
10
+
11
+ That's it. No loops, no queues, no storage - just the codec layer.
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Define Your Types
16
+
17
+ ```ts
18
+ import { Intent, Snapshot } from "./protocol";
19
+
20
+ interface GameState {
21
+ players: Record<number, { x: number; y: number; health: number }>;
22
+ }
23
+
24
+ interface MoveIntent extends Intent {
25
+ kind: 1;
26
+ tick: number;
27
+ dx: number;
28
+ dy: number;
29
+ }
30
+
31
+ interface ShootIntent extends Intent {
32
+ kind: 2;
33
+ tick: number;
34
+ targetId: number;
35
+ }
36
+ ```
37
+
38
+ ### 2. Register Intent Codecs
39
+
40
+ ```ts
41
+ import { IntentRegistry } from "./protocol/intent";
42
+ import { PooledCodec } from "./core/pooled-codec";
43
+ import { BinaryCodec } from "./core/binary-codec";
44
+
45
+ const registry = new IntentRegistry();
46
+
47
+ // Register move intent
48
+ registry.register(
49
+ 1,
50
+ new PooledCodec({
51
+ kind: BinaryCodec.u8,
52
+ tick: BinaryCodec.u32,
53
+ dx: BinaryCodec.f32,
54
+ dy: BinaryCodec.f32,
55
+ })
56
+ );
57
+
58
+ // Register shoot intent
59
+ registry.register(
60
+ 2,
61
+ new PooledCodec({
62
+ kind: BinaryCodec.u8,
63
+ tick: BinaryCodec.u32,
64
+ targetId: BinaryCodec.u32,
65
+ })
66
+ );
67
+ ```
68
+
69
+ ### 3. Client: Encode & Send Intents
70
+
71
+ ```ts
72
+ // Generate intent from input
73
+ const intent: MoveIntent = {
74
+ kind: 1,
75
+ tick: currentTick,
76
+ dx: input.x,
77
+ dy: input.y,
78
+ };
79
+
80
+ // Encode using pooled codec (zero allocation)
81
+ const buf = registry.encode(intent);
82
+
83
+ // Send to server
84
+ socket.send(buf);
85
+ ```
86
+
87
+ ### 4. Server: Receive & Decode Intents
88
+
89
+ ```ts
90
+ socket.on("data", (buf: Uint8Array) => {
91
+ // First byte is always the intent kind
92
+ const kind = buf[0];
93
+
94
+ // Decode using registered codec
95
+ const intent = registry.decode(kind, buf);
96
+
97
+ // Process intent in your game logic
98
+ processIntent(intent);
99
+ });
100
+ ```
101
+
102
+ ### 5. Server: Create & Send Snapshots
103
+
104
+ ```ts
105
+ import { SnapshotCodec } from "./protocol/snapshot";
106
+ import { PooledCodec } from "./core/pooled-codec";
107
+
108
+ // Create codec for state updates (same schema as your state)
109
+ const stateCodec = new PooledCodec({
110
+ players: // your state schema here
111
+ });
112
+
113
+ const snapshotCodec = new SnapshotCodec(stateCodec);
114
+
115
+ // After processing intents, create a snapshot
116
+ const snapshot: Snapshot<GameState> = {
117
+ tick: serverTick,
118
+ updates: {
119
+ // Only include what changed
120
+ players: {
121
+ 1: { x: 10, y: 20, health: 90 },
122
+ 2: { x: 15, y: 25 }, // health unchanged
123
+ },
124
+ },
125
+ };
126
+
127
+ // Encode and send (zero allocation)
128
+ const buf = snapshotCodec.encode(snapshot);
129
+ socket.send(buf);
130
+ ```
131
+
132
+ ### 6. Client: Decode & Apply Snapshots
133
+
134
+ ```ts
135
+ import { applySnapshot } from "./protocol/snapshot";
136
+
137
+ socket.on("snapshot", (buf: Uint8Array) => {
138
+ // Decode snapshot
139
+ const snapshot = snapshotCodec.decode(buf);
140
+
141
+ // Deep merge updates into client state
142
+ applySnapshot(clientState, snapshot);
143
+
144
+ // Render updated state
145
+ render(clientState);
146
+ });
147
+ ```
148
+
149
+ ## Memory Efficiency
150
+
151
+ The `PooledCodec` reuses buffers and objects:
152
+
153
+ ```ts
154
+ // Encoding - reuses Uint8Array from pool
155
+ const buf1 = registry.encode(intent1); // Acquires from pool
156
+ socket.send(buf1);
157
+ // buf1 automatically released back to pool after use
158
+
159
+ // Decoding - reuses objects from pool
160
+ const intent = registry.decode(kind, buf); // Acquires from pool
161
+ processIntent(intent);
162
+ // intent automatically released back to pool
163
+ ```
164
+
165
+ **Zero allocations** during gameplay = zero GC pauses.
166
+
167
+ ## Snapshot Deep Merging
168
+
169
+ `applySnapshot` intelligently merges nested updates:
170
+
171
+ ```ts
172
+ const state: GameState = {
173
+ players: {
174
+ 1: { x: 0, y: 0, health: 100 },
175
+ 2: { x: 10, y: 10, health: 100 },
176
+ },
177
+ };
178
+
179
+ const snapshot: Snapshot<GameState> = {
180
+ tick: 100,
181
+ updates: {
182
+ players: {
183
+ 1: { x: 5 }, // Only update x, keep y and health
184
+ },
185
+ },
186
+ };
187
+
188
+ applySnapshot(state, snapshot);
189
+
190
+ // Result:
191
+ // state.players[1] = { x: 5, y: 0, health: 100 }
192
+ // state.players[2] unchanged
193
+ ```
194
+
195
+ - **Objects**: Deep merged
196
+ - **Arrays**: Replaced entirely
197
+ - **Primitives**: Overwritten
198
+
199
+ ## Efficient Partial Updates with SnapshotRegistry
200
+
201
+ For games with many state fields, use `SnapshotRegistry` to send only specific update types:
202
+
203
+ ```ts
204
+ import { SnapshotRegistry } from "./protocol/snapshot";
205
+ import { PooledCodec } from "./core/pooled-codec";
206
+ import { BinaryCodec } from "./core/binary-codec";
207
+
208
+ // Define separate update types
209
+ interface PlayerUpdate {
210
+ players: Array<{ entityId: number; x: number; y: number }>;
211
+ }
212
+
213
+ interface ScoreUpdate {
214
+ score: number;
215
+ }
216
+
217
+ interface ProjectileUpdate {
218
+ projectiles: Array<{ id: number; x: number; y: number }>;
219
+ }
220
+
221
+ type GameUpdate = PlayerUpdate | ScoreUpdate | ProjectileUpdate;
222
+
223
+ // Create registry
224
+ const registry = new SnapshotRegistry<GameUpdate>();
225
+
226
+ // Register codecs for each update type
227
+ registry.register("players", new PooledCodec({
228
+ players: // schema
229
+ }));
230
+
231
+ registry.register("score", new PooledCodec({
232
+ score: BinaryCodec.u32
233
+ }));
234
+
235
+ registry.register("projectiles", new PooledCodec({
236
+ projectiles: // array schema
237
+ }));
238
+
239
+ // Server: Send only what changed
240
+ if (playersChanged) {
241
+ const buf = registry.encode("players", {
242
+ tick: 100,
243
+ updates: { players: [{ entityId: 1, x: 5, y: 10 }] }
244
+ });
245
+ socket.send(buf);
246
+ }
247
+
248
+ if (scoreChanged) {
249
+ const buf = registry.encode("score", {
250
+ tick: 100,
251
+ updates: { score: 50 }
252
+ });
253
+ socket.send(buf);
254
+ }
255
+
256
+ // Client: Decode and apply
257
+ socket.on("snapshot", (buf: Uint8Array) => {
258
+ const { type, snapshot } = registry.decode(buf);
259
+ applySnapshot(clientState, snapshot);
260
+ });
261
+ ```
262
+
263
+ **Benefits:**
264
+ - ✅ Only encode fields that changed (true partial updates)
265
+ - ✅ No bandwidth wasted on nil/empty values
266
+ - ✅ Type ID embedded in message (1 byte overhead)
267
+ - ✅ Works with arrays, Records, primitives
268
+
269
+ ## Multiple Intents
270
+
271
+ You can have as many intent types as needed:
272
+
273
+ ```ts
274
+ enum IntentKind {
275
+ Move = 1,
276
+ Shoot = 2,
277
+ Jump = 3,
278
+ UseItem = 4,
279
+ Chat = 5,
280
+ }
281
+
282
+ registry.register(IntentKind.Move, new PooledCodec(moveSchema));
283
+ registry.register(IntentKind.Shoot, new PooledCodec(shootSchema));
284
+ registry.register(IntentKind.Jump, new PooledCodec(jumpSchema));
285
+ // ... etc
286
+ ```
287
+
288
+ ## What You Build Yourself
289
+
290
+ This layer intentionally **does not** provide:
291
+
292
+ - ❌ Game loops (use `FixedTicker` from core)
293
+ - ❌ Intent queuing/buffering (application-specific)
294
+ - ❌ Snapshot storage/history (application-specific)
295
+ - ❌ Network transport (WebSocket, WebRTC, etc.)
296
+ - ❌ Prediction/rollback (use `IntentTracker` and `Reconciliator` from core)
297
+ - ❌ Interpolation (application-specific)
298
+ - ❌ Lag compensation (application-specific)
299
+
300
+ The protocol layer gives you type-safe codecs. Core utilities like `FixedTicker`, `IntentTracker`, and `Reconciliator` are available separately.
301
+
302
+ ## Testing
303
+
304
+ ```bash
305
+ npm test -- intent-registry
306
+ ```
307
+
308
+ ## License
309
+
310
+ MIT
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Protocol Layer - Type-safe networking primitives
3
+ *
4
+ * This layer enforces:
5
+ * - Type-safe intent definitions (extend Intent interface)
6
+ * - Type-safe snapshot definitions (Snapshot<YourState>)
7
+ * - Memory-efficient encoding (use PooledCodec from core)
8
+ *
9
+ * You provide:
10
+ * - Your intent types
11
+ * - Your state types
12
+ * - Your schemas (for binary encoding)
13
+ * - Codec instances (instantiate once, reuse)
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * // Define your types
18
+ * interface MoveIntent extends Intent {
19
+ * kind: 1;
20
+ * tick: number;
21
+ * dx: number;
22
+ * dy: number;
23
+ * }
24
+ *
25
+ * interface GameState {
26
+ * players: Record<number, { x: number; y: number }>;
27
+ * }
28
+ *
29
+ * // Create codecs once (reuse these!)
30
+ * const intentRegistry = new IntentRegistry();
31
+ * intentRegistry.register(1, new PooledCodec(moveSchema));
32
+ *
33
+ * const snapshotCodec = new SnapshotCodec<GameState>(
34
+ * new PooledCodec(stateSchema)
35
+ * );
36
+ *
37
+ * // Use them
38
+ * const buf = intentRegistry.encode(intent);
39
+ * const snapshot = snapshotCodec.decode(buf);
40
+ * ```
41
+ */
42
+
43
+ export * from "./intent";
44
+ export * from "./snapshot";
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Intent system for client-to-server actions.
3
+ *
4
+ * Intents are player/AI actions that need to be:
5
+ * 1. Encoded efficiently (binary)
6
+ * 2. Sent over network
7
+ * 3. Decoded on the other end
8
+ * 4. Processed deterministically
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { Intent, IntentRegistry } from './protocol/intent';
13
+ * import { PooledCodec } from '../core/pooled-codec';
14
+ * import { BinaryCodec } from '../core/binary-codec';
15
+ *
16
+ * // 1. Define your intent type
17
+ * interface MoveIntent extends Intent {
18
+ * kind: 1;
19
+ * tick: number;
20
+ * dx: number;
21
+ * dy: number;
22
+ * }
23
+ *
24
+ * // 2. Create registry and register once (reuse this instance)
25
+ * const registry = new IntentRegistry();
26
+ * registry.register(1, new PooledCodec({
27
+ * kind: BinaryCodec.u8,
28
+ * tick: BinaryCodec.u32,
29
+ * dx: BinaryCodec.f32,
30
+ * dy: BinaryCodec.f32,
31
+ * }));
32
+ *
33
+ * // 3. Encode/decode
34
+ * const buf = registry.encode(intent);
35
+ * const decoded = registry.decode(1, buf);
36
+ * ```
37
+ */
38
+
39
+ export type { Intent } from "./intent";
40
+ export { IntentRegistry } from "./intent-registry";
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { IntentRegistry } from "./intent-registry";
3
+ import { Intent } from "./intent";
4
+ import { Codec } from "./intent-registry";
5
+
6
+ // Mock intent types
7
+ interface MockIntent extends Intent {
8
+ kind: 1;
9
+ tick: number;
10
+ value: number;
11
+ }
12
+
13
+ interface AnotherIntent extends Intent {
14
+ kind: 2;
15
+ tick: number;
16
+ data: string;
17
+ }
18
+
19
+ // Mock codec implementation
20
+ class MockCodec implements Codec<MockIntent> {
21
+ encode(value: MockIntent): Uint8Array {
22
+ const buf = new Uint8Array(9);
23
+ buf[0] = value.kind;
24
+ new DataView(buf.buffer).setUint32(1, value.tick, true);
25
+ new DataView(buf.buffer).setUint32(5, value.value, true);
26
+ return buf;
27
+ }
28
+
29
+ decode(buf: Uint8Array): MockIntent {
30
+ const view = new DataView(buf.buffer, buf.byteOffset);
31
+ return {
32
+ kind: 1,
33
+ tick: view.getUint32(1, true),
34
+ value: view.getUint32(5, true),
35
+ };
36
+ }
37
+ }
38
+
39
+ class StringCodec implements Codec<AnotherIntent> {
40
+ encode(value: AnotherIntent): Uint8Array {
41
+ const encoder = new TextEncoder();
42
+ const dataBytes = encoder.encode(value.data);
43
+ const buf = new Uint8Array(5 + dataBytes.length);
44
+ buf[0] = value.kind;
45
+ new DataView(buf.buffer).setUint32(1, value.tick, true);
46
+ buf.set(dataBytes, 5);
47
+ return buf;
48
+ }
49
+
50
+ decode(buf: Uint8Array): AnotherIntent {
51
+ const view = new DataView(buf.buffer, buf.byteOffset);
52
+ const decoder = new TextDecoder();
53
+ return {
54
+ kind: 2,
55
+ tick: view.getUint32(1, true),
56
+ data: decoder.decode(buf.slice(5)),
57
+ };
58
+ }
59
+ }
60
+
61
+ describe("IntentRegistry", () => {
62
+ let registry: IntentRegistry;
63
+
64
+ beforeEach(() => {
65
+ registry = new IntentRegistry();
66
+ });
67
+
68
+ describe("register", () => {
69
+ it("should register a codec for an intent kind", () => {
70
+ const codec = new MockCodec();
71
+ registry.register(1, codec);
72
+ expect(registry.has(1)).toBe(true);
73
+ });
74
+
75
+ it("should throw error when registering duplicate kind", () => {
76
+ const codec = new MockCodec();
77
+ registry.register(1, codec);
78
+ expect(() => registry.register(1, codec)).toThrow(
79
+ "Intent kind 1 is already registered"
80
+ );
81
+ });
82
+
83
+ it("should allow registering multiple different kinds", () => {
84
+ registry.register(1, new MockCodec());
85
+ registry.register(2, new StringCodec());
86
+ expect(registry.has(1)).toBe(true);
87
+ expect(registry.has(2)).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("encode", () => {
92
+ it("should encode an intent using registered codec", () => {
93
+ registry.register(1, new MockCodec());
94
+ const intent: MockIntent = { kind: 1, tick: 100, value: 42 };
95
+ const buf = registry.encode(intent);
96
+
97
+ expect(buf).toBeInstanceOf(Uint8Array);
98
+ expect(buf.length).toBe(9);
99
+ expect(buf[0]).toBe(1); // kind
100
+ });
101
+
102
+ it("should throw error when encoding unregistered intent kind", () => {
103
+ const intent: MockIntent = { kind: 1, tick: 100, value: 42 };
104
+ expect(() => registry.encode(intent)).toThrow(
105
+ "No codec registered for intent kind 1"
106
+ );
107
+ });
108
+
109
+ it("should encode different intent types correctly", () => {
110
+ registry.register(1, new MockCodec());
111
+ registry.register(2, new StringCodec());
112
+
113
+ const intent1: MockIntent = { kind: 1, tick: 100, value: 42 };
114
+ const intent2: AnotherIntent = { kind: 2, tick: 200, data: "test" };
115
+
116
+ const buf1 = registry.encode(intent1);
117
+ const buf2 = registry.encode(intent2);
118
+
119
+ expect(buf1[0]).toBe(1);
120
+ expect(buf2[0]).toBe(2);
121
+ });
122
+ });
123
+
124
+ describe("decode", () => {
125
+ it("should decode a buffer using registered codec", () => {
126
+ registry.register(1, new MockCodec());
127
+ const original: MockIntent = { kind: 1, tick: 100, value: 42 };
128
+ const buf = registry.encode(original);
129
+ const decoded = registry.decode(1, buf);
130
+
131
+ expect(decoded).toEqual(original);
132
+ });
133
+
134
+ it("should throw error when decoding with unregistered kind", () => {
135
+ const buf = new Uint8Array([1, 0, 0, 0, 100, 0, 0, 0, 42]);
136
+ expect(() => registry.decode(1, buf)).toThrow(
137
+ "No codec registered for intent kind 1"
138
+ );
139
+ });
140
+
141
+ it("should decode different intent types correctly", () => {
142
+ registry.register(1, new MockCodec());
143
+ registry.register(2, new StringCodec());
144
+
145
+ const intent1: MockIntent = { kind: 1, tick: 100, value: 42 };
146
+ const intent2: AnotherIntent = { kind: 2, tick: 200, data: "hello" };
147
+
148
+ const buf1 = registry.encode(intent1);
149
+ const buf2 = registry.encode(intent2);
150
+
151
+ const decoded1 = registry.decode(1, buf1);
152
+ const decoded2 = registry.decode(2, buf2);
153
+
154
+ expect(decoded1).toEqual(intent1);
155
+ expect(decoded2).toEqual(intent2);
156
+ });
157
+ });
158
+
159
+ describe("has", () => {
160
+ it("should return true for registered kinds", () => {
161
+ registry.register(1, new MockCodec());
162
+ expect(registry.has(1)).toBe(true);
163
+ });
164
+
165
+ it("should return false for unregistered kinds", () => {
166
+ expect(registry.has(1)).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe("unregister", () => {
171
+ it("should remove a registered codec", () => {
172
+ registry.register(1, new MockCodec());
173
+ expect(registry.has(1)).toBe(true);
174
+
175
+ const removed = registry.unregister(1);
176
+ expect(removed).toBe(true);
177
+ expect(registry.has(1)).toBe(false);
178
+ });
179
+
180
+ it("should return false when unregistering non-existent kind", () => {
181
+ const removed = registry.unregister(1);
182
+ expect(removed).toBe(false);
183
+ });
184
+ });
185
+
186
+ describe("clear", () => {
187
+ it("should remove all registered codecs", () => {
188
+ registry.register(1, new MockCodec());
189
+ registry.register(2, new StringCodec());
190
+
191
+ registry.clear();
192
+
193
+ expect(registry.has(1)).toBe(false);
194
+ expect(registry.has(2)).toBe(false);
195
+ });
196
+ });
197
+
198
+ describe("getKinds", () => {
199
+ it("should return empty array when no codecs registered", () => {
200
+ expect(registry.getKinds()).toEqual([]);
201
+ });
202
+
203
+ it("should return all registered kinds", () => {
204
+ registry.register(1, new MockCodec());
205
+ registry.register(2, new StringCodec());
206
+
207
+ const kinds = registry.getKinds();
208
+ expect(kinds).toContain(1);
209
+ expect(kinds).toContain(2);
210
+ expect(kinds.length).toBe(2);
211
+ });
212
+ });
213
+
214
+ describe("round-trip encoding/decoding", () => {
215
+ it("should preserve intent data through encode/decode cycle", () => {
216
+ registry.register(1, new MockCodec());
217
+
218
+ const original: MockIntent = { kind: 1, tick: 12345, value: 98765 };
219
+ const buf = registry.encode(original);
220
+ const decoded = registry.decode(1, buf);
221
+
222
+ expect(decoded).toEqual(original);
223
+ });
224
+
225
+ it("should handle multiple round-trips", () => {
226
+ registry.register(1, new MockCodec());
227
+
228
+ const original: MockIntent = { kind: 1, tick: 100, value: 42 };
229
+
230
+ for (let i = 0; i < 10; i++) {
231
+ const buf = registry.encode(original);
232
+ const decoded = registry.decode(1, buf);
233
+ expect(decoded).toEqual(original);
234
+ }
235
+ });
236
+ });
237
+ });
@@ -0,0 +1,88 @@
1
+ import type { Intent } from "./intent";
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
+ * Registry for mapping intent kinds to their codecs.
13
+ *
14
+ * Users instantiate this once and register their intent types with
15
+ * PooledCodec instances (from core/pooled-codec).
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { IntentRegistry } from './protocol/intent';
20
+ * import { PooledCodec } from './core/pooled-codec';
21
+ * import { BinaryCodec } from './core/binary-codec';
22
+ *
23
+ * const registry = new IntentRegistry();
24
+ *
25
+ * registry.register(1, new PooledCodec({
26
+ * kind: BinaryCodec.u8,
27
+ * tick: BinaryCodec.u32,
28
+ * dx: BinaryCodec.f32,
29
+ * dy: BinaryCodec.f32,
30
+ * }));
31
+ *
32
+ * // Encode/decode
33
+ * const buf = registry.encode(intent);
34
+ * const decoded = registry.decode(1, buf);
35
+ * ```
36
+ */
37
+ export class IntentRegistry {
38
+ private codecs = new Map<number, Codec<any>>();
39
+
40
+ /**
41
+ * Register a codec for a specific intent kind.
42
+ * Call this once per intent type at startup.
43
+ */
44
+ register<T extends Intent>(kind: number, codec: Codec<T>): void {
45
+ if (this.codecs.has(kind)) {
46
+ throw new Error(`Intent kind ${kind} is already registered`);
47
+ }
48
+ this.codecs.set(kind, codec);
49
+ }
50
+
51
+ /**
52
+ * Encode an intent into binary format.
53
+ */
54
+ encode<T extends Intent>(intent: T): Uint8Array {
55
+ const codec = this.codecs.get(intent.kind);
56
+ if (!codec) {
57
+ throw new Error(`No codec registered for intent kind ${intent.kind}`);
58
+ }
59
+ return codec.encode(intent);
60
+ }
61
+
62
+ /**
63
+ * Decode binary data into an intent.
64
+ */
65
+ decode(kind: number, buf: Uint8Array): Intent {
66
+ const codec = this.codecs.get(kind);
67
+ if (!codec) {
68
+ throw new Error(`No codec registered for intent kind ${kind}`);
69
+ }
70
+ return codec.decode(buf);
71
+ }
72
+
73
+ has(kind: number): boolean {
74
+ return this.codecs.has(kind);
75
+ }
76
+
77
+ unregister(kind: number): boolean {
78
+ return this.codecs.delete(kind);
79
+ }
80
+
81
+ clear(): void {
82
+ this.codecs.clear();
83
+ }
84
+
85
+ getKinds(): number[] {
86
+ return Array.from(this.codecs.keys());
87
+ }
88
+ }