@smoregg/sdk 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/cjs/controller.cjs +260 -113
  2. package/dist/cjs/controller.cjs.map +1 -1
  3. package/dist/cjs/errors.cjs +1 -0
  4. package/dist/cjs/errors.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +26 -3
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/index.cjs +2 -7
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/screen.cjs +244 -128
  10. package/dist/cjs/screen.cjs.map +1 -1
  11. package/dist/cjs/shared.cjs +34 -0
  12. package/dist/cjs/shared.cjs.map +1 -0
  13. package/dist/cjs/testing.cjs +181 -73
  14. package/dist/cjs/testing.cjs.map +1 -1
  15. package/dist/cjs/transport/PostMessageTransport.cjs +12 -0
  16. package/dist/cjs/transport/PostMessageTransport.cjs.map +1 -1
  17. package/dist/cjs/transport/protocol.cjs +2 -0
  18. package/dist/cjs/transport/protocol.cjs.map +1 -1
  19. package/dist/cjs/types.cjs +16 -0
  20. package/dist/cjs/types.cjs.map +1 -0
  21. package/dist/esm/controller.js +262 -115
  22. package/dist/esm/controller.js.map +1 -1
  23. package/dist/esm/errors.js +1 -0
  24. package/dist/esm/errors.js.map +1 -1
  25. package/dist/esm/events.js +25 -4
  26. package/dist/esm/events.js.map +1 -1
  27. package/dist/esm/index.js +1 -3
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/screen.js +246 -130
  30. package/dist/esm/screen.js.map +1 -1
  31. package/dist/esm/shared.js +30 -0
  32. package/dist/esm/shared.js.map +1 -0
  33. package/dist/esm/testing.js +181 -73
  34. package/dist/esm/testing.js.map +1 -1
  35. package/dist/esm/transport/PostMessageTransport.js +12 -0
  36. package/dist/esm/transport/PostMessageTransport.js.map +1 -1
  37. package/dist/esm/transport/protocol.js +2 -1
  38. package/dist/esm/transport/protocol.js.map +1 -1
  39. package/dist/esm/types.js +14 -0
  40. package/dist/esm/types.js.map +1 -0
  41. package/dist/types/controller.d.ts +1 -1
  42. package/dist/types/controller.d.ts.map +1 -1
  43. package/dist/types/errors.d.ts.map +1 -1
  44. package/dist/types/events.d.ts +14 -1
  45. package/dist/types/events.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +4 -8
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/screen.d.ts +3 -3
  49. package/dist/types/screen.d.ts.map +1 -1
  50. package/dist/types/shared.d.ts +21 -0
  51. package/dist/types/shared.d.ts.map +1 -0
  52. package/dist/types/testing.d.ts +65 -4
  53. package/dist/types/testing.d.ts.map +1 -1
  54. package/dist/types/transport/PostMessageTransport.d.ts +1 -0
  55. package/dist/types/transport/PostMessageTransport.d.ts.map +1 -1
  56. package/dist/types/transport/protocol.d.ts +5 -0
  57. package/dist/types/transport/protocol.d.ts.map +1 -1
  58. package/dist/types/types.d.ts +254 -345
  59. package/dist/types/types.d.ts.map +1 -1
  60. package/dist/umd/smore-sdk.umd.js +575 -784
  61. package/dist/umd/smore-sdk.umd.js.map +1 -1
  62. package/dist/umd/smore-sdk.umd.min.js +1 -1
  63. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  64. package/package.json +7 -1
  65. package/dist/cjs/config.cjs +0 -13
  66. package/dist/cjs/config.cjs.map +0 -1
  67. package/dist/esm/config.js +0 -10
  68. package/dist/esm/config.js.map +0 -1
  69. package/dist/types/config.d.ts +0 -35
  70. package/dist/types/config.d.ts.map +0 -1
@@ -5,7 +5,7 @@ var protocol = require('./transport/protocol.cjs');
5
5
  var errors = require('./errors.cjs');
6
6
  var events = require('./events.cjs');
7
7
  var logger = require('./logger.cjs');
8
- var config = require('./config.cjs');
8
+ var shared = require('./shared.cjs');
9
9
 
10
10
  const DEFAULT_TIMEOUT = 1e4;
11
11
  class ControllerImpl {
@@ -13,7 +13,7 @@ class ControllerImpl {
13
13
  config;
14
14
  logger;
15
15
  _roomCode = "";
16
- _myIndex = -1;
16
+ _myPlayerIndex = -1;
17
17
  _isReady = false;
18
18
  _isDestroyed = false;
19
19
  _initTimeoutId = null;
@@ -23,19 +23,20 @@ class ControllerImpl {
23
23
  // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
24
24
  handlerToTransport = /* @__PURE__ */ new Map();
25
25
  _controllers = [];
26
+ _customStates = /* @__PURE__ */ new Map();
27
+ _stateChangeListeners = /* @__PURE__ */ new Set();
26
28
  // Pending handlers registered via on() before transport is ready
27
29
  _pendingHandlers = [];
28
- // Lifecycle callback arrays
29
- _onAllReadyCallbacks = /* @__PURE__ */ new Set();
30
- _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
31
- _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
32
- _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
33
- _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
34
- _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
35
- _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
36
- _onErrorCallbacks = /* @__PURE__ */ new Set();
30
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
31
+ _lifecycleListeners = /* @__PURE__ */ new Map();
32
+ // Outbound message buffer (messages sent before ready)
33
+ _outboundBuffer = [];
37
34
  // Whether all-ready has fired
38
35
  _allReadyFired = false;
36
+ // Self-connection awareness
37
+ _isConnected = false;
38
+ // Protocol versioning
39
+ _protocolVersion = protocol.PROTOCOL_VERSION;
39
40
  // Ready promise
40
41
  _readyResolve;
41
42
  _readyReject;
@@ -52,8 +53,8 @@ class ControllerImpl {
52
53
  // ---------------------------------------------------------------------------
53
54
  // Properties (readonly)
54
55
  // ---------------------------------------------------------------------------
55
- get myIndex() {
56
- return this._myIndex;
56
+ get myPlayerIndex() {
57
+ return this._myPlayerIndex;
57
58
  }
58
59
  get roomCode() {
59
60
  return this._roomCode;
@@ -64,6 +65,12 @@ class ControllerImpl {
64
65
  get isDestroyed() {
65
66
  return this._isDestroyed;
66
67
  }
68
+ get isConnected() {
69
+ return this._isConnected;
70
+ }
71
+ get protocolVersion() {
72
+ return this._protocolVersion;
73
+ }
67
74
  /**
68
75
  * Read-only list of all known controllers (players) in the room.
69
76
  * Returns full ControllerInfo including playerIndex, nickname, connected status, and appearance.
@@ -74,12 +81,18 @@ class ControllerImpl {
74
81
  get controllers() {
75
82
  return [...this._controllers];
76
83
  }
84
+ get me() {
85
+ return this._controllers.find((c) => c.playerIndex === this._myPlayerIndex);
86
+ }
77
87
  /**
78
88
  * Returns the number of currently connected players.
79
89
  */
80
90
  getControllerCount() {
81
91
  return this._controllers.filter((c) => c.connected).length;
82
92
  }
93
+ getController(playerIndex) {
94
+ return this._controllers.find((c) => c.playerIndex === playerIndex);
95
+ }
83
96
  // ---------------------------------------------------------------------------
84
97
  // Initialization
85
98
  // ---------------------------------------------------------------------------
@@ -110,7 +123,7 @@ class ControllerImpl {
110
123
  };
111
124
  window.addEventListener("message", this.boundMessageHandler);
112
125
  this.logger.lifecycle("Sending _bridge:ready to parent");
113
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
126
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: protocol.PROTOCOL_VERSION }, parentOrigin);
114
127
  }
115
128
  handleInit(msg, parentOrigin) {
116
129
  const initPayload = msg.payload;
@@ -149,28 +162,48 @@ class ControllerImpl {
149
162
  this._readyReject(error);
150
163
  return;
151
164
  }
152
- this.transport = new PostMessageTransport.PostMessageTransport(parentOrigin);
165
+ this.transport = this.config.transport ?? new PostMessageTransport.PostMessageTransport(parentOrigin);
153
166
  this._roomCode = initData.roomCode;
154
- this._myIndex = initData.myIndex;
167
+ const serverProtocolVersion = initData.protocolVersion;
168
+ if (serverProtocolVersion !== void 0) {
169
+ this._protocolVersion = serverProtocolVersion;
170
+ if (serverProtocolVersion !== protocol.PROTOCOL_VERSION) {
171
+ this.logger.warn(
172
+ `Protocol version mismatch: SDK v${protocol.PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
173
+ );
174
+ }
175
+ }
176
+ this._myPlayerIndex = initData.myIndex;
155
177
  const initPlayers = initData.players;
156
- this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p) => ({
157
- playerIndex: p.playerIndex,
158
- nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
159
- connected: p.connected !== false,
160
- appearance: p.appearance ?? p.character
161
- }));
178
+ this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p, index) => shared.mapPlayerDTO(p, index));
162
179
  this.setupEventHandlers();
163
180
  for (const { event, handler } of this._pendingHandlers) {
164
181
  this.setupUserEventHandler(event, handler);
165
182
  }
166
183
  this._pendingHandlers = [];
184
+ this._isConnected = true;
167
185
  this._isReady = true;
186
+ if (initData.gameInProgress && this.transport) {
187
+ this.logger.lifecycle("Game in progress detected, requesting state recovery");
188
+ this.transport.emit(events.SMORE_EVENTS.STATE_GET_ALL, {});
189
+ }
190
+ for (const buffered of this._outboundBuffer) {
191
+ try {
192
+ switch (buffered.method) {
193
+ case "send":
194
+ this.send(buffered.args[0], buffered.args[1]);
195
+ break;
196
+ }
197
+ } catch (err) {
198
+ this.handleError(err instanceof errors.SmoreSDKError ? err : new errors.SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
199
+ }
200
+ }
201
+ this._outboundBuffer = [];
168
202
  this.logger.lifecycle("Controller ready", {
169
203
  roomCode: this._roomCode,
170
- myIndex: this._myIndex
204
+ myIndex: this._myPlayerIndex
171
205
  });
172
- const autoReady = config.getGlobalConfig().autoReady ?? true;
173
- if (autoReady) {
206
+ if (this.config.autoReady !== false) {
174
207
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
175
208
  this.signalReady();
176
209
  }
@@ -185,21 +218,16 @@ class ControllerImpl {
185
218
  this.logger.debug("Received _bridge:update", updateData);
186
219
  if (updateData.players && Array.isArray(updateData.players)) {
187
220
  const players = updateData.players;
188
- const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p) => ({
189
- playerIndex: p.playerIndex,
190
- nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
191
- connected: p.connected !== false,
192
- appearance: p.appearance ?? p.character
193
- }));
221
+ const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p, index) => shared.mapPlayerDTO(p, index));
194
222
  const oldControllers = this._controllers;
195
223
  for (const nc of newControllers) {
196
224
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
197
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
225
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
198
226
  }
199
227
  }
200
228
  for (const oc of oldControllers) {
201
229
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
202
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
230
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
203
231
  }
204
232
  }
205
233
  for (const nc of newControllers) {
@@ -207,11 +235,11 @@ class ControllerImpl {
207
235
  if (oc) {
208
236
  if (oc.connected && !nc.connected) {
209
237
  this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
210
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(nc.playerIndex));
238
+ this._emitLifecycle("$controller-disconnect", nc.playerIndex);
211
239
  }
212
240
  if (!oc.connected && nc.connected) {
213
241
  this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
214
- this._onControllerReconnectCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
242
+ this._emitLifecycle("$controller-reconnect", nc.playerIndex, nc);
215
243
  }
216
244
  }
217
245
  }
@@ -226,19 +254,10 @@ class ControllerImpl {
226
254
  const playerIndex = playerInfo?.playerIndex ?? data.playerIndex;
227
255
  if (playerIndex !== void 0) {
228
256
  if (this._controllers.some((c) => c.playerIndex === playerIndex)) return;
229
- const controllerInfo = playerInfo ? {
230
- playerIndex,
231
- nickname: playerInfo.nickname || playerInfo.name || `Player ${playerIndex + 1}`,
232
- connected: playerInfo.connected !== false,
233
- appearance: playerInfo.appearance ?? playerInfo.character
234
- } : {
235
- playerIndex,
236
- nickname: `Player ${playerIndex + 1}`,
237
- connected: true
238
- };
257
+ const controllerInfo = playerInfo ? shared.mapPlayerDTO(playerInfo, playerIndex) : shared.mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
239
258
  this._controllers = [...this._controllers, controllerInfo];
240
259
  this.logger.debug("Player joined", { playerIndex });
241
- this._onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
260
+ this._emitLifecycle("$controller-join", playerIndex, controllerInfo);
242
261
  }
243
262
  });
244
263
  this.registerHandler(events.SMORE_EVENTS.PLAYER_LEFT, (raw) => {
@@ -248,7 +267,7 @@ class ControllerImpl {
248
267
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
249
268
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
250
269
  this.logger.debug("Player left", { playerIndex });
251
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
270
+ this._emitLifecycle("$controller-leave", playerIndex);
252
271
  }
253
272
  });
254
273
  this.registerHandler(events.SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
@@ -260,7 +279,7 @@ class ControllerImpl {
260
279
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
261
280
  );
262
281
  this.logger.debug("Player disconnected", { playerIndex });
263
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
282
+ this._emitLifecycle("$controller-disconnect", playerIndex);
264
283
  }
265
284
  });
266
285
  this.registerHandler(events.SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
@@ -268,21 +287,12 @@ class ControllerImpl {
268
287
  const playerData = data.player;
269
288
  const playerIndex = playerData?.playerIndex ?? data.playerIndex;
270
289
  if (playerIndex !== void 0) {
271
- const controllerInfo = playerData ? {
272
- playerIndex,
273
- nickname: playerData.nickname || playerData.name || `Player ${playerIndex + 1}`,
274
- connected: true,
275
- appearance: playerData.appearance ?? playerData.character
276
- } : {
277
- playerIndex,
278
- nickname: `Player ${playerIndex + 1}`,
279
- connected: true
280
- };
290
+ const controllerInfo = playerData ? shared.mapPlayerDTO(playerData, playerIndex) : shared.mapPlayerDTO({ playerIndex, connected: true }, playerIndex);
281
291
  this._controllers = this._controllers.map(
282
292
  (c) => c.playerIndex === playerIndex ? controllerInfo : c
283
293
  );
284
294
  this.logger.debug("Player reconnected", { playerIndex });
285
- this._onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
295
+ this._emitLifecycle("$controller-reconnect", playerIndex, controllerInfo);
286
296
  }
287
297
  });
288
298
  this.registerHandler(events.SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
@@ -295,19 +305,75 @@ class ControllerImpl {
295
305
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
296
306
  );
297
307
  this.logger.debug("Player character updated", { playerIndex: pi });
298
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
308
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
299
309
  }
300
310
  });
301
311
  this.registerHandler(events.SMORE_EVENTS.RATE_LIMITED, (raw) => {
302
312
  const data = raw;
303
- const event = data?.event ?? "unknown";
304
- this.logger.warn(`Rate limited: ${event}`);
305
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
313
+ const eventName = data?.event ?? "unknown";
314
+ this.handleError(
315
+ new errors.SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
316
+ details: { event: eventName }
317
+ })
318
+ );
319
+ });
320
+ this.registerHandler(events.SMORE_EVENTS.GAME_OVER, (raw) => {
321
+ const data = raw;
322
+ this.logger.lifecycle("Game over", data?.results);
323
+ this._emitLifecycle("$game-over", data?.results);
306
324
  });
307
325
  this.registerHandler(events.SMORE_EVENTS.ALL_READY, () => {
308
326
  this.logger.lifecycle("All participants ready");
309
327
  this._allReadyFired = true;
310
- this._onAllReadyCallbacks.forEach((cb) => cb());
328
+ this._emitLifecycle("$all-ready");
329
+ });
330
+ this.registerHandler(events.SMORE_EVENTS.SELF_DISCONNECTED, () => {
331
+ this._isConnected = false;
332
+ this.logger.lifecycle("Connection lost");
333
+ this._emitLifecycle("$connection-change", false);
334
+ });
335
+ this.registerHandler(events.SMORE_EVENTS.SELF_RECONNECTED, () => {
336
+ this._isConnected = true;
337
+ this.logger.lifecycle("Connection restored");
338
+ this._emitLifecycle("$connection-change", true);
339
+ });
340
+ this.registerHandler(events.SMORE_EVENTS.STATE_CHANGED, (raw) => {
341
+ const data = raw;
342
+ if (typeof data?.playerIndex === "number" && data.state) {
343
+ this._customStates.set(data.playerIndex, data.state);
344
+ this._stateChangeListeners.forEach((cb) => {
345
+ try {
346
+ cb(data.playerIndex, data.state);
347
+ } catch (err) {
348
+ this.handleError(
349
+ new errors.SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
350
+ cause: err instanceof Error ? err : void 0
351
+ })
352
+ );
353
+ }
354
+ });
355
+ }
356
+ });
357
+ this.registerHandler(events.SMORE_EVENTS.STATE_ALL, (raw) => {
358
+ const data = raw;
359
+ if (data?.states) {
360
+ for (const [key, value] of Object.entries(data.states)) {
361
+ const pi = Number(key);
362
+ this._customStates.set(pi, value);
363
+ this._stateChangeListeners.forEach((cb) => {
364
+ try {
365
+ cb(pi, value);
366
+ } catch (err) {
367
+ this.handleError(
368
+ new errors.SmoreSDKError("UNKNOWN", "Error in custom state change listener", {
369
+ cause: err instanceof Error ? err : void 0
370
+ })
371
+ );
372
+ }
373
+ });
374
+ }
375
+ this._emitLifecycle("$state-recovery", data.states);
376
+ }
311
377
  });
312
378
  }
313
379
  /**
@@ -346,57 +412,89 @@ class ControllerImpl {
346
412
  this.registeredHandlers.push({ event, handler });
347
413
  }
348
414
  // ---------------------------------------------------------------------------
415
+ // Lifecycle Listener Helpers
416
+ // ---------------------------------------------------------------------------
417
+ _addLifecycleListener(event, listener) {
418
+ let set = this._lifecycleListeners.get(event);
419
+ if (!set) {
420
+ set = /* @__PURE__ */ new Set();
421
+ this._lifecycleListeners.set(event, set);
422
+ }
423
+ set.add(listener);
424
+ return () => {
425
+ set.delete(listener);
426
+ if (set.size === 0) this._lifecycleListeners.delete(event);
427
+ };
428
+ }
429
+ _emitLifecycle(event, ...args) {
430
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
431
+ try {
432
+ cb(...args);
433
+ } catch (err) {
434
+ this.handleError(
435
+ new errors.SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
436
+ cause: err instanceof Error ? err : void 0,
437
+ details: { event }
438
+ })
439
+ );
440
+ }
441
+ });
442
+ }
443
+ _hasLifecycleListeners(event) {
444
+ const set = this._lifecycleListeners.get(event);
445
+ return set !== void 0 && set.size > 0;
446
+ }
447
+ // ---------------------------------------------------------------------------
349
448
  // Lifecycle Methods
350
449
  // ---------------------------------------------------------------------------
351
450
  onAllReady(callback) {
352
451
  if (this._allReadyFired) {
353
452
  callback();
354
453
  }
355
- this._onAllReadyCallbacks.add(callback);
356
- return () => {
357
- this._onAllReadyCallbacks.delete(callback);
358
- };
454
+ return this._addLifecycleListener("$all-ready", callback);
359
455
  }
360
456
  onControllerJoin(callback) {
361
- this._onControllerJoinCallbacks.add(callback);
362
- return () => {
363
- this._onControllerJoinCallbacks.delete(callback);
364
- };
457
+ return this._addLifecycleListener("$controller-join", callback);
365
458
  }
366
459
  onControllerLeave(callback) {
367
- this._onControllerLeaveCallbacks.add(callback);
368
- return () => {
369
- this._onControllerLeaveCallbacks.delete(callback);
370
- };
460
+ return this._addLifecycleListener("$controller-leave", callback);
371
461
  }
372
462
  onControllerDisconnect(callback) {
373
- this._onControllerDisconnectCallbacks.add(callback);
374
- return () => {
375
- this._onControllerDisconnectCallbacks.delete(callback);
376
- };
463
+ return this._addLifecycleListener("$controller-disconnect", callback);
377
464
  }
378
465
  onControllerReconnect(callback) {
379
- this._onControllerReconnectCallbacks.add(callback);
380
- return () => {
381
- this._onControllerReconnectCallbacks.delete(callback);
382
- };
466
+ return this._addLifecycleListener("$controller-reconnect", callback);
383
467
  }
384
468
  onCharacterUpdated(callback) {
385
- this._onCharacterUpdatedCallbacks.add(callback);
386
- return () => {
387
- this._onCharacterUpdatedCallbacks.delete(callback);
388
- };
389
- }
390
- onRateLimited(callback) {
391
- this._onRateLimitedCallbacks.add(callback);
392
- return () => {
393
- this._onRateLimitedCallbacks.delete(callback);
394
- };
469
+ return this._addLifecycleListener("$character-updated", callback);
395
470
  }
396
471
  onError(callback) {
397
- this._onErrorCallbacks.add(callback);
472
+ return this._addLifecycleListener("$error", callback);
473
+ }
474
+ onConnectionChange(callback) {
475
+ return this._addLifecycleListener("$connection-change", callback);
476
+ }
477
+ onGameOver(callback) {
478
+ return this._addLifecycleListener("$game-over", callback);
479
+ }
480
+ // ---------------------------------------------------------------------------
481
+ // Custom State Methods
482
+ // ---------------------------------------------------------------------------
483
+ setState(state) {
484
+ const current = this._customStates.get(this._myPlayerIndex) ?? {};
485
+ const merged = { ...current, ...state };
486
+ this._customStates.set(this._myPlayerIndex, merged);
487
+ if (this.transport) {
488
+ this.transport.emit(events.SMORE_EVENTS.STATE_SET, { state });
489
+ }
490
+ }
491
+ getMyState() {
492
+ return this._customStates.get(this._myPlayerIndex);
493
+ }
494
+ onCustomStateChange(listener) {
495
+ this._stateChangeListeners.add(listener);
398
496
  return () => {
399
- this._onErrorCallbacks.delete(callback);
497
+ this._stateChangeListeners.delete(listener);
400
498
  };
401
499
  }
402
500
  // ---------------------------------------------------------------------------
@@ -413,8 +511,16 @@ class ControllerImpl {
413
511
  * Use the onError callback or smore:rate-limited event to detect rate limiting.
414
512
  */
415
513
  send(event, data) {
416
- this.ensureReady("send");
514
+ if (this._isDestroyed) {
515
+ throw new errors.SmoreSDKError("DESTROYED", "Cannot call send() after destroy()");
516
+ }
517
+ if (!this._isReady || !this.transport) {
518
+ this._outboundBuffer.push({ method: "send", args: [event, data] });
519
+ this.logger.debug(`Buffered send "${event}" (controller not ready yet)`);
520
+ return;
521
+ }
417
522
  events.validateEventName(event);
523
+ shared.validatePayloadSize(data);
418
524
  if (typeof data !== "object" || data === null) {
419
525
  this.logger.warn(
420
526
  '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).'
@@ -423,12 +529,6 @@ class ControllerImpl {
423
529
  this.logSend(event, data);
424
530
  this.transport.emit(event, data);
425
531
  }
426
- sendRaw(event, data) {
427
- this.ensureReady("sendRaw");
428
- events.validateEventName(event);
429
- this.logSend(event, data);
430
- this.transport.emit(event, data);
431
- }
432
532
  signalReady() {
433
533
  this.ensureReady("signalReady");
434
534
  this.logSend(events.SMORE_EVENTS.GAME_READY, {});
@@ -450,6 +550,16 @@ class ControllerImpl {
450
550
  * handler receives `(data)` -- targeted to this specific controller.
451
551
  */
452
552
  on(event, handler) {
553
+ if (typeof event === "string" && event.startsWith("$")) {
554
+ const validEvents = events.CONTROLLER_LIFECYCLE_EVENTS;
555
+ if (!validEvents.has(event)) {
556
+ throw new errors.SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
557
+ }
558
+ if (event === "$all-ready" && this._allReadyFired) {
559
+ handler();
560
+ }
561
+ return this._addLifecycleListener(event, handler);
562
+ }
453
563
  events.validateEventName(event);
454
564
  let listeners = this.eventListeners.get(event);
455
565
  if (!listeners) {
@@ -502,6 +612,23 @@ class ControllerImpl {
502
612
  * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
503
613
  */
504
614
  once(event, handler) {
615
+ if (typeof event === "string" && event.startsWith("$")) {
616
+ const validEvents = events.CONTROLLER_LIFECYCLE_EVENTS;
617
+ if (!validEvents.has(event)) {
618
+ throw new errors.SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
619
+ }
620
+ if (event === "$all-ready" && this._allReadyFired) {
621
+ handler();
622
+ return () => {
623
+ };
624
+ }
625
+ const wrapper = (...args) => {
626
+ unsub();
627
+ handler(...args);
628
+ };
629
+ const unsub = this._addLifecycleListener(event, wrapper);
630
+ return unsub;
631
+ }
505
632
  const unsubscribe = this.on(event, ((data) => {
506
633
  unsubscribe();
507
634
  handler(data);
@@ -509,6 +636,14 @@ class ControllerImpl {
509
636
  return unsubscribe;
510
637
  }
511
638
  off(event, handler) {
639
+ if (typeof event === "string" && event.startsWith("$")) {
640
+ if (!handler) {
641
+ this._lifecycleListeners.delete(event);
642
+ } else {
643
+ this._lifecycleListeners.get(event)?.delete(handler);
644
+ }
645
+ return;
646
+ }
512
647
  if (!handler) {
513
648
  this.eventListeners.delete(event);
514
649
  this.transport?.off(event);
@@ -536,6 +671,21 @@ class ControllerImpl {
536
671
  );
537
672
  }
538
673
  }
674
+ removeAllListeners(event) {
675
+ if (event) {
676
+ this.eventListeners.delete(event);
677
+ this.transport?.off(event);
678
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
679
+ for (const [key, val] of this.handlerToTransport) {
680
+ if (val.event === event) this.handlerToTransport.delete(key);
681
+ }
682
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
683
+ } else {
684
+ for (const evt of [...this.eventListeners.keys()]) {
685
+ this.removeAllListeners(evt);
686
+ }
687
+ }
688
+ }
539
689
  // ---------------------------------------------------------------------------
540
690
  // Cleanup
541
691
  // ---------------------------------------------------------------------------
@@ -559,14 +709,11 @@ class ControllerImpl {
559
709
  this.eventListeners.clear();
560
710
  this.handlerToTransport.clear();
561
711
  this._pendingHandlers = [];
562
- this._onAllReadyCallbacks.clear();
563
- this._onControllerJoinCallbacks.clear();
564
- this._onControllerLeaveCallbacks.clear();
565
- this._onControllerDisconnectCallbacks.clear();
566
- this._onControllerReconnectCallbacks.clear();
567
- this._onCharacterUpdatedCallbacks.clear();
568
- this._onRateLimitedCallbacks.clear();
569
- this._onErrorCallbacks.clear();
712
+ this._lifecycleListeners.clear();
713
+ this._customStates.clear();
714
+ this._stateChangeListeners.clear();
715
+ this._isConnected = false;
716
+ this._outboundBuffer = [];
570
717
  if (this.transport) {
571
718
  this.transport.destroy();
572
719
  this.transport = null;
@@ -598,8 +745,8 @@ class ControllerImpl {
598
745
  handleError(error) {
599
746
  this.logger.warn(`Error in handler: ${error.message}`);
600
747
  const smoreError = error.toSmoreError();
601
- if (this._onErrorCallbacks.size > 0) {
602
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
748
+ if (this._hasLifecycleListeners("$error")) {
749
+ this._emitLifecycle("$error", smoreError);
603
750
  } else {
604
751
  this.logger.error(error.message, error.details);
605
752
  }