@realnation/builder-shared-sdk 1.0.5 → 1.1.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.
@@ -0,0 +1,427 @@
1
+ import { decodeSilhouette, parseBinaryFrame, binaryCorrelationId } from './decode.js';
2
+ export class RuntimeCommandError extends Error {
3
+ code;
4
+ constructor(err) {
5
+ super(err.message ?? err.code);
6
+ this.name = 'RuntimeCommandError';
7
+ this.code = err.code;
8
+ }
9
+ }
10
+ /** Per-command timeout. Snapshot is slow enough that the default would trip. */
11
+ const TIMEOUTS = {
12
+ 'camera.snapshot': 15000,
13
+ };
14
+ const DEFAULT_TIMEOUT_MS = 5000;
15
+ /** Emitter with unsubscribe. One handler set per channel. */
16
+ class Channel {
17
+ handlers = new Set();
18
+ add(fn) {
19
+ this.handlers.add(fn);
20
+ return () => {
21
+ this.handlers.delete(fn);
22
+ };
23
+ }
24
+ emit(v) {
25
+ for (const fn of this.handlers)
26
+ fn(v);
27
+ }
28
+ get size() {
29
+ return this.handlers.size;
30
+ }
31
+ }
32
+ export class RuntimeFacade {
33
+ transport;
34
+ pending = new Map();
35
+ /** Ids whose JSON response said binary:true, in arrival order. */
36
+ binaryQueue = [];
37
+ requestSeq = 0;
38
+ disposers = [];
39
+ bodyCh = new Channel();
40
+ gestureCh = new Channel();
41
+ handGestureCh = new Channel();
42
+ handGestureDiagnosticCh = new Channel();
43
+ gestureByName = new Map();
44
+ pointerCh = new Channel();
45
+ silhouetteCh = new Channel();
46
+ playerSilhouetteCh = new Channel();
47
+ playerSilhouetteLostCh = new Channel();
48
+ motionCh = new Channel();
49
+ deviceErrCh = new Channel();
50
+ deviceConnCh = new Channel();
51
+ playerLostCh = new Channel();
52
+ capChangeCh = new Channel();
53
+ stateCh = new Channel();
54
+ /** Last `device:connected.config` seen. Read-through only — see effectiveConfig. */
55
+ lastConfig = {};
56
+ lastPlayerSilhouette = null;
57
+ trackedPlayers = new Set();
58
+ setTimer;
59
+ clearTimer;
60
+ constructor(transport, timers) {
61
+ this.transport = transport;
62
+ this.setTimer = timers?.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
63
+ this.clearTimer = timers?.clearTimer ?? ((h) => clearTimeout(h));
64
+ this.disposers.push(transport.onMessage((m) => this.dispatch(m)));
65
+ this.disposers.push(transport.onBinary((b) => this.dispatchBinary(b)));
66
+ this.disposers.push(transport.onStateChange((s) => this.stateCh.emit(s)));
67
+ }
68
+ /* --- inbound ----------------------------------------------------------- */
69
+ dispatch(msg) {
70
+ switch (msg.type) {
71
+ case 'body:update': {
72
+ const body = msg;
73
+ this.trackedPlayers.add(body.player);
74
+ this.bodyCh.emit(body);
75
+ break;
76
+ }
77
+ case 'gesture:update': {
78
+ const g = msg;
79
+ this.gestureCh.emit(g);
80
+ this.gestureByName.get(g.gesture)?.emit(g);
81
+ break;
82
+ }
83
+ case 'hand-gesture:update':
84
+ this.handGestureCh.emit(msg);
85
+ break;
86
+ case 'hand-gesture:diagnostic':
87
+ this.handGestureDiagnosticCh.emit(msg);
88
+ break;
89
+ case 'pointer:update':
90
+ this.pointerCh.emit(msg);
91
+ break;
92
+ case 'depth:silhouette':
93
+ this.silhouetteCh.emit(decodeSilhouette(msg));
94
+ break;
95
+ case 'depth:player-silhouette': {
96
+ const raw = msg;
97
+ const frame = decodeSilhouette(raw);
98
+ frame.player = raw.player;
99
+ this.lastPlayerSilhouette = frame;
100
+ this.playerSilhouetteCh.emit(frame);
101
+ break;
102
+ }
103
+ case 'player-silhouette:lost': {
104
+ const raw = msg;
105
+ this.lastPlayerSilhouette = null;
106
+ this.playerSilhouetteLostCh.emit({ reason: raw.reason, ts: raw.ts });
107
+ break;
108
+ }
109
+ case 'motion:update':
110
+ this.motionCh.emit(msg);
111
+ break;
112
+ case 'device:connected': {
113
+ const dc = msg;
114
+ if (dc.config)
115
+ this.lastConfig = dc.config;
116
+ this.deviceConnCh.emit(dc);
117
+ break;
118
+ }
119
+ case 'device:error': {
120
+ const de = msg;
121
+ this.deviceErrCh.emit(de);
122
+ break;
123
+ }
124
+ case 'player:lost':
125
+ this.trackedPlayers.clear();
126
+ this.playerLostCh.emit();
127
+ break;
128
+ case 'capability:changed':
129
+ this.capChangeCh.emit(msg);
130
+ break;
131
+ case 'response':
132
+ this.settle(msg);
133
+ break;
134
+ default:
135
+ break; // unknown types are ignored, never fatal
136
+ }
137
+ }
138
+ settle(res) {
139
+ const entry = this.pending.get(res.id);
140
+ if (!entry)
141
+ return; // late response after timeout; nothing to settle
142
+ if (!res.ok) {
143
+ this.clearTimer(entry.timer);
144
+ this.pending.delete(res.id);
145
+ entry.reject(new RuntimeCommandError(res.error ?? { code: 'INTERNAL' }));
146
+ return;
147
+ }
148
+ if (res.binary) {
149
+ // Hold the promise open: the payload arrives in the next binary frame.
150
+ entry.awaitingBinary = { result: res.result ?? {} };
151
+ this.binaryQueue.push(res.id);
152
+ return;
153
+ }
154
+ this.clearTimer(entry.timer);
155
+ this.pending.delete(res.id);
156
+ entry.resolve(res.result ?? {});
157
+ }
158
+ dispatchBinary(buf) {
159
+ const id = this.binaryQueue.shift();
160
+ if (id === undefined)
161
+ return; // unsolicited frame
162
+ const entry = this.pending.get(id);
163
+ if (!entry?.awaitingBinary)
164
+ return;
165
+ let frame;
166
+ try {
167
+ frame = parseBinaryFrame(buf);
168
+ }
169
+ catch (err) {
170
+ this.clearTimer(entry.timer);
171
+ this.pending.delete(id);
172
+ entry.reject(err);
173
+ return;
174
+ }
175
+ // The header carries the id precisely so a reordered frame is detected
176
+ // rather than silently handed to the wrong caller.
177
+ if (frame.id !== binaryCorrelationId(id)) {
178
+ this.clearTimer(entry.timer);
179
+ this.pending.delete(id);
180
+ entry.reject(new Error(`binary frame id mismatch for request ${id}`));
181
+ return;
182
+ }
183
+ const declared = entry.awaitingBinary.result.bytes;
184
+ if (typeof declared === 'number' && declared !== frame.payload.byteLength) {
185
+ this.clearTimer(entry.timer);
186
+ this.pending.delete(id);
187
+ entry.reject(new Error(`binary payload length ${frame.payload.byteLength} != declared ${declared}`));
188
+ return;
189
+ }
190
+ this.clearTimer(entry.timer);
191
+ this.pending.delete(id);
192
+ entry.resolve({ ...entry.awaitingBinary.result, payload: frame.payload });
193
+ }
194
+ /* --- commands ---------------------------------------------------------- */
195
+ /**
196
+ * A true `#private` method, not a TypeScript `private` one.
197
+ *
198
+ * TS `private` is erased at compile time: the method still sits on the
199
+ * prototype and `runtime['request']('camera.snapshot')` works at runtime. That
200
+ * is exactly the escape hatch P3-4 exists to close, and once someone reaches
201
+ * for it, `setInterval(() => request('body'), 33)` follows. `#` is enforced by
202
+ * the language, so the only way to issue a command is a named method below.
203
+ */
204
+ #request(name, args = {}) {
205
+ const id = `r-${++this.requestSeq}-${Math.random().toString(36).slice(2, 8)}`;
206
+ const timeoutMs = TIMEOUTS[name] ?? DEFAULT_TIMEOUT_MS;
207
+ return new Promise((resolve, reject) => {
208
+ const timer = this.setTimer(() => {
209
+ this.pending.delete(id);
210
+ const qi = this.binaryQueue.indexOf(id);
211
+ if (qi >= 0)
212
+ this.binaryQueue.splice(qi, 1);
213
+ reject(new RuntimeCommandError({ code: 'TIMEOUT', message: `${name} timed out after ${timeoutMs}ms` }));
214
+ }, timeoutMs);
215
+ this.pending.set(id, { resolve, reject, timer });
216
+ this.transport.send({ type: 'request', id, name, args });
217
+ });
218
+ }
219
+ camera = {
220
+ snapshot: () => this.#request('camera.snapshot').then((r) => r),
221
+ getConfig: () => this.#request('camera.config').then((r) => r),
222
+ start: () => this.#request('camera.start').then(() => undefined),
223
+ stop: () => this.#request('camera.stop').then(() => undefined),
224
+ stream: () => this.openStream(),
225
+ stopStream: () => this.closeStream(),
226
+ };
227
+ device = {
228
+ list: () => this.#request('device.list').then((r) => (r.devices ?? r)),
229
+ onError: (fn) => this.deviceErrCh.add(fn),
230
+ onConnected: (fn) => this.deviceConnCh.add(fn),
231
+ onPlayerLost: (fn) => this.playerLostCh.add(fn),
232
+ };
233
+ status() {
234
+ return this.#request('runtime.status').then((r) => r);
235
+ }
236
+ /**
237
+ * Live retuning. Applied by the runtime — the SDK never recomputes Pointer
238
+ * locally, or the browser and runtime would disagree about where the hand is.
239
+ */
240
+ reconfigure(config) {
241
+ this.transport.send({ type: 'reconfigure', config });
242
+ }
243
+ /** Values the runtime reported as actually in effect, for HUDs. Read-only. */
244
+ get effectiveConfig() {
245
+ return this.lastConfig;
246
+ }
247
+ /* --- subscriptions ----------------------------------------------------- */
248
+ body = {
249
+ onUpdate: (fn) => this.bodyCh.add(fn),
250
+ };
251
+ gesture = {
252
+ onUpdate: (fn) => this.gestureCh.add(fn),
253
+ on: (name, fn) => {
254
+ let ch = this.gestureByName.get(name);
255
+ if (!ch) {
256
+ ch = new Channel();
257
+ this.gestureByName.set(name, ch);
258
+ }
259
+ return ch.add(fn);
260
+ },
261
+ };
262
+ /** Fine-grained fingers/hand-shape events from the optional Hand5 pipeline. */
263
+ handGesture = {
264
+ onUpdate: (fn) => this.handGestureCh.add(fn),
265
+ onDiagnostic: (fn) => this.handGestureDiagnosticCh.add(fn),
266
+ };
267
+ pointer = {
268
+ onUpdate: (fn) => this.pointerCh.add(fn),
269
+ };
270
+ silhouette = {
271
+ onUpdate: (fn) => this.silhouetteCh.add(fn),
272
+ };
273
+ playerSilhouette = {
274
+ onUpdate: (fn) => this.playerSilhouetteCh.add(fn),
275
+ onLost: (fn) => this.playerSilhouetteLostCh.add(fn),
276
+ current: () => this.lastPlayerSilhouette,
277
+ };
278
+ motion = {
279
+ onUpdate: (fn) => this.motionCh.add(fn),
280
+ };
281
+ onCapabilityChanged(fn) {
282
+ return this.capChangeCh.add(fn);
283
+ }
284
+ onStateChange(fn) {
285
+ return this.stateCh.add(fn);
286
+ }
287
+ /* --- video ------------------------------------------------------------- */
288
+ pc = null;
289
+ /**
290
+ * Establishes the WebRTC stream. The whole signalling exchange is hidden;
291
+ * callers get a MediaStream.
292
+ */
293
+ async openStream() {
294
+ const RTC = globalThis.RTCPeerConnection;
295
+ if (!RTC)
296
+ throw new Error('WebRTC is not available in this environment');
297
+ this.closeStream(); // one receiver per Runtime session
298
+ const pc = new RTC({ iceServers: [] }); // local-only: no STUN needed
299
+ this.pc = pc;
300
+ pc.addTransceiver('video', { direction: 'recvonly' });
301
+ const stream = new MediaStream();
302
+ let remoteDescriptionApplied = false;
303
+ const pendingRemoteIce = [];
304
+ const addRemoteIce = async (candidate) => {
305
+ if (!candidate)
306
+ return;
307
+ await pc.addIceCandidate({ candidate, sdpMid: '0' });
308
+ };
309
+ // camera:ice is intentionally an event rather than a response: host ICE
310
+ // gathering can happen while camera.offer is still awaiting its answer.
311
+ const offRemoteIce = this.transport.onMessage((raw) => {
312
+ const msg = raw;
313
+ if (msg.type !== 'camera:ice' || typeof msg.candidate !== 'string' || !msg.candidate)
314
+ return;
315
+ if (!remoteDescriptionApplied)
316
+ pendingRemoteIce.push(msg.candidate);
317
+ else
318
+ void addRemoteIce(msg.candidate).catch(() => { });
319
+ });
320
+ const track = new Promise((resolve) => {
321
+ pc.ontrack = (ev) => {
322
+ stream.addTrack(ev.track);
323
+ resolve(stream);
324
+ };
325
+ });
326
+ pc.onicecandidate = (ev) => {
327
+ if (ev.candidate) {
328
+ void this.#request('camera.ice', { candidate: ev.candidate.candidate }).catch(() => {
329
+ /* trickle ICE is best-effort; the offer/answer already carries host candidates */
330
+ });
331
+ }
332
+ else {
333
+ void this.#request('camera.ice', { done: true }).catch(() => { });
334
+ }
335
+ };
336
+ try {
337
+ const offer = await pc.createOffer();
338
+ await pc.setLocalDescription(offer);
339
+ const res = await this.#request('camera.offer', {
340
+ sdp: offer.sdp, type: offer.type, stream: 'color', transport: 'webrtc-h264',
341
+ });
342
+ if (typeof res.sdp !== 'string' || !res.sdp)
343
+ throw new Error('camera.offer returned no SDP answer');
344
+ await pc.setRemoteDescription({ type: 'answer', sdp: res.sdp });
345
+ remoteDescriptionApplied = true;
346
+ while (pendingRemoteIce.length)
347
+ await addRemoteIce(pendingRemoteIce.shift());
348
+ return await track;
349
+ }
350
+ catch (error) {
351
+ if (this.pc === pc)
352
+ this.closeStream();
353
+ throw error;
354
+ }
355
+ finally {
356
+ // The connection-level dispatcher remains installed; this one exists
357
+ // only to bridge this particular PeerConnection's ICE lifecycle.
358
+ offRemoteIce();
359
+ }
360
+ }
361
+ closeStream() {
362
+ this.pc?.close();
363
+ this.pc = null;
364
+ }
365
+ /**
366
+ * Delays skeleton delivery to match the video's playback clock.
367
+ *
368
+ * Skeletons arrive one to two frames ahead of the picture, so drawing an
369
+ * overlay straight from body.onUpdate puts the lines in front of the player.
370
+ */
371
+ alignToVideo(video, offsetMs = 0) {
372
+ const queue = [];
373
+ const MAX = 120; // ~4s at 30fps; enough for any plausible offset
374
+ let baseTs = null;
375
+ let baseTime = null;
376
+ let offset = offsetMs;
377
+ const off = this.bodyCh.add((b) => {
378
+ if (baseTs === null) {
379
+ baseTs = b.ts;
380
+ baseTime = video.currentTime;
381
+ }
382
+ queue.push(b);
383
+ if (queue.length > MAX)
384
+ queue.shift();
385
+ });
386
+ return {
387
+ current() {
388
+ if (queue.length === 0 || baseTs === null || baseTime === null)
389
+ return null;
390
+ // Map playback position back onto the skeleton clock.
391
+ const wanted = baseTs + (video.currentTime - baseTime) * 1000 - offset;
392
+ let best = queue[0];
393
+ let bestDist = Math.abs(best.ts - wanted);
394
+ for (let i = 1; i < queue.length; i++) {
395
+ const d = Math.abs(queue[i].ts - wanted);
396
+ if (d <= bestDist) {
397
+ best = queue[i];
398
+ bestDist = d;
399
+ }
400
+ }
401
+ return best;
402
+ },
403
+ setOffset(ms) {
404
+ offset = ms;
405
+ },
406
+ get offsetMs() {
407
+ return offset;
408
+ },
409
+ dispose() {
410
+ off();
411
+ queue.length = 0;
412
+ },
413
+ };
414
+ }
415
+ /* --- lifecycle --------------------------------------------------------- */
416
+ dispose() {
417
+ for (const d of this.disposers)
418
+ d();
419
+ this.disposers = [];
420
+ for (const [, entry] of this.pending) {
421
+ this.clearTimer(entry.timer);
422
+ entry.reject(new Error('runtime disposed'));
423
+ }
424
+ this.pending.clear();
425
+ this.closeStream();
426
+ }
427
+ }
@@ -0,0 +1,42 @@
1
+ import type { ConnectionOptions, ConnectionState, Transport } from './connection.js';
2
+ import type { ConnectStatus } from './capability.js';
3
+ import { RuntimeFacade } from './facade.js';
4
+ import type { HelloMessage } from './protocol.js';
5
+ export interface CreateRuntimeOptions extends Omit<ConnectionOptions, 'require'> {
6
+ /** 'live' talks to the runtime over WebSocket; 'mock' synthesises the same envelope. */
7
+ source?: 'live' | 'mock';
8
+ require?: HelloMessage['require'];
9
+ /** Frame rate for the mock source. Ignored when live. */
10
+ mockFps?: number;
11
+ }
12
+ export declare class Runtime extends RuntimeFacade {
13
+ /** Named for the handshake result, not the runtime.status command above it. */
14
+ private lastConnectStatus;
15
+ constructor(transport: Transport, timers?: ConnectionOptions);
16
+ /**
17
+ * Opens the connection and returns the runtime's verdict.
18
+ *
19
+ * Waits for exactly one message type. The protocol guarantees hello is always
20
+ * answered with ready — success or not — so there is no race between "wait for
21
+ * ready" and "wait for an error message".
22
+ */
23
+ connect(): Promise<ConnectStatus>;
24
+ /** The last handshake result. Null before connect() resolves. */
25
+ get connectStatus(): ConnectStatus | null;
26
+ get state(): ConnectionState;
27
+ close(): void;
28
+ }
29
+ export declare function createRuntime(options?: CreateRuntimeOptions): Runtime;
30
+ export { WsConnection, defaultUrl } from './connection.js';
31
+ export { MockTransport } from './mock.js';
32
+ export { RuntimeFacade } from './facade.js';
33
+ export { toConnectStatus, explainMiss, describeGate } from './capability.js';
34
+ export type { ConnectStatus } from './capability.js';
35
+ export { decodeSilhouette, parseBinaryFrame, fnv1a64, binaryCorrelationId, QSBN_HEADER_BYTES } from './decode.js';
36
+ export type { SilhouetteFrame, BinaryFrame } from './decode.js';
37
+ export { RuntimeCommandError } from './facade.js';
38
+ export type { Unsubscribe, GestureName, MotionUpdate, SnapshotResult, DeviceInfo, RuntimeStatus, CameraConfig, CapabilityChange, VideoAligner, PlayerSilhouetteFrame, PlayerSilhouetteLost, } from './facade.js';
39
+ export type { ConnectionState, ConnectionOptions, Transport } from './connection.js';
40
+ export type { BodyUpdate, Capability, CapabilityMiss, CalibBox, DepthSilhouette, DeviceConnected, DeviceError, GestureUpdate, HandGestureUpdate, HandGestureDiagnostic, HelloMessage, Joint, PointerUpdate, PlayerSilhouette, PlayerSilhouetteLost as PlayerSilhouetteLostEvent, ReadyMessage, RequestError, RequestName, RuntimeConfig, RuntimeNamespace, ServerMessage, Timestamp, } from './protocol.js';
41
+ export { SCHEMA_HASH, RUNTIME_URL, GRID_W, GRID_H } from './protocol.js';
42
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,iBAAiB,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAErF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,iBAAiB,EAAE,SAAS,CAAC;IAC9E,wFAAwF;IACxF,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,YAAY,CAAC,SAAS,CAAC,CAAC;IAClC,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,OAAQ,SAAQ,aAAa;IACxC,+EAA+E;IAC/E,OAAO,CAAC,iBAAiB,CAA8B;gBAE3C,SAAS,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,iBAAiB;IAO5D;;;;;;OAMG;IACG,OAAO,IAAI,OAAO,CAAC,aAAa,CAAC;IAMvC,iEAAiE;IACjE,IAAI,aAAa,IAAI,aAAa,GAAG,IAAI,CAExC;IAED,IAAI,KAAK,IAAI,eAAe,CAE3B;IAED,KAAK,IAAI,IAAI;CAId;AAED,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAazE;AAMD,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC7E,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAClH,YAAY,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,YAAY,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,cAAc,EACd,UAAU,EACV,aAAa,EACb,YAAY,EACZ,gBAAgB,EAChB,YAAY,EACZ,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACrF,YAAY,EACV,UAAU,EACV,UAAU,EACV,cAAc,EACd,QAAQ,EACR,eAAe,EACf,eAAe,EACf,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,qBAAqB,EACrB,YAAY,EACZ,KAAK,EACL,aAAa,EACb,gBAAgB,EAChB,oBAAoB,IAAI,yBAAyB,EACjD,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,SAAS,GACV,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * QikSense Runtime SDK — the single entry point for web experiences.
3
+ *
4
+ * Games write `runtime.body.onUpdate(...)`, never `socket.onmessage`. Protocol,
5
+ * reconnect, heartbeat and capability negotiation all live below this line.
6
+ *
7
+ * Framework-free by construction. The Vue adapter is a separate subpath
8
+ * (`@realnation/builder-shared-sdk/runtime/vue`) so Canvas/WebGL experiences,
9
+ * tooling and non-Vue hosts never pull in a reactivity system they do not want.
10
+ */
11
+ import { WsConnection } from './connection.js';
12
+ import { toConnectStatus } from './capability.js';
13
+ import { RuntimeFacade } from './facade.js';
14
+ import { MockTransport } from './mock.js';
15
+ export class Runtime extends RuntimeFacade {
16
+ /** Named for the handshake result, not the runtime.status command above it. */
17
+ lastConnectStatus = null;
18
+ constructor(transport, timers) {
19
+ super(transport, {
20
+ setTimer: timers?.setTimer,
21
+ clearTimer: timers?.clearTimer,
22
+ });
23
+ }
24
+ /**
25
+ * Opens the connection and returns the runtime's verdict.
26
+ *
27
+ * Waits for exactly one message type. The protocol guarantees hello is always
28
+ * answered with ready — success or not — so there is no race between "wait for
29
+ * ready" and "wait for an error message".
30
+ */
31
+ async connect() {
32
+ const ready = await this.transport.connect();
33
+ this.lastConnectStatus = toConnectStatus(ready);
34
+ return this.lastConnectStatus;
35
+ }
36
+ /** The last handshake result. Null before connect() resolves. */
37
+ get connectStatus() {
38
+ return this.lastConnectStatus;
39
+ }
40
+ get state() {
41
+ return this.transport.state;
42
+ }
43
+ close() {
44
+ this.dispose();
45
+ this.transport.close();
46
+ }
47
+ }
48
+ export function createRuntime(options = {}) {
49
+ const transport = options.source === 'mock'
50
+ ? new MockTransport({
51
+ require: options.require,
52
+ fps: options.mockFps,
53
+ setTimer: options.setTimer,
54
+ clearTimer: options.clearTimer,
55
+ now: options.now,
56
+ })
57
+ : new WsConnection(options);
58
+ return new Runtime(transport, options);
59
+ }
60
+ /* --- public surface -------------------------------------------------------
61
+ * Named exports only. `export *` would collide with the package root's generic
62
+ * names (Capability, Pointer) the moment anyone imports both.
63
+ */
64
+ export { WsConnection, defaultUrl } from './connection.js';
65
+ export { MockTransport } from './mock.js';
66
+ export { RuntimeFacade } from './facade.js';
67
+ export { toConnectStatus, explainMiss, describeGate } from './capability.js';
68
+ export { decodeSilhouette, parseBinaryFrame, fnv1a64, binaryCorrelationId, QSBN_HEADER_BYTES } from './decode.js';
69
+ export { RuntimeCommandError } from './facade.js';
70
+ export { SCHEMA_HASH, RUNTIME_URL, GRID_W, GRID_H } from './protocol.js';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Synthetic source. Same envelope as real hardware, no device required.
3
+ *
4
+ * Principle: the mock may be stricter than the real runtime, never looser.
5
+ * A permissive mock lets code pass here and fail on hardware — the most
6
+ * expensive failure mode there is, because it defers the bug to the one
7
+ * environment where a camera is plugged in.
8
+ *
9
+ * Concretely that means: it answers pong but never sends ping (heartbeat
10
+ * direction matches), and it emits ready.requested and device:connected.config
11
+ * even though nothing here reads them.
12
+ */
13
+ import type { ConnectionState, Transport } from './connection.js';
14
+ import type { Capability, ClientMessage, HelloMessage, ReadyMessage, ServerMessage } from './protocol.js';
15
+ export interface MockOptions {
16
+ require?: HelloMessage['require'];
17
+ fps?: number;
18
+ /** Capabilities the fake runtime claims. Defaults to whatever was requested. */
19
+ provides?: Capability['name'][];
20
+ setTimer?: (fn: () => void, ms: number) => unknown;
21
+ clearTimer?: (h: unknown) => void;
22
+ now?: () => number;
23
+ /** Drives frames manually instead of on a timer. For deterministic tests. */
24
+ manual?: boolean;
25
+ }
26
+ export declare class MockTransport implements Transport {
27
+ private messageHandlers;
28
+ private binaryHandlers;
29
+ private stateHandlers;
30
+ private timerHandle;
31
+ private _state;
32
+ private frame;
33
+ private readonly opts;
34
+ /** Set once ping has been observed, so tests can assert direction. */
35
+ pingsReceived: number;
36
+ constructor(options?: MockOptions);
37
+ get state(): ConnectionState;
38
+ private setState;
39
+ connect(): Promise<ReadyMessage>;
40
+ private schedule;
41
+ /** Emits one frame's worth of events. Public so `manual: true` tests can step. */
42
+ tick(): void;
43
+ private makeBody;
44
+ private makePointer;
45
+ private makeSilhouette;
46
+ private makePlayerSilhouette;
47
+ send(msg: ClientMessage): void;
48
+ private answer;
49
+ /**
50
+ * Public so tests and demos can inject an exact frame. Real sessions never
51
+ * call it — the mock drives itself from tick().
52
+ */
53
+ emit(msg: ServerMessage): void;
54
+ onMessage(fn: (m: ServerMessage) => void): () => void;
55
+ onBinary(fn: (d: ArrayBuffer) => void): () => void;
56
+ onStateChange(fn: (s: ConnectionState) => void): () => void;
57
+ close(): void;
58
+ }
59
+ //# sourceMappingURL=mock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../../src/runtime/mock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAElE,OAAO,KAAK,EAEV,UAAU,EACV,aAAa,EAIb,YAAY,EAIZ,YAAY,EAGZ,aAAa,EACd,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,YAAY,CAAC,SAAS,CAAC,CAAC;IAClC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,QAAQ,CAAC,EAAE,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;IAChC,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACnD,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAClC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,6EAA6E;IAC7E,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAQD,qBAAa,aAAc,YAAW,SAAS;IAC7C,OAAO,CAAC,eAAe,CAAyC;IAChE,OAAO,CAAC,cAAc,CAAuC;IAC7D,OAAO,CAAC,aAAa,CAA2C;IAChE,OAAO,CAAC,WAAW,CAAiB;IACpC,OAAO,CAAC,MAAM,CAA2B;IACzC,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,QAAQ,CAAC,IAAI,CACuB;IAC5C,sEAAsE;IACtE,aAAa,SAAK;gBAEN,OAAO,GAAE,WAAgB;IAYrC,IAAI,KAAK,IAAI,eAAe,CAE3B;IAED,OAAO,CAAC,QAAQ;IAMhB,OAAO,IAAI,OAAO,CAAC,YAAY,CAAC;IA4ChC,OAAO,CAAC,QAAQ;IAShB,kFAAkF;IAClF,IAAI,IAAI,IAAI;IAmCZ,OAAO,CAAC,QAAQ;IAgBhB,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,cAAc;IA2BtB,OAAO,CAAC,oBAAoB;IAQ5B,IAAI,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI;IAsB9B,OAAO,CAAC,MAAM;IA4Bd;;;OAGG;IACH,IAAI,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI;IAI9B,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,IAAI,GAAG,MAAM,IAAI;IAKrD,QAAQ,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,IAAI,GAAG,MAAM,IAAI;IAKlD,aAAa,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,eAAe,KAAK,IAAI,GAAG,MAAM,IAAI;IAK3D,KAAK,IAAI,IAAI;CAOd"}