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