@smoregg/sdk 2.0.0 → 2.2.0

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 (70) hide show
  1. package/dist/cjs/controller.cjs +260 -113
  2. package/dist/cjs/controller.cjs.map +1 -1
  3. package/dist/cjs/errors.cjs +1 -0
  4. package/dist/cjs/errors.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +26 -3
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/index.cjs +2 -7
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/screen.cjs +244 -128
  10. package/dist/cjs/screen.cjs.map +1 -1
  11. package/dist/cjs/shared.cjs +34 -0
  12. package/dist/cjs/shared.cjs.map +1 -0
  13. package/dist/cjs/testing.cjs +181 -73
  14. package/dist/cjs/testing.cjs.map +1 -1
  15. package/dist/cjs/transport/PostMessageTransport.cjs +12 -0
  16. package/dist/cjs/transport/PostMessageTransport.cjs.map +1 -1
  17. package/dist/cjs/transport/protocol.cjs +2 -0
  18. package/dist/cjs/transport/protocol.cjs.map +1 -1
  19. package/dist/cjs/types.cjs +16 -0
  20. package/dist/cjs/types.cjs.map +1 -0
  21. package/dist/esm/controller.js +262 -115
  22. package/dist/esm/controller.js.map +1 -1
  23. package/dist/esm/errors.js +1 -0
  24. package/dist/esm/errors.js.map +1 -1
  25. package/dist/esm/events.js +25 -4
  26. package/dist/esm/events.js.map +1 -1
  27. package/dist/esm/index.js +1 -3
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/screen.js +246 -130
  30. package/dist/esm/screen.js.map +1 -1
  31. package/dist/esm/shared.js +30 -0
  32. package/dist/esm/shared.js.map +1 -0
  33. package/dist/esm/testing.js +181 -73
  34. package/dist/esm/testing.js.map +1 -1
  35. package/dist/esm/transport/PostMessageTransport.js +12 -0
  36. package/dist/esm/transport/PostMessageTransport.js.map +1 -1
  37. package/dist/esm/transport/protocol.js +2 -1
  38. package/dist/esm/transport/protocol.js.map +1 -1
  39. package/dist/esm/types.js +14 -0
  40. package/dist/esm/types.js.map +1 -0
  41. package/dist/types/controller.d.ts +1 -1
  42. package/dist/types/controller.d.ts.map +1 -1
  43. package/dist/types/errors.d.ts.map +1 -1
  44. package/dist/types/events.d.ts +14 -1
  45. package/dist/types/events.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +4 -8
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/screen.d.ts +3 -3
  49. package/dist/types/screen.d.ts.map +1 -1
  50. package/dist/types/shared.d.ts +21 -0
  51. package/dist/types/shared.d.ts.map +1 -0
  52. package/dist/types/testing.d.ts +65 -4
  53. package/dist/types/testing.d.ts.map +1 -1
  54. package/dist/types/transport/PostMessageTransport.d.ts +1 -0
  55. package/dist/types/transport/PostMessageTransport.d.ts.map +1 -1
  56. package/dist/types/transport/protocol.d.ts +5 -0
  57. package/dist/types/transport/protocol.d.ts.map +1 -1
  58. package/dist/types/types.d.ts +254 -345
  59. package/dist/types/types.d.ts.map +1 -1
  60. package/dist/umd/smore-sdk.umd.js +575 -784
  61. package/dist/umd/smore-sdk.umd.js.map +1 -1
  62. package/dist/umd/smore-sdk.umd.min.js +1 -1
  63. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  64. package/package.json +7 -1
  65. package/dist/cjs/config.cjs +0 -13
  66. package/dist/cjs/config.cjs.map +0 -1
  67. package/dist/esm/config.js +0 -10
  68. package/dist/esm/config.js.map +0 -1
  69. package/dist/types/config.d.ts +0 -35
  70. package/dist/types/config.d.ts.map +0 -1
@@ -5,7 +5,7 @@ var protocol = require('./transport/protocol.cjs');
5
5
  var errors = require('./errors.cjs');
6
6
  var events = require('./events.cjs');
7
7
  var logger = require('./logger.cjs');
8
- var config = require('./config.cjs');
8
+ var shared = require('./shared.cjs');
9
9
 
10
10
  const DEFAULT_TIMEOUT = 1e4;
11
11
  function validatePlayerIndex(playerIndex, controllers) {
@@ -36,17 +36,19 @@ class ScreenImpl {
36
36
  handlerToTransport = /* @__PURE__ */ new Map();
37
37
  // Pending handlers registered via on() before transport is ready
38
38
  _pendingHandlers = [];
39
- // Lifecycle callback arrays
40
- _onAllReadyCallbacks = /* @__PURE__ */ new Set();
41
- _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
42
- _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
43
- _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
44
- _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
45
- _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
46
- _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
47
- _onErrorCallbacks = /* @__PURE__ */ new Set();
39
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
40
+ _lifecycleListeners = /* @__PURE__ */ new Map();
41
+ // Outbound message buffer (messages sent before ready)
42
+ _outboundBuffer = [];
48
43
  // Whether all-ready has fired
49
44
  _allReadyFired = false;
45
+ // Self-connection awareness
46
+ _isConnected = false;
47
+ // Custom state management
48
+ _customStates = /* @__PURE__ */ new Map();
49
+ _stateChangeListeners = /* @__PURE__ */ new Set();
50
+ // Protocol versioning
51
+ _protocolVersion = protocol.PROTOCOL_VERSION;
50
52
  // Ready promise
51
53
  _readyResolve;
52
54
  _readyReject;
@@ -108,8 +110,17 @@ class ScreenImpl {
108
110
  this._readyReject(error);
109
111
  return;
110
112
  }
111
- this.transport = new PostMessageTransport.PostMessageTransport(parentOrigin);
113
+ this.transport = this.config.transport ?? new PostMessageTransport.PostMessageTransport(parentOrigin);
112
114
  this._roomCode = initData.roomCode;
115
+ const serverProtocolVersion = initData.protocolVersion;
116
+ if (serverProtocolVersion !== void 0) {
117
+ this._protocolVersion = serverProtocolVersion;
118
+ if (serverProtocolVersion !== protocol.PROTOCOL_VERSION) {
119
+ this.logger.warn(
120
+ `Protocol version mismatch: SDK v${protocol.PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
121
+ );
122
+ }
123
+ }
113
124
  this._controllers = this.mapControllersFromInit(initData.players);
114
125
  if (this._controllers.length === 0) {
115
126
  this.logger.warn("Screen initialized with zero controllers");
@@ -119,13 +130,28 @@ class ScreenImpl {
119
130
  this.setupUserEventHandler(event, handler);
120
131
  }
121
132
  this._pendingHandlers = [];
133
+ this._isConnected = true;
122
134
  this._isReady = true;
135
+ for (const buffered of this._outboundBuffer) {
136
+ try {
137
+ switch (buffered.method) {
138
+ case "broadcast":
139
+ this.broadcast(buffered.args[0], buffered.args[1]);
140
+ break;
141
+ case "sendToController":
142
+ this.sendToController(buffered.args[0], buffered.args[1], buffered.args[2]);
143
+ break;
144
+ }
145
+ } catch (err) {
146
+ this.handleError(err instanceof errors.SmoreSDKError ? err : new errors.SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
147
+ }
148
+ }
149
+ this._outboundBuffer = [];
123
150
  this.logger.lifecycle("Screen ready", {
124
151
  roomCode: this._roomCode,
125
152
  controllers: this._controllers.length
126
153
  });
127
- const autoReady = config.getGlobalConfig().autoReady ?? true;
128
- if (autoReady) {
154
+ if (this.config.autoReady !== false) {
129
155
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
130
156
  this.signalReady();
131
157
  }
@@ -143,13 +169,13 @@ class ScreenImpl {
143
169
  for (const nc of newControllers) {
144
170
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
145
171
  this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
146
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
172
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
147
173
  }
148
174
  }
149
175
  for (const oc of oldControllers) {
150
176
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
151
177
  this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
152
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
178
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
153
179
  }
154
180
  }
155
181
  }
@@ -159,18 +185,11 @@ class ScreenImpl {
159
185
  }
160
186
  };
161
187
  window.addEventListener("message", this.boundMessageHandler);
162
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
188
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: protocol.PROTOCOL_VERSION }, parentOrigin);
163
189
  this.logger.lifecycle("Sent _bridge:ready to parent");
164
190
  }
165
191
  mapControllersFromInit(players) {
166
- return players.map((p, index) => ({
167
- playerIndex: p.playerIndex ?? index,
168
- // Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
169
- nickname: p.nickname || p.name || `Player ${index + 1}`,
170
- connected: p.connected !== false,
171
- // Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
172
- appearance: p.appearance ?? p.character
173
- }));
192
+ return players.map((p, index) => shared.mapPlayerDTO(p, index));
174
193
  }
175
194
  setupEventHandlers() {
176
195
  if (!this.transport) return;
@@ -178,16 +197,11 @@ class ScreenImpl {
178
197
  const payload = data;
179
198
  const playerData = payload?.player;
180
199
  if (playerData && typeof playerData.playerIndex === "number") {
181
- const controllerInfo = {
182
- playerIndex: playerData.playerIndex,
183
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
184
- connected: playerData.connected !== false,
185
- appearance: playerData.appearance ?? playerData.character
186
- };
200
+ const controllerInfo = shared.mapPlayerDTO(playerData, playerData.playerIndex);
187
201
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
188
202
  this._controllers = [...this._controllers, controllerInfo];
189
203
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
190
- this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
204
+ this._emitLifecycle("$controller-join", controllerInfo.playerIndex, controllerInfo);
191
205
  }
192
206
  });
193
207
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -197,7 +211,7 @@ class ScreenImpl {
197
211
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
198
212
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
199
213
  this.logger.lifecycle("Controller left", { playerIndex });
200
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
214
+ this._emitLifecycle("$controller-leave", playerIndex);
201
215
  }
202
216
  });
203
217
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -208,24 +222,19 @@ class ScreenImpl {
208
222
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
209
223
  );
210
224
  this.logger.lifecycle("Controller disconnected", { playerIndex });
211
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
225
+ this._emitLifecycle("$controller-disconnect", playerIndex);
212
226
  }
213
227
  });
214
228
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
215
229
  const payload = data;
216
230
  const playerData = payload?.player;
217
231
  if (playerData && typeof playerData.playerIndex === "number") {
218
- const controllerInfo = {
219
- playerIndex: playerData.playerIndex,
220
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
221
- connected: true,
222
- appearance: playerData.appearance ?? playerData.character
223
- };
232
+ const controllerInfo = shared.mapPlayerDTO(playerData, playerData.playerIndex);
224
233
  this._controllers = this._controllers.map(
225
234
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
226
235
  );
227
236
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
228
- this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
237
+ this._emitLifecycle("$controller-reconnect", controllerInfo.playerIndex, controllerInfo);
229
238
  }
230
239
  });
231
240
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
@@ -238,19 +247,69 @@ class ScreenImpl {
238
247
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
239
248
  );
240
249
  this.logger.lifecycle("Player character updated", { playerIndex: pi });
241
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
250
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
242
251
  }
243
252
  });
244
253
  this.registerTransportHandler(events.SMORE_EVENTS.RATE_LIMITED, (data) => {
245
254
  const payload = data;
246
- const event = payload?.event ?? "unknown";
247
- this.logger.warn(`Rate limited: ${event}`);
248
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
255
+ const eventName = payload?.event ?? "unknown";
256
+ this.handleError(
257
+ new errors.SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
258
+ details: { event: eventName }
259
+ })
260
+ );
249
261
  });
250
262
  this.registerTransportHandler(events.SMORE_EVENTS.ALL_READY, () => {
251
263
  this.logger.lifecycle("All participants ready");
252
264
  this._allReadyFired = true;
253
- this._onAllReadyCallbacks.forEach((cb) => cb());
265
+ this._emitLifecycle("$all-ready");
266
+ });
267
+ this.registerTransportHandler(events.SMORE_EVENTS.SELF_DISCONNECTED, () => {
268
+ this._isConnected = false;
269
+ this.logger.lifecycle("Connection lost");
270
+ this._emitLifecycle("$connection-change", false);
271
+ });
272
+ this.registerTransportHandler(events.SMORE_EVENTS.SELF_RECONNECTED, () => {
273
+ this._isConnected = true;
274
+ this.logger.lifecycle("Connection restored");
275
+ this._emitLifecycle("$connection-change", true);
276
+ });
277
+ this.registerTransportHandler(events.SMORE_EVENTS.STATE_CHANGED, (raw) => {
278
+ const data = raw;
279
+ if (typeof data?.playerIndex === "number" && data.state) {
280
+ this._customStates.set(data.playerIndex, data.state);
281
+ this._stateChangeListeners.forEach((cb) => {
282
+ try {
283
+ cb(data.playerIndex, data.state);
284
+ } catch (err) {
285
+ this.handleError(
286
+ new errors.SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
287
+ cause: err instanceof Error ? err : void 0
288
+ })
289
+ );
290
+ }
291
+ });
292
+ }
293
+ });
294
+ this.registerTransportHandler(events.SMORE_EVENTS.STATE_ALL, (raw) => {
295
+ const data = raw;
296
+ if (data?.states) {
297
+ for (const [key, value] of Object.entries(data.states)) {
298
+ const pi = Number(key);
299
+ this._customStates.set(pi, value);
300
+ this._stateChangeListeners.forEach((cb) => {
301
+ try {
302
+ cb(pi, value);
303
+ } catch (err) {
304
+ this.handleError(
305
+ new errors.SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
306
+ cause: err instanceof Error ? err : void 0
307
+ })
308
+ );
309
+ }
310
+ });
311
+ }
312
+ }
254
313
  });
255
314
  }
256
315
  /**
@@ -319,6 +378,45 @@ class ScreenImpl {
319
378
  get isDestroyed() {
320
379
  return this._isDestroyed;
321
380
  }
381
+ get isConnected() {
382
+ return this._isConnected;
383
+ }
384
+ get protocolVersion() {
385
+ return this._protocolVersion;
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // Lifecycle Listener Helpers
389
+ // ---------------------------------------------------------------------------
390
+ _addLifecycleListener(event, listener) {
391
+ let set = this._lifecycleListeners.get(event);
392
+ if (!set) {
393
+ set = /* @__PURE__ */ new Set();
394
+ this._lifecycleListeners.set(event, set);
395
+ }
396
+ set.add(listener);
397
+ return () => {
398
+ set.delete(listener);
399
+ if (set.size === 0) this._lifecycleListeners.delete(event);
400
+ };
401
+ }
402
+ _emitLifecycle(event, ...args) {
403
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
404
+ try {
405
+ cb(...args);
406
+ } catch (err) {
407
+ this.handleError(
408
+ new errors.SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
409
+ cause: err instanceof Error ? err : void 0,
410
+ details: { event }
411
+ })
412
+ );
413
+ }
414
+ });
415
+ }
416
+ _hasLifecycleListeners(event) {
417
+ const set = this._lifecycleListeners.get(event);
418
+ return set !== void 0 && set.size > 0;
419
+ }
322
420
  // ---------------------------------------------------------------------------
323
421
  // Lifecycle Methods
324
422
  // ---------------------------------------------------------------------------
@@ -326,51 +424,46 @@ class ScreenImpl {
326
424
  if (this._allReadyFired) {
327
425
  callback();
328
426
  }
329
- this._onAllReadyCallbacks.add(callback);
330
- return () => {
331
- this._onAllReadyCallbacks.delete(callback);
332
- };
427
+ return this._addLifecycleListener("$all-ready", callback);
333
428
  }
334
429
  onControllerJoin(callback) {
335
- this._onControllerJoinCallbacks.add(callback);
336
- return () => {
337
- this._onControllerJoinCallbacks.delete(callback);
338
- };
430
+ return this._addLifecycleListener("$controller-join", callback);
339
431
  }
340
432
  onControllerLeave(callback) {
341
- this._onControllerLeaveCallbacks.add(callback);
342
- return () => {
343
- this._onControllerLeaveCallbacks.delete(callback);
344
- };
433
+ return this._addLifecycleListener("$controller-leave", callback);
345
434
  }
346
435
  onControllerDisconnect(callback) {
347
- this._onControllerDisconnectCallbacks.add(callback);
348
- return () => {
349
- this._onControllerDisconnectCallbacks.delete(callback);
350
- };
436
+ return this._addLifecycleListener("$controller-disconnect", callback);
351
437
  }
352
438
  onControllerReconnect(callback) {
353
- this._onControllerReconnectCallbacks.add(callback);
354
- return () => {
355
- this._onControllerReconnectCallbacks.delete(callback);
356
- };
439
+ return this._addLifecycleListener("$controller-reconnect", callback);
357
440
  }
358
441
  onCharacterUpdated(callback) {
359
- this._onCharacterUpdatedCallbacks.add(callback);
360
- return () => {
361
- this._onCharacterUpdatedCallbacks.delete(callback);
362
- };
363
- }
364
- onRateLimited(callback) {
365
- this._onRateLimitedCallbacks.add(callback);
366
- return () => {
367
- this._onRateLimitedCallbacks.delete(callback);
368
- };
442
+ return this._addLifecycleListener("$character-updated", callback);
369
443
  }
370
444
  onError(callback) {
371
- this._onErrorCallbacks.add(callback);
445
+ return this._addLifecycleListener("$error", callback);
446
+ }
447
+ onConnectionChange(callback) {
448
+ return this._addLifecycleListener("$connection-change", callback);
449
+ }
450
+ // ---------------------------------------------------------------------------
451
+ // Custom State Methods
452
+ // ---------------------------------------------------------------------------
453
+ getControllerState(playerIndex) {
454
+ return this._customStates.get(playerIndex);
455
+ }
456
+ getAllControllerStates() {
457
+ const result = {};
458
+ for (const [key, value] of this._customStates) {
459
+ result[key] = value;
460
+ }
461
+ return result;
462
+ }
463
+ onCustomStateChange(listener) {
464
+ this._stateChangeListeners.add(listener);
372
465
  return () => {
373
- this._onErrorCallbacks.delete(callback);
466
+ this._stateChangeListeners.delete(listener);
374
467
  };
375
468
  }
376
469
  // ---------------------------------------------------------------------------
@@ -380,7 +473,6 @@ class ScreenImpl {
380
473
  * Send type-safe events to all controllers.
381
474
  *
382
475
  * Uses EventMap generic for compile-time type checking of event names and data payloads.
383
- * Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
384
476
  *
385
477
  * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
386
478
  * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
@@ -389,31 +481,18 @@ class ScreenImpl {
389
481
  *
390
482
  * Warning: Avoid sending primitive values directly (string, number, boolean).
391
483
  * Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
392
- *
393
- * @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
394
484
  */
395
485
  broadcast(event, data) {
396
- this.ensureReady("broadcast");
397
- events.validateEventName(event);
398
- this.logger.send(event, data);
399
- this.transport.emit(event, data);
400
- }
401
- /**
402
- * Send events to all controllers without TypeScript type checking.
403
- *
404
- * Bypasses EventMap generic type checks at compile time.
405
- * Runtime behavior is identical to broadcast - both call validateEventName at runtime.
406
- *
407
- * Use this when you need dynamic event names or when working without a predefined EventMap.
408
- *
409
- * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
410
- * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
411
- *
412
- * @see broadcast for type-safe version using EventMap generic
413
- */
414
- broadcastRaw(event, data) {
415
- this.ensureReady("broadcastRaw");
486
+ if (this._isDestroyed) {
487
+ throw new errors.SmoreSDKError("DESTROYED", "Cannot call broadcast() after destroy()");
488
+ }
489
+ if (!this._isReady || !this.transport) {
490
+ this._outboundBuffer.push({ method: "broadcast", args: [event, data] });
491
+ this.logger.debug(`Buffered broadcast "${event}" (screen not ready yet)`);
492
+ return;
493
+ }
416
494
  events.validateEventName(event);
495
+ shared.validatePayloadSize(data);
417
496
  this.logger.send(event, data);
418
497
  this.transport.emit(event, data);
419
498
  }
@@ -432,24 +511,17 @@ class ScreenImpl {
432
511
  * @param data - Event data payload
433
512
  */
434
513
  sendToController(playerIndex, event, data) {
435
- this.ensureReady("sendToController");
436
- events.validateEventName(event);
437
- validatePlayerIndex(playerIndex, this._controllers);
438
- if (data && typeof data === "object" && "targetPlayerIndex" in data) {
439
- this.logger.warn(
440
- `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
441
- );
514
+ if (this._isDestroyed) {
515
+ throw new errors.SmoreSDKError("DESTROYED", "Cannot call sendToController() after destroy()");
516
+ }
517
+ if (!this._isReady || !this.transport) {
518
+ this._outboundBuffer.push({ method: "sendToController", args: [playerIndex, event, data] });
519
+ this.logger.debug(`Buffered sendToController "${event}" -> Player ${playerIndex} (screen not ready yet)`);
520
+ return;
442
521
  }
443
- this.logger.send(`${event} -> Player ${playerIndex}`, data);
444
- this.transport.emit(event, {
445
- targetPlayerIndex: playerIndex,
446
- ...data && typeof data === "object" ? data : { data }
447
- });
448
- }
449
- sendToControllerRaw(playerIndex, event, data) {
450
- this.ensureReady("sendToControllerRaw");
451
522
  events.validateEventName(event);
452
523
  validatePlayerIndex(playerIndex, this._controllers);
524
+ shared.validatePayloadSize(data);
453
525
  if (data && typeof data === "object" && "targetPlayerIndex" in data) {
454
526
  this.logger.warn(
455
527
  `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
@@ -484,6 +556,16 @@ class ScreenImpl {
484
556
  * are queued and activated when the transport becomes available.
485
557
  */
486
558
  on(event, handler) {
559
+ if (typeof event === "string" && event.startsWith("$")) {
560
+ const validEvents = events.SCREEN_LIFECYCLE_EVENTS;
561
+ if (!validEvents.has(event)) {
562
+ throw new errors.SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
563
+ }
564
+ if (event === "$all-ready" && this._allReadyFired) {
565
+ handler();
566
+ }
567
+ return this._addLifecycleListener(event, handler);
568
+ }
487
569
  events.validateEventName(event);
488
570
  let handlers = this.eventHandlers.get(event);
489
571
  if (!handlers) {
@@ -546,6 +628,23 @@ class ScreenImpl {
546
628
  * @returns Unsubscribe function to remove the handler before it fires
547
629
  */
548
630
  once(event, handler) {
631
+ if (typeof event === "string" && event.startsWith("$")) {
632
+ const validEvents = events.SCREEN_LIFECYCLE_EVENTS;
633
+ if (!validEvents.has(event)) {
634
+ throw new errors.SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
635
+ }
636
+ if (event === "$all-ready" && this._allReadyFired) {
637
+ handler();
638
+ return () => {
639
+ };
640
+ }
641
+ const wrapper = (...args) => {
642
+ unsub();
643
+ handler(...args);
644
+ };
645
+ const unsub = this._addLifecycleListener(event, wrapper);
646
+ return unsub;
647
+ }
549
648
  const wrappedHandler = (playerIndex, data) => {
550
649
  unsubscribe();
551
650
  handler(playerIndex, data);
@@ -554,6 +653,14 @@ class ScreenImpl {
554
653
  return unsubscribe;
555
654
  }
556
655
  off(event, handler) {
656
+ if (typeof event === "string" && event.startsWith("$")) {
657
+ if (!handler) {
658
+ this._lifecycleListeners.delete(event);
659
+ } else {
660
+ this._lifecycleListeners.get(event)?.delete(handler);
661
+ }
662
+ return;
663
+ }
557
664
  if (!handler) {
558
665
  this.eventHandlers.delete(event);
559
666
  this.transport?.off(event);
@@ -581,6 +688,21 @@ class ScreenImpl {
581
688
  );
582
689
  }
583
690
  }
691
+ removeAllListeners(event) {
692
+ if (event) {
693
+ this.eventHandlers.delete(event);
694
+ this.transport?.off(event);
695
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
696
+ for (const [key, val] of this.handlerToTransport) {
697
+ if (val.event === event) this.handlerToTransport.delete(key);
698
+ }
699
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
700
+ } else {
701
+ for (const evt of [...this.eventHandlers.keys()]) {
702
+ this.removeAllListeners(evt);
703
+ }
704
+ }
705
+ }
584
706
  // ---------------------------------------------------------------------------
585
707
  // Utilities
586
708
  // ---------------------------------------------------------------------------
@@ -590,9 +712,6 @@ class ScreenImpl {
590
712
  getControllerCount() {
591
713
  return this._controllers.filter((c) => c.connected).length;
592
714
  }
593
- hasAnyConnectedControllers() {
594
- return this._controllers.some((c) => c.connected);
595
- }
596
715
  // ---------------------------------------------------------------------------
597
716
  // Cleanup
598
717
  // ---------------------------------------------------------------------------
@@ -616,14 +735,11 @@ class ScreenImpl {
616
735
  this.eventHandlers.clear();
617
736
  this.handlerToTransport.clear();
618
737
  this._pendingHandlers = [];
619
- this._onAllReadyCallbacks.clear();
620
- this._onControllerJoinCallbacks.clear();
621
- this._onControllerLeaveCallbacks.clear();
622
- this._onControllerDisconnectCallbacks.clear();
623
- this._onControllerReconnectCallbacks.clear();
624
- this._onCharacterUpdatedCallbacks.clear();
625
- this._onRateLimitedCallbacks.clear();
626
- this._onErrorCallbacks.clear();
738
+ this._lifecycleListeners.clear();
739
+ this._customStates.clear();
740
+ this._stateChangeListeners.clear();
741
+ this._isConnected = false;
742
+ this._outboundBuffer = [];
627
743
  if (this.transport instanceof PostMessageTransport.PostMessageTransport) {
628
744
  this.transport.destroy();
629
745
  }
@@ -639,8 +755,8 @@ class ScreenImpl {
639
755
  handleError(error) {
640
756
  this.logger.warn(`Error in handler: ${error.message}`);
641
757
  const smoreError = error.toSmoreError();
642
- if (this._onErrorCallbacks.size > 0) {
643
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
758
+ if (this._hasLifecycleListeners("$error")) {
759
+ this._emitLifecycle("$error", smoreError);
644
760
  } else {
645
761
  this.logger.error(error.message, error.details);
646
762
  }