@crowdedkingdoms/construct 0.0.0 → 0.2.1-dev.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.
Files changed (69) hide show
  1. package/README.md +76 -1
  2. package/node/copy-dsh-web.mjs +71 -0
  3. package/node/security-headers.d.mts +23 -0
  4. package/node/security-headers.mjs +243 -0
  5. package/node/vite-plugins.d.mts +6 -0
  6. package/node/vite-plugins.mjs +102 -0
  7. package/package.json +48 -3
  8. package/sandbox/boot.ts +74 -0
  9. package/src/engine/GameLoop.ts +106 -0
  10. package/src/engine/GameScene.ts +74 -0
  11. package/src/engine/Input.test.ts +129 -0
  12. package/src/engine/Input.ts +257 -0
  13. package/src/engine/SceneRouter.test.ts +80 -0
  14. package/src/engine/SceneRouter.ts +113 -0
  15. package/src/engine/controls.test.ts +26 -0
  16. package/src/engine/controls.ts +63 -0
  17. package/src/grid/GridProgramRunner.ts +205 -0
  18. package/src/grid/sandboxProtocol.ts +9 -0
  19. package/src/index.ts +88 -0
  20. package/src/platform/GameSession.ts +239 -0
  21. package/src/platform/auth/AuthService.ts +45 -0
  22. package/src/platform/config.test.ts +110 -0
  23. package/src/platform/config.ts +212 -0
  24. package/src/platform/envScope.test.ts +42 -0
  25. package/src/platform/envScope.ts +76 -0
  26. package/src/platform/exec/worldHub.test.ts +61 -0
  27. package/src/platform/exec/worldHub.ts +86 -0
  28. package/src/platform/media/VoiceService.ts +423 -0
  29. package/src/platform/media/WebcamService.ts +346 -0
  30. package/src/platform/media/mulaw.test.ts +20 -0
  31. package/src/platform/media/mulaw.ts +56 -0
  32. package/src/platform/media/videoFrames.test.ts +197 -0
  33. package/src/platform/media/voiceFrames.test.ts +82 -0
  34. package/src/platform/model/ModelService.test.ts +59 -0
  35. package/src/platform/model/ModelService.ts +124 -0
  36. package/src/platform/network/NetworkManager.ts +519 -0
  37. package/src/platform/onboarding/steps.d.mts +154 -0
  38. package/src/platform/onboarding/steps.mjs +616 -0
  39. package/src/platform/onboarding/steps.test.ts +486 -0
  40. package/src/platform/realtime/WorldStores.ts +153 -0
  41. package/src/platform/realtime/actorCodec.test.ts +62 -0
  42. package/src/platform/realtime/actorCodec.ts +142 -0
  43. package/src/platform/realtime/space.test.ts +33 -0
  44. package/src/platform/realtime/space.ts +63 -0
  45. package/src/platform/social/ChatService.ts +92 -0
  46. package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
  47. package/src/platform/studio/ClientModLifecycle.ts +102 -0
  48. package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
  49. package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
  50. package/src/platform/studio/GridService.test.ts +131 -0
  51. package/src/platform/studio/GridService.ts +295 -0
  52. package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
  53. package/src/platform/studio/HumanInputMonitor.ts +48 -0
  54. package/src/platform/studio/StudioService.ts +686 -0
  55. package/src/platform/studio/agentLocomotion.test.ts +31 -0
  56. package/src/platform/studio/agentLocomotion.ts +97 -0
  57. package/src/platform/studio/clientModHost.test.ts +199 -0
  58. package/src/platform/studio/clientModHost.ts +369 -0
  59. package/src/platform/studio/instanceSchema.test.ts +280 -0
  60. package/src/platform/studio/instanceSchema.ts +746 -0
  61. package/src/platform/studio/instanceStore.test.ts +97 -0
  62. package/src/platform/studio/instanceStore.ts +120 -0
  63. package/src/platform/studio/modOverlay.test.ts +56 -0
  64. package/src/platform/studio/modOverlay.ts +51 -0
  65. package/src/platform/studio/permissions.test.ts +51 -0
  66. package/src/platform/studio/permissions.ts +134 -0
  67. package/src/platform/studio/pointerClicks.test.ts +23 -0
  68. package/src/platform/studio/pointerClicks.ts +132 -0
  69. package/src/platform/util/Emitter.ts +32 -0
@@ -0,0 +1,746 @@
1
+ /**
2
+ * construct.scene.v1 — the LCD scene graph a claimed-grid mod may put in the
3
+ * world. SERVER owns the catalog + poses (replicated). CLIENT overlay_draw
4
+ * uses the same JSON for local gizmos. Unknown keys are ignored.
5
+ */
6
+ import type { PlayerCodeGridBounds } from '@crowdedkingdoms/crowdyjs';
7
+
8
+ import { CHUNK_SIZE } from '../config';
9
+
10
+ export const SCENE_VERSION = 1;
11
+ export const MAX_MESHES = 16;
12
+ export const MAX_NODES = 64;
13
+ export const MAX_OVERLAY_OBJECTS = MAX_NODES;
14
+ export const MAX_VERTS = 256;
15
+ export const MAX_INDICES = 768;
16
+ export const MAX_CATALOG_BYTES = 48 * 1024;
17
+ export const MIN_SIZE = 0.04;
18
+ export const MAX_SIZE = 8;
19
+ export const MAX_ID_CHARS = 32;
20
+
21
+ /** Construct-owned EventRouter types. Payload is the state AFTER uint16 eventType. */
22
+ export const EVENT_CATALOG = 0xc501;
23
+ export const EVENT_POSES = 0xc502;
24
+
25
+ /** emit_spatial payload is 1024 bytes including the 2-byte eventType. */
26
+ export const MAX_EVENT_STATE_BYTES = 1022;
27
+ const CATALOG_CHUNK_HEADER = 8;
28
+
29
+ export type MeshKind = 'box' | 'sphere' | 'cylinder' | 'capsule' | 'plane' | 'mesh';
30
+ export type ModOverlayShape = MeshKind;
31
+
32
+ export interface SceneMesh {
33
+ id: string;
34
+ kind: MeshKind;
35
+ color: number;
36
+ positions?: Float32Array;
37
+ indices?: Uint16Array;
38
+ normals?: Float32Array;
39
+ }
40
+
41
+ export interface SceneNode {
42
+ id: string;
43
+ parent?: string;
44
+ mesh?: string;
45
+ kind: MeshKind;
46
+ x: number;
47
+ y: number;
48
+ z: number;
49
+ qx: number;
50
+ qy: number;
51
+ qz: number;
52
+ qw: number;
53
+ sx: number;
54
+ sy: number;
55
+ sz: number;
56
+ color: number;
57
+ visible: boolean;
58
+ bindActor?: string;
59
+ }
60
+
61
+ /** Overlay objects are scene nodes; `shape` is an alias of `kind` for older payloads. */
62
+ export type ModOverlayObject = SceneNode & { shape: MeshKind };
63
+
64
+ export interface SceneCatalog {
65
+ revision: number;
66
+ meshes: SceneMesh[];
67
+ nodes: SceneNode[];
68
+ }
69
+
70
+ export interface ActorXform {
71
+ uuid: string;
72
+ x: number;
73
+ y: number;
74
+ z: number;
75
+ yaw: number;
76
+ pitch: number;
77
+ }
78
+
79
+ export interface ComposedInstance {
80
+ id: string;
81
+ kind: MeshKind;
82
+ mesh?: SceneMesh;
83
+ x: number;
84
+ y: number;
85
+ z: number;
86
+ qx: number;
87
+ qy: number;
88
+ qz: number;
89
+ qw: number;
90
+ sx: number;
91
+ sy: number;
92
+ sz: number;
93
+ color: number;
94
+ visible: boolean;
95
+ }
96
+
97
+ export interface WorldAabb {
98
+ minX: number;
99
+ maxX: number;
100
+ minY: number;
101
+ maxY: number;
102
+ minZ: number;
103
+ maxZ: number;
104
+ }
105
+
106
+ export function gridWorldBounds(grid: PlayerCodeGridBounds): WorldAabb {
107
+ return {
108
+ minX: Number(grid.low.x) * CHUNK_SIZE,
109
+ maxX: (Number(grid.high.x) + 1) * CHUNK_SIZE,
110
+ minY: Number(grid.low.y) * CHUNK_SIZE,
111
+ maxY: (Number(grid.high.y) + 1) * CHUNK_SIZE,
112
+ minZ: Number(grid.low.z) * CHUNK_SIZE,
113
+ maxZ: (Number(grid.high.z) + 1) * CHUNK_SIZE,
114
+ };
115
+ }
116
+
117
+ export function clamp(value: number, lo: number, hi: number): number {
118
+ return Math.min(hi, Math.max(lo, value));
119
+ }
120
+
121
+ function asRecord(value: unknown): Record<string, unknown> | null {
122
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
123
+ ? (value as Record<string, unknown>)
124
+ : null;
125
+ }
126
+
127
+ function finiteNumber(value: unknown, fallback?: number): number | null {
128
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
129
+ if (typeof value === 'string' && value.trim() !== '') {
130
+ const n = Number(value);
131
+ if (Number.isFinite(n)) return n;
132
+ }
133
+ return fallback === undefined ? null : fallback;
134
+ }
135
+
136
+ export function parseColor(value: unknown): number {
137
+ if (typeof value === 'number' && Number.isFinite(value)) {
138
+ return Math.max(0, Math.min(0xffffff, Math.floor(value)));
139
+ }
140
+ if (typeof value === 'string') {
141
+ const hex = value.trim().replace(/^#/, '');
142
+ if (/^[0-9a-fA-F]{6}$/.test(hex)) return Number.parseInt(hex, 16);
143
+ }
144
+ return 0xf8fafc;
145
+ }
146
+
147
+ function parseKind(value: unknown): MeshKind {
148
+ if (
149
+ value === 'sphere' ||
150
+ value === 'cylinder' ||
151
+ value === 'capsule' ||
152
+ value === 'plane' ||
153
+ value === 'mesh'
154
+ ) {
155
+ return value;
156
+ }
157
+ return 'box';
158
+ }
159
+
160
+ function parseId(value: unknown, fallback: string): string {
161
+ if (typeof value === 'string' && value.trim()) return value.trim().slice(0, MAX_ID_CHARS);
162
+ return fallback.slice(0, MAX_ID_CHARS);
163
+ }
164
+
165
+ function parseQuat(rec: Record<string, unknown>): [number, number, number, number] {
166
+ const q = asRecord(rec.q) ?? asRecord(rec.quat) ?? asRecord(rec.quaternion);
167
+ const qx = finiteNumber(rec.qx ?? rec.qi ?? q?.x ?? q?.qx, 0) ?? 0;
168
+ const qy = finiteNumber(rec.qy ?? rec.qj ?? q?.y ?? q?.qy, 0) ?? 0;
169
+ const qz = finiteNumber(rec.qz ?? rec.qk ?? q?.z ?? q?.qz, 0) ?? 0;
170
+ const qw = finiteNumber(rec.qw ?? rec.qr ?? q?.w ?? q?.qw, 1) ?? 1;
171
+ return normalizeQuat(qx, qy, qz, qw);
172
+ }
173
+
174
+ export function normalizeQuat(
175
+ qx: number,
176
+ qy: number,
177
+ qz: number,
178
+ qw: number,
179
+ ): [number, number, number, number] {
180
+ const len = Math.hypot(qx, qy, qz, qw);
181
+ if (!Number.isFinite(len) || len < 1e-8) return [0, 0, 0, 1];
182
+ return [qx / len, qy / len, qz / len, qw / len];
183
+ }
184
+
185
+ function parseBindActor(value: unknown): string | undefined {
186
+ if (typeof value !== 'string') return undefined;
187
+ const trimmed = value.trim();
188
+ if (!trimmed) return undefined;
189
+ return trimmed.slice(0, 64);
190
+ }
191
+
192
+ function parseFloatList(value: unknown, max: number): number[] | null {
193
+ if (!Array.isArray(value)) return null;
194
+ if (value.length > max) return null;
195
+ const out: number[] = [];
196
+ for (const item of value) {
197
+ const n = finiteNumber(item);
198
+ if (n === null) return null;
199
+ out.push(n);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ function parseMesh(raw: unknown, index: number): SceneMesh | null {
205
+ const rec = asRecord(raw);
206
+ if (!rec) return null;
207
+ const id = parseId(rec.id ?? rec.name, `mesh-${index}`);
208
+ const kind = parseKind(rec.kind ?? rec.shape);
209
+ const color = parseColor(rec.color ?? rec.colour);
210
+ if (kind !== 'mesh') return { id, kind, color };
211
+ const pos = parseFloatList(rec.positions ?? rec.verts ?? rec.vertices, MAX_VERTS * 3);
212
+ const idx = parseFloatList(rec.indices ?? rec.tris, MAX_INDICES);
213
+ if (!pos || pos.length < 9 || pos.length % 3 !== 0) return null;
214
+ const vertCount = pos.length / 3;
215
+ if (vertCount > MAX_VERTS) return null;
216
+ if (!idx || idx.length < 3 || idx.length % 3 !== 0) return null;
217
+ const indices = new Uint16Array(idx.length);
218
+ for (let i = 0; i < idx.length; i++) {
219
+ const v = idx[i]!;
220
+ if (!Number.isInteger(v) || v < 0 || v >= vertCount) return null;
221
+ indices[i] = v;
222
+ }
223
+ const mesh: SceneMesh = {
224
+ id,
225
+ kind: 'mesh',
226
+ color,
227
+ positions: Float32Array.from(pos),
228
+ indices,
229
+ };
230
+ const nrm = parseFloatList(rec.normals, MAX_VERTS * 3);
231
+ if (nrm && nrm.length === pos.length) mesh.normals = Float32Array.from(nrm);
232
+ return mesh;
233
+ }
234
+
235
+ function parseNode(
236
+ raw: unknown,
237
+ index: number,
238
+ grid?: PlayerCodeGridBounds,
239
+ defaultScale = 1,
240
+ ): SceneNode | null {
241
+ const rec = asRecord(raw);
242
+ if (!rec) return null;
243
+ const p = asRecord(rec.p) ?? asRecord(rec.pos) ?? asRecord(rec.position);
244
+ const x = finiteNumber(rec.x ?? rec[0] ?? p?.x);
245
+ const y = finiteNumber(rec.y ?? rec[1] ?? p?.y, 0.5);
246
+ const z = finiteNumber(rec.z ?? rec[2] ?? p?.z);
247
+ if (x === null || y === null || z === null) return null;
248
+ const s = asRecord(rec.s) ?? asRecord(rec.scale);
249
+ const uniform = finiteNumber(rec.r ?? rec.radius ?? rec.size);
250
+ const fallback = uniform ?? defaultScale;
251
+ const sx = clamp(finiteNumber(rec.sx ?? rec.w ?? s?.x, fallback) ?? fallback, MIN_SIZE, MAX_SIZE);
252
+ const sy = clamp(finiteNumber(rec.sy ?? rec.h ?? s?.y, fallback) ?? fallback, MIN_SIZE, MAX_SIZE);
253
+ const sz = clamp(finiteNumber(rec.sz ?? rec.d ?? s?.z, fallback) ?? fallback, MIN_SIZE, MAX_SIZE);
254
+ const [qx, qy, qz, qw] = parseQuat(rec);
255
+ const id = parseId(rec.id ?? rec.name, `obj-${index}`);
256
+ let px = x;
257
+ let py = y;
258
+ let pz = z;
259
+ if (grid) {
260
+ const b = gridWorldBounds(grid);
261
+ px = clamp(px, b.minX, b.maxX);
262
+ py = clamp(py, b.minY, b.maxY);
263
+ pz = clamp(pz, b.minZ, b.maxZ);
264
+ }
265
+ const parentRaw = rec.parent ?? rec.parentId;
266
+ const meshRaw = rec.mesh ?? rec.meshId;
267
+ const kind = parseKind(rec.kind ?? rec.shape);
268
+ const visible = rec.visible === false || rec.hidden === true ? false : true;
269
+ return {
270
+ id,
271
+ parent:
272
+ typeof parentRaw === 'string' && parentRaw.trim()
273
+ ? parentRaw.trim().slice(0, MAX_ID_CHARS)
274
+ : undefined,
275
+ mesh:
276
+ typeof meshRaw === 'string' && meshRaw.trim()
277
+ ? meshRaw.trim().slice(0, MAX_ID_CHARS)
278
+ : undefined,
279
+ kind,
280
+ x: px,
281
+ y: py,
282
+ z: pz,
283
+ qx,
284
+ qy,
285
+ qz,
286
+ qw,
287
+ sx,
288
+ sy,
289
+ sz,
290
+ color: parseColor(rec.color ?? rec.colour),
291
+ visible,
292
+ bindActor: parseBindActor(rec.bindActor ?? rec.bind_actor ?? rec.actor),
293
+ };
294
+ }
295
+
296
+ function toOverlay(node: SceneNode): ModOverlayObject {
297
+ return { ...node, shape: node.kind };
298
+ }
299
+
300
+ /** CLIENT overlay_draw / local gizmos. Also accepts a catalog `{nodes,objects}`. */
301
+ export function parseOverlayPayload(
302
+ payload: unknown,
303
+ grid?: PlayerCodeGridBounds,
304
+ ): ModOverlayObject[] {
305
+ const rec = asRecord(payload);
306
+ const list = rec
307
+ ? Array.isArray(rec.objects)
308
+ ? rec.objects
309
+ : Array.isArray(rec.nodes)
310
+ ? rec.nodes
311
+ : Array.isArray(rec.shapes)
312
+ ? rec.shapes
313
+ : null
314
+ : Array.isArray(payload)
315
+ ? payload
316
+ : null;
317
+ const rows = list ?? (rec && (rec.x !== undefined || rec.z !== undefined) ? [rec] : []);
318
+ const out: ModOverlayObject[] = [];
319
+ const seen = new Set<string>();
320
+ for (const row of rows) {
321
+ if (out.length >= MAX_NODES) break;
322
+ const parsed = parseNode(row, out.length, grid, 0.2);
323
+ if (!parsed || seen.has(parsed.id)) continue;
324
+ seen.add(parsed.id);
325
+ out.push(toOverlay(parsed));
326
+ }
327
+ return out;
328
+ }
329
+
330
+ export function parseSceneCatalog(
331
+ payload: unknown,
332
+ grid?: PlayerCodeGridBounds,
333
+ ): SceneCatalog | null {
334
+ const rec = asRecord(payload);
335
+ if (!rec) return null;
336
+ const jsonBytes = (() => {
337
+ try {
338
+ return new TextEncoder().encode(JSON.stringify(rec)).length;
339
+ } catch {
340
+ return MAX_CATALOG_BYTES + 1;
341
+ }
342
+ })();
343
+ if (jsonBytes > MAX_CATALOG_BYTES) return null;
344
+ const version = finiteNumber(rec.v ?? rec.version, SCENE_VERSION) ?? SCENE_VERSION;
345
+ if (version !== SCENE_VERSION) return null;
346
+ const meshes: SceneMesh[] = [];
347
+ const meshIds = new Set<string>();
348
+ const meshList = Array.isArray(rec.meshes) ? rec.meshes : [];
349
+ for (const row of meshList) {
350
+ if (meshes.length >= MAX_MESHES) break;
351
+ const mesh = parseMesh(row, meshes.length);
352
+ if (!mesh || meshIds.has(mesh.id)) continue;
353
+ meshIds.add(mesh.id);
354
+ meshes.push(mesh);
355
+ }
356
+ const nodes: SceneNode[] = [];
357
+ const nodeIds = new Set<string>();
358
+ const nodeList = Array.isArray(rec.nodes)
359
+ ? rec.nodes
360
+ : Array.isArray(rec.objects)
361
+ ? rec.objects
362
+ : [];
363
+ for (const row of nodeList) {
364
+ if (nodes.length >= MAX_NODES) break;
365
+ const node = parseNode(row, nodes.length, grid, 1);
366
+ if (!node || nodeIds.has(node.id)) continue;
367
+ nodeIds.add(node.id);
368
+ nodes.push(node);
369
+ }
370
+ const revision = Math.max(0, Math.floor(finiteNumber(rec.revision ?? rec.rev, 0) ?? 0));
371
+ return { revision, meshes, nodes };
372
+ }
373
+
374
+ export function quatMultiply(
375
+ ax: number,
376
+ ay: number,
377
+ az: number,
378
+ aw: number,
379
+ bx: number,
380
+ by: number,
381
+ bz: number,
382
+ bw: number,
383
+ ): [number, number, number, number] {
384
+ return [
385
+ aw * bx + ax * bw + ay * bz - az * by,
386
+ aw * by - ax * bz + ay * bw + az * bx,
387
+ aw * bz + ax * by - ay * bx + az * bw,
388
+ aw * bw - ax * bx - ay * by - az * bz,
389
+ ];
390
+ }
391
+
392
+ export function rotateByQuat(
393
+ qx: number,
394
+ qy: number,
395
+ qz: number,
396
+ qw: number,
397
+ x: number,
398
+ y: number,
399
+ z: number,
400
+ ): [number, number, number] {
401
+ const [ix, iy, iz, iw] = quatMultiply(qx, qy, qz, qw, x, y, z, 0);
402
+ const [rx, ry, rz] = quatMultiply(ix, iy, iz, iw, -qx, -qy, -qz, qw);
403
+ return [rx, ry, rz];
404
+ }
405
+
406
+ export function yawPitchQuat(yaw: number, pitch: number): [number, number, number, number] {
407
+ const hy = yaw * 0.5;
408
+ const hp = pitch * 0.5;
409
+ const cy = Math.cos(hy);
410
+ const sy = Math.sin(hy);
411
+ const cp = Math.cos(hp);
412
+ const sp = Math.sin(hp);
413
+ return normalizeQuat(sp * cy, cp * sy, -sp * sy, cp * cy);
414
+ }
415
+
416
+ function actorParent(actor: ActorXform): SceneNode {
417
+ const [qx, qy, qz, qw] = yawPitchQuat(actor.yaw, actor.pitch);
418
+ return {
419
+ id: `actor:${actor.uuid}`,
420
+ kind: 'capsule',
421
+ x: actor.x,
422
+ y: actor.y,
423
+ z: actor.z,
424
+ qx,
425
+ qy,
426
+ qz,
427
+ qw,
428
+ sx: 1,
429
+ sy: 1,
430
+ sz: 1,
431
+ color: 0,
432
+ visible: true,
433
+ };
434
+ }
435
+
436
+ function hasParentCycle(nodes: Map<string, SceneNode>, start: string): boolean {
437
+ const seen = new Set<string>();
438
+ let cur: string | undefined = start;
439
+ while (cur) {
440
+ if (seen.has(cur)) return true;
441
+ seen.add(cur);
442
+ cur = nodes.get(cur)?.parent;
443
+ }
444
+ return false;
445
+ }
446
+
447
+ export function composeScene(
448
+ nodes: readonly SceneNode[],
449
+ meshes: readonly SceneMesh[],
450
+ actors: readonly ActorXform[],
451
+ grid?: PlayerCodeGridBounds,
452
+ ): ComposedInstance[] {
453
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
454
+ const meshMap = new Map(meshes.map((m) => [m.id, m]));
455
+ const actorMap = new Map(actors.map((a) => [a.uuid, a]));
456
+ const aabb = grid ? gridWorldBounds(grid) : null;
457
+ const world = new Map<string, ComposedInstance>();
458
+
459
+ const resolve = (id: string): ComposedInstance | null => {
460
+ const cached = world.get(id);
461
+ if (cached) return cached;
462
+ const node = nodeMap.get(id);
463
+ if (!node || !node.visible) return null;
464
+ if (hasParentCycle(nodeMap, id)) return null;
465
+
466
+ let parent: Pick<
467
+ ComposedInstance,
468
+ 'x' | 'y' | 'z' | 'qx' | 'qy' | 'qz' | 'qw' | 'sx' | 'sy' | 'sz'
469
+ > | null = null;
470
+ if (node.bindActor) {
471
+ const actor = actorMap.get(node.bindActor);
472
+ if (actor) {
473
+ const ap = actorParent(actor);
474
+ parent = ap;
475
+ }
476
+ } else if (node.parent) {
477
+ parent = resolve(node.parent);
478
+ }
479
+
480
+ let x = node.x;
481
+ let y = node.y;
482
+ let z = node.z;
483
+ let qx = node.qx;
484
+ let qy = node.qy;
485
+ let qz = node.qz;
486
+ let qw = node.qw;
487
+ let sx = node.sx;
488
+ let sy = node.sy;
489
+ let sz = node.sz;
490
+ if (parent) {
491
+ const [rx, ry, rz] = rotateByQuat(
492
+ parent.qx,
493
+ parent.qy,
494
+ parent.qz,
495
+ parent.qw,
496
+ node.x * parent.sx,
497
+ node.y * parent.sy,
498
+ node.z * parent.sz,
499
+ );
500
+ x = parent.x + rx;
501
+ y = parent.y + ry;
502
+ z = parent.z + rz;
503
+ [qx, qy, qz, qw] = normalizeQuat(
504
+ ...quatMultiply(
505
+ parent.qx,
506
+ parent.qy,
507
+ parent.qz,
508
+ parent.qw,
509
+ node.qx,
510
+ node.qy,
511
+ node.qz,
512
+ node.qw,
513
+ ),
514
+ );
515
+ sx = clamp(parent.sx * node.sx, MIN_SIZE, MAX_SIZE);
516
+ sy = clamp(parent.sy * node.sy, MIN_SIZE, MAX_SIZE);
517
+ sz = clamp(parent.sz * node.sz, MIN_SIZE, MAX_SIZE);
518
+ }
519
+ if (aabb) {
520
+ x = clamp(x, aabb.minX, aabb.maxX);
521
+ y = clamp(y, aabb.minY, aabb.maxY);
522
+ z = clamp(z, aabb.minZ, aabb.maxZ);
523
+ }
524
+ const mesh = node.mesh ? meshMap.get(node.mesh) : undefined;
525
+ const composed: ComposedInstance = {
526
+ id: node.id,
527
+ kind: mesh?.kind ?? node.kind,
528
+ mesh,
529
+ x,
530
+ y,
531
+ z,
532
+ qx,
533
+ qy,
534
+ qz,
535
+ qw,
536
+ sx,
537
+ sy,
538
+ sz,
539
+ color: node.color || mesh?.color || 0xf8fafc,
540
+ visible: true,
541
+ };
542
+ world.set(id, composed);
543
+ return composed;
544
+ };
545
+
546
+ const out: ComposedInstance[] = [];
547
+ for (const node of nodes) {
548
+ const composed = resolve(node.id);
549
+ if (composed) out.push(composed);
550
+ }
551
+ return out;
552
+ }
553
+
554
+ export function applyPoses(
555
+ nodes: readonly SceneNode[],
556
+ poses: ReadonlyMap<string, SceneNode>,
557
+ ): SceneNode[] {
558
+ return nodes.map((node) => {
559
+ const pose = poses.get(node.id);
560
+ if (!pose) return node;
561
+ return {
562
+ ...node,
563
+ x: pose.x,
564
+ y: pose.y,
565
+ z: pose.z,
566
+ qx: pose.qx,
567
+ qy: pose.qy,
568
+ qz: pose.qz,
569
+ qw: pose.qw,
570
+ sx: pose.sx,
571
+ sy: pose.sy,
572
+ sz: pose.sz,
573
+ visible: pose.visible,
574
+ };
575
+ });
576
+ }
577
+
578
+ function scaleToByte(value: number): number {
579
+ const t = (clamp(value, MIN_SIZE, MAX_SIZE) - MIN_SIZE) / (MAX_SIZE - MIN_SIZE);
580
+ return Math.round(t * 255);
581
+ }
582
+
583
+ function scaleFromByte(value: number): number {
584
+ return MIN_SIZE + (clamp(value, 0, 255) / 255) * (MAX_SIZE - MIN_SIZE);
585
+ }
586
+
587
+ function quatToI16(value: number): number {
588
+ return Math.round(clamp(value, -1, 1) * 32767);
589
+ }
590
+
591
+ function quatFromI16(value: number): number {
592
+ return clamp(value, -32767, 32767) / 32767;
593
+ }
594
+
595
+ /** Packed pose datagram (EventRouter state, no eventType prefix). */
596
+ const POSE_STRIDE = 24;
597
+
598
+ export function encodePosePacket(catalogRev: number, nodes: readonly SceneNode[]): Uint8Array {
599
+ const count = Math.min(nodes.length, MAX_NODES);
600
+ const bytes = new Uint8Array(5 + count * POSE_STRIDE);
601
+ const view = new DataView(bytes.buffer);
602
+ view.setUint32(0, catalogRev >>> 0, true);
603
+ view.setUint8(4, count);
604
+ let o = 5;
605
+ for (let i = 0; i < count; i++) {
606
+ const n = nodes[i]!;
607
+ view.setUint8(o, i);
608
+ view.setFloat32(o + 1, n.x, true);
609
+ view.setFloat32(o + 5, n.y, true);
610
+ view.setFloat32(o + 9, n.z, true);
611
+ view.setInt16(o + 13, quatToI16(n.qx), true);
612
+ view.setInt16(o + 15, quatToI16(n.qy), true);
613
+ view.setInt16(o + 17, quatToI16(n.qz), true);
614
+ view.setInt16(o + 19, quatToI16(n.qw), true);
615
+ view.setUint8(o + 21, scaleToByte(n.sx));
616
+ view.setUint8(o + 22, scaleToByte(n.sy));
617
+ view.setUint8(o + 23, scaleToByte(n.sz));
618
+ o += POSE_STRIDE;
619
+ }
620
+ return bytes.subarray(0, o);
621
+ }
622
+
623
+ export function decodePosePacket(
624
+ bytes: Uint8Array,
625
+ nodeIds: readonly string[],
626
+ ): { catalogRev: number; poses: Map<string, SceneNode> } | null {
627
+ if (bytes.length < 5) return null;
628
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
629
+ const catalogRev = view.getUint32(0, true);
630
+ const count = view.getUint8(4);
631
+ if (count > MAX_NODES || bytes.length < 5 + count * POSE_STRIDE) return null;
632
+ const poses = new Map<string, SceneNode>();
633
+ let o = 5;
634
+ for (let i = 0; i < count; i++) {
635
+ const index = view.getUint8(o);
636
+ const id = nodeIds[index];
637
+ if (id) {
638
+ const [qx, qy, qz, qw] = normalizeQuat(
639
+ quatFromI16(view.getInt16(o + 13, true)),
640
+ quatFromI16(view.getInt16(o + 15, true)),
641
+ quatFromI16(view.getInt16(o + 17, true)),
642
+ quatFromI16(view.getInt16(o + 19, true)),
643
+ );
644
+ poses.set(id, {
645
+ id,
646
+ kind: 'box',
647
+ x: view.getFloat32(o + 1, true),
648
+ y: view.getFloat32(o + 5, true),
649
+ z: view.getFloat32(o + 9, true),
650
+ qx,
651
+ qy,
652
+ qz,
653
+ qw,
654
+ sx: scaleFromByte(view.getUint8(o + 21)),
655
+ sy: scaleFromByte(view.getUint8(o + 22)),
656
+ sz: scaleFromByte(view.getUint8(o + 23)),
657
+ color: 0,
658
+ visible: true,
659
+ });
660
+ }
661
+ o += POSE_STRIDE;
662
+ }
663
+ return { catalogRev, poses };
664
+ }
665
+
666
+ export function encodeCatalogChunks(revision: number, utf8: Uint8Array): Uint8Array[] {
667
+ const maxData = MAX_EVENT_STATE_BYTES - CATALOG_CHUNK_HEADER;
668
+ const parts = Math.max(1, Math.ceil(utf8.length / maxData));
669
+ if (parts > 255) return [];
670
+ const out: Uint8Array[] = [];
671
+ for (let part = 0; part < parts; part++) {
672
+ const slice = utf8.subarray(part * maxData, (part + 1) * maxData);
673
+ const bytes = new Uint8Array(CATALOG_CHUNK_HEADER + slice.length);
674
+ const view = new DataView(bytes.buffer);
675
+ view.setUint32(0, revision >>> 0, true);
676
+ view.setUint8(4, part);
677
+ view.setUint8(5, parts);
678
+ view.setUint16(6, slice.length, true);
679
+ bytes.set(slice, CATALOG_CHUNK_HEADER);
680
+ out.push(bytes);
681
+ }
682
+ return out;
683
+ }
684
+
685
+ export function decodeCatalogChunk(
686
+ bytes: Uint8Array,
687
+ ): { revision: number; part: number; parts: number; data: Uint8Array } | null {
688
+ if (bytes.length < CATALOG_CHUNK_HEADER) return null;
689
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
690
+ const revision = view.getUint32(0, true);
691
+ const part = view.getUint8(4);
692
+ const parts = view.getUint8(5);
693
+ const n = view.getUint16(6, true);
694
+ if (parts === 0 || part >= parts || bytes.length < CATALOG_CHUNK_HEADER + n) return null;
695
+ return {
696
+ revision,
697
+ part,
698
+ parts,
699
+ data: bytes.subarray(CATALOG_CHUNK_HEADER, CATALOG_CHUNK_HEADER + n),
700
+ };
701
+ }
702
+
703
+ export class CatalogAssembler {
704
+ private revision = -1;
705
+ private parts = 0;
706
+ private slots: Array<Uint8Array | undefined> = [];
707
+
708
+ push(bytes: Uint8Array): Uint8Array | null {
709
+ const chunk = decodeCatalogChunk(bytes);
710
+ if (!chunk) return null;
711
+ if (chunk.revision !== this.revision) {
712
+ this.revision = chunk.revision;
713
+ this.parts = chunk.parts;
714
+ this.slots = new Array(chunk.parts);
715
+ }
716
+ if (chunk.parts !== this.parts) return null;
717
+ this.slots[chunk.part] = chunk.data;
718
+ if (this.slots.some((s) => !s)) return null;
719
+ const total = this.slots.reduce((n, s) => n + (s?.length ?? 0), 0);
720
+ if (total > MAX_CATALOG_BYTES) {
721
+ this.slots = [];
722
+ return null;
723
+ }
724
+ const joined = new Uint8Array(total);
725
+ let o = 0;
726
+ for (const slot of this.slots) {
727
+ joined.set(slot!, o);
728
+ o += slot!.length;
729
+ }
730
+ this.slots = [];
731
+ return joined;
732
+ }
733
+ }
734
+
735
+ export function bytesFromBase64(data: string): Uint8Array {
736
+ const bin = atob(data);
737
+ const out = new Uint8Array(bin.length);
738
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
739
+ return out;
740
+ }
741
+
742
+ export function bytesToBase64(bytes: Uint8Array): string {
743
+ let binary = '';
744
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
745
+ return btoa(binary);
746
+ }