@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,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,9 +160,17 @@
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
- SEND_TO_PLAYER: "smore:send-to-player"
167
+ SEND_TO_PLAYER: "smore:send-to-player",
153
168
  // Used internally by platform, not handled by SDK
169
+ // Custom state management
170
+ STATE_SET: "smore:set-custom-state",
171
+ STATE_CHANGED: "smore:custom-state-changed",
172
+ STATE_GET_ALL: "smore:get-custom-states",
173
+ STATE_ALL: "smore:custom-states"
154
174
  };
155
175
  new Set(
156
176
  Object.values(SMORE_EVENTS)
@@ -176,6 +196,21 @@
176
196
  );
177
197
  }
178
198
  }
199
+ const SCREEN_LIFECYCLE_EVENTS = /* @__PURE__ */ new Set([
200
+ "$all-ready",
201
+ "$controller-join",
202
+ "$controller-leave",
203
+ "$controller-disconnect",
204
+ "$controller-reconnect",
205
+ "$character-updated",
206
+ "$error",
207
+ "$connection-change"
208
+ ]);
209
+ const CONTROLLER_LIFECYCLE_EVENTS = /* @__PURE__ */ new Set([
210
+ ...SCREEN_LIFECYCLE_EVENTS,
211
+ "$game-over",
212
+ "$state-recovery"
213
+ ]);
179
214
 
180
215
  class DebugLogger {
181
216
  enabled;
@@ -248,12 +283,30 @@
248
283
  }
249
284
  }
250
285
 
251
- let globalConfig = {};
252
- function configure(config) {
253
- globalConfig = { ...globalConfig, ...config };
286
+ const MAX_PAYLOAD_SIZE = 65536;
287
+ function mapPlayerDTO(raw, fallbackIndex) {
288
+ return {
289
+ playerIndex: raw.playerIndex ?? fallbackIndex,
290
+ nickname: raw.nickname || raw.name || `Player ${(raw.playerIndex ?? fallbackIndex) + 1}`,
291
+ connected: raw.connected !== false,
292
+ appearance: raw.appearance ?? raw.character
293
+ };
254
294
  }
255
- function getGlobalConfig() {
256
- return globalConfig;
295
+ function validatePayloadSize(data) {
296
+ if (data === void 0 || data === null) return;
297
+ try {
298
+ const serialized = JSON.stringify(data);
299
+ const byteLength = new TextEncoder().encode(serialized).byteLength;
300
+ if (byteLength > MAX_PAYLOAD_SIZE) {
301
+ throw new SmoreSDKError(
302
+ "PAYLOAD_TOO_LARGE",
303
+ `Event payload exceeds maximum size of ${MAX_PAYLOAD_SIZE} bytes (got ${byteLength} bytes). The server will silently drop this event.`,
304
+ { details: { size: byteLength, limit: MAX_PAYLOAD_SIZE } }
305
+ );
306
+ }
307
+ } catch (err) {
308
+ if (err instanceof SmoreSDKError) throw err;
309
+ }
257
310
  }
258
311
 
259
312
  const DEFAULT_TIMEOUT$1 = 1e4;
@@ -285,17 +338,19 @@
285
338
  handlerToTransport = /* @__PURE__ */ new Map();
286
339
  // Pending handlers registered via on() before transport is ready
287
340
  _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();
341
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
342
+ _lifecycleListeners = /* @__PURE__ */ new Map();
343
+ // Outbound message buffer (messages sent before ready)
344
+ _outboundBuffer = [];
297
345
  // Whether all-ready has fired
298
346
  _allReadyFired = false;
347
+ // Self-connection awareness
348
+ _isConnected = false;
349
+ // Custom state management
350
+ _customStates = /* @__PURE__ */ new Map();
351
+ _stateChangeListeners = /* @__PURE__ */ new Set();
352
+ // Protocol versioning
353
+ _protocolVersion = PROTOCOL_VERSION;
299
354
  // Ready promise
300
355
  _readyResolve;
301
356
  _readyReject;
@@ -357,8 +412,17 @@
357
412
  this._readyReject(error);
358
413
  return;
359
414
  }
360
- this.transport = new PostMessageTransport(parentOrigin);
415
+ this.transport = this.config.transport ?? new PostMessageTransport(parentOrigin);
361
416
  this._roomCode = initData.roomCode;
417
+ const serverProtocolVersion = initData.protocolVersion;
418
+ if (serverProtocolVersion !== void 0) {
419
+ this._protocolVersion = serverProtocolVersion;
420
+ if (serverProtocolVersion !== PROTOCOL_VERSION) {
421
+ this.logger.warn(
422
+ `Protocol version mismatch: SDK v${PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
423
+ );
424
+ }
425
+ }
362
426
  this._controllers = this.mapControllersFromInit(initData.players);
363
427
  if (this._controllers.length === 0) {
364
428
  this.logger.warn("Screen initialized with zero controllers");
@@ -368,13 +432,28 @@
368
432
  this.setupUserEventHandler(event, handler);
369
433
  }
370
434
  this._pendingHandlers = [];
435
+ this._isConnected = true;
371
436
  this._isReady = true;
437
+ for (const buffered of this._outboundBuffer) {
438
+ try {
439
+ switch (buffered.method) {
440
+ case "broadcast":
441
+ this.broadcast(buffered.args[0], buffered.args[1]);
442
+ break;
443
+ case "sendToController":
444
+ this.sendToController(buffered.args[0], buffered.args[1], buffered.args[2]);
445
+ break;
446
+ }
447
+ } catch (err) {
448
+ this.handleError(err instanceof SmoreSDKError ? err : new SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
449
+ }
450
+ }
451
+ this._outboundBuffer = [];
372
452
  this.logger.lifecycle("Screen ready", {
373
453
  roomCode: this._roomCode,
374
454
  controllers: this._controllers.length
375
455
  });
376
- const autoReady = getGlobalConfig().autoReady ?? true;
377
- if (autoReady) {
456
+ if (this.config.autoReady !== false) {
378
457
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
379
458
  this.signalReady();
380
459
  }
@@ -392,13 +471,13 @@
392
471
  for (const nc of newControllers) {
393
472
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
394
473
  this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
395
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
474
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
396
475
  }
397
476
  }
398
477
  for (const oc of oldControllers) {
399
478
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
400
479
  this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
401
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
480
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
402
481
  }
403
482
  }
404
483
  }
@@ -408,18 +487,11 @@
408
487
  }
409
488
  };
410
489
  window.addEventListener("message", this.boundMessageHandler);
411
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
490
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: PROTOCOL_VERSION }, parentOrigin);
412
491
  this.logger.lifecycle("Sent _bridge:ready to parent");
413
492
  }
414
493
  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
- }));
494
+ return players.map((p, index) => mapPlayerDTO(p, index));
423
495
  }
424
496
  setupEventHandlers() {
425
497
  if (!this.transport) return;
@@ -427,16 +499,11 @@
427
499
  const payload = data;
428
500
  const playerData = payload?.player;
429
501
  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
- };
502
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
436
503
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
437
504
  this._controllers = [...this._controllers, controllerInfo];
438
505
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
439
- this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
506
+ this._emitLifecycle("$controller-join", controllerInfo.playerIndex, controllerInfo);
440
507
  }
441
508
  });
442
509
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -446,7 +513,7 @@
446
513
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
447
514
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
448
515
  this.logger.lifecycle("Controller left", { playerIndex });
449
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
516
+ this._emitLifecycle("$controller-leave", playerIndex);
450
517
  }
451
518
  });
452
519
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -457,24 +524,19 @@
457
524
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
458
525
  );
459
526
  this.logger.lifecycle("Controller disconnected", { playerIndex });
460
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
527
+ this._emitLifecycle("$controller-disconnect", playerIndex);
461
528
  }
462
529
  });
463
530
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
464
531
  const payload = data;
465
532
  const playerData = payload?.player;
466
533
  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
- };
534
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
473
535
  this._controllers = this._controllers.map(
474
536
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
475
537
  );
476
538
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
477
- this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
539
+ this._emitLifecycle("$controller-reconnect", controllerInfo.playerIndex, controllerInfo);
478
540
  }
479
541
  });
480
542
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
@@ -487,19 +549,69 @@
487
549
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
488
550
  );
489
551
  this.logger.lifecycle("Player character updated", { playerIndex: pi });
490
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
552
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
491
553
  }
492
554
  });
493
555
  this.registerTransportHandler(SMORE_EVENTS.RATE_LIMITED, (data) => {
494
556
  const payload = data;
495
- const event = payload?.event ?? "unknown";
496
- this.logger.warn(`Rate limited: ${event}`);
497
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
557
+ const eventName = payload?.event ?? "unknown";
558
+ this.handleError(
559
+ new SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
560
+ details: { event: eventName }
561
+ })
562
+ );
498
563
  });
499
564
  this.registerTransportHandler(SMORE_EVENTS.ALL_READY, () => {
500
565
  this.logger.lifecycle("All participants ready");
501
566
  this._allReadyFired = true;
502
- this._onAllReadyCallbacks.forEach((cb) => cb());
567
+ this._emitLifecycle("$all-ready");
568
+ });
569
+ this.registerTransportHandler(SMORE_EVENTS.SELF_DISCONNECTED, () => {
570
+ this._isConnected = false;
571
+ this.logger.lifecycle("Connection lost");
572
+ this._emitLifecycle("$connection-change", false);
573
+ });
574
+ this.registerTransportHandler(SMORE_EVENTS.SELF_RECONNECTED, () => {
575
+ this._isConnected = true;
576
+ this.logger.lifecycle("Connection restored");
577
+ this._emitLifecycle("$connection-change", true);
578
+ });
579
+ this.registerTransportHandler(SMORE_EVENTS.STATE_CHANGED, (raw) => {
580
+ const data = raw;
581
+ if (typeof data?.playerIndex === "number" && data.state) {
582
+ this._customStates.set(data.playerIndex, data.state);
583
+ this._stateChangeListeners.forEach((cb) => {
584
+ try {
585
+ cb(data.playerIndex, data.state);
586
+ } catch (err) {
587
+ this.handleError(
588
+ new SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
589
+ cause: err instanceof Error ? err : void 0
590
+ })
591
+ );
592
+ }
593
+ });
594
+ }
595
+ });
596
+ this.registerTransportHandler(SMORE_EVENTS.STATE_ALL, (raw) => {
597
+ const data = raw;
598
+ if (data?.states) {
599
+ for (const [key, value] of Object.entries(data.states)) {
600
+ const pi = Number(key);
601
+ this._customStates.set(pi, value);
602
+ this._stateChangeListeners.forEach((cb) => {
603
+ try {
604
+ cb(pi, value);
605
+ } catch (err) {
606
+ this.handleError(
607
+ new SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
608
+ cause: err instanceof Error ? err : void 0
609
+ })
610
+ );
611
+ }
612
+ });
613
+ }
614
+ }
503
615
  });
504
616
  }
505
617
  /**
@@ -568,6 +680,45 @@
568
680
  get isDestroyed() {
569
681
  return this._isDestroyed;
570
682
  }
683
+ get isConnected() {
684
+ return this._isConnected;
685
+ }
686
+ get protocolVersion() {
687
+ return this._protocolVersion;
688
+ }
689
+ // ---------------------------------------------------------------------------
690
+ // Lifecycle Listener Helpers
691
+ // ---------------------------------------------------------------------------
692
+ _addLifecycleListener(event, listener) {
693
+ let set = this._lifecycleListeners.get(event);
694
+ if (!set) {
695
+ set = /* @__PURE__ */ new Set();
696
+ this._lifecycleListeners.set(event, set);
697
+ }
698
+ set.add(listener);
699
+ return () => {
700
+ set.delete(listener);
701
+ if (set.size === 0) this._lifecycleListeners.delete(event);
702
+ };
703
+ }
704
+ _emitLifecycle(event, ...args) {
705
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
706
+ try {
707
+ cb(...args);
708
+ } catch (err) {
709
+ this.handleError(
710
+ new SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
711
+ cause: err instanceof Error ? err : void 0,
712
+ details: { event }
713
+ })
714
+ );
715
+ }
716
+ });
717
+ }
718
+ _hasLifecycleListeners(event) {
719
+ const set = this._lifecycleListeners.get(event);
720
+ return set !== void 0 && set.size > 0;
721
+ }
571
722
  // ---------------------------------------------------------------------------
572
723
  // Lifecycle Methods
573
724
  // ---------------------------------------------------------------------------
@@ -575,51 +726,46 @@
575
726
  if (this._allReadyFired) {
576
727
  callback();
577
728
  }
578
- this._onAllReadyCallbacks.add(callback);
579
- return () => {
580
- this._onAllReadyCallbacks.delete(callback);
581
- };
729
+ return this._addLifecycleListener("$all-ready", callback);
582
730
  }
583
731
  onControllerJoin(callback) {
584
- this._onControllerJoinCallbacks.add(callback);
585
- return () => {
586
- this._onControllerJoinCallbacks.delete(callback);
587
- };
732
+ return this._addLifecycleListener("$controller-join", callback);
588
733
  }
589
734
  onControllerLeave(callback) {
590
- this._onControllerLeaveCallbacks.add(callback);
591
- return () => {
592
- this._onControllerLeaveCallbacks.delete(callback);
593
- };
735
+ return this._addLifecycleListener("$controller-leave", callback);
594
736
  }
595
737
  onControllerDisconnect(callback) {
596
- this._onControllerDisconnectCallbacks.add(callback);
597
- return () => {
598
- this._onControllerDisconnectCallbacks.delete(callback);
599
- };
738
+ return this._addLifecycleListener("$controller-disconnect", callback);
600
739
  }
601
740
  onControllerReconnect(callback) {
602
- this._onControllerReconnectCallbacks.add(callback);
603
- return () => {
604
- this._onControllerReconnectCallbacks.delete(callback);
605
- };
741
+ return this._addLifecycleListener("$controller-reconnect", callback);
606
742
  }
607
743
  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
- };
744
+ return this._addLifecycleListener("$character-updated", callback);
618
745
  }
619
746
  onError(callback) {
620
- this._onErrorCallbacks.add(callback);
747
+ return this._addLifecycleListener("$error", callback);
748
+ }
749
+ onConnectionChange(callback) {
750
+ return this._addLifecycleListener("$connection-change", callback);
751
+ }
752
+ // ---------------------------------------------------------------------------
753
+ // Custom State Methods
754
+ // ---------------------------------------------------------------------------
755
+ getControllerState(playerIndex) {
756
+ return this._customStates.get(playerIndex);
757
+ }
758
+ getAllControllerStates() {
759
+ const result = {};
760
+ for (const [key, value] of this._customStates) {
761
+ result[key] = value;
762
+ }
763
+ return result;
764
+ }
765
+ onCustomStateChange(listener) {
766
+ this._stateChangeListeners.add(listener);
621
767
  return () => {
622
- this._onErrorCallbacks.delete(callback);
768
+ this._stateChangeListeners.delete(listener);
623
769
  };
624
770
  }
625
771
  // ---------------------------------------------------------------------------
@@ -629,7 +775,6 @@
629
775
  * Send type-safe events to all controllers.
630
776
  *
631
777
  * 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
778
  *
634
779
  * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
635
780
  * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
@@ -638,31 +783,18 @@
638
783
  *
639
784
  * Warning: Avoid sending primitive values directly (string, number, boolean).
640
785
  * 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
786
  */
644
787
  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");
788
+ if (this._isDestroyed) {
789
+ throw new SmoreSDKError("DESTROYED", "Cannot call broadcast() after destroy()");
790
+ }
791
+ if (!this._isReady || !this.transport) {
792
+ this._outboundBuffer.push({ method: "broadcast", args: [event, data] });
793
+ this.logger.debug(`Buffered broadcast "${event}" (screen not ready yet)`);
794
+ return;
795
+ }
665
796
  validateEventName(event);
797
+ validatePayloadSize(data);
666
798
  this.logger.send(event, data);
667
799
  this.transport.emit(event, data);
668
800
  }
@@ -681,24 +813,17 @@
681
813
  * @param data - Event data payload
682
814
  */
683
815
  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
- );
816
+ if (this._isDestroyed) {
817
+ throw new SmoreSDKError("DESTROYED", "Cannot call sendToController() after destroy()");
818
+ }
819
+ if (!this._isReady || !this.transport) {
820
+ this._outboundBuffer.push({ method: "sendToController", args: [playerIndex, event, data] });
821
+ this.logger.debug(`Buffered sendToController "${event}" -> Player ${playerIndex} (screen not ready yet)`);
822
+ return;
691
823
  }
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
824
  validateEventName(event);
701
825
  validatePlayerIndex(playerIndex, this._controllers);
826
+ validatePayloadSize(data);
702
827
  if (data && typeof data === "object" && "targetPlayerIndex" in data) {
703
828
  this.logger.warn(
704
829
  `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
@@ -733,6 +858,16 @@
733
858
  * are queued and activated when the transport becomes available.
734
859
  */
735
860
  on(event, handler) {
861
+ if (typeof event === "string" && event.startsWith("$")) {
862
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
863
+ if (!validEvents.has(event)) {
864
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
865
+ }
866
+ if (event === "$all-ready" && this._allReadyFired) {
867
+ handler();
868
+ }
869
+ return this._addLifecycleListener(event, handler);
870
+ }
736
871
  validateEventName(event);
737
872
  let handlers = this.eventHandlers.get(event);
738
873
  if (!handlers) {
@@ -795,6 +930,23 @@
795
930
  * @returns Unsubscribe function to remove the handler before it fires
796
931
  */
797
932
  once(event, handler) {
933
+ if (typeof event === "string" && event.startsWith("$")) {
934
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
935
+ if (!validEvents.has(event)) {
936
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
937
+ }
938
+ if (event === "$all-ready" && this._allReadyFired) {
939
+ handler();
940
+ return () => {
941
+ };
942
+ }
943
+ const wrapper = (...args) => {
944
+ unsub();
945
+ handler(...args);
946
+ };
947
+ const unsub = this._addLifecycleListener(event, wrapper);
948
+ return unsub;
949
+ }
798
950
  const wrappedHandler = (playerIndex, data) => {
799
951
  unsubscribe();
800
952
  handler(playerIndex, data);
@@ -803,6 +955,14 @@
803
955
  return unsubscribe;
804
956
  }
805
957
  off(event, handler) {
958
+ if (typeof event === "string" && event.startsWith("$")) {
959
+ if (!handler) {
960
+ this._lifecycleListeners.delete(event);
961
+ } else {
962
+ this._lifecycleListeners.get(event)?.delete(handler);
963
+ }
964
+ return;
965
+ }
806
966
  if (!handler) {
807
967
  this.eventHandlers.delete(event);
808
968
  this.transport?.off(event);
@@ -830,6 +990,21 @@
830
990
  );
831
991
  }
832
992
  }
993
+ removeAllListeners(event) {
994
+ if (event) {
995
+ this.eventHandlers.delete(event);
996
+ this.transport?.off(event);
997
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
998
+ for (const [key, val] of this.handlerToTransport) {
999
+ if (val.event === event) this.handlerToTransport.delete(key);
1000
+ }
1001
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
1002
+ } else {
1003
+ for (const evt of [...this.eventHandlers.keys()]) {
1004
+ this.removeAllListeners(evt);
1005
+ }
1006
+ }
1007
+ }
833
1008
  // ---------------------------------------------------------------------------
834
1009
  // Utilities
835
1010
  // ---------------------------------------------------------------------------
@@ -839,9 +1014,6 @@
839
1014
  getControllerCount() {
840
1015
  return this._controllers.filter((c) => c.connected).length;
841
1016
  }
842
- hasAnyConnectedControllers() {
843
- return this._controllers.some((c) => c.connected);
844
- }
845
1017
  // ---------------------------------------------------------------------------
846
1018
  // Cleanup
847
1019
  // ---------------------------------------------------------------------------
@@ -865,14 +1037,11 @@
865
1037
  this.eventHandlers.clear();
866
1038
  this.handlerToTransport.clear();
867
1039
  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();
1040
+ this._lifecycleListeners.clear();
1041
+ this._customStates.clear();
1042
+ this._stateChangeListeners.clear();
1043
+ this._isConnected = false;
1044
+ this._outboundBuffer = [];
876
1045
  if (this.transport instanceof PostMessageTransport) {
877
1046
  this.transport.destroy();
878
1047
  }
@@ -888,8 +1057,8 @@
888
1057
  handleError(error) {
889
1058
  this.logger.warn(`Error in handler: ${error.message}`);
890
1059
  const smoreError = error.toSmoreError();
891
- if (this._onErrorCallbacks.size > 0) {
892
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
1060
+ if (this._hasLifecycleListeners("$error")) {
1061
+ this._emitLifecycle("$error", smoreError);
893
1062
  } else {
894
1063
  this.logger.error(error.message, error.details);
895
1064
  }
@@ -921,7 +1090,7 @@
921
1090
  config;
922
1091
  logger;
923
1092
  _roomCode = "";
924
- _myIndex = -1;
1093
+ _myPlayerIndex = -1;
925
1094
  _isReady = false;
926
1095
  _isDestroyed = false;
927
1096
  _initTimeoutId = null;
@@ -931,19 +1100,20 @@
931
1100
  // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
932
1101
  handlerToTransport = /* @__PURE__ */ new Map();
933
1102
  _controllers = [];
1103
+ _customStates = /* @__PURE__ */ new Map();
1104
+ _stateChangeListeners = /* @__PURE__ */ new Set();
934
1105
  // Pending handlers registered via on() before transport is ready
935
1106
  _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();
1107
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
1108
+ _lifecycleListeners = /* @__PURE__ */ new Map();
1109
+ // Outbound message buffer (messages sent before ready)
1110
+ _outboundBuffer = [];
945
1111
  // Whether all-ready has fired
946
1112
  _allReadyFired = false;
1113
+ // Self-connection awareness
1114
+ _isConnected = false;
1115
+ // Protocol versioning
1116
+ _protocolVersion = PROTOCOL_VERSION;
947
1117
  // Ready promise
948
1118
  _readyResolve;
949
1119
  _readyReject;
@@ -960,8 +1130,8 @@
960
1130
  // ---------------------------------------------------------------------------
961
1131
  // Properties (readonly)
962
1132
  // ---------------------------------------------------------------------------
963
- get myIndex() {
964
- return this._myIndex;
1133
+ get myPlayerIndex() {
1134
+ return this._myPlayerIndex;
965
1135
  }
966
1136
  get roomCode() {
967
1137
  return this._roomCode;
@@ -972,6 +1142,12 @@
972
1142
  get isDestroyed() {
973
1143
  return this._isDestroyed;
974
1144
  }
1145
+ get isConnected() {
1146
+ return this._isConnected;
1147
+ }
1148
+ get protocolVersion() {
1149
+ return this._protocolVersion;
1150
+ }
975
1151
  /**
976
1152
  * Read-only list of all known controllers (players) in the room.
977
1153
  * Returns full ControllerInfo including playerIndex, nickname, connected status, and appearance.
@@ -982,12 +1158,18 @@
982
1158
  get controllers() {
983
1159
  return [...this._controllers];
984
1160
  }
1161
+ get me() {
1162
+ return this._controllers.find((c) => c.playerIndex === this._myPlayerIndex);
1163
+ }
985
1164
  /**
986
1165
  * Returns the number of currently connected players.
987
1166
  */
988
1167
  getControllerCount() {
989
1168
  return this._controllers.filter((c) => c.connected).length;
990
1169
  }
1170
+ getController(playerIndex) {
1171
+ return this._controllers.find((c) => c.playerIndex === playerIndex);
1172
+ }
991
1173
  // ---------------------------------------------------------------------------
992
1174
  // Initialization
993
1175
  // ---------------------------------------------------------------------------
@@ -1018,7 +1200,7 @@
1018
1200
  };
1019
1201
  window.addEventListener("message", this.boundMessageHandler);
1020
1202
  this.logger.lifecycle("Sending _bridge:ready to parent");
1021
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
1203
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: PROTOCOL_VERSION }, parentOrigin);
1022
1204
  }
1023
1205
  handleInit(msg, parentOrigin) {
1024
1206
  const initPayload = msg.payload;
@@ -1057,28 +1239,48 @@
1057
1239
  this._readyReject(error);
1058
1240
  return;
1059
1241
  }
1060
- this.transport = new PostMessageTransport(parentOrigin);
1242
+ this.transport = this.config.transport ?? new PostMessageTransport(parentOrigin);
1061
1243
  this._roomCode = initData.roomCode;
1062
- this._myIndex = initData.myIndex;
1244
+ const serverProtocolVersion = initData.protocolVersion;
1245
+ if (serverProtocolVersion !== void 0) {
1246
+ this._protocolVersion = serverProtocolVersion;
1247
+ if (serverProtocolVersion !== PROTOCOL_VERSION) {
1248
+ this.logger.warn(
1249
+ `Protocol version mismatch: SDK v${PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
1250
+ );
1251
+ }
1252
+ }
1253
+ this._myPlayerIndex = initData.myIndex;
1063
1254
  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
- }));
1255
+ this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p, index) => mapPlayerDTO(p, index));
1070
1256
  this.setupEventHandlers();
1071
1257
  for (const { event, handler } of this._pendingHandlers) {
1072
1258
  this.setupUserEventHandler(event, handler);
1073
1259
  }
1074
1260
  this._pendingHandlers = [];
1261
+ this._isConnected = true;
1075
1262
  this._isReady = true;
1263
+ if (initData.gameInProgress && this.transport) {
1264
+ this.logger.lifecycle("Game in progress detected, requesting state recovery");
1265
+ this.transport.emit(SMORE_EVENTS.STATE_GET_ALL, {});
1266
+ }
1267
+ for (const buffered of this._outboundBuffer) {
1268
+ try {
1269
+ switch (buffered.method) {
1270
+ case "send":
1271
+ this.send(buffered.args[0], buffered.args[1]);
1272
+ break;
1273
+ }
1274
+ } catch (err) {
1275
+ this.handleError(err instanceof SmoreSDKError ? err : new SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
1276
+ }
1277
+ }
1278
+ this._outboundBuffer = [];
1076
1279
  this.logger.lifecycle("Controller ready", {
1077
1280
  roomCode: this._roomCode,
1078
- myIndex: this._myIndex
1281
+ myIndex: this._myPlayerIndex
1079
1282
  });
1080
- const autoReady = getGlobalConfig().autoReady ?? true;
1081
- if (autoReady) {
1283
+ if (this.config.autoReady !== false) {
1082
1284
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
1083
1285
  this.signalReady();
1084
1286
  }
@@ -1093,21 +1295,16 @@
1093
1295
  this.logger.debug("Received _bridge:update", updateData);
1094
1296
  if (updateData.players && Array.isArray(updateData.players)) {
1095
1297
  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
- }));
1298
+ const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p, index) => mapPlayerDTO(p, index));
1102
1299
  const oldControllers = this._controllers;
1103
1300
  for (const nc of newControllers) {
1104
1301
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
1105
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
1302
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
1106
1303
  }
1107
1304
  }
1108
1305
  for (const oc of oldControllers) {
1109
1306
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
1110
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
1307
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
1111
1308
  }
1112
1309
  }
1113
1310
  for (const nc of newControllers) {
@@ -1115,11 +1312,11 @@
1115
1312
  if (oc) {
1116
1313
  if (oc.connected && !nc.connected) {
1117
1314
  this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
1118
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(nc.playerIndex));
1315
+ this._emitLifecycle("$controller-disconnect", nc.playerIndex);
1119
1316
  }
1120
1317
  if (!oc.connected && nc.connected) {
1121
1318
  this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
1122
- this._onControllerReconnectCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
1319
+ this._emitLifecycle("$controller-reconnect", nc.playerIndex, nc);
1123
1320
  }
1124
1321
  }
1125
1322
  }
@@ -1134,19 +1331,10 @@
1134
1331
  const playerIndex = playerInfo?.playerIndex ?? data.playerIndex;
1135
1332
  if (playerIndex !== void 0) {
1136
1333
  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
- };
1334
+ const controllerInfo = playerInfo ? mapPlayerDTO(playerInfo, playerIndex) : mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
1147
1335
  this._controllers = [...this._controllers, controllerInfo];
1148
1336
  this.logger.debug("Player joined", { playerIndex });
1149
- this._onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
1337
+ this._emitLifecycle("$controller-join", playerIndex, controllerInfo);
1150
1338
  }
1151
1339
  });
1152
1340
  this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
@@ -1156,7 +1344,7 @@
1156
1344
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
1157
1345
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
1158
1346
  this.logger.debug("Player left", { playerIndex });
1159
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
1347
+ this._emitLifecycle("$controller-leave", playerIndex);
1160
1348
  }
1161
1349
  });
1162
1350
  this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
@@ -1168,7 +1356,7 @@
1168
1356
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1169
1357
  );
1170
1358
  this.logger.debug("Player disconnected", { playerIndex });
1171
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
1359
+ this._emitLifecycle("$controller-disconnect", playerIndex);
1172
1360
  }
1173
1361
  });
1174
1362
  this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
@@ -1176,21 +1364,12 @@
1176
1364
  const playerData = data.player;
1177
1365
  const playerIndex = playerData?.playerIndex ?? data.playerIndex;
1178
1366
  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
- };
1367
+ const controllerInfo = playerData ? mapPlayerDTO(playerData, playerIndex) : mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
1189
1368
  this._controllers = this._controllers.map(
1190
1369
  (c) => c.playerIndex === playerIndex ? controllerInfo : c
1191
1370
  );
1192
1371
  this.logger.debug("Player reconnected", { playerIndex });
1193
- this._onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
1372
+ this._emitLifecycle("$controller-reconnect", playerIndex, controllerInfo);
1194
1373
  }
1195
1374
  });
1196
1375
  this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
@@ -1203,19 +1382,75 @@
1203
1382
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
1204
1383
  );
1205
1384
  this.logger.debug("Player character updated", { playerIndex: pi });
1206
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
1385
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
1207
1386
  }
1208
1387
  });
1209
1388
  this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
1210
1389
  const data = raw;
1211
- const event = data?.event ?? "unknown";
1212
- this.logger.warn(`Rate limited: ${event}`);
1213
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
1390
+ const eventName = data?.event ?? "unknown";
1391
+ this.handleError(
1392
+ new SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
1393
+ details: { event: eventName }
1394
+ })
1395
+ );
1396
+ });
1397
+ this.registerHandler(SMORE_EVENTS.GAME_OVER, (raw) => {
1398
+ const data = raw;
1399
+ this.logger.lifecycle("Game over", data?.results);
1400
+ this._emitLifecycle("$game-over", data?.results);
1214
1401
  });
1215
1402
  this.registerHandler(SMORE_EVENTS.ALL_READY, () => {
1216
1403
  this.logger.lifecycle("All participants ready");
1217
1404
  this._allReadyFired = true;
1218
- this._onAllReadyCallbacks.forEach((cb) => cb());
1405
+ this._emitLifecycle("$all-ready");
1406
+ });
1407
+ this.registerHandler(SMORE_EVENTS.SELF_DISCONNECTED, () => {
1408
+ this._isConnected = false;
1409
+ this.logger.lifecycle("Connection lost");
1410
+ this._emitLifecycle("$connection-change", false);
1411
+ });
1412
+ this.registerHandler(SMORE_EVENTS.SELF_RECONNECTED, () => {
1413
+ this._isConnected = true;
1414
+ this.logger.lifecycle("Connection restored");
1415
+ this._emitLifecycle("$connection-change", true);
1416
+ });
1417
+ this.registerHandler(SMORE_EVENTS.STATE_CHANGED, (raw) => {
1418
+ const data = raw;
1419
+ if (typeof data?.playerIndex === "number" && data.state) {
1420
+ this._customStates.set(data.playerIndex, data.state);
1421
+ this._stateChangeListeners.forEach((cb) => {
1422
+ try {
1423
+ cb(data.playerIndex, data.state);
1424
+ } catch (err) {
1425
+ this.handleError(
1426
+ new SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
1427
+ cause: err instanceof Error ? err : void 0
1428
+ })
1429
+ );
1430
+ }
1431
+ });
1432
+ }
1433
+ });
1434
+ this.registerHandler(SMORE_EVENTS.STATE_ALL, (raw) => {
1435
+ const data = raw;
1436
+ if (data?.states) {
1437
+ for (const [key, value] of Object.entries(data.states)) {
1438
+ const pi = Number(key);
1439
+ this._customStates.set(pi, value);
1440
+ this._stateChangeListeners.forEach((cb) => {
1441
+ try {
1442
+ cb(pi, value);
1443
+ } catch (err) {
1444
+ this.handleError(
1445
+ new SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
1446
+ cause: err instanceof Error ? err : void 0
1447
+ })
1448
+ );
1449
+ }
1450
+ });
1451
+ }
1452
+ this._emitLifecycle("$state-recovery", data.states);
1453
+ }
1219
1454
  });
1220
1455
  }
1221
1456
  /**
@@ -1254,57 +1489,89 @@
1254
1489
  this.registeredHandlers.push({ event, handler });
1255
1490
  }
1256
1491
  // ---------------------------------------------------------------------------
1492
+ // Lifecycle Listener Helpers
1493
+ // ---------------------------------------------------------------------------
1494
+ _addLifecycleListener(event, listener) {
1495
+ let set = this._lifecycleListeners.get(event);
1496
+ if (!set) {
1497
+ set = /* @__PURE__ */ new Set();
1498
+ this._lifecycleListeners.set(event, set);
1499
+ }
1500
+ set.add(listener);
1501
+ return () => {
1502
+ set.delete(listener);
1503
+ if (set.size === 0) this._lifecycleListeners.delete(event);
1504
+ };
1505
+ }
1506
+ _emitLifecycle(event, ...args) {
1507
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
1508
+ try {
1509
+ cb(...args);
1510
+ } catch (err) {
1511
+ this.handleError(
1512
+ new SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
1513
+ cause: err instanceof Error ? err : void 0,
1514
+ details: { event }
1515
+ })
1516
+ );
1517
+ }
1518
+ });
1519
+ }
1520
+ _hasLifecycleListeners(event) {
1521
+ const set = this._lifecycleListeners.get(event);
1522
+ return set !== void 0 && set.size > 0;
1523
+ }
1524
+ // ---------------------------------------------------------------------------
1257
1525
  // Lifecycle Methods
1258
1526
  // ---------------------------------------------------------------------------
1259
1527
  onAllReady(callback) {
1260
1528
  if (this._allReadyFired) {
1261
1529
  callback();
1262
1530
  }
1263
- this._onAllReadyCallbacks.add(callback);
1264
- return () => {
1265
- this._onAllReadyCallbacks.delete(callback);
1266
- };
1531
+ return this._addLifecycleListener("$all-ready", callback);
1267
1532
  }
1268
1533
  onControllerJoin(callback) {
1269
- this._onControllerJoinCallbacks.add(callback);
1270
- return () => {
1271
- this._onControllerJoinCallbacks.delete(callback);
1272
- };
1534
+ return this._addLifecycleListener("$controller-join", callback);
1273
1535
  }
1274
1536
  onControllerLeave(callback) {
1275
- this._onControllerLeaveCallbacks.add(callback);
1276
- return () => {
1277
- this._onControllerLeaveCallbacks.delete(callback);
1278
- };
1537
+ return this._addLifecycleListener("$controller-leave", callback);
1279
1538
  }
1280
1539
  onControllerDisconnect(callback) {
1281
- this._onControllerDisconnectCallbacks.add(callback);
1282
- return () => {
1283
- this._onControllerDisconnectCallbacks.delete(callback);
1284
- };
1540
+ return this._addLifecycleListener("$controller-disconnect", callback);
1285
1541
  }
1286
1542
  onControllerReconnect(callback) {
1287
- this._onControllerReconnectCallbacks.add(callback);
1288
- return () => {
1289
- this._onControllerReconnectCallbacks.delete(callback);
1290
- };
1543
+ return this._addLifecycleListener("$controller-reconnect", callback);
1291
1544
  }
1292
1545
  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
- };
1546
+ return this._addLifecycleListener("$character-updated", callback);
1303
1547
  }
1304
1548
  onError(callback) {
1305
- this._onErrorCallbacks.add(callback);
1549
+ return this._addLifecycleListener("$error", callback);
1550
+ }
1551
+ onConnectionChange(callback) {
1552
+ return this._addLifecycleListener("$connection-change", callback);
1553
+ }
1554
+ onGameOver(callback) {
1555
+ return this._addLifecycleListener("$game-over", callback);
1556
+ }
1557
+ // ---------------------------------------------------------------------------
1558
+ // Custom State Methods
1559
+ // ---------------------------------------------------------------------------
1560
+ setState(state) {
1561
+ const current = this._customStates.get(this._myPlayerIndex) ?? {};
1562
+ const merged = { ...current, ...state };
1563
+ this._customStates.set(this._myPlayerIndex, merged);
1564
+ if (this.transport) {
1565
+ this.transport.emit(SMORE_EVENTS.STATE_SET, { state });
1566
+ }
1567
+ }
1568
+ getMyState() {
1569
+ return this._customStates.get(this._myPlayerIndex);
1570
+ }
1571
+ onCustomStateChange(listener) {
1572
+ this._stateChangeListeners.add(listener);
1306
1573
  return () => {
1307
- this._onErrorCallbacks.delete(callback);
1574
+ this._stateChangeListeners.delete(listener);
1308
1575
  };
1309
1576
  }
1310
1577
  // ---------------------------------------------------------------------------
@@ -1321,8 +1588,16 @@
1321
1588
  * Use the onError callback or smore:rate-limited event to detect rate limiting.
1322
1589
  */
1323
1590
  send(event, data) {
1324
- this.ensureReady("send");
1591
+ if (this._isDestroyed) {
1592
+ throw new SmoreSDKError("DESTROYED", "Cannot call send() after destroy()");
1593
+ }
1594
+ if (!this._isReady || !this.transport) {
1595
+ this._outboundBuffer.push({ method: "send", args: [event, data] });
1596
+ this.logger.debug(`Buffered send "${event}" (controller not ready yet)`);
1597
+ return;
1598
+ }
1325
1599
  validateEventName(event);
1600
+ validatePayloadSize(data);
1326
1601
  if (typeof data !== "object" || data === null) {
1327
1602
  this.logger.warn(
1328
1603
  '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 +1606,6 @@
1331
1606
  this.logSend(event, data);
1332
1607
  this.transport.emit(event, data);
1333
1608
  }
1334
- sendRaw(event, data) {
1335
- this.ensureReady("sendRaw");
1336
- validateEventName(event);
1337
- this.logSend(event, data);
1338
- this.transport.emit(event, data);
1339
- }
1340
1609
  signalReady() {
1341
1610
  this.ensureReady("signalReady");
1342
1611
  this.logSend(SMORE_EVENTS.GAME_READY, {});
@@ -1358,6 +1627,16 @@
1358
1627
  * handler receives `(data)` -- targeted to this specific controller.
1359
1628
  */
1360
1629
  on(event, handler) {
1630
+ if (typeof event === "string" && event.startsWith("$")) {
1631
+ const validEvents = CONTROLLER_LIFECYCLE_EVENTS;
1632
+ if (!validEvents.has(event)) {
1633
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
1634
+ }
1635
+ if (event === "$all-ready" && this._allReadyFired) {
1636
+ handler();
1637
+ }
1638
+ return this._addLifecycleListener(event, handler);
1639
+ }
1361
1640
  validateEventName(event);
1362
1641
  let listeners = this.eventListeners.get(event);
1363
1642
  if (!listeners) {
@@ -1410,6 +1689,23 @@
1410
1689
  * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
1411
1690
  */
1412
1691
  once(event, handler) {
1692
+ if (typeof event === "string" && event.startsWith("$")) {
1693
+ const validEvents = CONTROLLER_LIFECYCLE_EVENTS;
1694
+ if (!validEvents.has(event)) {
1695
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
1696
+ }
1697
+ if (event === "$all-ready" && this._allReadyFired) {
1698
+ handler();
1699
+ return () => {
1700
+ };
1701
+ }
1702
+ const wrapper = (...args) => {
1703
+ unsub();
1704
+ handler(...args);
1705
+ };
1706
+ const unsub = this._addLifecycleListener(event, wrapper);
1707
+ return unsub;
1708
+ }
1413
1709
  const unsubscribe = this.on(event, ((data) => {
1414
1710
  unsubscribe();
1415
1711
  handler(data);
@@ -1417,6 +1713,14 @@
1417
1713
  return unsubscribe;
1418
1714
  }
1419
1715
  off(event, handler) {
1716
+ if (typeof event === "string" && event.startsWith("$")) {
1717
+ if (!handler) {
1718
+ this._lifecycleListeners.delete(event);
1719
+ } else {
1720
+ this._lifecycleListeners.get(event)?.delete(handler);
1721
+ }
1722
+ return;
1723
+ }
1420
1724
  if (!handler) {
1421
1725
  this.eventListeners.delete(event);
1422
1726
  this.transport?.off(event);
@@ -1444,6 +1748,21 @@
1444
1748
  );
1445
1749
  }
1446
1750
  }
1751
+ removeAllListeners(event) {
1752
+ if (event) {
1753
+ this.eventListeners.delete(event);
1754
+ this.transport?.off(event);
1755
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
1756
+ for (const [key, val] of this.handlerToTransport) {
1757
+ if (val.event === event) this.handlerToTransport.delete(key);
1758
+ }
1759
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
1760
+ } else {
1761
+ for (const evt of [...this.eventListeners.keys()]) {
1762
+ this.removeAllListeners(evt);
1763
+ }
1764
+ }
1765
+ }
1447
1766
  // ---------------------------------------------------------------------------
1448
1767
  // Cleanup
1449
1768
  // ---------------------------------------------------------------------------
@@ -1467,14 +1786,11 @@
1467
1786
  this.eventListeners.clear();
1468
1787
  this.handlerToTransport.clear();
1469
1788
  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();
1789
+ this._lifecycleListeners.clear();
1790
+ this._customStates.clear();
1791
+ this._stateChangeListeners.clear();
1792
+ this._isConnected = false;
1793
+ this._outboundBuffer = [];
1478
1794
  if (this.transport) {
1479
1795
  this.transport.destroy();
1480
1796
  this.transport = null;
@@ -1506,8 +1822,8 @@
1506
1822
  handleError(error) {
1507
1823
  this.logger.warn(`Error in handler: ${error.message}`);
1508
1824
  const smoreError = error.toSmoreError();
1509
- if (this._onErrorCallbacks.size > 0) {
1510
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
1825
+ if (this._hasLifecycleListeners("$error")) {
1826
+ this._emitLifecycle("$error", smoreError);
1511
1827
  } else {
1512
1828
  this.logger.error(error.message, error.details);
1513
1829
  }
@@ -1523,547 +1839,22 @@
1523
1839
  return new ControllerImpl(config ?? {});
1524
1840
  }
1525
1841
 
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
- }
1842
+ const LifecycleEvent = {
1843
+ ALL_READY: "$all-ready",
1844
+ CONTROLLER_JOIN: "$controller-join",
1845
+ CONTROLLER_LEAVE: "$controller-leave",
1846
+ CONTROLLER_DISCONNECT: "$controller-disconnect",
1847
+ CONTROLLER_RECONNECT: "$controller-reconnect",
1848
+ CHARACTER_UPDATED: "$character-updated",
1849
+ ERROR: "$error",
1850
+ GAME_OVER: "$game-over",
1851
+ CONNECTION_CHANGE: "$connection-change"
1852
+ };
2059
1853
 
1854
+ exports.LifecycleEvent = LifecycleEvent;
2060
1855
  exports.SmoreSDKError = SmoreSDKError;
2061
- exports.configure = configure;
2062
1856
  exports.createController = createController;
2063
- exports.createMockController = createMockController;
2064
- exports.createMockScreen = createMockScreen;
2065
1857
  exports.createScreen = createScreen;
2066
- exports.validateEventName = validateEventName;
2067
1858
 
2068
1859
  }));
2069
1860
  //# sourceMappingURL=smore-sdk.umd.js.map