@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,346 @@
1
+ /**
2
+ * Proximity webcam over the realtime plane — engine-agnostic.
3
+ *
4
+ * Capture: `getUserMedia` at 128×96 / 10 fps → an offscreen canvas → a small
5
+ * JPEG (≤ `MAX_FRAME_BYTES`) → `udp.sendVideoFrame`, which cuts the frame into
6
+ * UDP-sized fragments and sends each as a `sendVideoPacket` addressed to the
7
+ * chunk the local actor stands in. Every receiver within `distance` chunks
8
+ * pays the bytes, so the defaults are deliberately small and the distance is
9
+ * 1 (this chunk and its neighbours), not the pose replication distance.
10
+ *
11
+ * Playback: every `video` notification is one fragment. The SDK's
12
+ * `VideoFrameAssembler` puts frames back together per sender; a completed
13
+ * frame is decoded to an `ImageBitmap` and handed to whoever listens
14
+ * (`frame(uuid, bitmap)`). Scenes draw it however they like — the holodeck
15
+ * puts it on the avatar's face, the paint program only shows a "camera on"
16
+ * ring. Listeners own the bitmap they receive and close it when they replace
17
+ * or drop it.
18
+ *
19
+ * Leaving: the server's `actorLeft` notice (and, as a fallback, the World
20
+ * Stores lane's `onLeave` reaper) ends the sender's stream at once —
21
+ * `ended(uuid)` — so no texture outlives the player.
22
+ *
23
+ * Permission: `use_video_chat` on the sender's tier (and any grid limit under
24
+ * them). Without it the API refuses `sendVideoPacket` with `UNAUTHORIZED`,
25
+ * which this service reports once and then stops sending. The Setup wizard
26
+ * grants the key on the Constructor tier.
27
+ *
28
+ * Mods do not get the camera: there is no host call for it, on purpose.
29
+ */
30
+ import {
31
+ VideoFrameAssembler,
32
+ VideoCodec,
33
+ decodeBase64,
34
+ type AssembledVideoFrame,
35
+ } from '@crowdedkingdoms/crowdyjs';
36
+
37
+ import { Emitter } from '../util/Emitter';
38
+
39
+ export interface WebcamEvents {
40
+ /** A complete frame from a nearby player (never the local player). */
41
+ frame: { uuid: string; bitmap: ImageBitmap; frameId: number };
42
+ /** The player's stream is over: they left, or their frames stopped. */
43
+ ended: { uuid: string };
44
+ /** Local capture state changed (for the HUD toggle and self-preview). */
45
+ local: { live: boolean; stream: MediaStream | null; error: string | null };
46
+ }
47
+
48
+ /** Capture size and rate. Small on purpose: every receiver pays the bytes. */
49
+ export const CAPTURE_WIDTH = 128;
50
+ export const CAPTURE_HEIGHT = 96;
51
+ export const CAPTURE_FPS = 10;
52
+ /** Frames above this are re-encoded at a lower quality, then dropped. */
53
+ export const MAX_FRAME_BYTES = 8 * 1024;
54
+ /** Chunks around the sender's that receive the frames (0 = own chunk only). */
55
+ export const VIDEO_DISTANCE = 1;
56
+ /** A sender with no frame for this long is treated as ended (fallback). */
57
+ export const REMOTE_IDLE_MS = 3_000;
58
+
59
+ /**
60
+ * What the service needs from the platform, as an interface so a unit test
61
+ * can drive it without a network or a browser. `NetworkManager` + `GameSession`
62
+ * satisfy it in `GameSession.webcam`.
63
+ */
64
+ export interface WebcamTransport {
65
+ /** Subscribe to `video` / `actorLeft` notifications; returns unsubscribe. */
66
+ onVideo(handler: (n: { uuid: string; videoData: string }) => void): () => void;
67
+ onActorLeft(handler: (n: { uuid: string }) => void): () => void;
68
+ /** The World Stores reaper: fires when a stale actor is dropped. */
69
+ onLaneLeave(handler: (uuid: string) => void): () => void;
70
+ /** The local actor's uuid (frames from it are ignored on receive). */
71
+ selfUuid(): string | null;
72
+ /** Where the local actor is; null when not joined. */
73
+ selfChunk(): { x: string; y: string; z: string } | null;
74
+ appId(): string;
75
+ sendVideoFrame(input: {
76
+ appId: string;
77
+ chunk: { x: string; y: string; z: string };
78
+ uuid: string;
79
+ frame: Uint8Array;
80
+ frameId: number;
81
+ codec: VideoCodec;
82
+ distance: number;
83
+ }): Promise<number>;
84
+ log(message: string): void;
85
+ }
86
+
87
+ /** Decodes an encoded frame to a bitmap. Injected so tests need no canvas. */
88
+ export type FrameDecoder = (frame: AssembledVideoFrame) => Promise<ImageBitmap>;
89
+
90
+ export const decodeWithCreateImageBitmap: FrameDecoder = (frame) =>
91
+ createImageBitmap(
92
+ new Blob([frame.bytes as BlobPart], {
93
+ type: frame.codec === VideoCodec.WEBP ? 'image/webp' : 'image/jpeg',
94
+ }),
95
+ );
96
+
97
+ export class WebcamService {
98
+ readonly events = new Emitter<WebcamEvents>();
99
+
100
+ private readonly assembler = new VideoFrameAssembler();
101
+ private readonly remoteLastFrame = new Map<string, number>();
102
+ private unsubscribes: Array<() => void> = [];
103
+ private idleTimer: ReturnType<typeof setInterval> | null = null;
104
+
105
+ private stream: MediaStream | null = null;
106
+ private video: HTMLVideoElement | null = null;
107
+ private canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
108
+ private captureTimer: ReturnType<typeof setInterval> | null = null;
109
+ private encoding = false;
110
+ private frameId = 0;
111
+ /** Frames the API accepted since start(); for the HUD and the live test. */
112
+ framesSent = 0;
113
+ private quality = 0.6;
114
+ private errorValue: string | null = null;
115
+ private refusedOnce = false;
116
+
117
+ constructor(
118
+ private readonly transport: WebcamTransport,
119
+ private readonly decode: FrameDecoder = decodeWithCreateImageBitmap,
120
+ private readonly now: () => number = () => Date.now(),
121
+ ) {}
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Receiving
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /** Begin receiving other players' frames. Idempotent. */
128
+ listen(): void {
129
+ if (this.unsubscribes.length > 0) return;
130
+ this.unsubscribes.push(
131
+ this.transport.onVideo((n) => void this.ingest(n.uuid, n.videoData)),
132
+ this.transport.onActorLeft((n) => this.end(n.uuid)),
133
+ this.transport.onLaneLeave((uuid) => this.end(uuid)),
134
+ );
135
+ this.idleTimer = setInterval(() => this.reapIdle(), 1_000);
136
+ }
137
+
138
+ /** True while frames are arriving from this player. */
139
+ isLive(uuid: string): boolean {
140
+ return this.remoteLastFrame.has(uuid);
141
+ }
142
+
143
+ /** Players currently sending video. */
144
+ liveSenders(): string[] {
145
+ return [...this.remoteLastFrame.keys()];
146
+ }
147
+
148
+ private async ingest(uuid: string, videoData: string): Promise<void> {
149
+ if (uuid === this.transport.selfUuid()) return;
150
+ let bytes: Uint8Array;
151
+ try {
152
+ bytes = decodeBase64(videoData);
153
+ } catch {
154
+ return;
155
+ }
156
+ const frame = this.assembler.ingest(uuid, bytes, this.now());
157
+ if (!frame) return;
158
+ let bitmap: ImageBitmap;
159
+ try {
160
+ bitmap = await this.decode(frame);
161
+ } catch (error) {
162
+ this.transport.log(
163
+ `video frame from ${uuid.slice(0, 8)} did not decode: ${messageOf(error)}`,
164
+ );
165
+ return;
166
+ }
167
+ // The sender may have left while we decoded; do not resurrect them.
168
+ if (!this.unsubscribes.length) {
169
+ bitmap.close();
170
+ return;
171
+ }
172
+ this.remoteLastFrame.set(uuid, this.now());
173
+ this.events.emit('frame', { uuid, bitmap, frameId: frame.frameId });
174
+ }
175
+
176
+ private end(uuid: string): void {
177
+ this.assembler.forget(uuid);
178
+ if (!this.remoteLastFrame.delete(uuid)) return;
179
+ this.events.emit('ended', { uuid });
180
+ }
181
+
182
+ private reapIdle(): void {
183
+ const cutoff = this.now() - REMOTE_IDLE_MS;
184
+ for (const [uuid, at] of this.remoteLastFrame) if (at < cutoff) this.end(uuid);
185
+ this.assembler.prune(this.now());
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Sending
190
+ // ---------------------------------------------------------------------------
191
+
192
+ get isTransmitting(): boolean {
193
+ return this.captureTimer !== null;
194
+ }
195
+
196
+ /** The local camera stream while live (for a self-preview), else null. */
197
+ get localStream(): MediaStream | null {
198
+ return this.stream;
199
+ }
200
+
201
+ get error(): string | null {
202
+ return this.errorValue;
203
+ }
204
+
205
+ async toggle(): Promise<void> {
206
+ if (this.isTransmitting) this.stop();
207
+ else await this.start();
208
+ }
209
+
210
+ /** Ask for the camera and start sending frames. Resolves once capturing. */
211
+ async start(): Promise<void> {
212
+ if (this.captureTimer) return;
213
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
214
+ this.fail('This browser has no camera access');
215
+ return;
216
+ }
217
+ try {
218
+ this.stream = await navigator.mediaDevices.getUserMedia({
219
+ video: { width: CAPTURE_WIDTH, height: CAPTURE_HEIGHT, frameRate: CAPTURE_FPS },
220
+ audio: false,
221
+ });
222
+ } catch (error) {
223
+ this.fail(`Camera unavailable: ${messageOf(error)}`);
224
+ return;
225
+ }
226
+ const video = document.createElement('video');
227
+ video.muted = true;
228
+ video.playsInline = true;
229
+ video.srcObject = this.stream;
230
+ await video.play().catch(() => undefined);
231
+ this.video = video;
232
+ this.canvas =
233
+ typeof OffscreenCanvas === 'undefined'
234
+ ? Object.assign(document.createElement('canvas'), {
235
+ width: CAPTURE_WIDTH,
236
+ height: CAPTURE_HEIGHT,
237
+ })
238
+ : new OffscreenCanvas(CAPTURE_WIDTH, CAPTURE_HEIGHT);
239
+ this.errorValue = null;
240
+ this.refusedOnce = false;
241
+ this.captureTimer = setInterval(() => void this.captureOnce(), 1000 / CAPTURE_FPS);
242
+ this.events.emit('local', { live: true, stream: this.stream, error: null });
243
+ }
244
+
245
+ /** Stop sending and release the camera. Receiving continues. */
246
+ stop(): void {
247
+ if (this.captureTimer) clearInterval(this.captureTimer);
248
+ this.captureTimer = null;
249
+ this.video?.pause();
250
+ this.video = null;
251
+ this.canvas = null;
252
+ for (const track of this.stream?.getTracks() ?? []) track.stop();
253
+ const wasLive = this.stream !== null;
254
+ this.stream = null;
255
+ if (wasLive) this.events.emit('local', { live: false, stream: null, error: this.errorValue });
256
+ }
257
+
258
+ private async captureOnce(): Promise<void> {
259
+ if (this.encoding || !this.video || !this.canvas) return;
260
+ const chunk = this.transport.selfChunk();
261
+ const uuid = this.transport.selfUuid();
262
+ if (!chunk || !uuid) return;
263
+ if (this.video.readyState < 2) return; // HAVE_CURRENT_DATA
264
+ this.encoding = true;
265
+ try {
266
+ const frame = await this.encodeFrame();
267
+ if (!frame) return;
268
+ this.frameId = (this.frameId + 1) & 0xffff;
269
+ await this.transport.sendVideoFrame({
270
+ appId: this.transport.appId(),
271
+ chunk,
272
+ uuid,
273
+ frame,
274
+ frameId: this.frameId,
275
+ codec: VideoCodec.JPEG,
276
+ distance: VIDEO_DISTANCE,
277
+ });
278
+ this.framesSent += 1;
279
+ } catch (error) {
280
+ const message = messageOf(error);
281
+ if (/UNAUTHORIZED|permission/i.test(message)) {
282
+ // The tier (or a grid under us) lacks use_video_chat. Say so once and
283
+ // stop rather than sending 10 refusals a second.
284
+ if (!this.refusedOnce) {
285
+ this.refusedOnce = true;
286
+ this.fail('Camera refused: this app does not grant use_video_chat here');
287
+ this.stop();
288
+ }
289
+ } else {
290
+ this.transport.log(`video send failed: ${message}`);
291
+ }
292
+ } finally {
293
+ this.encoding = false;
294
+ }
295
+ }
296
+
297
+ /** Draw the current video frame and encode it as a JPEG under the size cap. */
298
+ private async encodeFrame(): Promise<Uint8Array | null> {
299
+ const canvas = this.canvas;
300
+ const video = this.video;
301
+ if (!canvas || !video) return null;
302
+ const ctx = canvas.getContext('2d') as
303
+ OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D | null;
304
+ if (!ctx) return null;
305
+ ctx.drawImage(video, 0, 0, CAPTURE_WIDTH, CAPTURE_HEIGHT);
306
+ for (let attempt = 0; attempt < 3; attempt++) {
307
+ const blob = await toBlob(canvas, this.quality);
308
+ if (!blob) return null;
309
+ if (blob.size <= MAX_FRAME_BYTES) {
310
+ // Creep the quality back up while there is headroom.
311
+ if (blob.size < MAX_FRAME_BYTES / 2 && this.quality < 0.8) this.quality += 0.05;
312
+ return new Uint8Array(await blob.arrayBuffer());
313
+ }
314
+ this.quality = Math.max(0.2, this.quality - 0.15);
315
+ }
316
+ return null; // still too big: skip this frame rather than send a partial one
317
+ }
318
+
319
+ private fail(message: string): void {
320
+ this.errorValue = message;
321
+ this.transport.log(message);
322
+ this.events.emit('local', { live: false, stream: null, error: message });
323
+ }
324
+
325
+ /** Stop everything: capture, receiving, and every remote stream. */
326
+ dispose(): void {
327
+ this.stop();
328
+ for (const unsubscribe of this.unsubscribes) unsubscribe();
329
+ this.unsubscribes = [];
330
+ if (this.idleTimer) clearInterval(this.idleTimer);
331
+ this.idleTimer = null;
332
+ for (const uuid of [...this.remoteLastFrame.keys()]) this.end(uuid);
333
+ }
334
+ }
335
+
336
+ function toBlob(
337
+ canvas: OffscreenCanvas | HTMLCanvasElement,
338
+ quality: number,
339
+ ): Promise<Blob | null> {
340
+ if ('convertToBlob' in canvas) return canvas.convertToBlob({ type: 'image/jpeg', quality });
341
+ return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
342
+ }
343
+
344
+ function messageOf(error: unknown): string {
345
+ return error instanceof Error ? error.message : String(error);
346
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { acceptVoiceSequence, muLawDecode, muLawEncode } from './mulaw';
4
+
5
+ describe('mu-law', () => {
6
+ it('round-trips silence and a peak within a loose tolerance', () => {
7
+ expect(muLawDecode(muLawEncode(0))).toBeCloseTo(0, 2);
8
+ expect(muLawDecode(muLawEncode(0.5))).toBeGreaterThan(0.4);
9
+ expect(muLawDecode(muLawEncode(-0.5))).toBeLessThan(-0.4);
10
+ });
11
+ });
12
+
13
+ describe('acceptVoiceSequence', () => {
14
+ it('drops duplicates and stale wraps, and resets after a gap', () => {
15
+ expect(acceptVoiceSequence(5, 5, 10, 1500).accept).toBe(false);
16
+ expect(acceptVoiceSequence(5, 4, 10, 1500).accept).toBe(false);
17
+ expect(acceptVoiceSequence(5, 6, 10, 1500)).toEqual({ accept: true, nextSequence: 6 });
18
+ expect(acceptVoiceSequence(5, 1, 2000, 1500)).toEqual({ accept: true, nextSequence: 1 });
19
+ });
20
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * G.711 µ-law — the codec BWF and this starter send over `udp.sendAudioPacket`.
3
+ * The platform never looks inside `audioData`; this is a client convention.
4
+ */
5
+
6
+ const MU_BIAS = 0x84;
7
+ const MU_CLIP = 32635;
8
+
9
+ export function muLawEncode(sample: number): number {
10
+ let value = Math.max(-1, Math.min(1, sample)) * 32767;
11
+ const sign = value < 0 ? 0x80 : 0;
12
+ value = Math.abs(value);
13
+ if (value > MU_CLIP) value = MU_CLIP;
14
+ value += MU_BIAS;
15
+ let exponent = 7;
16
+ for (let mask = 0x4000; (value & mask) === 0 && exponent > 0; mask >>= 1) exponent--;
17
+ const mantissa = (value >> (exponent + 3)) & 0x0f;
18
+ return ~(sign | (exponent << 4) | mantissa) & 0xff;
19
+ }
20
+
21
+ export function muLawDecode(byte: number): number {
22
+ const inverted = ~byte & 0xff;
23
+ const sign = inverted & 0x80;
24
+ const exponent = (inverted >> 4) & 0x07;
25
+ const mantissa = inverted & 0x0f;
26
+ let value = ((mantissa << 3) + MU_BIAS) << exponent;
27
+ value -= MU_BIAS;
28
+ return (sign ? -value : value) / 32767;
29
+ }
30
+
31
+ export function encodeSamples(samples: Float32Array): Uint8Array {
32
+ const encoded = new Uint8Array(samples.length);
33
+ for (let i = 0; i < samples.length; i++) encoded[i] = muLawEncode(samples[i] ?? 0);
34
+ return encoded;
35
+ }
36
+
37
+ export function decodeSamples(bytes: Uint8Array): Float32Array {
38
+ const samples = new Float32Array(bytes.length);
39
+ for (let i = 0; i < bytes.length; i++) samples[i] = muLawDecode(bytes[i]!);
40
+ return samples;
41
+ }
42
+
43
+ /** Drop duplicate / stale 8-bit sequence numbers; a long gap starts a new utterance. */
44
+ export function acceptVoiceSequence(
45
+ lastSequence: number,
46
+ sequence: number,
47
+ gapMs: number,
48
+ resetAfterMs: number,
49
+ ): { accept: boolean; nextSequence: number } {
50
+ if (gapMs > resetAfterMs) lastSequence = -1;
51
+ if (sequence >= 0 && lastSequence >= 0) {
52
+ const advance = (sequence - lastSequence + 256) % 256;
53
+ if (advance === 0 || advance > 128) return { accept: false, nextSequence: lastSequence };
54
+ }
55
+ return { accept: true, nextSequence: sequence >= 0 ? sequence : lastSequence };
56
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * WebcamService's receive path over the SDK's real fragmenter/assembler, with
3
+ * the platform and the browser mocked: a frame arrives once and whole, the
4
+ * server's actor-left notice ends the stream at once, the idle fallback ends
5
+ * a silent one, and dispose releases everything.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ // The SDK root barrel pulls Monaco, which does not load under happy-dom, so the
10
+ // service's import is answered from the two pure modules it actually uses.
11
+ vi.mock('@crowdedkingdoms/crowdyjs', async () => {
12
+ // Hoisted above the imports, so everything it needs is computed inside.
13
+ // The package exports only its barrels, so walk to node_modules directly
14
+ // (vitest runs from the repo root, where vitest.config.ts is).
15
+ const sdkDist = `${process.cwd()}/node_modules/@crowdedkingdoms/crowdyjs/dist/`;
16
+ const media = await vi.importActual<Record<string, unknown>>(`${sdkDist}media/video-frames.js`);
17
+ const utils = await vi.importActual<Record<string, unknown>>(`${sdkDist}utils.js`);
18
+ return { ...media, decodeBase64: utils.decodeBase64, encodeBase64: utils.encodeBase64 };
19
+ });
20
+
21
+ import { encodeBase64, fragmentFrame } from '@crowdedkingdoms/crowdyjs';
22
+
23
+ import { REMOTE_IDLE_MS, WebcamService, type WebcamTransport } from './WebcamService';
24
+
25
+ type Handler<T> = (n: T) => void;
26
+
27
+ function fakeTransport() {
28
+ const video = new Set<Handler<{ uuid: string; videoData: string }>>();
29
+ const left = new Set<Handler<{ uuid: string }>>();
30
+ const lane = new Set<Handler<string>>();
31
+ const transport: WebcamTransport & {
32
+ emitVideo(uuid: string, packet: Uint8Array): void;
33
+ emitLeft(uuid: string): void;
34
+ emitLaneLeave(uuid: string): void;
35
+ subscriptions(): number;
36
+ } = {
37
+ onVideo: (h) => {
38
+ video.add(h);
39
+ return () => video.delete(h);
40
+ },
41
+ onActorLeft: (h) => {
42
+ left.add(h);
43
+ return () => left.delete(h);
44
+ },
45
+ onLaneLeave: (h) => {
46
+ lane.add(h);
47
+ return () => lane.delete(h);
48
+ },
49
+ selfUuid: () => 'self-uuid',
50
+ selfChunk: () => ({ x: '0', y: '0', z: '0' }),
51
+ appId: () => '1',
52
+ sendVideoFrame: vi.fn(async () => 1),
53
+ log: vi.fn(),
54
+ emitVideo: (uuid, packet) => {
55
+ for (const h of video) h({ uuid, videoData: encodeBase64(packet) });
56
+ },
57
+ emitLeft: (uuid) => {
58
+ for (const h of left) h({ uuid });
59
+ },
60
+ emitLaneLeave: (uuid) => {
61
+ for (const h of lane) h(uuid);
62
+ },
63
+ subscriptions: () => video.size + left.size + lane.size,
64
+ };
65
+ return transport;
66
+ }
67
+
68
+ function fakeBitmap(): ImageBitmap {
69
+ return { width: 128, height: 96, close: vi.fn() } as unknown as ImageBitmap;
70
+ }
71
+
72
+ // Decoding is awaited inside `ingest`; drain the microtask queue (fake timers
73
+ // are on, so a setTimeout-based flush would hang).
74
+ const flush = async () => {
75
+ for (let i = 0; i < 8; i++) await Promise.resolve();
76
+ };
77
+
78
+ describe('WebcamService receive path', () => {
79
+ let now = 1_000_000;
80
+ const clock = () => now;
81
+
82
+ beforeEach(() => {
83
+ now = 1_000_000;
84
+ vi.useFakeTimers();
85
+ });
86
+ afterEach(() => {
87
+ vi.useRealTimers();
88
+ });
89
+
90
+ it('delivers a fragmented frame exactly once, decoded, and never its own', async () => {
91
+ const transport = fakeTransport();
92
+ const decode = vi.fn(async () => fakeBitmap());
93
+ const service = new WebcamService(transport, decode, clock);
94
+ const frames: string[] = [];
95
+ service.events.on('frame', ({ uuid, frameId }) => frames.push(`${uuid}:${frameId}`));
96
+ service.listen();
97
+ expect(transport.subscriptions()).toBe(3);
98
+
99
+ const jpeg = new Uint8Array(3_000).fill(0xab);
100
+ for (const packet of fragmentFrame(jpeg, 7)) transport.emitVideo('peer-a', packet);
101
+ await flush();
102
+ expect(frames).toEqual(['peer-a:7']);
103
+ expect(decode).toHaveBeenCalledTimes(1);
104
+ expect(service.isLive('peer-a')).toBe(true);
105
+ expect(service.liveSenders()).toEqual(['peer-a']);
106
+
107
+ // Our own fragments are echoed by the fan-out; they must not become a face.
108
+ for (const packet of fragmentFrame(jpeg, 8)) transport.emitVideo('self-uuid', packet);
109
+ await flush();
110
+ expect(frames).toEqual(['peer-a:7']);
111
+ service.dispose();
112
+ });
113
+
114
+ it('ends the stream at once on the server actor-left notice', async () => {
115
+ const transport = fakeTransport();
116
+ const service = new WebcamService(transport, async () => fakeBitmap(), clock);
117
+ const ended: string[] = [];
118
+ service.events.on('ended', ({ uuid }) => ended.push(uuid));
119
+ service.listen();
120
+ for (const packet of fragmentFrame(new Uint8Array(500).fill(1), 1))
121
+ transport.emitVideo('peer-b', packet);
122
+ await flush();
123
+ expect(service.isLive('peer-b')).toBe(true);
124
+
125
+ transport.emitLeft('peer-b');
126
+ expect(ended).toEqual(['peer-b']);
127
+ expect(service.isLive('peer-b')).toBe(false);
128
+ // A second notice, or the lane reaper following up, does not fire twice.
129
+ transport.emitLeft('peer-b');
130
+ transport.emitLaneLeave('peer-b');
131
+ expect(ended).toEqual(['peer-b']);
132
+ service.dispose();
133
+ });
134
+
135
+ it('ends a stream whose frames stopped (fallback), via the lane reaper too', async () => {
136
+ const transport = fakeTransport();
137
+ const service = new WebcamService(transport, async () => fakeBitmap(), clock);
138
+ const ended: string[] = [];
139
+ service.events.on('ended', ({ uuid }) => ended.push(uuid));
140
+ service.listen();
141
+ for (const packet of fragmentFrame(new Uint8Array(500).fill(1), 1))
142
+ transport.emitVideo('peer-c', packet);
143
+ for (const packet of fragmentFrame(new Uint8Array(500).fill(1), 1))
144
+ transport.emitVideo('peer-d', packet);
145
+ await flush();
146
+
147
+ transport.emitLaneLeave('peer-d');
148
+ expect(ended).toEqual(['peer-d']);
149
+
150
+ now += REMOTE_IDLE_MS + 1;
151
+ vi.advanceTimersByTime(1_000);
152
+ expect(ended).toEqual(['peer-d', 'peer-c']);
153
+ service.dispose();
154
+ });
155
+
156
+ it('dispose ends every live stream and unsubscribes', async () => {
157
+ const transport = fakeTransport();
158
+ const service = new WebcamService(transport, async () => fakeBitmap(), clock);
159
+ const ended: string[] = [];
160
+ service.events.on('ended', ({ uuid }) => ended.push(uuid));
161
+ service.listen();
162
+ for (const packet of fragmentFrame(new Uint8Array(500).fill(1), 1))
163
+ transport.emitVideo('peer-e', packet);
164
+ await flush();
165
+ service.dispose();
166
+ expect(ended).toEqual(['peer-e']);
167
+ expect(transport.subscriptions()).toBe(0);
168
+ expect(service.isTransmitting).toBe(false);
169
+ });
170
+
171
+ it('drops a frame whose bytes do not decode, without ending a live stream', async () => {
172
+ const transport = fakeTransport();
173
+ let fail = false;
174
+ const service = new WebcamService(
175
+ transport,
176
+ async () => {
177
+ if (fail) throw new Error('bad jpeg');
178
+ return fakeBitmap();
179
+ },
180
+ clock,
181
+ );
182
+ const frames: number[] = [];
183
+ service.events.on('frame', ({ frameId }) => frames.push(frameId));
184
+ service.listen();
185
+ for (const packet of fragmentFrame(new Uint8Array(500).fill(1), 1))
186
+ transport.emitVideo('peer-f', packet);
187
+ await flush();
188
+ fail = true;
189
+ for (const packet of fragmentFrame(new Uint8Array(500).fill(2), 2))
190
+ transport.emitVideo('peer-f', packet);
191
+ await flush();
192
+ expect(frames).toEqual([1]);
193
+ expect(service.isLive('peer-f')).toBe(true);
194
+ expect(transport.log).toHaveBeenCalledWith(expect.stringContaining('did not decode'));
195
+ service.dispose();
196
+ });
197
+ });