@voicethere/agent 0.7.12 → 0.8.0
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/README.md +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +11 -2
- package/dist/runtime.js.map +1 -1
- package/dist/templates/echo/agent.js +1 -1
- package/dist/templates/echo-dc/agent.js +1 -1
- package/dist/templates/game-sync/agent.js +162 -125
- package/dist/templates/registry.d.ts +1 -1
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +42 -26
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/spatial-showcase/agent.js +261 -22
- package/dist/templates/voice-starter/agent.js +1 -1
- package/dist/templates/webhooks/agent.js +2 -2
- package/dist/templates/webhooks-redis/agent.js +11 -4
- package/dist/templates/world-sync/agent.js +829 -0
- package/dist/templates/world-sync-binary/agent.js +1031 -0
- package/package.json +3 -2
- package/templates/README.md +41 -69
- package/templates/crash/README.md +13 -0
- package/templates/echo/README.md +13 -0
- package/templates/{echo.ts → echo/agent.ts} +1 -1
- package/templates/echo-dc/README.md +13 -0
- package/templates/{echo-dc.ts → echo-dc/agent.ts} +1 -1
- package/templates/echo-smoke/README.md +13 -0
- package/templates/game-sync/README.md +21 -0
- package/templates/{game-sync.ts → game-sync/agent.ts} +126 -94
- package/templates/{game-sync-redis.ts → game-sync/redis.ts} +4 -13
- package/templates/{game-sync-sim.ts → game-sync/sim.ts} +14 -7
- package/templates/{game-sync-world-layout.ts → game-sync/world-layout.ts} +72 -30
- package/templates/game-sync-smoke/README.md +13 -0
- package/templates/mix-smoke/README.md +13 -0
- package/templates/positional-tts/README.md +13 -0
- package/templates/recording-consent/README.md +13 -0
- package/templates/redis-sync/README.md +23 -0
- package/templates/redis-sync/agent.ts +72 -95
- package/templates/redis-sync/world-layout.ts +103 -26
- package/templates/spatial-showcase/README.md +13 -0
- package/templates/spatial-showcase/agent.ts +245 -18
- package/templates/spatial-showcase/protocol.ts +79 -0
- package/templates/spatial-showcase/sine.ts +28 -5
- package/templates/voice-showcase/README.md +13 -0
- package/templates/voice-starter/README.md +13 -0
- package/templates/webhooks/README.md +13 -0
- package/templates/{webhooks.ts → webhooks/agent.ts} +1 -1
- package/templates/webhooks-redis/README.md +13 -0
- package/templates/{webhooks-redis.ts → webhooks-redis/agent.ts} +9 -9
- package/templates/world-sync/README.md +20 -0
- package/templates/world-sync/agent.ts +62 -0
- package/templates/world-sync-binary/README.md +21 -0
- package/templates/world-sync-binary/agent.ts +107 -0
- package/templates/world-sync-binary/protocol.ts +281 -0
- /package/templates/{crash.ts → crash/agent.ts} +0 -0
- /package/templates/{echo-smoke.ts → echo-smoke/agent.ts} +0 -0
- /package/templates/{game-sync-protocol.ts → game-sync/protocol.ts} +0 -0
- /package/templates/{game-sync-smoke.ts → game-sync-smoke/agent.ts} +0 -0
- /package/templates/{mix-smoke.ts → mix-smoke/agent.ts} +0 -0
- /package/templates/{agent.ts → voice-starter/agent.ts} +0 -0
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Redis Lua scripts and helpers for game-sync world buffer.
|
|
3
3
|
*/
|
|
4
|
-
import { MAX_LIVE_OBJECTS } from "./
|
|
4
|
+
import { MAX_LIVE_OBJECTS } from "./protocol.js";
|
|
5
5
|
import {
|
|
6
|
-
|
|
6
|
+
OBJECT_ID_HEADERS_BLOB,
|
|
7
7
|
OBJECT_SLOT_BYTE_LENGTH,
|
|
8
8
|
WORLD_BYTE_LENGTH,
|
|
9
9
|
REDIS_WORLD_KEY,
|
|
10
|
-
} from "./
|
|
10
|
+
} from "./world-layout.js";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
export function buildObjectIdHeadersBlob(): Buffer {
|
|
14
|
-
const parts: Buffer[] = [];
|
|
15
|
-
for (let objectId = 1; objectId <= MAX_LIVE_OBJECTS; objectId += 1) {
|
|
16
|
-
parts.push(objectIdHeaderBytes(objectId));
|
|
17
|
-
}
|
|
18
|
-
return Buffer.concat(parts);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export const OBJECT_ID_HEADERS_BLOB = buildObjectIdHeadersBlob();
|
|
12
|
+
export { OBJECT_ID_HEADERS_BLOB };
|
|
22
13
|
|
|
23
14
|
/**
|
|
24
15
|
* Atomic allocate: find first empty slot, enforce global live cap, write 9-float record.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server-authoritative game-sync physics step (pure Float32Array, no Redis).
|
|
3
3
|
*/
|
|
4
|
-
import { objectIdToSlot } from "./
|
|
4
|
+
import { objectIdToSlot } from "./world-layout.js";
|
|
5
5
|
|
|
6
6
|
export const BOARD_WIDTH = 1280;
|
|
7
7
|
export const BOARD_HEIGHT = 720;
|
|
@@ -9,12 +9,19 @@ export const OBJECT_RADIUS = 25;
|
|
|
9
9
|
export const COLLISION_RESTITUTION = 1.0;
|
|
10
10
|
export const OBJECT_STRIDE = 9;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Advance every active object by `dtSec`. `activeObjectIds` may be a plain
|
|
14
|
+
* array or a persistent Int32Array; `count` limits how many leading entries
|
|
15
|
+
* are live so the caller can reuse one typed array across ticks.
|
|
16
|
+
*/
|
|
12
17
|
export function simulateWorldStep(
|
|
13
18
|
worldState: Float32Array,
|
|
14
19
|
dtSec: number,
|
|
15
|
-
activeObjectIds: number
|
|
20
|
+
activeObjectIds: ArrayLike<number>,
|
|
21
|
+
count: number = activeObjectIds.length,
|
|
16
22
|
): void {
|
|
17
|
-
for (
|
|
23
|
+
for (let i = 0; i < count; i += 1) {
|
|
24
|
+
const objectId = activeObjectIds[i] ?? 0;
|
|
18
25
|
const slot = objectIdToSlot(objectId);
|
|
19
26
|
const start = slot * OBJECT_STRIDE;
|
|
20
27
|
if (start < 0 || start + OBJECT_STRIDE > worldState.length) continue;
|
|
@@ -42,13 +49,13 @@ export function simulateWorldStep(
|
|
|
42
49
|
worldState[start + 6] = vy;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
for (let i = 0; i <
|
|
46
|
-
const aId = activeObjectIds[i];
|
|
52
|
+
for (let i = 0; i < count; i += 1) {
|
|
53
|
+
const aId = activeObjectIds[i] ?? 0;
|
|
47
54
|
const aSlot = objectIdToSlot(aId);
|
|
48
55
|
const aStart = aSlot * OBJECT_STRIDE;
|
|
49
56
|
if (aStart < 0 || aStart + OBJECT_STRIDE > worldState.length) continue;
|
|
50
|
-
for (let j = i + 1; j <
|
|
51
|
-
const bId = activeObjectIds[j];
|
|
57
|
+
for (let j = i + 1; j < count; j += 1) {
|
|
58
|
+
const bId = activeObjectIds[j] ?? 0;
|
|
52
59
|
const bSlot = objectIdToSlot(bId);
|
|
53
60
|
const bStart = bSlot * OBJECT_STRIDE;
|
|
54
61
|
if (bStart < 0 || bStart + OBJECT_STRIDE > worldState.length) continue;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Slot layout: [objectId, posX, posY, posZ, posW, dirX, dirY, dirZ, dirW]
|
|
6
6
|
* Empty slot: objectId === 0
|
|
7
7
|
*/
|
|
8
|
-
import { MAX_LIVE_OBJECTS } from "./
|
|
8
|
+
import { MAX_LIVE_OBJECTS } from "./protocol.js";
|
|
9
9
|
|
|
10
10
|
export const OBJECT_STRIDE = 9;
|
|
11
11
|
export const WORLD_FLOAT_COUNT = MAX_LIVE_OBJECTS * OBJECT_STRIDE;
|
|
@@ -74,6 +74,14 @@ export function markSlotFree(world: Float32Array, slot: number): void {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
const OBJECT_SLOT_SCRATCH = new Float32Array(OBJECT_STRIDE);
|
|
78
|
+
const OBJECT_SLOT_SCRATCH_BUF = Buffer.from(
|
|
79
|
+
OBJECT_SLOT_SCRATCH.buffer,
|
|
80
|
+
OBJECT_SLOT_SCRATCH.byteOffset,
|
|
81
|
+
OBJECT_SLOT_BYTE_LENGTH,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/** Shared 9-float slot buffer (overwritten on the next call). */
|
|
77
85
|
export function encodeObjectSlot(
|
|
78
86
|
objectId: number,
|
|
79
87
|
posX: number,
|
|
@@ -85,18 +93,16 @@ export function encodeObjectSlot(
|
|
|
85
93
|
dirZ: number,
|
|
86
94
|
dirW: number,
|
|
87
95
|
): Buffer {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
]);
|
|
99
|
-
return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
|
|
96
|
+
OBJECT_SLOT_SCRATCH[0] = objectId;
|
|
97
|
+
OBJECT_SLOT_SCRATCH[1] = posX;
|
|
98
|
+
OBJECT_SLOT_SCRATCH[2] = posY;
|
|
99
|
+
OBJECT_SLOT_SCRATCH[3] = posZ;
|
|
100
|
+
OBJECT_SLOT_SCRATCH[4] = posW;
|
|
101
|
+
OBJECT_SLOT_SCRATCH[5] = dirX;
|
|
102
|
+
OBJECT_SLOT_SCRATCH[6] = dirY;
|
|
103
|
+
OBJECT_SLOT_SCRATCH[7] = dirZ;
|
|
104
|
+
OBJECT_SLOT_SCRATCH[8] = dirW;
|
|
105
|
+
return OBJECT_SLOT_SCRATCH_BUF;
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
export function writeObjectSlot(
|
|
@@ -162,6 +168,26 @@ export function collectActiveObjectIds(world: Float32Array): number[] {
|
|
|
162
168
|
return ids;
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Allocation-free variant for the sim loop: writes live object ids into `dest`
|
|
173
|
+
* (capacity >= MAX_LIVE_OBJECTS) and returns how many were written.
|
|
174
|
+
*/
|
|
175
|
+
export function collectActiveObjectIdsInto(
|
|
176
|
+
world: Float32Array,
|
|
177
|
+
dest: Int32Array,
|
|
178
|
+
): number {
|
|
179
|
+
let count = 0;
|
|
180
|
+
const limit = Math.min(MAX_LIVE_OBJECTS, dest.length);
|
|
181
|
+
for (let slot = 0; slot < MAX_LIVE_OBJECTS && count < limit; slot += 1) {
|
|
182
|
+
const objectId = readSlotObjectId(world, slot);
|
|
183
|
+
if (objectId !== 0) {
|
|
184
|
+
dest[count] = objectId;
|
|
185
|
+
count += 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return count;
|
|
189
|
+
}
|
|
190
|
+
|
|
165
191
|
export interface LiveWorldObjectInfo {
|
|
166
192
|
objectId: number;
|
|
167
193
|
ownerSessionId: string;
|
|
@@ -208,32 +234,48 @@ export function clampSimulationDtSec(
|
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
/**
|
|
211
|
-
*
|
|
237
|
+
* Copy Redis / Node Buffer bytes into `dest` (default: a new world).
|
|
238
|
+
*
|
|
239
|
+
* Node Buffer pools can have `byteOffset` not divisible by 4, so we copy via
|
|
240
|
+
* a Uint8Array view onto `dest` instead of slicing a new ArrayBuffer.
|
|
212
241
|
*/
|
|
213
242
|
export function normalizeWorldBuffer(
|
|
214
243
|
raw: Uint8Array | null | undefined,
|
|
244
|
+
dest: Float32Array = createEmptyWorldBuffer(),
|
|
215
245
|
): Float32Array {
|
|
246
|
+
const destBytes = new Uint8Array(
|
|
247
|
+
dest.buffer,
|
|
248
|
+
dest.byteOffset,
|
|
249
|
+
dest.byteLength,
|
|
250
|
+
);
|
|
216
251
|
if (!raw || raw.byteLength === 0) {
|
|
217
|
-
|
|
252
|
+
destBytes.fill(0);
|
|
253
|
+
return dest;
|
|
218
254
|
}
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
255
|
+
const byteCount = Math.min(
|
|
256
|
+
Math.floor(raw.byteLength / 4) * 4,
|
|
257
|
+
destBytes.byteLength,
|
|
258
|
+
);
|
|
259
|
+
if (byteCount > 0) {
|
|
260
|
+
destBytes.set(raw.subarray(0, byteCount));
|
|
222
261
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (decoded.length === WORLD_FLOAT_COUNT) {
|
|
226
|
-
return decoded;
|
|
262
|
+
if (byteCount < destBytes.byteLength) {
|
|
263
|
+
destBytes.fill(0, byteCount);
|
|
227
264
|
}
|
|
228
|
-
|
|
229
|
-
normalized.set(
|
|
230
|
-
decoded.subarray(0, Math.min(decoded.length, WORLD_FLOAT_COUNT)),
|
|
231
|
-
);
|
|
232
|
-
return normalized;
|
|
265
|
+
return dest;
|
|
233
266
|
}
|
|
234
267
|
|
|
235
|
-
/**
|
|
268
|
+
/** Concatenated float32 objectId headers (1..MAX_LIVE_OBJECTS), one allocation. */
|
|
269
|
+
export const OBJECT_ID_HEADERS_BLOB = (() => {
|
|
270
|
+
const blob = Buffer.alloc(MAX_LIVE_OBJECTS * 4);
|
|
271
|
+
for (let objectId = 1; objectId <= MAX_LIVE_OBJECTS; objectId += 1) {
|
|
272
|
+
blob.writeFloatLE(objectId, (objectId - 1) * 4);
|
|
273
|
+
}
|
|
274
|
+
return blob;
|
|
275
|
+
})();
|
|
276
|
+
|
|
277
|
+
/** View into {@link OBJECT_ID_HEADERS_BLOB} for Lua slot header splice. */
|
|
236
278
|
export function objectIdHeaderBytes(objectId: number): Buffer {
|
|
237
|
-
const
|
|
238
|
-
return
|
|
279
|
+
const index = objectId - 1;
|
|
280
|
+
return OBJECT_ID_HEADERS_BLOB.subarray(index * 4, index * 4 + 4);
|
|
239
281
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# game-sync-smoke
|
|
2
|
+
|
|
3
|
+
Minimal data-channel agent for deploy-smoke, shared-child, and idle smokes. Not seeded on project create.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/game-sync-smoke/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# mix-smoke
|
|
2
|
+
|
|
3
|
+
Positional mix DataChannel commands for voice-data-mix-smoke (Voice+Data, shared child). Not seeded on project create.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/mix-smoke/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# positional-tts
|
|
2
|
+
|
|
3
|
+
Voice+Data demo — enables positional mixing and orbits each listener’s TTS speaker with `setTtsPose`. Requires Voice+Data runner mode.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/positional-tts/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`, `orbit.ts`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# recording-consent
|
|
2
|
+
|
|
3
|
+
Conversation recording consent on connect. Pauses capture while collecting name and date of birth, then resumes only if the customer consented.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/recording-consent/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`, `conversation.ts`
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# redis-sync
|
|
2
|
+
|
|
3
|
+
Redis-backed world buffer sync for redis-sync-smoke (Advanced tier + project Redis). Not seeded on project create.
|
|
4
|
+
|
|
5
|
+
Clients publish a 12-byte `Float32Array([clientIndex, x, y])` on `voicethere-sync`. Each tick's Redis GET is copied into one `Float32Array` in place and every broadcast is one `broadCastBinaryToClients` call with the same persistent `Buffer` view over a live session array. Slot patches reuse one 16-byte scratch buffer.
|
|
6
|
+
|
|
7
|
+
## Protocol
|
|
8
|
+
|
|
9
|
+
- **Inbound (12 bytes):** little-endian `float32` `[clientIndex, x, y]` via `onDataChannelBinary`
|
|
10
|
+
- **Outbound:** full world blob (`MAX_PEERS` slots × `[clientIndex, x, y, active]`)
|
|
11
|
+
- **Redis:** key `e2e:redis-sync:world`, Lua `LUA_PATCH_PEER_SLOT`
|
|
12
|
+
|
|
13
|
+
Reuse one `Float32Array(3)` on the client (`sendSyncBinary`).
|
|
14
|
+
|
|
15
|
+
## Build
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @voicethere/agent build --entry templates/redis-sync/agent.ts --outfile dist/agent.js
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Sources
|
|
22
|
+
|
|
23
|
+
- `agent.ts`, `world-layout.ts`
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Redis-backed world buffer sync for redis-sync-smoke (Advanced tier + project Redis).
|
|
3
3
|
*
|
|
4
4
|
* Game-style layout: one Float32Array world blob in Redis (`e2e:redis-sync:world`).
|
|
5
|
-
* Each client owns a fixed slot and publishes
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Each client owns a fixed slot and publishes a 12-byte `ArrayBuffer`
|
|
6
|
+
* `Float32Array([clientIndex, x, y])` on the sync DataChannel. Each runner pod
|
|
7
|
+
* runs its own broadcast loop: one Redis GET per tick, then binary fan-out on
|
|
8
|
+
* voicethere-sync to all local sessions (no pub/sub). Clients patch out of sync;
|
|
9
|
+
* the 20Hz server tick is the authoritative sync path.
|
|
9
10
|
*
|
|
10
11
|
* Slot writes use a Lua read-modify-write so concurrent patches from many sessions
|
|
11
12
|
* cannot clobber each other (WATCH/MULTI lost slots under 30-way connect storms).
|
|
@@ -13,85 +14,50 @@
|
|
|
13
14
|
* Build:
|
|
14
15
|
* npx @voicethere/agent build --entry templates/redis-sync/agent.ts --outfile dist/agent.js
|
|
15
16
|
*/
|
|
16
|
-
import Redis from "ioredis";
|
|
17
|
-
import {
|
|
17
|
+
import { Redis } from "ioredis";
|
|
18
|
+
import {
|
|
19
|
+
agentLog,
|
|
20
|
+
broadCastBinaryToClients,
|
|
21
|
+
defineAgent,
|
|
22
|
+
sendBinaryToClient,
|
|
23
|
+
} from "@voicethere/agent";
|
|
18
24
|
|
|
19
25
|
import {
|
|
20
26
|
createEmptyWorldBuffer,
|
|
27
|
+
decodePositionBuffer,
|
|
21
28
|
normalizeWorldBuffer,
|
|
22
29
|
peerSlotOffset,
|
|
23
30
|
PEER_SLOT_BYTE_LENGTH,
|
|
31
|
+
PEER_STRIDE,
|
|
24
32
|
REDIS_WORLD_KEY,
|
|
25
33
|
WORLD_BYTE_LENGTH,
|
|
26
34
|
writePeerSlot,
|
|
35
|
+
LUA_PATCH_PEER_SLOT,
|
|
27
36
|
} from "./world-layout.js";
|
|
28
37
|
|
|
29
|
-
/** Atomic splice of one peer slot (16 bytes) into the world blob. */
|
|
30
|
-
const LUA_PATCH_PEER_SLOT = `
|
|
31
|
-
local key = KEYS[1]
|
|
32
|
-
local offset = tonumber(ARGV[1])
|
|
33
|
-
local slot = ARGV[2]
|
|
34
|
-
local size = tonumber(ARGV[3])
|
|
35
|
-
local world = redis.call('GET', key)
|
|
36
|
-
if not world then
|
|
37
|
-
world = string.rep(string.char(0), size)
|
|
38
|
-
elseif #world < size then
|
|
39
|
-
world = world .. string.rep(string.char(0), size - #world)
|
|
40
|
-
elseif #world > size then
|
|
41
|
-
world = string.sub(world, 1, size)
|
|
42
|
-
end
|
|
43
|
-
world = string.sub(world, 1, offset) .. slot .. string.sub(world, offset + #slot + 1)
|
|
44
|
-
redis.call('SET', key, world)
|
|
45
|
-
return size
|
|
46
|
-
`;
|
|
47
|
-
|
|
48
38
|
const WORLD_BROADCAST_HZ = 20;
|
|
49
39
|
const WORLD_BROADCAST_INTERVAL_MS = Math.floor(1000 / WORLD_BROADCAST_HZ);
|
|
50
40
|
|
|
51
41
|
const connectedSessions = new Set<string>();
|
|
42
|
+
/** Same members as connectedSessions; array form for the per-tick broadcast. */
|
|
43
|
+
const connectedSessionList: string[] = [];
|
|
52
44
|
const sessionClientIndex = new Map<string, number>();
|
|
53
|
-
|
|
45
|
+
/** The one world buffer: Redis GETs copy into it, every broadcast sends this view. */
|
|
46
|
+
const localWorld = createEmptyWorldBuffer();
|
|
47
|
+
const localWorldBytes = Buffer.from(
|
|
48
|
+
localWorld.buffer,
|
|
49
|
+
localWorld.byteOffset,
|
|
50
|
+
localWorld.byteLength,
|
|
51
|
+
);
|
|
54
52
|
let redis: Redis | null = null;
|
|
55
53
|
let broadcastTimer: NodeJS.Timeout | null = null;
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const record = message as {
|
|
64
|
-
type?: unknown;
|
|
65
|
-
clientIndex?: unknown;
|
|
66
|
-
x?: unknown;
|
|
67
|
-
y?: unknown;
|
|
68
|
-
};
|
|
69
|
-
if (record.type !== "position") {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
if (
|
|
73
|
-
typeof record.clientIndex !== "number" ||
|
|
74
|
-
!Number.isFinite(record.clientIndex) ||
|
|
75
|
-
record.clientIndex < 0
|
|
76
|
-
) {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
if (typeof record.x !== "number" || !Number.isFinite(record.x)) {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
if (typeof record.y !== "number" || !Number.isFinite(record.y)) {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
return {
|
|
86
|
-
clientIndex: record.clientIndex,
|
|
87
|
-
x: record.x,
|
|
88
|
-
y: record.y,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function copyWorldBuffer(world: Float32Array): Buffer {
|
|
93
|
-
return Buffer.from(world.buffer, world.byteOffset, world.byteLength);
|
|
94
|
-
}
|
|
55
|
+
const PEER_SLOT = new Float32Array(PEER_STRIDE);
|
|
56
|
+
const PEER_SLOT_BUF = Buffer.from(
|
|
57
|
+
PEER_SLOT.buffer,
|
|
58
|
+
PEER_SLOT.byteOffset,
|
|
59
|
+
PEER_SLOT.byteLength,
|
|
60
|
+
);
|
|
95
61
|
|
|
96
62
|
function encodePeerSlot(
|
|
97
63
|
clientIndex: number,
|
|
@@ -99,15 +65,15 @@ function encodePeerSlot(
|
|
|
99
65
|
y: number,
|
|
100
66
|
active: number,
|
|
101
67
|
): Buffer {
|
|
102
|
-
|
|
103
|
-
|
|
68
|
+
PEER_SLOT[0] = clientIndex;
|
|
69
|
+
PEER_SLOT[1] = x;
|
|
70
|
+
PEER_SLOT[2] = y;
|
|
71
|
+
PEER_SLOT[3] = active;
|
|
72
|
+
return PEER_SLOT_BUF;
|
|
104
73
|
}
|
|
105
74
|
|
|
106
|
-
function broadcastWorldBuffer(
|
|
107
|
-
|
|
108
|
-
targetSessionId?: string,
|
|
109
|
-
): void {
|
|
110
|
-
const payload = copyWorldBuffer(world);
|
|
75
|
+
function broadcastWorldBuffer(targetSessionId?: string): void {
|
|
76
|
+
const payload = localWorldBytes;
|
|
111
77
|
if (targetSessionId) {
|
|
112
78
|
try {
|
|
113
79
|
sendBinaryToClient(targetSessionId, payload, "sync");
|
|
@@ -120,41 +86,44 @@ function broadcastWorldBuffer(
|
|
|
120
86
|
}
|
|
121
87
|
return;
|
|
122
88
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
89
|
+
if (connectedSessionList.length === 0) return;
|
|
90
|
+
try {
|
|
91
|
+
// payload is already a Buffer: forwarded as-is, one call, no Set iterator.
|
|
92
|
+
broadCastBinaryToClients(payload, connectedSessionList, "sync");
|
|
93
|
+
} catch (error: unknown) {
|
|
94
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
95
|
+
agentLog("error", `world broadcast send failed: ${detail}`);
|
|
130
96
|
}
|
|
131
97
|
}
|
|
132
98
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
99
|
+
/** Copy the Redis blob into `localWorld` in place; the GET reply is the only allocation. */
|
|
100
|
+
async function loadWorldFromRedis(): Promise<void> {
|
|
101
|
+
if (!redis) return;
|
|
137
102
|
const raw = await redis.getBuffer(REDIS_WORLD_KEY);
|
|
138
|
-
|
|
103
|
+
normalizeWorldBuffer(raw, localWorld);
|
|
139
104
|
}
|
|
140
105
|
|
|
141
106
|
async function broadcastWorldFromRedis(
|
|
142
107
|
targetSessionId?: string,
|
|
143
108
|
): Promise<void> {
|
|
144
|
-
|
|
145
|
-
broadcastWorldBuffer(
|
|
109
|
+
await loadWorldFromRedis();
|
|
110
|
+
broadcastWorldBuffer(targetSessionId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function logBroadcastError(error: unknown): void {
|
|
114
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
115
|
+
agentLog("error", `world broadcast failed: ${detail}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onBroadcastTick(): void {
|
|
119
|
+
void broadcastWorldFromRedis().catch(logBroadcastError);
|
|
146
120
|
}
|
|
147
121
|
|
|
148
122
|
function startBroadcastLoopIfNeeded(): void {
|
|
149
123
|
if (broadcastTimer || connectedSessions.size === 0) {
|
|
150
124
|
return;
|
|
151
125
|
}
|
|
152
|
-
broadcastTimer = setInterval(
|
|
153
|
-
void broadcastWorldFromRedis().catch((error: unknown) => {
|
|
154
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
155
|
-
agentLog("error", `world broadcast failed: ${detail}`);
|
|
156
|
-
});
|
|
157
|
-
}, WORLD_BROADCAST_INTERVAL_MS);
|
|
126
|
+
broadcastTimer = setInterval(onBroadcastTick, WORLD_BROADCAST_INTERVAL_MS);
|
|
158
127
|
agentLog("info", `world loop started (${WORLD_BROADCAST_HZ}Hz)`);
|
|
159
128
|
}
|
|
160
129
|
|
|
@@ -218,13 +187,21 @@ defineAgent({
|
|
|
218
187
|
},
|
|
219
188
|
|
|
220
189
|
async onClientJoin({ sessionId }) {
|
|
221
|
-
connectedSessions.
|
|
190
|
+
if (!connectedSessions.has(sessionId)) {
|
|
191
|
+
connectedSessions.add(sessionId);
|
|
192
|
+
connectedSessionList.push(sessionId);
|
|
193
|
+
}
|
|
222
194
|
startBroadcastLoopIfNeeded();
|
|
223
195
|
await broadcastWorldFromRedis(sessionId);
|
|
224
196
|
},
|
|
225
197
|
|
|
226
198
|
async onClientLeave({ sessionId }) {
|
|
227
|
-
connectedSessions.delete(sessionId)
|
|
199
|
+
if (connectedSessions.delete(sessionId)) {
|
|
200
|
+
const index = connectedSessionList.indexOf(sessionId);
|
|
201
|
+
if (index !== -1) {
|
|
202
|
+
connectedSessionList.splice(index, 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
228
205
|
const clientIndex = sessionClientIndex.get(sessionId);
|
|
229
206
|
sessionClientIndex.delete(sessionId);
|
|
230
207
|
stopBroadcastLoopIfNeeded();
|
|
@@ -239,8 +216,8 @@ defineAgent({
|
|
|
239
216
|
}
|
|
240
217
|
},
|
|
241
218
|
|
|
242
|
-
async
|
|
243
|
-
const position =
|
|
219
|
+
async onDataChannelBinary(ctx) {
|
|
220
|
+
const position = decodePositionBuffer(ctx.rawBinary);
|
|
244
221
|
if (!position) {
|
|
245
222
|
return;
|
|
246
223
|
}
|