@smoregg/sdk 2.0.0 → 2.1.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 +193 -115
  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 +19 -2
  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 +185 -130
  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 +125 -74
  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 +195 -117
  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 +18 -3
  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 +187 -132
  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 +125 -74
  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 +10 -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 +63 -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 +4 -0
  57. package/dist/types/transport/protocol.d.ts.map +1 -1
  58. package/dist/types/types.d.ts +215 -347
  59. package/dist/types/types.d.ts.map +1 -1
  60. package/dist/umd/smore-sdk.umd.js +442 -787
  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,6 +5,7 @@
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
7
  const BRIDGE_MSG_PREFIX = "_bridge:";
8
+ const PROTOCOL_VERSION = 1;
8
9
  function isBridgeMessage(data) {
9
10
  return data !== null && typeof data === "object" && "type" in data && typeof data.type === "string" && data.type.startsWith(BRIDGE_MSG_PREFIX);
10
11
  }
@@ -29,6 +30,8 @@
29
30
  }
30
31
 
31
32
  class PostMessageTransport {
33
+ static ACK_TIMEOUT = 3e4;
34
+ // 30 seconds
32
35
  handlers = /* @__PURE__ */ new Map();
33
36
  ackCallbacks = /* @__PURE__ */ new Map();
34
37
  ackCounter = 0;
@@ -47,11 +50,21 @@
47
50
  const callback = args[0];
48
51
  ackId = `ack_${++this.ackCounter}`;
49
52
  this.ackCallbacks.set(ackId, callback);
53
+ setTimeout(() => {
54
+ if (this.ackCallbacks.has(ackId)) {
55
+ this.ackCallbacks.delete(ackId);
56
+ }
57
+ }, PostMessageTransport.ACK_TIMEOUT);
50
58
  } else if (args.length >= 2 && typeof args[args.length - 1] === "function") {
51
59
  data = args[0];
52
60
  const callback = args[args.length - 1];
53
61
  ackId = `ack_${++this.ackCounter}`;
54
62
  this.ackCallbacks.set(ackId, callback);
63
+ setTimeout(() => {
64
+ if (this.ackCallbacks.has(ackId)) {
65
+ this.ackCallbacks.delete(ackId);
66
+ }
67
+ }, PostMessageTransport.ACK_TIMEOUT);
55
68
  }
56
69
  window.parent.postMessage(
57
70
  { type: "_bridge:emit", payload: { event, data, ackId } },
@@ -115,6 +128,7 @@
115
128
  super(message, options?.cause ? { cause: options.cause } : void 0);
116
129
  this.name = "SmoreSDKError";
117
130
  this.code = code;
131
+ this.cause = options?.cause;
118
132
  this.details = options?.details;
119
133
  const ErrorWithCapture = Error;
120
134
  if (typeof ErrorWithCapture.captureStackTrace === "function") {
@@ -134,8 +148,6 @@
134
148
  const SMORE_EVENTS = {
135
149
  // Game lifecycle
136
150
  GAME_OVER: "smore:game-over",
137
- RETURN_TO_LOBBY: "smore:return-to-lobby",
138
- // Used internally by platform, not handled by SDK
139
151
  // Player management
140
152
  PLAYER_JOINED: "smore:player-joined",
141
153
  PLAYER_LEFT: "smore:player-left",
@@ -148,6 +160,9 @@
148
160
  // Game ready sync
149
161
  GAME_READY: "smore:game-ready",
150
162
  ALL_READY: "smore:all-ready",
163
+ // Connection status (self)
164
+ SELF_DISCONNECTED: "smore:self-disconnected",
165
+ SELF_RECONNECTED: "smore:self-reconnected",
151
166
  // Send to specific player (internal use)
152
167
  SEND_TO_PLAYER: "smore:send-to-player"
153
168
  // Used internally by platform, not handled by SDK
@@ -176,6 +191,20 @@
176
191
  );
177
192
  }
178
193
  }
194
+ const SCREEN_LIFECYCLE_EVENTS = /* @__PURE__ */ new Set([
195
+ "$all-ready",
196
+ "$controller-join",
197
+ "$controller-leave",
198
+ "$controller-disconnect",
199
+ "$controller-reconnect",
200
+ "$character-updated",
201
+ "$error",
202
+ "$connection-change"
203
+ ]);
204
+ const CONTROLLER_LIFECYCLE_EVENTS = /* @__PURE__ */ new Set([
205
+ ...SCREEN_LIFECYCLE_EVENTS,
206
+ "$game-over"
207
+ ]);
179
208
 
180
209
  class DebugLogger {
181
210
  enabled;
@@ -248,12 +277,30 @@
248
277
  }
249
278
  }
250
279
 
251
- let globalConfig = {};
252
- function configure(config) {
253
- globalConfig = { ...globalConfig, ...config };
280
+ const MAX_PAYLOAD_SIZE = 65536;
281
+ function mapPlayerDTO(raw, fallbackIndex) {
282
+ return {
283
+ playerIndex: raw.playerIndex ?? fallbackIndex,
284
+ nickname: raw.nickname || raw.name || `Player ${(raw.playerIndex ?? fallbackIndex) + 1}`,
285
+ connected: raw.connected !== false,
286
+ appearance: raw.appearance ?? raw.character
287
+ };
254
288
  }
255
- function getGlobalConfig() {
256
- return globalConfig;
289
+ function validatePayloadSize(data) {
290
+ if (data === void 0 || data === null) return;
291
+ try {
292
+ const serialized = JSON.stringify(data);
293
+ const byteLength = new TextEncoder().encode(serialized).byteLength;
294
+ if (byteLength > MAX_PAYLOAD_SIZE) {
295
+ throw new SmoreSDKError(
296
+ "PAYLOAD_TOO_LARGE",
297
+ `Event payload exceeds maximum size of ${MAX_PAYLOAD_SIZE} bytes (got ${byteLength} bytes). The server will silently drop this event.`,
298
+ { details: { size: byteLength, limit: MAX_PAYLOAD_SIZE } }
299
+ );
300
+ }
301
+ } catch (err) {
302
+ if (err instanceof SmoreSDKError) throw err;
303
+ }
257
304
  }
258
305
 
259
306
  const DEFAULT_TIMEOUT$1 = 1e4;
@@ -285,17 +332,16 @@
285
332
  handlerToTransport = /* @__PURE__ */ new Map();
286
333
  // Pending handlers registered via on() before transport is ready
287
334
  _pendingHandlers = [];
288
- // Lifecycle callback arrays
289
- _onAllReadyCallbacks = /* @__PURE__ */ new Set();
290
- _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
291
- _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
292
- _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
293
- _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
294
- _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
295
- _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
296
- _onErrorCallbacks = /* @__PURE__ */ new Set();
335
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
336
+ _lifecycleListeners = /* @__PURE__ */ new Map();
337
+ // Outbound message buffer (messages sent before ready)
338
+ _outboundBuffer = [];
297
339
  // Whether all-ready has fired
298
340
  _allReadyFired = false;
341
+ // Self-connection awareness
342
+ _isConnected = false;
343
+ // Protocol versioning
344
+ _protocolVersion = PROTOCOL_VERSION;
299
345
  // Ready promise
300
346
  _readyResolve;
301
347
  _readyReject;
@@ -357,8 +403,17 @@
357
403
  this._readyReject(error);
358
404
  return;
359
405
  }
360
- this.transport = new PostMessageTransport(parentOrigin);
406
+ this.transport = this.config.transport ?? new PostMessageTransport(parentOrigin);
361
407
  this._roomCode = initData.roomCode;
408
+ const serverProtocolVersion = initData.protocolVersion;
409
+ if (serverProtocolVersion !== void 0) {
410
+ this._protocolVersion = serverProtocolVersion;
411
+ if (serverProtocolVersion !== PROTOCOL_VERSION) {
412
+ this.logger.warn(
413
+ `Protocol version mismatch: SDK v${PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
414
+ );
415
+ }
416
+ }
362
417
  this._controllers = this.mapControllersFromInit(initData.players);
363
418
  if (this._controllers.length === 0) {
364
419
  this.logger.warn("Screen initialized with zero controllers");
@@ -368,13 +423,28 @@
368
423
  this.setupUserEventHandler(event, handler);
369
424
  }
370
425
  this._pendingHandlers = [];
426
+ this._isConnected = true;
371
427
  this._isReady = true;
428
+ for (const buffered of this._outboundBuffer) {
429
+ try {
430
+ switch (buffered.method) {
431
+ case "broadcast":
432
+ this.broadcast(buffered.args[0], buffered.args[1]);
433
+ break;
434
+ case "sendToController":
435
+ this.sendToController(buffered.args[0], buffered.args[1], buffered.args[2]);
436
+ break;
437
+ }
438
+ } catch (err) {
439
+ this.handleError(err instanceof SmoreSDKError ? err : new SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
440
+ }
441
+ }
442
+ this._outboundBuffer = [];
372
443
  this.logger.lifecycle("Screen ready", {
373
444
  roomCode: this._roomCode,
374
445
  controllers: this._controllers.length
375
446
  });
376
- const autoReady = getGlobalConfig().autoReady ?? true;
377
- if (autoReady) {
447
+ if (this.config.autoReady !== false) {
378
448
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
379
449
  this.signalReady();
380
450
  }
@@ -392,13 +462,13 @@
392
462
  for (const nc of newControllers) {
393
463
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
394
464
  this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
395
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
465
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
396
466
  }
397
467
  }
398
468
  for (const oc of oldControllers) {
399
469
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
400
470
  this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
401
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
471
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
402
472
  }
403
473
  }
404
474
  }
@@ -408,18 +478,11 @@
408
478
  }
409
479
  };
410
480
  window.addEventListener("message", this.boundMessageHandler);
411
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
481
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: PROTOCOL_VERSION }, parentOrigin);
412
482
  this.logger.lifecycle("Sent _bridge:ready to parent");
413
483
  }
414
484
  mapControllersFromInit(players) {
415
- return players.map((p, index) => ({
416
- playerIndex: p.playerIndex ?? index,
417
- // Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
418
- nickname: p.nickname || p.name || `Player ${index + 1}`,
419
- connected: p.connected !== false,
420
- // Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
421
- appearance: p.appearance ?? p.character
422
- }));
485
+ return players.map((p, index) => mapPlayerDTO(p, index));
423
486
  }
424
487
  setupEventHandlers() {
425
488
  if (!this.transport) return;
@@ -427,16 +490,11 @@
427
490
  const payload = data;
428
491
  const playerData = payload?.player;
429
492
  if (playerData && typeof playerData.playerIndex === "number") {
430
- const controllerInfo = {
431
- playerIndex: playerData.playerIndex,
432
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
433
- connected: playerData.connected !== false,
434
- appearance: playerData.appearance ?? playerData.character
435
- };
493
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
436
494
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
437
495
  this._controllers = [...this._controllers, controllerInfo];
438
496
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
439
- this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
497
+ this._emitLifecycle("$controller-join", controllerInfo.playerIndex, controllerInfo);
440
498
  }
441
499
  });
442
500
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -446,7 +504,7 @@
446
504
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
447
505
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
448
506
  this.logger.lifecycle("Controller left", { playerIndex });
449
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
507
+ this._emitLifecycle("$controller-leave", playerIndex);
450
508
  }
451
509
  });
452
510
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -457,24 +515,19 @@
457
515
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
458
516
  );
459
517
  this.logger.lifecycle("Controller disconnected", { playerIndex });
460
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
518
+ this._emitLifecycle("$controller-disconnect", playerIndex);
461
519
  }
462
520
  });
463
521
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
464
522
  const payload = data;
465
523
  const playerData = payload?.player;
466
524
  if (playerData && typeof playerData.playerIndex === "number") {
467
- const controllerInfo = {
468
- playerIndex: playerData.playerIndex,
469
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
470
- connected: true,
471
- appearance: playerData.appearance ?? playerData.character
472
- };
525
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
473
526
  this._controllers = this._controllers.map(
474
527
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
475
528
  );
476
529
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
477
- this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
530
+ this._emitLifecycle("$controller-reconnect", controllerInfo.playerIndex, controllerInfo);
478
531
  }
479
532
  });
480
533
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
@@ -487,19 +540,32 @@
487
540
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
488
541
  );
489
542
  this.logger.lifecycle("Player character updated", { playerIndex: pi });
490
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
543
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
491
544
  }
492
545
  });
493
546
  this.registerTransportHandler(SMORE_EVENTS.RATE_LIMITED, (data) => {
494
547
  const payload = data;
495
- const event = payload?.event ?? "unknown";
496
- this.logger.warn(`Rate limited: ${event}`);
497
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
548
+ const eventName = payload?.event ?? "unknown";
549
+ this.handleError(
550
+ new SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
551
+ details: { event: eventName }
552
+ })
553
+ );
498
554
  });
499
555
  this.registerTransportHandler(SMORE_EVENTS.ALL_READY, () => {
500
556
  this.logger.lifecycle("All participants ready");
501
557
  this._allReadyFired = true;
502
- this._onAllReadyCallbacks.forEach((cb) => cb());
558
+ this._emitLifecycle("$all-ready");
559
+ });
560
+ this.registerTransportHandler(SMORE_EVENTS.SELF_DISCONNECTED, () => {
561
+ this._isConnected = false;
562
+ this.logger.lifecycle("Connection lost");
563
+ this._emitLifecycle("$connection-change", false);
564
+ });
565
+ this.registerTransportHandler(SMORE_EVENTS.SELF_RECONNECTED, () => {
566
+ this._isConnected = true;
567
+ this.logger.lifecycle("Connection restored");
568
+ this._emitLifecycle("$connection-change", true);
503
569
  });
504
570
  }
505
571
  /**
@@ -568,6 +634,45 @@
568
634
  get isDestroyed() {
569
635
  return this._isDestroyed;
570
636
  }
637
+ get isConnected() {
638
+ return this._isConnected;
639
+ }
640
+ get protocolVersion() {
641
+ return this._protocolVersion;
642
+ }
643
+ // ---------------------------------------------------------------------------
644
+ // Lifecycle Listener Helpers
645
+ // ---------------------------------------------------------------------------
646
+ _addLifecycleListener(event, listener) {
647
+ let set = this._lifecycleListeners.get(event);
648
+ if (!set) {
649
+ set = /* @__PURE__ */ new Set();
650
+ this._lifecycleListeners.set(event, set);
651
+ }
652
+ set.add(listener);
653
+ return () => {
654
+ set.delete(listener);
655
+ if (set.size === 0) this._lifecycleListeners.delete(event);
656
+ };
657
+ }
658
+ _emitLifecycle(event, ...args) {
659
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
660
+ try {
661
+ cb(...args);
662
+ } catch (err) {
663
+ this.handleError(
664
+ new SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
665
+ cause: err instanceof Error ? err : void 0,
666
+ details: { event }
667
+ })
668
+ );
669
+ }
670
+ });
671
+ }
672
+ _hasLifecycleListeners(event) {
673
+ const set = this._lifecycleListeners.get(event);
674
+ return set !== void 0 && set.size > 0;
675
+ }
571
676
  // ---------------------------------------------------------------------------
572
677
  // Lifecycle Methods
573
678
  // ---------------------------------------------------------------------------
@@ -575,52 +680,28 @@
575
680
  if (this._allReadyFired) {
576
681
  callback();
577
682
  }
578
- this._onAllReadyCallbacks.add(callback);
579
- return () => {
580
- this._onAllReadyCallbacks.delete(callback);
581
- };
683
+ return this._addLifecycleListener("$all-ready", callback);
582
684
  }
583
685
  onControllerJoin(callback) {
584
- this._onControllerJoinCallbacks.add(callback);
585
- return () => {
586
- this._onControllerJoinCallbacks.delete(callback);
587
- };
686
+ return this._addLifecycleListener("$controller-join", callback);
588
687
  }
589
688
  onControllerLeave(callback) {
590
- this._onControllerLeaveCallbacks.add(callback);
591
- return () => {
592
- this._onControllerLeaveCallbacks.delete(callback);
593
- };
689
+ return this._addLifecycleListener("$controller-leave", callback);
594
690
  }
595
691
  onControllerDisconnect(callback) {
596
- this._onControllerDisconnectCallbacks.add(callback);
597
- return () => {
598
- this._onControllerDisconnectCallbacks.delete(callback);
599
- };
692
+ return this._addLifecycleListener("$controller-disconnect", callback);
600
693
  }
601
694
  onControllerReconnect(callback) {
602
- this._onControllerReconnectCallbacks.add(callback);
603
- return () => {
604
- this._onControllerReconnectCallbacks.delete(callback);
605
- };
695
+ return this._addLifecycleListener("$controller-reconnect", callback);
606
696
  }
607
697
  onCharacterUpdated(callback) {
608
- this._onCharacterUpdatedCallbacks.add(callback);
609
- return () => {
610
- this._onCharacterUpdatedCallbacks.delete(callback);
611
- };
612
- }
613
- onRateLimited(callback) {
614
- this._onRateLimitedCallbacks.add(callback);
615
- return () => {
616
- this._onRateLimitedCallbacks.delete(callback);
617
- };
698
+ return this._addLifecycleListener("$character-updated", callback);
618
699
  }
619
700
  onError(callback) {
620
- this._onErrorCallbacks.add(callback);
621
- return () => {
622
- this._onErrorCallbacks.delete(callback);
623
- };
701
+ return this._addLifecycleListener("$error", callback);
702
+ }
703
+ onConnectionChange(callback) {
704
+ return this._addLifecycleListener("$connection-change", callback);
624
705
  }
625
706
  // ---------------------------------------------------------------------------
626
707
  // Communication Methods
@@ -629,7 +710,6 @@
629
710
  * Send type-safe events to all controllers.
630
711
  *
631
712
  * Uses EventMap generic for compile-time type checking of event names and data payloads.
632
- * Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
633
713
  *
634
714
  * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
635
715
  * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
@@ -638,31 +718,18 @@
638
718
  *
639
719
  * Warning: Avoid sending primitive values directly (string, number, boolean).
640
720
  * Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
641
- *
642
- * @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
643
721
  */
644
722
  broadcast(event, data) {
645
- this.ensureReady("broadcast");
646
- validateEventName(event);
647
- this.logger.send(event, data);
648
- this.transport.emit(event, data);
649
- }
650
- /**
651
- * Send events to all controllers without TypeScript type checking.
652
- *
653
- * Bypasses EventMap generic type checks at compile time.
654
- * Runtime behavior is identical to broadcast - both call validateEventName at runtime.
655
- *
656
- * Use this when you need dynamic event names or when working without a predefined EventMap.
657
- *
658
- * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
659
- * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
660
- *
661
- * @see broadcast for type-safe version using EventMap generic
662
- */
663
- broadcastRaw(event, data) {
664
- this.ensureReady("broadcastRaw");
723
+ if (this._isDestroyed) {
724
+ throw new SmoreSDKError("DESTROYED", "Cannot call broadcast() after destroy()");
725
+ }
726
+ if (!this._isReady || !this.transport) {
727
+ this._outboundBuffer.push({ method: "broadcast", args: [event, data] });
728
+ this.logger.debug(`Buffered broadcast "${event}" (screen not ready yet)`);
729
+ return;
730
+ }
665
731
  validateEventName(event);
732
+ validatePayloadSize(data);
666
733
  this.logger.send(event, data);
667
734
  this.transport.emit(event, data);
668
735
  }
@@ -681,24 +748,17 @@
681
748
  * @param data - Event data payload
682
749
  */
683
750
  sendToController(playerIndex, event, data) {
684
- this.ensureReady("sendToController");
685
- validateEventName(event);
686
- validatePlayerIndex(playerIndex, this._controllers);
687
- if (data && typeof data === "object" && "targetPlayerIndex" in data) {
688
- this.logger.warn(
689
- `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
690
- );
751
+ if (this._isDestroyed) {
752
+ throw new SmoreSDKError("DESTROYED", "Cannot call sendToController() after destroy()");
753
+ }
754
+ if (!this._isReady || !this.transport) {
755
+ this._outboundBuffer.push({ method: "sendToController", args: [playerIndex, event, data] });
756
+ this.logger.debug(`Buffered sendToController "${event}" -> Player ${playerIndex} (screen not ready yet)`);
757
+ return;
691
758
  }
692
- this.logger.send(`${event} -> Player ${playerIndex}`, data);
693
- this.transport.emit(event, {
694
- targetPlayerIndex: playerIndex,
695
- ...data && typeof data === "object" ? data : { data }
696
- });
697
- }
698
- sendToControllerRaw(playerIndex, event, data) {
699
- this.ensureReady("sendToControllerRaw");
700
759
  validateEventName(event);
701
760
  validatePlayerIndex(playerIndex, this._controllers);
761
+ validatePayloadSize(data);
702
762
  if (data && typeof data === "object" && "targetPlayerIndex" in data) {
703
763
  this.logger.warn(
704
764
  `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
@@ -733,6 +793,16 @@
733
793
  * are queued and activated when the transport becomes available.
734
794
  */
735
795
  on(event, handler) {
796
+ if (typeof event === "string" && event.startsWith("$")) {
797
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
798
+ if (!validEvents.has(event)) {
799
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
800
+ }
801
+ if (event === "$all-ready" && this._allReadyFired) {
802
+ handler();
803
+ }
804
+ return this._addLifecycleListener(event, handler);
805
+ }
736
806
  validateEventName(event);
737
807
  let handlers = this.eventHandlers.get(event);
738
808
  if (!handlers) {
@@ -795,6 +865,23 @@
795
865
  * @returns Unsubscribe function to remove the handler before it fires
796
866
  */
797
867
  once(event, handler) {
868
+ if (typeof event === "string" && event.startsWith("$")) {
869
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
870
+ if (!validEvents.has(event)) {
871
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
872
+ }
873
+ if (event === "$all-ready" && this._allReadyFired) {
874
+ handler();
875
+ return () => {
876
+ };
877
+ }
878
+ const wrapper = (...args) => {
879
+ unsub();
880
+ handler(...args);
881
+ };
882
+ const unsub = this._addLifecycleListener(event, wrapper);
883
+ return unsub;
884
+ }
798
885
  const wrappedHandler = (playerIndex, data) => {
799
886
  unsubscribe();
800
887
  handler(playerIndex, data);
@@ -803,6 +890,14 @@
803
890
  return unsubscribe;
804
891
  }
805
892
  off(event, handler) {
893
+ if (typeof event === "string" && event.startsWith("$")) {
894
+ if (!handler) {
895
+ this._lifecycleListeners.delete(event);
896
+ } else {
897
+ this._lifecycleListeners.get(event)?.delete(handler);
898
+ }
899
+ return;
900
+ }
806
901
  if (!handler) {
807
902
  this.eventHandlers.delete(event);
808
903
  this.transport?.off(event);
@@ -830,6 +925,21 @@
830
925
  );
831
926
  }
832
927
  }
928
+ removeAllListeners(event) {
929
+ if (event) {
930
+ this.eventHandlers.delete(event);
931
+ this.transport?.off(event);
932
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
933
+ for (const [key, val] of this.handlerToTransport) {
934
+ if (val.event === event) this.handlerToTransport.delete(key);
935
+ }
936
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
937
+ } else {
938
+ for (const evt of [...this.eventHandlers.keys()]) {
939
+ this.removeAllListeners(evt);
940
+ }
941
+ }
942
+ }
833
943
  // ---------------------------------------------------------------------------
834
944
  // Utilities
835
945
  // ---------------------------------------------------------------------------
@@ -839,9 +949,6 @@
839
949
  getControllerCount() {
840
950
  return this._controllers.filter((c) => c.connected).length;
841
951
  }
842
- hasAnyConnectedControllers() {
843
- return this._controllers.some((c) => c.connected);
844
- }
845
952
  // ---------------------------------------------------------------------------
846
953
  // Cleanup
847
954
  // ---------------------------------------------------------------------------
@@ -865,14 +972,9 @@
865
972
  this.eventHandlers.clear();
866
973
  this.handlerToTransport.clear();
867
974
  this._pendingHandlers = [];
868
- this._onAllReadyCallbacks.clear();
869
- this._onControllerJoinCallbacks.clear();
870
- this._onControllerLeaveCallbacks.clear();
871
- this._onControllerDisconnectCallbacks.clear();
872
- this._onControllerReconnectCallbacks.clear();
873
- this._onCharacterUpdatedCallbacks.clear();
874
- this._onRateLimitedCallbacks.clear();
875
- this._onErrorCallbacks.clear();
975
+ this._lifecycleListeners.clear();
976
+ this._isConnected = false;
977
+ this._outboundBuffer = [];
876
978
  if (this.transport instanceof PostMessageTransport) {
877
979
  this.transport.destroy();
878
980
  }
@@ -888,8 +990,8 @@
888
990
  handleError(error) {
889
991
  this.logger.warn(`Error in handler: ${error.message}`);
890
992
  const smoreError = error.toSmoreError();
891
- if (this._onErrorCallbacks.size > 0) {
892
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
993
+ if (this._hasLifecycleListeners("$error")) {
994
+ this._emitLifecycle("$error", smoreError);
893
995
  } else {
894
996
  this.logger.error(error.message, error.details);
895
997
  }
@@ -921,7 +1023,7 @@
921
1023
  config;
922
1024
  logger;
923
1025
  _roomCode = "";
924
- _myIndex = -1;
1026
+ _myPlayerIndex = -1;
925
1027
  _isReady = false;
926
1028
  _isDestroyed = false;
927
1029
  _initTimeoutId = null;
@@ -933,17 +1035,16 @@
933
1035
  _controllers = [];
934
1036
  // Pending handlers registered via on() before transport is ready
935
1037
  _pendingHandlers = [];
936
- // Lifecycle callback arrays
937
- _onAllReadyCallbacks = /* @__PURE__ */ new Set();
938
- _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
939
- _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
940
- _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
941
- _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
942
- _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
943
- _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
944
- _onErrorCallbacks = /* @__PURE__ */ new Set();
1038
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
1039
+ _lifecycleListeners = /* @__PURE__ */ new Map();
1040
+ // Outbound message buffer (messages sent before ready)
1041
+ _outboundBuffer = [];
945
1042
  // Whether all-ready has fired
946
1043
  _allReadyFired = false;
1044
+ // Self-connection awareness
1045
+ _isConnected = false;
1046
+ // Protocol versioning
1047
+ _protocolVersion = PROTOCOL_VERSION;
947
1048
  // Ready promise
948
1049
  _readyResolve;
949
1050
  _readyReject;
@@ -960,8 +1061,8 @@
960
1061
  // ---------------------------------------------------------------------------
961
1062
  // Properties (readonly)
962
1063
  // ---------------------------------------------------------------------------
963
- get myIndex() {
964
- return this._myIndex;
1064
+ get myPlayerIndex() {
1065
+ return this._myPlayerIndex;
965
1066
  }
966
1067
  get roomCode() {
967
1068
  return this._roomCode;
@@ -972,6 +1073,12 @@
972
1073
  get isDestroyed() {
973
1074
  return this._isDestroyed;
974
1075
  }
1076
+ get isConnected() {
1077
+ return this._isConnected;
1078
+ }
1079
+ get protocolVersion() {
1080
+ return this._protocolVersion;
1081
+ }
975
1082
  /**
976
1083
  * Read-only list of all known controllers (players) in the room.
977
1084
  * Returns full ControllerInfo including playerIndex, nickname, connected status, and appearance.
@@ -988,6 +1095,9 @@
988
1095
  getControllerCount() {
989
1096
  return this._controllers.filter((c) => c.connected).length;
990
1097
  }
1098
+ getController(playerIndex) {
1099
+ return this._controllers.find((c) => c.playerIndex === playerIndex);
1100
+ }
991
1101
  // ---------------------------------------------------------------------------
992
1102
  // Initialization
993
1103
  // ---------------------------------------------------------------------------
@@ -1018,7 +1128,7 @@
1018
1128
  };
1019
1129
  window.addEventListener("message", this.boundMessageHandler);
1020
1130
  this.logger.lifecycle("Sending _bridge:ready to parent");
1021
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
1131
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: PROTOCOL_VERSION }, parentOrigin);
1022
1132
  }
1023
1133
  handleInit(msg, parentOrigin) {
1024
1134
  const initPayload = msg.payload;
@@ -1057,28 +1167,44 @@
1057
1167
  this._readyReject(error);
1058
1168
  return;
1059
1169
  }
1060
- this.transport = new PostMessageTransport(parentOrigin);
1170
+ this.transport = this.config.transport ?? new PostMessageTransport(parentOrigin);
1061
1171
  this._roomCode = initData.roomCode;
1062
- this._myIndex = initData.myIndex;
1172
+ const serverProtocolVersion = initData.protocolVersion;
1173
+ if (serverProtocolVersion !== void 0) {
1174
+ this._protocolVersion = serverProtocolVersion;
1175
+ if (serverProtocolVersion !== PROTOCOL_VERSION) {
1176
+ this.logger.warn(
1177
+ `Protocol version mismatch: SDK v${PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
1178
+ );
1179
+ }
1180
+ }
1181
+ this._myPlayerIndex = initData.myIndex;
1063
1182
  const initPlayers = initData.players;
1064
- this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p) => ({
1065
- playerIndex: p.playerIndex,
1066
- nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
1067
- connected: p.connected !== false,
1068
- appearance: p.appearance ?? p.character
1069
- }));
1183
+ this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p, index) => mapPlayerDTO(p, index));
1070
1184
  this.setupEventHandlers();
1071
1185
  for (const { event, handler } of this._pendingHandlers) {
1072
1186
  this.setupUserEventHandler(event, handler);
1073
1187
  }
1074
1188
  this._pendingHandlers = [];
1189
+ this._isConnected = true;
1075
1190
  this._isReady = true;
1191
+ for (const buffered of this._outboundBuffer) {
1192
+ try {
1193
+ switch (buffered.method) {
1194
+ case "send":
1195
+ this.send(buffered.args[0], buffered.args[1]);
1196
+ break;
1197
+ }
1198
+ } catch (err) {
1199
+ this.handleError(err instanceof SmoreSDKError ? err : new SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
1200
+ }
1201
+ }
1202
+ this._outboundBuffer = [];
1076
1203
  this.logger.lifecycle("Controller ready", {
1077
1204
  roomCode: this._roomCode,
1078
- myIndex: this._myIndex
1205
+ myIndex: this._myPlayerIndex
1079
1206
  });
1080
- const autoReady = getGlobalConfig().autoReady ?? true;
1081
- if (autoReady) {
1207
+ if (this.config.autoReady !== false) {
1082
1208
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
1083
1209
  this.signalReady();
1084
1210
  }
@@ -1093,21 +1219,16 @@
1093
1219
  this.logger.debug("Received _bridge:update", updateData);
1094
1220
  if (updateData.players && Array.isArray(updateData.players)) {
1095
1221
  const players = updateData.players;
1096
- const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p) => ({
1097
- playerIndex: p.playerIndex,
1098
- nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
1099
- connected: p.connected !== false,
1100
- appearance: p.appearance ?? p.character
1101
- }));
1222
+ const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p, index) => mapPlayerDTO(p, index));
1102
1223
  const oldControllers = this._controllers;
1103
1224
  for (const nc of newControllers) {
1104
1225
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
1105
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
1226
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
1106
1227
  }
1107
1228
  }
1108
1229
  for (const oc of oldControllers) {
1109
1230
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
1110
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
1231
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
1111
1232
  }
1112
1233
  }
1113
1234
  for (const nc of newControllers) {
@@ -1115,11 +1236,11 @@
1115
1236
  if (oc) {
1116
1237
  if (oc.connected && !nc.connected) {
1117
1238
  this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
1118
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(nc.playerIndex));
1239
+ this._emitLifecycle("$controller-disconnect", nc.playerIndex);
1119
1240
  }
1120
1241
  if (!oc.connected && nc.connected) {
1121
1242
  this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
1122
- this._onControllerReconnectCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
1243
+ this._emitLifecycle("$controller-reconnect", nc.playerIndex, nc);
1123
1244
  }
1124
1245
  }
1125
1246
  }
@@ -1134,19 +1255,10 @@
1134
1255
  const playerIndex = playerInfo?.playerIndex ?? data.playerIndex;
1135
1256
  if (playerIndex !== void 0) {
1136
1257
  if (this._controllers.some((c) => c.playerIndex === playerIndex)) return;
1137
- const controllerInfo = playerInfo ? {
1138
- playerIndex,
1139
- nickname: playerInfo.nickname || playerInfo.name || `Player ${playerIndex + 1}`,
1140
- connected: playerInfo.connected !== false,
1141
- appearance: playerInfo.appearance ?? playerInfo.character
1142
- } : {
1143
- playerIndex,
1144
- nickname: `Player ${playerIndex + 1}`,
1145
- connected: true
1146
- };
1258
+ const controllerInfo = playerInfo ? mapPlayerDTO(playerInfo, playerIndex) : mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
1147
1259
  this._controllers = [...this._controllers, controllerInfo];
1148
1260
  this.logger.debug("Player joined", { playerIndex });
1149
- this._onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
1261
+ this._emitLifecycle("$controller-join", playerIndex, controllerInfo);
1150
1262
  }
1151
1263
  });
1152
1264
  this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
@@ -1156,7 +1268,7 @@
1156
1268
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
1157
1269
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
1158
1270
  this.logger.debug("Player left", { playerIndex });
1159
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
1271
+ this._emitLifecycle("$controller-leave", playerIndex);
1160
1272
  }
1161
1273
  });
1162
1274
  this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
@@ -1168,7 +1280,7 @@
1168
1280
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1169
1281
  );
1170
1282
  this.logger.debug("Player disconnected", { playerIndex });
1171
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
1283
+ this._emitLifecycle("$controller-disconnect", playerIndex);
1172
1284
  }
1173
1285
  });
1174
1286
  this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
@@ -1176,21 +1288,12 @@
1176
1288
  const playerData = data.player;
1177
1289
  const playerIndex = playerData?.playerIndex ?? data.playerIndex;
1178
1290
  if (playerIndex !== void 0) {
1179
- const controllerInfo = playerData ? {
1180
- playerIndex,
1181
- nickname: playerData.nickname || playerData.name || `Player ${playerIndex + 1}`,
1182
- connected: true,
1183
- appearance: playerData.appearance ?? playerData.character
1184
- } : {
1185
- playerIndex,
1186
- nickname: `Player ${playerIndex + 1}`,
1187
- connected: true
1188
- };
1291
+ const controllerInfo = playerData ? mapPlayerDTO(playerData, playerIndex) : mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
1189
1292
  this._controllers = this._controllers.map(
1190
1293
  (c) => c.playerIndex === playerIndex ? controllerInfo : c
1191
1294
  );
1192
1295
  this.logger.debug("Player reconnected", { playerIndex });
1193
- this._onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
1296
+ this._emitLifecycle("$controller-reconnect", playerIndex, controllerInfo);
1194
1297
  }
1195
1298
  });
1196
1299
  this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
@@ -1203,19 +1306,37 @@
1203
1306
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
1204
1307
  );
1205
1308
  this.logger.debug("Player character updated", { playerIndex: pi });
1206
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
1309
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
1207
1310
  }
1208
1311
  });
1209
1312
  this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
1210
1313
  const data = raw;
1211
- const event = data?.event ?? "unknown";
1212
- this.logger.warn(`Rate limited: ${event}`);
1213
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
1314
+ const eventName = data?.event ?? "unknown";
1315
+ this.handleError(
1316
+ new SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
1317
+ details: { event: eventName }
1318
+ })
1319
+ );
1320
+ });
1321
+ this.registerHandler(SMORE_EVENTS.GAME_OVER, (raw) => {
1322
+ const data = raw;
1323
+ this.logger.lifecycle("Game over", data?.results);
1324
+ this._emitLifecycle("$game-over", data?.results);
1214
1325
  });
1215
1326
  this.registerHandler(SMORE_EVENTS.ALL_READY, () => {
1216
1327
  this.logger.lifecycle("All participants ready");
1217
1328
  this._allReadyFired = true;
1218
- this._onAllReadyCallbacks.forEach((cb) => cb());
1329
+ this._emitLifecycle("$all-ready");
1330
+ });
1331
+ this.registerHandler(SMORE_EVENTS.SELF_DISCONNECTED, () => {
1332
+ this._isConnected = false;
1333
+ this.logger.lifecycle("Connection lost");
1334
+ this._emitLifecycle("$connection-change", false);
1335
+ });
1336
+ this.registerHandler(SMORE_EVENTS.SELF_RECONNECTED, () => {
1337
+ this._isConnected = true;
1338
+ this.logger.lifecycle("Connection restored");
1339
+ this._emitLifecycle("$connection-change", true);
1219
1340
  });
1220
1341
  }
1221
1342
  /**
@@ -1254,58 +1375,70 @@
1254
1375
  this.registeredHandlers.push({ event, handler });
1255
1376
  }
1256
1377
  // ---------------------------------------------------------------------------
1378
+ // Lifecycle Listener Helpers
1379
+ // ---------------------------------------------------------------------------
1380
+ _addLifecycleListener(event, listener) {
1381
+ let set = this._lifecycleListeners.get(event);
1382
+ if (!set) {
1383
+ set = /* @__PURE__ */ new Set();
1384
+ this._lifecycleListeners.set(event, set);
1385
+ }
1386
+ set.add(listener);
1387
+ return () => {
1388
+ set.delete(listener);
1389
+ if (set.size === 0) this._lifecycleListeners.delete(event);
1390
+ };
1391
+ }
1392
+ _emitLifecycle(event, ...args) {
1393
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
1394
+ try {
1395
+ cb(...args);
1396
+ } catch (err) {
1397
+ this.handleError(
1398
+ new SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
1399
+ cause: err instanceof Error ? err : void 0,
1400
+ details: { event }
1401
+ })
1402
+ );
1403
+ }
1404
+ });
1405
+ }
1406
+ _hasLifecycleListeners(event) {
1407
+ const set = this._lifecycleListeners.get(event);
1408
+ return set !== void 0 && set.size > 0;
1409
+ }
1410
+ // ---------------------------------------------------------------------------
1257
1411
  // Lifecycle Methods
1258
1412
  // ---------------------------------------------------------------------------
1259
1413
  onAllReady(callback) {
1260
1414
  if (this._allReadyFired) {
1261
1415
  callback();
1262
1416
  }
1263
- this._onAllReadyCallbacks.add(callback);
1264
- return () => {
1265
- this._onAllReadyCallbacks.delete(callback);
1266
- };
1417
+ return this._addLifecycleListener("$all-ready", callback);
1267
1418
  }
1268
1419
  onControllerJoin(callback) {
1269
- this._onControllerJoinCallbacks.add(callback);
1270
- return () => {
1271
- this._onControllerJoinCallbacks.delete(callback);
1272
- };
1420
+ return this._addLifecycleListener("$controller-join", callback);
1273
1421
  }
1274
1422
  onControllerLeave(callback) {
1275
- this._onControllerLeaveCallbacks.add(callback);
1276
- return () => {
1277
- this._onControllerLeaveCallbacks.delete(callback);
1278
- };
1423
+ return this._addLifecycleListener("$controller-leave", callback);
1279
1424
  }
1280
1425
  onControllerDisconnect(callback) {
1281
- this._onControllerDisconnectCallbacks.add(callback);
1282
- return () => {
1283
- this._onControllerDisconnectCallbacks.delete(callback);
1284
- };
1426
+ return this._addLifecycleListener("$controller-disconnect", callback);
1285
1427
  }
1286
1428
  onControllerReconnect(callback) {
1287
- this._onControllerReconnectCallbacks.add(callback);
1288
- return () => {
1289
- this._onControllerReconnectCallbacks.delete(callback);
1290
- };
1429
+ return this._addLifecycleListener("$controller-reconnect", callback);
1291
1430
  }
1292
1431
  onCharacterUpdated(callback) {
1293
- this._onCharacterUpdatedCallbacks.add(callback);
1294
- return () => {
1295
- this._onCharacterUpdatedCallbacks.delete(callback);
1296
- };
1297
- }
1298
- onRateLimited(callback) {
1299
- this._onRateLimitedCallbacks.add(callback);
1300
- return () => {
1301
- this._onRateLimitedCallbacks.delete(callback);
1302
- };
1432
+ return this._addLifecycleListener("$character-updated", callback);
1303
1433
  }
1304
1434
  onError(callback) {
1305
- this._onErrorCallbacks.add(callback);
1306
- return () => {
1307
- this._onErrorCallbacks.delete(callback);
1308
- };
1435
+ return this._addLifecycleListener("$error", callback);
1436
+ }
1437
+ onConnectionChange(callback) {
1438
+ return this._addLifecycleListener("$connection-change", callback);
1439
+ }
1440
+ onGameOver(callback) {
1441
+ return this._addLifecycleListener("$game-over", callback);
1309
1442
  }
1310
1443
  // ---------------------------------------------------------------------------
1311
1444
  // Communication Methods
@@ -1321,8 +1454,16 @@
1321
1454
  * Use the onError callback or smore:rate-limited event to detect rate limiting.
1322
1455
  */
1323
1456
  send(event, data) {
1324
- this.ensureReady("send");
1457
+ if (this._isDestroyed) {
1458
+ throw new SmoreSDKError("DESTROYED", "Cannot call send() after destroy()");
1459
+ }
1460
+ if (!this._isReady || !this.transport) {
1461
+ this._outboundBuffer.push({ method: "send", args: [event, data] });
1462
+ this.logger.debug(`Buffered send "${event}" (controller not ready yet)`);
1463
+ return;
1464
+ }
1325
1465
  validateEventName(event);
1466
+ validatePayloadSize(data);
1326
1467
  if (typeof data !== "object" || data === null) {
1327
1468
  this.logger.warn(
1328
1469
  'Event data should be an object. Primitive values will be wrapped as { data: value } by the relay server. To avoid confusion, wrap explicitly: send("event", { value: 42 }) instead of send("event", 42).'
@@ -1331,12 +1472,6 @@
1331
1472
  this.logSend(event, data);
1332
1473
  this.transport.emit(event, data);
1333
1474
  }
1334
- sendRaw(event, data) {
1335
- this.ensureReady("sendRaw");
1336
- validateEventName(event);
1337
- this.logSend(event, data);
1338
- this.transport.emit(event, data);
1339
- }
1340
1475
  signalReady() {
1341
1476
  this.ensureReady("signalReady");
1342
1477
  this.logSend(SMORE_EVENTS.GAME_READY, {});
@@ -1358,6 +1493,16 @@
1358
1493
  * handler receives `(data)` -- targeted to this specific controller.
1359
1494
  */
1360
1495
  on(event, handler) {
1496
+ if (typeof event === "string" && event.startsWith("$")) {
1497
+ const validEvents = CONTROLLER_LIFECYCLE_EVENTS;
1498
+ if (!validEvents.has(event)) {
1499
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
1500
+ }
1501
+ if (event === "$all-ready" && this._allReadyFired) {
1502
+ handler();
1503
+ }
1504
+ return this._addLifecycleListener(event, handler);
1505
+ }
1361
1506
  validateEventName(event);
1362
1507
  let listeners = this.eventListeners.get(event);
1363
1508
  if (!listeners) {
@@ -1410,6 +1555,23 @@
1410
1555
  * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
1411
1556
  */
1412
1557
  once(event, handler) {
1558
+ if (typeof event === "string" && event.startsWith("$")) {
1559
+ const validEvents = CONTROLLER_LIFECYCLE_EVENTS;
1560
+ if (!validEvents.has(event)) {
1561
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
1562
+ }
1563
+ if (event === "$all-ready" && this._allReadyFired) {
1564
+ handler();
1565
+ return () => {
1566
+ };
1567
+ }
1568
+ const wrapper = (...args) => {
1569
+ unsub();
1570
+ handler(...args);
1571
+ };
1572
+ const unsub = this._addLifecycleListener(event, wrapper);
1573
+ return unsub;
1574
+ }
1413
1575
  const unsubscribe = this.on(event, ((data) => {
1414
1576
  unsubscribe();
1415
1577
  handler(data);
@@ -1417,6 +1579,14 @@
1417
1579
  return unsubscribe;
1418
1580
  }
1419
1581
  off(event, handler) {
1582
+ if (typeof event === "string" && event.startsWith("$")) {
1583
+ if (!handler) {
1584
+ this._lifecycleListeners.delete(event);
1585
+ } else {
1586
+ this._lifecycleListeners.get(event)?.delete(handler);
1587
+ }
1588
+ return;
1589
+ }
1420
1590
  if (!handler) {
1421
1591
  this.eventListeners.delete(event);
1422
1592
  this.transport?.off(event);
@@ -1444,6 +1614,21 @@
1444
1614
  );
1445
1615
  }
1446
1616
  }
1617
+ removeAllListeners(event) {
1618
+ if (event) {
1619
+ this.eventListeners.delete(event);
1620
+ this.transport?.off(event);
1621
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
1622
+ for (const [key, val] of this.handlerToTransport) {
1623
+ if (val.event === event) this.handlerToTransport.delete(key);
1624
+ }
1625
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
1626
+ } else {
1627
+ for (const evt of [...this.eventListeners.keys()]) {
1628
+ this.removeAllListeners(evt);
1629
+ }
1630
+ }
1631
+ }
1447
1632
  // ---------------------------------------------------------------------------
1448
1633
  // Cleanup
1449
1634
  // ---------------------------------------------------------------------------
@@ -1467,14 +1652,9 @@
1467
1652
  this.eventListeners.clear();
1468
1653
  this.handlerToTransport.clear();
1469
1654
  this._pendingHandlers = [];
1470
- this._onAllReadyCallbacks.clear();
1471
- this._onControllerJoinCallbacks.clear();
1472
- this._onControllerLeaveCallbacks.clear();
1473
- this._onControllerDisconnectCallbacks.clear();
1474
- this._onControllerReconnectCallbacks.clear();
1475
- this._onCharacterUpdatedCallbacks.clear();
1476
- this._onRateLimitedCallbacks.clear();
1477
- this._onErrorCallbacks.clear();
1655
+ this._lifecycleListeners.clear();
1656
+ this._isConnected = false;
1657
+ this._outboundBuffer = [];
1478
1658
  if (this.transport) {
1479
1659
  this.transport.destroy();
1480
1660
  this.transport = null;
@@ -1506,8 +1686,8 @@
1506
1686
  handleError(error) {
1507
1687
  this.logger.warn(`Error in handler: ${error.message}`);
1508
1688
  const smoreError = error.toSmoreError();
1509
- if (this._onErrorCallbacks.size > 0) {
1510
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
1689
+ if (this._hasLifecycleListeners("$error")) {
1690
+ this._emitLifecycle("$error", smoreError);
1511
1691
  } else {
1512
1692
  this.logger.error(error.message, error.details);
1513
1693
  }
@@ -1523,547 +1703,22 @@
1523
1703
  return new ControllerImpl(config ?? {});
1524
1704
  }
1525
1705
 
1526
- function createMockScreen(options = {}) {
1527
- const {
1528
- roomCode = "TEST",
1529
- controllers: initialControllers = [],
1530
- autoReady = true
1531
- } = options;
1532
- let _controllers = [...initialControllers];
1533
- let _isReady = false;
1534
- let _isDestroyed = false;
1535
- const listeners = /* @__PURE__ */ new Map();
1536
- const _onAllReadyCallbacks = /* @__PURE__ */ new Set();
1537
- const _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
1538
- const _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
1539
- const _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
1540
- const _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
1541
- const _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
1542
- const _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
1543
- const _onErrorCallbacks = /* @__PURE__ */ new Set();
1544
- let _allReadyFired = false;
1545
- let _readyResolve;
1546
- const _readyPromise = new Promise((resolve) => {
1547
- _readyResolve = resolve;
1548
- });
1549
- const broadcasts = [];
1550
- const sends = [];
1551
- const screen = {
1552
- // === Properties ===
1553
- get controllers() {
1554
- return [..._controllers];
1555
- },
1556
- get roomCode() {
1557
- return roomCode;
1558
- },
1559
- get isReady() {
1560
- return _isReady;
1561
- },
1562
- get isDestroyed() {
1563
- return _isDestroyed;
1564
- },
1565
- get ready() {
1566
- return _readyPromise;
1567
- },
1568
- // === Lifecycle Methods ===
1569
- onAllReady(callback) {
1570
- if (_allReadyFired) {
1571
- callback();
1572
- }
1573
- _onAllReadyCallbacks.add(callback);
1574
- return () => {
1575
- _onAllReadyCallbacks.delete(callback);
1576
- };
1577
- },
1578
- onControllerJoin(callback) {
1579
- _onControllerJoinCallbacks.add(callback);
1580
- return () => {
1581
- _onControllerJoinCallbacks.delete(callback);
1582
- };
1583
- },
1584
- onControllerLeave(callback) {
1585
- _onControllerLeaveCallbacks.add(callback);
1586
- return () => {
1587
- _onControllerLeaveCallbacks.delete(callback);
1588
- };
1589
- },
1590
- onControllerDisconnect(callback) {
1591
- _onControllerDisconnectCallbacks.add(callback);
1592
- return () => {
1593
- _onControllerDisconnectCallbacks.delete(callback);
1594
- };
1595
- },
1596
- onControllerReconnect(callback) {
1597
- _onControllerReconnectCallbacks.add(callback);
1598
- return () => {
1599
- _onControllerReconnectCallbacks.delete(callback);
1600
- };
1601
- },
1602
- onCharacterUpdated(callback) {
1603
- _onCharacterUpdatedCallbacks.add(callback);
1604
- return () => {
1605
- _onCharacterUpdatedCallbacks.delete(callback);
1606
- };
1607
- },
1608
- onRateLimited(callback) {
1609
- _onRateLimitedCallbacks.add(callback);
1610
- return () => {
1611
- _onRateLimitedCallbacks.delete(callback);
1612
- };
1613
- },
1614
- onError(callback) {
1615
- _onErrorCallbacks.add(callback);
1616
- return () => {
1617
- _onErrorCallbacks.delete(callback);
1618
- };
1619
- },
1620
- // === Communication Methods ===
1621
- broadcast(event, data) {
1622
- if (_isDestroyed) {
1623
- throw new Error("Cannot broadcast: screen is destroyed");
1624
- }
1625
- if (!_isReady) {
1626
- throw new Error("Cannot broadcast: screen is not ready");
1627
- }
1628
- validateEventName(event);
1629
- broadcasts.push({ event, data });
1630
- },
1631
- broadcastRaw(event, data) {
1632
- if (_isDestroyed) {
1633
- throw new Error("Cannot broadcast: screen is destroyed");
1634
- }
1635
- if (!_isReady) {
1636
- throw new Error("Cannot broadcast: screen is not ready");
1637
- }
1638
- validateEventName(event);
1639
- broadcasts.push({ event, data });
1640
- },
1641
- sendToController(playerIndex, event, data) {
1642
- if (_isDestroyed) {
1643
- throw new Error("Cannot send: screen is destroyed");
1644
- }
1645
- if (!_isReady) {
1646
- throw new Error("Cannot send: screen is not ready");
1647
- }
1648
- validateEventName(event);
1649
- if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1650
- throw new Error(`Invalid player index: ${playerIndex}`);
1651
- }
1652
- sends.push({ playerIndex, event, data });
1653
- },
1654
- sendToControllerRaw(playerIndex, event, data) {
1655
- if (_isDestroyed) {
1656
- throw new Error("Cannot send: screen is destroyed");
1657
- }
1658
- if (!_isReady) {
1659
- throw new Error("Cannot send: screen is not ready");
1660
- }
1661
- validateEventName(event);
1662
- if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1663
- throw new Error(`Invalid player index: ${playerIndex}`);
1664
- }
1665
- sends.push({ playerIndex, event, data });
1666
- },
1667
- // === Game Lifecycle ===
1668
- gameOver(results) {
1669
- if (_isDestroyed) {
1670
- throw new Error("Cannot call gameOver: screen is destroyed");
1671
- }
1672
- if (!_isReady) {
1673
- throw new Error("Cannot call gameOver: screen is not ready");
1674
- }
1675
- broadcasts.push({ event: "smore:game-over", data: { results } });
1676
- },
1677
- signalReady() {
1678
- if (_isDestroyed) {
1679
- throw new Error("Cannot call signalReady: screen is destroyed");
1680
- }
1681
- if (!_isReady) {
1682
- throw new Error("Cannot call signalReady: screen is not ready");
1683
- }
1684
- },
1685
- // === Event Subscription ===
1686
- on(event, handler) {
1687
- validateEventName(event);
1688
- const eventStr = event;
1689
- if (!listeners.has(eventStr)) {
1690
- listeners.set(eventStr, /* @__PURE__ */ new Set());
1691
- }
1692
- listeners.get(eventStr).add(handler);
1693
- return () => {
1694
- listeners.get(eventStr)?.delete(handler);
1695
- };
1696
- },
1697
- once(event, handler) {
1698
- validateEventName(event);
1699
- const wrapper = (playerIndex, data) => {
1700
- handler(playerIndex, data);
1701
- screen.off(event, wrapper);
1702
- };
1703
- return screen.on(event, wrapper);
1704
- },
1705
- off(event, handler) {
1706
- validateEventName(event);
1707
- const eventStr = event;
1708
- if (!handler) {
1709
- listeners.delete(eventStr);
1710
- } else {
1711
- listeners.get(eventStr)?.delete(handler);
1712
- }
1713
- },
1714
- // === Utilities ===
1715
- getController(playerIndex) {
1716
- return _controllers.find((c) => c.playerIndex === playerIndex);
1717
- },
1718
- getControllerCount() {
1719
- return _controllers.filter((c) => c.connected).length;
1720
- },
1721
- hasAnyConnectedControllers() {
1722
- return _controllers.some((c) => c.connected);
1723
- },
1724
- // === Cleanup ===
1725
- /**
1726
- * Note: destroy() clears recorded broadcast/event arrays. Call getBroadcasts() before destroy() if assertions are needed.
1727
- */
1728
- destroy() {
1729
- _isDestroyed = true;
1730
- listeners.clear();
1731
- broadcasts.length = 0;
1732
- sends.length = 0;
1733
- },
1734
- // === Mock-specific methods ===
1735
- simulateEvent(playerIndex, event, data) {
1736
- validateEventName(event);
1737
- const eventStr = event;
1738
- const handlers = listeners.get(eventStr);
1739
- if (handlers) {
1740
- handlers.forEach((handler) => {
1741
- handler(playerIndex, data);
1742
- });
1743
- }
1744
- },
1745
- simulateControllerJoin(info) {
1746
- _controllers.push(info);
1747
- _onControllerJoinCallbacks.forEach((cb) => cb(info.playerIndex, info));
1748
- },
1749
- simulateControllerLeave(playerIndex) {
1750
- _controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
1751
- _onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
1752
- },
1753
- /**
1754
- * Simulate a controller network disconnect (player still in room but unreachable).
1755
- */
1756
- simulateControllerDisconnect(playerIndex) {
1757
- const controller = _controllers.find((c) => c.playerIndex === playerIndex);
1758
- if (!controller) {
1759
- throw new Error(`Controller ${playerIndex} not found`);
1760
- }
1761
- _controllers = _controllers.map(
1762
- (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1763
- );
1764
- _onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
1765
- },
1766
- /**
1767
- * Simulate a controller network reconnect after disconnect.
1768
- */
1769
- simulateControllerReconnect(playerIndex) {
1770
- const controller = _controllers.find((c) => c.playerIndex === playerIndex);
1771
- if (!controller) {
1772
- throw new Error(`Controller ${playerIndex} not found`);
1773
- }
1774
- const reconnectedController = { ...controller, connected: true };
1775
- _controllers = _controllers.map(
1776
- (c) => c.playerIndex === playerIndex ? reconnectedController : c
1777
- );
1778
- _onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, reconnectedController));
1779
- },
1780
- simulateCharacterUpdate(playerIndex, appearance) {
1781
- const controller = _controllers.find((c) => c.playerIndex === playerIndex);
1782
- if (!controller) {
1783
- throw new Error(`Controller ${playerIndex} not found`);
1784
- }
1785
- _controllers = _controllers.map(
1786
- (c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
1787
- );
1788
- _onCharacterUpdatedCallbacks.forEach((cb) => cb(playerIndex, appearance));
1789
- },
1790
- simulateRateLimited(event) {
1791
- _onRateLimitedCallbacks.forEach((cb) => cb(event));
1792
- },
1793
- simulateAllReady() {
1794
- _allReadyFired = true;
1795
- _onAllReadyCallbacks.forEach((cb) => cb());
1796
- },
1797
- simulateError(error) {
1798
- _onErrorCallbacks.forEach((cb) => cb(error));
1799
- },
1800
- getBroadcasts() {
1801
- return [...broadcasts];
1802
- },
1803
- getSentToController(playerIndex) {
1804
- return sends.filter((s) => s.playerIndex === playerIndex).map((s) => ({ event: s.event, data: s.data }));
1805
- },
1806
- clearRecordedEvents() {
1807
- broadcasts.length = 0;
1808
- sends.length = 0;
1809
- },
1810
- triggerReady() {
1811
- _isReady = true;
1812
- _readyResolve();
1813
- }
1814
- };
1815
- if (autoReady) {
1816
- setTimeout(() => screen.triggerReady(), 0);
1817
- }
1818
- return screen;
1819
- }
1820
- function createMockController(options = {}) {
1821
- const {
1822
- roomCode = "TEST",
1823
- myIndex = 0,
1824
- autoReady = true
1825
- } = options;
1826
- let _isReady = false;
1827
- let _isDestroyed = false;
1828
- let _controllers = options.controllers ?? [];
1829
- const listeners = /* @__PURE__ */ new Map();
1830
- const _onAllReadyCallbacks = /* @__PURE__ */ new Set();
1831
- const _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
1832
- const _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
1833
- const _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
1834
- const _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
1835
- const _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
1836
- const _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
1837
- const _onErrorCallbacks = /* @__PURE__ */ new Set();
1838
- let _allReadyFired = false;
1839
- let _readyResolve;
1840
- const _readyPromise = new Promise((resolve) => {
1841
- _readyResolve = resolve;
1842
- });
1843
- const sentEvents = [];
1844
- const controller = {
1845
- // === Properties ===
1846
- get myIndex() {
1847
- return myIndex;
1848
- },
1849
- get roomCode() {
1850
- return roomCode;
1851
- },
1852
- get isReady() {
1853
- return _isReady;
1854
- },
1855
- get isDestroyed() {
1856
- return _isDestroyed;
1857
- },
1858
- get controllers() {
1859
- return [..._controllers];
1860
- },
1861
- get ready() {
1862
- return _readyPromise;
1863
- },
1864
- // === Lifecycle Methods ===
1865
- onAllReady(callback) {
1866
- if (_allReadyFired) {
1867
- callback();
1868
- }
1869
- _onAllReadyCallbacks.add(callback);
1870
- return () => {
1871
- _onAllReadyCallbacks.delete(callback);
1872
- };
1873
- },
1874
- onControllerJoin(callback) {
1875
- _onControllerJoinCallbacks.add(callback);
1876
- return () => {
1877
- _onControllerJoinCallbacks.delete(callback);
1878
- };
1879
- },
1880
- onControllerLeave(callback) {
1881
- _onControllerLeaveCallbacks.add(callback);
1882
- return () => {
1883
- _onControllerLeaveCallbacks.delete(callback);
1884
- };
1885
- },
1886
- onControllerDisconnect(callback) {
1887
- _onControllerDisconnectCallbacks.add(callback);
1888
- return () => {
1889
- _onControllerDisconnectCallbacks.delete(callback);
1890
- };
1891
- },
1892
- onControllerReconnect(callback) {
1893
- _onControllerReconnectCallbacks.add(callback);
1894
- return () => {
1895
- _onControllerReconnectCallbacks.delete(callback);
1896
- };
1897
- },
1898
- onCharacterUpdated(callback) {
1899
- _onCharacterUpdatedCallbacks.add(callback);
1900
- return () => {
1901
- _onCharacterUpdatedCallbacks.delete(callback);
1902
- };
1903
- },
1904
- onRateLimited(callback) {
1905
- _onRateLimitedCallbacks.add(callback);
1906
- return () => {
1907
- _onRateLimitedCallbacks.delete(callback);
1908
- };
1909
- },
1910
- onError(callback) {
1911
- _onErrorCallbacks.add(callback);
1912
- return () => {
1913
- _onErrorCallbacks.delete(callback);
1914
- };
1915
- },
1916
- getControllerCount() {
1917
- return _controllers.filter((c) => c.connected).length;
1918
- },
1919
- // === Communication Methods ===
1920
- send(event, data) {
1921
- if (_isDestroyed) {
1922
- throw new Error("Cannot send: controller is destroyed");
1923
- }
1924
- validateEventName(event);
1925
- sentEvents.push({ event, data });
1926
- },
1927
- sendRaw(event, data) {
1928
- if (_isDestroyed) {
1929
- throw new Error("Cannot send: controller is destroyed");
1930
- }
1931
- validateEventName(event);
1932
- sentEvents.push({ event, data });
1933
- },
1934
- signalReady() {
1935
- if (_isDestroyed) {
1936
- throw new Error("Cannot call signalReady: controller is destroyed");
1937
- }
1938
- if (!_isReady) {
1939
- throw new Error("Cannot call signalReady: controller is not ready");
1940
- }
1941
- },
1942
- // === Event Subscription ===
1943
- on(event, handler) {
1944
- validateEventName(event);
1945
- const eventStr = event;
1946
- if (!listeners.has(eventStr)) {
1947
- listeners.set(eventStr, /* @__PURE__ */ new Set());
1948
- }
1949
- listeners.get(eventStr).add(handler);
1950
- return () => {
1951
- listeners.get(eventStr)?.delete(handler);
1952
- };
1953
- },
1954
- once(event, handler) {
1955
- validateEventName(event);
1956
- const wrapper = (data) => {
1957
- handler(data);
1958
- controller.off(event, wrapper);
1959
- };
1960
- return controller.on(event, wrapper);
1961
- },
1962
- off(event, handler) {
1963
- validateEventName(event);
1964
- const eventStr = event;
1965
- if (!handler) {
1966
- listeners.delete(eventStr);
1967
- } else {
1968
- listeners.get(eventStr)?.delete(handler);
1969
- }
1970
- },
1971
- // === Cleanup ===
1972
- destroy() {
1973
- _isDestroyed = true;
1974
- listeners.clear();
1975
- sentEvents.length = 0;
1976
- },
1977
- // === Mock-specific methods ===
1978
- simulateEvent(event, data) {
1979
- validateEventName(event);
1980
- const eventStr = event;
1981
- const handlers = listeners.get(eventStr);
1982
- if (handlers) {
1983
- handlers.forEach((handler) => {
1984
- handler(data);
1985
- });
1986
- }
1987
- },
1988
- getSentEvents() {
1989
- return [...sentEvents];
1990
- },
1991
- clearRecordedEvents() {
1992
- sentEvents.length = 0;
1993
- },
1994
- triggerReady() {
1995
- _isReady = true;
1996
- _readyResolve();
1997
- },
1998
- /**
1999
- * Simulate a new player joining the room.
2000
- * Stores full ControllerInfo (nickname, appearance, etc.) for later retrieval.
2001
- */
2002
- simulatePlayerJoin(playerIndex, info) {
2003
- if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
2004
- _controllers = [..._controllers, { ...info, connected: info.connected ?? true }];
2005
- }
2006
- _onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, info));
2007
- },
2008
- /**
2009
- * Simulate a player leaving the room (fully removed).
2010
- */
2011
- simulatePlayerLeave(playerIndex) {
2012
- _controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
2013
- _onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
2014
- },
2015
- /**
2016
- * Simulate a player network disconnect (player still in room but unreachable).
2017
- */
2018
- simulatePlayerDisconnect(playerIndex) {
2019
- _controllers = _controllers.map(
2020
- (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
2021
- );
2022
- _onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
2023
- },
2024
- /**
2025
- * Simulate a player network reconnect after disconnect.
2026
- */
2027
- simulatePlayerReconnect(playerIndex, info) {
2028
- _controllers = _controllers.map(
2029
- (c) => c.playerIndex === playerIndex ? { ...info, connected: true } : c
2030
- );
2031
- _onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, info));
2032
- },
2033
- simulateCharacterUpdate(playerIndex, appearance) {
2034
- const ctrl = _controllers.find((c) => c.playerIndex === playerIndex);
2035
- if (!ctrl) {
2036
- throw new Error(`Controller ${playerIndex} not found`);
2037
- }
2038
- _controllers = _controllers.map(
2039
- (c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
2040
- );
2041
- _onCharacterUpdatedCallbacks.forEach((cb) => cb(playerIndex, appearance));
2042
- },
2043
- simulateRateLimited(event) {
2044
- _onRateLimitedCallbacks.forEach((cb) => cb(event));
2045
- },
2046
- simulateAllReady() {
2047
- _allReadyFired = true;
2048
- _onAllReadyCallbacks.forEach((cb) => cb());
2049
- },
2050
- simulateError(error) {
2051
- _onErrorCallbacks.forEach((cb) => cb(error));
2052
- }
2053
- };
2054
- if (autoReady) {
2055
- setTimeout(() => controller.triggerReady(), 0);
2056
- }
2057
- return controller;
2058
- }
1706
+ const LifecycleEvent = {
1707
+ ALL_READY: "$all-ready",
1708
+ CONTROLLER_JOIN: "$controller-join",
1709
+ CONTROLLER_LEAVE: "$controller-leave",
1710
+ CONTROLLER_DISCONNECT: "$controller-disconnect",
1711
+ CONTROLLER_RECONNECT: "$controller-reconnect",
1712
+ CHARACTER_UPDATED: "$character-updated",
1713
+ ERROR: "$error",
1714
+ GAME_OVER: "$game-over",
1715
+ CONNECTION_CHANGE: "$connection-change"
1716
+ };
2059
1717
 
1718
+ exports.LifecycleEvent = LifecycleEvent;
2060
1719
  exports.SmoreSDKError = SmoreSDKError;
2061
- exports.configure = configure;
2062
1720
  exports.createController = createController;
2063
- exports.createMockController = createMockController;
2064
- exports.createMockScreen = createMockScreen;
2065
1721
  exports.createScreen = createScreen;
2066
- exports.validateEventName = validateEventName;
2067
1722
 
2068
1723
  }));
2069
1724
  //# sourceMappingURL=smore-sdk.umd.js.map