@voicethere/agent 0.2.16 → 0.3.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.
@@ -1,66 +1,79 @@
1
1
  # Agent templates
2
2
 
3
- ## `echo.ts`
3
+ `@voicethere/agent` is the single source of truth for starter templates. Import the registry from `@voicethere/agent/templates`:
4
+
5
+ ```typescript
6
+ import {
7
+ listTemplates,
8
+ getTemplate,
9
+ resolveTemplateEntryPath,
10
+ loadTemplateSources,
11
+ loadTemplateBundle,
12
+ hasSeedBundle,
13
+ } from "@voicethere/agent/templates";
14
+ ```
4
15
 
5
- Full echo debug agent for the VoiceThere dashboard — speaks **"you said: …"** on voice finals and text chat, relays speech events over DataChannel, and echoes chat replies on DC.
16
+ ## Product vs e2e
17
+
18
+ | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
+ | --- | --- | --- | --- |
20
+ | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
+ | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
22
+
23
+ Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
6
24
 
7
- **Build:**
25
+ ## Sources vs prebuilt
26
+
27
+ - **Sources** live under `templates/` in the published package (editable TypeScript).
28
+ - **Prebuilt** bundles are derived artifacts at `dist/templates/<id>/agent.js` for product templates only.
29
+ - `loadTemplateSources(id)` returns `{ path, content }[]` — the canonical tree for a future web editor.
30
+ - `loadTemplateBundle(id)` returns prebuilt bytes for platform seed/deploy (runner-ready `agent.js`).
31
+
32
+ Build a template locally:
8
33
 
9
34
  ```bash
10
- npm install @voicethere/agent
11
- npx @voicethere/agent build --entry templates/echo.ts
35
+ npx @voicethere/agent build --entry templates/echo.ts --outfile dist/agent.js
12
36
  ```
13
37
 
14
- Platform auto-seeds the built bundle when a project is created with the **Echo (voice + chat)** template.
38
+ Prebuild all product seed bundles (also runs in `npm run build`):
15
39
 
16
- ## `echo-dc.ts`
40
+ ```bash
41
+ npm run build:templates
42
+ ```
17
43
 
18
- Data-channel-only echo — relays speech events and chat text over DC without TTS. Use when debugging the dashboard chat panel without agent playback.
44
+ ## Product templates
19
45
 
20
- ## `agent.ts`
46
+ ### `echo.ts` (`echo`)
21
47
 
22
- Full starter bundle covering every speech event from `@node-webrtc-rust/sdk/voice`:
48
+ Full echo debug agent for the VoiceThere dashboard — speaks **"you said: …"** on voice finals and text chat, relays speech events over DataChannel, and echoes chat replies on DC.
23
49
 
24
- | Group | Events |
25
- | ----------- | -------------------------------------------------------------------------------------------- |
26
- | User VAD | `user_speaking_start`, `user_speaking_end`, `vad_triggered` |
27
- | STT stream | `stt_stream_start`, `stt_stream_end`, `user_stt_start`, `user_stt_end`, `user_stt_not_found` |
28
- | Transcripts | `user_speech_partial`, `user_speech_final` |
29
- | Agent TTS | `agent_speaking_start`, `agent_speaking_end`, `barge_in` |
30
- | Failures | `error` |
50
+ ### `echo-dc.ts` (`echo-dc`)
31
51
 
32
- **Customize:** replace `onUserSpeechFinal` body with your LLM/tools; extend `PeerState` or swap `handleSpeechEvent` for your architecture.
52
+ Data-channel-only echo relays speech events and chat text over DC without TTS.
33
53
 
34
- **Build:**
54
+ ### `agent.ts` (`voice-starter`)
35
55
 
36
- ```bash
37
- npm install @voicethere/agent
38
- npx @voicethere/agent build
39
- # optional: --entry agent.ts --outfile dist/agent.js
40
- ```
56
+ Full starter bundle covering every speech event from `@node-webrtc-rust/sdk/voice`. Customize `onUserSpeechFinal` for your LLM/tools.
41
57
 
42
- **Verify sandbox (no WebRTC):**
58
+ ### `game-sync.ts` (`game-sync`)
43
59
 
44
- ```bash
45
- npx @voicethere/agent verify
46
- ```
60
+ Authoritative multi-object sync sample for real-time games/simulations (register, simulate, binary world snapshots).
47
61
 
48
- **Voice E2E:** host with the VoiceThere agent runner (platform or internal deployment) — set `AGENT_BUNDLE_PATH` to your built `dist/agent.js`.
62
+ ## E2e templates
49
63
 
50
- ## `game-sync.ts`
64
+ These mirror former `e2e/fixtures/*` sources. E2E resolves entries from the package, builds into ephemeral workdirs, and uploads `dist/agent.js`.
51
65
 
52
- Authoritative multi-object sync sample for real-time games/simulations:
66
+ | Id | Source | Purpose |
67
+ | --- | --- | --- |
68
+ | `echo-smoke` | `echo-smoke.ts` | voice-smoke, agent-smoke, cli-smoke |
69
+ | `crash` | `crash.ts` | session-errors-smoke, crash-policy smokes |
70
+ | `game-sync-smoke` | `game-sync-smoke.ts` | deploy-smoke, shared-child, idle smokes |
71
+ | `redis-sync` | `redis-sync/agent.ts` + `world-layout.ts` | redis-sync-smoke (project Redis world buffer) |
53
72
 
54
- - register tracked objects via control DC (`{ type: "register" }`)
55
- - receive `register_ack` + `object_registered` ownership notifications
56
- - run server-authoritative simulation (position + velocity) at 60Hz
57
- - resolve wall bounce + object-object collisions on the server
58
- - broadcast world-state snapshots to clients over binary sync channel (9-float records)
59
- - ignore client binary writes by default (safe base for adding intent/input messages later)
60
- - reuse freed slots on leave to keep world state compact
73
+ **Note:** Product `echo` is not the same as e2e `echo-smoke` keep both ids.
61
74
 
62
- Build:
75
+ ## Verify sandbox (no WebRTC)
63
76
 
64
77
  ```bash
65
- npx @voicethere/agent build --entry templates/game-sync.ts
78
+ npx @voicethere/agent verify
66
79
  ```
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Crash / echo agent for session-errors-smoke and agent-crash-policy-*-smoke.
3
+ *
4
+ * Protocol:
5
+ * - `{ type: "crash_trigger" }` → throw (AGENT_HANDLER_FAILED)
6
+ * - `{ type: "crash_exit" }` → process.exit(1)
7
+ * - `{ type: "ping", id }` → `{ type: "pong", id }`
8
+ * - `onUserSpeechFinal` text starting with "crash" → throw
9
+ * - other finals → speak(`echo:${text}`)
10
+ * - onSessionStart → speak("ready") after 1s (voice ready waiter)
11
+ */
12
+ import { defineAgent, sendToClient, speak } from "@voicethere/agent";
13
+
14
+ export const CRASH_AGENT_MESSAGE =
15
+ "e2e crash-agent: intentional handler failure";
16
+
17
+ function isRecord(message: unknown): message is Record<string, unknown> {
18
+ return !!message && typeof message === "object";
19
+ }
20
+
21
+ function isCrashTrigger(message: unknown): boolean {
22
+ return isRecord(message) && message.type === "crash_trigger";
23
+ }
24
+
25
+ function isCrashExit(message: unknown): boolean {
26
+ return isRecord(message) && message.type === "crash_exit";
27
+ }
28
+
29
+ function isPing(message: unknown): message is { type: "ping"; id: string } {
30
+ if (!isRecord(message) || message.type !== "ping") return false;
31
+ return typeof message.id === "string" && message.id.trim().length > 0;
32
+ }
33
+
34
+ defineAgent({
35
+ onSessionStart({ sessionId }) {
36
+ setTimeout(() => {
37
+ speak(sessionId, "ready");
38
+ }, 1000);
39
+ },
40
+
41
+ onUserSpeechFinal({ sessionId, text }) {
42
+ const trimmed = text.trim();
43
+ if (!trimmed) return;
44
+ if (/^crash\b/i.test(trimmed)) {
45
+ throw new Error(CRASH_AGENT_MESSAGE);
46
+ }
47
+ speak(sessionId, `echo:${trimmed}`);
48
+ },
49
+
50
+ onDataChannelMessage(ctx) {
51
+ if (isCrashTrigger(ctx.message)) {
52
+ throw new Error(CRASH_AGENT_MESSAGE);
53
+ }
54
+ if (isCrashExit(ctx.message)) {
55
+ process.exit(1);
56
+ }
57
+ if (isPing(ctx.message)) {
58
+ sendToClient(ctx.sessionId, { type: "pong", id: ctx.message.id });
59
+ }
60
+ },
61
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Minimal echo agent for e2e voice-smoke, agent-smoke, and cli-smoke uploads.
3
+ *
4
+ * Echoes voice finals and DataChannel chat via speak() → TTS (client sees agent_speaking_* speech_events).
5
+ */
6
+ import { defineAgent, parseChatText, speak } from "@voicethere/agent";
7
+
8
+ defineAgent({
9
+ onSessionStart({ sessionId }) {
10
+ setTimeout(() => {
11
+ speak(sessionId, "ready");
12
+ }, 1000);
13
+ },
14
+
15
+ onUserSpeechFinal({ sessionId, text }) {
16
+ const trimmed = text.trim();
17
+ if (!trimmed) return;
18
+ speak(sessionId, `echo:${trimmed}`);
19
+ },
20
+
21
+ onDataChannelMessage(ctx) {
22
+ const text = parseChatText(ctx.message);
23
+ if (!text?.trim()) return;
24
+ if (text.trim().toLowerCase() === "ping") return;
25
+ speak(ctx.sessionId, `echo:${text.trim()}`);
26
+ },
27
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Minimal data-channel agent for staging smokes (data-only / shared-child / idle).
3
+ *
4
+ * Protocol:
5
+ * - On join/leave: broadcast `{ type: "state", tick, players }` to every session.
6
+ * - On `{ type: "tick" }`: increment tick and rebroadcast.
7
+ * - On `{ type: "agent_log_probe", id }`: `agentLog` the id (agent-logs-smoke) and ack.
8
+ *
9
+ * Load-staging uses the full `game-sync` product template (object-sync).
10
+ */
11
+ import { agentLog, defineAgent, sendToClient } from "@voicethere/agent";
12
+
13
+ /** E2E deploy-smoke — rewritten before each fixture upload. */
14
+ export const FIXTURE_MARKER = "deploy-smoke-fixture-b";
15
+
16
+ const connectedSessions = new Set<string>();
17
+ let tick = 0;
18
+
19
+ function broadcastState(): void {
20
+ const players = connectedSessions.size;
21
+ for (const sessionId of connectedSessions) {
22
+ sendToClient(sessionId, {
23
+ type: "state",
24
+ tick,
25
+ players,
26
+ marker: FIXTURE_MARKER,
27
+ });
28
+ }
29
+ }
30
+
31
+ function isTickMessage(message: unknown): boolean {
32
+ return (
33
+ !!message &&
34
+ typeof message === "object" &&
35
+ (message as { type?: unknown }).type === "tick"
36
+ );
37
+ }
38
+
39
+ function isAgentLogProbe(
40
+ message: unknown,
41
+ ): message is { type: "agent_log_probe"; id: string } {
42
+ if (!message || typeof message !== "object") return false;
43
+ const record = message as { type?: unknown; id?: unknown };
44
+ return (
45
+ record.type === "agent_log_probe" &&
46
+ typeof record.id === "string" &&
47
+ record.id.trim().length > 0
48
+ );
49
+ }
50
+
51
+ defineAgent({
52
+ onClientJoin({ sessionId }) {
53
+ connectedSessions.add(sessionId);
54
+ broadcastState();
55
+ },
56
+
57
+ onClientLeave({ sessionId }) {
58
+ connectedSessions.delete(sessionId);
59
+ broadcastState();
60
+ },
61
+
62
+ onDataChannelMessage(ctx) {
63
+ if (isAgentLogProbe(ctx.message)) {
64
+ const id = ctx.message.id.trim();
65
+ agentLog(
66
+ "info",
67
+ `agent-logs-e2e probe ${id}`,
68
+ { probeId: id, e2e: "agent-logs-smoke" },
69
+ ctx.sessionId,
70
+ );
71
+ sendToClient(ctx.sessionId, { type: "agent_log_probe_ack", id });
72
+ return;
73
+ }
74
+
75
+ if (!isTickMessage(ctx.message)) {
76
+ return;
77
+ }
78
+ tick += 1;
79
+ broadcastState();
80
+ },
81
+ });
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Redis-backed world buffer sync for redis-sync-smoke (Advanced tier + project Redis).
3
+ *
4
+ * Game-style layout: one Float32Array world blob in Redis (`e2e:redis-sync:world`).
5
+ * Each client owns a fixed slot and publishes `{ type: "position", clientIndex, x, y }`.
6
+ * Each runner pod runs its own broadcast loop: one Redis GET per tick, then binary
7
+ * fan-out on voicethere-sync to all local sessions (no pub/sub). Clients patch
8
+ * out of sync; the 20Hz server tick is the authoritative sync path.
9
+ *
10
+ * Slot writes use a Lua read-modify-write so concurrent patches from many sessions
11
+ * cannot clobber each other (WATCH/MULTI lost slots under 30-way connect storms).
12
+ *
13
+ * Build:
14
+ * npx @voicethere/agent build --entry templates/redis-sync/agent.ts --outfile dist/agent.js
15
+ */
16
+ import Redis from "ioredis";
17
+ import { agentLog, defineAgent, sendBinaryToClient } from "@voicethere/agent";
18
+
19
+ import {
20
+ createEmptyWorldBuffer,
21
+ normalizeWorldBuffer,
22
+ peerSlotOffset,
23
+ PEER_SLOT_BYTE_LENGTH,
24
+ REDIS_WORLD_KEY,
25
+ WORLD_BYTE_LENGTH,
26
+ writePeerSlot,
27
+ } from "./world-layout.js";
28
+
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
+ const WORLD_BROADCAST_HZ = 20;
49
+ const WORLD_BROADCAST_INTERVAL_MS = Math.floor(1000 / WORLD_BROADCAST_HZ);
50
+
51
+ const connectedSessions = new Set<string>();
52
+ const sessionClientIndex = new Map<string, number>();
53
+ let localWorld = createEmptyWorldBuffer();
54
+ let redis: Redis | null = null;
55
+ let broadcastTimer: NodeJS.Timeout | null = null;
56
+
57
+ function parsePositionMessage(
58
+ message: unknown,
59
+ ): { clientIndex: number; x: number; y: number } | null {
60
+ if (!message || typeof message !== "object") {
61
+ return null;
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
+ }
95
+
96
+ function encodePeerSlot(
97
+ clientIndex: number,
98
+ x: number,
99
+ y: number,
100
+ active: number,
101
+ ): Buffer {
102
+ const floats = new Float32Array([clientIndex, x, y, active]);
103
+ return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
104
+ }
105
+
106
+ function broadcastWorldBuffer(
107
+ world: Float32Array,
108
+ targetSessionId?: string,
109
+ ): void {
110
+ const payload = copyWorldBuffer(world);
111
+ if (targetSessionId) {
112
+ try {
113
+ sendBinaryToClient(targetSessionId, payload, "sync");
114
+ } catch (error: unknown) {
115
+ const detail = error instanceof Error ? error.message : String(error);
116
+ agentLog(
117
+ "error",
118
+ `world send failed session=${targetSessionId}: ${detail}`,
119
+ );
120
+ }
121
+ return;
122
+ }
123
+ for (const sessionId of connectedSessions) {
124
+ try {
125
+ sendBinaryToClient(sessionId, payload, "sync");
126
+ } catch (error: unknown) {
127
+ const detail = error instanceof Error ? error.message : String(error);
128
+ agentLog("error", `world send failed session=${sessionId}: ${detail}`);
129
+ }
130
+ }
131
+ }
132
+
133
+ async function loadWorldFromRedis(): Promise<Float32Array> {
134
+ if (!redis) {
135
+ return new Float32Array(localWorld);
136
+ }
137
+ const raw = await redis.getBuffer(REDIS_WORLD_KEY);
138
+ return normalizeWorldBuffer(raw);
139
+ }
140
+
141
+ async function broadcastWorldFromRedis(
142
+ targetSessionId?: string,
143
+ ): Promise<void> {
144
+ const world = await loadWorldFromRedis();
145
+ broadcastWorldBuffer(world, targetSessionId);
146
+ }
147
+
148
+ function startBroadcastLoopIfNeeded(): void {
149
+ if (broadcastTimer || connectedSessions.size === 0) {
150
+ return;
151
+ }
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);
158
+ agentLog("info", `world loop started (${WORLD_BROADCAST_HZ}Hz)`);
159
+ }
160
+
161
+ function stopBroadcastLoopIfNeeded(): void {
162
+ if (connectedSessions.size > 0 || !broadcastTimer) {
163
+ return;
164
+ }
165
+ clearInterval(broadcastTimer);
166
+ broadcastTimer = null;
167
+ agentLog("info", "world loop stopped");
168
+ }
169
+
170
+ async function patchWorldSlot(
171
+ clientIndex: number,
172
+ x: number,
173
+ y: number,
174
+ active: number,
175
+ ): Promise<void> {
176
+ if (!redis) {
177
+ writePeerSlot(localWorld, clientIndex, x, y, active);
178
+ return;
179
+ }
180
+
181
+ const byteOffset = peerSlotOffset(clientIndex) * 4;
182
+ const slot = encodePeerSlot(clientIndex, x, y, active);
183
+ if (slot.byteLength !== PEER_SLOT_BYTE_LENGTH) {
184
+ throw new Error(
185
+ `peer slot encode length ${slot.byteLength} != ${PEER_SLOT_BYTE_LENGTH}`,
186
+ );
187
+ }
188
+ await redis.eval(
189
+ LUA_PATCH_PEER_SLOT,
190
+ 1,
191
+ REDIS_WORLD_KEY,
192
+ String(byteOffset),
193
+ slot,
194
+ String(WORLD_BYTE_LENGTH),
195
+ );
196
+ }
197
+
198
+ defineAgent({
199
+ async onAgentStart({ env }) {
200
+ const redisUrl = env.AGENT_REDIS_URL ?? process.env.AGENT_REDIS_URL;
201
+ if (!redisUrl?.trim()) {
202
+ agentLog(
203
+ "warn",
204
+ "AGENT_REDIS_URL unset — redis-sync fixture falls back to per-pod memory only",
205
+ );
206
+ return;
207
+ }
208
+
209
+ redis = new Redis(redisUrl, {
210
+ maxRetriesPerRequest: 3,
211
+ lazyConnect: true,
212
+ });
213
+ await redis.connect();
214
+ agentLog(
215
+ "info",
216
+ "redis-sync agent connected to project Redis world buffer",
217
+ );
218
+ },
219
+
220
+ async onClientJoin({ sessionId }) {
221
+ connectedSessions.add(sessionId);
222
+ startBroadcastLoopIfNeeded();
223
+ await broadcastWorldFromRedis(sessionId);
224
+ },
225
+
226
+ async onClientLeave({ sessionId }) {
227
+ connectedSessions.delete(sessionId);
228
+ const clientIndex = sessionClientIndex.get(sessionId);
229
+ sessionClientIndex.delete(sessionId);
230
+ stopBroadcastLoopIfNeeded();
231
+ if (clientIndex === undefined) {
232
+ return;
233
+ }
234
+ try {
235
+ await patchWorldSlot(clientIndex, 0, 0, 0);
236
+ } catch (error: unknown) {
237
+ const detail = error instanceof Error ? error.message : String(error);
238
+ agentLog("error", `world leave patch failed: ${detail}`);
239
+ }
240
+ },
241
+
242
+ async onDataChannelMessage(ctx) {
243
+ const position = parsePositionMessage(ctx.message);
244
+ if (!position) {
245
+ return;
246
+ }
247
+ sessionClientIndex.set(ctx.sessionId, position.clientIndex);
248
+ try {
249
+ await patchWorldSlot(position.clientIndex, position.x, position.y, 1);
250
+ } catch (error: unknown) {
251
+ const detail = error instanceof Error ? error.message : String(error);
252
+ agentLog("error", `world position patch failed: ${detail}`);
253
+ }
254
+ },
255
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Shared world buffer layout for redis-sync template (agent + E2E helpers).
3
+ *
4
+ * One Float32Array backs the full multiplayer state in Redis:
5
+ * peer slot = clientIndex * PEER_STRIDE
6
+ * [clientIndex, x, y, active]
7
+ *
8
+ * active: 1 = present, 0 = left / empty slot
9
+ */
10
+ export const MAX_PEERS = 128;
11
+ export const PEER_STRIDE = 4;
12
+ export const WORLD_FLOAT_COUNT = MAX_PEERS * PEER_STRIDE;
13
+ export const WORLD_BYTE_LENGTH = WORLD_FLOAT_COUNT * 4;
14
+ export const PEER_SLOT_BYTE_LENGTH = PEER_STRIDE * 4;
15
+
16
+ export const PEER_FIELD_INDEX = 0;
17
+ export const PEER_FIELD_X = 1;
18
+ export const PEER_FIELD_Y = 2;
19
+ export const PEER_FIELD_ACTIVE = 3;
20
+
21
+ export const REDIS_WORLD_KEY = "e2e:redis-sync:world";
22
+
23
+ export type PeerSlot = {
24
+ clientIndex: number;
25
+ x: number;
26
+ y: number;
27
+ active: number;
28
+ };
29
+
30
+ export function peerSlotOffset(clientIndex: number): number {
31
+ return clientIndex * PEER_STRIDE;
32
+ }
33
+
34
+ export function createEmptyWorldBuffer(): Float32Array {
35
+ return new Float32Array(WORLD_FLOAT_COUNT);
36
+ }
37
+
38
+ export function writePeerSlot(
39
+ world: Float32Array,
40
+ clientIndex: number,
41
+ x: number,
42
+ y: number,
43
+ active: number,
44
+ ): void {
45
+ const offset = peerSlotOffset(clientIndex);
46
+ world[offset + PEER_FIELD_INDEX] = clientIndex;
47
+ world[offset + PEER_FIELD_X] = x;
48
+ world[offset + PEER_FIELD_Y] = y;
49
+ world[offset + PEER_FIELD_ACTIVE] = active;
50
+ }
51
+
52
+ export function clearPeerSlot(world: Float32Array, clientIndex: number): void {
53
+ writePeerSlot(world, clientIndex, 0, 0, 0);
54
+ }
55
+
56
+ export function readPeerSlot(
57
+ world: Float32Array,
58
+ clientIndex: number,
59
+ ): PeerSlot | null {
60
+ const offset = peerSlotOffset(clientIndex);
61
+ const active = world[offset + PEER_FIELD_ACTIVE];
62
+ if (active !== 1) {
63
+ return null;
64
+ }
65
+ return {
66
+ clientIndex: world[offset + PEER_FIELD_INDEX],
67
+ x: world[offset + PEER_FIELD_X],
68
+ y: world[offset + PEER_FIELD_Y],
69
+ active,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Decode a Redis / Node Buffer (or any Uint8Array) into a world Float32Array.
75
+ *
76
+ * Node Buffer pools often hand out views whose `byteOffset` is not a multiple of
77
+ * 4. `new Float32Array(buf.buffer, buf.byteOffset, …)` then throws:
78
+ * "start offset of Float32Array should be a multiple of 4"
79
+ * Copy via `ArrayBuffer.slice` so the view always starts at offset 0.
80
+ */
81
+ export function normalizeWorldBuffer(
82
+ raw: Uint8Array | null | undefined,
83
+ ): Float32Array {
84
+ if (!raw || raw.byteLength === 0) {
85
+ return createEmptyWorldBuffer();
86
+ }
87
+ const bytes = Math.floor(raw.byteLength / 4) * 4;
88
+ if (bytes === 0) {
89
+ return createEmptyWorldBuffer();
90
+ }
91
+ const aligned = raw.buffer.slice(raw.byteOffset, raw.byteOffset + bytes);
92
+ const decoded = new Float32Array(aligned);
93
+ if (decoded.length === WORLD_FLOAT_COUNT) {
94
+ return decoded;
95
+ }
96
+ const normalized = createEmptyWorldBuffer();
97
+ normalized.set(
98
+ decoded.subarray(0, Math.min(decoded.length, WORLD_FLOAT_COUNT)),
99
+ );
100
+ return normalized;
101
+ }
102
+
103
+ export function decodeWorldBuffer(data: ArrayBuffer): Float32Array {
104
+ const view = new Float32Array(data);
105
+ if (view.length === WORLD_FLOAT_COUNT) {
106
+ return view;
107
+ }
108
+ const normalized = createEmptyWorldBuffer();
109
+ normalized.set(view.subarray(0, Math.min(view.length, WORLD_FLOAT_COUNT)));
110
+ return normalized;
111
+ }
112
+
113
+ export function worldBufferToArrayBuffer(world: Float32Array): ArrayBuffer {
114
+ return world.buffer.slice(
115
+ world.byteOffset,
116
+ world.byteOffset + world.byteLength,
117
+ ) as ArrayBuffer;
118
+ }