@smoregg/sdk 0.5.0 → 0.6.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 (40) hide show
  1. package/README.md +199 -0
  2. package/dist/cjs/controller.cjs +379 -0
  3. package/dist/cjs/controller.cjs.map +1 -0
  4. package/dist/cjs/index.cjs +8 -4
  5. package/dist/cjs/index.cjs.map +1 -1
  6. package/dist/cjs/screen.cjs +526 -0
  7. package/dist/cjs/screen.cjs.map +1 -0
  8. package/dist/cjs/testing.cjs +257 -0
  9. package/dist/cjs/testing.cjs.map +1 -0
  10. package/dist/cjs/transport/protocol.cjs.map +1 -1
  11. package/dist/esm/controller.js +376 -0
  12. package/dist/esm/controller.js.map +1 -0
  13. package/dist/esm/index.js +3 -2
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/screen.js +523 -0
  16. package/dist/esm/screen.js.map +1 -0
  17. package/dist/esm/testing.js +254 -0
  18. package/dist/esm/testing.js.map +1 -0
  19. package/dist/esm/transport/protocol.js.map +1 -1
  20. package/dist/types/controller.d.ts +78 -0
  21. package/dist/types/controller.d.ts.map +1 -0
  22. package/dist/types/index.d.ts +17 -18
  23. package/dist/types/index.d.ts.map +1 -1
  24. package/dist/types/screen.d.ts +79 -0
  25. package/dist/types/screen.d.ts.map +1 -0
  26. package/dist/types/testing.d.ts +61 -0
  27. package/dist/types/testing.d.ts.map +1 -0
  28. package/dist/types/transport/protocol.d.ts +1 -4
  29. package/dist/types/transport/protocol.d.ts.map +1 -1
  30. package/dist/types/types.d.ts +869 -4
  31. package/dist/types/types.d.ts.map +1 -1
  32. package/dist/umd/smore-sdk-vanilla.umd.js +956 -331
  33. package/dist/umd/smore-sdk-vanilla.umd.js.map +1 -1
  34. package/dist/umd/smore-sdk-vanilla.umd.min.js +1 -1
  35. package/dist/umd/smore-sdk-vanilla.umd.min.js.map +1 -1
  36. package/dist/umd/smore-sdk.umd.js +956 -331
  37. package/dist/umd/smore-sdk.umd.js.map +1 -1
  38. package/dist/umd/smore-sdk.umd.min.js +1 -1
  39. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  40. package/package.json +1 -1
@@ -4,25 +4,6 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.SmoreSDK = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
- class DirectTransport {
8
- constructor(socket) {
9
- this.socket = socket;
10
- }
11
- emit(event, ...args) {
12
- this.socket.emit(event, ...args);
13
- }
14
- on(event, handler) {
15
- this.socket.on(event, handler);
16
- }
17
- off(event, handler) {
18
- if (handler) {
19
- this.socket.off(event, handler);
20
- } else {
21
- this.socket.off(event);
22
- }
23
- }
24
- }
25
-
26
7
  const SMORE_MSG_PREFIX = "smore:";
27
8
  function isSmoreMessage(data) {
28
9
  return data && typeof data === "object" && typeof data.type === "string" && data.type.startsWith(SMORE_MSG_PREFIX);
@@ -98,89 +79,234 @@
98
79
  const SYSTEM_EVENTS$1 = {
99
80
  PLAYER_JOIN: `${SYSTEM_PREFIX$1}player-join`,
100
81
  PLAYER_LEAVE: `${SYSTEM_PREFIX$1}player-leave`,
82
+ PLAYER_RECONNECT: `${SYSTEM_PREFIX$1}player-reconnect`,
101
83
  GAME_OVER: `${SYSTEM_PREFIX$1}game-over`
102
84
  };
103
- const EVENT_NAME_REGEX$1 = /^[a-zA-Z]([a-zA-Z_-]*[a-zA-Z])?$/;
85
+ const DEFAULT_TIMEOUT$1 = 1e4;
86
+ let SmoreSDKError$1 = class SmoreSDKError extends Error {
87
+ code;
88
+ cause;
89
+ details;
90
+ constructor(code, message, options) {
91
+ super(message);
92
+ this.name = "SmoreSDKError";
93
+ this.code = code;
94
+ this.cause = options?.cause;
95
+ this.details = options?.details;
96
+ const ErrorWithCapture = Error;
97
+ if (typeof ErrorWithCapture.captureStackTrace === "function") {
98
+ ErrorWithCapture.captureStackTrace(this, SmoreSDKError);
99
+ }
100
+ }
101
+ toSmoreError() {
102
+ return {
103
+ code: this.code,
104
+ message: this.message,
105
+ cause: this.cause,
106
+ details: this.details
107
+ };
108
+ }
109
+ };
110
+ const EVENT_NAME_REGEX$1 = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
104
111
  function validateEventName$1(event) {
112
+ if (!event || typeof event !== "string") {
113
+ throw new SmoreSDKError$1("INVALID_EVENT", "Event name must be a non-empty string");
114
+ }
105
115
  if (!EVENT_NAME_REGEX$1.test(event)) {
106
- throw new Error(
107
- `[SmoreHost] Invalid event name "${event}". Event names must:
108
- - Only contain letters (a-z, A-Z), hyphens (-), and underscores (_)
109
- - Start and end with a letter (no leading/trailing - or _)`
116
+ throw new SmoreSDKError$1(
117
+ "INVALID_EVENT",
118
+ `Invalid event name "${event}". Event names must start with a letter, contain only letters, numbers, hyphens, or underscores, and end with a letter or number.`,
119
+ { details: { event } }
120
+ );
121
+ }
122
+ }
123
+ function validatePlayerIndex(playerIndex, controllersCount) {
124
+ if (typeof playerIndex !== "number" || !Number.isInteger(playerIndex)) {
125
+ throw new SmoreSDKError$1("INVALID_PLAYER", "Player index must be an integer");
126
+ }
127
+ if (playerIndex < 0 || playerIndex >= controllersCount) {
128
+ throw new SmoreSDKError$1(
129
+ "INVALID_PLAYER",
130
+ `Invalid player index ${playerIndex}. Valid range: 0-${controllersCount - 1}`,
131
+ { details: { playerIndex, controllersCount } }
110
132
  );
111
133
  }
112
134
  }
113
- class SmoreHost {
135
+ class DebugLogger {
136
+ enabled;
137
+ level;
138
+ prefix;
139
+ logSend;
140
+ logReceive;
141
+ logLifecycle;
142
+ customLogger;
143
+ static levelOrder = {
144
+ debug: 0,
145
+ info: 1,
146
+ warn: 2,
147
+ error: 3
148
+ };
149
+ constructor(options) {
150
+ if (typeof options === "boolean") {
151
+ this.enabled = options;
152
+ this.level = "debug";
153
+ this.prefix = "[SmoreScreen]";
154
+ this.logSend = true;
155
+ this.logReceive = true;
156
+ this.logLifecycle = true;
157
+ } else if (options) {
158
+ this.enabled = options.enabled ?? false;
159
+ this.level = options.level ?? "debug";
160
+ this.prefix = options.prefix ?? "[SmoreScreen]";
161
+ this.logSend = options.logSend ?? true;
162
+ this.logReceive = options.logReceive ?? true;
163
+ this.logLifecycle = options.logLifecycle ?? true;
164
+ this.customLogger = options.logger;
165
+ } else {
166
+ this.enabled = false;
167
+ this.level = "debug";
168
+ this.prefix = "[SmoreScreen]";
169
+ this.logSend = true;
170
+ this.logReceive = true;
171
+ this.logLifecycle = true;
172
+ }
173
+ }
174
+ shouldLog(level) {
175
+ return this.enabled && DebugLogger.levelOrder[level] >= DebugLogger.levelOrder[this.level];
176
+ }
177
+ log(level, message, data) {
178
+ if (!this.shouldLog(level)) return;
179
+ if (this.customLogger) {
180
+ this.customLogger(level, `${this.prefix} ${message}`, data);
181
+ return;
182
+ }
183
+ const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
184
+ if (data !== void 0) {
185
+ console[consoleMethod](`${this.prefix} ${message}`, data);
186
+ } else {
187
+ console[consoleMethod](`${this.prefix} ${message}`);
188
+ }
189
+ }
190
+ debug(message, data) {
191
+ this.log("debug", message, data);
192
+ }
193
+ info(message, data) {
194
+ this.log("info", message, data);
195
+ }
196
+ warn(message, data) {
197
+ this.log("warn", message, data);
198
+ }
199
+ error(message, data) {
200
+ this.log("error", message, data);
201
+ }
202
+ send(event, data) {
203
+ if (this.logSend) {
204
+ this.debug(`-> SEND: ${event}`, data);
205
+ }
206
+ }
207
+ receive(event, data) {
208
+ if (this.logReceive) {
209
+ this.debug(`<- RECV: ${event}`, data);
210
+ }
211
+ }
212
+ lifecycle(message, data) {
213
+ if (this.logLifecycle) {
214
+ this.info(`[Lifecycle] ${message}`, data);
215
+ }
216
+ }
217
+ }
218
+ class ScreenImpl {
114
219
  transport = null;
115
220
  config;
116
- _players = [];
221
+ logger;
222
+ _controllers = [];
117
223
  _roomCode = "";
118
224
  _leaderIndex = -1;
119
225
  _isReady = false;
120
226
  _isDestroyed = false;
227
+ eventHandlers = /* @__PURE__ */ new Map();
228
+ registeredTransportHandlers = [];
121
229
  boundMessageHandler = null;
122
- registeredHandlers = [];
123
230
  constructor(config = {}) {
124
231
  this.config = config;
232
+ this.logger = new DebugLogger(config.debug);
125
233
  if (config.listeners) {
126
234
  for (const event of Object.keys(config.listeners)) {
127
235
  validateEventName$1(event);
128
236
  }
129
237
  }
130
- if (config.socket) {
131
- this.initBundled(config);
132
- } else {
133
- this.initIframe(config);
134
- }
135
238
  }
136
239
  // ---------------------------------------------------------------------------
137
- // Initialization
240
+ // Initialization (called by factory)
138
241
  // ---------------------------------------------------------------------------
139
- initBundled(config) {
140
- if (!config.socket) {
141
- throw new Error("[SmoreHost] socket is required for bundled games");
142
- }
143
- this.transport = new DirectTransport(config.socket);
144
- this._roomCode = config.roomCode || "";
145
- this._players = config.players || [];
146
- this._leaderIndex = config.leaderIndex ?? -1;
147
- this.setupEventHandlers();
148
- this._isReady = true;
149
- this.config.onReady?.();
150
- }
151
- initIframe(config) {
152
- const parentOrigin = config.parentOrigin || "*";
153
- window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
154
- this.boundMessageHandler = (e) => {
155
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
156
- const msg = e.data;
157
- if (!isSmoreMessage(msg)) return;
158
- if (msg.type === "smore:init") {
159
- const initData = msg.payload;
160
- if (initData.side !== "host") {
161
- console.error("[SmoreHost] Received init for wrong side:", initData.side);
162
- return;
163
- }
164
- this.transport = new PostMessageTransport(parentOrigin);
165
- this._roomCode = initData.roomCode;
166
- this._players = this.mapPlayersFromInit(initData.players);
167
- this._leaderIndex = this.findLeaderIndex(initData.players, initData.leaderId);
168
- this.setupEventHandlers();
169
- this._isReady = true;
170
- this.config.onReady?.();
171
- } else if (msg.type === "smore:update") {
172
- const updateData = msg.payload;
173
- if (updateData.players) {
174
- this._players = this.mapPlayersFromInit(updateData.players);
175
- }
176
- if (updateData.leaderId !== void 0) {
177
- this._leaderIndex = this.findLeaderIndex(this._players, updateData.leaderId);
242
+ async initialize() {
243
+ this.logger.lifecycle("Initializing screen...");
244
+ const parentOrigin = this.config.parentOrigin ?? "*";
245
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUT$1;
246
+ return new Promise((resolve, reject) => {
247
+ const timeoutId = setTimeout(() => {
248
+ this.cleanup();
249
+ const error = new SmoreSDKError$1(
250
+ "TIMEOUT",
251
+ `Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends smore:init.`,
252
+ { details: { timeout } }
253
+ );
254
+ this.handleError(error);
255
+ reject(error);
256
+ }, timeout);
257
+ this.boundMessageHandler = (e) => {
258
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
259
+ const msg = e.data;
260
+ if (!isSmoreMessage(msg)) return;
261
+ if (msg.type === "smore:init") {
262
+ clearTimeout(timeoutId);
263
+ const initData = msg.payload;
264
+ if (initData.side !== "host") {
265
+ const error = new SmoreSDKError$1(
266
+ "INIT_FAILED",
267
+ `Received init for wrong side: ${initData.side}. Expected "host".`,
268
+ { details: { side: initData.side } }
269
+ );
270
+ this.handleError(error);
271
+ reject(error);
272
+ return;
273
+ }
274
+ this.transport = new PostMessageTransport(parentOrigin);
275
+ this._roomCode = initData.roomCode;
276
+ this._controllers = this.mapControllersFromInit(initData.players);
277
+ this._leaderIndex = this.findLeaderIndex(initData.players, initData.leaderId);
278
+ this.setupEventHandlers();
279
+ this._isReady = true;
280
+ this.logger.lifecycle("Screen ready", {
281
+ roomCode: this._roomCode,
282
+ controllers: this._controllers.length,
283
+ leaderIndex: this._leaderIndex
284
+ });
285
+ this.config.onReady?.();
286
+ resolve();
287
+ } else if (msg.type === "smore:update") {
288
+ const updateData = msg.payload;
289
+ if (updateData.players) {
290
+ this._controllers = this.mapControllersFromInit(updateData.players);
291
+ }
292
+ if (updateData.leaderId !== void 0) {
293
+ this._leaderIndex = this.findLeaderIndex(
294
+ updateData.players ?? [],
295
+ updateData.leaderId
296
+ );
297
+ }
298
+ this.logger.lifecycle("Room updated", {
299
+ controllers: this._controllers.length,
300
+ leaderIndex: this._leaderIndex
301
+ });
178
302
  }
179
- }
180
- };
181
- window.addEventListener("message", this.boundMessageHandler);
303
+ };
304
+ window.addEventListener("message", this.boundMessageHandler);
305
+ window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
306
+ this.logger.lifecycle("Sent smore:ready to parent");
307
+ });
182
308
  }
183
- mapPlayersFromInit(players) {
309
+ mapControllersFromInit(players) {
184
310
  return players.map((p, index) => ({
185
311
  playerIndex: p.playerIndex ?? index,
186
312
  nickname: p.nickname || `Player ${index + 1}`,
@@ -190,186 +316,239 @@
190
316
  }
191
317
  findLeaderIndex(players, leaderId) {
192
318
  if (!leaderId) return -1;
193
- const idx = players.findIndex((p) => p.sessionId === leaderId);
319
+ const idx = players.findIndex(
320
+ (p) => p.sessionId === leaderId
321
+ );
194
322
  return idx >= 0 ? idx : -1;
195
323
  }
196
324
  setupEventHandlers() {
197
325
  if (!this.transport) return;
198
- this.registerHandler(SYSTEM_EVENTS$1.PLAYER_JOIN, (data) => {
199
- const playerIndex = data.player?.playerIndex;
200
- if (playerIndex !== void 0) {
201
- this.config.onPlayerJoin?.(playerIndex);
326
+ this.registerTransportHandler(SYSTEM_EVENTS$1.PLAYER_JOIN, (data) => {
327
+ const payload = data;
328
+ const player = payload?.player;
329
+ if (player && typeof player.playerIndex === "number") {
330
+ this.logger.lifecycle("Controller joined", { playerIndex: player.playerIndex });
331
+ this.config.onControllerJoin?.(player.playerIndex, player);
332
+ }
333
+ });
334
+ this.registerTransportHandler(SYSTEM_EVENTS$1.PLAYER_LEAVE, (data) => {
335
+ const payload = data;
336
+ if (typeof payload?.playerIndex === "number") {
337
+ this.logger.lifecycle("Controller left", { playerIndex: payload.playerIndex });
338
+ this.config.onControllerLeave?.(payload.playerIndex);
202
339
  }
203
340
  });
204
- this.registerHandler(SYSTEM_EVENTS$1.PLAYER_LEAVE, (data) => {
205
- if (data.playerIndex !== void 0) {
206
- this.config.onPlayerLeave?.(data.playerIndex);
341
+ this.registerTransportHandler(SYSTEM_EVENTS$1.PLAYER_RECONNECT, (data) => {
342
+ const payload = data;
343
+ const player = payload?.player;
344
+ if (player && typeof player.playerIndex === "number") {
345
+ this.logger.lifecycle("Controller reconnected", { playerIndex: player.playerIndex });
346
+ this.config.onControllerReconnect?.(player.playerIndex, player);
207
347
  }
208
348
  });
209
- this.registerHandler("room:player-joined", (data) => {
210
- const playerIndex = data?.player?.playerIndex ?? data?.playerIndex;
211
- if (playerIndex !== void 0) {
212
- this.config.onPlayerJoin?.(playerIndex);
349
+ this.registerTransportHandler("room:player-joined", (data) => {
350
+ const payload = data;
351
+ const playerIndex = payload?.player?.playerIndex ?? payload?.playerIndex;
352
+ if (typeof playerIndex === "number") {
353
+ this.config.onControllerJoin?.(playerIndex, {
354
+ playerIndex,
355
+ nickname: `Player ${playerIndex + 1}`,
356
+ connected: true
357
+ });
213
358
  }
214
359
  });
215
- this.registerHandler("room:player-left", (data) => {
216
- const playerIndex = data?.playerIndex ?? data?.player?.playerIndex;
217
- if (playerIndex !== void 0) {
218
- this.config.onPlayerLeave?.(playerIndex);
360
+ this.registerTransportHandler("room:player-left", (data) => {
361
+ const payload = data;
362
+ const playerIndex = payload?.playerIndex ?? payload?.player?.playerIndex;
363
+ if (typeof playerIndex === "number") {
364
+ this.config.onControllerLeave?.(playerIndex);
219
365
  }
220
366
  });
221
367
  if (this.config.listeners) {
222
368
  for (const [event, handler] of Object.entries(this.config.listeners)) {
223
369
  if (!handler) continue;
224
- this.registerHandler(event, (data) => {
225
- const { playerIndex, ...rest } = data;
226
- if (playerIndex !== void 0) {
227
- handler(playerIndex, rest);
228
- }
229
- });
370
+ this.setupUserEventHandler(event, handler);
230
371
  }
231
372
  }
232
373
  }
233
- registerHandler(event, handler) {
374
+ setupUserEventHandler(event, handler) {
375
+ const wrappedHandler = (data) => {
376
+ this.logger.receive(event, data);
377
+ const payload = data;
378
+ const { playerIndex, ...rest } = payload;
379
+ if (typeof playerIndex === "number") {
380
+ try {
381
+ handler(playerIndex, rest);
382
+ } catch (err) {
383
+ this.handleError(
384
+ new SmoreSDKError$1("UNKNOWN", `Error in handler for event "${event}"`, {
385
+ cause: err instanceof Error ? err : void 0,
386
+ details: { event, playerIndex }
387
+ })
388
+ );
389
+ }
390
+ }
391
+ };
392
+ this.registerTransportHandler(event, wrappedHandler);
393
+ let handlers = this.eventHandlers.get(event);
394
+ if (!handlers) {
395
+ handlers = /* @__PURE__ */ new Set();
396
+ this.eventHandlers.set(event, handlers);
397
+ }
398
+ handlers.add(handler);
399
+ }
400
+ registerTransportHandler(event, handler) {
234
401
  if (!this.transport) return;
235
402
  this.transport.on(event, handler);
236
- this.registeredHandlers.push({ event, handler });
403
+ this.registeredTransportHandlers.push({ event, handler });
237
404
  }
238
405
  // ---------------------------------------------------------------------------
239
- // Public Properties
406
+ // Properties (readonly)
240
407
  // ---------------------------------------------------------------------------
241
- /**
242
- * Get all players in the room.
243
- * Returns a copy to prevent external mutation.
244
- */
245
- get players() {
246
- return [...this._players];
247
- }
248
- /**
249
- * Get the room code.
250
- */
408
+ get controllers() {
409
+ return [...this._controllers];
410
+ }
251
411
  get roomCode() {
252
412
  return this._roomCode;
253
413
  }
254
- /**
255
- * Get the leader's player index (-1 if no leader).
256
- */
257
414
  get leaderIndex() {
258
415
  return this._leaderIndex;
259
416
  }
260
- /**
261
- * Check if the host is initialized and ready.
262
- */
263
417
  get isReady() {
264
418
  return this._isReady;
265
419
  }
420
+ get isDestroyed() {
421
+ return this._isDestroyed;
422
+ }
266
423
  // ---------------------------------------------------------------------------
267
- // Public Methods
424
+ // Communication Methods
268
425
  // ---------------------------------------------------------------------------
269
- /**
270
- * Broadcast an event to all players.
271
- *
272
- * @param event - Event name (no colons allowed)
273
- * @param data - Optional data payload
274
- *
275
- * @example
276
- * ```ts
277
- * host.broadcast('phase-update', { phase: 'playing' });
278
- * host.broadcast('timer-tick', { remaining: 30 });
279
- * ```
280
- */
281
426
  broadcast(event, data) {
282
427
  this.ensureReady("broadcast");
283
428
  validateEventName$1(event);
429
+ this.logger.send(event, data);
430
+ this.transport.emit(event, data);
431
+ }
432
+ broadcastRaw(event, data) {
433
+ this.ensureReady("broadcastRaw");
434
+ validateEventName$1(event);
435
+ this.logger.send(event, data);
284
436
  this.transport.emit(event, data);
285
437
  }
286
- /**
287
- * Send an event to a specific player.
288
- *
289
- * @param playerIndex - Target player index (0, 1, 2, ...)
290
- * @param event - Event name (no colons allowed)
291
- * @param data - Optional data payload
292
- *
293
- * @example
294
- * ```ts
295
- * host.sendToPlayer(0, 'your-turn', { timeLimit: 30 });
296
- * host.sendToPlayer(1, 'wait', { message: 'Not your turn' });
297
- * ```
298
- */
299
- sendToPlayer(playerIndex, event, data) {
300
- this.ensureReady("sendToPlayer");
438
+ sendToController(playerIndex, event, data) {
439
+ this.ensureReady("sendToController");
301
440
  validateEventName$1(event);
441
+ validatePlayerIndex(playerIndex, this._controllers.length);
442
+ this.logger.send(`${event} -> Player ${playerIndex}`, data);
302
443
  this.transport.emit(event, {
303
444
  targetPlayerIndex: playerIndex,
304
445
  ...data && typeof data === "object" ? data : { data }
305
446
  });
306
447
  }
307
- /**
308
- * Signal game over with results.
309
- * This will broadcast the game over event to all players.
310
- *
311
- * @param results - Game results (scores, winner, etc.)
312
- *
313
- * @example
314
- * ```ts
315
- * host.gameOver({
316
- * scores: { 0: 100, 1: 75, 2: 50 },
317
- * winner: 0,
318
- * });
319
- * ```
320
- */
448
+ sendToControllerRaw(playerIndex, event, data) {
449
+ this.ensureReady("sendToControllerRaw");
450
+ validateEventName$1(event);
451
+ validatePlayerIndex(playerIndex, this._controllers.length);
452
+ this.logger.send(`${event} -> Player ${playerIndex}`, data);
453
+ this.transport.emit(event, {
454
+ targetPlayerIndex: playerIndex,
455
+ ...data && typeof data === "object" ? data : { data }
456
+ });
457
+ }
458
+ // ---------------------------------------------------------------------------
459
+ // Game Lifecycle
460
+ // ---------------------------------------------------------------------------
321
461
  gameOver(results) {
322
462
  this.ensureReady("gameOver");
463
+ this.logger.lifecycle("Game over", results);
323
464
  this.transport.emit(SYSTEM_EVENTS$1.GAME_OVER, { results });
324
465
  }
325
- /**
326
- * Add a listener for a specific event after construction.
327
- *
328
- * @param event - Event name (no colons allowed)
329
- * @param handler - Handler function (playerIndex, data) => void
330
- * @returns Cleanup function to remove the listener
331
- *
332
- * @example
333
- * ```ts
334
- * const cleanup = host.on('tap', (playerIndex, data) => {
335
- * console.log(`Player ${playerIndex} tapped`);
336
- * });
337
- *
338
- * // Later
339
- * cleanup();
340
- * ```
341
- */
466
+ // ---------------------------------------------------------------------------
467
+ // Event Subscription
468
+ // ---------------------------------------------------------------------------
342
469
  on(event, handler) {
343
470
  validateEventName$1(event);
344
- const wrappedHandler = (data) => {
345
- const { playerIndex, ...rest } = data;
346
- if (playerIndex !== void 0) {
347
- handler(playerIndex, rest);
348
- }
349
- };
471
+ let handlers = this.eventHandlers.get(event);
472
+ if (!handlers) {
473
+ handlers = /* @__PURE__ */ new Set();
474
+ this.eventHandlers.set(event, handlers);
475
+ }
476
+ handlers.add(handler);
350
477
  if (this.transport) {
351
- this.transport.on(event, wrappedHandler);
352
- this.registeredHandlers.push({ event, handler: wrappedHandler });
478
+ const wrappedHandler = (data) => {
479
+ this.logger.receive(event, data);
480
+ const payload = data;
481
+ const { playerIndex, ...rest } = payload;
482
+ if (typeof playerIndex === "number") {
483
+ try {
484
+ handler(playerIndex, rest);
485
+ } catch (err) {
486
+ this.handleError(
487
+ new SmoreSDKError$1("UNKNOWN", `Error in handler for event "${event}"`, {
488
+ cause: err instanceof Error ? err : void 0
489
+ })
490
+ );
491
+ }
492
+ }
493
+ };
494
+ this.registerTransportHandler(event, wrappedHandler);
353
495
  }
354
496
  return () => {
355
- this.transport?.off(event, wrappedHandler);
356
- this.registeredHandlers = this.registeredHandlers.filter(
357
- (h) => h.event !== event || h.handler !== wrappedHandler
358
- );
497
+ handlers?.delete(handler);
498
+ if (handlers?.size === 0) {
499
+ this.eventHandlers.delete(event);
500
+ }
359
501
  };
360
502
  }
361
- /**
362
- * Clean up all resources.
363
- * Call this when unmounting/destroying the game.
364
- */
503
+ once(event, handler) {
504
+ const wrappedHandler = (playerIndex, data) => {
505
+ unsubscribe();
506
+ handler(playerIndex, data);
507
+ };
508
+ const unsubscribe = this.on(event, wrappedHandler);
509
+ return unsubscribe;
510
+ }
511
+ off(event, handler) {
512
+ if (!handler) {
513
+ this.eventHandlers.delete(event);
514
+ this.transport?.off(event);
515
+ } else {
516
+ const handlers = this.eventHandlers.get(event);
517
+ handlers?.delete(handler);
518
+ if (handlers?.size === 0) {
519
+ this.eventHandlers.delete(event);
520
+ }
521
+ }
522
+ }
523
+ // ---------------------------------------------------------------------------
524
+ // Utilities
525
+ // ---------------------------------------------------------------------------
526
+ getController(playerIndex) {
527
+ return this._controllers.find((c) => c.playerIndex === playerIndex);
528
+ }
529
+ isLeader(playerIndex) {
530
+ return playerIndex === this._leaderIndex;
531
+ }
532
+ getControllerCount() {
533
+ return this._controllers.filter((c) => c.connected).length;
534
+ }
535
+ // ---------------------------------------------------------------------------
536
+ // Cleanup
537
+ // ---------------------------------------------------------------------------
365
538
  destroy() {
366
539
  if (this._isDestroyed) return;
540
+ this.logger.lifecycle("Destroying screen...");
367
541
  this._isDestroyed = true;
368
542
  this._isReady = false;
369
- for (const { event, handler } of this.registeredHandlers) {
543
+ this.cleanup();
544
+ this.logger.lifecycle("Screen destroyed");
545
+ }
546
+ cleanup() {
547
+ for (const { event, handler } of this.registeredTransportHandlers) {
370
548
  this.transport?.off(event, handler);
371
549
  }
372
- this.registeredHandlers = [];
550
+ this.registeredTransportHandlers = [];
551
+ this.eventHandlers.clear();
373
552
  if (this.transport instanceof PostMessageTransport) {
374
553
  this.transport.destroy();
375
554
  }
@@ -380,36 +559,126 @@
380
559
  }
381
560
  }
382
561
  // ---------------------------------------------------------------------------
383
- // Private Helpers
562
+ // Error Handling
384
563
  // ---------------------------------------------------------------------------
385
- ensureReady(method) {
386
- if (!this._isReady || !this.transport) {
387
- throw new Error(`[SmoreHost] Cannot call ${method}() before host is ready. Wait for onReady callback.`);
564
+ handleError(error) {
565
+ const smoreError = error.toSmoreError();
566
+ if (this.config.onError) {
567
+ this.config.onError(smoreError);
568
+ } else {
569
+ this.logger.error(error.message, error.details);
388
570
  }
571
+ }
572
+ ensureReady(method) {
389
573
  if (this._isDestroyed) {
390
- throw new Error(`[SmoreHost] Cannot call ${method}() after destroy()`);
574
+ throw new SmoreSDKError$1(
575
+ "DESTROYED",
576
+ `Cannot call ${method}() after destroy()`,
577
+ { details: { method } }
578
+ );
579
+ }
580
+ if (!this._isReady || !this.transport) {
581
+ throw new SmoreSDKError$1(
582
+ "NOT_READY",
583
+ `Cannot call ${method}() before screen is ready. Use await createScreen() or onReady callback.`,
584
+ { details: { method } }
585
+ );
391
586
  }
392
587
  }
393
588
  }
589
+ function createScreen(config) {
590
+ const screen = new ScreenImpl(config);
591
+ const promise = screen.initialize().then(() => screen);
592
+ promise.instance = screen;
593
+ return promise;
594
+ }
394
595
 
395
596
  const SYSTEM_PREFIX = "smore:";
396
597
  const SYSTEM_EVENTS = {
397
598
  PLAYER_JOIN: `${SYSTEM_PREFIX}player-join`,
398
599
  PLAYER_LEAVE: `${SYSTEM_PREFIX}player-leave`
399
600
  };
400
- const EVENT_NAME_REGEX = /^[a-zA-Z]([a-zA-Z_-]*[a-zA-Z])?$/;
601
+ const DEFAULT_TIMEOUT = 1e4;
602
+ class SmoreSDKError extends Error {
603
+ code;
604
+ cause;
605
+ details;
606
+ constructor(code, message, options) {
607
+ super(message);
608
+ this.name = "SmoreSDKError";
609
+ this.code = code;
610
+ this.cause = options?.cause;
611
+ this.details = options?.details;
612
+ const ErrorWithCapture = Error;
613
+ if (typeof ErrorWithCapture.captureStackTrace === "function") {
614
+ ErrorWithCapture.captureStackTrace(this, SmoreSDKError);
615
+ }
616
+ }
617
+ toSmoreError() {
618
+ return {
619
+ code: this.code,
620
+ message: this.message,
621
+ cause: this.cause,
622
+ details: this.details
623
+ };
624
+ }
625
+ }
626
+ const EVENT_NAME_REGEX = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
401
627
  function validateEventName(event) {
628
+ if (!event || typeof event !== "string") {
629
+ throw new SmoreSDKError("INVALID_EVENT", "Event name must be a non-empty string");
630
+ }
402
631
  if (!EVENT_NAME_REGEX.test(event)) {
403
- throw new Error(
404
- `[SmorePlayer] Invalid event name "${event}". Event names must:
405
- - Only contain letters (a-z, A-Z), hyphens (-), and underscores (_)
406
- - Start and end with a letter (no leading/trailing - or _)`
632
+ throw new SmoreSDKError(
633
+ "INVALID_EVENT",
634
+ `Invalid event name "${event}". Event names must:
635
+ - Start with a letter (a-z, A-Z)
636
+ - Only contain letters, numbers, hyphens (-), and underscores (_)
637
+ - End with a letter or number`,
638
+ { details: { event } }
407
639
  );
408
640
  }
409
641
  }
410
- class SmorePlayer {
642
+ function createLogger(options) {
643
+ const enabled = typeof options === "boolean" ? options : options?.enabled ?? false;
644
+ const level = (typeof options === "object" ? options.level : void 0) ?? "debug";
645
+ const prefix = (typeof options === "object" ? options.prefix : void 0) ?? "[SmoreController]";
646
+ const customLogger = typeof options === "object" ? options.logger : void 0;
647
+ const levelPriority = {
648
+ debug: 0,
649
+ info: 1,
650
+ warn: 2,
651
+ error: 3
652
+ };
653
+ const shouldLog = (msgLevel) => {
654
+ if (!enabled) return false;
655
+ return levelPriority[msgLevel] >= levelPriority[level];
656
+ };
657
+ const log = (msgLevel, message, data) => {
658
+ if (!shouldLog(msgLevel)) return;
659
+ if (customLogger) {
660
+ customLogger(msgLevel, message, data);
661
+ return;
662
+ }
663
+ const fullMessage = `${prefix} ${message}`;
664
+ const consoleFn = console[msgLevel] ?? console.log;
665
+ if (data !== void 0) {
666
+ consoleFn(fullMessage, data);
667
+ } else {
668
+ consoleFn(fullMessage);
669
+ }
670
+ };
671
+ return {
672
+ debug: (msg, data) => log("debug", msg, data),
673
+ info: (msg, data) => log("info", msg, data),
674
+ warn: (msg, data) => log("warn", msg, data),
675
+ error: (msg, data) => log("error", msg, data)
676
+ };
677
+ }
678
+ class ControllerImpl {
411
679
  transport = null;
412
680
  config;
681
+ logger;
413
682
  _roomCode = "";
414
683
  _myIndex = -1;
415
684
  _isLeader = false;
@@ -417,83 +686,138 @@
417
686
  _isDestroyed = false;
418
687
  boundMessageHandler = null;
419
688
  registeredHandlers = [];
689
+ eventListeners = /* @__PURE__ */ new Map();
420
690
  constructor(config = {}) {
421
691
  this.config = config;
692
+ this.logger = createLogger(config.debug);
422
693
  if (config.listeners) {
423
694
  for (const event of Object.keys(config.listeners)) {
424
695
  validateEventName(event);
425
696
  }
426
697
  }
427
- if (config.socket) {
428
- this.initBundled(config);
429
- } else {
430
- this.initIframe(config);
431
- }
698
+ }
699
+ // ---------------------------------------------------------------------------
700
+ // Properties (readonly)
701
+ // ---------------------------------------------------------------------------
702
+ get myIndex() {
703
+ return this._myIndex;
704
+ }
705
+ get isLeader() {
706
+ return this._isLeader;
707
+ }
708
+ get roomCode() {
709
+ return this._roomCode;
710
+ }
711
+ get isReady() {
712
+ return this._isReady;
713
+ }
714
+ get isDestroyed() {
715
+ return this._isDestroyed;
432
716
  }
433
717
  // ---------------------------------------------------------------------------
434
718
  // Initialization
435
719
  // ---------------------------------------------------------------------------
436
- initBundled(config) {
437
- if (!config.socket) {
438
- throw new Error("[SmorePlayer] socket is required for bundled games");
439
- }
440
- this.transport = new DirectTransport(config.socket);
441
- this._roomCode = config.roomCode || "";
442
- this._myIndex = config.myIndex ?? -1;
443
- this._isLeader = config.isLeader ?? false;
720
+ async initialize() {
721
+ const parentOrigin = this.config.parentOrigin ?? "*";
722
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
723
+ this.logger.debug("Initializing controller...", { parentOrigin, timeout });
724
+ return new Promise((resolve, reject) => {
725
+ const timeoutId = setTimeout(() => {
726
+ this.cleanup();
727
+ const error = new SmoreSDKError(
728
+ "TIMEOUT",
729
+ `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends smore:init message.`,
730
+ { details: { timeout } }
731
+ );
732
+ this.handleError(error);
733
+ reject(error);
734
+ }, timeout);
735
+ this.boundMessageHandler = (e) => {
736
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
737
+ const msg = e.data;
738
+ if (!isSmoreMessage(msg)) return;
739
+ if (msg.type === "smore:init") {
740
+ clearTimeout(timeoutId);
741
+ this.handleInit(msg, parentOrigin, resolve, reject);
742
+ } else if (msg.type === "smore:update") {
743
+ this.handleUpdate(msg);
744
+ }
745
+ };
746
+ window.addEventListener("message", this.boundMessageHandler);
747
+ this.logger.debug("Sending smore:ready to parent");
748
+ window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
749
+ });
750
+ }
751
+ handleInit(msg, parentOrigin, resolve, reject) {
752
+ const initData = msg.payload;
753
+ this.logger.debug("Received smore:init", initData);
754
+ if (initData.side !== "player") {
755
+ const error = new SmoreSDKError(
756
+ "INIT_FAILED",
757
+ `Controller received init for wrong side: ${initData.side}`,
758
+ { details: { side: initData.side } }
759
+ );
760
+ this.handleError(error);
761
+ reject(error);
762
+ return;
763
+ }
764
+ if (initData.myIndex === void 0) {
765
+ const error = new SmoreSDKError(
766
+ "INIT_FAILED",
767
+ "Missing myIndex in init payload",
768
+ { details: initData }
769
+ );
770
+ this.handleError(error);
771
+ reject(error);
772
+ return;
773
+ }
774
+ this.transport = new PostMessageTransport(parentOrigin);
775
+ this._roomCode = initData.roomCode;
776
+ this._myIndex = initData.myIndex;
777
+ this._isLeader = initData.isLeader ?? false;
444
778
  this.setupEventHandlers();
445
779
  this._isReady = true;
780
+ this.logger.info("Controller ready", {
781
+ roomCode: this._roomCode,
782
+ myIndex: this._myIndex,
783
+ isLeader: this._isLeader
784
+ });
446
785
  this.config.onReady?.();
786
+ resolve();
447
787
  }
448
- initIframe(config) {
449
- const parentOrigin = config.parentOrigin || "*";
450
- window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
451
- this.boundMessageHandler = (e) => {
452
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
453
- const msg = e.data;
454
- if (!isSmoreMessage(msg)) return;
455
- if (msg.type === "smore:init") {
456
- const initData = msg.payload;
457
- if (initData.side !== "player") {
458
- console.error("[SmorePlayer] Received init for wrong side:", initData.side);
459
- return;
460
- }
461
- if (initData.myIndex === void 0) {
462
- console.error("[SmorePlayer] Missing myIndex in init payload");
463
- return;
464
- }
465
- this.transport = new PostMessageTransport(parentOrigin);
466
- this._roomCode = initData.roomCode;
467
- this._myIndex = initData.myIndex;
468
- this._isLeader = initData.isLeader ?? false;
469
- this.setupEventHandlers();
470
- this._isReady = true;
471
- this.config.onReady?.();
472
- } else if (msg.type === "smore:update") {
473
- const updateData = msg.payload;
474
- if (updateData.leaderId !== void 0) ;
475
- }
476
- };
477
- window.addEventListener("message", this.boundMessageHandler);
788
+ handleUpdate(msg) {
789
+ const updateData = msg.payload;
790
+ this.logger.debug("Received smore:update", updateData);
478
791
  }
479
792
  setupEventHandlers() {
480
793
  if (!this.transport) return;
481
- this.registerHandler(SYSTEM_EVENTS.PLAYER_JOIN, (data) => {
482
- const playerIndex = data.player?.playerIndex ?? data.playerIndex;
483
- if (playerIndex !== void 0) {
484
- this.config.onPlayerJoin?.(playerIndex);
794
+ this.registerHandler(
795
+ SYSTEM_EVENTS.PLAYER_JOIN,
796
+ (data) => {
797
+ const playerIndex = data.player?.playerIndex ?? data.playerIndex;
798
+ if (playerIndex !== void 0) {
799
+ this.logger.debug("Player joined", { playerIndex });
800
+ this.config.onControllerJoin?.(playerIndex, data.player);
801
+ }
485
802
  }
486
- });
487
- this.registerHandler(SYSTEM_EVENTS.PLAYER_LEAVE, (data) => {
488
- const playerIndex = data.player?.playerIndex ?? data.playerIndex;
489
- if (playerIndex !== void 0) {
490
- this.config.onPlayerLeave?.(playerIndex);
803
+ );
804
+ this.registerHandler(
805
+ SYSTEM_EVENTS.PLAYER_LEAVE,
806
+ (data) => {
807
+ const playerIndex = data.player?.playerIndex ?? data.playerIndex;
808
+ if (playerIndex !== void 0) {
809
+ this.logger.debug("Player left", { playerIndex });
810
+ this.config.onControllerLeave?.(playerIndex);
811
+ }
491
812
  }
492
- });
813
+ );
493
814
  if (this.config.listeners) {
494
815
  for (const [event, handler] of Object.entries(this.config.listeners)) {
495
816
  if (!handler) continue;
496
- this.registerHandler(event, handler);
817
+ this.registerHandler(event, (data) => {
818
+ this.logReceive(event, data);
819
+ handler(data);
820
+ });
497
821
  }
498
822
  }
499
823
  }
@@ -503,98 +827,90 @@
503
827
  this.registeredHandlers.push({ event, handler });
504
828
  }
505
829
  // ---------------------------------------------------------------------------
506
- // Public Properties
830
+ // Communication Methods
507
831
  // ---------------------------------------------------------------------------
508
- /**
509
- * Get my player index (0, 1, 2, ...).
510
- */
511
- get myIndex() {
512
- return this._myIndex;
513
- }
514
- /**
515
- * Check if I am the room leader.
516
- */
517
- get isLeader() {
518
- return this._isLeader;
519
- }
520
- /**
521
- * Get the room code.
522
- */
523
- get roomCode() {
524
- return this._roomCode;
525
- }
526
- /**
527
- * Check if the player is initialized and ready.
528
- */
529
- get isReady() {
530
- return this._isReady;
531
- }
532
- // ---------------------------------------------------------------------------
533
- // Public Methods
534
- // ---------------------------------------------------------------------------
535
- /**
536
- * Send an event to the host.
537
- *
538
- * @param event - Event name (no colons allowed)
539
- * @param data - Optional data payload
540
- *
541
- * @example
542
- * ```ts
543
- * player.send('tap', { timestamp: Date.now() });
544
- * player.send('answer', { choice: 2 });
545
- * ```
546
- */
547
832
  send(event, data) {
548
833
  this.ensureReady("send");
549
834
  validateEventName(event);
835
+ this.logSend(event, data);
550
836
  this.transport.emit(event, data);
551
837
  }
552
- /**
553
- * Add a listener for a specific event after construction.
554
- *
555
- * @param event - Event name (no colons allowed)
556
- * @param handler - Handler function (data) => void
557
- * @returns Cleanup function to remove the listener
558
- *
559
- * @example
560
- * ```ts
561
- * const cleanup = player.on('phase-update', (data) => {
562
- * console.log('New phase:', data.phase);
563
- * });
564
- *
565
- * // Later
566
- * cleanup();
567
- * ```
568
- */
838
+ sendRaw(event, data) {
839
+ this.ensureReady("sendRaw");
840
+ validateEventName(event);
841
+ this.logSend(event, data);
842
+ this.transport.emit(event, data);
843
+ }
844
+ // ---------------------------------------------------------------------------
845
+ // Event Subscription
846
+ // ---------------------------------------------------------------------------
569
847
  on(event, handler) {
570
848
  validateEventName(event);
849
+ let listeners = this.eventListeners.get(event);
850
+ if (!listeners) {
851
+ listeners = /* @__PURE__ */ new Set();
852
+ this.eventListeners.set(event, listeners);
853
+ }
854
+ listeners.add(handler);
855
+ const transportHandler = (data) => {
856
+ this.logReceive(event, data);
857
+ handler(data);
858
+ };
571
859
  if (this.transport) {
572
- this.transport.on(event, handler);
573
- this.registeredHandlers.push({ event, handler });
860
+ this.transport.on(event, transportHandler);
861
+ this.registeredHandlers.push({ event, handler: transportHandler });
574
862
  }
575
863
  return () => {
576
- this.transport?.off(event, handler);
864
+ listeners?.delete(handler);
865
+ if (listeners?.size === 0) {
866
+ this.eventListeners.delete(event);
867
+ }
868
+ this.transport?.off(event, transportHandler);
577
869
  this.registeredHandlers = this.registeredHandlers.filter(
578
- (h) => h.event !== event || h.handler !== handler
870
+ (h) => h.handler !== transportHandler
579
871
  );
580
872
  };
581
873
  }
582
- /**
583
- * Clean up all resources.
584
- * Call this when unmounting/destroying the game.
585
- */
874
+ once(event, handler) {
875
+ const unsubscribe = this.on(event, ((data) => {
876
+ unsubscribe();
877
+ handler(data);
878
+ }));
879
+ return unsubscribe;
880
+ }
881
+ off(event, handler) {
882
+ if (!handler) {
883
+ this.eventListeners.delete(event);
884
+ this.transport?.off(event);
885
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
886
+ } else {
887
+ const listeners = this.eventListeners.get(event);
888
+ listeners?.delete(handler);
889
+ if (listeners?.size === 0) {
890
+ this.eventListeners.delete(event);
891
+ }
892
+ }
893
+ }
894
+ // ---------------------------------------------------------------------------
895
+ // Cleanup
896
+ // ---------------------------------------------------------------------------
586
897
  destroy() {
587
898
  if (this._isDestroyed) return;
899
+ this.logger.info("Destroying controller");
900
+ this.cleanup();
588
901
  this._isDestroyed = true;
902
+ }
903
+ cleanup() {
589
904
  this._isReady = false;
590
905
  for (const { event, handler } of this.registeredHandlers) {
591
906
  this.transport?.off(event, handler);
592
907
  }
593
908
  this.registeredHandlers = [];
594
- if (this.transport instanceof PostMessageTransport) {
909
+ this.eventListeners.clear();
910
+ if (this.transport) {
595
911
  this.transport.destroy();
912
+ this.transport = null;
596
913
  }
597
- this.transport = null;
598
914
  if (this.boundMessageHandler) {
599
915
  window.removeEventListener("message", this.boundMessageHandler);
600
916
  this.boundMessageHandler = null;
@@ -604,11 +920,65 @@
604
920
  // Private Helpers
605
921
  // ---------------------------------------------------------------------------
606
922
  ensureReady(method) {
923
+ if (this._isDestroyed) {
924
+ throw new SmoreSDKError(
925
+ "DESTROYED",
926
+ `Cannot call ${method}() after destroy()`,
927
+ { details: { method } }
928
+ );
929
+ }
607
930
  if (!this._isReady || !this.transport) {
608
- throw new Error(`[SmorePlayer] Cannot call ${method}() before player is ready. Wait for onReady callback.`);
931
+ throw new SmoreSDKError(
932
+ "NOT_READY",
933
+ `Cannot call ${method}() before controller is ready. Use await createController() or wait for onReady callback.`,
934
+ { details: { method, isReady: this._isReady } }
935
+ );
609
936
  }
610
- if (this._isDestroyed) {
611
- throw new Error(`[SmorePlayer] Cannot call ${method}() after destroy()`);
937
+ }
938
+ handleError(error) {
939
+ if (this.config.onError) {
940
+ this.config.onError(error.toSmoreError());
941
+ } else {
942
+ this.logger.error(error.message, error.details);
943
+ }
944
+ }
945
+ logSend(event, data) {
946
+ const options = this.config.debug;
947
+ const shouldLog = typeof options === "object" ? options.logSend ?? true : Boolean(options);
948
+ if (shouldLog) {
949
+ this.logger.debug(`\u2192 SEND [${event}]`, data);
950
+ }
951
+ }
952
+ logReceive(event, data) {
953
+ const options = this.config.debug;
954
+ const shouldLog = typeof options === "object" ? options.logReceive ?? true : Boolean(options);
955
+ if (shouldLog) {
956
+ this.logger.debug(`\u2190 RECV [${event}]`, data);
957
+ }
958
+ }
959
+ }
960
+ function createController(config) {
961
+ const controller = new ControllerImpl(config ?? {});
962
+ const promise = controller.initialize().then(() => controller);
963
+ promise.instance = controller;
964
+ return promise;
965
+ }
966
+
967
+ class DirectTransport {
968
+ constructor(socket) {
969
+ this.socket = socket;
970
+ }
971
+ emit(event, ...args) {
972
+ this.socket.emit(event, ...args);
973
+ }
974
+ on(event, handler) {
975
+ this.socket.on(event, handler);
976
+ }
977
+ off(event, handler) {
978
+ if (handler) {
979
+ this.socket.off(event, handler);
980
+ } else {
981
+ this.socket.off(event);
612
982
  }
613
983
  }
614
984
  }
@@ -638,11 +1008,266 @@
638
1008
  return event.startsWith("smore:");
639
1009
  }
640
1010
 
1011
+ function createMockScreen(options = {}) {
1012
+ const {
1013
+ roomCode = "TEST",
1014
+ controllers: initialControllers = [],
1015
+ autoReady = true
1016
+ } = options;
1017
+ let _controllers = [...initialControllers];
1018
+ let _isReady = false;
1019
+ let _isDestroyed = false;
1020
+ let _leaderIndex = initialControllers[0]?.playerIndex ?? -1;
1021
+ const listeners = /* @__PURE__ */ new Map();
1022
+ const broadcasts = [];
1023
+ const sends = [];
1024
+ const screen = {
1025
+ // === Properties ===
1026
+ get controllers() {
1027
+ return [..._controllers];
1028
+ },
1029
+ get roomCode() {
1030
+ return roomCode;
1031
+ },
1032
+ get leaderIndex() {
1033
+ return _leaderIndex;
1034
+ },
1035
+ get isReady() {
1036
+ return _isReady;
1037
+ },
1038
+ get isDestroyed() {
1039
+ return _isDestroyed;
1040
+ },
1041
+ // === Communication Methods ===
1042
+ broadcast(event, data) {
1043
+ if (_isDestroyed) {
1044
+ throw new Error("Cannot broadcast: screen is destroyed");
1045
+ }
1046
+ broadcasts.push({ event, data });
1047
+ },
1048
+ broadcastRaw(event, data) {
1049
+ if (_isDestroyed) {
1050
+ throw new Error("Cannot broadcast: screen is destroyed");
1051
+ }
1052
+ broadcasts.push({ event, data });
1053
+ },
1054
+ sendToController(playerIndex, event, data) {
1055
+ if (_isDestroyed) {
1056
+ throw new Error("Cannot send: screen is destroyed");
1057
+ }
1058
+ if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1059
+ throw new Error(`Invalid player index: ${playerIndex}`);
1060
+ }
1061
+ sends.push({ playerIndex, event, data });
1062
+ },
1063
+ sendToControllerRaw(playerIndex, event, data) {
1064
+ if (_isDestroyed) {
1065
+ throw new Error("Cannot send: screen is destroyed");
1066
+ }
1067
+ if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1068
+ throw new Error(`Invalid player index: ${playerIndex}`);
1069
+ }
1070
+ sends.push({ playerIndex, event, data });
1071
+ },
1072
+ // === Game Lifecycle ===
1073
+ gameOver(results) {
1074
+ screen.broadcastRaw("game-over", results);
1075
+ },
1076
+ // === Event Subscription ===
1077
+ on(event, handler) {
1078
+ const eventStr = event;
1079
+ if (!listeners.has(eventStr)) {
1080
+ listeners.set(eventStr, /* @__PURE__ */ new Set());
1081
+ }
1082
+ listeners.get(eventStr).add(handler);
1083
+ return () => {
1084
+ listeners.get(eventStr)?.delete(handler);
1085
+ };
1086
+ },
1087
+ once(event, handler) {
1088
+ const wrapper = (playerIndex, data) => {
1089
+ handler(playerIndex, data);
1090
+ screen.off(event, wrapper);
1091
+ };
1092
+ return screen.on(event, wrapper);
1093
+ },
1094
+ off(event, handler) {
1095
+ const eventStr = event;
1096
+ if (!handler) {
1097
+ listeners.delete(eventStr);
1098
+ } else {
1099
+ listeners.get(eventStr)?.delete(handler);
1100
+ }
1101
+ },
1102
+ // === Utilities ===
1103
+ getController(playerIndex) {
1104
+ return _controllers.find((c) => c.playerIndex === playerIndex);
1105
+ },
1106
+ isLeader(playerIndex) {
1107
+ return playerIndex === _leaderIndex;
1108
+ },
1109
+ getControllerCount() {
1110
+ return _controllers.length;
1111
+ },
1112
+ // === Cleanup ===
1113
+ destroy() {
1114
+ _isDestroyed = true;
1115
+ listeners.clear();
1116
+ broadcasts.length = 0;
1117
+ sends.length = 0;
1118
+ },
1119
+ // === Mock-specific methods ===
1120
+ simulateEvent(playerIndex, event, data) {
1121
+ const eventStr = event;
1122
+ const handlers = listeners.get(eventStr);
1123
+ if (handlers) {
1124
+ handlers.forEach((handler) => {
1125
+ handler(playerIndex, data);
1126
+ });
1127
+ }
1128
+ },
1129
+ simulateControllerJoin(info) {
1130
+ _controllers.push(info);
1131
+ if (_leaderIndex === -1) {
1132
+ _leaderIndex = info.playerIndex;
1133
+ }
1134
+ },
1135
+ simulateControllerLeave(playerIndex) {
1136
+ _controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
1137
+ if (_leaderIndex === playerIndex) {
1138
+ _leaderIndex = _controllers[0]?.playerIndex ?? -1;
1139
+ }
1140
+ },
1141
+ getBroadcasts() {
1142
+ return [...broadcasts];
1143
+ },
1144
+ getSentToController(playerIndex) {
1145
+ return sends.filter((s) => s.playerIndex === playerIndex).map((s) => ({ event: s.event, data: s.data }));
1146
+ },
1147
+ clearRecordedEvents() {
1148
+ broadcasts.length = 0;
1149
+ sends.length = 0;
1150
+ },
1151
+ triggerReady() {
1152
+ _isReady = true;
1153
+ }
1154
+ };
1155
+ if (autoReady) {
1156
+ setTimeout(() => screen.triggerReady(), 0);
1157
+ }
1158
+ return screen;
1159
+ }
1160
+ function createMockController(options = {}) {
1161
+ const {
1162
+ roomCode = "TEST",
1163
+ myIndex = 0,
1164
+ isLeader: initialIsLeader = false,
1165
+ autoReady = true
1166
+ } = options;
1167
+ let _isReady = false;
1168
+ let _isDestroyed = false;
1169
+ let _isLeader = initialIsLeader;
1170
+ const listeners = /* @__PURE__ */ new Map();
1171
+ const sentEvents = [];
1172
+ const controller = {
1173
+ // === Properties ===
1174
+ get myIndex() {
1175
+ return myIndex;
1176
+ },
1177
+ get isLeader() {
1178
+ return _isLeader;
1179
+ },
1180
+ get roomCode() {
1181
+ return roomCode;
1182
+ },
1183
+ get isReady() {
1184
+ return _isReady;
1185
+ },
1186
+ get isDestroyed() {
1187
+ return _isDestroyed;
1188
+ },
1189
+ // === Communication Methods ===
1190
+ send(event, data) {
1191
+ if (_isDestroyed) {
1192
+ throw new Error("Cannot send: controller is destroyed");
1193
+ }
1194
+ sentEvents.push({ event, data });
1195
+ },
1196
+ sendRaw(event, data) {
1197
+ if (_isDestroyed) {
1198
+ throw new Error("Cannot send: controller is destroyed");
1199
+ }
1200
+ sentEvents.push({ event, data });
1201
+ },
1202
+ // === Event Subscription ===
1203
+ on(event, handler) {
1204
+ const eventStr = event;
1205
+ if (!listeners.has(eventStr)) {
1206
+ listeners.set(eventStr, /* @__PURE__ */ new Set());
1207
+ }
1208
+ listeners.get(eventStr).add(handler);
1209
+ return () => {
1210
+ listeners.get(eventStr)?.delete(handler);
1211
+ };
1212
+ },
1213
+ once(event, handler) {
1214
+ const wrapper = (data) => {
1215
+ handler(data);
1216
+ controller.off(event, wrapper);
1217
+ };
1218
+ return controller.on(event, wrapper);
1219
+ },
1220
+ off(event, handler) {
1221
+ const eventStr = event;
1222
+ if (!handler) {
1223
+ listeners.delete(eventStr);
1224
+ } else {
1225
+ listeners.get(eventStr)?.delete(handler);
1226
+ }
1227
+ },
1228
+ // === Cleanup ===
1229
+ destroy() {
1230
+ _isDestroyed = true;
1231
+ listeners.clear();
1232
+ sentEvents.length = 0;
1233
+ },
1234
+ // === Mock-specific methods ===
1235
+ simulateEvent(event, data) {
1236
+ const eventStr = event;
1237
+ const handlers = listeners.get(eventStr);
1238
+ if (handlers) {
1239
+ handlers.forEach((handler) => {
1240
+ handler(data);
1241
+ });
1242
+ }
1243
+ },
1244
+ getSentEvents() {
1245
+ return [...sentEvents];
1246
+ },
1247
+ clearRecordedEvents() {
1248
+ sentEvents.length = 0;
1249
+ },
1250
+ triggerReady() {
1251
+ _isReady = true;
1252
+ },
1253
+ setLeader(isLeader) {
1254
+ _isLeader = isLeader;
1255
+ }
1256
+ };
1257
+ if (autoReady) {
1258
+ setTimeout(() => controller.triggerReady(), 0);
1259
+ }
1260
+ return controller;
1261
+ }
1262
+
641
1263
  exports.DirectTransport = DirectTransport;
642
1264
  exports.PostMessageTransport = PostMessageTransport;
643
1265
  exports.SMORE_EVENTS = SMORE_EVENTS;
644
- exports.SmoreHost = SmoreHost;
645
- exports.SmorePlayer = SmorePlayer;
1266
+ exports.SmoreSDKError = SmoreSDKError$1;
1267
+ exports.createController = createController;
1268
+ exports.createMockController = createMockController;
1269
+ exports.createMockScreen = createMockScreen;
1270
+ exports.createScreen = createScreen;
646
1271
  exports.isSystemEvent = isSystemEvent;
647
1272
  exports.validateUserEvent = validateUserEvent;
648
1273