@smoregg/sdk 0.6.1 → 1.0.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.
- package/README.md +29 -38
- package/dist/cjs/controller.cjs +299 -144
- package/dist/cjs/controller.cjs.map +1 -1
- package/dist/cjs/errors.cjs +36 -0
- package/dist/cjs/errors.cjs.map +1 -0
- package/dist/cjs/events.cjs +40 -19
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/index.cjs +3 -8
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/logger.cjs +75 -0
- package/dist/cjs/logger.cjs.map +1 -0
- package/dist/cjs/screen.cjs +302 -215
- package/dist/cjs/screen.cjs.map +1 -1
- package/dist/cjs/testing.cjs +265 -22
- package/dist/cjs/testing.cjs.map +1 -1
- package/dist/cjs/transport/DirectTransport.cjs.map +1 -1
- package/dist/cjs/transport/PostMessageTransport.cjs +11 -6
- package/dist/cjs/transport/PostMessageTransport.cjs.map +1 -1
- package/dist/cjs/transport/protocol.cjs +25 -5
- package/dist/cjs/transport/protocol.cjs.map +1 -1
- package/dist/esm/controller.js +292 -136
- package/dist/esm/controller.js.map +1 -1
- package/dist/esm/errors.js +34 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/events.js +38 -18
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/index.js +3 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/logger.js +73 -0
- package/dist/esm/logger.js.map +1 -0
- package/dist/esm/screen.js +290 -202
- package/dist/esm/screen.js.map +1 -1
- package/dist/esm/testing.js +265 -22
- package/dist/esm/testing.js.map +1 -1
- package/dist/esm/transport/DirectTransport.js.map +1 -1
- package/dist/esm/transport/PostMessageTransport.js +12 -7
- package/dist/esm/transport/PostMessageTransport.js.map +1 -1
- package/dist/esm/transport/protocol.js +23 -4
- package/dist/esm/transport/protocol.js.map +1 -1
- package/dist/types/controller.d.ts +1 -14
- package/dist/types/controller.d.ts.map +1 -1
- package/dist/types/errors.d.ts +45 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/events.d.ts +52 -12
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/index.d.ts +4 -6
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/logger.d.ts +35 -0
- package/dist/types/logger.d.ts.map +1 -0
- package/dist/types/screen.d.ts +1 -14
- package/dist/types/screen.d.ts.map +1 -1
- package/dist/types/testing.d.ts +0 -1
- package/dist/types/testing.d.ts.map +1 -1
- package/dist/types/transport/DirectTransport.d.ts +2 -1
- package/dist/types/transport/DirectTransport.d.ts.map +1 -1
- package/dist/types/transport/PostMessageTransport.d.ts +17 -2
- package/dist/types/transport/PostMessageTransport.d.ts.map +1 -1
- package/dist/types/transport/index.d.ts +2 -2
- package/dist/types/transport/index.d.ts.map +1 -1
- package/dist/types/transport/protocol.d.ts +71 -23
- package/dist/types/transport/protocol.d.ts.map +1 -1
- package/dist/types/transport/types.d.ts +24 -2
- package/dist/types/transport/types.d.ts.map +1 -1
- package/dist/types/types.d.ts +298 -212
- package/dist/types/types.d.ts.map +1 -1
- package/dist/umd/smore-sdk.umd.js +950 -349
- package/dist/umd/smore-sdk.umd.js.map +1 -1
- package/dist/umd/smore-sdk.umd.min.js +1 -1
- package/dist/umd/smore-sdk.umd.min.js.map +1 -1
- package/package.json +8 -13
|
@@ -4,9 +4,28 @@
|
|
|
4
4
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.SmoreSDK = {}));
|
|
5
5
|
})(this, (function (exports) { 'use strict';
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
function
|
|
9
|
-
return data && typeof data === "object" && typeof data.type === "string" && data.type.startsWith(
|
|
7
|
+
const BRIDGE_MSG_PREFIX = "_bridge:";
|
|
8
|
+
function isBridgeMessage(data) {
|
|
9
|
+
return data !== null && typeof data === "object" && "type" in data && typeof data.type === "string" && data.type.startsWith(BRIDGE_MSG_PREFIX);
|
|
10
|
+
}
|
|
11
|
+
function validateInitPayload(payload) {
|
|
12
|
+
if (!payload || typeof payload !== "object") {
|
|
13
|
+
throw new Error("[SDK] _bridge:init payload must be an object");
|
|
14
|
+
}
|
|
15
|
+
const p = payload;
|
|
16
|
+
if (typeof p.side !== "string" || !["host", "player"].includes(p.side)) {
|
|
17
|
+
throw new Error(`[SDK] _bridge:init payload.side must be "host" or "player", got: ${p.side}`);
|
|
18
|
+
}
|
|
19
|
+
if (typeof p.roomCode !== "string" || p.roomCode.length === 0) {
|
|
20
|
+
throw new Error("[SDK] _bridge:init payload.roomCode must be a non-empty string");
|
|
21
|
+
}
|
|
22
|
+
if (!Array.isArray(p.players)) {
|
|
23
|
+
throw new Error("[SDK] _bridge:init payload.players must be an array");
|
|
24
|
+
}
|
|
25
|
+
if (p.myIndex !== void 0 && typeof p.myIndex !== "number") {
|
|
26
|
+
throw new Error("[SDK] _bridge:init payload.myIndex must be a number if provided");
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
10
29
|
}
|
|
11
30
|
|
|
12
31
|
class PostMessageTransport {
|
|
@@ -23,14 +42,19 @@
|
|
|
23
42
|
emit(event, ...args) {
|
|
24
43
|
let data = args[0];
|
|
25
44
|
let ackId;
|
|
26
|
-
if (args.length
|
|
27
|
-
data =
|
|
45
|
+
if (args.length === 1 && typeof args[0] === "function") {
|
|
46
|
+
data = void 0;
|
|
47
|
+
const callback = args[0];
|
|
48
|
+
ackId = `ack_${++this.ackCounter}`;
|
|
49
|
+
this.ackCallbacks.set(ackId, callback);
|
|
50
|
+
} else if (args.length >= 2 && typeof args[args.length - 1] === "function") {
|
|
51
|
+
data = args[0];
|
|
28
52
|
const callback = args[args.length - 1];
|
|
29
53
|
ackId = `ack_${++this.ackCounter}`;
|
|
30
54
|
this.ackCallbacks.set(ackId, callback);
|
|
31
55
|
}
|
|
32
56
|
window.parent.postMessage(
|
|
33
|
-
{ type: "
|
|
57
|
+
{ type: "_bridge:emit", payload: { event, data, ackId } },
|
|
34
58
|
this.parentOrigin
|
|
35
59
|
);
|
|
36
60
|
}
|
|
@@ -57,14 +81,14 @@
|
|
|
57
81
|
handleMessage(e) {
|
|
58
82
|
if (this.parentOrigin !== "*" && e.origin !== this.parentOrigin) return;
|
|
59
83
|
const msg = e.data;
|
|
60
|
-
if (!
|
|
61
|
-
if (msg.type === "
|
|
84
|
+
if (!isBridgeMessage(msg)) return;
|
|
85
|
+
if (msg.type === "_bridge:event") {
|
|
62
86
|
const { event, data } = msg.payload;
|
|
63
87
|
const set = this.handlers.get(event);
|
|
64
88
|
if (set) {
|
|
65
89
|
set.forEach((handler) => handler(data));
|
|
66
90
|
}
|
|
67
|
-
} else if (msg.type === "
|
|
91
|
+
} else if (msg.type === "_bridge:ack") {
|
|
68
92
|
const { ackId, data } = msg.payload;
|
|
69
93
|
const cb = this.ackCallbacks.get(ackId);
|
|
70
94
|
if (cb) {
|
|
@@ -75,23 +99,22 @@
|
|
|
75
99
|
}
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
|
|
79
|
-
const SYSTEM_EVENTS$1 = {
|
|
80
|
-
PLAYER_JOIN: `${SYSTEM_PREFIX$1}player-join`,
|
|
81
|
-
PLAYER_LEAVE: `${SYSTEM_PREFIX$1}player-leave`,
|
|
82
|
-
PLAYER_RECONNECT: `${SYSTEM_PREFIX$1}player-reconnect`,
|
|
83
|
-
GAME_OVER: `${SYSTEM_PREFIX$1}game-over`
|
|
84
|
-
};
|
|
85
|
-
const DEFAULT_TIMEOUT$1 = 1e4;
|
|
86
|
-
let SmoreSDKError$1 = class SmoreSDKError extends Error {
|
|
102
|
+
class SmoreSDKError extends Error {
|
|
87
103
|
code;
|
|
104
|
+
/**
|
|
105
|
+
* The original error that caused this error.
|
|
106
|
+
*
|
|
107
|
+
* **Note:** This field intentionally shadows the native `Error.cause` (ES2022).
|
|
108
|
+
* Both this class field and the native property (set via `super()` options bag)
|
|
109
|
+
* are assigned the same value, so there is no behavioral difference.
|
|
110
|
+
* The explicit field provides TypeScript type narrowing to `Error` instead of `unknown`.
|
|
111
|
+
*/
|
|
88
112
|
cause;
|
|
89
113
|
details;
|
|
90
114
|
constructor(code, message, options) {
|
|
91
|
-
super(message);
|
|
115
|
+
super(message, options?.cause ? { cause: options.cause } : void 0);
|
|
92
116
|
this.name = "SmoreSDKError";
|
|
93
117
|
this.code = code;
|
|
94
|
-
this.cause = options?.cause;
|
|
95
118
|
this.details = options?.details;
|
|
96
119
|
const ErrorWithCapture = Error;
|
|
97
120
|
if (typeof ErrorWithCapture.captureStackTrace === "function") {
|
|
@@ -106,32 +129,51 @@
|
|
|
106
129
|
details: this.details
|
|
107
130
|
};
|
|
108
131
|
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const SMORE_EVENTS = {
|
|
135
|
+
// Game lifecycle
|
|
136
|
+
GAME_OVER: "smore:game-over",
|
|
137
|
+
RETURN_TO_LOBBY: "smore:return-to-lobby",
|
|
138
|
+
// Used internally by platform, not handled by SDK
|
|
139
|
+
// Player management
|
|
140
|
+
PLAYER_JOINED: "smore:player-joined",
|
|
141
|
+
PLAYER_LEFT: "smore:player-left",
|
|
142
|
+
PLAYER_DISCONNECTED: "smore:player-disconnected",
|
|
143
|
+
PLAYER_RECONNECTED: "smore:player-reconnected",
|
|
144
|
+
// Character change
|
|
145
|
+
PLAYER_CHARACTER_UPDATED: "smore:player-character-updated",
|
|
146
|
+
// Rate limiting
|
|
147
|
+
RATE_LIMITED: "smore:rate-limited",
|
|
148
|
+
// Send to specific player (internal use)
|
|
149
|
+
SEND_TO_PLAYER: "smore:send-to-player"
|
|
150
|
+
// Used internally by platform, not handled by SDK
|
|
109
151
|
};
|
|
110
|
-
|
|
111
|
-
|
|
152
|
+
new Set(
|
|
153
|
+
Object.values(SMORE_EVENTS)
|
|
154
|
+
);
|
|
155
|
+
const EVENT_NAME_REGEX = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
|
|
156
|
+
const EVENT_NAME_MAX_LENGTH = 128;
|
|
157
|
+
function validateEventName(event) {
|
|
112
158
|
if (!event || typeof event !== "string") {
|
|
113
|
-
throw new SmoreSDKError
|
|
159
|
+
throw new SmoreSDKError("INVALID_EVENT", "Event name must be a non-empty string");
|
|
114
160
|
}
|
|
115
|
-
if (
|
|
116
|
-
throw new SmoreSDKError
|
|
161
|
+
if (event.length > EVENT_NAME_MAX_LENGTH) {
|
|
162
|
+
throw new SmoreSDKError(
|
|
117
163
|
"INVALID_EVENT",
|
|
118
|
-
`
|
|
119
|
-
{ details: { event } }
|
|
164
|
+
`Event name exceeds maximum length of ${EVENT_NAME_MAX_LENGTH} characters (got ${event.length}).`,
|
|
165
|
+
{ details: { event: event.slice(0, 50) + "..." } }
|
|
120
166
|
);
|
|
121
167
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 } }
|
|
168
|
+
if (!EVENT_NAME_REGEX.test(event)) {
|
|
169
|
+
throw new SmoreSDKError(
|
|
170
|
+
"INVALID_EVENT",
|
|
171
|
+
`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.`,
|
|
172
|
+
{ details: { event } }
|
|
132
173
|
);
|
|
133
174
|
}
|
|
134
175
|
}
|
|
176
|
+
|
|
135
177
|
class DebugLogger {
|
|
136
178
|
enabled;
|
|
137
179
|
level;
|
|
@@ -146,30 +188,15 @@
|
|
|
146
188
|
warn: 2,
|
|
147
189
|
error: 3
|
|
148
190
|
};
|
|
149
|
-
constructor(options) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
}
|
|
191
|
+
constructor(options, defaultPrefix = "[Smore]") {
|
|
192
|
+
const opts = typeof options === "boolean" ? { enabled: options } : options;
|
|
193
|
+
this.enabled = opts?.enabled ?? false;
|
|
194
|
+
this.level = opts?.level ?? "debug";
|
|
195
|
+
this.prefix = opts?.prefix ?? defaultPrefix;
|
|
196
|
+
this.logSend = opts?.logSend ?? true;
|
|
197
|
+
this.logReceive = opts?.logReceive ?? true;
|
|
198
|
+
this.logLifecycle = opts?.logLifecycle ?? true;
|
|
199
|
+
this.customLogger = opts?.logger;
|
|
173
200
|
}
|
|
174
201
|
shouldLog(level) {
|
|
175
202
|
return this.enabled && DebugLogger.levelOrder[level] >= DebugLogger.levelOrder[this.level];
|
|
@@ -180,7 +207,7 @@
|
|
|
180
207
|
this.customLogger(level, `${this.prefix} ${message}`, data);
|
|
181
208
|
return;
|
|
182
209
|
}
|
|
183
|
-
const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "
|
|
210
|
+
const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : level === "debug" ? "debug" : "info";
|
|
184
211
|
if (data !== void 0) {
|
|
185
212
|
console[consoleMethod](`${this.prefix} ${message}`, data);
|
|
186
213
|
} else {
|
|
@@ -200,11 +227,13 @@
|
|
|
200
227
|
this.log("error", message, data);
|
|
201
228
|
}
|
|
202
229
|
send(event, data) {
|
|
230
|
+
if (!this.enabled) return;
|
|
203
231
|
if (this.logSend) {
|
|
204
232
|
this.debug(`-> SEND: ${event}`, data);
|
|
205
233
|
}
|
|
206
234
|
}
|
|
207
235
|
receive(event, data) {
|
|
236
|
+
if (!this.enabled) return;
|
|
208
237
|
if (this.logReceive) {
|
|
209
238
|
this.debug(`<- RECV: ${event}`, data);
|
|
210
239
|
}
|
|
@@ -215,24 +244,41 @@
|
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
246
|
}
|
|
247
|
+
|
|
248
|
+
const DEFAULT_TIMEOUT$1 = 1e4;
|
|
249
|
+
function validatePlayerIndex(playerIndex, controllers) {
|
|
250
|
+
if (typeof playerIndex !== "number" || !Number.isInteger(playerIndex)) {
|
|
251
|
+
throw new SmoreSDKError("INVALID_PLAYER", "Player index must be an integer");
|
|
252
|
+
}
|
|
253
|
+
if (!controllers.some((c) => c.playerIndex === playerIndex)) {
|
|
254
|
+
throw new SmoreSDKError(
|
|
255
|
+
"INVALID_PLAYER",
|
|
256
|
+
`No controller found with player index ${playerIndex}`,
|
|
257
|
+
{ details: { playerIndex } }
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
218
261
|
class ScreenImpl {
|
|
219
262
|
transport = null;
|
|
220
263
|
config;
|
|
221
264
|
logger;
|
|
222
265
|
_controllers = [];
|
|
223
266
|
_roomCode = "";
|
|
224
|
-
_leaderIndex = -1;
|
|
225
267
|
_isReady = false;
|
|
226
268
|
_isDestroyed = false;
|
|
227
269
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
228
270
|
registeredTransportHandlers = [];
|
|
229
271
|
boundMessageHandler = null;
|
|
272
|
+
// Maps user-facing handler → transport wrappedHandler for proper cleanup in on()/off()
|
|
273
|
+
handlerToTransport = /* @__PURE__ */ new Map();
|
|
274
|
+
// Tracks event names registered via config.listeners so off(event) without handler won't remove them
|
|
275
|
+
_configListenerEvents = /* @__PURE__ */ new Set();
|
|
230
276
|
constructor(config = {}) {
|
|
231
277
|
this.config = config;
|
|
232
|
-
this.logger = new DebugLogger(config.debug);
|
|
278
|
+
this.logger = new DebugLogger(config.debug, "[SmoreScreen]");
|
|
233
279
|
if (config.listeners) {
|
|
234
280
|
for (const event of Object.keys(config.listeners)) {
|
|
235
|
-
validateEventName
|
|
281
|
+
validateEventName(event);
|
|
236
282
|
}
|
|
237
283
|
}
|
|
238
284
|
}
|
|
@@ -246,9 +292,9 @@
|
|
|
246
292
|
return new Promise((resolve, reject) => {
|
|
247
293
|
const timeoutId = setTimeout(() => {
|
|
248
294
|
this.cleanup();
|
|
249
|
-
const error = new SmoreSDKError
|
|
295
|
+
const error = new SmoreSDKError(
|
|
250
296
|
"TIMEOUT",
|
|
251
|
-
`Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends
|
|
297
|
+
`Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends _bridge:init. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Screen instance to retry (this instance has been cleaned up).`,
|
|
252
298
|
{ details: { timeout } }
|
|
253
299
|
);
|
|
254
300
|
this.handleError(error);
|
|
@@ -257,12 +303,26 @@
|
|
|
257
303
|
this.boundMessageHandler = (e) => {
|
|
258
304
|
if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
|
|
259
305
|
const msg = e.data;
|
|
260
|
-
if (!
|
|
261
|
-
if (msg.type === "
|
|
306
|
+
if (!isBridgeMessage(msg)) return;
|
|
307
|
+
if (msg.type === "_bridge:init") {
|
|
262
308
|
clearTimeout(timeoutId);
|
|
263
|
-
const
|
|
309
|
+
const initPayload = msg.payload;
|
|
310
|
+
try {
|
|
311
|
+
validateInitPayload(initPayload);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const error = new SmoreSDKError(
|
|
314
|
+
"INIT_FAILED",
|
|
315
|
+
`Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
|
|
316
|
+
{ details: { payload: initPayload } }
|
|
317
|
+
);
|
|
318
|
+
this.logger.warn("_bridge:init validation failed", error);
|
|
319
|
+
this.handleError(error);
|
|
320
|
+
reject(error);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const initData = initPayload;
|
|
264
324
|
if (initData.side !== "host") {
|
|
265
|
-
const error = new SmoreSDKError
|
|
325
|
+
const error = new SmoreSDKError(
|
|
266
326
|
"INIT_FAILED",
|
|
267
327
|
`Received init for wrong side: ${initData.side}. Expected "host".`,
|
|
268
328
|
{ details: { side: initData.side } }
|
|
@@ -274,103 +334,155 @@
|
|
|
274
334
|
this.transport = new PostMessageTransport(parentOrigin);
|
|
275
335
|
this._roomCode = initData.roomCode;
|
|
276
336
|
this._controllers = this.mapControllersFromInit(initData.players);
|
|
277
|
-
|
|
337
|
+
if (this._controllers.length === 0) {
|
|
338
|
+
this.logger.warn("Screen initialized with zero controllers");
|
|
339
|
+
}
|
|
278
340
|
this.setupEventHandlers();
|
|
279
341
|
this._isReady = true;
|
|
280
342
|
this.logger.lifecycle("Screen ready", {
|
|
281
343
|
roomCode: this._roomCode,
|
|
282
|
-
controllers: this._controllers.length
|
|
283
|
-
leaderIndex: this._leaderIndex
|
|
344
|
+
controllers: this._controllers.length
|
|
284
345
|
});
|
|
285
346
|
this.config.onReady?.();
|
|
286
347
|
resolve();
|
|
287
|
-
} else if (msg.type === "
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
348
|
+
} else if (msg.type === "_bridge:update") {
|
|
349
|
+
if (!this._isReady) {
|
|
350
|
+
this.logger.debug("Ignoring _bridge:update before init completes");
|
|
351
|
+
return;
|
|
291
352
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
353
|
+
const updateData = msg.payload;
|
|
354
|
+
if (updateData.players && Array.isArray(updateData.players)) {
|
|
355
|
+
const oldControllers = this._controllers;
|
|
356
|
+
const newControllers = this.mapControllersFromInit(updateData.players);
|
|
357
|
+
this._controllers = newControllers;
|
|
358
|
+
for (const nc of newControllers) {
|
|
359
|
+
if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
|
|
360
|
+
this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
|
|
361
|
+
this.config.onControllerJoin?.(nc.playerIndex, nc);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const oc of oldControllers) {
|
|
365
|
+
if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
|
|
366
|
+
this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
|
|
367
|
+
this.config.onControllerLeave?.(oc.playerIndex);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
297
370
|
}
|
|
298
371
|
this.logger.lifecycle("Room updated", {
|
|
299
|
-
controllers: this._controllers.length
|
|
300
|
-
leaderIndex: this._leaderIndex
|
|
372
|
+
controllers: this._controllers.length
|
|
301
373
|
});
|
|
302
374
|
}
|
|
303
375
|
};
|
|
304
376
|
window.addEventListener("message", this.boundMessageHandler);
|
|
305
|
-
window.parent.postMessage({ type: "
|
|
306
|
-
this.logger.lifecycle("Sent
|
|
377
|
+
window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
|
|
378
|
+
this.logger.lifecycle("Sent _bridge:ready to parent");
|
|
307
379
|
});
|
|
308
380
|
}
|
|
309
381
|
mapControllersFromInit(players) {
|
|
310
382
|
return players.map((p, index) => ({
|
|
311
383
|
playerIndex: p.playerIndex ?? index,
|
|
312
|
-
|
|
384
|
+
// Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
|
|
385
|
+
nickname: p.nickname || p.name || `Player ${index + 1}`,
|
|
313
386
|
connected: p.connected !== false,
|
|
314
|
-
appearance
|
|
387
|
+
// Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
|
|
388
|
+
appearance: p.appearance ?? p.character
|
|
315
389
|
}));
|
|
316
390
|
}
|
|
317
|
-
findLeaderIndex(players, leaderId) {
|
|
318
|
-
if (!leaderId) return -1;
|
|
319
|
-
const idx = players.findIndex(
|
|
320
|
-
(p) => p.sessionId === leaderId
|
|
321
|
-
);
|
|
322
|
-
return idx >= 0 ? idx : -1;
|
|
323
|
-
}
|
|
324
391
|
setupEventHandlers() {
|
|
325
392
|
if (!this.transport) return;
|
|
326
|
-
this.registerTransportHandler(
|
|
393
|
+
this.registerTransportHandler(SMORE_EVENTS.PLAYER_JOINED, (data) => {
|
|
327
394
|
const payload = data;
|
|
328
|
-
const
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
395
|
+
const playerData = payload?.player;
|
|
396
|
+
if (playerData && typeof playerData.playerIndex === "number") {
|
|
397
|
+
const controllerInfo = {
|
|
398
|
+
playerIndex: playerData.playerIndex,
|
|
399
|
+
nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
|
|
400
|
+
connected: playerData.connected !== false,
|
|
401
|
+
appearance: playerData.appearance ?? playerData.character
|
|
402
|
+
};
|
|
403
|
+
if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
|
|
404
|
+
this._controllers = [...this._controllers, controllerInfo];
|
|
405
|
+
this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
|
|
406
|
+
this.config.onControllerJoin?.(controllerInfo.playerIndex, controllerInfo);
|
|
332
407
|
}
|
|
333
408
|
});
|
|
334
|
-
this.registerTransportHandler(
|
|
409
|
+
this.registerTransportHandler(SMORE_EVENTS.PLAYER_LEFT, (data) => {
|
|
335
410
|
const payload = data;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
this.
|
|
411
|
+
const playerIndex = payload?.player?.playerIndex ?? payload?.playerIndex;
|
|
412
|
+
if (typeof playerIndex === "number") {
|
|
413
|
+
if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
|
|
414
|
+
this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
|
|
415
|
+
this.logger.lifecycle("Controller left", { playerIndex });
|
|
416
|
+
this.config.onControllerLeave?.(playerIndex);
|
|
339
417
|
}
|
|
340
418
|
});
|
|
341
|
-
this.registerTransportHandler(
|
|
419
|
+
this.registerTransportHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
|
|
342
420
|
const payload = data;
|
|
343
|
-
const
|
|
344
|
-
if (
|
|
345
|
-
this.
|
|
346
|
-
|
|
421
|
+
const playerIndex = payload?.player?.playerIndex ?? payload?.playerIndex;
|
|
422
|
+
if (typeof playerIndex === "number") {
|
|
423
|
+
this._controllers = this._controllers.map(
|
|
424
|
+
(c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
|
|
425
|
+
);
|
|
426
|
+
this.logger.lifecycle("Controller disconnected", { playerIndex });
|
|
427
|
+
this.config.onControllerDisconnect?.(playerIndex);
|
|
347
428
|
}
|
|
348
429
|
});
|
|
349
|
-
this.registerTransportHandler(
|
|
430
|
+
this.registerTransportHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
|
|
350
431
|
const payload = data;
|
|
351
|
-
const
|
|
352
|
-
if (typeof playerIndex === "number") {
|
|
353
|
-
|
|
354
|
-
playerIndex,
|
|
355
|
-
nickname: `Player ${playerIndex + 1}`,
|
|
356
|
-
connected: true
|
|
357
|
-
|
|
432
|
+
const playerData = payload?.player;
|
|
433
|
+
if (playerData && typeof playerData.playerIndex === "number") {
|
|
434
|
+
const controllerInfo = {
|
|
435
|
+
playerIndex: playerData.playerIndex,
|
|
436
|
+
nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
|
|
437
|
+
connected: true,
|
|
438
|
+
appearance: playerData.appearance ?? playerData.character
|
|
439
|
+
};
|
|
440
|
+
this._controllers = this._controllers.map(
|
|
441
|
+
(c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
|
|
442
|
+
);
|
|
443
|
+
this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
|
|
444
|
+
this.config.onControllerReconnect?.(controllerInfo.playerIndex, controllerInfo);
|
|
358
445
|
}
|
|
359
446
|
});
|
|
360
|
-
this.registerTransportHandler(
|
|
447
|
+
this.registerTransportHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
|
|
361
448
|
const payload = data;
|
|
362
|
-
const
|
|
363
|
-
if (typeof playerIndex === "number") {
|
|
364
|
-
|
|
449
|
+
const playerData = payload?.player;
|
|
450
|
+
if (playerData && typeof playerData.playerIndex === "number") {
|
|
451
|
+
const appearance = playerData.character ?? null;
|
|
452
|
+
this._controllers = this._controllers.map(
|
|
453
|
+
(c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
|
|
454
|
+
);
|
|
455
|
+
this.logger.lifecycle("Player character updated", { playerIndex: playerData.playerIndex });
|
|
456
|
+
this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
|
|
365
457
|
}
|
|
366
458
|
});
|
|
459
|
+
this.registerTransportHandler(SMORE_EVENTS.RATE_LIMITED, (data) => {
|
|
460
|
+
const payload = data;
|
|
461
|
+
const event = payload?.event ?? "unknown";
|
|
462
|
+
this.logger.warn(`Rate limited: ${event}`);
|
|
463
|
+
this.config.onRateLimited?.(event);
|
|
464
|
+
});
|
|
367
465
|
if (this.config.listeners) {
|
|
368
466
|
for (const [event, handler] of Object.entries(this.config.listeners)) {
|
|
369
467
|
if (!handler) continue;
|
|
468
|
+
this._configListenerEvents.add(event);
|
|
370
469
|
this.setupUserEventHandler(event, handler);
|
|
371
470
|
}
|
|
372
471
|
}
|
|
373
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Sets up a user event handler with playerIndex extraction.
|
|
475
|
+
*
|
|
476
|
+
* Events received from controllers are dropped if they lack a playerIndex field.
|
|
477
|
+
* This is a security measure to prevent controller impersonation - the relay server
|
|
478
|
+
* automatically attaches playerIndex based on the sender's authenticated session,
|
|
479
|
+
* ensuring controllers cannot forge events as other players.
|
|
480
|
+
*
|
|
481
|
+
* Note: `playerIndex` is a reserved field name in event payloads.
|
|
482
|
+
* It is automatically extracted by the SDK and passed as the first argument
|
|
483
|
+
* to Screen event handlers. Game developers must NOT use `playerIndex` as
|
|
484
|
+
* a custom data field name -- it will be stripped from the data object.
|
|
485
|
+
*/
|
|
374
486
|
setupUserEventHandler(event, handler) {
|
|
375
487
|
const wrappedHandler = (data) => {
|
|
376
488
|
this.logger.receive(event, data);
|
|
@@ -381,12 +493,14 @@
|
|
|
381
493
|
handler(playerIndex, rest);
|
|
382
494
|
} catch (err) {
|
|
383
495
|
this.handleError(
|
|
384
|
-
new SmoreSDKError
|
|
496
|
+
new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
|
|
385
497
|
cause: err instanceof Error ? err : void 0,
|
|
386
498
|
details: { event, playerIndex }
|
|
387
499
|
})
|
|
388
500
|
);
|
|
389
501
|
}
|
|
502
|
+
} else {
|
|
503
|
+
this.logger.debug(`Dropping event "${event}" without playerIndex`, data);
|
|
390
504
|
}
|
|
391
505
|
};
|
|
392
506
|
this.registerTransportHandler(event, wrappedHandler);
|
|
@@ -405,15 +519,16 @@
|
|
|
405
519
|
// ---------------------------------------------------------------------------
|
|
406
520
|
// Properties (readonly)
|
|
407
521
|
// ---------------------------------------------------------------------------
|
|
522
|
+
/**
|
|
523
|
+
* Returns a new shallow copy of the controllers array on every access.
|
|
524
|
+
* Cache the result if accessing repeatedly in the same frame/tick.
|
|
525
|
+
*/
|
|
408
526
|
get controllers() {
|
|
409
527
|
return [...this._controllers];
|
|
410
528
|
}
|
|
411
529
|
get roomCode() {
|
|
412
530
|
return this._roomCode;
|
|
413
531
|
}
|
|
414
|
-
get leaderIndex() {
|
|
415
|
-
return this._leaderIndex;
|
|
416
|
-
}
|
|
417
532
|
get isReady() {
|
|
418
533
|
return this._isReady;
|
|
419
534
|
}
|
|
@@ -423,22 +538,70 @@
|
|
|
423
538
|
// ---------------------------------------------------------------------------
|
|
424
539
|
// Communication Methods
|
|
425
540
|
// ---------------------------------------------------------------------------
|
|
541
|
+
/**
|
|
542
|
+
* Send type-safe events to all controllers.
|
|
543
|
+
*
|
|
544
|
+
* Uses EventMap generic for compile-time type checking of event names and data payloads.
|
|
545
|
+
* Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
|
|
546
|
+
*
|
|
547
|
+
* @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
|
|
548
|
+
* @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
|
|
549
|
+
* @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
|
|
550
|
+
* Use the onError callback or smore:rate-limited event to detect rate limiting.
|
|
551
|
+
*
|
|
552
|
+
* Warning: Avoid sending primitive values directly (string, number, boolean).
|
|
553
|
+
* Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
|
|
554
|
+
*
|
|
555
|
+
* @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
|
|
556
|
+
*/
|
|
426
557
|
broadcast(event, data) {
|
|
427
558
|
this.ensureReady("broadcast");
|
|
428
|
-
validateEventName
|
|
559
|
+
validateEventName(event);
|
|
429
560
|
this.logger.send(event, data);
|
|
430
561
|
this.transport.emit(event, data);
|
|
431
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* Send events to all controllers without TypeScript type checking.
|
|
565
|
+
*
|
|
566
|
+
* Bypasses EventMap generic type checks at compile time.
|
|
567
|
+
* Runtime behavior is identical to broadcast - both call validateEventName at runtime.
|
|
568
|
+
*
|
|
569
|
+
* Use this when you need dynamic event names or when working without a predefined EventMap.
|
|
570
|
+
*
|
|
571
|
+
* @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
|
|
572
|
+
* @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
|
|
573
|
+
*
|
|
574
|
+
* @see broadcast for type-safe version using EventMap generic
|
|
575
|
+
*/
|
|
432
576
|
broadcastRaw(event, data) {
|
|
433
577
|
this.ensureReady("broadcastRaw");
|
|
434
|
-
validateEventName
|
|
578
|
+
validateEventName(event);
|
|
435
579
|
this.logger.send(event, data);
|
|
436
580
|
this.transport.emit(event, data);
|
|
437
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* Send an event to a specific controller.
|
|
584
|
+
*
|
|
585
|
+
* **Reserved field:** `targetPlayerIndex` is automatically merged into the data payload
|
|
586
|
+
* to route the event to the specified controller. Game developers should avoid using
|
|
587
|
+
* `targetPlayerIndex` as a custom data field name to prevent conflicts.
|
|
588
|
+
*
|
|
589
|
+
* @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
|
|
590
|
+
* @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
|
|
591
|
+
*
|
|
592
|
+
* @param playerIndex - Target controller's player index
|
|
593
|
+
* @param event - Event name
|
|
594
|
+
* @param data - Event data payload
|
|
595
|
+
*/
|
|
438
596
|
sendToController(playerIndex, event, data) {
|
|
439
597
|
this.ensureReady("sendToController");
|
|
440
|
-
validateEventName
|
|
441
|
-
validatePlayerIndex(playerIndex, this._controllers
|
|
598
|
+
validateEventName(event);
|
|
599
|
+
validatePlayerIndex(playerIndex, this._controllers);
|
|
600
|
+
if (data && typeof data === "object" && "targetPlayerIndex" in data) {
|
|
601
|
+
this.logger.warn(
|
|
602
|
+
`Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
442
605
|
this.logger.send(`${event} -> Player ${playerIndex}`, data);
|
|
443
606
|
this.transport.emit(event, {
|
|
444
607
|
targetPlayerIndex: playerIndex,
|
|
@@ -447,8 +610,13 @@
|
|
|
447
610
|
}
|
|
448
611
|
sendToControllerRaw(playerIndex, event, data) {
|
|
449
612
|
this.ensureReady("sendToControllerRaw");
|
|
450
|
-
validateEventName
|
|
451
|
-
validatePlayerIndex(playerIndex, this._controllers
|
|
613
|
+
validateEventName(event);
|
|
614
|
+
validatePlayerIndex(playerIndex, this._controllers);
|
|
615
|
+
if (data && typeof data === "object" && "targetPlayerIndex" in data) {
|
|
616
|
+
this.logger.warn(
|
|
617
|
+
`Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
452
620
|
this.logger.send(`${event} -> Player ${playerIndex}`, data);
|
|
453
621
|
this.transport.emit(event, {
|
|
454
622
|
targetPlayerIndex: playerIndex,
|
|
@@ -461,21 +629,31 @@
|
|
|
461
629
|
gameOver(results) {
|
|
462
630
|
this.ensureReady("gameOver");
|
|
463
631
|
this.logger.lifecycle("Game over", results);
|
|
464
|
-
this.transport.emit(
|
|
632
|
+
this.transport.emit(SMORE_EVENTS.GAME_OVER, { results });
|
|
465
633
|
}
|
|
466
634
|
// ---------------------------------------------------------------------------
|
|
467
635
|
// Event Subscription
|
|
468
636
|
// ---------------------------------------------------------------------------
|
|
637
|
+
/**
|
|
638
|
+
* Register an event handler for messages from controllers.
|
|
639
|
+
*
|
|
640
|
+
* **Important:** If called before the Screen is ready (i.e., before `await createScreen()`
|
|
641
|
+
* resolves or before the `onReady` callback fires), the handler is stored locally but
|
|
642
|
+
* will NOT be registered with the transport layer. This means the handler will never
|
|
643
|
+
* fire for events received during the pre-ready window. Always call `on()` after
|
|
644
|
+
* initialization completes, or use `config.listeners` for handlers needed from the start.
|
|
645
|
+
*/
|
|
469
646
|
on(event, handler) {
|
|
470
|
-
validateEventName
|
|
647
|
+
validateEventName(event);
|
|
471
648
|
let handlers = this.eventHandlers.get(event);
|
|
472
649
|
if (!handlers) {
|
|
473
650
|
handlers = /* @__PURE__ */ new Set();
|
|
474
651
|
this.eventHandlers.set(event, handlers);
|
|
475
652
|
}
|
|
476
653
|
handlers.add(handler);
|
|
654
|
+
let wrappedHandler = null;
|
|
477
655
|
if (this.transport) {
|
|
478
|
-
|
|
656
|
+
wrappedHandler = (data) => {
|
|
479
657
|
this.logger.receive(event, data);
|
|
480
658
|
const payload = data;
|
|
481
659
|
const { playerIndex, ...rest } = payload;
|
|
@@ -484,22 +662,54 @@
|
|
|
484
662
|
handler(playerIndex, rest);
|
|
485
663
|
} catch (err) {
|
|
486
664
|
this.handleError(
|
|
487
|
-
new SmoreSDKError
|
|
665
|
+
new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
|
|
488
666
|
cause: err instanceof Error ? err : void 0
|
|
489
667
|
})
|
|
490
668
|
);
|
|
491
669
|
}
|
|
670
|
+
} else {
|
|
671
|
+
this.logger.debug(`Dropping event "${event}" without playerIndex`, data);
|
|
492
672
|
}
|
|
493
673
|
};
|
|
494
674
|
this.registerTransportHandler(event, wrappedHandler);
|
|
675
|
+
this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
|
|
495
676
|
}
|
|
496
677
|
return () => {
|
|
497
678
|
handlers?.delete(handler);
|
|
498
679
|
if (handlers?.size === 0) {
|
|
499
680
|
this.eventHandlers.delete(event);
|
|
500
681
|
}
|
|
682
|
+
if (wrappedHandler) {
|
|
683
|
+
this.transport?.off(event, wrappedHandler);
|
|
684
|
+
this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
|
|
685
|
+
(h) => h.handler !== wrappedHandler
|
|
686
|
+
);
|
|
687
|
+
this.handlerToTransport.delete(handler);
|
|
688
|
+
}
|
|
501
689
|
};
|
|
502
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Register an event handler that will be called only once.
|
|
693
|
+
*
|
|
694
|
+
* The handler is automatically removed after the first invocation.
|
|
695
|
+
*
|
|
696
|
+
* **Important:** The wrapped handler cannot be removed via `off(event, originalHandler)`.
|
|
697
|
+
* Use the returned unsubscribe function instead.
|
|
698
|
+
*
|
|
699
|
+
* @param event - Event name to listen for
|
|
700
|
+
* @param handler - Handler function to call once
|
|
701
|
+
* @returns Unsubscribe function to remove the handler before it fires
|
|
702
|
+
*
|
|
703
|
+
* @example
|
|
704
|
+
* ```ts
|
|
705
|
+
* const unsubscribe = screen.once('ready', (playerIndex, data) => {
|
|
706
|
+
* console.log('Ready event received');
|
|
707
|
+
* });
|
|
708
|
+
*
|
|
709
|
+
* // To remove before the event fires:
|
|
710
|
+
* unsubscribe();
|
|
711
|
+
* ```
|
|
712
|
+
*/
|
|
503
713
|
once(event, handler) {
|
|
504
714
|
const wrappedHandler = (playerIndex, data) => {
|
|
505
715
|
unsubscribe();
|
|
@@ -510,14 +720,38 @@
|
|
|
510
720
|
}
|
|
511
721
|
off(event, handler) {
|
|
512
722
|
if (!handler) {
|
|
513
|
-
this.
|
|
514
|
-
|
|
723
|
+
if (this._configListenerEvents.has(event)) {
|
|
724
|
+
for (const [key, val] of this.handlerToTransport) {
|
|
725
|
+
if (val.event === event) {
|
|
726
|
+
this.transport?.off(event, val.transportHandler);
|
|
727
|
+
this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
|
|
728
|
+
(h) => h.handler !== val.transportHandler
|
|
729
|
+
);
|
|
730
|
+
this.handlerToTransport.delete(key);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
this.eventHandlers.delete(event);
|
|
735
|
+
this.transport?.off(event);
|
|
736
|
+
this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
|
|
737
|
+
for (const [key, val] of this.handlerToTransport) {
|
|
738
|
+
if (val.event === event) this.handlerToTransport.delete(key);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
515
741
|
} else {
|
|
516
742
|
const handlers = this.eventHandlers.get(event);
|
|
517
743
|
handlers?.delete(handler);
|
|
518
744
|
if (handlers?.size === 0) {
|
|
519
745
|
this.eventHandlers.delete(event);
|
|
520
746
|
}
|
|
747
|
+
const entry = this.handlerToTransport.get(handler);
|
|
748
|
+
if (entry) {
|
|
749
|
+
this.transport?.off(event, entry.transportHandler);
|
|
750
|
+
this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
|
|
751
|
+
(h) => h.handler !== entry.transportHandler
|
|
752
|
+
);
|
|
753
|
+
this.handlerToTransport.delete(handler);
|
|
754
|
+
}
|
|
521
755
|
}
|
|
522
756
|
}
|
|
523
757
|
// ---------------------------------------------------------------------------
|
|
@@ -526,12 +760,31 @@
|
|
|
526
760
|
getController(playerIndex) {
|
|
527
761
|
return this._controllers.find((c) => c.playerIndex === playerIndex);
|
|
528
762
|
}
|
|
529
|
-
isLeader(playerIndex) {
|
|
530
|
-
return playerIndex === this._leaderIndex;
|
|
531
|
-
}
|
|
532
763
|
getControllerCount() {
|
|
533
764
|
return this._controllers.filter((c) => c.connected).length;
|
|
534
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* Check if there is at least one connected controller.
|
|
768
|
+
* Useful for detecting when all players have disconnected
|
|
769
|
+
* (e.g., to pause the game or show a waiting screen).
|
|
770
|
+
*
|
|
771
|
+
* Use this in onControllerDisconnect callback to detect when all controllers have disconnected.
|
|
772
|
+
*
|
|
773
|
+
* @example
|
|
774
|
+
* ```ts
|
|
775
|
+
* const screen = await createScreen<MyEvents>({
|
|
776
|
+
* onControllerDisconnect: (playerIndex) => {
|
|
777
|
+
* if (!screen.hasAnyConnectedControllers()) {
|
|
778
|
+
* console.log('All controllers disconnected!');
|
|
779
|
+
* screen.broadcast('waiting-for-players', {});
|
|
780
|
+
* }
|
|
781
|
+
* },
|
|
782
|
+
* });
|
|
783
|
+
* ```
|
|
784
|
+
*/
|
|
785
|
+
hasAnyConnectedControllers() {
|
|
786
|
+
return this._controllers.some((c) => c.connected);
|
|
787
|
+
}
|
|
535
788
|
// ---------------------------------------------------------------------------
|
|
536
789
|
// Cleanup
|
|
537
790
|
// ---------------------------------------------------------------------------
|
|
@@ -549,6 +802,7 @@
|
|
|
549
802
|
}
|
|
550
803
|
this.registeredTransportHandlers = [];
|
|
551
804
|
this.eventHandlers.clear();
|
|
805
|
+
this.handlerToTransport.clear();
|
|
552
806
|
if (this.transport instanceof PostMessageTransport) {
|
|
553
807
|
this.transport.destroy();
|
|
554
808
|
}
|
|
@@ -562,6 +816,7 @@
|
|
|
562
816
|
// Error Handling
|
|
563
817
|
// ---------------------------------------------------------------------------
|
|
564
818
|
handleError(error) {
|
|
819
|
+
this.logger.warn(`Error in handler: ${error.message}`);
|
|
565
820
|
const smoreError = error.toSmoreError();
|
|
566
821
|
if (this.config.onError) {
|
|
567
822
|
this.config.onError(smoreError);
|
|
@@ -571,14 +826,14 @@
|
|
|
571
826
|
}
|
|
572
827
|
ensureReady(method) {
|
|
573
828
|
if (this._isDestroyed) {
|
|
574
|
-
throw new SmoreSDKError
|
|
829
|
+
throw new SmoreSDKError(
|
|
575
830
|
"DESTROYED",
|
|
576
831
|
`Cannot call ${method}() after destroy()`,
|
|
577
832
|
{ details: { method } }
|
|
578
833
|
);
|
|
579
834
|
}
|
|
580
835
|
if (!this._isReady || !this.transport) {
|
|
581
|
-
throw new SmoreSDKError
|
|
836
|
+
throw new SmoreSDKError(
|
|
582
837
|
"NOT_READY",
|
|
583
838
|
`Cannot call ${method}() before screen is ready. Use await createScreen() or onReady callback.`,
|
|
584
839
|
{ details: { method } }
|
|
@@ -593,103 +848,26 @@
|
|
|
593
848
|
return promise;
|
|
594
849
|
}
|
|
595
850
|
|
|
596
|
-
const SYSTEM_PREFIX = "smore:";
|
|
597
|
-
const SYSTEM_EVENTS = {
|
|
598
|
-
PLAYER_JOIN: `${SYSTEM_PREFIX}player-join`,
|
|
599
|
-
PLAYER_LEAVE: `${SYSTEM_PREFIX}player-leave`
|
|
600
|
-
};
|
|
601
851
|
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])?$/;
|
|
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
|
-
}
|
|
631
|
-
if (!EVENT_NAME_REGEX.test(event)) {
|
|
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 } }
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
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
852
|
class ControllerImpl {
|
|
679
853
|
transport = null;
|
|
680
854
|
config;
|
|
681
855
|
logger;
|
|
682
856
|
_roomCode = "";
|
|
683
857
|
_myIndex = -1;
|
|
684
|
-
_isLeader = false;
|
|
685
858
|
_isReady = false;
|
|
686
859
|
_isDestroyed = false;
|
|
687
860
|
boundMessageHandler = null;
|
|
688
861
|
registeredHandlers = [];
|
|
689
862
|
eventListeners = /* @__PURE__ */ new Map();
|
|
863
|
+
// Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
|
|
864
|
+
handlerToTransport = /* @__PURE__ */ new Map();
|
|
865
|
+
_controllers = [];
|
|
866
|
+
// Tracks event names registered via config.listeners so off(event) without handler won't remove them
|
|
867
|
+
_configListenerEvents = /* @__PURE__ */ new Set();
|
|
690
868
|
constructor(config = {}) {
|
|
691
869
|
this.config = config;
|
|
692
|
-
this.logger =
|
|
870
|
+
this.logger = new DebugLogger(config.debug, "[SmoreController]");
|
|
693
871
|
if (config.listeners) {
|
|
694
872
|
for (const event of Object.keys(config.listeners)) {
|
|
695
873
|
validateEventName(event);
|
|
@@ -702,9 +880,6 @@
|
|
|
702
880
|
get myIndex() {
|
|
703
881
|
return this._myIndex;
|
|
704
882
|
}
|
|
705
|
-
get isLeader() {
|
|
706
|
-
return this._isLeader;
|
|
707
|
-
}
|
|
708
883
|
get roomCode() {
|
|
709
884
|
return this._roomCode;
|
|
710
885
|
}
|
|
@@ -714,19 +889,35 @@
|
|
|
714
889
|
get isDestroyed() {
|
|
715
890
|
return this._isDestroyed;
|
|
716
891
|
}
|
|
892
|
+
/**
|
|
893
|
+
* Read-only list of all known controllers (players) in the room.
|
|
894
|
+
* Returns full ControllerInfo including playerIndex, nickname, connected status, and appearance.
|
|
895
|
+
*
|
|
896
|
+
* Returns a new shallow copy on every access. Cache the result if accessing
|
|
897
|
+
* repeatedly in the same frame/tick.
|
|
898
|
+
*/
|
|
899
|
+
get controllers() {
|
|
900
|
+
return [...this._controllers];
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Returns the number of currently connected players.
|
|
904
|
+
*/
|
|
905
|
+
getControllerCount() {
|
|
906
|
+
return this._controllers.filter((c) => c.connected).length;
|
|
907
|
+
}
|
|
717
908
|
// ---------------------------------------------------------------------------
|
|
718
909
|
// Initialization
|
|
719
910
|
// ---------------------------------------------------------------------------
|
|
720
911
|
async initialize() {
|
|
721
912
|
const parentOrigin = this.config.parentOrigin ?? "*";
|
|
722
913
|
const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
|
|
723
|
-
this.logger.
|
|
914
|
+
this.logger.lifecycle("Initializing controller...", { parentOrigin, timeout });
|
|
724
915
|
return new Promise((resolve, reject) => {
|
|
725
916
|
const timeoutId = setTimeout(() => {
|
|
726
917
|
this.cleanup();
|
|
727
918
|
const error = new SmoreSDKError(
|
|
728
919
|
"TIMEOUT",
|
|
729
|
-
`Controller initialization timed out after ${timeout}ms. Make sure the parent window sends
|
|
920
|
+
`Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
|
|
730
921
|
{ details: { timeout } }
|
|
731
922
|
);
|
|
732
923
|
this.handleError(error);
|
|
@@ -735,22 +926,36 @@
|
|
|
735
926
|
this.boundMessageHandler = (e) => {
|
|
736
927
|
if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
|
|
737
928
|
const msg = e.data;
|
|
738
|
-
if (!
|
|
739
|
-
if (msg.type === "
|
|
929
|
+
if (!isBridgeMessage(msg)) return;
|
|
930
|
+
if (msg.type === "_bridge:init") {
|
|
740
931
|
clearTimeout(timeoutId);
|
|
741
932
|
this.handleInit(msg, parentOrigin, resolve, reject);
|
|
742
|
-
} else if (msg.type === "
|
|
933
|
+
} else if (msg.type === "_bridge:update") {
|
|
743
934
|
this.handleUpdate(msg);
|
|
744
935
|
}
|
|
745
936
|
};
|
|
746
937
|
window.addEventListener("message", this.boundMessageHandler);
|
|
747
|
-
this.logger.
|
|
748
|
-
window.parent.postMessage({ type: "
|
|
938
|
+
this.logger.lifecycle("Sending _bridge:ready to parent");
|
|
939
|
+
window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
|
|
749
940
|
});
|
|
750
941
|
}
|
|
751
942
|
handleInit(msg, parentOrigin, resolve, reject) {
|
|
752
|
-
const
|
|
753
|
-
this.logger.debug("Received
|
|
943
|
+
const initPayload = msg.payload;
|
|
944
|
+
this.logger.debug("Received _bridge:init", initPayload);
|
|
945
|
+
try {
|
|
946
|
+
validateInitPayload(initPayload);
|
|
947
|
+
} catch (err) {
|
|
948
|
+
const error = new SmoreSDKError(
|
|
949
|
+
"INIT_FAILED",
|
|
950
|
+
`Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
|
|
951
|
+
{ details: { payload: initPayload } }
|
|
952
|
+
);
|
|
953
|
+
this.logger.warn("_bridge:init validation failed", error);
|
|
954
|
+
this.handleError(error);
|
|
955
|
+
reject(error);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const initData = initPayload;
|
|
754
959
|
if (initData.side !== "player") {
|
|
755
960
|
const error = new SmoreSDKError(
|
|
756
961
|
"INIT_FAILED",
|
|
@@ -774,49 +979,171 @@
|
|
|
774
979
|
this.transport = new PostMessageTransport(parentOrigin);
|
|
775
980
|
this._roomCode = initData.roomCode;
|
|
776
981
|
this._myIndex = initData.myIndex;
|
|
777
|
-
|
|
982
|
+
const initPlayers = initData.players;
|
|
983
|
+
this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p) => ({
|
|
984
|
+
playerIndex: p.playerIndex,
|
|
985
|
+
nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
|
|
986
|
+
connected: p.connected !== false,
|
|
987
|
+
appearance: p.appearance ?? p.character
|
|
988
|
+
}));
|
|
778
989
|
this.setupEventHandlers();
|
|
779
990
|
this._isReady = true;
|
|
780
|
-
this.logger.
|
|
991
|
+
this.logger.lifecycle("Controller ready", {
|
|
781
992
|
roomCode: this._roomCode,
|
|
782
|
-
myIndex: this._myIndex
|
|
783
|
-
isLeader: this._isLeader
|
|
993
|
+
myIndex: this._myIndex
|
|
784
994
|
});
|
|
785
995
|
this.config.onReady?.();
|
|
786
996
|
resolve();
|
|
787
997
|
}
|
|
788
998
|
handleUpdate(msg) {
|
|
999
|
+
if (!this._isReady) {
|
|
1000
|
+
this.logger.debug("Ignoring _bridge:update before init completes");
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
789
1003
|
const updateData = msg.payload;
|
|
790
|
-
this.logger.debug("Received
|
|
1004
|
+
this.logger.debug("Received _bridge:update", updateData);
|
|
1005
|
+
if (updateData.players && Array.isArray(updateData.players)) {
|
|
1006
|
+
const players = updateData.players;
|
|
1007
|
+
const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p) => ({
|
|
1008
|
+
playerIndex: p.playerIndex,
|
|
1009
|
+
nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
|
|
1010
|
+
connected: p.connected !== false,
|
|
1011
|
+
appearance: p.appearance ?? p.character
|
|
1012
|
+
}));
|
|
1013
|
+
const oldControllers = this._controllers;
|
|
1014
|
+
for (const nc of newControllers) {
|
|
1015
|
+
if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
|
|
1016
|
+
this.config.onControllerJoin?.(nc.playerIndex, nc);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
for (const oc of oldControllers) {
|
|
1020
|
+
if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
|
|
1021
|
+
this.config.onControllerLeave?.(oc.playerIndex);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
for (const nc of newControllers) {
|
|
1025
|
+
const oc = oldControllers.find((c) => c.playerIndex === nc.playerIndex);
|
|
1026
|
+
if (oc) {
|
|
1027
|
+
if (oc.connected && !nc.connected) {
|
|
1028
|
+
this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
|
|
1029
|
+
this.config.onControllerDisconnect?.(nc.playerIndex);
|
|
1030
|
+
}
|
|
1031
|
+
if (!oc.connected && nc.connected) {
|
|
1032
|
+
this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
|
|
1033
|
+
this.config.onControllerReconnect?.(nc.playerIndex, nc);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
this._controllers = newControllers;
|
|
1038
|
+
}
|
|
791
1039
|
}
|
|
792
1040
|
setupEventHandlers() {
|
|
793
1041
|
if (!this.transport) return;
|
|
794
|
-
this.registerHandler(
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1042
|
+
this.registerHandler(SMORE_EVENTS.PLAYER_JOINED, (raw) => {
|
|
1043
|
+
const data = raw;
|
|
1044
|
+
const playerInfo = data.player;
|
|
1045
|
+
const playerIndex = playerInfo?.playerIndex ?? data.playerIndex;
|
|
1046
|
+
if (playerIndex !== void 0) {
|
|
1047
|
+
if (this._controllers.some((c) => c.playerIndex === playerIndex)) return;
|
|
1048
|
+
const controllerInfo = playerInfo ? {
|
|
1049
|
+
playerIndex,
|
|
1050
|
+
nickname: playerInfo.nickname || playerInfo.name || `Player ${playerIndex + 1}`,
|
|
1051
|
+
connected: playerInfo.connected !== false,
|
|
1052
|
+
appearance: playerInfo.appearance ?? playerInfo.character
|
|
1053
|
+
} : {
|
|
1054
|
+
playerIndex,
|
|
1055
|
+
nickname: `Player ${playerIndex + 1}`,
|
|
1056
|
+
connected: true
|
|
1057
|
+
};
|
|
1058
|
+
this._controllers = [...this._controllers, controllerInfo];
|
|
1059
|
+
this.logger.debug("Player joined", { playerIndex });
|
|
1060
|
+
this.config.onControllerJoin?.(playerIndex, controllerInfo);
|
|
802
1061
|
}
|
|
803
|
-
);
|
|
804
|
-
this.registerHandler(
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
if (playerIndex
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1062
|
+
});
|
|
1063
|
+
this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
|
|
1064
|
+
const data = raw;
|
|
1065
|
+
const playerIndex = data.player?.playerIndex ?? data.playerIndex;
|
|
1066
|
+
if (playerIndex !== void 0) {
|
|
1067
|
+
if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
|
|
1068
|
+
this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
|
|
1069
|
+
this.logger.debug("Player left", { playerIndex });
|
|
1070
|
+
this.config.onControllerLeave?.(playerIndex);
|
|
812
1071
|
}
|
|
813
|
-
);
|
|
1072
|
+
});
|
|
1073
|
+
this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
|
|
1074
|
+
const data = raw;
|
|
1075
|
+
const playerData = data.player;
|
|
1076
|
+
const playerIndex = playerData?.playerIndex ?? data.playerIndex;
|
|
1077
|
+
if (playerIndex !== void 0) {
|
|
1078
|
+
this._controllers = this._controllers.map(
|
|
1079
|
+
(c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
|
|
1080
|
+
);
|
|
1081
|
+
this.logger.debug("Player disconnected", { playerIndex });
|
|
1082
|
+
this.config.onControllerDisconnect?.(playerIndex);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
|
|
1086
|
+
const data = raw;
|
|
1087
|
+
const playerData = data.player;
|
|
1088
|
+
const playerIndex = playerData?.playerIndex ?? data.playerIndex;
|
|
1089
|
+
if (playerIndex !== void 0) {
|
|
1090
|
+
const controllerInfo = playerData ? {
|
|
1091
|
+
playerIndex,
|
|
1092
|
+
nickname: playerData.nickname || playerData.name || `Player ${playerIndex + 1}`,
|
|
1093
|
+
connected: true,
|
|
1094
|
+
appearance: playerData.appearance ?? playerData.character
|
|
1095
|
+
} : {
|
|
1096
|
+
playerIndex,
|
|
1097
|
+
nickname: `Player ${playerIndex + 1}`,
|
|
1098
|
+
connected: true
|
|
1099
|
+
};
|
|
1100
|
+
this._controllers = this._controllers.map(
|
|
1101
|
+
(c) => c.playerIndex === playerIndex ? controllerInfo : c
|
|
1102
|
+
);
|
|
1103
|
+
this.logger.debug("Player reconnected", { playerIndex });
|
|
1104
|
+
this.config.onControllerReconnect?.(playerIndex, controllerInfo);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
|
|
1108
|
+
const payload = raw;
|
|
1109
|
+
const playerData = payload?.player;
|
|
1110
|
+
if (playerData && typeof playerData.playerIndex === "number") {
|
|
1111
|
+
const appearance = playerData.character ?? null;
|
|
1112
|
+
this._controllers = this._controllers.map(
|
|
1113
|
+
(c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
|
|
1114
|
+
);
|
|
1115
|
+
this.logger.debug("Player character updated", { playerIndex: playerData.playerIndex });
|
|
1116
|
+
this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
|
|
1120
|
+
const data = raw;
|
|
1121
|
+
const event = data?.event ?? "unknown";
|
|
1122
|
+
this.logger.warn(`Rate limited: ${event}`);
|
|
1123
|
+
this.config.onRateLimited?.(event);
|
|
1124
|
+
});
|
|
1125
|
+
if (this.config.onHostDisconnect) {
|
|
1126
|
+
this.logger.warn("onHostDisconnect is reserved for future use and currently non-functional");
|
|
1127
|
+
}
|
|
1128
|
+
if (this.config.onHostReconnect) {
|
|
1129
|
+
this.logger.warn("onHostReconnect is reserved for future use and currently non-functional");
|
|
1130
|
+
}
|
|
814
1131
|
if (this.config.listeners) {
|
|
815
1132
|
for (const [event, handler] of Object.entries(this.config.listeners)) {
|
|
816
1133
|
if (!handler) continue;
|
|
1134
|
+
this._configListenerEvents.add(event);
|
|
817
1135
|
this.registerHandler(event, (data) => {
|
|
818
1136
|
this.logReceive(event, data);
|
|
819
|
-
|
|
1137
|
+
try {
|
|
1138
|
+
handler(data);
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
this.handleError(
|
|
1141
|
+
new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
|
|
1142
|
+
cause: err instanceof Error ? err : void 0,
|
|
1143
|
+
details: { event }
|
|
1144
|
+
})
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
820
1147
|
});
|
|
821
1148
|
}
|
|
822
1149
|
}
|
|
@@ -829,9 +1156,24 @@
|
|
|
829
1156
|
// ---------------------------------------------------------------------------
|
|
830
1157
|
// Communication Methods
|
|
831
1158
|
// ---------------------------------------------------------------------------
|
|
1159
|
+
/**
|
|
1160
|
+
* Send an event to the Screen. Controller-to-Controller direct communication
|
|
1161
|
+
* is not supported; all messages must go through the Screen.
|
|
1162
|
+
*
|
|
1163
|
+
* Data is sent to the Screen only (not to other controllers). For Screen→Controller communication,
|
|
1164
|
+
* Screen uses broadcast() or sendToController().
|
|
1165
|
+
*
|
|
1166
|
+
* @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
|
|
1167
|
+
* Use the onError callback or smore:rate-limited event to detect rate limiting.
|
|
1168
|
+
*/
|
|
832
1169
|
send(event, data) {
|
|
833
1170
|
this.ensureReady("send");
|
|
834
1171
|
validateEventName(event);
|
|
1172
|
+
if (typeof data !== "object" || data === null) {
|
|
1173
|
+
this.logger.warn(
|
|
1174
|
+
'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).'
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
835
1177
|
this.logSend(event, data);
|
|
836
1178
|
this.transport.emit(event, data);
|
|
837
1179
|
}
|
|
@@ -844,6 +1186,28 @@
|
|
|
844
1186
|
// ---------------------------------------------------------------------------
|
|
845
1187
|
// Event Subscription
|
|
846
1188
|
// ---------------------------------------------------------------------------
|
|
1189
|
+
/**
|
|
1190
|
+
* Register a handler for custom events.
|
|
1191
|
+
*
|
|
1192
|
+
* When receiving events from Screen's `broadcast()`:
|
|
1193
|
+
* handler receives `(data)` — no playerIndex included.
|
|
1194
|
+
*
|
|
1195
|
+
* When receiving events from Screen's `sendToController()`:
|
|
1196
|
+
* handler receives `(data)` — targeted to this specific controller.
|
|
1197
|
+
*
|
|
1198
|
+
* @note Unlike Screen's `on()` which receives `(playerIndex, data)`,
|
|
1199
|
+
* Controller's `on()` receives only `(data)` since there's only one player per controller.
|
|
1200
|
+
*
|
|
1201
|
+
* Controller's on() handler signature: (data) => void
|
|
1202
|
+
* Unlike Screen's (playerIndex, data) => void, Controller doesn't receive playerIndex
|
|
1203
|
+
* because Controller only receives events from Screen, not from other controllers.
|
|
1204
|
+
* The sender is always the Screen, so playerIndex is not applicable.
|
|
1205
|
+
*
|
|
1206
|
+
* **Important:** If called before the Controller is ready (i.e., before `await createController()`
|
|
1207
|
+
* resolves or before the `onReady` callback fires), the handler is stored locally but
|
|
1208
|
+
* will NOT receive events until the transport is initialized. Always call `on()` after
|
|
1209
|
+
* initialization completes, or use `config.listeners` for handlers needed from the start.
|
|
1210
|
+
*/
|
|
847
1211
|
on(event, handler) {
|
|
848
1212
|
validateEventName(event);
|
|
849
1213
|
let listeners = this.eventListeners.get(event);
|
|
@@ -854,11 +1218,21 @@
|
|
|
854
1218
|
listeners.add(handler);
|
|
855
1219
|
const transportHandler = (data) => {
|
|
856
1220
|
this.logReceive(event, data);
|
|
857
|
-
|
|
1221
|
+
try {
|
|
1222
|
+
handler(data);
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
this.handleError(
|
|
1225
|
+
new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
|
|
1226
|
+
cause: err instanceof Error ? err : void 0,
|
|
1227
|
+
details: { event }
|
|
1228
|
+
})
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
858
1231
|
};
|
|
859
1232
|
if (this.transport) {
|
|
860
1233
|
this.transport.on(event, transportHandler);
|
|
861
1234
|
this.registeredHandlers.push({ event, handler: transportHandler });
|
|
1235
|
+
this.handlerToTransport.set(handler, { event, transportHandler });
|
|
862
1236
|
}
|
|
863
1237
|
return () => {
|
|
864
1238
|
listeners?.delete(handler);
|
|
@@ -869,8 +1243,25 @@
|
|
|
869
1243
|
this.registeredHandlers = this.registeredHandlers.filter(
|
|
870
1244
|
(h) => h.handler !== transportHandler
|
|
871
1245
|
);
|
|
1246
|
+
this.handlerToTransport.delete(handler);
|
|
872
1247
|
};
|
|
873
1248
|
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Add a one-time listener that auto-removes after first call.
|
|
1251
|
+
*
|
|
1252
|
+
* @note The handler is internally wrapped, so it cannot be removed via
|
|
1253
|
+
* `off(event, originalHandler)`. Use the returned unsubscribe function instead.
|
|
1254
|
+
*
|
|
1255
|
+
* @example
|
|
1256
|
+
* ```ts
|
|
1257
|
+
* const unsubscribe = controller.once('game-start', (data) => {
|
|
1258
|
+
* console.log('Game started!', data);
|
|
1259
|
+
* });
|
|
1260
|
+
*
|
|
1261
|
+
* // To cancel before it fires:
|
|
1262
|
+
* unsubscribe();
|
|
1263
|
+
* ```
|
|
1264
|
+
*/
|
|
874
1265
|
once(event, handler) {
|
|
875
1266
|
const unsubscribe = this.on(event, ((data) => {
|
|
876
1267
|
unsubscribe();
|
|
@@ -880,15 +1271,38 @@
|
|
|
880
1271
|
}
|
|
881
1272
|
off(event, handler) {
|
|
882
1273
|
if (!handler) {
|
|
883
|
-
this.
|
|
884
|
-
|
|
885
|
-
|
|
1274
|
+
if (this._configListenerEvents.has(event)) {
|
|
1275
|
+
for (const [key, val] of this.handlerToTransport) {
|
|
1276
|
+
if (val.event === event) {
|
|
1277
|
+
this.transport?.off(event, val.transportHandler);
|
|
1278
|
+
this.registeredHandlers = this.registeredHandlers.filter(
|
|
1279
|
+
(h) => h.handler !== val.transportHandler
|
|
1280
|
+
);
|
|
1281
|
+
this.handlerToTransport.delete(key);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
} else {
|
|
1285
|
+
this.eventListeners.delete(event);
|
|
1286
|
+
this.transport?.off(event);
|
|
1287
|
+
this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
|
|
1288
|
+
for (const [key, val] of this.handlerToTransport) {
|
|
1289
|
+
if (val.event === event) this.handlerToTransport.delete(key);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
886
1292
|
} else {
|
|
887
1293
|
const listeners = this.eventListeners.get(event);
|
|
888
1294
|
listeners?.delete(handler);
|
|
889
1295
|
if (listeners?.size === 0) {
|
|
890
1296
|
this.eventListeners.delete(event);
|
|
891
1297
|
}
|
|
1298
|
+
const entry = this.handlerToTransport.get(handler);
|
|
1299
|
+
if (entry) {
|
|
1300
|
+
this.transport?.off(event, entry.transportHandler);
|
|
1301
|
+
this.registeredHandlers = this.registeredHandlers.filter(
|
|
1302
|
+
(h) => h.handler !== entry.transportHandler
|
|
1303
|
+
);
|
|
1304
|
+
this.handlerToTransport.delete(handler);
|
|
1305
|
+
}
|
|
892
1306
|
}
|
|
893
1307
|
}
|
|
894
1308
|
// ---------------------------------------------------------------------------
|
|
@@ -896,7 +1310,7 @@
|
|
|
896
1310
|
// ---------------------------------------------------------------------------
|
|
897
1311
|
destroy() {
|
|
898
1312
|
if (this._isDestroyed) return;
|
|
899
|
-
this.logger.
|
|
1313
|
+
this.logger.lifecycle("Destroying controller");
|
|
900
1314
|
this.cleanup();
|
|
901
1315
|
this._isDestroyed = true;
|
|
902
1316
|
}
|
|
@@ -907,6 +1321,7 @@
|
|
|
907
1321
|
}
|
|
908
1322
|
this.registeredHandlers = [];
|
|
909
1323
|
this.eventListeners.clear();
|
|
1324
|
+
this.handlerToTransport.clear();
|
|
910
1325
|
if (this.transport) {
|
|
911
1326
|
this.transport.destroy();
|
|
912
1327
|
this.transport = null;
|
|
@@ -936,6 +1351,7 @@
|
|
|
936
1351
|
}
|
|
937
1352
|
}
|
|
938
1353
|
handleError(error) {
|
|
1354
|
+
this.logger.warn(`Error in handler: ${error.message}`);
|
|
939
1355
|
if (this.config.onError) {
|
|
940
1356
|
this.config.onError(error.toSmoreError());
|
|
941
1357
|
} else {
|
|
@@ -943,18 +1359,10 @@
|
|
|
943
1359
|
}
|
|
944
1360
|
}
|
|
945
1361
|
logSend(event, data) {
|
|
946
|
-
|
|
947
|
-
const shouldLog = typeof options === "object" ? options.logSend ?? true : Boolean(options);
|
|
948
|
-
if (shouldLog) {
|
|
949
|
-
this.logger.debug(`\u2192 SEND [${event}]`, data);
|
|
950
|
-
}
|
|
1362
|
+
this.logger.send(event, data);
|
|
951
1363
|
}
|
|
952
1364
|
logReceive(event, data) {
|
|
953
|
-
|
|
954
|
-
const shouldLog = typeof options === "object" ? options.logReceive ?? true : Boolean(options);
|
|
955
|
-
if (shouldLog) {
|
|
956
|
-
this.logger.debug(`\u2190 RECV [${event}]`, data);
|
|
957
|
-
}
|
|
1365
|
+
this.logger.receive(event, data);
|
|
958
1366
|
}
|
|
959
1367
|
}
|
|
960
1368
|
function createController(config) {
|
|
@@ -964,61 +1372,40 @@
|
|
|
964
1372
|
return promise;
|
|
965
1373
|
}
|
|
966
1374
|
|
|
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);
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const SMORE_EVENTS = {
|
|
987
|
-
// 게임 lifecycle
|
|
988
|
-
READY: "smore:ready",
|
|
989
|
-
GAME_OVER: "smore:game-over",
|
|
990
|
-
RETURN_TO_LOBBY: "smore:return-to-lobby",
|
|
991
|
-
// 플레이어 관리
|
|
992
|
-
PLAYER_JOIN: "smore:player-join",
|
|
993
|
-
PLAYER_LEAVE: "smore:player-leave",
|
|
994
|
-
// 특정 플레이어에게 전송 (내부용)
|
|
995
|
-
SEND_TO_PLAYER: "smore:send-to-player",
|
|
996
|
-
// 초기화
|
|
997
|
-
INIT: "smore:init",
|
|
998
|
-
UPDATE: "smore:update"
|
|
999
|
-
};
|
|
1000
|
-
function validateUserEvent(event) {
|
|
1001
|
-
if (event.includes(":")) {
|
|
1002
|
-
throw new Error(
|
|
1003
|
-
`Invalid event name "${event}": User events cannot contain ':'. Use '_' or '-' instead. System events use 'smore:' prefix.`
|
|
1004
|
-
);
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
function isSystemEvent(event) {
|
|
1008
|
-
return event.startsWith("smore:");
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
1375
|
function createMockScreen(options = {}) {
|
|
1012
1376
|
const {
|
|
1013
1377
|
roomCode = "TEST",
|
|
1014
1378
|
controllers: initialControllers = [],
|
|
1015
|
-
autoReady = true
|
|
1379
|
+
autoReady = true,
|
|
1380
|
+
onReady: onReadyCb,
|
|
1381
|
+
onControllerJoin: onJoinCb,
|
|
1382
|
+
onControllerLeave: onLeaveCb,
|
|
1383
|
+
onControllerDisconnect: onDisconnectCb,
|
|
1384
|
+
onControllerReconnect: onReconnectCb,
|
|
1385
|
+
onCharacterUpdated: onCharacterUpdatedCb,
|
|
1386
|
+
onRateLimited: onRateLimitedCb,
|
|
1387
|
+
onError: onErrorCb
|
|
1016
1388
|
} = options;
|
|
1017
1389
|
let _controllers = [...initialControllers];
|
|
1018
1390
|
let _isReady = false;
|
|
1019
1391
|
let _isDestroyed = false;
|
|
1020
|
-
let _leaderIndex = initialControllers[0]?.playerIndex ?? -1;
|
|
1021
1392
|
const listeners = /* @__PURE__ */ new Map();
|
|
1393
|
+
let onReadyCallback;
|
|
1394
|
+
let onControllerJoinCallback;
|
|
1395
|
+
let onControllerLeaveCallback;
|
|
1396
|
+
let onControllerDisconnectCallback;
|
|
1397
|
+
let onControllerReconnectCallback;
|
|
1398
|
+
let onCharacterUpdatedCallback;
|
|
1399
|
+
let onRateLimitedCallback;
|
|
1400
|
+
let onErrorCallback;
|
|
1401
|
+
onReadyCallback = onReadyCb;
|
|
1402
|
+
onControllerJoinCallback = onJoinCb;
|
|
1403
|
+
onControllerLeaveCallback = onLeaveCb;
|
|
1404
|
+
onControllerDisconnectCallback = onDisconnectCb;
|
|
1405
|
+
onControllerReconnectCallback = onReconnectCb;
|
|
1406
|
+
onCharacterUpdatedCallback = onCharacterUpdatedCb;
|
|
1407
|
+
onRateLimitedCallback = onRateLimitedCb;
|
|
1408
|
+
onErrorCallback = onErrorCb;
|
|
1022
1409
|
const broadcasts = [];
|
|
1023
1410
|
const sends = [];
|
|
1024
1411
|
const screen = {
|
|
@@ -1029,9 +1416,6 @@
|
|
|
1029
1416
|
get roomCode() {
|
|
1030
1417
|
return roomCode;
|
|
1031
1418
|
},
|
|
1032
|
-
get leaderIndex() {
|
|
1033
|
-
return _leaderIndex;
|
|
1034
|
-
},
|
|
1035
1419
|
get isReady() {
|
|
1036
1420
|
return _isReady;
|
|
1037
1421
|
},
|
|
@@ -1043,18 +1427,30 @@
|
|
|
1043
1427
|
if (_isDestroyed) {
|
|
1044
1428
|
throw new Error("Cannot broadcast: screen is destroyed");
|
|
1045
1429
|
}
|
|
1430
|
+
if (!_isReady) {
|
|
1431
|
+
throw new Error("Cannot broadcast: screen is not ready");
|
|
1432
|
+
}
|
|
1433
|
+
validateEventName(event);
|
|
1046
1434
|
broadcasts.push({ event, data });
|
|
1047
1435
|
},
|
|
1048
1436
|
broadcastRaw(event, data) {
|
|
1049
1437
|
if (_isDestroyed) {
|
|
1050
1438
|
throw new Error("Cannot broadcast: screen is destroyed");
|
|
1051
1439
|
}
|
|
1440
|
+
if (!_isReady) {
|
|
1441
|
+
throw new Error("Cannot broadcast: screen is not ready");
|
|
1442
|
+
}
|
|
1443
|
+
validateEventName(event);
|
|
1052
1444
|
broadcasts.push({ event, data });
|
|
1053
1445
|
},
|
|
1054
1446
|
sendToController(playerIndex, event, data) {
|
|
1055
1447
|
if (_isDestroyed) {
|
|
1056
1448
|
throw new Error("Cannot send: screen is destroyed");
|
|
1057
1449
|
}
|
|
1450
|
+
if (!_isReady) {
|
|
1451
|
+
throw new Error("Cannot send: screen is not ready");
|
|
1452
|
+
}
|
|
1453
|
+
validateEventName(event);
|
|
1058
1454
|
if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
|
|
1059
1455
|
throw new Error(`Invalid player index: ${playerIndex}`);
|
|
1060
1456
|
}
|
|
@@ -1064,6 +1460,10 @@
|
|
|
1064
1460
|
if (_isDestroyed) {
|
|
1065
1461
|
throw new Error("Cannot send: screen is destroyed");
|
|
1066
1462
|
}
|
|
1463
|
+
if (!_isReady) {
|
|
1464
|
+
throw new Error("Cannot send: screen is not ready");
|
|
1465
|
+
}
|
|
1466
|
+
validateEventName(event);
|
|
1067
1467
|
if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
|
|
1068
1468
|
throw new Error(`Invalid player index: ${playerIndex}`);
|
|
1069
1469
|
}
|
|
@@ -1071,10 +1471,17 @@
|
|
|
1071
1471
|
},
|
|
1072
1472
|
// === Game Lifecycle ===
|
|
1073
1473
|
gameOver(results) {
|
|
1074
|
-
|
|
1474
|
+
if (_isDestroyed) {
|
|
1475
|
+
throw new Error("Cannot call gameOver: screen is destroyed");
|
|
1476
|
+
}
|
|
1477
|
+
if (!_isReady) {
|
|
1478
|
+
throw new Error("Cannot call gameOver: screen is not ready");
|
|
1479
|
+
}
|
|
1480
|
+
broadcasts.push({ event: "smore:game-over", data: { results } });
|
|
1075
1481
|
},
|
|
1076
1482
|
// === Event Subscription ===
|
|
1077
1483
|
on(event, handler) {
|
|
1484
|
+
validateEventName(event);
|
|
1078
1485
|
const eventStr = event;
|
|
1079
1486
|
if (!listeners.has(eventStr)) {
|
|
1080
1487
|
listeners.set(eventStr, /* @__PURE__ */ new Set());
|
|
@@ -1085,6 +1492,7 @@
|
|
|
1085
1492
|
};
|
|
1086
1493
|
},
|
|
1087
1494
|
once(event, handler) {
|
|
1495
|
+
validateEventName(event);
|
|
1088
1496
|
const wrapper = (playerIndex, data) => {
|
|
1089
1497
|
handler(playerIndex, data);
|
|
1090
1498
|
screen.off(event, wrapper);
|
|
@@ -1092,6 +1500,7 @@
|
|
|
1092
1500
|
return screen.on(event, wrapper);
|
|
1093
1501
|
},
|
|
1094
1502
|
off(event, handler) {
|
|
1503
|
+
validateEventName(event);
|
|
1095
1504
|
const eventStr = event;
|
|
1096
1505
|
if (!handler) {
|
|
1097
1506
|
listeners.delete(eventStr);
|
|
@@ -1103,13 +1512,16 @@
|
|
|
1103
1512
|
getController(playerIndex) {
|
|
1104
1513
|
return _controllers.find((c) => c.playerIndex === playerIndex);
|
|
1105
1514
|
},
|
|
1106
|
-
isLeader(playerIndex) {
|
|
1107
|
-
return playerIndex === _leaderIndex;
|
|
1108
|
-
},
|
|
1109
1515
|
getControllerCount() {
|
|
1110
|
-
return _controllers.length;
|
|
1516
|
+
return _controllers.filter((c) => c.connected).length;
|
|
1517
|
+
},
|
|
1518
|
+
hasAnyConnectedControllers() {
|
|
1519
|
+
return _controllers.some((c) => c.connected);
|
|
1111
1520
|
},
|
|
1112
1521
|
// === Cleanup ===
|
|
1522
|
+
/**
|
|
1523
|
+
* Note: destroy() clears recorded broadcast/event arrays. Call getBroadcasts() before destroy() if assertions are needed.
|
|
1524
|
+
*/
|
|
1113
1525
|
destroy() {
|
|
1114
1526
|
_isDestroyed = true;
|
|
1115
1527
|
listeners.clear();
|
|
@@ -1118,6 +1530,7 @@
|
|
|
1118
1530
|
},
|
|
1119
1531
|
// === Mock-specific methods ===
|
|
1120
1532
|
simulateEvent(playerIndex, event, data) {
|
|
1533
|
+
validateEventName(event);
|
|
1121
1534
|
const eventStr = event;
|
|
1122
1535
|
const handlers = listeners.get(eventStr);
|
|
1123
1536
|
if (handlers) {
|
|
@@ -1128,14 +1541,80 @@
|
|
|
1128
1541
|
},
|
|
1129
1542
|
simulateControllerJoin(info) {
|
|
1130
1543
|
_controllers.push(info);
|
|
1131
|
-
if (
|
|
1132
|
-
|
|
1544
|
+
if (onControllerJoinCallback) {
|
|
1545
|
+
onControllerJoinCallback(info.playerIndex, info);
|
|
1133
1546
|
}
|
|
1134
1547
|
},
|
|
1135
1548
|
simulateControllerLeave(playerIndex) {
|
|
1136
1549
|
_controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
|
|
1137
|
-
if (
|
|
1138
|
-
|
|
1550
|
+
if (onControllerLeaveCallback) {
|
|
1551
|
+
onControllerLeaveCallback(playerIndex);
|
|
1552
|
+
}
|
|
1553
|
+
},
|
|
1554
|
+
/**
|
|
1555
|
+
* Simulate a controller network disconnect (player still in room but unreachable).
|
|
1556
|
+
*
|
|
1557
|
+
* @example
|
|
1558
|
+
* ```ts
|
|
1559
|
+
* screen.simulateControllerDisconnect(0);
|
|
1560
|
+
* expect(screen.getController(0)?.connected).toBe(false);
|
|
1561
|
+
* ```
|
|
1562
|
+
*/
|
|
1563
|
+
simulateControllerDisconnect(playerIndex) {
|
|
1564
|
+
const controller = _controllers.find((c) => c.playerIndex === playerIndex);
|
|
1565
|
+
if (!controller) {
|
|
1566
|
+
throw new Error(`Controller ${playerIndex} not found`);
|
|
1567
|
+
}
|
|
1568
|
+
_controllers = _controllers.map(
|
|
1569
|
+
(c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
|
|
1570
|
+
);
|
|
1571
|
+
if (onControllerDisconnectCallback) {
|
|
1572
|
+
onControllerDisconnectCallback(playerIndex);
|
|
1573
|
+
}
|
|
1574
|
+
},
|
|
1575
|
+
/**
|
|
1576
|
+
* Simulate a controller network reconnect after disconnect.
|
|
1577
|
+
*
|
|
1578
|
+
* @example
|
|
1579
|
+
* ```ts
|
|
1580
|
+
* screen.simulateControllerDisconnect(0);
|
|
1581
|
+
* screen.simulateControllerReconnect(0);
|
|
1582
|
+
* expect(screen.getController(0)?.connected).toBe(true);
|
|
1583
|
+
* ```
|
|
1584
|
+
*/
|
|
1585
|
+
simulateControllerReconnect(playerIndex) {
|
|
1586
|
+
const controller = _controllers.find((c) => c.playerIndex === playerIndex);
|
|
1587
|
+
if (!controller) {
|
|
1588
|
+
throw new Error(`Controller ${playerIndex} not found`);
|
|
1589
|
+
}
|
|
1590
|
+
const reconnectedController = { ...controller, connected: true };
|
|
1591
|
+
_controllers = _controllers.map(
|
|
1592
|
+
(c) => c.playerIndex === playerIndex ? reconnectedController : c
|
|
1593
|
+
);
|
|
1594
|
+
if (onControllerReconnectCallback) {
|
|
1595
|
+
onControllerReconnectCallback(playerIndex, reconnectedController);
|
|
1596
|
+
}
|
|
1597
|
+
},
|
|
1598
|
+
simulateCharacterUpdate(playerIndex, appearance) {
|
|
1599
|
+
const controller = _controllers.find((c) => c.playerIndex === playerIndex);
|
|
1600
|
+
if (!controller) {
|
|
1601
|
+
throw new Error(`Controller ${playerIndex} not found`);
|
|
1602
|
+
}
|
|
1603
|
+
_controllers = _controllers.map(
|
|
1604
|
+
(c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
|
|
1605
|
+
);
|
|
1606
|
+
if (onCharacterUpdatedCallback) {
|
|
1607
|
+
onCharacterUpdatedCallback(playerIndex, appearance);
|
|
1608
|
+
}
|
|
1609
|
+
},
|
|
1610
|
+
simulateRateLimited(event) {
|
|
1611
|
+
if (onRateLimitedCallback) {
|
|
1612
|
+
onRateLimitedCallback(event);
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
simulateError(error) {
|
|
1616
|
+
if (onErrorCallback) {
|
|
1617
|
+
onErrorCallback(error);
|
|
1139
1618
|
}
|
|
1140
1619
|
},
|
|
1141
1620
|
getBroadcasts() {
|
|
@@ -1150,6 +1629,9 @@
|
|
|
1150
1629
|
},
|
|
1151
1630
|
triggerReady() {
|
|
1152
1631
|
_isReady = true;
|
|
1632
|
+
if (onReadyCallback) {
|
|
1633
|
+
onReadyCallback();
|
|
1634
|
+
}
|
|
1153
1635
|
}
|
|
1154
1636
|
};
|
|
1155
1637
|
if (autoReady) {
|
|
@@ -1161,22 +1643,42 @@
|
|
|
1161
1643
|
const {
|
|
1162
1644
|
roomCode = "TEST",
|
|
1163
1645
|
myIndex = 0,
|
|
1164
|
-
|
|
1165
|
-
|
|
1646
|
+
autoReady = true,
|
|
1647
|
+
onReady: onReadyCb,
|
|
1648
|
+
onControllerJoin: onJoinCb,
|
|
1649
|
+
onControllerLeave: onLeaveCb,
|
|
1650
|
+
onControllerDisconnect: onDisconnectCb,
|
|
1651
|
+
onControllerReconnect: onReconnectCb,
|
|
1652
|
+
onCharacterUpdated: onCharacterUpdatedCb,
|
|
1653
|
+
onRateLimited: onRateLimitedCb,
|
|
1654
|
+
onError: onErrorCb
|
|
1166
1655
|
} = options;
|
|
1167
1656
|
let _isReady = false;
|
|
1168
1657
|
let _isDestroyed = false;
|
|
1169
|
-
let
|
|
1658
|
+
let _controllers = options.controllers ?? [];
|
|
1170
1659
|
const listeners = /* @__PURE__ */ new Map();
|
|
1660
|
+
let onReadyCallback;
|
|
1661
|
+
let onControllerJoinCallback;
|
|
1662
|
+
let onControllerLeaveCallback;
|
|
1663
|
+
let onControllerDisconnectCallback;
|
|
1664
|
+
let onControllerReconnectCallback;
|
|
1665
|
+
let onCharacterUpdatedCallback;
|
|
1666
|
+
let onRateLimitedCallback;
|
|
1667
|
+
let onErrorCallback;
|
|
1668
|
+
onReadyCallback = onReadyCb;
|
|
1669
|
+
onControllerJoinCallback = onJoinCb;
|
|
1670
|
+
onControllerLeaveCallback = onLeaveCb;
|
|
1671
|
+
onControllerDisconnectCallback = onDisconnectCb;
|
|
1672
|
+
onControllerReconnectCallback = onReconnectCb;
|
|
1673
|
+
onCharacterUpdatedCallback = onCharacterUpdatedCb;
|
|
1674
|
+
onRateLimitedCallback = onRateLimitedCb;
|
|
1675
|
+
onErrorCallback = onErrorCb;
|
|
1171
1676
|
const sentEvents = [];
|
|
1172
1677
|
const controller = {
|
|
1173
1678
|
// === Properties ===
|
|
1174
1679
|
get myIndex() {
|
|
1175
1680
|
return myIndex;
|
|
1176
1681
|
},
|
|
1177
|
-
get isLeader() {
|
|
1178
|
-
return _isLeader;
|
|
1179
|
-
},
|
|
1180
1682
|
get roomCode() {
|
|
1181
1683
|
return roomCode;
|
|
1182
1684
|
},
|
|
@@ -1186,21 +1688,30 @@
|
|
|
1186
1688
|
get isDestroyed() {
|
|
1187
1689
|
return _isDestroyed;
|
|
1188
1690
|
},
|
|
1691
|
+
get controllers() {
|
|
1692
|
+
return [..._controllers];
|
|
1693
|
+
},
|
|
1694
|
+
getControllerCount() {
|
|
1695
|
+
return _controllers.filter((c) => c.connected).length;
|
|
1696
|
+
},
|
|
1189
1697
|
// === Communication Methods ===
|
|
1190
1698
|
send(event, data) {
|
|
1191
1699
|
if (_isDestroyed) {
|
|
1192
1700
|
throw new Error("Cannot send: controller is destroyed");
|
|
1193
1701
|
}
|
|
1702
|
+
validateEventName(event);
|
|
1194
1703
|
sentEvents.push({ event, data });
|
|
1195
1704
|
},
|
|
1196
1705
|
sendRaw(event, data) {
|
|
1197
1706
|
if (_isDestroyed) {
|
|
1198
1707
|
throw new Error("Cannot send: controller is destroyed");
|
|
1199
1708
|
}
|
|
1709
|
+
validateEventName(event);
|
|
1200
1710
|
sentEvents.push({ event, data });
|
|
1201
1711
|
},
|
|
1202
1712
|
// === Event Subscription ===
|
|
1203
1713
|
on(event, handler) {
|
|
1714
|
+
validateEventName(event);
|
|
1204
1715
|
const eventStr = event;
|
|
1205
1716
|
if (!listeners.has(eventStr)) {
|
|
1206
1717
|
listeners.set(eventStr, /* @__PURE__ */ new Set());
|
|
@@ -1211,6 +1722,7 @@
|
|
|
1211
1722
|
};
|
|
1212
1723
|
},
|
|
1213
1724
|
once(event, handler) {
|
|
1725
|
+
validateEventName(event);
|
|
1214
1726
|
const wrapper = (data) => {
|
|
1215
1727
|
handler(data);
|
|
1216
1728
|
controller.off(event, wrapper);
|
|
@@ -1218,6 +1730,7 @@
|
|
|
1218
1730
|
return controller.on(event, wrapper);
|
|
1219
1731
|
},
|
|
1220
1732
|
off(event, handler) {
|
|
1733
|
+
validateEventName(event);
|
|
1221
1734
|
const eventStr = event;
|
|
1222
1735
|
if (!handler) {
|
|
1223
1736
|
listeners.delete(eventStr);
|
|
@@ -1233,6 +1746,7 @@
|
|
|
1233
1746
|
},
|
|
1234
1747
|
// === Mock-specific methods ===
|
|
1235
1748
|
simulateEvent(event, data) {
|
|
1749
|
+
validateEventName(event);
|
|
1236
1750
|
const eventStr = event;
|
|
1237
1751
|
const handlers = listeners.get(eventStr);
|
|
1238
1752
|
if (handlers) {
|
|
@@ -1249,9 +1763,100 @@
|
|
|
1249
1763
|
},
|
|
1250
1764
|
triggerReady() {
|
|
1251
1765
|
_isReady = true;
|
|
1766
|
+
if (onReadyCallback) {
|
|
1767
|
+
onReadyCallback();
|
|
1768
|
+
}
|
|
1769
|
+
},
|
|
1770
|
+
/**
|
|
1771
|
+
* Simulate a new player joining the room.
|
|
1772
|
+
* Stores full ControllerInfo (nickname, appearance, etc.) for later retrieval.
|
|
1773
|
+
*
|
|
1774
|
+
* @example
|
|
1775
|
+
* ```ts
|
|
1776
|
+
* controller.simulatePlayerJoin(2, { playerIndex: 2, nickname: 'Alice', connected: true });
|
|
1777
|
+
* expect(controller.getControllerCount()).toBe(3);
|
|
1778
|
+
* expect(controller.controllers.find(c => c.playerIndex === 2)?.nickname).toBe('Alice');
|
|
1779
|
+
* ```
|
|
1780
|
+
*/
|
|
1781
|
+
simulatePlayerJoin(playerIndex, info) {
|
|
1782
|
+
if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
|
|
1783
|
+
_controllers = [..._controllers, { ...info, connected: info.connected ?? true }];
|
|
1784
|
+
}
|
|
1785
|
+
if (onControllerJoinCallback) {
|
|
1786
|
+
onControllerJoinCallback(playerIndex, info);
|
|
1787
|
+
}
|
|
1788
|
+
},
|
|
1789
|
+
/**
|
|
1790
|
+
* Simulate a player leaving the room (fully removed).
|
|
1791
|
+
*
|
|
1792
|
+
* @example
|
|
1793
|
+
* ```ts
|
|
1794
|
+
* controller.simulatePlayerLeave(1);
|
|
1795
|
+
* expect(controller.controllers.some(c => c.playerIndex === 1)).toBe(false);
|
|
1796
|
+
* ```
|
|
1797
|
+
*/
|
|
1798
|
+
simulatePlayerLeave(playerIndex) {
|
|
1799
|
+
_controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
|
|
1800
|
+
if (onControllerLeaveCallback) {
|
|
1801
|
+
onControllerLeaveCallback(playerIndex);
|
|
1802
|
+
}
|
|
1252
1803
|
},
|
|
1253
|
-
|
|
1254
|
-
|
|
1804
|
+
/**
|
|
1805
|
+
* Simulate a player network disconnect (player still in room but unreachable).
|
|
1806
|
+
*
|
|
1807
|
+
* @example
|
|
1808
|
+
* ```ts
|
|
1809
|
+
* controller.simulatePlayerDisconnect(1);
|
|
1810
|
+
* expect(controller.controllers.find(c => c.playerIndex === 1)?.connected).toBe(false);
|
|
1811
|
+
* ```
|
|
1812
|
+
*/
|
|
1813
|
+
simulatePlayerDisconnect(playerIndex) {
|
|
1814
|
+
_controllers = _controllers.map(
|
|
1815
|
+
(c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
|
|
1816
|
+
);
|
|
1817
|
+
if (onControllerDisconnectCallback) {
|
|
1818
|
+
onControllerDisconnectCallback(playerIndex);
|
|
1819
|
+
}
|
|
1820
|
+
},
|
|
1821
|
+
/**
|
|
1822
|
+
* Simulate a player network reconnect after disconnect.
|
|
1823
|
+
*
|
|
1824
|
+
* @example
|
|
1825
|
+
* ```ts
|
|
1826
|
+
* controller.simulatePlayerDisconnect(1);
|
|
1827
|
+
* controller.simulatePlayerReconnect(1, { playerIndex: 1, nickname: 'Bob', connected: true });
|
|
1828
|
+
* expect(controller.controllers.find(c => c.playerIndex === 1)?.connected).toBe(true);
|
|
1829
|
+
* ```
|
|
1830
|
+
*/
|
|
1831
|
+
simulatePlayerReconnect(playerIndex, info) {
|
|
1832
|
+
_controllers = _controllers.map(
|
|
1833
|
+
(c) => c.playerIndex === playerIndex ? { ...info, connected: true } : c
|
|
1834
|
+
);
|
|
1835
|
+
if (onControllerReconnectCallback) {
|
|
1836
|
+
onControllerReconnectCallback(playerIndex, info);
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
simulateCharacterUpdate(playerIndex, appearance) {
|
|
1840
|
+
const controller2 = _controllers.find((c) => c.playerIndex === playerIndex);
|
|
1841
|
+
if (!controller2) {
|
|
1842
|
+
throw new Error(`Controller ${playerIndex} not found`);
|
|
1843
|
+
}
|
|
1844
|
+
_controllers = _controllers.map(
|
|
1845
|
+
(c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
|
|
1846
|
+
);
|
|
1847
|
+
if (onCharacterUpdatedCallback) {
|
|
1848
|
+
onCharacterUpdatedCallback(playerIndex, appearance);
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
simulateRateLimited(event) {
|
|
1852
|
+
if (onRateLimitedCallback) {
|
|
1853
|
+
onRateLimitedCallback(event);
|
|
1854
|
+
}
|
|
1855
|
+
},
|
|
1856
|
+
simulateError(error) {
|
|
1857
|
+
if (onErrorCallback) {
|
|
1858
|
+
onErrorCallback(error);
|
|
1859
|
+
}
|
|
1255
1860
|
}
|
|
1256
1861
|
};
|
|
1257
1862
|
if (autoReady) {
|
|
@@ -1260,16 +1865,12 @@
|
|
|
1260
1865
|
return controller;
|
|
1261
1866
|
}
|
|
1262
1867
|
|
|
1263
|
-
exports.
|
|
1264
|
-
exports.PostMessageTransport = PostMessageTransport;
|
|
1265
|
-
exports.SMORE_EVENTS = SMORE_EVENTS;
|
|
1266
|
-
exports.SmoreSDKError = SmoreSDKError$1;
|
|
1868
|
+
exports.SmoreSDKError = SmoreSDKError;
|
|
1267
1869
|
exports.createController = createController;
|
|
1268
1870
|
exports.createMockController = createMockController;
|
|
1269
1871
|
exports.createMockScreen = createMockScreen;
|
|
1270
1872
|
exports.createScreen = createScreen;
|
|
1271
|
-
exports.
|
|
1272
|
-
exports.validateUserEvent = validateUserEvent;
|
|
1873
|
+
exports.validateEventName = validateEventName;
|
|
1273
1874
|
|
|
1274
1875
|
}));
|
|
1275
1876
|
//# sourceMappingURL=smore-sdk.umd.js.map
|