@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.
- package/dist/templates/game-sync/agent.js +11428 -191
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +21 -1
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +1397 -0
- package/package.json +11 -11
- package/templates/README.md +19 -11
- package/templates/game-sync-protocol.ts +65 -0
- package/templates/game-sync-redis.ts +121 -0
- package/templates/game-sync-sim.ts +122 -0
- package/templates/game-sync-world-layout.ts +194 -0
- package/templates/game-sync.ts +482 -300
- package/templates/voice-showcase/agent.ts +119 -0
- package/templates/voice-showcase/conversation.ts +531 -0
- package/templates/voice-showcase/fun-facts.ts +24 -0
- package/templates/voice-showcase/recipes.ts +57 -0
- package/templates/voice-showcase/weather.ts +356 -0
package/templates/game-sync.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
agentLog,
|
|
38
|
+
defineAgent,
|
|
39
|
+
sendBinaryToClient,
|
|
40
|
+
sendToClient,
|
|
30
41
|
} from "@voicethere/agent";
|
|
31
42
|
|
|
32
|
-
|
|
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
|
|
36
|
-
const
|
|
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>();
|
|
44
|
-
const sessionObjects = new Map<string, Set<number>>();
|
|
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 =
|
|
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
|
-
|
|
54
|
-
|
|
144
|
+
objectId: number;
|
|
145
|
+
ownerSessionId: string;
|
|
55
146
|
}
|
|
56
147
|
|
|
57
148
|
function rand(min: number, max: number): number {
|
|
58
|
-
|
|
149
|
+
return min + Math.random() * (max - min);
|
|
59
150
|
}
|
|
60
151
|
|
|
61
152
|
function randomVelocity(): number {
|
|
62
|
-
|
|
153
|
+
return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
|
|
63
154
|
}
|
|
64
155
|
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
192
|
+
function allocateSlotInMemory(): number | null {
|
|
193
|
+
if (countLiveObjects(worldState) >= MAX_LIVE_OBJECTS) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
88
196
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return worldState.length / OBJECT_STRIDE - 1;
|
|
94
|
-
}
|
|
197
|
+
const reused = freeSlots.shift();
|
|
198
|
+
if (reused !== undefined) {
|
|
199
|
+
return reused;
|
|
200
|
+
}
|
|
95
201
|
|
|
96
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
199
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
425
|
+
agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
|
|
324
426
|
}
|
|
325
427
|
|
|
326
428
|
function stopBroadcastLoopIfNeeded(): void {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
535
|
+
"info",
|
|
536
|
+
`unregister_nack session=${ctx.sessionId} reason=${result.reason}`,
|
|
363
537
|
);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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";
|