@voicethere/agent 0.5.2 → 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.
- package/dist/templates/game-sync/agent.js +11313 -143
- 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 +1242 -0
- package/package.json +11 -11
- package/templates/README.md +19 -11
- package/templates/game-sync-protocol.ts +52 -0
- package/templates/game-sync-redis.ts +121 -0
- package/templates/game-sync-sim.ts +122 -0
- package/templates/game-sync-smoke.ts +34 -0
- package/templates/game-sync-world-layout.ts +167 -0
- package/templates/game-sync.ts +431 -303
- package/templates/voice-showcase/agent.ts +119 -0
- package/templates/voice-showcase/conversation.ts +520 -0
- package/templates/voice-showcase/fun-facts.ts +24 -0
- package/templates/voice-showcase/recipes.ts +57 -0
- package/templates/voice-showcase/weather.ts +177 -0
package/templates/game-sync.ts
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
agentLog,
|
|
35
|
+
defineAgent,
|
|
36
|
+
sendBinaryToClient,
|
|
37
|
+
sendToClient,
|
|
30
38
|
} from "@voicethere/agent";
|
|
31
39
|
|
|
32
|
-
|
|
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
|
|
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>();
|
|
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).
|
|
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 =
|
|
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
|
-
|
|
54
|
-
|
|
91
|
+
objectId: number;
|
|
92
|
+
ownerSessionId: string;
|
|
55
93
|
}
|
|
56
94
|
|
|
57
95
|
function rand(min: number, max: number): number {
|
|
58
|
-
|
|
96
|
+
return min + Math.random() * (max - min);
|
|
59
97
|
}
|
|
60
98
|
|
|
61
99
|
function randomVelocity(): number {
|
|
62
|
-
|
|
100
|
+
return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
|
|
63
101
|
}
|
|
64
102
|
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
139
|
+
function allocateSlotInMemory(): number | null {
|
|
140
|
+
if (countLiveObjects(worldState) >= MAX_LIVE_OBJECTS) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
88
143
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return worldState.length / OBJECT_STRIDE - 1;
|
|
94
|
-
}
|
|
144
|
+
const reused = freeSlots.shift();
|
|
145
|
+
if (reused !== undefined) {
|
|
146
|
+
return reused;
|
|
147
|
+
}
|
|
95
148
|
|
|
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);
|
|
149
|
+
return findFirstEmptySlot(worldState);
|
|
105
150
|
}
|
|
106
151
|
|
|
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
|
-
}
|
|
152
|
+
function releaseObjectInMemory(objectId: number): void {
|
|
153
|
+
const slot = objectIdToSlot(objectId);
|
|
154
|
+
if (slot < 0 || slot >= MAX_LIVE_OBJECTS) return;
|
|
117
155
|
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
}
|
|
304
|
+
function copyWorldBuffer(world: Float32Array): Buffer {
|
|
305
|
+
return Buffer.from(world.buffer, world.byteOffset, world.byteLength);
|
|
206
306
|
}
|
|
207
307
|
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
362
|
-
|
|
481
|
+
"info",
|
|
482
|
+
`unregister_nack session=${ctx.sessionId} reason=${result.reason}`,
|
|
363
483
|
);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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";
|