@voicethere/agent 0.5.3 → 0.5.5

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,393 +2,575 @@
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`). World writes (allocate/release Lua, sim SET)
11
+ * serialize on `game-sync:sim-lock`; one holder runs physics per tick.
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: "remove", objectId }` or `{ type: "unregister", objectId?: number }` ->
18
+ * zeros that live slot (objectId required for click-to-remove; omit objectId on
19
+ * unregister to drop the highest object owned by this session)
20
+ * - `{ type: "unregister_ack", objectId }` or `{ type: "unregister_nack", reason }`
12
21
  *
13
22
  * Simulation:
14
23
  * - server-authoritative movement at 60Hz
15
24
  * - wall bounce + object-object elastic collisions on server
16
25
  * - clients render server snapshots; client binary writes are ignored
26
+ * - with project Redis, the sim loop keeps running with zero connected clients so
27
+ * objects persist and keep moving after everyone disconnects
17
28
  *
18
29
  * Broadcast:
19
- * - 60Hz world-state broadcast starts when at least 1 client is connected
20
- * - stops when connected client count drops below 1
30
+ * - 60Hz world-state broadcast while the sim loop runs (no-op send when 0 sessions)
21
31
  *
22
32
  * Build:
23
33
  * npx @voicethere/agent build --entry templates/game-sync.ts
24
34
  */
35
+ import Redis from "ioredis";
25
36
  import {
26
- agentLog,
27
- defineAgent,
28
- sendBinaryToClient,
29
- sendToClient,
37
+ agentLog,
38
+ defineAgent,
39
+ sendBinaryToClient,
40
+ sendToClient,
30
41
  } from "@voicethere/agent";
31
42
 
32
- const OBJECT_STRIDE = 9;
43
+ import {
44
+ MAX_LIVE_OBJECTS,
45
+ parseChatCommand,
46
+ parseRegisterCommand,
47
+ parseUnregisterCommand,
48
+ resolveRemoveTarget,
49
+ REGISTER_NACK_REASON_WORLD_FULL,
50
+ UNREGISTER_NACK_REASON_NOT_FOUND,
51
+ } from "./game-sync-protocol.js";
52
+ import {
53
+ LUA_ALLOCATE_OBJECT,
54
+ LUA_RELEASE_OBJECT,
55
+ REDIS_EVAL_KEYS,
56
+ } from "./game-sync-redis.js";
57
+ import {
58
+ BOARD_HEIGHT,
59
+ BOARD_WIDTH,
60
+ OBJECT_RADIUS,
61
+ simulateWorldStep,
62
+ } from "./game-sync-sim.js";
63
+ import {
64
+ collectActiveObjectIds,
65
+ commitSimulatedWorld,
66
+ countLiveObjects,
67
+ createEmptyWorldBuffer,
68
+ findFirstEmptySlot,
69
+ markSlotFree,
70
+ normalizeWorldBuffer,
71
+ objectIdToSlot,
72
+ OBJECT_SLOT_BYTE_LENGTH,
73
+ REDIS_SIM_LOCK_KEY,
74
+ REDIS_WORLD_KEY,
75
+ slotToObjectId,
76
+ writeObjectSlot,
77
+ } from "./game-sync-world-layout.js";
78
+
33
79
  const BROADCAST_HZ = 60;
34
80
  const BROADCAST_INTERVAL_MS = Math.floor(1000 / BROADCAST_HZ);
35
- const BOARD_WIDTH = 1280;
36
- const BOARD_HEIGHT = 720;
37
- const OBJECT_RADIUS = 25;
81
+ const SIM_LOCK_TTL_MS = BROADCAST_INTERVAL_MS * 2;
82
+ const SIM_LOCK_RETRY_MS = 5;
38
83
  const MIN_SPEED = 90;
39
84
  const MAX_SPEED = 180;
40
- const COLLISION_RESTITUTION = 1.0;
41
85
 
42
86
  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).
87
+ const objectOwners = new Map<number, string>();
88
+ const sessionObjects = new Map<string, Set<number>>();
47
89
  const freeSlots: number[] = [];
48
90
 
49
- let worldState = new Float32Array(0);
91
+ let worldState = createEmptyWorldBuffer();
92
+ let redis: Redis | null = null;
50
93
  let broadcastTimer: NodeJS.Timeout | null = null;
94
+ let worldMutationChain: Promise<void> = Promise.resolve();
95
+
96
+ function withWorldMutation<T>(fn: () => Promise<T>): Promise<T> {
97
+ const run = worldMutationChain.then(fn);
98
+ worldMutationChain = run.then(
99
+ () => undefined,
100
+ () => undefined,
101
+ );
102
+ return run;
103
+ }
104
+
105
+ function sleep(ms: number): Promise<void> {
106
+ return new Promise((resolve) => {
107
+ setTimeout(resolve, ms);
108
+ });
109
+ }
110
+
111
+ async function withRedisSimLock<T>(
112
+ fn: () => Promise<T>,
113
+ options?: { retryUntilAcquired?: boolean },
114
+ ): Promise<T | null> {
115
+ if (!redis) {
116
+ return fn();
117
+ }
118
+
119
+ const retryUntilAcquired = options?.retryUntilAcquired ?? true;
120
+
121
+ while (true) {
122
+ const lockAcquired = await redis.set(
123
+ REDIS_SIM_LOCK_KEY,
124
+ "1",
125
+ "PX",
126
+ SIM_LOCK_TTL_MS,
127
+ "NX",
128
+ );
129
+ if (lockAcquired === "OK") {
130
+ try {
131
+ return await fn();
132
+ } finally {
133
+ await redis.del(REDIS_SIM_LOCK_KEY);
134
+ }
135
+ }
136
+ if (!retryUntilAcquired) {
137
+ return null;
138
+ }
139
+ await sleep(SIM_LOCK_RETRY_MS);
140
+ }
141
+ }
51
142
 
52
143
  interface TrackedObjectInfo {
53
- objectId: number;
54
- ownerSessionId: string;
144
+ objectId: number;
145
+ ownerSessionId: string;
55
146
  }
56
147
 
57
148
  function rand(min: number, max: number): number {
58
- return min + Math.random() * (max - min);
149
+ return min + Math.random() * (max - min);
59
150
  }
60
151
 
61
152
  function randomVelocity(): number {
62
- return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
153
+ return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
63
154
  }
64
155
 
65
- function slotToObjectId(slot: number): number {
66
- // Object ids are 1-based so 0 can represent "unused".
67
- return slot + 1;
156
+ function randomInitialTail(): Buffer {
157
+ const floats = new Float32Array([
158
+ rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS),
159
+ rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS),
160
+ 0,
161
+ 1,
162
+ randomVelocity(),
163
+ randomVelocity(),
164
+ 0,
165
+ 0,
166
+ ]);
167
+ return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
68
168
  }
69
169
 
70
- function objectIdToSlot(objectId: number): number {
71
- // Inverse of slotToObjectId().
72
- return objectId - 1;
170
+ function attachObjectToSession(sessionId: string, objectId: number): void {
171
+ let owned = sessionObjects.get(sessionId);
172
+ if (!owned) {
173
+ owned = new Set<number>();
174
+ sessionObjects.set(sessionId, owned);
175
+ }
176
+ owned.add(objectId);
177
+ objectOwners.set(objectId, sessionId);
73
178
  }
74
179
 
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;
180
+ function detachObjectFromSession(objectId: number): void {
181
+ const owner = objectOwners.get(objectId);
182
+ if (owner) {
183
+ const owned = sessionObjects.get(owner);
184
+ owned?.delete(objectId);
185
+ if (owned && owned.size === 0) {
186
+ sessionObjects.delete(owner);
81
187
  }
188
+ }
189
+ objectOwners.delete(objectId);
82
190
  }
83
191
 
84
- function allocateSlot(): number {
85
- // Prefer recycling old slots before growing worldState.
86
- const reused = freeSlots.shift();
87
- if (reused !== undefined) return reused;
192
+ function allocateSlotInMemory(): number | null {
193
+ if (countLiveObjects(worldState) >= MAX_LIVE_OBJECTS) {
194
+ return null;
195
+ }
88
196
 
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
- }
197
+ const reused = freeSlots.shift();
198
+ if (reused !== undefined) {
199
+ return reused;
200
+ }
95
201
 
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);
202
+ return findFirstEmptySlot(worldState);
105
203
  }
106
204
 
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
- }
117
-
118
- objectOwners.delete(objectId);
205
+ function releaseObjectInMemory(objectId: number): void {
206
+ const slot = objectIdToSlot(objectId);
207
+ if (slot < 0 || slot >= MAX_LIVE_OBJECTS) return;
119
208
 
120
- const slot = objectIdToSlot(objectId);
121
- if (slot < 0) return;
122
- if (slot >= worldState.length / OBJECT_STRIDE) return;
209
+ markSlotFree(worldState, slot);
210
+ if (!freeSlots.includes(slot)) {
211
+ freeSlots.push(slot);
212
+ freeSlots.sort((a, b) => a - b);
213
+ }
214
+ }
123
215
 
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
- }
216
+ function registerObjectInMemory(sessionId: string): number | null {
217
+ const slot = allocateSlotInMemory();
218
+ if (slot === null) {
219
+ return null;
220
+ }
221
+
222
+ const objectId = slotToObjectId(slot);
223
+ writeObjectSlot(
224
+ worldState,
225
+ slot,
226
+ objectId,
227
+ rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS),
228
+ rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS),
229
+ 0,
230
+ 1,
231
+ randomVelocity(),
232
+ randomVelocity(),
233
+ 0,
234
+ 0,
235
+ );
236
+ attachObjectToSession(sessionId, objectId);
237
+ return objectId;
130
238
  }
131
239
 
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;
240
+ async function registerObjectInRedis(
241
+ sessionId: string,
242
+ ): Promise<number | null> {
243
+ if (!redis) {
244
+ return registerObjectInMemory(sessionId);
245
+ }
246
+
247
+ const result = await redis.eval(
248
+ LUA_ALLOCATE_OBJECT,
249
+ 1,
250
+ REDIS_EVAL_KEYS.worldKey,
251
+ REDIS_EVAL_KEYS.worldByteLength,
252
+ REDIS_EVAL_KEYS.slotByteLength,
253
+ REDIS_EVAL_KEYS.maxSlots,
254
+ randomInitialTail(),
255
+ REDIS_EVAL_KEYS.headers,
256
+ );
257
+
258
+ const objectId = Number(result);
259
+ if (!Number.isFinite(objectId) || objectId < 1) {
260
+ return null;
261
+ }
262
+
263
+ attachObjectToSession(sessionId, objectId);
264
+ worldState = await loadWorldFromRedis();
265
+ return objectId;
148
266
  }
149
267
 
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";
268
+ async function releaseObjectInRedis(objectId: number): Promise<boolean> {
269
+ detachObjectFromSession(objectId);
270
+ if (!redis) {
271
+ releaseObjectInMemory(objectId);
272
+ return true;
273
+ }
274
+
275
+ const released = await redis.eval(
276
+ LUA_RELEASE_OBJECT,
277
+ 1,
278
+ REDIS_EVAL_KEYS.worldKey,
279
+ REDIS_EVAL_KEYS.worldByteLength,
280
+ REDIS_EVAL_KEYS.slotByteLength,
281
+ REDIS_EVAL_KEYS.maxSlots,
282
+ String(objectId),
283
+ REDIS_EVAL_KEYS.headers,
284
+ );
285
+ if (Number(released) === 1) {
286
+ worldState = await loadWorldFromRedis();
287
+ }
288
+ return Number(released) === 1;
154
289
  }
155
290
 
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};
291
+ async function unregisterObject(
292
+ sessionId: string,
293
+ objectId?: number,
294
+ ): Promise<{ ok: true; objectId: number } | { ok: false; reason: string }> {
295
+ const owned = sessionObjects.get(sessionId);
296
+ const target = resolveRemoveTarget(objectId, owned);
297
+ if (!target.ok) {
298
+ return target;
299
+ }
300
+
301
+ const previousOwner = objectOwners.get(target.objectId) ?? sessionId;
302
+ notifyObjectReleased(target.objectId, previousOwner);
303
+ const released = await withWorldMutation(async () => {
304
+ if (!redis) {
305
+ return releaseObjectInRedis(target.objectId);
306
+ }
307
+ const result = await withRedisSimLock(() =>
308
+ releaseObjectInRedis(target.objectId),
309
+ );
310
+ return result === true;
311
+ });
312
+ if (!released) {
313
+ return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
314
+ }
315
+ return { ok: true, objectId: target.objectId };
165
316
  }
166
317
 
167
318
  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;
319
+ const objects: TrackedObjectInfo[] = [];
320
+ for (const [objectId, ownerSessionId] of objectOwners) {
321
+ objects.push({ objectId, ownerSessionId });
322
+ }
323
+ objects.sort((a, b) => a.objectId - b.objectId);
324
+ return objects;
174
325
  }
175
326
 
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
- }
327
+ function notifyObjectRegistered(
328
+ objectId: number,
329
+ ownerSessionId: string,
330
+ ): void {
331
+ for (const sessionId of connectedSessions) {
332
+ if (sessionId === ownerSessionId) continue;
333
+ sendToClient(sessionId, {
334
+ type: "object_registered",
335
+ objectId,
336
+ ownerSessionId,
337
+ });
338
+ }
185
339
  }
186
340
 
187
341
  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
- }
342
+ for (const sessionId of connectedSessions) {
343
+ if (sessionId === ownerSessionId) continue;
344
+ sendToClient(sessionId, {
345
+ type: "object_released",
346
+ objectId,
347
+ ownerSessionId,
348
+ });
349
+ }
196
350
  }
197
351
 
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
- }
352
+ function copyWorldBuffer(world: Float32Array): Buffer {
353
+ return Buffer.from(world.buffer, world.byteOffset, world.byteLength);
206
354
  }
207
355
 
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;
356
+ function broadcastWorldBuffer(world: Float32Array): void {
357
+ if (connectedSessions.size === 0) return;
358
+ const payload = copyWorldBuffer(world);
359
+ for (const sessionId of connectedSessions) {
360
+ sendBinaryToClient(sessionId, payload, "sync");
361
+ }
362
+ }
222
363
 
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
- }
364
+ async function loadWorldFromRedis(): Promise<Float32Array> {
365
+ if (!redis) {
366
+ return new Float32Array(worldState);
367
+ }
368
+ const raw = await redis.getBuffer(REDIS_WORLD_KEY);
369
+ return normalizeWorldBuffer(raw);
370
+ }
231
371
 
232
- worldState[start + 1] = x;
233
- worldState[start + 2] = y;
234
- worldState[start + 5] = vx;
235
- worldState[start + 6] = vy;
236
- }
372
+ async function saveWorldToRedis(world: Float32Array): Promise<void> {
373
+ if (!redis) return;
374
+ await redis.setBuffer(REDIS_WORLD_KEY, copyWorldBuffer(world));
375
+ }
237
376
 
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
- }
377
+ async function runSimulationTick(): Promise<void> {
378
+ if (!redis) {
379
+ await withWorldMutation(async () => {
380
+ const activeObjectIds = [...objectOwners.keys()];
381
+ simulateWorldStep(worldState, 1 / BROADCAST_HZ, activeObjectIds);
382
+ });
383
+ broadcastWorldBuffer(worldState);
384
+ return;
385
+ }
386
+
387
+ await withWorldMutation(async () => {
388
+ await withRedisSimLock(
389
+ async () => {
390
+ const world = await loadWorldFromRedis();
391
+ const activeObjectIds = collectActiveObjectIds(world);
392
+ simulateWorldStep(world, 1 / BROADCAST_HZ, activeObjectIds);
393
+ const latestRedis = await loadWorldFromRedis();
394
+ commitSimulatedWorld(world, latestRedis);
395
+ await saveWorldToRedis(world);
396
+ worldState = world;
397
+ },
398
+ { retryUntilAcquired: false },
399
+ );
400
+ });
401
+
402
+ const world = await loadWorldFromRedis();
403
+ worldState = world;
404
+ broadcastWorldBuffer(world);
305
405
  }
306
406
 
307
407
  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);
408
+ if (broadcastTimer) return;
409
+ if (!redis && connectedSessions.size < 1) return;
410
+
411
+ broadcastTimer = setInterval(() => {
412
+ if (!redis && connectedSessions.size < 1) {
413
+ if (broadcastTimer) {
414
+ clearInterval(broadcastTimer);
415
+ broadcastTimer = null;
416
+ }
417
+ return;
418
+ }
419
+ void runSimulationTick().catch((error: unknown) => {
420
+ const detail = error instanceof Error ? error.message : String(error);
421
+ agentLog("error", `world tick failed: ${detail}`);
422
+ });
423
+ }, BROADCAST_INTERVAL_MS);
322
424
 
323
- agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
425
+ agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
324
426
  }
325
427
 
326
428
  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");
429
+ if (redis) return;
430
+ if (connectedSessions.size >= 1) return;
431
+ if (!broadcastTimer) return;
432
+ clearInterval(broadcastTimer);
433
+ broadcastTimer = null;
434
+ agentLog("info", "world loop stopped");
435
+ }
436
+
437
+ async function ensureRedisWorldInitialized(): Promise<void> {
438
+ if (!redis) return;
439
+ const existing = await redis.getBuffer(REDIS_WORLD_KEY);
440
+ if (!existing || existing.byteLength === 0) {
441
+ const empty = createEmptyWorldBuffer();
442
+ await redis.setBuffer(REDIS_WORLD_KEY, copyWorldBuffer(empty));
443
+ }
332
444
  }
333
445
 
334
446
  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(),
343
- });
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);
447
+ async onAgentStart({ env }) {
448
+ const redisUrl = env.AGENT_REDIS_URL ?? process.env.AGENT_REDIS_URL;
449
+ if (!redisUrl?.trim()) {
450
+ agentLog(
451
+ "warn",
452
+ "AGENT_REDIS_URL unset — game-sync uses per-worker in-memory world only",
453
+ );
454
+ worldState = createEmptyWorldBuffer();
455
+ return;
456
+ }
457
+
458
+ redis = new Redis(redisUrl, {
459
+ maxRetriesPerRequest: 3,
460
+ lazyConnect: true,
461
+ });
462
+ await redis.connect();
463
+ await ensureRedisWorldInitialized();
464
+ worldState = await loadWorldFromRedis();
465
+ startBroadcastLoopIfNeeded();
466
+ agentLog("info", "game-sync agent connected to project Redis world buffer");
467
+ },
468
+
469
+ onClientJoin({ sessionId }) {
470
+ connectedSessions.add(sessionId);
471
+ startBroadcastLoopIfNeeded();
472
+ sendToClient(sessionId, {
473
+ type: "world_snapshot",
474
+ objects: trackedObjectsSnapshot(),
475
+ });
476
+ agentLog("info", `join ${sessionId} connected=${connectedSessions.size}`);
477
+ },
478
+
479
+ async onClientLeave({ sessionId }) {
480
+ connectedSessions.delete(sessionId);
481
+ for (const [objectId, ownerSessionId] of objectOwners) {
482
+ if (ownerSessionId === sessionId) {
483
+ objectOwners.delete(objectId);
484
+ }
485
+ }
486
+ sessionObjects.delete(sessionId);
487
+ stopBroadcastLoopIfNeeded();
488
+ agentLog(
489
+ "info",
490
+ `leave ${sessionId} connected=${connectedSessions.size} live=${countLiveObjects(worldState)}`,
491
+ );
492
+ },
493
+
494
+ async onDataChannelMessage(ctx) {
495
+ if (parseRegisterCommand(ctx.message)) {
496
+ const objectId = await withWorldMutation(async () => {
497
+ if (!redis) {
498
+ return registerObjectInMemory(ctx.sessionId);
357
499
  }
500
+ const result = await withRedisSimLock(() =>
501
+ registerObjectInRedis(ctx.sessionId),
502
+ );
503
+ return result;
504
+ });
505
+ if (objectId === null) {
506
+ sendToClient(ctx.sessionId, {
507
+ type: "register_nack",
508
+ reason: REGISTER_NACK_REASON_WORLD_FULL,
509
+ maxObjects: MAX_LIVE_OBJECTS,
510
+ });
511
+ agentLog(
512
+ "info",
513
+ `register_nack world_full session=${ctx.sessionId} redis=${Boolean(redis)}`,
514
+ );
515
+ return;
516
+ }
517
+ sendToClient(ctx.sessionId, { type: "register_ack", objectId });
518
+ notifyObjectRegistered(objectId, ctx.sessionId);
519
+ agentLog(
520
+ "info",
521
+ `register session=${ctx.sessionId} objectId=${objectId}`,
522
+ );
523
+ return;
524
+ }
358
525
 
359
- stopBroadcastLoopIfNeeded();
526
+ const unregister = parseUnregisterCommand(ctx.message);
527
+ if (unregister) {
528
+ const result = await unregisterObject(ctx.sessionId, unregister.objectId);
529
+ if (!result.ok) {
530
+ sendToClient(ctx.sessionId, {
531
+ type: "unregister_nack",
532
+ reason: result.reason,
533
+ });
360
534
  agentLog(
361
- "info",
362
- `leave ${sessionId} connected=${connectedSessions.size} worldFloats=${worldState.length} freeSlots=${freeSlots.length}`,
535
+ "info",
536
+ `unregister_nack session=${ctx.sessionId} reason=${result.reason}`,
363
537
  );
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
- }
538
+ return;
539
+ }
540
+ sendToClient(ctx.sessionId, {
541
+ type: "unregister_ack",
542
+ objectId: result.objectId,
543
+ });
544
+ agentLog(
545
+ "info",
546
+ `unregister_ack session=${ctx.sessionId} objectId=${result.objectId}`,
547
+ );
548
+ return;
549
+ }
376
550
 
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
- },
551
+ const chat = parseChatCommand(ctx.message);
552
+ if (!chat) return;
553
+ for (const sessionId of connectedSessions) {
554
+ sendToClient(sessionId, {
555
+ type: "chat_broadcast",
556
+ senderSessionId: ctx.sessionId,
557
+ text: chat.text,
558
+ });
559
+ }
560
+ },
388
561
 
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
- },
562
+ onDataChannelBinary(ctx) {
563
+ void ctx;
564
+ },
394
565
  });
566
+
567
+ // Re-export layout helpers for unit tests.
568
+ export {
569
+ countLiveObjects,
570
+ findFirstEmptySlot,
571
+ markSlotFree,
572
+ readSlotObjectId,
573
+ slotToObjectId,
574
+ writeObjectSlot,
575
+ } from "./game-sync-world-layout.js";
576
+ export { simulateWorldStep } from "./game-sync-sim.js";