@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.
- package/dist/templates/build-seed-bundles-cli.d.ts +3 -0
- package/dist/templates/build-seed-bundles-cli.d.ts.map +1 -0
- package/dist/templates/build-seed-bundles-cli.js +8 -0
- package/dist/templates/build-seed-bundles-cli.js.map +1 -0
- package/dist/templates/echo/agent.js +527 -0
- package/dist/templates/echo-dc/agent.js +517 -0
- package/dist/templates/game-sync/agent.js +771 -0
- package/dist/templates/index.d.ts +19 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +77 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/templates/registry.d.ts +18 -0
- package/dist/templates/registry.d.ts.map +1 -0
- package/dist/templates/registry.js +81 -0
- package/dist/templates/registry.js.map +1 -0
- package/dist/templates/voice-starter/agent.js +598 -0
- package/package.json +18 -5
- package/templates/README.md +53 -40
- package/templates/crash.ts +61 -0
- package/templates/echo-smoke.ts +27 -0
- package/templates/game-sync-smoke.ts +81 -0
- package/templates/redis-sync/agent.ts +255 -0
- package/templates/redis-sync/world-layout.ts +118 -0
package/templates/README.md
CHANGED
|
@@ -1,66 +1,79 @@
|
|
|
1
1
|
# Agent templates
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
Prebuild all product seed bundles (also runs in `npm run build`):
|
|
15
39
|
|
|
16
|
-
|
|
40
|
+
```bash
|
|
41
|
+
npm run build:templates
|
|
42
|
+
```
|
|
17
43
|
|
|
18
|
-
|
|
44
|
+
## Product templates
|
|
19
45
|
|
|
20
|
-
|
|
46
|
+
### `echo.ts` (`echo`)
|
|
21
47
|
|
|
22
|
-
Full
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
Data-channel-only echo — relays speech events and chat text over DC without TTS.
|
|
33
53
|
|
|
34
|
-
|
|
54
|
+
### `agent.ts` (`voice-starter`)
|
|
35
55
|
|
|
36
|
-
|
|
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
|
-
|
|
58
|
+
### `game-sync.ts` (`game-sync`)
|
|
43
59
|
|
|
44
|
-
|
|
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
|
-
|
|
62
|
+
## E2e templates
|
|
49
63
|
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
## Verify sandbox (no WebRTC)
|
|
63
76
|
|
|
64
77
|
```bash
|
|
65
|
-
npx @voicethere/agent
|
|
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
|
+
}
|