@playcademy/sdk 0.2.1 → 0.2.3
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 +20 -17
- package/dist/index.d.ts +486 -480
- package/dist/index.js +1455 -1399
- package/dist/internal.d.ts +4515 -3537
- package/dist/internal.js +2612 -2603
- package/dist/server.d.ts +304 -44
- package/dist/server.js +118 -9
- package/dist/types.d.ts +2779 -996
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -1,3 +1,202 @@
|
|
|
1
|
+
// src/messaging.ts
|
|
2
|
+
var MessageEvents;
|
|
3
|
+
((MessageEvents2) => {
|
|
4
|
+
MessageEvents2["INIT"] = "PLAYCADEMY_INIT";
|
|
5
|
+
MessageEvents2["TOKEN_REFRESH"] = "PLAYCADEMY_TOKEN_REFRESH";
|
|
6
|
+
MessageEvents2["PAUSE"] = "PLAYCADEMY_PAUSE";
|
|
7
|
+
MessageEvents2["RESUME"] = "PLAYCADEMY_RESUME";
|
|
8
|
+
MessageEvents2["FORCE_EXIT"] = "PLAYCADEMY_FORCE_EXIT";
|
|
9
|
+
MessageEvents2["OVERLAY"] = "PLAYCADEMY_OVERLAY";
|
|
10
|
+
MessageEvents2["CONNECTION_STATE"] = "PLAYCADEMY_CONNECTION_STATE";
|
|
11
|
+
MessageEvents2["READY"] = "PLAYCADEMY_READY";
|
|
12
|
+
MessageEvents2["EXIT"] = "PLAYCADEMY_EXIT";
|
|
13
|
+
MessageEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
|
|
14
|
+
MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
|
|
15
|
+
MessageEvents2["DISPLAY_ALERT"] = "PLAYCADEMY_DISPLAY_ALERT";
|
|
16
|
+
MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
|
|
17
|
+
MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
|
|
18
|
+
})(MessageEvents ||= {});
|
|
19
|
+
|
|
20
|
+
class PlaycademyMessaging {
|
|
21
|
+
listeners = new Map;
|
|
22
|
+
send(type, payload, options) {
|
|
23
|
+
if (options?.target) {
|
|
24
|
+
this.sendViaPostMessage(type, payload, options.target, options.origin || "*");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const context = this.getMessagingContext(type);
|
|
28
|
+
if (context.shouldUsePostMessage) {
|
|
29
|
+
this.sendViaPostMessage(type, payload, context.target, context.origin);
|
|
30
|
+
} else {
|
|
31
|
+
this.sendViaCustomEvent(type, payload);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
listen(type, handler) {
|
|
35
|
+
const postMessageListener = (event) => {
|
|
36
|
+
const messageEvent = event;
|
|
37
|
+
if (messageEvent.data?.type === type) {
|
|
38
|
+
handler(messageEvent.data.payload || messageEvent.data);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const customEventListener = (event) => {
|
|
42
|
+
handler(event.detail);
|
|
43
|
+
};
|
|
44
|
+
if (!this.listeners.has(type)) {
|
|
45
|
+
this.listeners.set(type, new Map);
|
|
46
|
+
}
|
|
47
|
+
const listenerMap = this.listeners.get(type);
|
|
48
|
+
listenerMap.set(handler, {
|
|
49
|
+
postMessage: postMessageListener,
|
|
50
|
+
customEvent: customEventListener
|
|
51
|
+
});
|
|
52
|
+
window.addEventListener("message", postMessageListener);
|
|
53
|
+
window.addEventListener(type, customEventListener);
|
|
54
|
+
}
|
|
55
|
+
unlisten(type, handler) {
|
|
56
|
+
const typeListeners = this.listeners.get(type);
|
|
57
|
+
if (!typeListeners || !typeListeners.has(handler)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const listeners = typeListeners.get(handler);
|
|
61
|
+
window.removeEventListener("message", listeners.postMessage);
|
|
62
|
+
window.removeEventListener(type, listeners.customEvent);
|
|
63
|
+
typeListeners.delete(handler);
|
|
64
|
+
if (typeListeners.size === 0) {
|
|
65
|
+
this.listeners.delete(type);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
getMessagingContext(eventType) {
|
|
69
|
+
const isIframe = typeof window !== "undefined" && window.self !== window.top;
|
|
70
|
+
const iframeToParentEvents = [
|
|
71
|
+
"PLAYCADEMY_READY" /* READY */,
|
|
72
|
+
"PLAYCADEMY_EXIT" /* EXIT */,
|
|
73
|
+
"PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
|
|
74
|
+
"PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
|
|
75
|
+
"PLAYCADEMY_DISPLAY_ALERT" /* DISPLAY_ALERT */
|
|
76
|
+
];
|
|
77
|
+
const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
|
|
78
|
+
return {
|
|
79
|
+
shouldUsePostMessage,
|
|
80
|
+
target: shouldUsePostMessage ? window.parent : undefined,
|
|
81
|
+
origin: "*"
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
sendViaPostMessage(type, payload, target = window.parent, origin = "*") {
|
|
85
|
+
const messageData = { type };
|
|
86
|
+
if (payload !== undefined) {
|
|
87
|
+
messageData.payload = payload;
|
|
88
|
+
}
|
|
89
|
+
target.postMessage(messageData, origin);
|
|
90
|
+
}
|
|
91
|
+
sendViaCustomEvent(type, payload) {
|
|
92
|
+
window.dispatchEvent(new CustomEvent(type, { detail: payload }));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
var messaging = new PlaycademyMessaging;
|
|
96
|
+
|
|
97
|
+
// src/core/static/init.ts
|
|
98
|
+
async function getPlaycademyConfig(allowedParentOrigins) {
|
|
99
|
+
const preloaded = window.PLAYCADEMY;
|
|
100
|
+
if (preloaded?.token) {
|
|
101
|
+
return preloaded;
|
|
102
|
+
}
|
|
103
|
+
if (window.self !== window.top) {
|
|
104
|
+
return await waitForPlaycademyInit(allowedParentOrigins);
|
|
105
|
+
} else {
|
|
106
|
+
return createStandaloneConfig();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function getReferrerOrigin() {
|
|
110
|
+
try {
|
|
111
|
+
return document.referrer ? new URL(document.referrer).origin : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function buildAllowedOrigins(explicit) {
|
|
117
|
+
if (Array.isArray(explicit) && explicit.length > 0)
|
|
118
|
+
return explicit;
|
|
119
|
+
const ref = getReferrerOrigin();
|
|
120
|
+
return ref ? [ref] : [];
|
|
121
|
+
}
|
|
122
|
+
function isOriginAllowed(origin, allowlist) {
|
|
123
|
+
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (!allowlist || allowlist.length === 0) {
|
|
127
|
+
console.error("[Playcademy SDK] No allowed origins configured. Consider passing allowedParentOrigins explicitly to init().");
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return allowlist.includes(origin);
|
|
131
|
+
}
|
|
132
|
+
async function waitForPlaycademyInit(allowedParentOrigins) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
let contextReceived = false;
|
|
135
|
+
const timeoutDuration = 5000;
|
|
136
|
+
const allowlist = buildAllowedOrigins(allowedParentOrigins);
|
|
137
|
+
let hasWarnedAboutUntrustedOrigin = false;
|
|
138
|
+
function warnAboutUntrustedOrigin(origin) {
|
|
139
|
+
if (hasWarnedAboutUntrustedOrigin)
|
|
140
|
+
return;
|
|
141
|
+
hasWarnedAboutUntrustedOrigin = true;
|
|
142
|
+
console.warn("[Playcademy SDK] Ignoring INIT from untrusted origin:", origin);
|
|
143
|
+
}
|
|
144
|
+
const handleMessage = (event) => {
|
|
145
|
+
if (event.data?.type !== "PLAYCADEMY_INIT" /* INIT */)
|
|
146
|
+
return;
|
|
147
|
+
if (!isOriginAllowed(event.origin, allowlist)) {
|
|
148
|
+
warnAboutUntrustedOrigin(event.origin);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
contextReceived = true;
|
|
152
|
+
window.removeEventListener("message", handleMessage);
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
window.PLAYCADEMY = event.data.payload;
|
|
155
|
+
resolve(event.data.payload);
|
|
156
|
+
};
|
|
157
|
+
window.addEventListener("message", handleMessage);
|
|
158
|
+
const timeoutId = setTimeout(() => {
|
|
159
|
+
if (!contextReceived) {
|
|
160
|
+
window.removeEventListener("message", handleMessage);
|
|
161
|
+
reject(new Error(`${"PLAYCADEMY_INIT" /* INIT */} not received within ${timeoutDuration}ms`));
|
|
162
|
+
}
|
|
163
|
+
}, timeoutDuration);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function createStandaloneConfig() {
|
|
167
|
+
console.debug("[Playcademy SDK] Standalone mode detected, creating mock context for sandbox development");
|
|
168
|
+
const mockConfig = {
|
|
169
|
+
baseUrl: "http://localhost:4321",
|
|
170
|
+
gameUrl: window.location.origin,
|
|
171
|
+
token: "mock-game-token-for-local-dev",
|
|
172
|
+
gameId: "mock-game-id-from-template",
|
|
173
|
+
realtimeUrl: undefined
|
|
174
|
+
};
|
|
175
|
+
window.PLAYCADEMY = mockConfig;
|
|
176
|
+
return mockConfig;
|
|
177
|
+
}
|
|
178
|
+
async function init(options) {
|
|
179
|
+
if (typeof window === "undefined") {
|
|
180
|
+
throw new Error("Playcademy SDK must run in a browser context");
|
|
181
|
+
}
|
|
182
|
+
const config = await getPlaycademyConfig(options?.allowedParentOrigins);
|
|
183
|
+
if (options?.baseUrl) {
|
|
184
|
+
config.baseUrl = options.baseUrl;
|
|
185
|
+
}
|
|
186
|
+
const client = new this({
|
|
187
|
+
baseUrl: config.baseUrl,
|
|
188
|
+
gameUrl: config.gameUrl,
|
|
189
|
+
token: config.token,
|
|
190
|
+
gameId: config.gameId,
|
|
191
|
+
autoStartSession: window.self !== window.top,
|
|
192
|
+
onDisconnect: options?.onDisconnect,
|
|
193
|
+
enableConnectionMonitoring: options?.enableConnectionMonitoring
|
|
194
|
+
});
|
|
195
|
+
client["initPayload"] = config;
|
|
196
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, ({ token }) => client.setToken(token));
|
|
197
|
+
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
198
|
+
return client;
|
|
199
|
+
}
|
|
1
200
|
// ../logger/src/index.ts
|
|
2
201
|
var isBrowser = () => {
|
|
3
202
|
const g = globalThis;
|
|
@@ -42,6 +241,7 @@ var detectOutputFormat = () => {
|
|
|
42
241
|
};
|
|
43
242
|
var colors = {
|
|
44
243
|
reset: "\x1B[0m",
|
|
244
|
+
bold: "\x1B[1m",
|
|
45
245
|
dim: "\x1B[2m",
|
|
46
246
|
red: "\x1B[31m",
|
|
47
247
|
yellow: "\x1B[33m",
|
|
@@ -63,44 +263,48 @@ var getLevelColor = (level) => {
|
|
|
63
263
|
return colors.reset;
|
|
64
264
|
}
|
|
65
265
|
};
|
|
66
|
-
var formatBrowserOutput = (level, message, context) => {
|
|
266
|
+
var formatBrowserOutput = (level, message, context, scope) => {
|
|
67
267
|
const timestamp = new Date().toISOString();
|
|
68
268
|
const levelUpper = level.toUpperCase();
|
|
69
269
|
const consoleMethod = getConsoleMethod(level);
|
|
270
|
+
const scopePrefix = scope ? `[${scope}] ` : "";
|
|
70
271
|
if (context && Object.keys(context).length > 0) {
|
|
71
|
-
consoleMethod(`[${timestamp}] ${levelUpper}`, message
|
|
272
|
+
consoleMethod(`[${timestamp}] ${levelUpper}`, `${scopePrefix}${message}`, context);
|
|
72
273
|
} else {
|
|
73
|
-
consoleMethod(`[${timestamp}] ${levelUpper}`, message);
|
|
274
|
+
consoleMethod(`[${timestamp}] ${levelUpper}`, `${scopePrefix}${message}`);
|
|
74
275
|
}
|
|
75
276
|
};
|
|
76
|
-
var formatColorTTY = (level, message, context) => {
|
|
277
|
+
var formatColorTTY = (level, message, context, scope) => {
|
|
77
278
|
const timestamp = new Date().toISOString();
|
|
78
279
|
const levelColor = getLevelColor(level);
|
|
79
280
|
const levelUpper = level.toUpperCase().padEnd(5);
|
|
80
281
|
const consoleMethod = getConsoleMethod(level);
|
|
81
282
|
const coloredPrefix = `${colors.dim}[${timestamp}]${colors.reset} ${levelColor}${levelUpper}${colors.reset}`;
|
|
283
|
+
const scopePrefix = scope ? `${colors.bold}[${scope}]${colors.reset} ` : "";
|
|
82
284
|
if (context && Object.keys(context).length > 0) {
|
|
83
|
-
consoleMethod(`${coloredPrefix} ${message}`, context);
|
|
285
|
+
consoleMethod(`${coloredPrefix} ${scopePrefix}${message}`, context);
|
|
84
286
|
} else {
|
|
85
|
-
consoleMethod(`${coloredPrefix} ${message}`);
|
|
287
|
+
consoleMethod(`${coloredPrefix} ${scopePrefix}${message}`);
|
|
86
288
|
}
|
|
87
289
|
};
|
|
88
|
-
var formatJSONSingleLine = (level, message, context) => {
|
|
290
|
+
var formatJSONSingleLine = (level, message, context, scope) => {
|
|
89
291
|
const timestamp = new Date().toISOString();
|
|
90
292
|
const logEntry = {
|
|
91
293
|
timestamp,
|
|
92
294
|
level: level.toUpperCase(),
|
|
295
|
+
...scope && { scope },
|
|
93
296
|
message,
|
|
94
297
|
...context && Object.keys(context).length > 0 && { context }
|
|
95
298
|
};
|
|
96
299
|
const consoleMethod = getConsoleMethod(level);
|
|
97
300
|
consoleMethod(JSON.stringify(logEntry));
|
|
98
301
|
};
|
|
99
|
-
var formatJSONPretty = (level, message, context) => {
|
|
302
|
+
var formatJSONPretty = (level, message, context, scope) => {
|
|
100
303
|
const timestamp = new Date().toISOString();
|
|
101
304
|
const logEntry = {
|
|
102
305
|
timestamp,
|
|
103
306
|
level: level.toUpperCase(),
|
|
307
|
+
...scope && { scope },
|
|
104
308
|
message,
|
|
105
309
|
...context && Object.keys(context).length > 0 && { context }
|
|
106
310
|
};
|
|
@@ -141,119 +345,216 @@ var shouldLog = (level) => {
|
|
|
141
345
|
return levelPriority[level] >= levelPriority[minLevel];
|
|
142
346
|
};
|
|
143
347
|
var customHandler;
|
|
144
|
-
var performLog = (level, message, context) => {
|
|
348
|
+
var performLog = (level, message, context, scope) => {
|
|
145
349
|
if (!shouldLog(level))
|
|
146
350
|
return;
|
|
147
351
|
if (customHandler) {
|
|
148
|
-
customHandler(level, message, context);
|
|
352
|
+
customHandler(level, message, context, scope);
|
|
149
353
|
return;
|
|
150
354
|
}
|
|
151
355
|
const outputFormat = detectOutputFormat();
|
|
152
356
|
switch (outputFormat) {
|
|
153
357
|
case "browser":
|
|
154
|
-
formatBrowserOutput(level, message, context);
|
|
358
|
+
formatBrowserOutput(level, message, context, scope);
|
|
155
359
|
break;
|
|
156
360
|
case "color-tty":
|
|
157
|
-
formatColorTTY(level, message, context);
|
|
361
|
+
formatColorTTY(level, message, context, scope);
|
|
158
362
|
break;
|
|
159
363
|
case "json-single-line":
|
|
160
|
-
formatJSONSingleLine(level, message, context);
|
|
364
|
+
formatJSONSingleLine(level, message, context, scope);
|
|
161
365
|
break;
|
|
162
366
|
case "json-pretty":
|
|
163
|
-
formatJSONPretty(level, message, context);
|
|
367
|
+
formatJSONPretty(level, message, context, scope);
|
|
164
368
|
break;
|
|
165
369
|
}
|
|
166
370
|
};
|
|
167
|
-
var createLogger = () => {
|
|
371
|
+
var createLogger = (scopeName) => {
|
|
168
372
|
return {
|
|
169
|
-
debug: (message, context) => performLog("debug", message, context),
|
|
170
|
-
info: (message, context) => performLog("info", message, context),
|
|
171
|
-
warn: (message, context) => performLog("warn", message, context),
|
|
172
|
-
error: (message, context) => performLog("error", message, context),
|
|
173
|
-
log: performLog
|
|
373
|
+
debug: (message, context) => performLog("debug", message, context, scopeName),
|
|
374
|
+
info: (message, context) => performLog("info", message, context, scopeName),
|
|
375
|
+
warn: (message, context) => performLog("warn", message, context, scopeName),
|
|
376
|
+
error: (message, context) => performLog("error", message, context, scopeName),
|
|
377
|
+
log: (level, message, context) => performLog(level, message, context, scopeName),
|
|
378
|
+
scope: (name) => createLogger(scopeName ? `${scopeName}.${name}` : name)
|
|
174
379
|
};
|
|
175
380
|
};
|
|
176
381
|
var log = createLogger();
|
|
177
382
|
|
|
178
|
-
// src/core/
|
|
179
|
-
class
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
this.
|
|
183
|
-
}
|
|
184
|
-
getToken() {
|
|
185
|
-
return this.apiKey;
|
|
186
|
-
}
|
|
187
|
-
getType() {
|
|
188
|
-
return "apiKey";
|
|
189
|
-
}
|
|
190
|
-
getHeaders() {
|
|
191
|
-
return { "x-api-key": this.apiKey };
|
|
383
|
+
// src/core/errors.ts
|
|
384
|
+
class PlaycademyError extends Error {
|
|
385
|
+
constructor(message) {
|
|
386
|
+
super(message);
|
|
387
|
+
this.name = "PlaycademyError";
|
|
192
388
|
}
|
|
193
389
|
}
|
|
194
390
|
|
|
195
|
-
class
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
391
|
+
class ApiError extends Error {
|
|
392
|
+
status;
|
|
393
|
+
code;
|
|
394
|
+
details;
|
|
395
|
+
rawBody;
|
|
396
|
+
constructor(status, code, message, details, rawBody) {
|
|
397
|
+
super(message);
|
|
398
|
+
this.status = status;
|
|
399
|
+
this.name = "ApiError";
|
|
400
|
+
this.code = code;
|
|
401
|
+
this.details = details;
|
|
402
|
+
this.rawBody = rawBody;
|
|
403
|
+
Object.setPrototypeOf(this, ApiError.prototype);
|
|
205
404
|
}
|
|
206
|
-
|
|
207
|
-
|
|
405
|
+
static fromResponse(status, statusText, body) {
|
|
406
|
+
if (body && typeof body === "object" && "error" in body) {
|
|
407
|
+
const errorBody = body;
|
|
408
|
+
const err = errorBody.error;
|
|
409
|
+
if (err && typeof err === "object") {
|
|
410
|
+
return new ApiError(status, err.code ?? statusCodeToErrorCode(status), err.message ?? statusText, err.details, body);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return new ApiError(status, statusCodeToErrorCode(status), statusText, undefined, body);
|
|
208
414
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
class GameJwtAuth {
|
|
212
|
-
gameToken;
|
|
213
|
-
constructor(gameToken) {
|
|
214
|
-
this.gameToken = gameToken;
|
|
415
|
+
is(code) {
|
|
416
|
+
return this.code === code;
|
|
215
417
|
}
|
|
216
|
-
|
|
217
|
-
return this.
|
|
418
|
+
isClientError() {
|
|
419
|
+
return this.status >= 400 && this.status < 500;
|
|
218
420
|
}
|
|
219
|
-
|
|
220
|
-
return
|
|
421
|
+
isServerError() {
|
|
422
|
+
return this.status >= 500;
|
|
221
423
|
}
|
|
222
|
-
|
|
223
|
-
return
|
|
424
|
+
isRetryable() {
|
|
425
|
+
return this.isServerError() || this.code === "TOO_MANY_REQUESTS" || this.code === "RATE_LIMITED" || this.code === "TIMEOUT";
|
|
224
426
|
}
|
|
225
427
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
428
|
+
function statusCodeToErrorCode(status) {
|
|
429
|
+
switch (status) {
|
|
430
|
+
case 400:
|
|
431
|
+
return "BAD_REQUEST";
|
|
432
|
+
case 401:
|
|
433
|
+
return "UNAUTHORIZED";
|
|
434
|
+
case 403:
|
|
435
|
+
return "FORBIDDEN";
|
|
436
|
+
case 404:
|
|
437
|
+
return "NOT_FOUND";
|
|
438
|
+
case 405:
|
|
439
|
+
return "METHOD_NOT_ALLOWED";
|
|
440
|
+
case 409:
|
|
441
|
+
return "CONFLICT";
|
|
442
|
+
case 410:
|
|
443
|
+
return "GONE";
|
|
444
|
+
case 412:
|
|
445
|
+
return "PRECONDITION_FAILED";
|
|
446
|
+
case 413:
|
|
447
|
+
return "PAYLOAD_TOO_LARGE";
|
|
448
|
+
case 422:
|
|
449
|
+
return "VALIDATION_FAILED";
|
|
450
|
+
case 429:
|
|
451
|
+
return "TOO_MANY_REQUESTS";
|
|
452
|
+
case 500:
|
|
453
|
+
return "INTERNAL_ERROR";
|
|
454
|
+
case 501:
|
|
455
|
+
return "NOT_IMPLEMENTED";
|
|
456
|
+
case 503:
|
|
457
|
+
return "SERVICE_UNAVAILABLE";
|
|
458
|
+
case 504:
|
|
459
|
+
return "TIMEOUT";
|
|
460
|
+
default:
|
|
461
|
+
return status >= 500 ? "INTERNAL_ERROR" : "BAD_REQUEST";
|
|
230
462
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return {};
|
|
463
|
+
}
|
|
464
|
+
function extractApiErrorInfo(error) {
|
|
465
|
+
if (!(error instanceof ApiError)) {
|
|
466
|
+
return null;
|
|
236
467
|
}
|
|
468
|
+
return {
|
|
469
|
+
status: error.status,
|
|
470
|
+
code: error.code,
|
|
471
|
+
message: error.message,
|
|
472
|
+
...error.details !== undefined && { details: error.details }
|
|
473
|
+
};
|
|
237
474
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
475
|
+
|
|
476
|
+
// src/core/static/login.ts
|
|
477
|
+
async function login(baseUrl, email, password) {
|
|
478
|
+
let url = baseUrl;
|
|
479
|
+
if (baseUrl.startsWith("/") && typeof window !== "undefined") {
|
|
480
|
+
url = window.location.origin + baseUrl;
|
|
241
481
|
}
|
|
242
|
-
|
|
243
|
-
|
|
482
|
+
url = url + "/auth/login";
|
|
483
|
+
const response = await fetch(url, {
|
|
484
|
+
method: "POST",
|
|
485
|
+
headers: {
|
|
486
|
+
"Content-Type": "application/json"
|
|
487
|
+
},
|
|
488
|
+
body: JSON.stringify({ email, password })
|
|
489
|
+
});
|
|
490
|
+
if (!response.ok) {
|
|
491
|
+
try {
|
|
492
|
+
const errorData = await response.json();
|
|
493
|
+
const errorMessage = errorData && errorData.message ? String(errorData.message) : response.statusText;
|
|
494
|
+
throw new PlaycademyError(errorMessage);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
log.error("[Playcademy SDK] Failed to parse error response JSON, using status text instead:", { error });
|
|
497
|
+
throw new PlaycademyError(response.statusText);
|
|
498
|
+
}
|
|
244
499
|
}
|
|
245
|
-
|
|
246
|
-
|
|
500
|
+
return response.json();
|
|
501
|
+
}
|
|
502
|
+
// ../utils/src/random.ts
|
|
503
|
+
async function generateSecureRandomString(length) {
|
|
504
|
+
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
505
|
+
const randomValues = new Uint8Array(length);
|
|
506
|
+
globalThis.crypto.getRandomValues(randomValues);
|
|
507
|
+
return Array.from(randomValues).map((byte) => charset[byte % charset.length]).join("");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/core/auth/oauth.ts
|
|
511
|
+
function getTimebackConfig() {
|
|
512
|
+
return {
|
|
513
|
+
authorizationEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/authorize",
|
|
514
|
+
tokenEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/token",
|
|
515
|
+
scope: "openid email phone"
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
var OAUTH_CONFIGS = {
|
|
519
|
+
get TIMEBACK() {
|
|
520
|
+
return getTimebackConfig;
|
|
247
521
|
}
|
|
248
|
-
|
|
249
|
-
|
|
522
|
+
};
|
|
523
|
+
async function generateOAuthState(data) {
|
|
524
|
+
const csrfToken = await generateSecureRandomString(32);
|
|
525
|
+
if (data && Object.keys(data).length > 0) {
|
|
526
|
+
const jsonStr = JSON.stringify(data);
|
|
527
|
+
const base64 = btoa(jsonStr).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
528
|
+
return `${csrfToken}.${base64}`;
|
|
250
529
|
}
|
|
251
|
-
|
|
252
|
-
|
|
530
|
+
return csrfToken;
|
|
531
|
+
}
|
|
532
|
+
function parseOAuthState(state) {
|
|
533
|
+
const lastDotIndex = state.lastIndexOf(".");
|
|
534
|
+
if (lastDotIndex > 0 && lastDotIndex < state.length - 1) {
|
|
535
|
+
try {
|
|
536
|
+
const csrfToken = state.substring(0, lastDotIndex);
|
|
537
|
+
const base64 = state.substring(lastDotIndex + 1);
|
|
538
|
+
const base64WithPadding = base64.replace(/-/g, "+").replace(/_/g, "/");
|
|
539
|
+
const paddedBase64 = base64WithPadding + "=".repeat((4 - base64WithPadding.length % 4) % 4);
|
|
540
|
+
const jsonStr = atob(paddedBase64);
|
|
541
|
+
const data = JSON.parse(jsonStr);
|
|
542
|
+
return { csrfToken, data };
|
|
543
|
+
} catch {}
|
|
253
544
|
}
|
|
254
|
-
return
|
|
545
|
+
return { csrfToken: state };
|
|
546
|
+
}
|
|
547
|
+
function getOAuthConfig(provider) {
|
|
548
|
+
const configGetter = OAUTH_CONFIGS[provider];
|
|
549
|
+
if (!configGetter)
|
|
550
|
+
throw new Error(`Unsupported auth provider: ${provider}`);
|
|
551
|
+
return configGetter();
|
|
255
552
|
}
|
|
256
553
|
|
|
554
|
+
// src/core/static/identity.ts
|
|
555
|
+
var identity = {
|
|
556
|
+
parseOAuthState
|
|
557
|
+
};
|
|
257
558
|
// src/core/auth/utils.ts
|
|
258
559
|
function openPopupWindow(url, name = "auth-popup", width = 500, height = 600) {
|
|
259
560
|
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
@@ -283,845 +584,208 @@ function isInIframe() {
|
|
|
283
584
|
}
|
|
284
585
|
}
|
|
285
586
|
|
|
286
|
-
// src/core/
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
heartbeatTimeout: config.heartbeatTimeout ?? 5000,
|
|
299
|
-
failureThreshold: config.failureThreshold ?? 2,
|
|
300
|
-
enableHeartbeat: config.enableHeartbeat ?? true,
|
|
301
|
-
enableOfflineEvents: config.enableOfflineEvents ?? true
|
|
302
|
-
};
|
|
303
|
-
this._detectInitialState();
|
|
304
|
-
}
|
|
305
|
-
start() {
|
|
306
|
-
if (this.isMonitoring)
|
|
307
|
-
return;
|
|
308
|
-
this.isMonitoring = true;
|
|
309
|
-
if (this.config.enableOfflineEvents && typeof window !== "undefined") {
|
|
310
|
-
window.addEventListener("online", this._handleOnline);
|
|
311
|
-
window.addEventListener("offline", this._handleOffline);
|
|
312
|
-
}
|
|
313
|
-
if (this.config.enableHeartbeat) {
|
|
314
|
-
this._startHeartbeat();
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
stop() {
|
|
318
|
-
if (!this.isMonitoring)
|
|
319
|
-
return;
|
|
320
|
-
this.isMonitoring = false;
|
|
321
|
-
if (typeof window !== "undefined") {
|
|
322
|
-
window.removeEventListener("online", this._handleOnline);
|
|
323
|
-
window.removeEventListener("offline", this._handleOffline);
|
|
587
|
+
// src/core/auth/flows/popup.ts
|
|
588
|
+
async function initiatePopupFlow(options) {
|
|
589
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
590
|
+
try {
|
|
591
|
+
onStateChange?.({
|
|
592
|
+
status: "opening_popup",
|
|
593
|
+
message: "Opening authentication window..."
|
|
594
|
+
});
|
|
595
|
+
const defaults = getOAuthConfig(provider);
|
|
596
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
597
|
+
if (!config.clientId) {
|
|
598
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
324
599
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
600
|
+
const stateData = options.stateData;
|
|
601
|
+
const state = await generateOAuthState(stateData);
|
|
602
|
+
const params = new URLSearchParams({
|
|
603
|
+
response_type: "code",
|
|
604
|
+
client_id: config.clientId,
|
|
605
|
+
redirect_uri: callbackUrl,
|
|
606
|
+
state
|
|
607
|
+
});
|
|
608
|
+
if (config.scope) {
|
|
609
|
+
params.set("scope", config.scope);
|
|
328
610
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
getState() {
|
|
335
|
-
return this.state;
|
|
336
|
-
}
|
|
337
|
-
async checkNow() {
|
|
338
|
-
await this._performHeartbeat();
|
|
339
|
-
return this.state;
|
|
340
|
-
}
|
|
341
|
-
reportRequestFailure(error) {
|
|
342
|
-
const isNetworkError = error instanceof TypeError || error instanceof Error && error.message.includes("fetch");
|
|
343
|
-
if (!isNetworkError)
|
|
344
|
-
return;
|
|
345
|
-
this.consecutiveFailures++;
|
|
346
|
-
if (this.consecutiveFailures >= this.config.failureThreshold) {
|
|
347
|
-
this._setState("degraded", "Multiple consecutive request failures");
|
|
611
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
612
|
+
const popup = openPopupWindow(authUrl, "playcademy-auth");
|
|
613
|
+
if (!popup || popup.closed) {
|
|
614
|
+
throw new Error("Popup blocked. Please enable popups and try again.");
|
|
348
615
|
}
|
|
616
|
+
onStateChange?.({
|
|
617
|
+
status: "exchanging_token",
|
|
618
|
+
message: "Waiting for authentication..."
|
|
619
|
+
});
|
|
620
|
+
return await waitForServerMessage(popup, onStateChange);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
|
623
|
+
onStateChange?.({
|
|
624
|
+
status: "error",
|
|
625
|
+
message: errorMessage,
|
|
626
|
+
error: error instanceof Error ? error : new Error(errorMessage)
|
|
627
|
+
});
|
|
628
|
+
throw error;
|
|
349
629
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
630
|
+
}
|
|
631
|
+
async function waitForServerMessage(popup, onStateChange) {
|
|
632
|
+
return new Promise((resolve) => {
|
|
633
|
+
let resolved = false;
|
|
634
|
+
const handleMessage = (event) => {
|
|
635
|
+
if (event.origin !== window.location.origin)
|
|
636
|
+
return;
|
|
637
|
+
const data = event.data;
|
|
638
|
+
if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
|
|
639
|
+
resolved = true;
|
|
640
|
+
window.removeEventListener("message", handleMessage);
|
|
641
|
+
if (data.authenticated && data.user) {
|
|
642
|
+
onStateChange?.({
|
|
643
|
+
status: "complete",
|
|
644
|
+
message: "Authentication successful"
|
|
645
|
+
});
|
|
646
|
+
resolve({
|
|
647
|
+
success: true,
|
|
648
|
+
user: data.user
|
|
649
|
+
});
|
|
650
|
+
} else {
|
|
651
|
+
const error = new Error(data.error || "Authentication failed");
|
|
652
|
+
onStateChange?.({
|
|
653
|
+
status: "error",
|
|
654
|
+
message: error.message,
|
|
655
|
+
error
|
|
656
|
+
});
|
|
657
|
+
resolve({
|
|
658
|
+
success: false,
|
|
659
|
+
error
|
|
660
|
+
});
|
|
661
|
+
}
|
|
355
662
|
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
this.heartbeatInterval = setInterval(() => {
|
|
373
|
-
this._performHeartbeat();
|
|
374
|
-
}, this.config.heartbeatInterval);
|
|
375
|
-
}
|
|
376
|
-
async _performHeartbeat() {
|
|
377
|
-
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
try {
|
|
381
|
-
const controller = new AbortController;
|
|
382
|
-
const timeoutId = setTimeout(() => controller.abort(), this.config.heartbeatTimeout);
|
|
383
|
-
const response = await fetch(`${this.config.baseUrl}/ping`, {
|
|
384
|
-
method: "GET",
|
|
385
|
-
signal: controller.signal,
|
|
386
|
-
cache: "no-store"
|
|
387
|
-
});
|
|
388
|
-
clearTimeout(timeoutId);
|
|
389
|
-
if (response.ok) {
|
|
390
|
-
this.consecutiveFailures = 0;
|
|
391
|
-
if (this.state !== "online") {
|
|
392
|
-
this._setState("online", "Heartbeat successful");
|
|
393
|
-
}
|
|
394
|
-
} else {
|
|
395
|
-
this._handleHeartbeatFailure("Heartbeat returned non-OK status");
|
|
663
|
+
};
|
|
664
|
+
window.addEventListener("message", handleMessage);
|
|
665
|
+
const checkClosed = setInterval(() => {
|
|
666
|
+
if (popup.closed && !resolved) {
|
|
667
|
+
clearInterval(checkClosed);
|
|
668
|
+
window.removeEventListener("message", handleMessage);
|
|
669
|
+
const error = new Error("Authentication cancelled");
|
|
670
|
+
onStateChange?.({
|
|
671
|
+
status: "error",
|
|
672
|
+
message: error.message,
|
|
673
|
+
error
|
|
674
|
+
});
|
|
675
|
+
resolve({
|
|
676
|
+
success: false,
|
|
677
|
+
error
|
|
678
|
+
});
|
|
396
679
|
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
680
|
+
}, 500);
|
|
681
|
+
setTimeout(() => {
|
|
682
|
+
if (!resolved) {
|
|
683
|
+
window.removeEventListener("message", handleMessage);
|
|
684
|
+
clearInterval(checkClosed);
|
|
685
|
+
const error = new Error("Authentication timeout");
|
|
686
|
+
onStateChange?.({
|
|
687
|
+
status: "error",
|
|
688
|
+
message: error.message,
|
|
689
|
+
error
|
|
690
|
+
});
|
|
691
|
+
resolve({
|
|
692
|
+
success: false,
|
|
693
|
+
error
|
|
694
|
+
});
|
|
408
695
|
}
|
|
696
|
+
}, 5 * 60 * 1000);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/core/auth/flows/redirect.ts
|
|
701
|
+
async function initiateRedirectFlow(options) {
|
|
702
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
703
|
+
try {
|
|
704
|
+
onStateChange?.({
|
|
705
|
+
status: "opening_popup",
|
|
706
|
+
message: "Redirecting to authentication provider..."
|
|
707
|
+
});
|
|
708
|
+
const defaults = getOAuthConfig(provider);
|
|
709
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
710
|
+
if (!config.clientId) {
|
|
711
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
409
712
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
713
|
+
const stateData = options.stateData;
|
|
714
|
+
const state = await generateOAuthState(stateData);
|
|
715
|
+
const params = new URLSearchParams({
|
|
716
|
+
response_type: "code",
|
|
717
|
+
client_id: config.clientId,
|
|
718
|
+
redirect_uri: callbackUrl,
|
|
719
|
+
state
|
|
720
|
+
});
|
|
721
|
+
if (config.scope) {
|
|
722
|
+
params.set("scope", config.scope);
|
|
723
|
+
}
|
|
724
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
725
|
+
window.location.href = authUrl;
|
|
726
|
+
return new Promise(() => {});
|
|
727
|
+
} catch (error) {
|
|
728
|
+
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
|
729
|
+
onStateChange?.({
|
|
730
|
+
status: "error",
|
|
731
|
+
message: errorMessage,
|
|
732
|
+
error: error instanceof Error ? error : new Error(errorMessage)
|
|
423
733
|
});
|
|
734
|
+
throw error;
|
|
424
735
|
}
|
|
425
736
|
}
|
|
426
|
-
// src/messaging.ts
|
|
427
|
-
var MessageEvents;
|
|
428
|
-
((MessageEvents2) => {
|
|
429
|
-
MessageEvents2["INIT"] = "PLAYCADEMY_INIT";
|
|
430
|
-
MessageEvents2["TOKEN_REFRESH"] = "PLAYCADEMY_TOKEN_REFRESH";
|
|
431
|
-
MessageEvents2["PAUSE"] = "PLAYCADEMY_PAUSE";
|
|
432
|
-
MessageEvents2["RESUME"] = "PLAYCADEMY_RESUME";
|
|
433
|
-
MessageEvents2["FORCE_EXIT"] = "PLAYCADEMY_FORCE_EXIT";
|
|
434
|
-
MessageEvents2["OVERLAY"] = "PLAYCADEMY_OVERLAY";
|
|
435
|
-
MessageEvents2["CONNECTION_STATE"] = "PLAYCADEMY_CONNECTION_STATE";
|
|
436
|
-
MessageEvents2["READY"] = "PLAYCADEMY_READY";
|
|
437
|
-
MessageEvents2["EXIT"] = "PLAYCADEMY_EXIT";
|
|
438
|
-
MessageEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
|
|
439
|
-
MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
|
|
440
|
-
MessageEvents2["DISPLAY_ALERT"] = "PLAYCADEMY_DISPLAY_ALERT";
|
|
441
|
-
MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
|
|
442
|
-
MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
|
|
443
|
-
})(MessageEvents ||= {});
|
|
444
737
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
this.sendViaCustomEvent(type, payload);
|
|
457
|
-
}
|
|
738
|
+
// src/core/auth/flows/unified.ts
|
|
739
|
+
async function initiateUnifiedFlow(options) {
|
|
740
|
+
const { mode = "auto" } = options;
|
|
741
|
+
const effectiveMode = mode === "auto" ? isInIframe() ? "popup" : "redirect" : mode;
|
|
742
|
+
switch (effectiveMode) {
|
|
743
|
+
case "popup":
|
|
744
|
+
return initiatePopupFlow(options);
|
|
745
|
+
case "redirect":
|
|
746
|
+
return initiateRedirectFlow(options);
|
|
747
|
+
default:
|
|
748
|
+
throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
|
|
458
749
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/core/auth/login.ts
|
|
753
|
+
async function login2(client, options) {
|
|
754
|
+
try {
|
|
755
|
+
let stateData = options.stateData;
|
|
756
|
+
if (!stateData) {
|
|
757
|
+
try {
|
|
758
|
+
const currentUser = await client.users.me();
|
|
759
|
+
if (currentUser?.id) {
|
|
760
|
+
stateData = { playcademy_user_id: currentUser.id };
|
|
761
|
+
}
|
|
762
|
+
} catch {
|
|
763
|
+
log.debug("[Playcademy SDK] No current user available for state data");
|
|
464
764
|
}
|
|
465
|
-
};
|
|
466
|
-
const customEventListener = (event) => {
|
|
467
|
-
handler(event.detail);
|
|
468
|
-
};
|
|
469
|
-
if (!this.listeners.has(type)) {
|
|
470
|
-
this.listeners.set(type, new Map);
|
|
471
765
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
766
|
+
log.debug("[Playcademy SDK] Starting OAuth login", {
|
|
767
|
+
provider: options.provider,
|
|
768
|
+
mode: options.mode || "auto",
|
|
769
|
+
callbackUrl: options.callbackUrl,
|
|
770
|
+
hasStateData: !!stateData
|
|
476
771
|
});
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
window.removeEventListener("message", listeners.postMessage);
|
|
487
|
-
window.removeEventListener(type, listeners.customEvent);
|
|
488
|
-
typeListeners.delete(handler);
|
|
489
|
-
if (typeListeners.size === 0) {
|
|
490
|
-
this.listeners.delete(type);
|
|
772
|
+
const optionsWithState = {
|
|
773
|
+
...options,
|
|
774
|
+
stateData
|
|
775
|
+
};
|
|
776
|
+
const result = await initiateUnifiedFlow(optionsWithState);
|
|
777
|
+
if (result.success && result.user) {
|
|
778
|
+
log.debug("[Playcademy SDK] OAuth login successful", {
|
|
779
|
+
userId: result.user.sub
|
|
780
|
+
});
|
|
491
781
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
"PLAYCADEMY_READY" /* READY */,
|
|
497
|
-
"PLAYCADEMY_EXIT" /* EXIT */,
|
|
498
|
-
"PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
|
|
499
|
-
"PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
|
|
500
|
-
"PLAYCADEMY_DISPLAY_ALERT" /* DISPLAY_ALERT */
|
|
501
|
-
];
|
|
502
|
-
const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
|
|
782
|
+
return result;
|
|
783
|
+
} catch (error) {
|
|
784
|
+
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
785
|
+
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
503
786
|
return {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
origin: "*"
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
sendViaPostMessage(type, payload, target = window.parent, origin = "*") {
|
|
510
|
-
const messageData = { type };
|
|
511
|
-
if (payload !== undefined) {
|
|
512
|
-
messageData.payload = payload;
|
|
513
|
-
}
|
|
514
|
-
target.postMessage(messageData, origin);
|
|
515
|
-
}
|
|
516
|
-
sendViaCustomEvent(type, payload) {
|
|
517
|
-
window.dispatchEvent(new CustomEvent(type, { detail: payload }));
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
var messaging = new PlaycademyMessaging;
|
|
521
|
-
|
|
522
|
-
// src/core/connection/utils.ts
|
|
523
|
-
function createDisplayAlert(authContext) {
|
|
524
|
-
return (message, options) => {
|
|
525
|
-
if (authContext?.isInIframe && typeof window !== "undefined" && window.parent !== window) {
|
|
526
|
-
window.parent.postMessage({
|
|
527
|
-
type: "PLAYCADEMY_DISPLAY_ALERT",
|
|
528
|
-
message,
|
|
529
|
-
options
|
|
530
|
-
}, "*");
|
|
531
|
-
} else {
|
|
532
|
-
const prefix = options?.type === "error" ? "❌" : options?.type === "warning" ? "⚠️" : "ℹ️";
|
|
533
|
-
console.log(`${prefix} ${message}`);
|
|
534
|
-
}
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// src/core/connection/manager.ts
|
|
539
|
-
class ConnectionManager {
|
|
540
|
-
monitor;
|
|
541
|
-
authContext;
|
|
542
|
-
disconnectHandler;
|
|
543
|
-
connectionChangeCallback;
|
|
544
|
-
currentState = "online";
|
|
545
|
-
additionalDisconnectHandlers = new Set;
|
|
546
|
-
constructor(config) {
|
|
547
|
-
this.authContext = config.authContext;
|
|
548
|
-
this.disconnectHandler = config.onDisconnect;
|
|
549
|
-
this.connectionChangeCallback = config.onConnectionChange;
|
|
550
|
-
if (config.authContext?.isInIframe) {
|
|
551
|
-
this._setupPlatformListener();
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
getState() {
|
|
555
|
-
return this.monitor?.getState() ?? this.currentState;
|
|
556
|
-
}
|
|
557
|
-
async checkNow() {
|
|
558
|
-
if (!this.monitor) {
|
|
559
|
-
return this.currentState;
|
|
560
|
-
}
|
|
561
|
-
return await this.monitor.checkNow();
|
|
562
|
-
}
|
|
563
|
-
reportRequestSuccess() {
|
|
564
|
-
this.monitor?.reportRequestSuccess();
|
|
565
|
-
}
|
|
566
|
-
reportRequestFailure(error) {
|
|
567
|
-
this.monitor?.reportRequestFailure(error);
|
|
568
|
-
}
|
|
569
|
-
onDisconnect(callback) {
|
|
570
|
-
this.additionalDisconnectHandlers.add(callback);
|
|
571
|
-
return () => {
|
|
572
|
-
this.additionalDisconnectHandlers.delete(callback);
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
stop() {
|
|
576
|
-
this.monitor?.stop();
|
|
577
|
-
}
|
|
578
|
-
_setupPlatformListener() {
|
|
579
|
-
messaging.listen("PLAYCADEMY_CONNECTION_STATE" /* CONNECTION_STATE */, ({ state, reason }) => {
|
|
580
|
-
this.currentState = state;
|
|
581
|
-
this._handleConnectionChange(state, reason);
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
_handleConnectionChange(state, reason) {
|
|
585
|
-
this.connectionChangeCallback?.(state, reason);
|
|
586
|
-
if (state === "offline" || state === "degraded") {
|
|
587
|
-
const context = {
|
|
588
|
-
state,
|
|
589
|
-
reason,
|
|
590
|
-
timestamp: Date.now(),
|
|
591
|
-
displayAlert: createDisplayAlert(this.authContext)
|
|
592
|
-
};
|
|
593
|
-
if (this.disconnectHandler) {
|
|
594
|
-
this.disconnectHandler(context);
|
|
595
|
-
}
|
|
596
|
-
this.additionalDisconnectHandlers.forEach((handler) => {
|
|
597
|
-
handler(context);
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
// src/core/errors.ts
|
|
603
|
-
class PlaycademyError extends Error {
|
|
604
|
-
constructor(message) {
|
|
605
|
-
super(message);
|
|
606
|
-
this.name = "PlaycademyError";
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
class ApiError extends Error {
|
|
611
|
-
status;
|
|
612
|
-
details;
|
|
613
|
-
constructor(status, message, details) {
|
|
614
|
-
super(`${status} ${message}`);
|
|
615
|
-
this.status = status;
|
|
616
|
-
this.details = details;
|
|
617
|
-
Object.setPrototypeOf(this, ApiError.prototype);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
function extractApiErrorInfo(error) {
|
|
621
|
-
if (!(error instanceof ApiError)) {
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
624
|
-
const info = {
|
|
625
|
-
status: error.status,
|
|
626
|
-
statusText: error.message
|
|
627
|
-
};
|
|
628
|
-
if (error.details) {
|
|
629
|
-
info.details = error.details;
|
|
630
|
-
}
|
|
631
|
-
return info;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// src/core/request.ts
|
|
635
|
-
function checkDevWarnings(data) {
|
|
636
|
-
if (!data || typeof data !== "object")
|
|
637
|
-
return;
|
|
638
|
-
const response = data;
|
|
639
|
-
const warningType = response.__playcademyDevWarning;
|
|
640
|
-
if (!warningType)
|
|
641
|
-
return;
|
|
642
|
-
switch (warningType) {
|
|
643
|
-
case "timeback-not-configured":
|
|
644
|
-
console.warn("%c⚠️ TimeBack Not Configured", "background: #f59e0b; color: white; padding: 6px 12px; border-radius: 4px; font-weight: bold; font-size: 13px");
|
|
645
|
-
console.log("%cTimeBack is configured in playcademy.config.js but the sandbox does not have TimeBack credentials.", "color: #f59e0b; font-weight: 500");
|
|
646
|
-
console.log("To test TimeBack locally:");
|
|
647
|
-
console.log(" Set the following environment variables:");
|
|
648
|
-
console.log(" • %cTIMEBACK_ONEROSTER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
649
|
-
console.log(" • %cTIMEBACK_CALIPER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
650
|
-
console.log(" • %cTIMEBACK_API_CLIENT_ID/SECRET", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
651
|
-
console.log(" Or deploy your game: %cplaycademy deploy", "color: #10b981; font-weight: 600; font-family: monospace");
|
|
652
|
-
console.log(" Or wait for %c@superbuilders/timeback-local%c (coming soon)", "color: #8b5cf6; font-weight: 600; font-family: monospace", "color: inherit");
|
|
653
|
-
break;
|
|
654
|
-
default:
|
|
655
|
-
console.warn(`[Playcademy Dev Warning] ${warningType}`);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
function prepareRequestBody(body, headers) {
|
|
659
|
-
if (body instanceof FormData) {
|
|
660
|
-
return body;
|
|
661
|
-
}
|
|
662
|
-
if (body instanceof ArrayBuffer || body instanceof Blob || ArrayBuffer.isView(body)) {
|
|
663
|
-
if (!headers["Content-Type"]) {
|
|
664
|
-
headers["Content-Type"] = "application/octet-stream";
|
|
665
|
-
}
|
|
666
|
-
return body;
|
|
667
|
-
}
|
|
668
|
-
if (body !== undefined && body !== null) {
|
|
669
|
-
headers["Content-Type"] = "application/json";
|
|
670
|
-
return JSON.stringify(body);
|
|
671
|
-
}
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
async function request({
|
|
675
|
-
path,
|
|
676
|
-
baseUrl,
|
|
677
|
-
method = "GET",
|
|
678
|
-
body,
|
|
679
|
-
extraHeaders = {},
|
|
680
|
-
raw = false
|
|
681
|
-
}) {
|
|
682
|
-
const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
|
|
683
|
-
const headers = { ...extraHeaders };
|
|
684
|
-
const payload = prepareRequestBody(body, headers);
|
|
685
|
-
const res = await fetch(url, {
|
|
686
|
-
method,
|
|
687
|
-
headers,
|
|
688
|
-
body: payload,
|
|
689
|
-
credentials: "omit"
|
|
690
|
-
});
|
|
691
|
-
if (raw) {
|
|
692
|
-
return res;
|
|
693
|
-
}
|
|
694
|
-
if (!res.ok) {
|
|
695
|
-
const clonedRes = res.clone();
|
|
696
|
-
const errorBody = await clonedRes.json().catch(() => clonedRes.text().catch(() => {
|
|
697
|
-
return;
|
|
698
|
-
})) ?? undefined;
|
|
699
|
-
throw new ApiError(res.status, res.statusText, errorBody);
|
|
700
|
-
}
|
|
701
|
-
if (res.status === 204)
|
|
702
|
-
return;
|
|
703
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
704
|
-
if (contentType.includes("application/json")) {
|
|
705
|
-
try {
|
|
706
|
-
const parsed = await res.json();
|
|
707
|
-
checkDevWarnings(parsed);
|
|
708
|
-
return parsed;
|
|
709
|
-
} catch (err) {
|
|
710
|
-
if (err instanceof SyntaxError)
|
|
711
|
-
return;
|
|
712
|
-
throw err;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
const rawText = await res.text().catch(() => "");
|
|
716
|
-
return rawText && rawText.length > 0 ? rawText : undefined;
|
|
717
|
-
}
|
|
718
|
-
async function fetchManifest(deploymentUrl) {
|
|
719
|
-
const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
|
|
720
|
-
try {
|
|
721
|
-
const response = await fetch(manifestUrl);
|
|
722
|
-
if (!response.ok) {
|
|
723
|
-
log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
|
|
724
|
-
throw new PlaycademyError(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
|
|
725
|
-
}
|
|
726
|
-
return await response.json();
|
|
727
|
-
} catch (error) {
|
|
728
|
-
if (error instanceof PlaycademyError) {
|
|
729
|
-
throw error;
|
|
730
|
-
}
|
|
731
|
-
log.error(`[Playcademy SDK] Error fetching or parsing manifest from ${manifestUrl}:`, {
|
|
732
|
-
error
|
|
733
|
-
});
|
|
734
|
-
throw new PlaycademyError("Failed to load or parse game manifest");
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// src/core/static/init.ts
|
|
739
|
-
async function getPlaycademyConfig(allowedParentOrigins) {
|
|
740
|
-
const preloaded = window.PLAYCADEMY;
|
|
741
|
-
if (preloaded?.token) {
|
|
742
|
-
return preloaded;
|
|
743
|
-
}
|
|
744
|
-
if (window.self !== window.top) {
|
|
745
|
-
return await waitForPlaycademyInit(allowedParentOrigins);
|
|
746
|
-
} else {
|
|
747
|
-
return createStandaloneConfig();
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
function getReferrerOrigin() {
|
|
751
|
-
try {
|
|
752
|
-
return document.referrer ? new URL(document.referrer).origin : null;
|
|
753
|
-
} catch {
|
|
754
|
-
return null;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
function buildAllowedOrigins(explicit) {
|
|
758
|
-
if (Array.isArray(explicit) && explicit.length > 0)
|
|
759
|
-
return explicit;
|
|
760
|
-
const ref = getReferrerOrigin();
|
|
761
|
-
return ref ? [ref] : [];
|
|
762
|
-
}
|
|
763
|
-
function isOriginAllowed(origin, allowlist) {
|
|
764
|
-
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
|
|
765
|
-
return true;
|
|
766
|
-
}
|
|
767
|
-
if (!allowlist || allowlist.length === 0) {
|
|
768
|
-
console.error("[Playcademy SDK] No allowed origins configured. Consider passing allowedParentOrigins explicitly to init().");
|
|
769
|
-
return false;
|
|
770
|
-
}
|
|
771
|
-
return allowlist.includes(origin);
|
|
772
|
-
}
|
|
773
|
-
async function waitForPlaycademyInit(allowedParentOrigins) {
|
|
774
|
-
return new Promise((resolve, reject) => {
|
|
775
|
-
let contextReceived = false;
|
|
776
|
-
const timeoutDuration = 5000;
|
|
777
|
-
const allowlist = buildAllowedOrigins(allowedParentOrigins);
|
|
778
|
-
let hasWarnedAboutUntrustedOrigin = false;
|
|
779
|
-
function warnAboutUntrustedOrigin(origin) {
|
|
780
|
-
if (hasWarnedAboutUntrustedOrigin)
|
|
781
|
-
return;
|
|
782
|
-
hasWarnedAboutUntrustedOrigin = true;
|
|
783
|
-
console.warn("[Playcademy SDK] Ignoring INIT from untrusted origin:", origin);
|
|
784
|
-
}
|
|
785
|
-
const handleMessage = (event) => {
|
|
786
|
-
if (event.data?.type !== "PLAYCADEMY_INIT" /* INIT */)
|
|
787
|
-
return;
|
|
788
|
-
if (!isOriginAllowed(event.origin, allowlist)) {
|
|
789
|
-
warnAboutUntrustedOrigin(event.origin);
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
contextReceived = true;
|
|
793
|
-
window.removeEventListener("message", handleMessage);
|
|
794
|
-
clearTimeout(timeoutId);
|
|
795
|
-
window.PLAYCADEMY = event.data.payload;
|
|
796
|
-
resolve(event.data.payload);
|
|
797
|
-
};
|
|
798
|
-
window.addEventListener("message", handleMessage);
|
|
799
|
-
const timeoutId = setTimeout(() => {
|
|
800
|
-
if (!contextReceived) {
|
|
801
|
-
window.removeEventListener("message", handleMessage);
|
|
802
|
-
reject(new Error(`${"PLAYCADEMY_INIT" /* INIT */} not received within ${timeoutDuration}ms`));
|
|
803
|
-
}
|
|
804
|
-
}, timeoutDuration);
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
function createStandaloneConfig() {
|
|
808
|
-
console.debug("[Playcademy SDK] Standalone mode detected, creating mock context for sandbox development");
|
|
809
|
-
const mockConfig = {
|
|
810
|
-
baseUrl: "http://localhost:4321",
|
|
811
|
-
gameUrl: window.location.origin,
|
|
812
|
-
token: "mock-game-token-for-local-dev",
|
|
813
|
-
gameId: "mock-game-id-from-template",
|
|
814
|
-
realtimeUrl: undefined
|
|
815
|
-
};
|
|
816
|
-
window.PLAYCADEMY = mockConfig;
|
|
817
|
-
return mockConfig;
|
|
818
|
-
}
|
|
819
|
-
async function init(options) {
|
|
820
|
-
if (typeof window === "undefined") {
|
|
821
|
-
throw new Error("Playcademy SDK must run in a browser context");
|
|
822
|
-
}
|
|
823
|
-
const config = await getPlaycademyConfig(options?.allowedParentOrigins);
|
|
824
|
-
if (options?.baseUrl) {
|
|
825
|
-
config.baseUrl = options.baseUrl;
|
|
826
|
-
}
|
|
827
|
-
const client = new this({
|
|
828
|
-
baseUrl: config.baseUrl,
|
|
829
|
-
gameUrl: config.gameUrl,
|
|
830
|
-
token: config.token,
|
|
831
|
-
gameId: config.gameId,
|
|
832
|
-
autoStartSession: window.self !== window.top,
|
|
833
|
-
onDisconnect: options?.onDisconnect,
|
|
834
|
-
enableConnectionMonitoring: options?.enableConnectionMonitoring
|
|
835
|
-
});
|
|
836
|
-
client["initPayload"] = config;
|
|
837
|
-
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, ({ token }) => client.setToken(token));
|
|
838
|
-
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
839
|
-
return client;
|
|
840
|
-
}
|
|
841
|
-
// src/core/static/login.ts
|
|
842
|
-
async function login(baseUrl, email, password) {
|
|
843
|
-
let url = baseUrl;
|
|
844
|
-
if (baseUrl.startsWith("/") && typeof window !== "undefined") {
|
|
845
|
-
url = window.location.origin + baseUrl;
|
|
846
|
-
}
|
|
847
|
-
url = url + "/auth/login";
|
|
848
|
-
const response = await fetch(url, {
|
|
849
|
-
method: "POST",
|
|
850
|
-
headers: {
|
|
851
|
-
"Content-Type": "application/json"
|
|
852
|
-
},
|
|
853
|
-
body: JSON.stringify({ email, password })
|
|
854
|
-
});
|
|
855
|
-
if (!response.ok) {
|
|
856
|
-
try {
|
|
857
|
-
const errorData = await response.json();
|
|
858
|
-
const errorMessage = errorData && errorData.message ? String(errorData.message) : response.statusText;
|
|
859
|
-
throw new PlaycademyError(errorMessage);
|
|
860
|
-
} catch (error) {
|
|
861
|
-
log.error("[Playcademy SDK] Failed to parse error response JSON, using status text instead:", { error });
|
|
862
|
-
throw new PlaycademyError(response.statusText);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
return response.json();
|
|
866
|
-
}
|
|
867
|
-
// ../utils/src/random.ts
|
|
868
|
-
async function generateSecureRandomString(length) {
|
|
869
|
-
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
870
|
-
const randomValues = new Uint8Array(length);
|
|
871
|
-
globalThis.crypto.getRandomValues(randomValues);
|
|
872
|
-
return Array.from(randomValues).map((byte) => charset[byte % charset.length]).join("");
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// src/core/auth/oauth.ts
|
|
876
|
-
function getTimebackConfig() {
|
|
877
|
-
return {
|
|
878
|
-
authorizationEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/authorize",
|
|
879
|
-
tokenEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/token",
|
|
880
|
-
scope: "openid email phone"
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
var OAUTH_CONFIGS = {
|
|
884
|
-
get TIMEBACK() {
|
|
885
|
-
return getTimebackConfig;
|
|
886
|
-
}
|
|
887
|
-
};
|
|
888
|
-
async function generateOAuthState(data) {
|
|
889
|
-
const csrfToken = await generateSecureRandomString(32);
|
|
890
|
-
if (data && Object.keys(data).length > 0) {
|
|
891
|
-
const jsonStr = JSON.stringify(data);
|
|
892
|
-
const base64 = btoa(jsonStr).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
893
|
-
return `${csrfToken}.${base64}`;
|
|
894
|
-
}
|
|
895
|
-
return csrfToken;
|
|
896
|
-
}
|
|
897
|
-
function parseOAuthState(state) {
|
|
898
|
-
const lastDotIndex = state.lastIndexOf(".");
|
|
899
|
-
if (lastDotIndex > 0 && lastDotIndex < state.length - 1) {
|
|
900
|
-
try {
|
|
901
|
-
const csrfToken = state.substring(0, lastDotIndex);
|
|
902
|
-
const base64 = state.substring(lastDotIndex + 1);
|
|
903
|
-
const base64WithPadding = base64.replace(/-/g, "+").replace(/_/g, "/");
|
|
904
|
-
const paddedBase64 = base64WithPadding + "=".repeat((4 - base64WithPadding.length % 4) % 4);
|
|
905
|
-
const jsonStr = atob(paddedBase64);
|
|
906
|
-
const data = JSON.parse(jsonStr);
|
|
907
|
-
return { csrfToken, data };
|
|
908
|
-
} catch {}
|
|
909
|
-
}
|
|
910
|
-
return { csrfToken: state };
|
|
911
|
-
}
|
|
912
|
-
function getOAuthConfig(provider) {
|
|
913
|
-
const configGetter = OAUTH_CONFIGS[provider];
|
|
914
|
-
if (!configGetter)
|
|
915
|
-
throw new Error(`Unsupported auth provider: ${provider}`);
|
|
916
|
-
return configGetter();
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// src/core/static/identity.ts
|
|
920
|
-
var identity = {
|
|
921
|
-
parseOAuthState
|
|
922
|
-
};
|
|
923
|
-
// src/core/auth/flows/popup.ts
|
|
924
|
-
async function initiatePopupFlow(options) {
|
|
925
|
-
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
926
|
-
try {
|
|
927
|
-
onStateChange?.({
|
|
928
|
-
status: "opening_popup",
|
|
929
|
-
message: "Opening authentication window..."
|
|
930
|
-
});
|
|
931
|
-
const defaults = getOAuthConfig(provider);
|
|
932
|
-
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
933
|
-
if (!config.clientId) {
|
|
934
|
-
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
935
|
-
}
|
|
936
|
-
const stateData = options.stateData;
|
|
937
|
-
const state = await generateOAuthState(stateData);
|
|
938
|
-
const params = new URLSearchParams({
|
|
939
|
-
response_type: "code",
|
|
940
|
-
client_id: config.clientId,
|
|
941
|
-
redirect_uri: callbackUrl,
|
|
942
|
-
state
|
|
943
|
-
});
|
|
944
|
-
if (config.scope) {
|
|
945
|
-
params.set("scope", config.scope);
|
|
946
|
-
}
|
|
947
|
-
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
948
|
-
const popup = openPopupWindow(authUrl, "playcademy-auth");
|
|
949
|
-
if (!popup || popup.closed) {
|
|
950
|
-
throw new Error("Popup blocked. Please enable popups and try again.");
|
|
951
|
-
}
|
|
952
|
-
onStateChange?.({
|
|
953
|
-
status: "exchanging_token",
|
|
954
|
-
message: "Waiting for authentication..."
|
|
955
|
-
});
|
|
956
|
-
return await waitForServerMessage(popup, onStateChange);
|
|
957
|
-
} catch (error) {
|
|
958
|
-
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
|
959
|
-
onStateChange?.({
|
|
960
|
-
status: "error",
|
|
961
|
-
message: errorMessage,
|
|
962
|
-
error: error instanceof Error ? error : new Error(errorMessage)
|
|
963
|
-
});
|
|
964
|
-
throw error;
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
async function waitForServerMessage(popup, onStateChange) {
|
|
968
|
-
return new Promise((resolve) => {
|
|
969
|
-
let resolved = false;
|
|
970
|
-
const handleMessage = (event) => {
|
|
971
|
-
if (event.origin !== window.location.origin)
|
|
972
|
-
return;
|
|
973
|
-
const data = event.data;
|
|
974
|
-
if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
|
|
975
|
-
resolved = true;
|
|
976
|
-
window.removeEventListener("message", handleMessage);
|
|
977
|
-
if (data.authenticated && data.user) {
|
|
978
|
-
onStateChange?.({
|
|
979
|
-
status: "complete",
|
|
980
|
-
message: "Authentication successful"
|
|
981
|
-
});
|
|
982
|
-
resolve({
|
|
983
|
-
success: true,
|
|
984
|
-
user: data.user
|
|
985
|
-
});
|
|
986
|
-
} else {
|
|
987
|
-
const error = new Error(data.error || "Authentication failed");
|
|
988
|
-
onStateChange?.({
|
|
989
|
-
status: "error",
|
|
990
|
-
message: error.message,
|
|
991
|
-
error
|
|
992
|
-
});
|
|
993
|
-
resolve({
|
|
994
|
-
success: false,
|
|
995
|
-
error
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
};
|
|
1000
|
-
window.addEventListener("message", handleMessage);
|
|
1001
|
-
const checkClosed = setInterval(() => {
|
|
1002
|
-
if (popup.closed && !resolved) {
|
|
1003
|
-
clearInterval(checkClosed);
|
|
1004
|
-
window.removeEventListener("message", handleMessage);
|
|
1005
|
-
const error = new Error("Authentication cancelled");
|
|
1006
|
-
onStateChange?.({
|
|
1007
|
-
status: "error",
|
|
1008
|
-
message: error.message,
|
|
1009
|
-
error
|
|
1010
|
-
});
|
|
1011
|
-
resolve({
|
|
1012
|
-
success: false,
|
|
1013
|
-
error
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
1016
|
-
}, 500);
|
|
1017
|
-
setTimeout(() => {
|
|
1018
|
-
if (!resolved) {
|
|
1019
|
-
window.removeEventListener("message", handleMessage);
|
|
1020
|
-
clearInterval(checkClosed);
|
|
1021
|
-
const error = new Error("Authentication timeout");
|
|
1022
|
-
onStateChange?.({
|
|
1023
|
-
status: "error",
|
|
1024
|
-
message: error.message,
|
|
1025
|
-
error
|
|
1026
|
-
});
|
|
1027
|
-
resolve({
|
|
1028
|
-
success: false,
|
|
1029
|
-
error
|
|
1030
|
-
});
|
|
1031
|
-
}
|
|
1032
|
-
}, 5 * 60 * 1000);
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// src/core/auth/flows/redirect.ts
|
|
1037
|
-
async function initiateRedirectFlow(options) {
|
|
1038
|
-
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
1039
|
-
try {
|
|
1040
|
-
onStateChange?.({
|
|
1041
|
-
status: "opening_popup",
|
|
1042
|
-
message: "Redirecting to authentication provider..."
|
|
1043
|
-
});
|
|
1044
|
-
const defaults = getOAuthConfig(provider);
|
|
1045
|
-
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
1046
|
-
if (!config.clientId) {
|
|
1047
|
-
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
1048
|
-
}
|
|
1049
|
-
const stateData = options.stateData;
|
|
1050
|
-
const state = await generateOAuthState(stateData);
|
|
1051
|
-
const params = new URLSearchParams({
|
|
1052
|
-
response_type: "code",
|
|
1053
|
-
client_id: config.clientId,
|
|
1054
|
-
redirect_uri: callbackUrl,
|
|
1055
|
-
state
|
|
1056
|
-
});
|
|
1057
|
-
if (config.scope) {
|
|
1058
|
-
params.set("scope", config.scope);
|
|
1059
|
-
}
|
|
1060
|
-
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
1061
|
-
window.location.href = authUrl;
|
|
1062
|
-
return new Promise(() => {});
|
|
1063
|
-
} catch (error) {
|
|
1064
|
-
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
|
1065
|
-
onStateChange?.({
|
|
1066
|
-
status: "error",
|
|
1067
|
-
message: errorMessage,
|
|
1068
|
-
error: error instanceof Error ? error : new Error(errorMessage)
|
|
1069
|
-
});
|
|
1070
|
-
throw error;
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// src/core/auth/flows/unified.ts
|
|
1075
|
-
async function initiateUnifiedFlow(options) {
|
|
1076
|
-
const { mode = "auto" } = options;
|
|
1077
|
-
const effectiveMode = mode === "auto" ? isInIframe() ? "popup" : "redirect" : mode;
|
|
1078
|
-
switch (effectiveMode) {
|
|
1079
|
-
case "popup":
|
|
1080
|
-
return initiatePopupFlow(options);
|
|
1081
|
-
case "redirect":
|
|
1082
|
-
return initiateRedirectFlow(options);
|
|
1083
|
-
default:
|
|
1084
|
-
throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// src/core/auth/login.ts
|
|
1089
|
-
async function login2(client, options) {
|
|
1090
|
-
try {
|
|
1091
|
-
let stateData = options.stateData;
|
|
1092
|
-
if (!stateData) {
|
|
1093
|
-
try {
|
|
1094
|
-
const currentUser = await client.users.me();
|
|
1095
|
-
if (currentUser?.id) {
|
|
1096
|
-
stateData = { playcademy_user_id: currentUser.id };
|
|
1097
|
-
}
|
|
1098
|
-
} catch {
|
|
1099
|
-
log.debug("[Playcademy SDK] No current user available for state data");
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
log.debug("[Playcademy SDK] Starting OAuth login", {
|
|
1103
|
-
provider: options.provider,
|
|
1104
|
-
mode: options.mode || "auto",
|
|
1105
|
-
callbackUrl: options.callbackUrl,
|
|
1106
|
-
hasStateData: !!stateData
|
|
1107
|
-
});
|
|
1108
|
-
const optionsWithState = {
|
|
1109
|
-
...options,
|
|
1110
|
-
stateData
|
|
1111
|
-
};
|
|
1112
|
-
const result = await initiateUnifiedFlow(optionsWithState);
|
|
1113
|
-
if (result.success && result.user) {
|
|
1114
|
-
log.debug("[Playcademy SDK] OAuth login successful", {
|
|
1115
|
-
userId: result.user.sub
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
return result;
|
|
1119
|
-
} catch (error) {
|
|
1120
|
-
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
1121
|
-
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
1122
|
-
return {
|
|
1123
|
-
success: false,
|
|
1124
|
-
error: authError
|
|
787
|
+
success: false,
|
|
788
|
+
error: authError
|
|
1125
789
|
};
|
|
1126
790
|
}
|
|
1127
791
|
}
|
|
@@ -1243,49 +907,53 @@ function createRuntimeNamespace(client) {
|
|
|
1243
907
|
}
|
|
1244
908
|
return counts;
|
|
1245
909
|
},
|
|
1246
|
-
assets:
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
json: async (path) => {
|
|
1274
|
-
const response = await client.runtime.assets.fetch(path);
|
|
1275
|
-
return response.json();
|
|
1276
|
-
},
|
|
1277
|
-
blob: async (path) => {
|
|
1278
|
-
const response = await client.runtime.assets.fetch(path);
|
|
1279
|
-
return response.blob();
|
|
1280
|
-
},
|
|
1281
|
-
text: async (path) => {
|
|
1282
|
-
const response = await client.runtime.assets.fetch(path);
|
|
1283
|
-
return response.text();
|
|
1284
|
-
},
|
|
1285
|
-
arrayBuffer: async (path) => {
|
|
1286
|
-
const response = await client.runtime.assets.fetch(path);
|
|
1287
|
-
return response.arrayBuffer();
|
|
910
|
+
assets: createAssetsNamespace(client)
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function createAssetsNamespace(client) {
|
|
914
|
+
const fetchAsset = async (path, options) => {
|
|
915
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
916
|
+
if (!gameUrl) {
|
|
917
|
+
const relativePath = path.startsWith("./") ? path : "./" + path;
|
|
918
|
+
return fetch(relativePath, options);
|
|
919
|
+
}
|
|
920
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
921
|
+
return fetch(gameUrl + cleanPath, options);
|
|
922
|
+
};
|
|
923
|
+
return {
|
|
924
|
+
url(pathOrStrings, ...values) {
|
|
925
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
926
|
+
let path;
|
|
927
|
+
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
928
|
+
const strings = pathOrStrings;
|
|
929
|
+
path = strings.reduce((acc, str, i) => {
|
|
930
|
+
return acc + str + (values[i] != null ? String(values[i]) : "");
|
|
931
|
+
}, "");
|
|
932
|
+
} else {
|
|
933
|
+
path = pathOrStrings;
|
|
934
|
+
}
|
|
935
|
+
if (!gameUrl) {
|
|
936
|
+
return path.startsWith("./") ? path : "./" + path;
|
|
1288
937
|
}
|
|
938
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
939
|
+
return gameUrl + cleanPath;
|
|
940
|
+
},
|
|
941
|
+
fetch: fetchAsset,
|
|
942
|
+
json: async (path) => {
|
|
943
|
+
const response = await fetchAsset(path);
|
|
944
|
+
return response.json();
|
|
945
|
+
},
|
|
946
|
+
blob: async (path) => {
|
|
947
|
+
const response = await fetchAsset(path);
|
|
948
|
+
return response.blob();
|
|
949
|
+
},
|
|
950
|
+
text: async (path) => {
|
|
951
|
+
const response = await fetchAsset(path);
|
|
952
|
+
return response.text();
|
|
953
|
+
},
|
|
954
|
+
arrayBuffer: async (path) => {
|
|
955
|
+
const response = await fetchAsset(path);
|
|
956
|
+
return response.arrayBuffer();
|
|
1289
957
|
}
|
|
1290
958
|
};
|
|
1291
959
|
}
|
|
@@ -1324,530 +992,914 @@ function createBackendNamespace(client) {
|
|
|
1324
992
|
}, "");
|
|
1325
993
|
return `${client.gameUrl}/api${path2.startsWith("/") ? path2 : `/${path2}`}`;
|
|
1326
994
|
}
|
|
1327
|
-
const path = pathOrStrings;
|
|
1328
|
-
return `${client.gameUrl}/api${path.startsWith("/") ? path : `/${path}`}`;
|
|
995
|
+
const path = pathOrStrings;
|
|
996
|
+
return `${client.gameUrl}/api${path.startsWith("/") ? path : `/${path}`}`;
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
// src/core/cache/permanent-cache.ts
|
|
1001
|
+
function createPermanentCache(keyPrefix) {
|
|
1002
|
+
const cache = new Map;
|
|
1003
|
+
async function get(key, loader) {
|
|
1004
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1005
|
+
const existing = cache.get(fullKey);
|
|
1006
|
+
if (existing)
|
|
1007
|
+
return existing;
|
|
1008
|
+
const promise = loader().catch((error) => {
|
|
1009
|
+
cache.delete(fullKey);
|
|
1010
|
+
throw error;
|
|
1011
|
+
});
|
|
1012
|
+
cache.set(fullKey, promise);
|
|
1013
|
+
return promise;
|
|
1014
|
+
}
|
|
1015
|
+
function clear(key) {
|
|
1016
|
+
if (key === undefined) {
|
|
1017
|
+
cache.clear();
|
|
1018
|
+
} else {
|
|
1019
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1020
|
+
cache.delete(fullKey);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
function has(key) {
|
|
1024
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1025
|
+
return cache.has(fullKey);
|
|
1026
|
+
}
|
|
1027
|
+
function size() {
|
|
1028
|
+
return cache.size;
|
|
1029
|
+
}
|
|
1030
|
+
function keys() {
|
|
1031
|
+
const result = [];
|
|
1032
|
+
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
1033
|
+
for (const fullKey of cache.keys()) {
|
|
1034
|
+
result.push(fullKey.substring(prefixLen));
|
|
1035
|
+
}
|
|
1036
|
+
return result;
|
|
1037
|
+
}
|
|
1038
|
+
return { get, clear, has, size, keys };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// src/namespaces/game/users.ts
|
|
1042
|
+
function createUsersNamespace(client) {
|
|
1043
|
+
const itemIdCache = createPermanentCache("items");
|
|
1044
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1045
|
+
const resolveItemId = async (identifier) => {
|
|
1046
|
+
if (UUID_REGEX.test(identifier))
|
|
1047
|
+
return identifier;
|
|
1048
|
+
const gameId = client["gameId"];
|
|
1049
|
+
const cacheKey = gameId ? `${identifier}:${gameId}` : identifier;
|
|
1050
|
+
return itemIdCache.get(cacheKey, async () => {
|
|
1051
|
+
const queryParams = new URLSearchParams({ slug: identifier });
|
|
1052
|
+
if (gameId)
|
|
1053
|
+
queryParams.append("gameId", gameId);
|
|
1054
|
+
const item = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
|
|
1055
|
+
return item.id;
|
|
1056
|
+
});
|
|
1057
|
+
};
|
|
1058
|
+
return {
|
|
1059
|
+
me: async () => {
|
|
1060
|
+
return client["request"]("/users/me", "GET");
|
|
1061
|
+
},
|
|
1062
|
+
inventory: {
|
|
1063
|
+
get: async () => client["request"](`/inventory`, "GET"),
|
|
1064
|
+
add: async (identifier, qty) => {
|
|
1065
|
+
const itemId = await resolveItemId(identifier);
|
|
1066
|
+
const res = await client["request"](`/inventory/add`, "POST", { body: { itemId, qty } });
|
|
1067
|
+
client["emit"]("inventoryChange", {
|
|
1068
|
+
itemId,
|
|
1069
|
+
delta: qty,
|
|
1070
|
+
newTotal: res.newTotal
|
|
1071
|
+
});
|
|
1072
|
+
return res;
|
|
1073
|
+
},
|
|
1074
|
+
remove: async (identifier, qty) => {
|
|
1075
|
+
const itemId = await resolveItemId(identifier);
|
|
1076
|
+
const res = await client["request"](`/inventory/remove`, "POST", { body: { itemId, qty } });
|
|
1077
|
+
client["emit"]("inventoryChange", {
|
|
1078
|
+
itemId,
|
|
1079
|
+
delta: -qty,
|
|
1080
|
+
newTotal: res.newTotal
|
|
1081
|
+
});
|
|
1082
|
+
return res;
|
|
1083
|
+
},
|
|
1084
|
+
quantity: async (identifier) => {
|
|
1085
|
+
const itemId = await resolveItemId(identifier);
|
|
1086
|
+
const inventory = await client["request"](`/inventory`, "GET");
|
|
1087
|
+
const item = inventory.find((inv) => inv.item?.id === itemId);
|
|
1088
|
+
return item?.quantity ?? 0;
|
|
1089
|
+
},
|
|
1090
|
+
has: async (identifier, minQuantity = 1) => {
|
|
1091
|
+
const itemId = await resolveItemId(identifier);
|
|
1092
|
+
const inventory = await client["request"](`/inventory`, "GET");
|
|
1093
|
+
const item = inventory.find((inv) => inv.item?.id === itemId);
|
|
1094
|
+
const qty = item?.quantity ?? 0;
|
|
1095
|
+
return qty >= minQuantity;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
// ../constants/src/achievements.ts
|
|
1101
|
+
var ACHIEVEMENT_IDS = {
|
|
1102
|
+
PLAY_ANY_GAME_DAILY: "play_any_game_daily",
|
|
1103
|
+
DAILY_CHEST_OPEN: "daily_chest_open",
|
|
1104
|
+
LEADERBOARD_TOP3_DAILY: "leaderboard_top3_daily",
|
|
1105
|
+
LEADERBOARD_TOP3_WEEKLY: "leaderboard_top3_weekly",
|
|
1106
|
+
FIRST_SCORE_ANY_GAME: "first_score_any_game",
|
|
1107
|
+
PERSONAL_BEST_ANY_GAME: "personal_best_any_game"
|
|
1108
|
+
};
|
|
1109
|
+
var ACHIEVEMENT_DEFINITIONS = [
|
|
1110
|
+
{
|
|
1111
|
+
id: ACHIEVEMENT_IDS.PLAY_ANY_GAME_DAILY,
|
|
1112
|
+
title: "Play any game",
|
|
1113
|
+
description: "Play any arcade game for at least 60 seconds in a single session.",
|
|
1114
|
+
scope: "daily",
|
|
1115
|
+
rewardCredits: 10,
|
|
1116
|
+
limit: 1,
|
|
1117
|
+
completionType: "time_played_session",
|
|
1118
|
+
completionConfig: { minSeconds: 60 },
|
|
1119
|
+
target: { anyArcadeGame: true },
|
|
1120
|
+
active: true
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
id: ACHIEVEMENT_IDS.DAILY_CHEST_OPEN,
|
|
1124
|
+
title: "Opened the daily chest",
|
|
1125
|
+
description: "Find the chest on the map and open it to claim your reward.",
|
|
1126
|
+
scope: "daily",
|
|
1127
|
+
rewardCredits: 10,
|
|
1128
|
+
limit: 1,
|
|
1129
|
+
completionType: "interaction",
|
|
1130
|
+
completionConfig: { triggerId: "bunny_chest" },
|
|
1131
|
+
target: { map: "arcade" },
|
|
1132
|
+
active: true
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
id: ACHIEVEMENT_IDS.LEADERBOARD_TOP3_DAILY,
|
|
1136
|
+
title: "Daily Champion",
|
|
1137
|
+
description: "Finish in the top 3 of any game leaderboard for the day.",
|
|
1138
|
+
scope: "daily",
|
|
1139
|
+
rewardCredits: 25,
|
|
1140
|
+
limit: 1,
|
|
1141
|
+
completionType: "leaderboard_rank",
|
|
1142
|
+
completionConfig: {
|
|
1143
|
+
rankRewards: [50, 30, 15]
|
|
1144
|
+
},
|
|
1145
|
+
target: { anyArcadeGame: true },
|
|
1146
|
+
active: true
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
id: ACHIEVEMENT_IDS.LEADERBOARD_TOP3_WEEKLY,
|
|
1150
|
+
title: "Weekly Legend",
|
|
1151
|
+
description: "Finish in the top 3 of any game leaderboard for the week.",
|
|
1152
|
+
scope: "weekly",
|
|
1153
|
+
rewardCredits: 50,
|
|
1154
|
+
limit: 1,
|
|
1155
|
+
completionType: "leaderboard_rank",
|
|
1156
|
+
completionConfig: {
|
|
1157
|
+
rankRewards: [100, 60, 30]
|
|
1158
|
+
},
|
|
1159
|
+
target: { anyArcadeGame: true },
|
|
1160
|
+
active: true
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
id: ACHIEVEMENT_IDS.FIRST_SCORE_ANY_GAME,
|
|
1164
|
+
title: "First Score",
|
|
1165
|
+
description: "Submit your first score in any arcade game.",
|
|
1166
|
+
scope: "game",
|
|
1167
|
+
rewardCredits: 5,
|
|
1168
|
+
limit: 1,
|
|
1169
|
+
completionType: "first_score",
|
|
1170
|
+
completionConfig: {},
|
|
1171
|
+
target: { anyArcadeGame: true },
|
|
1172
|
+
active: true
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
id: ACHIEVEMENT_IDS.PERSONAL_BEST_ANY_GAME,
|
|
1176
|
+
title: "Personal Best",
|
|
1177
|
+
description: "Beat your personal best score in any arcade game.",
|
|
1178
|
+
scope: "daily",
|
|
1179
|
+
rewardCredits: 5,
|
|
1180
|
+
limit: 3,
|
|
1181
|
+
completionType: "personal_best",
|
|
1182
|
+
completionConfig: {},
|
|
1183
|
+
target: { anyArcadeGame: true },
|
|
1184
|
+
active: true
|
|
1185
|
+
}
|
|
1186
|
+
];
|
|
1187
|
+
// ../constants/src/typescript.ts
|
|
1188
|
+
var TSC_PACKAGE = "@typescript/native-preview";
|
|
1189
|
+
var USE_NATIVE_TSC = TSC_PACKAGE.includes("native-preview");
|
|
1190
|
+
// ../constants/src/overworld.ts
|
|
1191
|
+
var ITEM_SLUGS = {
|
|
1192
|
+
PLAYCADEMY_CREDITS: "PLAYCADEMY_CREDITS",
|
|
1193
|
+
PLAYCADEMY_XP: "PLAYCADEMY_XP",
|
|
1194
|
+
FOUNDING_MEMBER_BADGE: "FOUNDING_MEMBER_BADGE",
|
|
1195
|
+
EARLY_ADOPTER_BADGE: "EARLY_ADOPTER_BADGE",
|
|
1196
|
+
FIRST_GAME_BADGE: "FIRST_GAME_BADGE",
|
|
1197
|
+
COMMON_SWORD: "COMMON_SWORD",
|
|
1198
|
+
SMALL_HEALTH_POTION: "SMALL_HEALTH_POTION",
|
|
1199
|
+
SMALL_BACKPACK: "SMALL_BACKPACK",
|
|
1200
|
+
LAVA_LAMP: "LAVA_LAMP",
|
|
1201
|
+
BOOMBOX: "BOOMBOX",
|
|
1202
|
+
CABIN_BED: "CABIN_BED"
|
|
1203
|
+
};
|
|
1204
|
+
var CURRENCIES = {
|
|
1205
|
+
PRIMARY: ITEM_SLUGS.PLAYCADEMY_CREDITS,
|
|
1206
|
+
XP: ITEM_SLUGS.PLAYCADEMY_XP
|
|
1207
|
+
};
|
|
1208
|
+
var BADGES = {
|
|
1209
|
+
FOUNDING_MEMBER: ITEM_SLUGS.FOUNDING_MEMBER_BADGE,
|
|
1210
|
+
EARLY_ADOPTER: ITEM_SLUGS.EARLY_ADOPTER_BADGE,
|
|
1211
|
+
FIRST_GAME: ITEM_SLUGS.FIRST_GAME_BADGE
|
|
1212
|
+
};
|
|
1213
|
+
// ../constants/src/timeback.ts
|
|
1214
|
+
var TIMEBACK_ROUTES = {
|
|
1215
|
+
END_ACTIVITY: "/integrations/timeback/end-activity"
|
|
1216
|
+
};
|
|
1217
|
+
// src/core/cache/singleton-cache.ts
|
|
1218
|
+
function createSingletonCache() {
|
|
1219
|
+
let cachedValue;
|
|
1220
|
+
let hasValue = false;
|
|
1221
|
+
async function get(loader) {
|
|
1222
|
+
if (hasValue) {
|
|
1223
|
+
return cachedValue;
|
|
1224
|
+
}
|
|
1225
|
+
const value = await loader();
|
|
1226
|
+
cachedValue = value;
|
|
1227
|
+
hasValue = true;
|
|
1228
|
+
return value;
|
|
1229
|
+
}
|
|
1230
|
+
function clear() {
|
|
1231
|
+
cachedValue = undefined;
|
|
1232
|
+
hasValue = false;
|
|
1233
|
+
}
|
|
1234
|
+
function has() {
|
|
1235
|
+
return hasValue;
|
|
1236
|
+
}
|
|
1237
|
+
return { get, clear, has };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// src/namespaces/game/credits.ts
|
|
1241
|
+
function createCreditsNamespace(client) {
|
|
1242
|
+
const creditsIdCache = createSingletonCache();
|
|
1243
|
+
const getCreditsItemId = async () => {
|
|
1244
|
+
return creditsIdCache.get(async () => {
|
|
1245
|
+
const queryParams = new URLSearchParams({ slug: CURRENCIES.PRIMARY });
|
|
1246
|
+
const creditsItem = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
|
|
1247
|
+
if (!creditsItem || !creditsItem.id) {
|
|
1248
|
+
throw new Error("Playcademy Credits item not found in catalog");
|
|
1249
|
+
}
|
|
1250
|
+
return creditsItem.id;
|
|
1251
|
+
});
|
|
1252
|
+
};
|
|
1253
|
+
return {
|
|
1254
|
+
balance: async () => {
|
|
1255
|
+
const inventory = await client["request"]("/inventory", "GET");
|
|
1256
|
+
const primaryCurrencyInventoryItem = inventory.find((item) => item.item?.slug === CURRENCIES.PRIMARY);
|
|
1257
|
+
return primaryCurrencyInventoryItem?.quantity ?? 0;
|
|
1258
|
+
},
|
|
1259
|
+
add: async (amount) => {
|
|
1260
|
+
if (amount <= 0) {
|
|
1261
|
+
throw new Error("Amount must be positive");
|
|
1262
|
+
}
|
|
1263
|
+
const creditsItemId = await getCreditsItemId();
|
|
1264
|
+
const result = await client["request"]("/inventory/add", "POST", {
|
|
1265
|
+
body: {
|
|
1266
|
+
itemId: creditsItemId,
|
|
1267
|
+
qty: amount
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
client["emit"]("inventoryChange", {
|
|
1271
|
+
itemId: creditsItemId,
|
|
1272
|
+
delta: amount,
|
|
1273
|
+
newTotal: result.newTotal
|
|
1274
|
+
});
|
|
1275
|
+
return result.newTotal;
|
|
1276
|
+
},
|
|
1277
|
+
spend: async (amount) => {
|
|
1278
|
+
if (amount <= 0) {
|
|
1279
|
+
throw new Error("Amount must be positive");
|
|
1280
|
+
}
|
|
1281
|
+
const creditsItemId = await getCreditsItemId();
|
|
1282
|
+
const result = await client["request"]("/inventory/remove", "POST", {
|
|
1283
|
+
body: {
|
|
1284
|
+
itemId: creditsItemId,
|
|
1285
|
+
qty: amount
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
client["emit"]("inventoryChange", {
|
|
1289
|
+
itemId: creditsItemId,
|
|
1290
|
+
delta: -amount,
|
|
1291
|
+
newTotal: result.newTotal
|
|
1292
|
+
});
|
|
1293
|
+
return result.newTotal;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
// src/namespaces/game/scores.ts
|
|
1298
|
+
function createScoresNamespace(client) {
|
|
1299
|
+
return {
|
|
1300
|
+
submit: async (gameId, score, metadata) => {
|
|
1301
|
+
return client["request"](`/games/${gameId}/scores`, "POST", {
|
|
1302
|
+
body: {
|
|
1303
|
+
score,
|
|
1304
|
+
metadata
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
// src/namespaces/game/realtime.ts
|
|
1311
|
+
function createRealtimeNamespace(client) {
|
|
1312
|
+
return {
|
|
1313
|
+
token: {
|
|
1314
|
+
get: async () => {
|
|
1315
|
+
const endpoint = client["gameId"] ? `/games/${client["gameId"]}/realtime/token` : "/realtime/token";
|
|
1316
|
+
return client["request"](endpoint, "POST");
|
|
1317
|
+
}
|
|
1329
1318
|
}
|
|
1330
1319
|
};
|
|
1331
1320
|
}
|
|
1332
|
-
// src/core/cache/
|
|
1333
|
-
function
|
|
1321
|
+
// src/core/cache/ttl-cache.ts
|
|
1322
|
+
function createTTLCache(options) {
|
|
1334
1323
|
const cache = new Map;
|
|
1335
|
-
|
|
1324
|
+
const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
|
|
1325
|
+
async function get(key, loader, config) {
|
|
1336
1326
|
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1327
|
+
const now = Date.now();
|
|
1328
|
+
const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
|
|
1329
|
+
const force = config?.force || false;
|
|
1330
|
+
const skipCache = config?.skipCache || false;
|
|
1331
|
+
if (effectiveTTL === 0 || skipCache) {
|
|
1332
|
+
return loader();
|
|
1333
|
+
}
|
|
1334
|
+
if (!force) {
|
|
1335
|
+
const cached = cache.get(fullKey);
|
|
1336
|
+
if (cached && cached.expiresAt > now) {
|
|
1337
|
+
return cached.value;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
1340
|
const promise = loader().catch((error) => {
|
|
1341
1341
|
cache.delete(fullKey);
|
|
1342
1342
|
throw error;
|
|
1343
1343
|
});
|
|
1344
|
-
cache.set(fullKey,
|
|
1344
|
+
cache.set(fullKey, {
|
|
1345
|
+
value: promise,
|
|
1346
|
+
expiresAt: now + effectiveTTL
|
|
1347
|
+
});
|
|
1345
1348
|
return promise;
|
|
1346
1349
|
}
|
|
1347
1350
|
function clear(key) {
|
|
1348
1351
|
if (key === undefined) {
|
|
1349
1352
|
cache.clear();
|
|
1353
|
+
onClear?.();
|
|
1350
1354
|
} else {
|
|
1351
1355
|
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1352
1356
|
cache.delete(fullKey);
|
|
1353
1357
|
}
|
|
1354
1358
|
}
|
|
1355
|
-
function has(key) {
|
|
1356
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1357
|
-
return cache.has(fullKey);
|
|
1358
|
-
}
|
|
1359
1359
|
function size() {
|
|
1360
1360
|
return cache.size;
|
|
1361
1361
|
}
|
|
1362
|
-
function
|
|
1363
|
-
const
|
|
1362
|
+
function prune() {
|
|
1363
|
+
const now = Date.now();
|
|
1364
|
+
for (const [key, entry] of cache.entries()) {
|
|
1365
|
+
if (entry.expiresAt <= now) {
|
|
1366
|
+
cache.delete(key);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
function getKeys() {
|
|
1371
|
+
const keys = [];
|
|
1364
1372
|
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
1365
1373
|
for (const fullKey of cache.keys()) {
|
|
1366
|
-
|
|
1374
|
+
keys.push(fullKey.substring(prefixLen));
|
|
1367
1375
|
}
|
|
1368
|
-
return
|
|
1376
|
+
return keys;
|
|
1369
1377
|
}
|
|
1370
|
-
|
|
1378
|
+
function has(key) {
|
|
1379
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1380
|
+
const cached = cache.get(fullKey);
|
|
1381
|
+
if (!cached)
|
|
1382
|
+
return false;
|
|
1383
|
+
const now = Date.now();
|
|
1384
|
+
if (cached.expiresAt <= now) {
|
|
1385
|
+
cache.delete(fullKey);
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
return { get, clear, size, prune, getKeys, has };
|
|
1371
1391
|
}
|
|
1372
1392
|
|
|
1373
|
-
// src/namespaces/game/
|
|
1374
|
-
function
|
|
1375
|
-
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
const cacheKey = gameId ? `${identifier}:${gameId}` : identifier;
|
|
1382
|
-
return itemIdCache.get(cacheKey, async () => {
|
|
1383
|
-
const queryParams = new URLSearchParams({ slug: identifier });
|
|
1384
|
-
if (gameId)
|
|
1385
|
-
queryParams.append("gameId", gameId);
|
|
1386
|
-
const item = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
|
|
1387
|
-
return item.id;
|
|
1388
|
-
});
|
|
1389
|
-
};
|
|
1393
|
+
// src/namespaces/game/timeback.ts
|
|
1394
|
+
function createTimebackNamespace(client) {
|
|
1395
|
+
let currentActivity = null;
|
|
1396
|
+
const userCache = createTTLCache({
|
|
1397
|
+
ttl: 5 * 60 * 1000,
|
|
1398
|
+
keyPrefix: "game.timeback.user"
|
|
1399
|
+
});
|
|
1400
|
+
const getTimeback = () => client["initPayload"]?.timeback;
|
|
1390
1401
|
return {
|
|
1391
|
-
|
|
1392
|
-
return
|
|
1402
|
+
get user() {
|
|
1403
|
+
return {
|
|
1404
|
+
get id() {
|
|
1405
|
+
return getTimeback()?.id;
|
|
1406
|
+
},
|
|
1407
|
+
get role() {
|
|
1408
|
+
return getTimeback()?.role;
|
|
1409
|
+
},
|
|
1410
|
+
get enrollments() {
|
|
1411
|
+
return getTimeback()?.enrollments ?? [];
|
|
1412
|
+
},
|
|
1413
|
+
get organizations() {
|
|
1414
|
+
return getTimeback()?.organizations ?? [];
|
|
1415
|
+
},
|
|
1416
|
+
fetch: async (options) => {
|
|
1417
|
+
return userCache.get("current", async () => {
|
|
1418
|
+
const response = await client["request"]("/timeback/user", "GET");
|
|
1419
|
+
const initPayload = client["initPayload"];
|
|
1420
|
+
if (initPayload) {
|
|
1421
|
+
initPayload.timeback = response;
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
id: response.id,
|
|
1425
|
+
role: response.role,
|
|
1426
|
+
enrollments: response.enrollments,
|
|
1427
|
+
organizations: response.organizations
|
|
1428
|
+
};
|
|
1429
|
+
}, options);
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1393
1432
|
},
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1433
|
+
startActivity: (metadata) => {
|
|
1434
|
+
currentActivity = {
|
|
1435
|
+
startTime: Date.now(),
|
|
1436
|
+
metadata,
|
|
1437
|
+
pausedTime: 0,
|
|
1438
|
+
pauseStartTime: null
|
|
1439
|
+
};
|
|
1440
|
+
},
|
|
1441
|
+
pauseActivity: () => {
|
|
1442
|
+
if (!currentActivity) {
|
|
1443
|
+
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
1444
|
+
}
|
|
1445
|
+
if (currentActivity.pauseStartTime !== null) {
|
|
1446
|
+
throw new Error("Activity is already paused.");
|
|
1447
|
+
}
|
|
1448
|
+
currentActivity.pauseStartTime = Date.now();
|
|
1449
|
+
},
|
|
1450
|
+
resumeActivity: () => {
|
|
1451
|
+
if (!currentActivity) {
|
|
1452
|
+
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
1453
|
+
}
|
|
1454
|
+
if (currentActivity.pauseStartTime === null) {
|
|
1455
|
+
throw new Error("Activity is not paused.");
|
|
1456
|
+
}
|
|
1457
|
+
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
1458
|
+
currentActivity.pausedTime += pauseDuration;
|
|
1459
|
+
currentActivity.pauseStartTime = null;
|
|
1460
|
+
},
|
|
1461
|
+
endActivity: async (data) => {
|
|
1462
|
+
if (!currentActivity) {
|
|
1463
|
+
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
1464
|
+
}
|
|
1465
|
+
if (currentActivity.pauseStartTime !== null) {
|
|
1466
|
+
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
1467
|
+
currentActivity.pausedTime += pauseDuration;
|
|
1468
|
+
currentActivity.pauseStartTime = null;
|
|
1469
|
+
}
|
|
1470
|
+
const endTime = Date.now();
|
|
1471
|
+
const totalElapsed = endTime - currentActivity.startTime;
|
|
1472
|
+
const activeTime = totalElapsed - currentActivity.pausedTime;
|
|
1473
|
+
const durationSeconds = Math.floor(activeTime / 1000);
|
|
1474
|
+
const { correctQuestions, totalQuestions } = data;
|
|
1475
|
+
const request = {
|
|
1476
|
+
activityData: currentActivity.metadata,
|
|
1477
|
+
scoreData: {
|
|
1478
|
+
correctQuestions,
|
|
1479
|
+
totalQuestions
|
|
1480
|
+
},
|
|
1481
|
+
timingData: {
|
|
1482
|
+
durationSeconds
|
|
1483
|
+
},
|
|
1484
|
+
xpEarned: data.xpAwarded,
|
|
1485
|
+
masteredUnits: data.masteredUnits
|
|
1486
|
+
};
|
|
1487
|
+
try {
|
|
1488
|
+
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request);
|
|
1489
|
+
currentActivity = null;
|
|
1490
|
+
return response;
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
currentActivity = null;
|
|
1493
|
+
throw error;
|
|
1428
1494
|
}
|
|
1429
1495
|
}
|
|
1430
1496
|
};
|
|
1431
1497
|
}
|
|
1432
|
-
//
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
};
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1498
|
+
// src/core/auth/strategies.ts
|
|
1499
|
+
class ApiKeyAuth {
|
|
1500
|
+
apiKey;
|
|
1501
|
+
constructor(apiKey) {
|
|
1502
|
+
this.apiKey = apiKey;
|
|
1503
|
+
}
|
|
1504
|
+
getToken() {
|
|
1505
|
+
return this.apiKey;
|
|
1506
|
+
}
|
|
1507
|
+
getType() {
|
|
1508
|
+
return "apiKey";
|
|
1509
|
+
}
|
|
1510
|
+
getHeaders() {
|
|
1511
|
+
return { "x-api-key": this.apiKey };
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
class SessionAuth {
|
|
1516
|
+
sessionToken;
|
|
1517
|
+
constructor(sessionToken) {
|
|
1518
|
+
this.sessionToken = sessionToken;
|
|
1519
|
+
}
|
|
1520
|
+
getToken() {
|
|
1521
|
+
return this.sessionToken;
|
|
1522
|
+
}
|
|
1523
|
+
getType() {
|
|
1524
|
+
return "session";
|
|
1525
|
+
}
|
|
1526
|
+
getHeaders() {
|
|
1527
|
+
return { Authorization: `Bearer ${this.sessionToken}` };
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
class GameJwtAuth {
|
|
1532
|
+
gameToken;
|
|
1533
|
+
constructor(gameToken) {
|
|
1534
|
+
this.gameToken = gameToken;
|
|
1535
|
+
}
|
|
1536
|
+
getToken() {
|
|
1537
|
+
return this.gameToken;
|
|
1538
|
+
}
|
|
1539
|
+
getType() {
|
|
1540
|
+
return "gameJwt";
|
|
1541
|
+
}
|
|
1542
|
+
getHeaders() {
|
|
1543
|
+
return { Authorization: `Bearer ${this.gameToken}` };
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
class NoAuth {
|
|
1548
|
+
getToken() {
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
getType() {
|
|
1552
|
+
return "session";
|
|
1553
|
+
}
|
|
1554
|
+
getHeaders() {
|
|
1555
|
+
return {};
|
|
1471
1556
|
}
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1557
|
+
}
|
|
1558
|
+
function createAuthStrategy(token, tokenType) {
|
|
1559
|
+
if (!token) {
|
|
1560
|
+
return new NoAuth;
|
|
1475
1561
|
}
|
|
1476
|
-
|
|
1477
|
-
return
|
|
1562
|
+
if (tokenType === "apiKey") {
|
|
1563
|
+
return new ApiKeyAuth(token);
|
|
1478
1564
|
}
|
|
1479
|
-
|
|
1565
|
+
if (tokenType === "session") {
|
|
1566
|
+
return new SessionAuth(token);
|
|
1567
|
+
}
|
|
1568
|
+
if (tokenType === "gameJwt") {
|
|
1569
|
+
return new GameJwtAuth(token);
|
|
1570
|
+
}
|
|
1571
|
+
if (token.startsWith("cademy")) {
|
|
1572
|
+
return new ApiKeyAuth(token);
|
|
1573
|
+
}
|
|
1574
|
+
return new GameJwtAuth(token);
|
|
1480
1575
|
}
|
|
1481
1576
|
|
|
1482
|
-
// src/
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1577
|
+
// src/core/connection/monitor.ts
|
|
1578
|
+
class ConnectionMonitor {
|
|
1579
|
+
state = "online";
|
|
1580
|
+
callbacks = new Set;
|
|
1581
|
+
heartbeatInterval;
|
|
1582
|
+
consecutiveFailures = 0;
|
|
1583
|
+
isMonitoring = false;
|
|
1584
|
+
config;
|
|
1585
|
+
constructor(config) {
|
|
1586
|
+
this.config = {
|
|
1587
|
+
baseUrl: config.baseUrl,
|
|
1588
|
+
heartbeatInterval: config.heartbeatInterval ?? 1e4,
|
|
1589
|
+
heartbeatTimeout: config.heartbeatTimeout ?? 5000,
|
|
1590
|
+
failureThreshold: config.failureThreshold ?? 2,
|
|
1591
|
+
enableHeartbeat: config.enableHeartbeat ?? true,
|
|
1592
|
+
enableOfflineEvents: config.enableOfflineEvents ?? true
|
|
1593
|
+
};
|
|
1594
|
+
this._detectInitialState();
|
|
1595
|
+
}
|
|
1596
|
+
start() {
|
|
1597
|
+
if (this.isMonitoring)
|
|
1598
|
+
return;
|
|
1599
|
+
this.isMonitoring = true;
|
|
1600
|
+
if (this.config.enableOfflineEvents && typeof window !== "undefined") {
|
|
1601
|
+
window.addEventListener("online", this._handleOnline);
|
|
1602
|
+
window.addEventListener("offline", this._handleOffline);
|
|
1603
|
+
}
|
|
1604
|
+
if (this.config.enableHeartbeat) {
|
|
1605
|
+
this._startHeartbeat();
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
stop() {
|
|
1609
|
+
if (!this.isMonitoring)
|
|
1610
|
+
return;
|
|
1611
|
+
this.isMonitoring = false;
|
|
1612
|
+
if (typeof window !== "undefined") {
|
|
1613
|
+
window.removeEventListener("online", this._handleOnline);
|
|
1614
|
+
window.removeEventListener("offline", this._handleOffline);
|
|
1615
|
+
}
|
|
1616
|
+
if (this.heartbeatInterval) {
|
|
1617
|
+
clearInterval(this.heartbeatInterval);
|
|
1618
|
+
this.heartbeatInterval = undefined;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
onChange(callback) {
|
|
1622
|
+
this.callbacks.add(callback);
|
|
1623
|
+
return () => this.callbacks.delete(callback);
|
|
1624
|
+
}
|
|
1625
|
+
getState() {
|
|
1626
|
+
return this.state;
|
|
1627
|
+
}
|
|
1628
|
+
async checkNow() {
|
|
1629
|
+
await this._performHeartbeat();
|
|
1630
|
+
return this.state;
|
|
1631
|
+
}
|
|
1632
|
+
reportRequestFailure(error) {
|
|
1633
|
+
const isNetworkError = error instanceof TypeError || error instanceof Error && error.message.includes("fetch");
|
|
1634
|
+
if (!isNetworkError)
|
|
1635
|
+
return;
|
|
1636
|
+
this.consecutiveFailures++;
|
|
1637
|
+
if (this.consecutiveFailures >= this.config.failureThreshold) {
|
|
1638
|
+
this._setState("degraded", "Multiple consecutive request failures");
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
reportRequestSuccess() {
|
|
1642
|
+
if (this.consecutiveFailures > 0) {
|
|
1643
|
+
this.consecutiveFailures = 0;
|
|
1644
|
+
if (this.state === "degraded") {
|
|
1645
|
+
this._setState("online", "Requests succeeding again");
|
|
1491
1646
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
_detectInitialState() {
|
|
1650
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
1651
|
+
this.state = "offline";
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
_handleOnline = () => {
|
|
1655
|
+
this.consecutiveFailures = 0;
|
|
1656
|
+
this._setState("online", "Browser online event");
|
|
1494
1657
|
};
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
newTotal: result.newTotal
|
|
1658
|
+
_handleOffline = () => {
|
|
1659
|
+
this._setState("offline", "Browser offline event");
|
|
1660
|
+
};
|
|
1661
|
+
_startHeartbeat() {
|
|
1662
|
+
this._performHeartbeat();
|
|
1663
|
+
this.heartbeatInterval = setInterval(() => {
|
|
1664
|
+
this._performHeartbeat();
|
|
1665
|
+
}, this.config.heartbeatInterval);
|
|
1666
|
+
}
|
|
1667
|
+
async _performHeartbeat() {
|
|
1668
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
try {
|
|
1672
|
+
const controller = new AbortController;
|
|
1673
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.heartbeatTimeout);
|
|
1674
|
+
const response = await fetch(`${this.config.baseUrl}/ping`, {
|
|
1675
|
+
method: "GET",
|
|
1676
|
+
signal: controller.signal,
|
|
1677
|
+
cache: "no-store"
|
|
1516
1678
|
});
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
}
|
|
1523
|
-
const creditsItemId = await getCreditsItemId();
|
|
1524
|
-
const result = await client["request"]("/inventory/remove", "POST", {
|
|
1525
|
-
body: {
|
|
1526
|
-
itemId: creditsItemId,
|
|
1527
|
-
qty: amount
|
|
1679
|
+
clearTimeout(timeoutId);
|
|
1680
|
+
if (response.ok) {
|
|
1681
|
+
this.consecutiveFailures = 0;
|
|
1682
|
+
if (this.state !== "online") {
|
|
1683
|
+
this._setState("online", "Heartbeat successful");
|
|
1528
1684
|
}
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
});
|
|
1535
|
-
return result.newTotal;
|
|
1685
|
+
} else {
|
|
1686
|
+
this._handleHeartbeatFailure("Heartbeat returned non-OK status");
|
|
1687
|
+
}
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
this._handleHeartbeatFailure(error instanceof Error ? error.message : "Heartbeat failed");
|
|
1536
1690
|
}
|
|
1537
|
-
}
|
|
1691
|
+
}
|
|
1692
|
+
_handleHeartbeatFailure(reason) {
|
|
1693
|
+
this.consecutiveFailures++;
|
|
1694
|
+
if (this.consecutiveFailures >= this.config.failureThreshold) {
|
|
1695
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
1696
|
+
this._setState("offline", reason);
|
|
1697
|
+
} else {
|
|
1698
|
+
this._setState("degraded", reason);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
_setState(newState, reason) {
|
|
1703
|
+
if (this.state === newState)
|
|
1704
|
+
return;
|
|
1705
|
+
const oldState = this.state;
|
|
1706
|
+
this.state = newState;
|
|
1707
|
+
console.debug(`[ConnectionMonitor] ${oldState} → ${newState}: ${reason}`);
|
|
1708
|
+
this.callbacks.forEach((callback) => {
|
|
1709
|
+
try {
|
|
1710
|
+
callback(newState, reason);
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
console.error("[ConnectionMonitor] Error in callback:", error);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1538
1716
|
}
|
|
1539
|
-
// src/
|
|
1540
|
-
function
|
|
1541
|
-
return {
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1717
|
+
// src/core/connection/utils.ts
|
|
1718
|
+
function createDisplayAlert(authContext) {
|
|
1719
|
+
return (message, options) => {
|
|
1720
|
+
if (authContext?.isInIframe && typeof window !== "undefined" && window.parent !== window) {
|
|
1721
|
+
window.parent.postMessage({
|
|
1722
|
+
type: "PLAYCADEMY_DISPLAY_ALERT",
|
|
1723
|
+
message,
|
|
1724
|
+
options
|
|
1725
|
+
}, "*");
|
|
1726
|
+
} else {
|
|
1727
|
+
const prefix = options?.type === "error" ? "❌" : options?.type === "warning" ? "⚠️" : "ℹ️";
|
|
1728
|
+
console.log(`${prefix} ${message}`);
|
|
1549
1729
|
}
|
|
1550
1730
|
};
|
|
1551
1731
|
}
|
|
1552
|
-
// src/namespaces/game/realtime.client.ts
|
|
1553
|
-
var CLOSE_CODES = {
|
|
1554
|
-
NORMAL_CLOSURE: 1000,
|
|
1555
|
-
TOKEN_REFRESH: 4000
|
|
1556
|
-
};
|
|
1557
1732
|
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
this.
|
|
1569
|
-
this.
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
}
|
|
1573
|
-
async connect() {
|
|
1574
|
-
try {
|
|
1575
|
-
const token = await this.getToken();
|
|
1576
|
-
let wsBase;
|
|
1577
|
-
if (/^ws(s)?:\/\//.test(this.baseUrl)) {
|
|
1578
|
-
wsBase = this.baseUrl;
|
|
1579
|
-
} else if (/^http(s)?:\/\//.test(this.baseUrl)) {
|
|
1580
|
-
wsBase = this.baseUrl.replace(/^http/, "ws");
|
|
1581
|
-
} else {
|
|
1582
|
-
const isBrowser2 = typeof window !== "undefined";
|
|
1583
|
-
if (isBrowser2) {
|
|
1584
|
-
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
1585
|
-
wsBase = `${proto}://${window.location.host}${this.baseUrl}`;
|
|
1586
|
-
} else {
|
|
1587
|
-
wsBase = `ws://${this.baseUrl.replace(/^\//, "")}`;
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
const url = new URL(wsBase);
|
|
1591
|
-
url.searchParams.set("token", token);
|
|
1592
|
-
url.searchParams.set("c", this._channelName);
|
|
1593
|
-
this.ws = new WebSocket(url);
|
|
1594
|
-
this.setupEventHandlers();
|
|
1595
|
-
this.setupTokenRefreshListener();
|
|
1596
|
-
await new Promise((resolve, reject) => {
|
|
1597
|
-
if (!this.ws) {
|
|
1598
|
-
reject(new Error("WebSocket creation failed"));
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
const onOpen = () => {
|
|
1602
|
-
this.ws?.removeEventListener("open", onOpen);
|
|
1603
|
-
this.ws?.removeEventListener("error", onError);
|
|
1604
|
-
resolve();
|
|
1605
|
-
};
|
|
1606
|
-
const onError = (event) => {
|
|
1607
|
-
this.ws?.removeEventListener("open", onOpen);
|
|
1608
|
-
this.ws?.removeEventListener("error", onError);
|
|
1609
|
-
reject(new Error(`WebSocket connection failed: ${event}`));
|
|
1610
|
-
};
|
|
1611
|
-
this.ws.addEventListener("open", onOpen);
|
|
1612
|
-
this.ws.addEventListener("error", onError);
|
|
1613
|
-
});
|
|
1614
|
-
log.debug("[RealtimeChannelClient] Connected to channel", {
|
|
1615
|
-
gameId: this.gameId,
|
|
1616
|
-
channel: this._channelName
|
|
1617
|
-
});
|
|
1618
|
-
return this;
|
|
1619
|
-
} catch (error) {
|
|
1620
|
-
log.error("[RealtimeChannelClient] Connection failed", {
|
|
1621
|
-
gameId: this.gameId,
|
|
1622
|
-
channel: this._channelName,
|
|
1623
|
-
error
|
|
1624
|
-
});
|
|
1625
|
-
throw error;
|
|
1733
|
+
// src/core/connection/manager.ts
|
|
1734
|
+
class ConnectionManager {
|
|
1735
|
+
monitor;
|
|
1736
|
+
authContext;
|
|
1737
|
+
disconnectHandler;
|
|
1738
|
+
connectionChangeCallback;
|
|
1739
|
+
currentState = "online";
|
|
1740
|
+
additionalDisconnectHandlers = new Set;
|
|
1741
|
+
constructor(config) {
|
|
1742
|
+
this.authContext = config.authContext;
|
|
1743
|
+
this.disconnectHandler = config.onDisconnect;
|
|
1744
|
+
this.connectionChangeCallback = config.onConnectionChange;
|
|
1745
|
+
if (config.authContext?.isInIframe) {
|
|
1746
|
+
this._setupPlatformListener();
|
|
1626
1747
|
}
|
|
1627
1748
|
}
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
return;
|
|
1631
|
-
this.ws.onmessage = (event) => {
|
|
1632
|
-
try {
|
|
1633
|
-
const data = JSON.parse(event.data);
|
|
1634
|
-
this.listeners.forEach((callback) => {
|
|
1635
|
-
try {
|
|
1636
|
-
callback(data);
|
|
1637
|
-
} catch (error) {
|
|
1638
|
-
log.warn("[RealtimeChannelClient] Message listener error", {
|
|
1639
|
-
channel: this._channelName,
|
|
1640
|
-
error
|
|
1641
|
-
});
|
|
1642
|
-
}
|
|
1643
|
-
});
|
|
1644
|
-
} catch (error) {
|
|
1645
|
-
log.warn("[RealtimeChannelClient] Failed to parse message", {
|
|
1646
|
-
channel: this._channelName,
|
|
1647
|
-
message: event.data,
|
|
1648
|
-
error
|
|
1649
|
-
});
|
|
1650
|
-
}
|
|
1651
|
-
};
|
|
1652
|
-
this.ws.onclose = (event) => {
|
|
1653
|
-
log.debug("[RealtimeChannelClient] Connection closed", {
|
|
1654
|
-
channel: this._channelName,
|
|
1655
|
-
code: event.code,
|
|
1656
|
-
reason: event.reason,
|
|
1657
|
-
wasClean: event.wasClean
|
|
1658
|
-
});
|
|
1659
|
-
if (!this.isClosing && event.code !== CLOSE_CODES.TOKEN_REFRESH) {
|
|
1660
|
-
log.warn("[RealtimeChannelClient] Unexpected disconnection", {
|
|
1661
|
-
channel: this._channelName,
|
|
1662
|
-
code: event.code,
|
|
1663
|
-
reason: event.reason
|
|
1664
|
-
});
|
|
1665
|
-
}
|
|
1666
|
-
};
|
|
1667
|
-
this.ws.onerror = (event) => {
|
|
1668
|
-
log.error("[RealtimeChannelClient] WebSocket error", {
|
|
1669
|
-
channel: this._channelName,
|
|
1670
|
-
event
|
|
1671
|
-
});
|
|
1672
|
-
};
|
|
1749
|
+
getState() {
|
|
1750
|
+
return this.monitor?.getState() ?? this.currentState;
|
|
1673
1751
|
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
|
|
1752
|
+
async checkNow() {
|
|
1753
|
+
if (!this.monitor) {
|
|
1754
|
+
return this.currentState;
|
|
1755
|
+
}
|
|
1756
|
+
return await this.monitor.checkNow();
|
|
1757
|
+
}
|
|
1758
|
+
reportRequestSuccess() {
|
|
1759
|
+
this.monitor?.reportRequestSuccess();
|
|
1760
|
+
}
|
|
1761
|
+
reportRequestFailure(error) {
|
|
1762
|
+
this.monitor?.reportRequestFailure(error);
|
|
1763
|
+
}
|
|
1764
|
+
onDisconnect(callback) {
|
|
1765
|
+
this.additionalDisconnectHandlers.add(callback);
|
|
1766
|
+
return () => {
|
|
1767
|
+
this.additionalDisconnectHandlers.delete(callback);
|
|
1691
1768
|
};
|
|
1692
1769
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
this.ws.close(CLOSE_CODES.TOKEN_REFRESH, "token_refresh");
|
|
1696
|
-
await new Promise((resolve) => {
|
|
1697
|
-
const checkClosed = () => {
|
|
1698
|
-
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
|
1699
|
-
resolve();
|
|
1700
|
-
} else {
|
|
1701
|
-
setTimeout(checkClosed, 10);
|
|
1702
|
-
}
|
|
1703
|
-
};
|
|
1704
|
-
checkClosed();
|
|
1705
|
-
});
|
|
1706
|
-
}
|
|
1707
|
-
await this.connect();
|
|
1770
|
+
stop() {
|
|
1771
|
+
this.monitor?.stop();
|
|
1708
1772
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1773
|
+
_setupPlatformListener() {
|
|
1774
|
+
messaging.listen("PLAYCADEMY_CONNECTION_STATE" /* CONNECTION_STATE */, ({ state, reason }) => {
|
|
1775
|
+
this.currentState = state;
|
|
1776
|
+
this._handleConnectionChange(state, reason);
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
_handleConnectionChange(state, reason) {
|
|
1780
|
+
this.connectionChangeCallback?.(state, reason);
|
|
1781
|
+
if (state === "offline" || state === "degraded") {
|
|
1782
|
+
const context = {
|
|
1783
|
+
state,
|
|
1784
|
+
reason,
|
|
1785
|
+
timestamp: Date.now(),
|
|
1786
|
+
displayAlert: createDisplayAlert(this.authContext)
|
|
1787
|
+
};
|
|
1788
|
+
if (this.disconnectHandler) {
|
|
1789
|
+
this.disconnectHandler(context);
|
|
1720
1790
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
channel: this._channelName,
|
|
1724
|
-
readyState: this.ws?.readyState
|
|
1791
|
+
this.additionalDisconnectHandlers.forEach((handler) => {
|
|
1792
|
+
handler(context);
|
|
1725
1793
|
});
|
|
1726
1794
|
}
|
|
1727
1795
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1796
|
+
}
|
|
1797
|
+
// src/core/request.ts
|
|
1798
|
+
function checkDevWarnings(data) {
|
|
1799
|
+
if (!data || typeof data !== "object")
|
|
1800
|
+
return;
|
|
1801
|
+
const response = data;
|
|
1802
|
+
const warningType = response.__playcademyDevWarning;
|
|
1803
|
+
if (!warningType)
|
|
1804
|
+
return;
|
|
1805
|
+
switch (warningType) {
|
|
1806
|
+
case "timeback-not-configured":
|
|
1807
|
+
console.warn("%c⚠️ TimeBack Not Configured", "background: #f59e0b; color: white; padding: 6px 12px; border-radius: 4px; font-weight: bold; font-size: 13px");
|
|
1808
|
+
console.log("%cTimeBack is configured in playcademy.config.js but the sandbox does not have TimeBack credentials.", "color: #f59e0b; font-weight: 500");
|
|
1809
|
+
console.log("To test TimeBack locally:");
|
|
1810
|
+
console.log(" Set the following environment variables:");
|
|
1811
|
+
console.log(" • %cTIMEBACK_ONEROSTER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
1812
|
+
console.log(" • %cTIMEBACK_CALIPER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
1813
|
+
console.log(" • %cTIMEBACK_API_CLIENT_ID/SECRET", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
1814
|
+
console.log(" Or deploy your game: %cplaycademy deploy", "color: #10b981; font-weight: 600; font-family: monospace");
|
|
1815
|
+
console.log(" Or wait for %c@superbuilders/timeback-local%c (coming soon)", "color: #8b5cf6; font-weight: 600; font-family: monospace", "color: inherit");
|
|
1816
|
+
break;
|
|
1817
|
+
default:
|
|
1818
|
+
console.warn(`[Playcademy Dev Warning] ${warningType}`);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
function prepareRequestBody(body, headers) {
|
|
1822
|
+
if (body instanceof FormData) {
|
|
1823
|
+
return body;
|
|
1731
1824
|
}
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
this.tokenRefreshUnsubscribe();
|
|
1736
|
-
this.tokenRefreshUnsubscribe = undefined;
|
|
1737
|
-
}
|
|
1738
|
-
if (this.ws) {
|
|
1739
|
-
this.ws.close(CLOSE_CODES.NORMAL_CLOSURE, "client_close");
|
|
1740
|
-
this.ws = undefined;
|
|
1825
|
+
if (body instanceof ArrayBuffer || body instanceof Blob || ArrayBuffer.isView(body)) {
|
|
1826
|
+
if (!headers["Content-Type"]) {
|
|
1827
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
1741
1828
|
}
|
|
1742
|
-
|
|
1743
|
-
log.debug("[RealtimeChannelClient] Channel closed", {
|
|
1744
|
-
channel: this._channelName
|
|
1745
|
-
});
|
|
1746
|
-
}
|
|
1747
|
-
get channelName() {
|
|
1748
|
-
return this._channelName;
|
|
1829
|
+
return body;
|
|
1749
1830
|
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1831
|
+
if (body !== undefined && body !== null) {
|
|
1832
|
+
headers["Content-Type"] = "application/json";
|
|
1833
|
+
return JSON.stringify(body);
|
|
1752
1834
|
}
|
|
1835
|
+
return;
|
|
1753
1836
|
}
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1837
|
+
async function request({
|
|
1838
|
+
path,
|
|
1839
|
+
baseUrl,
|
|
1840
|
+
method = "GET",
|
|
1841
|
+
body,
|
|
1842
|
+
extraHeaders = {},
|
|
1843
|
+
raw = false
|
|
1844
|
+
}) {
|
|
1845
|
+
const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
|
|
1846
|
+
const headers = { ...extraHeaders };
|
|
1847
|
+
const payload = prepareRequestBody(body, headers);
|
|
1848
|
+
const res = await fetch(url, {
|
|
1849
|
+
method,
|
|
1850
|
+
headers,
|
|
1851
|
+
body: payload,
|
|
1852
|
+
credentials: "omit"
|
|
1853
|
+
});
|
|
1854
|
+
if (raw) {
|
|
1855
|
+
return res;
|
|
1856
|
+
}
|
|
1857
|
+
if (!res.ok) {
|
|
1858
|
+
const clonedRes = res.clone();
|
|
1859
|
+
const errorBody = await clonedRes.json().catch(() => clonedRes.text().catch(() => {
|
|
1860
|
+
return;
|
|
1861
|
+
})) ?? undefined;
|
|
1862
|
+
throw ApiError.fromResponse(res.status, res.statusText, errorBody);
|
|
1863
|
+
}
|
|
1864
|
+
if (res.status === 204)
|
|
1865
|
+
return;
|
|
1866
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1867
|
+
if (contentType.includes("application/json")) {
|
|
1868
|
+
try {
|
|
1869
|
+
const parsed = await res.json();
|
|
1870
|
+
checkDevWarnings(parsed);
|
|
1871
|
+
return parsed;
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
if (err instanceof SyntaxError)
|
|
1874
|
+
return;
|
|
1875
|
+
throw err;
|
|
1777
1876
|
}
|
|
1778
|
-
}
|
|
1877
|
+
}
|
|
1878
|
+
const rawText = await res.text().catch(() => "");
|
|
1879
|
+
return rawText && rawText.length > 0 ? rawText : undefined;
|
|
1779
1880
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
metadata,
|
|
1788
|
-
pausedTime: 0,
|
|
1789
|
-
pauseStartTime: null
|
|
1790
|
-
};
|
|
1791
|
-
},
|
|
1792
|
-
pauseActivity: () => {
|
|
1793
|
-
if (!currentActivity) {
|
|
1794
|
-
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
1795
|
-
}
|
|
1796
|
-
if (currentActivity.pauseStartTime !== null) {
|
|
1797
|
-
throw new Error("Activity is already paused.");
|
|
1798
|
-
}
|
|
1799
|
-
currentActivity.pauseStartTime = Date.now();
|
|
1800
|
-
},
|
|
1801
|
-
resumeActivity: () => {
|
|
1802
|
-
if (!currentActivity) {
|
|
1803
|
-
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
1804
|
-
}
|
|
1805
|
-
if (currentActivity.pauseStartTime === null) {
|
|
1806
|
-
throw new Error("Activity is not paused.");
|
|
1807
|
-
}
|
|
1808
|
-
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
1809
|
-
currentActivity.pausedTime += pauseDuration;
|
|
1810
|
-
currentActivity.pauseStartTime = null;
|
|
1811
|
-
},
|
|
1812
|
-
endActivity: async (data) => {
|
|
1813
|
-
if (!currentActivity) {
|
|
1814
|
-
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
1815
|
-
}
|
|
1816
|
-
if (currentActivity.pauseStartTime !== null) {
|
|
1817
|
-
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
1818
|
-
currentActivity.pausedTime += pauseDuration;
|
|
1819
|
-
currentActivity.pauseStartTime = null;
|
|
1820
|
-
}
|
|
1821
|
-
const endTime = Date.now();
|
|
1822
|
-
const totalElapsed = endTime - currentActivity.startTime;
|
|
1823
|
-
const activeTime = totalElapsed - currentActivity.pausedTime;
|
|
1824
|
-
const durationSeconds = Math.floor(activeTime / 1000);
|
|
1825
|
-
const { correctQuestions, totalQuestions } = data;
|
|
1826
|
-
const request2 = {
|
|
1827
|
-
activityData: currentActivity.metadata,
|
|
1828
|
-
scoreData: {
|
|
1829
|
-
correctQuestions,
|
|
1830
|
-
totalQuestions
|
|
1831
|
-
},
|
|
1832
|
-
timingData: {
|
|
1833
|
-
durationSeconds
|
|
1834
|
-
},
|
|
1835
|
-
xpEarned: data.xpAwarded,
|
|
1836
|
-
masteredUnits: data.masteredUnits
|
|
1837
|
-
};
|
|
1838
|
-
try {
|
|
1839
|
-
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request2);
|
|
1840
|
-
currentActivity = null;
|
|
1841
|
-
return response;
|
|
1842
|
-
} catch (error) {
|
|
1843
|
-
currentActivity = null;
|
|
1844
|
-
throw error;
|
|
1845
|
-
}
|
|
1881
|
+
async function fetchManifest(deploymentUrl) {
|
|
1882
|
+
const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
|
|
1883
|
+
try {
|
|
1884
|
+
const response = await fetch(manifestUrl);
|
|
1885
|
+
if (!response.ok) {
|
|
1886
|
+
log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
|
|
1887
|
+
throw new PlaycademyError(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
|
|
1846
1888
|
}
|
|
1847
|
-
|
|
1889
|
+
return await response.json();
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
if (error instanceof PlaycademyError) {
|
|
1892
|
+
throw error;
|
|
1893
|
+
}
|
|
1894
|
+
log.error(`[Playcademy SDK] Error fetching or parsing manifest from ${manifestUrl}:`, {
|
|
1895
|
+
error
|
|
1896
|
+
});
|
|
1897
|
+
throw new PlaycademyError("Failed to load or parse game manifest");
|
|
1898
|
+
}
|
|
1848
1899
|
}
|
|
1849
|
-
|
|
1850
|
-
|
|
1900
|
+
|
|
1901
|
+
// src/clients/base.ts
|
|
1902
|
+
class PlaycademyBaseClient {
|
|
1851
1903
|
baseUrl;
|
|
1852
1904
|
gameUrl;
|
|
1853
1905
|
authStrategy;
|
|
@@ -2025,9 +2077,13 @@ class PlaycademyClient {
|
|
|
2025
2077
|
});
|
|
2026
2078
|
}
|
|
2027
2079
|
}
|
|
2080
|
+
users = createUsersNamespace(this);
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// src/clients/public.ts
|
|
2084
|
+
class PlaycademyClient extends PlaycademyBaseClient {
|
|
2028
2085
|
identity = createIdentityNamespace(this);
|
|
2029
2086
|
runtime = createRuntimeNamespace(this);
|
|
2030
|
-
users = createUsersNamespace(this);
|
|
2031
2087
|
timeback = createTimebackNamespace(this);
|
|
2032
2088
|
credits = createCreditsNamespace(this);
|
|
2033
2089
|
scores = createScoresNamespace(this);
|