@voicethere/agent 0.7.13 → 0.8.1
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 +10 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +28 -2
- package/dist/runtime.js.map +1 -1
- package/dist/templates/echo/agent.js +16 -5
- package/dist/templates/echo-dc/agent.js +1 -1
- package/dist/templates/game-sync/agent.js +162 -125
- package/dist/templates/recording-consent/agent.js +12 -12
- 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/voice-showcase/agent.js +64 -25
- 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} +4 -6
- 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/voice-showcase/README.md +13 -0
- package/templates/voice-showcase/agent.ts +3 -1
- package/templates/voice-showcase/conversation.ts +9 -5
- package/templates/voice-showcase/delivery.ts +40 -10
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voicethere/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
|
|
57
57
|
"test": "vitest run",
|
|
58
58
|
"test:ci": "npm run typecheck && npm run build && npm run test && node scripts/ci/verify-package-exports.mjs",
|
|
59
|
+
"test:redis": "vitest run test/redis-lua.test.ts test/game-sync-redis-agent.test.ts",
|
|
59
60
|
"verify:local": "npm run build && node ./dist/cli.js verify --no-build --bundle ./dist/agent.js",
|
|
60
61
|
"verify:local:only": "node ./dist/cli.js verify --no-build --bundle ./dist/agent.js",
|
|
61
62
|
"live-test:models": "bash ./scripts/select-sherpa-models.sh",
|
|
@@ -63,7 +64,7 @@
|
|
|
63
64
|
"live-test:starter": "bash ./scripts/live-test-starter.sh",
|
|
64
65
|
"live-test:stack": "npm run live-test:stack:echo",
|
|
65
66
|
"live-test:stack:echo": "LIVE_TEST_AGENT_ENTRY=examples/agent.ts LIVE_TEST_PAGE_PATH=/examples/live-test/index.html LIVE_TEST_SESSION_MODE=voice bash ./scripts/live-test-stack.sh",
|
|
66
|
-
"live-test:stack:game": "LIVE_TEST_AGENT_ENTRY=templates/game-sync.ts LIVE_TEST_PAGE_PATH=/examples/live-test/game-sync.html LIVE_TEST_SESSION_MODE=data-only bash ./scripts/live-test-stack.sh",
|
|
67
|
+
"live-test:stack:game": "LIVE_TEST_AGENT_ENTRY=templates/game-sync/agent.ts LIVE_TEST_PAGE_PATH=/examples/live-test/game-sync.html LIVE_TEST_SESSION_MODE=data-only bash ./scripts/live-test-stack.sh",
|
|
67
68
|
"prepublishOnly": "npm run build",
|
|
68
69
|
"prepare": "npm run build"
|
|
69
70
|
},
|
package/templates/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Agent templates
|
|
2
2
|
|
|
3
|
-
`@voicethere/agent` is the single source of truth for starter templates.
|
|
3
|
+
`@voicethere/agent` is the single source of truth for starter templates. Each template lives in its own folder under `templates/<id>/` with a `README.md` and an `agent.ts` entry.
|
|
4
|
+
|
|
5
|
+
Import the registry from `@voicethere/agent/templates`:
|
|
4
6
|
|
|
5
7
|
```typescript
|
|
6
8
|
import {
|
|
@@ -15,16 +17,16 @@ import {
|
|
|
15
17
|
|
|
16
18
|
## Product vs e2e
|
|
17
19
|
|
|
18
|
-
| Kind | Dashboard create
|
|
19
|
-
| ----------- |
|
|
20
|
-
| **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `positional-tts`, `spatial-showcase`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
|
|
21
|
-
| **e2e** | No
|
|
20
|
+
| Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
|
|
21
|
+
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
|
|
22
|
+
| **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `world-sync`, `world-sync-binary`, `game-sync`, `voice-showcase`, `recording-consent`, `positional-tts`, `spatial-showcase`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
|
|
23
|
+
| **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
|
|
22
24
|
|
|
23
25
|
Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
|
|
24
26
|
|
|
25
27
|
## Sources vs prebuilt
|
|
26
28
|
|
|
27
|
-
- **Sources** live under `templates
|
|
29
|
+
- **Sources** live under `templates/<id>/` in the published package (editable TypeScript plus a README).
|
|
28
30
|
- **Prebuilt** bundles are derived artifacts at `dist/templates/<id>/agent.js` for product templates only.
|
|
29
31
|
- `loadTemplateSources(id)` returns `{ path, content }[]` — the canonical tree for a future web editor.
|
|
30
32
|
- `loadTemplateBundle(id)` returns prebuilt bytes for platform seed/deploy (runner-ready `agent.js`).
|
|
@@ -32,7 +34,7 @@ Product templates always set `seedOnCreate: true`. CI fails if a product templat
|
|
|
32
34
|
Build a template locally:
|
|
33
35
|
|
|
34
36
|
```bash
|
|
35
|
-
npx @voicethere/agent build --entry templates/echo.ts --outfile dist/agent.js
|
|
37
|
+
npx @voicethere/agent build --entry templates/echo/agent.ts --outfile dist/agent.js
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
Prebuild all product seed bundles (also runs in `npm run build`):
|
|
@@ -41,78 +43,48 @@ Prebuild all product seed bundles (also runs in `npm run build`):
|
|
|
41
43
|
npm run build:templates
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
##
|
|
45
|
-
|
|
46
|
-
### `echo.ts` (`echo`)
|
|
47
|
-
|
|
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.
|
|
49
|
-
|
|
50
|
-
### `echo-dc.ts` (`echo-dc`)
|
|
51
|
-
|
|
52
|
-
Data-channel-only echo — relays speech events and chat text over DC without TTS.
|
|
53
|
-
|
|
54
|
-
### `agent.ts` (`voice-starter`)
|
|
55
|
-
|
|
56
|
-
Full starter bundle covering every speech event from `@node-webrtc-rust/sdk/voice`. Customize `onUserSpeechFinal` for your LLM/tools.
|
|
57
|
-
|
|
58
|
-
### `game-sync.ts` (`game-sync`)
|
|
59
|
-
|
|
60
|
-
Authoritative multi-object sync sample for real-time games/simulations (register, simulate, binary world snapshots). Live objects are capped at **25** per world; clients can send `{ type: "unregister" }` (or `{ type: "remove", objectId?: number }`) to release owned objects. Protocol helpers live in `game-sync-protocol.ts`.
|
|
61
|
-
|
|
62
|
-
When `AGENT_REDIS_URL` is set (project Redis), the world blob is stored at `game-sync:world` and shared across runner workers — Lua atomic allocate/release enforces the global cap; one worker holds a sim lock per tick. Without Redis, the template falls back to per-worker in-memory state (local live-test stack).
|
|
63
|
-
|
|
64
|
-
### `voice-showcase/` (`voice-showcase`)
|
|
65
|
-
|
|
66
|
-
Conversational voice demo for landing and dashboard previews — greets the user, asks for a name, then offers a menu: weather (Open-Meteo, no API key), count 1–10, short recipes, and rotating fun facts. Typed chat and voice finals share the same handler. Sends structured `menu` payloads plus `chat_reply` for the chat log, then triggers TTS play so the client has the spoken text before audio starts.
|
|
46
|
+
## World sync (data-only)
|
|
67
47
|
|
|
68
|
-
|
|
48
|
+
Three product templates cover positional / world sync, from JSON to Redis:
|
|
69
49
|
|
|
70
|
-
|
|
50
|
+
| Id | Channel | State |
|
|
51
|
+
| -------------------- | ------------------------------- | ----------------------------- |
|
|
52
|
+
| `world-sync` | `onDataChannelMessage` (JSON) | One agent, in-memory, no Redis |
|
|
53
|
+
| `world-sync-binary` | `onDataChannelBinary` + `broadCastBinaryToClients` (`ArrayBuffer`) | One agent, in-memory, no Redis |
|
|
54
|
+
| `game-sync` | JSON control + binary world snapshots | Redis when `AGENT_REDIS_URL` is set |
|
|
71
55
|
|
|
72
|
-
|
|
56
|
+
See each folder README for the wire format.
|
|
73
57
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
### `positional-tts/` (`positional-tts`)
|
|
77
|
-
|
|
78
|
-
Voice+Data demo — enables positional mixing and orbits each listener’s TTS speaker with `setTtsPose` on a ~50 ms timer. Speaks a short greeting on connect and echoes voice finals / chat so you hear panning while TTS plays. Requires Voice+Data runner mode (`isMixAvailable`).
|
|
79
|
-
|
|
80
|
-
Sources: `positional-tts/agent.ts` (defineAgent wiring), `positional-tts/orbit.ts` (pure circle helper for tests).
|
|
81
|
-
|
|
82
|
-
### `spatial-showcase/` (`spatial-showcase`)
|
|
83
|
-
|
|
84
|
-
Spatial audio showcase for `/showcase` — one agent template with three demos selected by `{ type: "join", demo }` on the control DataChannel:
|
|
85
|
-
|
|
86
|
-
| Demo | Behavior |
|
|
87
|
-
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
88
|
-
| `orbit` | Looping sine tone + TTS orbit the listener (`setPlayPose` + `setTtsPose`); STT echo off; `{ type: "orbit", action: "set" \| "say" }`; agent emits `{ type: "orbit_pose" }` |
|
|
89
|
-
| `soundboard` | Positional clip pads — browser sends `clipId` only (never URLs); agent resolves against an allowlisted `assetOrigin` |
|
|
90
|
-
| `proximity` | Shared mix room (`createMixGroup` + `setClientPose`); `{ type: "move" }`, `{ type: "mute_peer" }`; `{ type: "room_state" }` broadcast |
|
|
91
|
-
|
|
92
|
-
**Protocol:** inbound `join`, `orbit`, `pad`, `pad_stop`, `pad_status`, `move`, `mute_peer`, `leave`, `ping` — outbound `showcase_ack`, `orbit_pose`, `pad_progress`, `room_state`, `room_full`, `error`.
|
|
93
|
-
|
|
94
|
-
**Limits:** `MAX_ROOM_PEERS = 8`, `MAX_ACTIVE_PLAYS = 12` per session. Clip ids: `chime`, `bell`, `laser`, `impact`, `footsteps`, `rain-loop`, `cafe-loop`, `jingle`.
|
|
95
|
-
|
|
96
|
-
Sources: `spatial-showcase/agent.ts`, `protocol.ts`, `sounds.ts`, `room.ts`, `orbit.ts`, `sine.ts`.
|
|
97
|
-
|
|
98
|
-
### `webhooks.ts` (`webhooks`)
|
|
99
|
-
|
|
100
|
-
Inbound HTTP webhook sample — verifies `x-agent-webhook-signature` HMAC on the **raw body** with `AGENT_WEBHOOK_SIGNING_SECRET`, then `JSON.parse` and fans out to connected sessions via DataChannel + `speak`.
|
|
101
|
-
|
|
102
|
-
### `webhooks-redis.ts` (`webhooks-redis`)
|
|
58
|
+
## Product templates
|
|
103
59
|
|
|
104
|
-
|
|
60
|
+
Each folder has its own README. Summary:
|
|
61
|
+
|
|
62
|
+
| Id | Folder | Summary |
|
|
63
|
+
| -- | ------ | ------- |
|
|
64
|
+
| `echo` | `echo/` | Voice + chat echo |
|
|
65
|
+
| `echo-dc` | `echo-dc/` | Data-channel echo, no TTS |
|
|
66
|
+
| `voice-starter` | `voice-starter/` | Every speech event |
|
|
67
|
+
| `world-sync` | `world-sync/` | JSON pose broadcast |
|
|
68
|
+
| `world-sync-binary` | `world-sync-binary/` | Binary pose `ArrayBuffer` |
|
|
69
|
+
| `game-sync` | `game-sync/` | Authoritative sim, Redis + binary snapshots |
|
|
70
|
+
| `voice-showcase` | `voice-showcase/` | Conversational landing demo |
|
|
71
|
+
| `recording-consent` | `recording-consent/` | Recording consent flow |
|
|
72
|
+
| `positional-tts` | `positional-tts/` | Orbiting TTS |
|
|
73
|
+
| `spatial-showcase` | `spatial-showcase/` | Orbit / soundboard / proximity |
|
|
74
|
+
| `webhooks` | `webhooks/` | Inbound HMAC webhooks |
|
|
75
|
+
| `webhooks-redis` | `webhooks-redis/` | Webhooks plus Redis counter |
|
|
105
76
|
|
|
106
77
|
## E2e templates
|
|
107
78
|
|
|
108
79
|
These mirror former `e2e/fixtures/*` sources. E2E resolves entries from the package, builds into ephemeral workdirs, and uploads `dist/agent.js`.
|
|
109
80
|
|
|
110
|
-
| Id | Source
|
|
111
|
-
| ----------------- |
|
|
112
|
-
| `echo-smoke` | `echo-smoke.ts`
|
|
113
|
-
| `crash` | `crash.ts`
|
|
114
|
-
| `game-sync-smoke` | `game-sync-smoke.ts`
|
|
115
|
-
| `redis-sync` | `redis-sync/agent.ts` + `world-layout.ts`
|
|
81
|
+
| Id | Source | Purpose |
|
|
82
|
+
| ----------------- | ---------------------------------------------- | --------------------------------------------- |
|
|
83
|
+
| `echo-smoke` | `echo-smoke/agent.ts` | voice-smoke, agent-smoke, cli-smoke |
|
|
84
|
+
| `crash` | `crash/agent.ts` | session-errors-smoke, crash-policy smokes |
|
|
85
|
+
| `game-sync-smoke` | `game-sync-smoke/agent.ts` | deploy-smoke, shared-child, idle smokes |
|
|
86
|
+
| `redis-sync` | `redis-sync/agent.ts` + `world-layout.ts` | redis-sync-smoke (binary positions + Redis world blob) |
|
|
87
|
+
| `mix-smoke` | `mix-smoke/agent.ts` | voice-data-mix-smoke |
|
|
116
88
|
|
|
117
89
|
**Note:** Product `echo` is not the same as e2e `echo-smoke` — keep both ids.
|
|
118
90
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# crash
|
|
2
|
+
|
|
3
|
+
Crash / echo agent for session-errors-smoke and agent-crash-policy smokes. Not seeded on project create.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/crash/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# echo
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/echo/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Build:
|
|
5
5
|
* npm install @voicethere/agent
|
|
6
|
-
* npx @voicethere/agent build --entry templates/echo.ts
|
|
6
|
+
* npx @voicethere/agent build --entry templates/echo/agent.ts
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
9
|
agentLog,
|
|
10
10
|
defineAgent,
|
|
11
11
|
parseChatText,
|
|
12
12
|
sendToClient,
|
|
13
|
-
|
|
13
|
+
speakAndChat,
|
|
14
14
|
type SpeechEvent,
|
|
15
15
|
} from "@voicethere/agent";
|
|
16
16
|
|
|
@@ -42,16 +42,14 @@ defineAgent({
|
|
|
42
42
|
|
|
43
43
|
onUserSpeechFinal({ sessionId, text }) {
|
|
44
44
|
const reply = formatEcho(text);
|
|
45
|
-
|
|
46
|
-
sendToClient(sessionId, { type: "chat_reply", text: reply });
|
|
45
|
+
speakAndChat(sessionId, reply, { stream: true });
|
|
47
46
|
},
|
|
48
47
|
|
|
49
48
|
onDataChannelMessage(ctx) {
|
|
50
49
|
const text = parseChatText(ctx.message);
|
|
51
50
|
if (!text) return;
|
|
52
51
|
const reply = formatEcho(text);
|
|
53
|
-
|
|
54
|
-
speak(ctx.sessionId, reply);
|
|
52
|
+
speakAndChat(ctx.sessionId, reply, { stream: true });
|
|
55
53
|
},
|
|
56
54
|
|
|
57
55
|
onSessionEnd({ sessionId }) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# echo-smoke
|
|
2
|
+
|
|
3
|
+
Minimal echo agent for e2e voice-smoke, agent-smoke, and cli-smoke uploads. Not seeded on project create.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/echo-smoke/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# game-sync
|
|
2
|
+
|
|
3
|
+
Authoritative multi-object world sync for games and simulations. Live objects are capped at **25**. Clients register over JSON; the agent simulates at 60 Hz and broadcasts a **binary** `Float32Array` world snapshot with `sendBinaryToClient`.
|
|
4
|
+
|
|
5
|
+
When `AGENT_REDIS_URL` is set (Project Redis), the world blob lives at `game-sync:world` and is shared across runner workers. Redis GET bytes are copied into the one in-memory `Float32Array` (the GET reply is the only per-tick allocation); sim, broadcast, and Redis `SET` all use one persistent `Buffer` view of that array. The tick itself is allocation-free: live object ids are collected into a reusable `Int32Array`, tick bodies are module-level functions (no per-tick closures or option objects), and the broadcast is a single `broadCastBinaryToClients` call over a live session array. Without Redis, state is per-worker in memory.
|
|
6
|
+
|
|
7
|
+
This is the most advanced world-sync starter: Redis + binary snapshots + server-side physics.
|
|
8
|
+
|
|
9
|
+
## Build
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @voicethere/agent build --entry templates/game-sync/agent.ts --outfile dist/agent.js
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Sources
|
|
16
|
+
|
|
17
|
+
- `agent.ts` — `defineAgent` wiring, register/unregister, sim loop
|
|
18
|
+
- `protocol.ts` — JSON control messages
|
|
19
|
+
- `world-layout.ts` — Float32Array slot layout
|
|
20
|
+
- `sim.ts` — wall bounce + collisions
|
|
21
|
+
- `redis.ts` — Lua allocate/release
|
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
* - 60Hz world-state broadcast while the sim loop runs (no-op send when 0 sessions)
|
|
31
31
|
*
|
|
32
32
|
* Build:
|
|
33
|
-
* npx @voicethere/agent build --entry templates/game-sync.ts
|
|
33
|
+
* npx @voicethere/agent build --entry templates/game-sync/agent.ts
|
|
34
34
|
*/
|
|
35
|
-
import Redis from "ioredis";
|
|
35
|
+
import { Redis } from "ioredis";
|
|
36
36
|
import {
|
|
37
37
|
agentLog,
|
|
38
|
+
broadCastBinaryToClients,
|
|
38
39
|
defineAgent,
|
|
39
|
-
sendBinaryToClient,
|
|
40
40
|
sendToClient,
|
|
41
41
|
} from "@voicethere/agent";
|
|
42
42
|
|
|
@@ -48,21 +48,21 @@ import {
|
|
|
48
48
|
resolveRemoveTarget,
|
|
49
49
|
REGISTER_NACK_REASON_WORLD_FULL,
|
|
50
50
|
UNREGISTER_NACK_REASON_NOT_FOUND,
|
|
51
|
-
} from "./
|
|
51
|
+
} from "./protocol.js";
|
|
52
52
|
import {
|
|
53
53
|
LUA_ALLOCATE_OBJECT,
|
|
54
54
|
LUA_RELEASE_OBJECT,
|
|
55
55
|
REDIS_EVAL_KEYS,
|
|
56
|
-
} from "./
|
|
56
|
+
} from "./redis.js";
|
|
57
57
|
import {
|
|
58
58
|
BOARD_HEIGHT,
|
|
59
59
|
BOARD_WIDTH,
|
|
60
60
|
OBJECT_RADIUS,
|
|
61
61
|
simulateWorldStep,
|
|
62
|
-
} from "./
|
|
62
|
+
} from "./sim.js";
|
|
63
63
|
import {
|
|
64
64
|
clampSimulationDtSec,
|
|
65
|
-
|
|
65
|
+
collectActiveObjectIdsInto,
|
|
66
66
|
countLiveObjects,
|
|
67
67
|
createEmptyWorldBuffer,
|
|
68
68
|
findFirstEmptySlot,
|
|
@@ -70,13 +70,12 @@ import {
|
|
|
70
70
|
markSlotFree,
|
|
71
71
|
normalizeWorldBuffer,
|
|
72
72
|
objectIdToSlot,
|
|
73
|
-
OBJECT_SLOT_BYTE_LENGTH,
|
|
74
73
|
planRedisSimTick,
|
|
75
74
|
REDIS_SIM_LOCK_KEY,
|
|
76
75
|
REDIS_WORLD_KEY,
|
|
77
76
|
slotToObjectId,
|
|
78
77
|
writeObjectSlot,
|
|
79
|
-
} from "./
|
|
78
|
+
} from "./world-layout.js";
|
|
80
79
|
|
|
81
80
|
const BROADCAST_HZ = 60;
|
|
82
81
|
const BROADCAST_INTERVAL_MS = Math.floor(1000 / BROADCAST_HZ);
|
|
@@ -86,11 +85,19 @@ const MIN_SPEED = 90;
|
|
|
86
85
|
const MAX_SPEED = 180;
|
|
87
86
|
|
|
88
87
|
const connectedSessions = new Set<string>();
|
|
88
|
+
/** Same members as connectedSessions; array form lets the broadcast skip Set iteration. */
|
|
89
|
+
const connectedSessionList: string[] = [];
|
|
89
90
|
const objectOwners = new Map<number, string>();
|
|
90
91
|
const sessionObjects = new Map<string, Set<number>>();
|
|
91
92
|
const freeSlots: number[] = [];
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
/** The one world buffer: Redis loads copy into it, sim/broadcast/SET read from it. */
|
|
95
|
+
const worldState = createEmptyWorldBuffer();
|
|
96
|
+
const worldStateBytes = Buffer.from(
|
|
97
|
+
worldState.buffer,
|
|
98
|
+
worldState.byteOffset,
|
|
99
|
+
worldState.byteLength,
|
|
100
|
+
);
|
|
94
101
|
let redis: Redis | null = null;
|
|
95
102
|
let broadcastTimer: NodeJS.Timeout | null = null;
|
|
96
103
|
let worldMutationChain: Promise<void> = Promise.resolve();
|
|
@@ -151,18 +158,23 @@ function randomVelocity(): number {
|
|
|
151
158
|
return (Math.random() < 0.5 ? -1 : 1) * rand(MIN_SPEED, MAX_SPEED);
|
|
152
159
|
}
|
|
153
160
|
|
|
161
|
+
const INITIAL_TAIL = new Float32Array(8);
|
|
162
|
+
const INITIAL_TAIL_BUF = Buffer.from(
|
|
163
|
+
INITIAL_TAIL.buffer,
|
|
164
|
+
INITIAL_TAIL.byteOffset,
|
|
165
|
+
INITIAL_TAIL.byteLength,
|
|
166
|
+
);
|
|
167
|
+
|
|
154
168
|
function randomInitialTail(): Buffer {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
]);
|
|
165
|
-
return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
|
|
169
|
+
INITIAL_TAIL[0] = rand(OBJECT_RADIUS, BOARD_WIDTH - OBJECT_RADIUS);
|
|
170
|
+
INITIAL_TAIL[1] = rand(OBJECT_RADIUS, BOARD_HEIGHT - OBJECT_RADIUS);
|
|
171
|
+
INITIAL_TAIL[2] = 0;
|
|
172
|
+
INITIAL_TAIL[3] = 1;
|
|
173
|
+
INITIAL_TAIL[4] = randomVelocity();
|
|
174
|
+
INITIAL_TAIL[5] = randomVelocity();
|
|
175
|
+
INITIAL_TAIL[6] = 0;
|
|
176
|
+
INITIAL_TAIL[7] = 0;
|
|
177
|
+
return INITIAL_TAIL_BUF;
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
function attachObjectToSession(sessionId: string, objectId: number): void {
|
|
@@ -259,7 +271,7 @@ async function registerObjectInRedis(
|
|
|
259
271
|
}
|
|
260
272
|
|
|
261
273
|
attachObjectToSession(sessionId, objectId);
|
|
262
|
-
|
|
274
|
+
await loadWorldFromRedis();
|
|
263
275
|
return objectId;
|
|
264
276
|
}
|
|
265
277
|
|
|
@@ -281,7 +293,7 @@ async function releaseObjectInRedis(objectId: number): Promise<boolean> {
|
|
|
281
293
|
REDIS_EVAL_KEYS.headers,
|
|
282
294
|
);
|
|
283
295
|
if (Number(released) === 1) {
|
|
284
|
-
|
|
296
|
+
await loadWorldFromRedis();
|
|
285
297
|
}
|
|
286
298
|
return Number(released) === 1;
|
|
287
299
|
}
|
|
@@ -345,93 +357,109 @@ function notifyObjectReleased(objectId: number, ownerSessionId: string): void {
|
|
|
345
357
|
}
|
|
346
358
|
}
|
|
347
359
|
|
|
348
|
-
function
|
|
349
|
-
|
|
360
|
+
function broadcastWorldBuffer(): void {
|
|
361
|
+
if (connectedSessionList.length === 0) return;
|
|
362
|
+
// worldStateBytes is already a Buffer: forwarded as-is, one call, no iterator.
|
|
363
|
+
broadCastBinaryToClients(worldStateBytes, connectedSessionList, "sync");
|
|
350
364
|
}
|
|
351
365
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function loadWorldFromRedis(): Promise<Float32Array> {
|
|
361
|
-
if (!redis) {
|
|
362
|
-
return new Float32Array(worldState);
|
|
363
|
-
}
|
|
366
|
+
/**
|
|
367
|
+
* Copy the Redis blob into `worldState` in place (the GET reply is the only
|
|
368
|
+
* per-tick allocation; it is a fresh socket Buffer we cannot avoid).
|
|
369
|
+
*/
|
|
370
|
+
async function loadWorldFromRedis(): Promise<void> {
|
|
371
|
+
if (!redis) return;
|
|
364
372
|
const raw = await redis.getBuffer(REDIS_WORLD_KEY);
|
|
365
|
-
|
|
373
|
+
normalizeWorldBuffer(raw, worldState);
|
|
366
374
|
}
|
|
367
375
|
|
|
368
|
-
async function saveWorldToRedis(
|
|
376
|
+
async function saveWorldToRedis(): Promise<void> {
|
|
369
377
|
if (!redis) return;
|
|
370
|
-
await redis.set(REDIS_WORLD_KEY,
|
|
378
|
+
await redis.set(REDIS_WORLD_KEY, worldStateBytes);
|
|
371
379
|
}
|
|
372
380
|
|
|
373
|
-
|
|
381
|
+
/** Live object ids for the current tick; filled in place, never reallocated. */
|
|
382
|
+
const activeObjectIds = new Int32Array(MAX_LIVE_OBJECTS);
|
|
383
|
+
|
|
384
|
+
const SIM_LOCK_TRY_ONCE = { retryUntilAcquired: false } as const;
|
|
385
|
+
const SIM_TICK_PLAN_INPUT = { lockAcquired: false, connectedSessions } as const;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Elapsed seconds since the previous tick body ran. Measured inside the
|
|
389
|
+
* serialized mutation chain so queued ticks never share or overwrite a dt.
|
|
390
|
+
*/
|
|
391
|
+
function takeTickDtSec(): number {
|
|
374
392
|
const now = Date.now();
|
|
375
393
|
const dt = clampSimulationDtSec(
|
|
376
394
|
lastTickTimeMs === 0 ? 0 : now - lastTickTimeMs,
|
|
377
395
|
BROADCAST_HZ,
|
|
378
396
|
);
|
|
379
397
|
lastTickTimeMs = now;
|
|
398
|
+
return dt;
|
|
399
|
+
}
|
|
380
400
|
|
|
401
|
+
function stepWorldInPlace(): void {
|
|
402
|
+
const dt = takeTickDtSec();
|
|
403
|
+
const count = collectActiveObjectIdsInto(worldState, activeObjectIds);
|
|
404
|
+
simulateWorldStep(worldState, dt, activeObjectIds, count);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function stepWorldInMemory(): Promise<void> {
|
|
408
|
+
stepWorldInPlace();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function stepWorldWithRedis(): Promise<boolean> {
|
|
412
|
+
await loadWorldFromRedis();
|
|
413
|
+
stepWorldInPlace();
|
|
414
|
+
await saveWorldToRedis();
|
|
415
|
+
broadcastWorldBuffer();
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function runRedisTick(): Promise<void> {
|
|
420
|
+
const lockResult = await withRedisSimLock(
|
|
421
|
+
stepWorldWithRedis,
|
|
422
|
+
SIM_LOCK_TRY_ONCE,
|
|
423
|
+
);
|
|
424
|
+
if (lockResult !== null) return;
|
|
425
|
+
takeTickDtSec();
|
|
426
|
+
if (planRedisSimTick(SIM_TICK_PLAN_INPUT) === "relay") {
|
|
427
|
+
await loadWorldFromRedis();
|
|
428
|
+
broadcastWorldBuffer();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Tick bodies are module-level functions (no per-tick closures or option objects).
|
|
433
|
+
async function runSimulationTick(): Promise<void> {
|
|
381
434
|
if (!redis) {
|
|
382
|
-
await withWorldMutation(
|
|
383
|
-
|
|
384
|
-
});
|
|
385
|
-
broadcastWorldBuffer(worldState);
|
|
435
|
+
await withWorldMutation(stepWorldInMemory);
|
|
436
|
+
broadcastWorldBuffer();
|
|
386
437
|
return;
|
|
387
438
|
}
|
|
439
|
+
await withWorldMutation(runRedisTick);
|
|
440
|
+
}
|
|
388
441
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const activeObjectIds = collectActiveObjectIds(world);
|
|
394
|
-
simulateWorldStep(world, dt, activeObjectIds);
|
|
395
|
-
await saveWorldToRedis(world);
|
|
396
|
-
worldState = world;
|
|
397
|
-
broadcastWorldBuffer(world);
|
|
398
|
-
return true;
|
|
399
|
-
},
|
|
400
|
-
{ retryUntilAcquired: false },
|
|
401
|
-
);
|
|
442
|
+
function logTickError(error: unknown): void {
|
|
443
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
444
|
+
agentLog("error", `world tick failed: ${detail}`);
|
|
445
|
+
}
|
|
402
446
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (plan === "relay") {
|
|
409
|
-
const world = await loadWorldFromRedis();
|
|
410
|
-
worldState = world;
|
|
411
|
-
broadcastWorldBuffer(world);
|
|
412
|
-
}
|
|
447
|
+
function onBroadcastTick(): void {
|
|
448
|
+
if (!redis && connectedSessions.size < 1) {
|
|
449
|
+
if (broadcastTimer) {
|
|
450
|
+
clearInterval(broadcastTimer);
|
|
451
|
+
broadcastTimer = null;
|
|
413
452
|
}
|
|
414
|
-
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
void runSimulationTick().catch(logTickError);
|
|
415
456
|
}
|
|
416
457
|
|
|
417
458
|
function startBroadcastLoopIfNeeded(): void {
|
|
418
459
|
if (broadcastTimer) return;
|
|
419
460
|
if (!redis && connectedSessions.size < 1) return;
|
|
420
461
|
|
|
421
|
-
broadcastTimer = setInterval(
|
|
422
|
-
if (!redis && connectedSessions.size < 1) {
|
|
423
|
-
if (broadcastTimer) {
|
|
424
|
-
clearInterval(broadcastTimer);
|
|
425
|
-
broadcastTimer = null;
|
|
426
|
-
}
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
void runSimulationTick().catch((error: unknown) => {
|
|
430
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
431
|
-
agentLog("error", `world tick failed: ${detail}`);
|
|
432
|
-
});
|
|
433
|
-
}, BROADCAST_INTERVAL_MS);
|
|
434
|
-
|
|
462
|
+
broadcastTimer = setInterval(onBroadcastTick, BROADCAST_INTERVAL_MS);
|
|
435
463
|
agentLog("info", `world loop started (${BROADCAST_HZ}Hz)`);
|
|
436
464
|
}
|
|
437
465
|
|
|
@@ -448,8 +476,7 @@ async function ensureRedisWorldInitialized(): Promise<void> {
|
|
|
448
476
|
if (!redis) return;
|
|
449
477
|
const existing = await redis.getBuffer(REDIS_WORLD_KEY);
|
|
450
478
|
if (!existing || existing.byteLength === 0) {
|
|
451
|
-
|
|
452
|
-
await redis.set(REDIS_WORLD_KEY, copyWorldBuffer(empty));
|
|
479
|
+
await redis.set(REDIS_WORLD_KEY, worldStateBytes);
|
|
453
480
|
}
|
|
454
481
|
}
|
|
455
482
|
|
|
@@ -461,7 +488,6 @@ defineAgent({
|
|
|
461
488
|
"warn",
|
|
462
489
|
"AGENT_REDIS_URL unset — game-sync uses per-worker in-memory world only",
|
|
463
490
|
);
|
|
464
|
-
worldState = createEmptyWorldBuffer();
|
|
465
491
|
return;
|
|
466
492
|
}
|
|
467
493
|
|
|
@@ -471,17 +497,18 @@ defineAgent({
|
|
|
471
497
|
});
|
|
472
498
|
await redis.connect();
|
|
473
499
|
await ensureRedisWorldInitialized();
|
|
474
|
-
|
|
500
|
+
await loadWorldFromRedis();
|
|
475
501
|
startBroadcastLoopIfNeeded();
|
|
476
502
|
agentLog("info", "game-sync agent connected to project Redis world buffer");
|
|
477
503
|
},
|
|
478
504
|
|
|
479
505
|
async onClientJoin({ sessionId }) {
|
|
480
|
-
connectedSessions.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
worldState = await loadWorldFromRedis();
|
|
506
|
+
if (!connectedSessions.has(sessionId)) {
|
|
507
|
+
connectedSessions.add(sessionId);
|
|
508
|
+
connectedSessionList.push(sessionId);
|
|
484
509
|
}
|
|
510
|
+
startBroadcastLoopIfNeeded();
|
|
511
|
+
await loadWorldFromRedis();
|
|
485
512
|
sendToClient(sessionId, {
|
|
486
513
|
type: "world_snapshot",
|
|
487
514
|
objects: liveWorldSnapshot(worldState, objectOwners),
|
|
@@ -490,7 +517,12 @@ defineAgent({
|
|
|
490
517
|
},
|
|
491
518
|
|
|
492
519
|
async onClientLeave({ sessionId }) {
|
|
493
|
-
connectedSessions.delete(sessionId)
|
|
520
|
+
if (connectedSessions.delete(sessionId)) {
|
|
521
|
+
const index = connectedSessionList.indexOf(sessionId);
|
|
522
|
+
if (index !== -1) {
|
|
523
|
+
connectedSessionList.splice(index, 1);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
494
526
|
for (const [objectId, ownerSessionId] of objectOwners) {
|
|
495
527
|
if (ownerSessionId === sessionId) {
|
|
496
528
|
objectOwners.delete(objectId);
|
|
@@ -587,5 +619,5 @@ export {
|
|
|
587
619
|
readSlotObjectId,
|
|
588
620
|
slotToObjectId,
|
|
589
621
|
writeObjectSlot,
|
|
590
|
-
} from "./
|
|
591
|
-
export { simulateWorldStep } from "./
|
|
622
|
+
} from "./world-layout.js";
|
|
623
|
+
export { simulateWorldStep } from "./sim.js";
|