@voicethere/agent 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/templates/game-sync/agent.js +11428 -191
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +21 -1
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +1397 -0
- package/package.json +11 -11
- package/templates/README.md +19 -11
- package/templates/game-sync-protocol.ts +65 -0
- package/templates/game-sync-redis.ts +121 -0
- package/templates/game-sync-sim.ts +122 -0
- package/templates/game-sync-world-layout.ts +194 -0
- package/templates/game-sync.ts +482 -300
- package/templates/voice-showcase/agent.ts +119 -0
- package/templates/voice-showcase/conversation.ts +531 -0
- package/templates/voice-showcase/fun-facts.ts +24 -0
- package/templates/voice-showcase/recipes.ts +57 -0
- package/templates/voice-showcase/weather.ts +356 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voicethere/agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -83,20 +83,20 @@
|
|
|
83
83
|
},
|
|
84
84
|
"license": "MIT",
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"esbuild": "
|
|
86
|
+
"esbuild": "0.25.12"
|
|
87
87
|
},
|
|
88
88
|
"peerDependencies": {
|
|
89
89
|
"@node-webrtc-rust/sdk": ">=0.6.5"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
|
-
"@node-webrtc-rust/helpers": "
|
|
93
|
-
"@node-webrtc-rust/sdk": "0.7.
|
|
94
|
-
"@node-webrtc-rust/signaling": "0.7.
|
|
95
|
-
"@types/node": "
|
|
96
|
-
"http-server": "
|
|
97
|
-
"ioredis": "
|
|
98
|
-
"tsx": "
|
|
99
|
-
"typescript": "
|
|
100
|
-
"vitest": "
|
|
92
|
+
"@node-webrtc-rust/helpers": "0.7.4",
|
|
93
|
+
"@node-webrtc-rust/sdk": "0.7.4",
|
|
94
|
+
"@node-webrtc-rust/signaling": "0.7.4",
|
|
95
|
+
"@types/node": "22.20.1",
|
|
96
|
+
"http-server": "14.1.1",
|
|
97
|
+
"ioredis": "5.11.1",
|
|
98
|
+
"tsx": "4.19.2",
|
|
99
|
+
"typescript": "5.9.3",
|
|
100
|
+
"vitest": "3.2.7"
|
|
101
101
|
}
|
|
102
102
|
}
|
package/templates/README.md
CHANGED
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
|
|
16
16
|
## Product vs e2e
|
|
17
17
|
|
|
18
|
-
| Kind
|
|
19
|
-
|
|
|
20
|
-
| **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
|
|
21
|
-
| **e2e**
|
|
18
|
+
| Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
|
|
19
|
+
| ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
|
|
20
|
+
| **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
|
|
21
|
+
| **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
|
|
22
22
|
|
|
23
23
|
Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
|
|
24
24
|
|
|
@@ -57,7 +57,15 @@ Full starter bundle covering every speech event from `@node-webrtc-rust/sdk/voic
|
|
|
57
57
|
|
|
58
58
|
### `game-sync.ts` (`game-sync`)
|
|
59
59
|
|
|
60
|
-
Authoritative multi-object sync sample for real-time games/simulations (register, simulate, binary world snapshots).
|
|
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.
|
|
67
|
+
|
|
68
|
+
Sources: `voice-showcase/agent.ts` (defineAgent wiring), `conversation.ts` (pure state machine), `weather.ts`, `recipes.ts`, `fun-facts.ts`.
|
|
61
69
|
|
|
62
70
|
### `webhooks.ts` (`webhooks`)
|
|
63
71
|
|
|
@@ -71,12 +79,12 @@ Same HMAC verify path plus an atomic Redis counter (`AGENT_REDIS_URL`) before Da
|
|
|
71
79
|
|
|
72
80
|
These mirror former `e2e/fixtures/*` sources. E2E resolves entries from the package, builds into ephemeral workdirs, and uploads `dist/agent.js`.
|
|
73
81
|
|
|
74
|
-
| Id
|
|
75
|
-
|
|
|
76
|
-
| `echo-smoke`
|
|
77
|
-
| `crash`
|
|
78
|
-
| `game-sync-smoke` | `game-sync-smoke.ts`
|
|
79
|
-
| `redis-sync`
|
|
82
|
+
| Id | Source | Purpose |
|
|
83
|
+
| ----------------- | ----------------------------------------- | --------------------------------------------- |
|
|
84
|
+
| `echo-smoke` | `echo-smoke.ts` | voice-smoke, agent-smoke, cli-smoke |
|
|
85
|
+
| `crash` | `crash.ts` | session-errors-smoke, crash-policy smokes |
|
|
86
|
+
| `game-sync-smoke` | `game-sync-smoke.ts` | deploy-smoke, shared-child, idle smokes |
|
|
87
|
+
| `redis-sync` | `redis-sync/agent.ts` + `world-layout.ts` | redis-sync-smoke (project Redis world buffer) |
|
|
80
88
|
|
|
81
89
|
**Note:** Product `echo` is not the same as e2e `echo-smoke` — keep both ids.
|
|
82
90
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-sync control-plane message parsers and constants (no defineAgent).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const MAX_LIVE_OBJECTS = 25;
|
|
6
|
+
|
|
7
|
+
export const REGISTER_NACK_REASON_WORLD_FULL = "world_full" as const;
|
|
8
|
+
export const UNREGISTER_NACK_REASON_NOT_FOUND = "not_found" as const;
|
|
9
|
+
export const UNREGISTER_NACK_REASON_NOT_OWNER = "not_owner" as const;
|
|
10
|
+
|
|
11
|
+
export function parseRegisterCommand(message: unknown): boolean {
|
|
12
|
+
if (!message || typeof message !== "object") return false;
|
|
13
|
+
const record = message as { type?: unknown };
|
|
14
|
+
return record.type === "register";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UnregisterCommand {
|
|
18
|
+
type: "unregister" | "remove";
|
|
19
|
+
objectId?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveRemoveTarget(
|
|
23
|
+
objectId: number | undefined,
|
|
24
|
+
ownedObjectIds: ReadonlySet<number> | undefined | null,
|
|
25
|
+
): { ok: true; objectId: number } | { ok: false; reason: string } {
|
|
26
|
+
if (objectId !== undefined) {
|
|
27
|
+
return { ok: true, objectId };
|
|
28
|
+
}
|
|
29
|
+
if (!ownedObjectIds || ownedObjectIds.size === 0) {
|
|
30
|
+
return { ok: false, reason: UNREGISTER_NACK_REASON_NOT_FOUND };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, objectId: Math.max(...ownedObjectIds) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseUnregisterCommand(
|
|
36
|
+
message: unknown,
|
|
37
|
+
): UnregisterCommand | null {
|
|
38
|
+
if (!message || typeof message !== "object") return null;
|
|
39
|
+
const record = message as { type?: unknown; objectId?: unknown };
|
|
40
|
+
if (record.type !== "unregister" && record.type !== "remove") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (record.objectId === undefined) {
|
|
44
|
+
return { type: record.type as "unregister" | "remove" };
|
|
45
|
+
}
|
|
46
|
+
if (
|
|
47
|
+
typeof record.objectId !== "number" ||
|
|
48
|
+
!Number.isFinite(record.objectId)
|
|
49
|
+
) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
type: record.type as "unregister" | "remove",
|
|
54
|
+
objectId: Math.trunc(record.objectId),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseChatCommand(message: unknown): { text: string } | null {
|
|
59
|
+
if (!message || typeof message !== "object") return null;
|
|
60
|
+
const record = message as { type?: unknown; text?: unknown };
|
|
61
|
+
if (record.type !== "chat" || typeof record.text !== "string") return null;
|
|
62
|
+
const text = record.text.trim();
|
|
63
|
+
if (!text) return null;
|
|
64
|
+
return { text };
|
|
65
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Lua scripts and helpers for game-sync world buffer.
|
|
3
|
+
*/
|
|
4
|
+
import { MAX_LIVE_OBJECTS } from "./game-sync-protocol.js";
|
|
5
|
+
import {
|
|
6
|
+
objectIdHeaderBytes,
|
|
7
|
+
OBJECT_SLOT_BYTE_LENGTH,
|
|
8
|
+
WORLD_BYTE_LENGTH,
|
|
9
|
+
REDIS_WORLD_KEY,
|
|
10
|
+
} from "./game-sync-world-layout.js";
|
|
11
|
+
|
|
12
|
+
/** Concatenated 4-byte float32 headers for objectId 1..MAX_LIVE_OBJECTS. */
|
|
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();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Atomic allocate: find first empty slot, enforce global live cap, write 9-float record.
|
|
25
|
+
* Returns objectId (1-based) or -1 when world is full.
|
|
26
|
+
*
|
|
27
|
+
* ARGV[4] = 32-byte tail (8 floats: pos + dir without objectId header)
|
|
28
|
+
* ARGV[5] = 100-byte objectId header lookup blob
|
|
29
|
+
*/
|
|
30
|
+
export const LUA_ALLOCATE_OBJECT = `
|
|
31
|
+
local key = KEYS[1]
|
|
32
|
+
local size = tonumber(ARGV[1])
|
|
33
|
+
local slot_bytes = tonumber(ARGV[2])
|
|
34
|
+
local max_slots = tonumber(ARGV[3])
|
|
35
|
+
local tail = ARGV[4]
|
|
36
|
+
local headers = ARGV[5]
|
|
37
|
+
|
|
38
|
+
local world = redis.call('GET', key)
|
|
39
|
+
if not world then
|
|
40
|
+
world = string.rep(string.char(0), size)
|
|
41
|
+
elseif #world < size then
|
|
42
|
+
world = world .. string.rep(string.char(0), size - #world)
|
|
43
|
+
elseif #world > size then
|
|
44
|
+
world = string.sub(world, 1, size)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
local live = 0
|
|
48
|
+
local empty_off = nil
|
|
49
|
+
for i = 0, max_slots - 1 do
|
|
50
|
+
local off = i * slot_bytes + 1
|
|
51
|
+
local b1, b2, b3, b4 = string.byte(world, off, off + 3)
|
|
52
|
+
if b1 == 0 and b2 == 0 and b3 == 0 and b4 == 0 then
|
|
53
|
+
if empty_off == nil then empty_off = off end
|
|
54
|
+
else
|
|
55
|
+
live = live + 1
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if live >= max_slots or empty_off == nil then
|
|
60
|
+
return -1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
local slot_index = math.floor((empty_off - 1) / slot_bytes)
|
|
64
|
+
local object_id = slot_index + 1
|
|
65
|
+
local header = string.sub(headers, (object_id - 1) * 4 + 1, object_id * 4)
|
|
66
|
+
local new_slot = header .. tail
|
|
67
|
+
if #new_slot ~= slot_bytes then
|
|
68
|
+
return -2
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
world = string.sub(world, 1, empty_off - 1) .. new_slot .. string.sub(world, empty_off + slot_bytes)
|
|
72
|
+
redis.call('SET', key, world)
|
|
73
|
+
return object_id
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Zero the fixed slot for objectId when the header matches.
|
|
78
|
+
* Returns 1 on success, 0 when slot empty or objectId mismatch.
|
|
79
|
+
*/
|
|
80
|
+
export const LUA_RELEASE_OBJECT = `
|
|
81
|
+
local key = KEYS[1]
|
|
82
|
+
local size = tonumber(ARGV[1])
|
|
83
|
+
local slot_bytes = tonumber(ARGV[2])
|
|
84
|
+
local max_slots = tonumber(ARGV[3])
|
|
85
|
+
local object_id = tonumber(ARGV[4])
|
|
86
|
+
local headers = ARGV[5]
|
|
87
|
+
|
|
88
|
+
if object_id < 1 or object_id > max_slots then
|
|
89
|
+
return 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
local world = redis.call('GET', key)
|
|
93
|
+
if not world then
|
|
94
|
+
return 0
|
|
95
|
+
elseif #world < size then
|
|
96
|
+
world = world .. string.rep(string.char(0), size - #world)
|
|
97
|
+
elseif #world > size then
|
|
98
|
+
world = string.sub(world, 1, size)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
local slot_index = object_id - 1
|
|
102
|
+
local off = slot_index * slot_bytes + 1
|
|
103
|
+
local header = string.sub(headers, (object_id - 1) * 4 + 1, object_id * 4)
|
|
104
|
+
local current = string.sub(world, off, off + 3)
|
|
105
|
+
if current ~= header then
|
|
106
|
+
return 0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
local zero_slot = string.rep(string.char(0), slot_bytes)
|
|
110
|
+
world = string.sub(world, 1, off - 1) .. zero_slot .. string.sub(world, off + slot_bytes)
|
|
111
|
+
redis.call('SET', key, world)
|
|
112
|
+
return 1
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
export const REDIS_EVAL_KEYS = {
|
|
116
|
+
worldKey: REDIS_WORLD_KEY,
|
|
117
|
+
worldByteLength: String(WORLD_BYTE_LENGTH),
|
|
118
|
+
slotByteLength: String(OBJECT_SLOT_BYTE_LENGTH),
|
|
119
|
+
maxSlots: String(MAX_LIVE_OBJECTS),
|
|
120
|
+
headers: OBJECT_ID_HEADERS_BLOB,
|
|
121
|
+
} as const;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-authoritative game-sync physics step (pure Float32Array, no Redis).
|
|
3
|
+
*/
|
|
4
|
+
import { objectIdToSlot } from "./game-sync-world-layout.js";
|
|
5
|
+
|
|
6
|
+
export const BOARD_WIDTH = 1280;
|
|
7
|
+
export const BOARD_HEIGHT = 720;
|
|
8
|
+
export const OBJECT_RADIUS = 25;
|
|
9
|
+
export const COLLISION_RESTITUTION = 1.0;
|
|
10
|
+
export const OBJECT_STRIDE = 9;
|
|
11
|
+
|
|
12
|
+
export function simulateWorldStep(
|
|
13
|
+
worldState: Float32Array,
|
|
14
|
+
dtSec: number,
|
|
15
|
+
activeObjectIds: number[],
|
|
16
|
+
): void {
|
|
17
|
+
for (const objectId of activeObjectIds) {
|
|
18
|
+
const slot = objectIdToSlot(objectId);
|
|
19
|
+
const start = slot * OBJECT_STRIDE;
|
|
20
|
+
if (start < 0 || start + OBJECT_STRIDE > worldState.length) continue;
|
|
21
|
+
|
|
22
|
+
let x = worldState[start + 1] ?? 0;
|
|
23
|
+
let y = worldState[start + 2] ?? 0;
|
|
24
|
+
let vx = worldState[start + 5] ?? 0;
|
|
25
|
+
let vy = worldState[start + 6] ?? 0;
|
|
26
|
+
|
|
27
|
+
x += vx * dtSec;
|
|
28
|
+
y += vy * dtSec;
|
|
29
|
+
|
|
30
|
+
if (x < OBJECT_RADIUS || x > BOARD_WIDTH - OBJECT_RADIUS) {
|
|
31
|
+
vx *= -1;
|
|
32
|
+
x = Math.max(OBJECT_RADIUS, Math.min(BOARD_WIDTH - OBJECT_RADIUS, x));
|
|
33
|
+
}
|
|
34
|
+
if (y < OBJECT_RADIUS || y > BOARD_HEIGHT - OBJECT_RADIUS) {
|
|
35
|
+
vy *= -1;
|
|
36
|
+
y = Math.max(OBJECT_RADIUS, Math.min(BOARD_HEIGHT - OBJECT_RADIUS, y));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
worldState[start + 1] = x;
|
|
40
|
+
worldState[start + 2] = y;
|
|
41
|
+
worldState[start + 5] = vx;
|
|
42
|
+
worldState[start + 6] = vy;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < activeObjectIds.length; i += 1) {
|
|
46
|
+
const aId = activeObjectIds[i];
|
|
47
|
+
const aSlot = objectIdToSlot(aId);
|
|
48
|
+
const aStart = aSlot * OBJECT_STRIDE;
|
|
49
|
+
if (aStart < 0 || aStart + OBJECT_STRIDE > worldState.length) continue;
|
|
50
|
+
for (let j = i + 1; j < activeObjectIds.length; j += 1) {
|
|
51
|
+
const bId = activeObjectIds[j];
|
|
52
|
+
const bSlot = objectIdToSlot(bId);
|
|
53
|
+
const bStart = bSlot * OBJECT_STRIDE;
|
|
54
|
+
if (bStart < 0 || bStart + OBJECT_STRIDE > worldState.length) continue;
|
|
55
|
+
|
|
56
|
+
let ax = worldState[aStart + 1] ?? 0;
|
|
57
|
+
let ay = worldState[aStart + 2] ?? 0;
|
|
58
|
+
let avx = worldState[aStart + 5] ?? 0;
|
|
59
|
+
let avy = worldState[aStart + 6] ?? 0;
|
|
60
|
+
let bx = worldState[bStart + 1] ?? 0;
|
|
61
|
+
let by = worldState[bStart + 2] ?? 0;
|
|
62
|
+
let bvx = worldState[bStart + 5] ?? 0;
|
|
63
|
+
let bvy = worldState[bStart + 6] ?? 0;
|
|
64
|
+
|
|
65
|
+
let dx = bx - ax;
|
|
66
|
+
let dy = by - ay;
|
|
67
|
+
let distSq = dx * dx + dy * dy;
|
|
68
|
+
const minDist = OBJECT_RADIUS * 2;
|
|
69
|
+
const minDistSq = minDist * minDist;
|
|
70
|
+
if (!(distSq > 0 && distSq < minDistSq)) continue;
|
|
71
|
+
|
|
72
|
+
let dist = Math.sqrt(distSq);
|
|
73
|
+
if (dist === 0) {
|
|
74
|
+
dx = 1;
|
|
75
|
+
dy = 0;
|
|
76
|
+
dist = 1;
|
|
77
|
+
distSq = 1;
|
|
78
|
+
}
|
|
79
|
+
const nx = dx / dist;
|
|
80
|
+
const ny = dy / dist;
|
|
81
|
+
|
|
82
|
+
const overlap = minDist - dist;
|
|
83
|
+
const half = overlap * 0.5;
|
|
84
|
+
ax -= nx * half;
|
|
85
|
+
ay -= ny * half;
|
|
86
|
+
bx += nx * half;
|
|
87
|
+
by += ny * half;
|
|
88
|
+
|
|
89
|
+
const rvx = bvx - avx;
|
|
90
|
+
const rvy = bvy - avy;
|
|
91
|
+
const velAlongNormal = rvx * nx + rvy * ny;
|
|
92
|
+
if (velAlongNormal < 0) {
|
|
93
|
+
const impulse = (-(1 + COLLISION_RESTITUTION) * velAlongNormal) / 2;
|
|
94
|
+
avx -= impulse * nx;
|
|
95
|
+
avy -= impulse * ny;
|
|
96
|
+
bvx += impulse * nx;
|
|
97
|
+
bvy += impulse * ny;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
worldState[aStart + 1] = Math.max(
|
|
101
|
+
OBJECT_RADIUS,
|
|
102
|
+
Math.min(BOARD_WIDTH - OBJECT_RADIUS, ax),
|
|
103
|
+
);
|
|
104
|
+
worldState[aStart + 2] = Math.max(
|
|
105
|
+
OBJECT_RADIUS,
|
|
106
|
+
Math.min(BOARD_HEIGHT - OBJECT_RADIUS, ay),
|
|
107
|
+
);
|
|
108
|
+
worldState[aStart + 5] = avx;
|
|
109
|
+
worldState[aStart + 6] = avy;
|
|
110
|
+
worldState[bStart + 1] = Math.max(
|
|
111
|
+
OBJECT_RADIUS,
|
|
112
|
+
Math.min(BOARD_WIDTH - OBJECT_RADIUS, bx),
|
|
113
|
+
);
|
|
114
|
+
worldState[bStart + 2] = Math.max(
|
|
115
|
+
OBJECT_RADIUS,
|
|
116
|
+
Math.min(BOARD_HEIGHT - OBJECT_RADIUS, by),
|
|
117
|
+
);
|
|
118
|
+
worldState[bStart + 5] = bvx;
|
|
119
|
+
worldState[bStart + 6] = bvy;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared world buffer layout for game-sync (agent + unit tests).
|
|
3
|
+
*
|
|
4
|
+
* Fixed-size Float32Array: MAX_LIVE_OBJECTS slots × OBJECT_STRIDE floats each.
|
|
5
|
+
* Slot layout: [objectId, posX, posY, posZ, posW, dirX, dirY, dirZ, dirW]
|
|
6
|
+
* Empty slot: objectId === 0
|
|
7
|
+
*/
|
|
8
|
+
import { MAX_LIVE_OBJECTS } from "./game-sync-protocol.js";
|
|
9
|
+
|
|
10
|
+
export const OBJECT_STRIDE = 9;
|
|
11
|
+
export const WORLD_FLOAT_COUNT = MAX_LIVE_OBJECTS * OBJECT_STRIDE;
|
|
12
|
+
export const WORLD_BYTE_LENGTH = WORLD_FLOAT_COUNT * 4;
|
|
13
|
+
export const OBJECT_SLOT_BYTE_LENGTH = OBJECT_STRIDE * 4;
|
|
14
|
+
|
|
15
|
+
export const REDIS_WORLD_KEY = "game-sync:world";
|
|
16
|
+
export const REDIS_SIM_LOCK_KEY = "game-sync:sim-lock";
|
|
17
|
+
|
|
18
|
+
export function slotToObjectId(slot: number): number {
|
|
19
|
+
return slot + 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function objectIdToSlot(objectId: number): number {
|
|
23
|
+
return objectId - 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createEmptyWorldBuffer(): Float32Array {
|
|
27
|
+
return new Float32Array(WORLD_FLOAT_COUNT);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function slotByteOffset(slot: number): number {
|
|
31
|
+
return slot * OBJECT_SLOT_BYTE_LENGTH;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readSlotObjectId(world: Float32Array, slot: number): number {
|
|
35
|
+
const id = world[slot * OBJECT_STRIDE];
|
|
36
|
+
return Number.isFinite(id) ? id : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function countLiveObjects(world: Float32Array): number {
|
|
40
|
+
let live = 0;
|
|
41
|
+
for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
|
|
42
|
+
if (readSlotObjectId(world, slot) !== 0) {
|
|
43
|
+
live += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return live;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function findFirstEmptySlot(world: Float32Array): number | null {
|
|
50
|
+
for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
|
|
51
|
+
if (readSlotObjectId(world, slot) === 0) {
|
|
52
|
+
return slot;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findSlotByObjectId(
|
|
59
|
+
world: Float32Array,
|
|
60
|
+
objectId: number,
|
|
61
|
+
): number | null {
|
|
62
|
+
for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
|
|
63
|
+
if (readSlotObjectId(world, slot) === objectId) {
|
|
64
|
+
return slot;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function markSlotFree(world: Float32Array, slot: number): void {
|
|
71
|
+
const start = slot * OBJECT_STRIDE;
|
|
72
|
+
for (let i = 0; i < OBJECT_STRIDE; i += 1) {
|
|
73
|
+
world[start + i] = 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function encodeObjectSlot(
|
|
78
|
+
objectId: number,
|
|
79
|
+
posX: number,
|
|
80
|
+
posY: number,
|
|
81
|
+
posZ: number,
|
|
82
|
+
posW: number,
|
|
83
|
+
dirX: number,
|
|
84
|
+
dirY: number,
|
|
85
|
+
dirZ: number,
|
|
86
|
+
dirW: number,
|
|
87
|
+
): Buffer {
|
|
88
|
+
const floats = new Float32Array([
|
|
89
|
+
objectId,
|
|
90
|
+
posX,
|
|
91
|
+
posY,
|
|
92
|
+
posZ,
|
|
93
|
+
posW,
|
|
94
|
+
dirX,
|
|
95
|
+
dirY,
|
|
96
|
+
dirZ,
|
|
97
|
+
dirW,
|
|
98
|
+
]);
|
|
99
|
+
return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function writeObjectSlot(
|
|
103
|
+
world: Float32Array,
|
|
104
|
+
slot: number,
|
|
105
|
+
objectId: number,
|
|
106
|
+
posX: number,
|
|
107
|
+
posY: number,
|
|
108
|
+
posZ: number,
|
|
109
|
+
posW: number,
|
|
110
|
+
dirX: number,
|
|
111
|
+
dirY: number,
|
|
112
|
+
dirZ: number,
|
|
113
|
+
dirW: number,
|
|
114
|
+
): void {
|
|
115
|
+
const start = slot * OBJECT_STRIDE;
|
|
116
|
+
world[start] = objectId;
|
|
117
|
+
world[start + 1] = posX;
|
|
118
|
+
world[start + 2] = posY;
|
|
119
|
+
world[start + 3] = posZ;
|
|
120
|
+
world[start + 4] = posW;
|
|
121
|
+
world[start + 5] = dirX;
|
|
122
|
+
world[start + 6] = dirY;
|
|
123
|
+
world[start + 7] = dirZ;
|
|
124
|
+
world[start + 8] = dirW;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* After simulating from a Redis load, keep slots empty that a concurrent release
|
|
129
|
+
* zeroed in Redis while the tick was in flight — prevents saveWorldToRedis from
|
|
130
|
+
* restoring an object Lua just cleared (sim GET → simulate → SET race).
|
|
131
|
+
*
|
|
132
|
+
* `authoritative` must be a fresh Redis GET after simulate (never stale in-memory
|
|
133
|
+
* worldState — an empty buffer would wipe every live slot).
|
|
134
|
+
*/
|
|
135
|
+
export function preserveEmptySlots(
|
|
136
|
+
simulated: Float32Array,
|
|
137
|
+
authoritative: Float32Array,
|
|
138
|
+
): void {
|
|
139
|
+
for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
|
|
140
|
+
if (readSlotObjectId(authoritative, slot) === 0) {
|
|
141
|
+
markSlotFree(simulated, slot);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Merge a simulated world with the latest Redis snapshot before SET. */
|
|
147
|
+
export function commitSimulatedWorld(
|
|
148
|
+
simulated: Float32Array,
|
|
149
|
+
latestRedis: Float32Array,
|
|
150
|
+
): void {
|
|
151
|
+
preserveEmptySlots(simulated, latestRedis);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function collectActiveObjectIds(world: Float32Array): number[] {
|
|
155
|
+
const ids: number[] = [];
|
|
156
|
+
for (let slot = 0; slot < MAX_LIVE_OBJECTS; slot += 1) {
|
|
157
|
+
const objectId = readSlotObjectId(world, slot);
|
|
158
|
+
if (objectId !== 0) {
|
|
159
|
+
ids.push(objectId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return ids;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Decode a Redis / Node Buffer into a fixed-size world Float32Array.
|
|
167
|
+
*/
|
|
168
|
+
export function normalizeWorldBuffer(
|
|
169
|
+
raw: Uint8Array | null | undefined,
|
|
170
|
+
): Float32Array {
|
|
171
|
+
if (!raw || raw.byteLength === 0) {
|
|
172
|
+
return createEmptyWorldBuffer();
|
|
173
|
+
}
|
|
174
|
+
const bytes = Math.floor(raw.byteLength / 4) * 4;
|
|
175
|
+
if (bytes === 0) {
|
|
176
|
+
return createEmptyWorldBuffer();
|
|
177
|
+
}
|
|
178
|
+
const aligned = raw.buffer.slice(raw.byteOffset, raw.byteOffset + bytes);
|
|
179
|
+
const decoded = new Float32Array(aligned);
|
|
180
|
+
if (decoded.length === WORLD_FLOAT_COUNT) {
|
|
181
|
+
return decoded;
|
|
182
|
+
}
|
|
183
|
+
const normalized = createEmptyWorldBuffer();
|
|
184
|
+
normalized.set(
|
|
185
|
+
decoded.subarray(0, Math.min(decoded.length, WORLD_FLOAT_COUNT)),
|
|
186
|
+
);
|
|
187
|
+
return normalized;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Float32 objectId bytes for Lua slot header splice (objectId 1..MAX_LIVE_OBJECTS). */
|
|
191
|
+
export function objectIdHeaderBytes(objectId: number): Buffer {
|
|
192
|
+
const header = new Float32Array([objectId]);
|
|
193
|
+
return Buffer.from(header.buffer, header.byteOffset, 4);
|
|
194
|
+
}
|