@voicethere/agent 0.5.3 → 0.5.4

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.
@@ -2,13 +2,20 @@
2
2
  * Multiplayer object-sync template with ownership checks.
3
3
  *
4
4
  * World layout:
5
- * - one global Float32Array
5
+ * - one global Float32Array (fixed MAX_LIVE_OBJECTS slots when Redis is enabled)
6
6
  * - each tracked object uses exactly 9 floats:
7
7
  * [objectId, posX, posY, posZ, posW, dirX, dirY, dirZ, dirW]
8
8
  *
9
+ * With project Redis (`AGENT_REDIS_URL`), the world blob is shared across runner
10
+ * workers (key `game-sync:world`). One worker holds a sim lock per tick, runs
11
+ * physics, and writes the blob; every worker GET+broadcasts to local sessions.
12
+ *
9
13
  * Control messages:
10
14
  * - `{ type: "register" }` -> allocates (or reuses) one 9-float slot
11
15
  * - server replies `{ type: "register_ack", objectId }`
16
+ * - `{ type: "register_nack", reason: "world_full", maxObjects: 25 }` when cap reached
17
+ * - `{ type: "unregister" }` or `{ type: "remove", objectId?: number }` -> releases owned object(s)
18
+ * - `{ type: "unregister_ack", objectId }` or `{ type: "unregister_nack", reason }`
12
19
  *
13
20
  * Simulation:
14
21
  * - server-authoritative movement at 60Hz
@@ -22,373 +29,494 @@
22
29
  * Build:
23
30
  * npx @voicethere/agent build --entry templates/game-sync.ts
24
31
  */
32
+ import Redis from "ioredis";
25
33
  import {
26
- agentLog,
27
- defineAgent,
28
- sendBinaryToClient,
29
- sendToClient,
34
+ agentLog,
35
+ defineAgent,
36
+ sendBinaryToClient,
37
+ sendToClient,
30
38
  } from "@voicethere/agent";
31
39
 
32
- const OBJECT_STRIDE = 9;
40
+ import {
41
+ MAX_LIVE_OBJECTS,
42
+ parseChatCommand,
43
+ parseRegisterCommand,
44
+ parseUnregisterCommand,
45
+ REGISTER_NACK_REASON_WORLD_FULL,
46
+ UNREGISTER_NACK_REASON_NOT_FOUND,
47
+ UNREGISTER_NACK_REASON_NOT_OWNER,
48
+ } from "./game-sync-protocol.js";
49
+ import {
50
+ LUA_ALLOCATE_OBJECT,
51
+ LUA_RELEASE_OBJECT,
52
+ REDIS_EVAL_KEYS,
53
+ } from "./game-sync-redis.js";
54
+ import {
55
+ BOARD_HEIGHT,
56
+ BOARD_WIDTH,
57
+ OBJECT_RADIUS,
58
+ simulateWorldStep,
59
+ } from "./game-sync-sim.js";
60
+ import {
61
+ collectActiveObjectIds,
62
+ countLiveObjects,
63
+ createEmptyWorldBuffer,
64
+ findFirstEmptySlot,
65
+ markSlotFree,
66
+ normalizeWorldBuffer,
67
+ objectIdToSlot,
68
+ OBJECT_SLOT_BYTE_LENGTH,
69
+ REDIS_SIM_LOCK_KEY,
70
+ REDIS_WORLD_KEY,
71
+ slotToObjectId,
72
+ writeObjectSlot,
73
+ } from "./game-sync-world-layout.js";
74
+
33
75
  const BROADCAST_HZ = 60;
34
76
  const BROADCAST_INTERVAL_MS = Math.floor(1000 / BROADCAST_HZ);
35
- const BOARD_WIDTH = 1280;
36
- const BOARD_HEIGHT = 720;
37
- const OBJECT_RADIUS = 25;
77
+ const SIM_LOCK_TTL_MS = BROADCAST_INTERVAL_MS * 2;
38
78
  const MIN_SPEED = 90;
39
79
  const MAX_SPEED = 180;
40
- const COLLISION_RESTITUTION = 1.0;
41
80
 
42
81
  const connectedSessions = new Set<string>();
43
- const objectOwners = new Map<number, string>(); // objectId -> sessionId
44
- const sessionObjects = new Map<string, Set<number>>(); // sessionId -> owned objectIds
45
- // Slots we can recycle. Keeping a free-list avoids unbounded array growth when
46
- // clients churn (join/leave repeatedly).
82
+ const objectOwners = new Map<number, string>();
83
+ const sessionObjects = new Map<string, Set<number>>();
47
84
  const freeSlots: number[] = [];
48
85
 
49
- let worldState = new Float32Array(0);
86
+ let worldState = createEmptyWorldBuffer();
87
+ let redis: Redis | null = null;
50
88
  let broadcastTimer: NodeJS.Timeout | null = null;
51
89
 
52
90
  interface TrackedObjectInfo {
53
- objectId: number;
54
- ownerSessionId: string;
91
+ objectId: number;
92
+ ownerSessionId: string;
55
93
  }
56
94
 
57
95
  function rand(min: number, max: number): number {
58
- return min + Math.random() * (max - min);
96
+ return min + Math.random() * (max - min);
59
97
  }
60
98
 
61
99
  function randomVelocity(): number {
62
- return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
100
+ return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
63
101
  }
64
102
 
65
- function slotToObjectId(slot: number): number {
66
- // Object ids are 1-based so 0 can represent "unused".
67
- return slot + 1;
103
+ function randomInitialTail(): Buffer {
104
+ const floats = new Float32Array([
105
+ rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS),
106
+ rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS),
107
+ 0,
108
+ 1,
109
+ randomVelocity(),
110
+ randomVelocity(),
111
+ 0,
112
+ 0,
113
+ ]);
114
+ return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
68
115
  }
69
116
 
70
- function objectIdToSlot(objectId: number): number {
71
- // Inverse of slotToObjectId().
72
- return objectId - 1;
117
+ function attachObjectToSession(sessionId: string, objectId: number): void {
118
+ let owned = sessionObjects.get(sessionId);
119
+ if (!owned) {
120
+ owned = new Set<number>();
121
+ sessionObjects.set(sessionId, owned);
122
+ }
123
+ owned.add(objectId);
124
+ objectOwners.set(objectId, sessionId);
73
125
  }
74
126
 
75
- function markSlotFree(slot: number): void {
76
- // Zero the entire record. Slot is still allocated in array length terms,
77
- // but logically available for reuse.
78
- const start = slot * OBJECT_STRIDE;
79
- for (let i = 0; i < OBJECT_STRIDE; i += 1) {
80
- worldState[start + i] = 0;
127
+ function detachObjectFromSession(objectId: number): void {
128
+ const owner = objectOwners.get(objectId);
129
+ if (owner) {
130
+ const owned = sessionObjects.get(owner);
131
+ owned?.delete(objectId);
132
+ if (owned && owned.size === 0) {
133
+ sessionObjects.delete(owner);
81
134
  }
135
+ }
136
+ objectOwners.delete(objectId);
82
137
  }
83
138
 
84
- function allocateSlot(): number {
85
- // Prefer recycling old slots before growing worldState.
86
- const reused = freeSlots.shift();
87
- if (reused !== undefined) return reused;
139
+ function allocateSlotInMemory(): number | null {
140
+ if (countLiveObjects(worldState) >= MAX_LIVE_OBJECTS) {
141
+ return null;
142
+ }
88
143
 
89
- // Grow by exactly one object record (9 floats).
90
- const next = new Float32Array(worldState.length + OBJECT_STRIDE);
91
- next.set(worldState);
92
- worldState = next;
93
- return worldState.length / OBJECT_STRIDE - 1;
94
- }
144
+ const reused = freeSlots.shift();
145
+ if (reused !== undefined) {
146
+ return reused;
147
+ }
95
148
 
96
- function attachObjectToSession(sessionId: string, objectId: number): void {
97
- // Tracks ownership in both directions so validation and cleanup are O(1).
98
- let owned = sessionObjects.get(sessionId);
99
- if (!owned) {
100
- owned = new Set<number>();
101
- sessionObjects.set(sessionId, owned);
102
- }
103
- owned.add(objectId);
104
- objectOwners.set(objectId, sessionId);
149
+ return findFirstEmptySlot(worldState);
105
150
  }
106
151
 
107
- function releaseObject(objectId: number): void {
108
- // Remove reverse-ownership references first.
109
- const owner = objectOwners.get(objectId);
110
- if (owner) {
111
- const owned = sessionObjects.get(owner);
112
- owned?.delete(objectId);
113
- if (owned && owned.size === 0) {
114
- sessionObjects.delete(owner);
115
- }
116
- }
152
+ function releaseObjectInMemory(objectId: number): void {
153
+ const slot = objectIdToSlot(objectId);
154
+ if (slot < 0 || slot >= MAX_LIVE_OBJECTS) return;
117
155
 
118
- objectOwners.delete(objectId);
156
+ markSlotFree(worldState, slot);
157
+ if (!freeSlots.includes(slot)) {
158
+ freeSlots.push(slot);
159
+ freeSlots.sort((a, b) => a - b);
160
+ }
161
+ }
119
162
 
120
- const slot = objectIdToSlot(objectId);
121
- if (slot < 0) return;
122
- if (slot >= worldState.length / OBJECT_STRIDE) return;
163
+ function registerObjectInMemory(sessionId: string): number | null {
164
+ const slot = allocateSlotInMemory();
165
+ if (slot === null) {
166
+ return null;
167
+ }
168
+
169
+ const objectId = slotToObjectId(slot);
170
+ writeObjectSlot(
171
+ worldState,
172
+ slot,
173
+ objectId,
174
+ rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS),
175
+ rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS),
176
+ 0,
177
+ 1,
178
+ randomVelocity(),
179
+ randomVelocity(),
180
+ 0,
181
+ 0,
182
+ );
183
+ attachObjectToSession(sessionId, objectId);
184
+ return objectId;
185
+ }
123
186
 
124
- // Free slot contents and push slot into free-list for future register() calls.
125
- markSlotFree(slot);
126
- if (!freeSlots.includes(slot)) {
127
- freeSlots.push(slot);
128
- freeSlots.sort((a, b) => a - b);
129
- }
187
+ async function registerObjectInRedis(
188
+ sessionId: string,
189
+ ): Promise<number | null> {
190
+ if (!redis) {
191
+ return registerObjectInMemory(sessionId);
192
+ }
193
+
194
+ const result = await redis.eval(
195
+ LUA_ALLOCATE_OBJECT,
196
+ 1,
197
+ REDIS_EVAL_KEYS.worldKey,
198
+ REDIS_EVAL_KEYS.worldByteLength,
199
+ REDIS_EVAL_KEYS.slotByteLength,
200
+ REDIS_EVAL_KEYS.maxSlots,
201
+ randomInitialTail(),
202
+ REDIS_EVAL_KEYS.headers,
203
+ );
204
+
205
+ const objectId = Number(result);
206
+ if (!Number.isFinite(objectId) || objectId < 1) {
207
+ return null;
208
+ }
209
+
210
+ attachObjectToSession(sessionId, objectId);
211
+ return objectId;
130
212
  }
131
213
 
132
- function registerObject(sessionId: string): number {
133
- // Allocate (or reuse) one slot and stamp object id plus initial state.
134
- const slot = allocateSlot();
135
- const objectId = slotToObjectId(slot);
136
- const start = slot * OBJECT_STRIDE;
137
- worldState[start] = objectId;
138
- worldState[start + 1] = rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS);
139
- worldState[start + 2] = rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS);
140
- worldState[start + 3] = 0;
141
- worldState[start + 4] = 1;
142
- worldState[start + 5] = randomVelocity();
143
- worldState[start + 6] = randomVelocity();
144
- worldState[start + 7] = 0;
145
- worldState[start + 8] = 0;
146
- attachObjectToSession(sessionId, objectId);
147
- return objectId;
214
+ async function releaseObjectInRedis(objectId: number): Promise<boolean> {
215
+ detachObjectFromSession(objectId);
216
+ if (!redis) {
217
+ releaseObjectInMemory(objectId);
218
+ return true;
219
+ }
220
+
221
+ const released = await redis.eval(
222
+ LUA_RELEASE_OBJECT,
223
+ 1,
224
+ REDIS_EVAL_KEYS.worldKey,
225
+ REDIS_EVAL_KEYS.worldByteLength,
226
+ REDIS_EVAL_KEYS.slotByteLength,
227
+ REDIS_EVAL_KEYS.maxSlots,
228
+ String(objectId),
229
+ REDIS_EVAL_KEYS.headers,
230
+ );
231
+ return Number(released) === 1;
148
232
  }
149
233
 
150
- function parseRegisterCommand(message: unknown): boolean {
151
- if (!message || typeof message !== "object") return false;
152
- const record = message as { type?: unknown };
153
- return record.type === "register";
234
+ function highestOwnedObjectId(sessionId: string): number | null {
235
+ const owned = sessionObjects.get(sessionId);
236
+ if (!owned || owned.size === 0) return null;
237
+ return Math.max(...owned);
154
238
  }
155
239
 
156
- function parseChatCommand(
157
- message: unknown,
158
- ): { text: string } | null {
159
- if (!message || typeof message !== "object") return null;
160
- const record = message as { type?: unknown; text?: unknown };
161
- if (record.type !== "chat" || typeof record.text !== "string") return null;
162
- const text = record.text.trim();
163
- if (!text) return null;
164
- return {text};
240
+ async function unregisterObject(
241
+ sessionId: string,
242
+ objectId?: number,
243
+ ): Promise<{ ok: true; objectId: number } | { ok: false; reason: string }> {
244
+ const owned = sessionObjects.get(sessionId);
245
+ if (!owned || owned.size === 0) {
246
+ return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
247
+ }
248
+
249
+ let targetId = objectId;
250
+ if (targetId === undefined) {
251
+ const highest = highestOwnedObjectId(sessionId);
252
+ if (highest === null) {
253
+ return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
254
+ }
255
+ targetId = highest;
256
+ }
257
+
258
+ if (!owned.has(targetId)) {
259
+ if (objectId !== undefined) {
260
+ return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_OWNER };
261
+ }
262
+ return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
263
+ }
264
+
265
+ notifyObjectReleased(targetId, sessionId);
266
+ await releaseObjectInRedis(targetId);
267
+ return { ok: true, objectId: targetId };
165
268
  }
166
269
 
167
270
  function trackedObjectsSnapshot(): TrackedObjectInfo[] {
168
- const objects: TrackedObjectInfo[] = [];
169
- for (const [objectId, ownerSessionId] of objectOwners) {
170
- objects.push({objectId, ownerSessionId});
171
- }
172
- objects.sort((a, b) => a.objectId - b.objectId);
173
- return objects;
271
+ const objects: TrackedObjectInfo[] = [];
272
+ for (const [objectId, ownerSessionId] of objectOwners) {
273
+ objects.push({ objectId, ownerSessionId });
274
+ }
275
+ objects.sort((a, b) => a.objectId - b.objectId);
276
+ return objects;
174
277
  }
175
278
 
176
- function notifyObjectRegistered(objectId: number, ownerSessionId: string): void {
177
- for (const sessionId of connectedSessions) {
178
- if (sessionId === ownerSessionId) continue;
179
- sendToClient(sessionId, {
180
- type: "object_registered",
181
- objectId,
182
- ownerSessionId,
183
- });
184
- }
279
+ function notifyObjectRegistered(
280
+ objectId: number,
281
+ ownerSessionId: string,
282
+ ): void {
283
+ for (const sessionId of connectedSessions) {
284
+ if (sessionId === ownerSessionId) continue;
285
+ sendToClient(sessionId, {
286
+ type: "object_registered",
287
+ objectId,
288
+ ownerSessionId,
289
+ });
290
+ }
185
291
  }
186
292
 
187
293
  function notifyObjectReleased(objectId: number, ownerSessionId: string): void {
188
- for (const sessionId of connectedSessions) {
189
- if (sessionId === ownerSessionId) continue;
190
- sendToClient(sessionId, {
191
- type: "object_released",
192
- objectId,
193
- ownerSessionId,
194
- });
195
- }
294
+ for (const sessionId of connectedSessions) {
295
+ if (sessionId === ownerSessionId) continue;
296
+ sendToClient(sessionId, {
297
+ type: "object_released",
298
+ objectId,
299
+ ownerSessionId,
300
+ });
301
+ }
196
302
  }
197
303
 
198
- function broadcastWorldState(): void {
199
- if (connectedSessions.size === 0) return;
200
- // Copy to a detached buffer so downstream sends cannot observe mutations
201
- // from subsequent writes in the same event loop tick.
202
- const payload = Buffer.from(worldState.buffer.slice(0));
203
- for (const sessionId of connectedSessions) {
204
- sendBinaryToClient(sessionId, payload, "sync");
205
- }
304
+ function copyWorldBuffer(world: Float32Array): Buffer {
305
+ return Buffer.from(world.buffer, world.byteOffset, world.byteLength);
206
306
  }
207
307
 
208
- function simulateWorldStep(dtSec: number): void {
209
- const activeObjectIds = [...objectOwners.keys()];
210
- for (const objectId of activeObjectIds) {
211
- const slot = objectIdToSlot(objectId);
212
- const start = slot * OBJECT_STRIDE;
213
- if (start < 0 || start + OBJECT_STRIDE > worldState.length) continue;
214
-
215
- let x = worldState[start + 1] ?? 0;
216
- let y = worldState[start + 2] ?? 0;
217
- let vx = worldState[start + 5] ?? 0;
218
- let vy = worldState[start + 6] ?? 0;
219
-
220
- x += vx * dtSec;
221
- y += vy * dtSec;
222
-
223
- if (x < OBJECT_RADIUS || x > BOARD_WIDTH - OBJECT_RADIUS) {
224
- vx *= -1;
225
- x = Math.max(OBJECT_RADIUS, Math.min(BOARD_WIDTH - OBJECT_RADIUS, x));
226
- }
227
- if (y < OBJECT_RADIUS || y > BOARD_HEIGHT - OBJECT_RADIUS) {
228
- vy *= -1;
229
- y = Math.max(OBJECT_RADIUS, Math.min(BOARD_HEIGHT - OBJECT_RADIUS, y));
230
- }
231
-
232
- worldState[start + 1] = x;
233
- worldState[start + 2] = y;
234
- worldState[start + 5] = vx;
235
- worldState[start + 6] = vy;
236
- }
308
+ function broadcastWorldBuffer(world: Float32Array): void {
309
+ if (connectedSessions.size === 0) return;
310
+ const payload = copyWorldBuffer(world);
311
+ for (const sessionId of connectedSessions) {
312
+ sendBinaryToClient(sessionId, payload, "sync");
313
+ }
314
+ }
237
315
 
238
- for (let i = 0; i < activeObjectIds.length; i += 1) {
239
- const aId = activeObjectIds[i];
240
- const aSlot = objectIdToSlot(aId);
241
- const aStart = aSlot * OBJECT_STRIDE;
242
- if (aStart < 0 || aStart + OBJECT_STRIDE > worldState.length) continue;
243
- for (let j = i + 1; j < activeObjectIds.length; j += 1) {
244
- const bId = activeObjectIds[j];
245
- const bSlot = objectIdToSlot(bId);
246
- const bStart = bSlot * OBJECT_STRIDE;
247
- if (bStart < 0 || bStart + OBJECT_STRIDE > worldState.length) continue;
248
-
249
- let ax = worldState[aStart + 1] ?? 0;
250
- let ay = worldState[aStart + 2] ?? 0;
251
- let avx = worldState[aStart + 5] ?? 0;
252
- let avy = worldState[aStart + 6] ?? 0;
253
- let bx = worldState[bStart + 1] ?? 0;
254
- let by = worldState[bStart + 2] ?? 0;
255
- let bvx = worldState[bStart + 5] ?? 0;
256
- let bvy = worldState[bStart + 6] ?? 0;
257
-
258
- let dx = bx - ax;
259
- let dy = by - ay;
260
- let distSq = dx * dx + dy * dy;
261
- const minDist = OBJECT_RADIUS * 2;
262
- const minDistSq = minDist * minDist;
263
- if (!(distSq > 0 && distSq < minDistSq)) continue;
264
-
265
- let dist = Math.sqrt(distSq);
266
- if (dist === 0) {
267
- // Deterministic fallback axis when centers overlap exactly.
268
- dx = 1;
269
- dy = 0;
270
- dist = 1;
271
- distSq = 1;
272
- }
273
- const nx = dx / dist;
274
- const ny = dy / dist;
275
-
276
- // Positional correction prevents objects from remaining overlapped.
277
- const overlap = minDist - dist;
278
- const half = overlap * 0.5;
279
- ax -= nx * half;
280
- ay -= ny * half;
281
- bx += nx * half;
282
- by += ny * half;
283
-
284
- const rvx = bvx - avx;
285
- const rvy = bvy - avy;
286
- const velAlongNormal = rvx * nx + rvy * ny;
287
- if (velAlongNormal < 0) {
288
- const impulse = (-(1 + COLLISION_RESTITUTION) * velAlongNormal) / 2;
289
- avx -= impulse * nx;
290
- avy -= impulse * ny;
291
- bvx += impulse * nx;
292
- bvy += impulse * ny;
293
- }
294
-
295
- worldState[aStart + 1] = Math.max(OBJECT_RADIUS, Math.min(BOARD_WIDTH - OBJECT_RADIUS, ax));
296
- worldState[aStart + 2] = Math.max(OBJECT_RADIUS, Math.min(BOARD_HEIGHT - OBJECT_RADIUS, ay));
297
- worldState[aStart + 5] = avx;
298
- worldState[aStart + 6] = avy;
299
- worldState[bStart + 1] = Math.max(OBJECT_RADIUS, Math.min(BOARD_WIDTH - OBJECT_RADIUS, bx));
300
- worldState[bStart + 2] = Math.max(OBJECT_RADIUS, Math.min(BOARD_HEIGHT - OBJECT_RADIUS, by));
301
- worldState[bStart + 5] = bvx;
302
- worldState[bStart + 6] = bvy;
303
- }
304
- }
316
+ async function loadWorldFromRedis(): Promise<Float32Array> {
317
+ if (!redis) {
318
+ return new Float32Array(worldState);
319
+ }
320
+ const raw = await redis.getBuffer(REDIS_WORLD_KEY);
321
+ return normalizeWorldBuffer(raw);
322
+ }
323
+
324
+ async function saveWorldToRedis(world: Float32Array): Promise<void> {
325
+ if (!redis) return;
326
+ await redis.setBuffer(REDIS_WORLD_KEY, copyWorldBuffer(world));
327
+ }
328
+
329
+ async function runSimulationTick(): Promise<void> {
330
+ if (!redis) {
331
+ const activeObjectIds = [...objectOwners.keys()];
332
+ simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
333
+ broadcastWorldBuffer(worldState);
334
+ return;
335
+ }
336
+
337
+ const lockAcquired = await redis.set(
338
+ REDIS_SIM_LOCK_KEY,
339
+ "1",
340
+ "PX",
341
+ SIM_LOCK_TTL_MS,
342
+ "NX",
343
+ );
344
+ if (lockAcquired === "OK") {
345
+ const world = await loadWorldFromRedis();
346
+ const activeObjectIds = collectActiveObjectIds(world);
347
+ simulateWorldStep(world, 1 / BROADCAST_HZ, activeObjectIds);
348
+ await saveWorldToRedis(world);
349
+ worldState = world;
350
+ }
351
+
352
+ const world = await loadWorldFromRedis();
353
+ worldState = world;
354
+ broadcastWorldBuffer(world);
305
355
  }
306
356
 
307
357
  function startBroadcastLoopIfNeeded(): void {
308
- if (broadcastTimer) return;
309
- if (connectedSessions.size < 1) return;
310
-
311
- broadcastTimer = setInterval(() => {
312
- if (connectedSessions.size < 1) {
313
- if (broadcastTimer) {
314
- clearInterval(broadcastTimer);
315
- broadcastTimer = null;
316
- }
317
- return;
318
- }
319
- simulateWorldStep(1 / BROADCAST_HZ);
320
- broadcastWorldState();
321
- }, BROADCAST_INTERVAL_MS);
322
-
323
- agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
358
+ if (broadcastTimer) return;
359
+ if (connectedSessions.size < 1) return;
360
+
361
+ broadcastTimer = setInterval(() => {
362
+ if (connectedSessions.size < 1) {
363
+ if (broadcastTimer) {
364
+ clearInterval(broadcastTimer);
365
+ broadcastTimer = null;
366
+ }
367
+ return;
368
+ }
369
+ void runSimulationTick().catch((error: unknown) => {
370
+ const detail = error instanceof Error ? error.message : String(error);
371
+ agentLog("error", `world tick failed: ${detail}`);
372
+ });
373
+ }, BROADCAST_INTERVAL_MS);
374
+
375
+ agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
324
376
  }
325
377
 
326
378
  function stopBroadcastLoopIfNeeded(): void {
327
- if (connectedSessions.size >= 1) return;
328
- if (!broadcastTimer) return;
329
- clearInterval(broadcastTimer);
330
- broadcastTimer = null;
331
- agentLog("info", "world loop stopped");
379
+ if (connectedSessions.size >= 1) return;
380
+ if (!broadcastTimer) return;
381
+ clearInterval(broadcastTimer);
382
+ broadcastTimer = null;
383
+ agentLog("info", "world loop stopped");
384
+ }
385
+
386
+ async function ensureRedisWorldInitialized(): Promise<void> {
387
+ if (!redis) return;
388
+ const existing = await redis.getBuffer(REDIS_WORLD_KEY);
389
+ if (!existing || existing.byteLength === 0) {
390
+ const empty = createEmptyWorldBuffer();
391
+ await redis.setBuffer(REDIS_WORLD_KEY, copyWorldBuffer(empty));
392
+ }
332
393
  }
333
394
 
334
395
  defineAgent({
335
- onClientJoin({sessionId}) {
336
- connectedSessions.add(sessionId);
337
- startBroadcastLoopIfNeeded();
338
- // New client receives the current ownership map so it can assign stable
339
- // colors per owner and immediately render known tracked objects.
340
- sendToClient(sessionId, {
341
- type: "world_snapshot",
342
- objects: trackedObjectsSnapshot(),
396
+ async onAgentStart({ env }) {
397
+ const redisUrl = env.AGENT_REDIS_URL ?? process.env.AGENT_REDIS_URL;
398
+ if (!redisUrl?.trim()) {
399
+ agentLog(
400
+ "warn",
401
+ "AGENT_REDIS_URL unset — game-sync uses per-worker in-memory world only",
402
+ );
403
+ worldState = createEmptyWorldBuffer();
404
+ return;
405
+ }
406
+
407
+ redis = new Redis(redisUrl, {
408
+ maxRetriesPerRequest: 3,
409
+ lazyConnect: true,
410
+ });
411
+ await redis.connect();
412
+ worldState = createEmptyWorldBuffer();
413
+ await ensureRedisWorldInitialized();
414
+ agentLog("info", "game-sync agent connected to project Redis world buffer");
415
+ },
416
+
417
+ onClientJoin({ sessionId }) {
418
+ connectedSessions.add(sessionId);
419
+ startBroadcastLoopIfNeeded();
420
+ sendToClient(sessionId, {
421
+ type: "world_snapshot",
422
+ objects: trackedObjectsSnapshot(),
423
+ });
424
+ agentLog("info", `join ${sessionId} connected=${connectedSessions.size}`);
425
+ },
426
+
427
+ async onClientLeave({ sessionId }) {
428
+ connectedSessions.delete(sessionId);
429
+
430
+ const owned = sessionObjects.get(sessionId);
431
+ if (owned) {
432
+ for (const objectId of [...owned]) {
433
+ notifyObjectReleased(objectId, sessionId);
434
+ await releaseObjectInRedis(objectId);
435
+ }
436
+ sessionObjects.delete(sessionId);
437
+ }
438
+
439
+ stopBroadcastLoopIfNeeded();
440
+ agentLog(
441
+ "info",
442
+ `leave ${sessionId} connected=${connectedSessions.size} live=${countLiveObjects(worldState)}`,
443
+ );
444
+ },
445
+
446
+ async onDataChannelMessage(ctx) {
447
+ if (parseRegisterCommand(ctx.message)) {
448
+ const objectId = redis
449
+ ? await registerObjectInRedis(ctx.sessionId)
450
+ : registerObjectInMemory(ctx.sessionId);
451
+ if (objectId === null) {
452
+ sendToClient(ctx.sessionId, {
453
+ type: "register_nack",
454
+ reason: REGISTER_NACK_REASON_WORLD_FULL,
455
+ maxObjects: MAX_LIVE_OBJECTS,
456
+ });
457
+ agentLog(
458
+ "info",
459
+ `register_nack world_full session=${ctx.sessionId} redis=${Boolean(redis)}`,
460
+ );
461
+ return;
462
+ }
463
+ sendToClient(ctx.sessionId, { type: "register_ack", objectId });
464
+ notifyObjectRegistered(objectId, ctx.sessionId);
465
+ agentLog(
466
+ "info",
467
+ `register session=${ctx.sessionId} objectId=${objectId}`,
468
+ );
469
+ return;
470
+ }
471
+
472
+ const unregister = parseUnregisterCommand(ctx.message);
473
+ if (unregister) {
474
+ const result = await unregisterObject(ctx.sessionId, unregister.objectId);
475
+ if (!result.ok) {
476
+ sendToClient(ctx.sessionId, {
477
+ type: "unregister_nack",
478
+ reason: result.reason,
343
479
  });
344
- agentLog("info", `join ${sessionId} connected=${connectedSessions.size}`);
345
- },
346
-
347
- onClientLeave({sessionId}) {
348
- connectedSessions.delete(sessionId);
349
-
350
- const owned = sessionObjects.get(sessionId);
351
- if (owned) {
352
- for (const objectId of [...owned]) {
353
- notifyObjectReleased(objectId, sessionId);
354
- releaseObject(objectId);
355
- }
356
- sessionObjects.delete(sessionId);
357
- }
358
-
359
- stopBroadcastLoopIfNeeded();
360
480
  agentLog(
361
- "info",
362
- `leave ${sessionId} connected=${connectedSessions.size} worldFloats=${worldState.length} freeSlots=${freeSlots.length}`,
481
+ "info",
482
+ `unregister_nack session=${ctx.sessionId} reason=${result.reason}`,
363
483
  );
364
- },
365
-
366
- onDataChannelMessage(ctx) {
367
- // Register is a control-plane message over JSON data channel.
368
- // Binary channel is reserved for high-frequency state deltas.
369
- if (parseRegisterCommand(ctx.message)) {
370
- const objectId = registerObject(ctx.sessionId);
371
- sendToClient(ctx.sessionId, {type: "register_ack", objectId});
372
- notifyObjectRegistered(objectId, ctx.sessionId);
373
- agentLog("info", `register session=${ctx.sessionId} objectId=${objectId}`);
374
- return;
375
- }
376
-
377
- // Optional chat mode for debugging/coordinating live test sessions.
378
- const chat = parseChatCommand(ctx.message);
379
- if (!chat) return;
380
- for (const sessionId of connectedSessions) {
381
- sendToClient(sessionId, {
382
- type: "chat_broadcast",
383
- senderSessionId: ctx.sessionId,
384
- text: chat.text,
385
- });
386
- }
387
- },
388
-
389
- onDataChannelBinary(ctx) {
390
- // Server-authoritative simulation: ignore client binary state writes.
391
- // Keeping the hook makes intent-based controls easy to add later.
392
- void ctx;
393
- },
484
+ return;
485
+ }
486
+ sendToClient(ctx.sessionId, {
487
+ type: "unregister_ack",
488
+ objectId: result.objectId,
489
+ });
490
+ agentLog(
491
+ "info",
492
+ `unregister_ack session=${ctx.sessionId} objectId=${result.objectId}`,
493
+ );
494
+ return;
495
+ }
496
+
497
+ const chat = parseChatCommand(ctx.message);
498
+ if (!chat) return;
499
+ for (const sessionId of connectedSessions) {
500
+ sendToClient(sessionId, {
501
+ type: "chat_broadcast",
502
+ senderSessionId: ctx.sessionId,
503
+ text: chat.text,
504
+ });
505
+ }
506
+ },
507
+
508
+ onDataChannelBinary(ctx) {
509
+ void ctx;
510
+ },
394
511
  });
512
+
513
+ // Re-export layout helpers for unit tests.
514
+ export {
515
+ countLiveObjects,
516
+ findFirstEmptySlot,
517
+ markSlotFree,
518
+ readSlotObjectId,
519
+ slotToObjectId,
520
+ writeObjectSlot,
521
+ } from "./game-sync-world-layout.js";
522
+ export { simulateWorldStep } from "./game-sync-sim.js";