@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/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, context);
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/auth/strategies.ts
179
- class ApiKeyAuth {
180
- apiKey;
181
- constructor(apiKey) {
182
- this.apiKey = apiKey;
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 SessionAuth {
196
- sessionToken;
197
- constructor(sessionToken) {
198
- this.sessionToken = sessionToken;
199
- }
200
- getToken() {
201
- return this.sessionToken;
202
- }
203
- getType() {
204
- return "session";
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
- getHeaders() {
207
- return { Authorization: `Bearer ${this.sessionToken}` };
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
- getToken() {
217
- return this.gameToken;
418
+ isClientError() {
419
+ return this.status >= 400 && this.status < 500;
218
420
  }
219
- getType() {
220
- return "gameJwt";
421
+ isServerError() {
422
+ return this.status >= 500;
221
423
  }
222
- getHeaders() {
223
- return { Authorization: `Bearer ${this.gameToken}` };
424
+ isRetryable() {
425
+ return this.isServerError() || this.code === "TOO_MANY_REQUESTS" || this.code === "RATE_LIMITED" || this.code === "TIMEOUT";
224
426
  }
225
427
  }
226
-
227
- class NoAuth {
228
- getToken() {
229
- return null;
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
- getType() {
232
- return "session";
233
- }
234
- getHeaders() {
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
- function createAuthStrategy(token, tokenType) {
239
- if (!token) {
240
- return new NoAuth;
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
- if (tokenType === "apiKey") {
243
- return new ApiKeyAuth(token);
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
- if (tokenType === "session") {
246
- return new SessionAuth(token);
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
- if (tokenType === "gameJwt") {
249
- return new GameJwtAuth(token);
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
- if (token.startsWith("cademy")) {
252
- return new ApiKeyAuth(token);
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 new GameJwtAuth(token);
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/connection/monitor.ts
287
- class ConnectionMonitor {
288
- state = "online";
289
- callbacks = new Set;
290
- heartbeatInterval;
291
- consecutiveFailures = 0;
292
- isMonitoring = false;
293
- config;
294
- constructor(config) {
295
- this.config = {
296
- baseUrl: config.baseUrl,
297
- heartbeatInterval: config.heartbeatInterval ?? 1e4,
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
- if (this.heartbeatInterval) {
326
- clearInterval(this.heartbeatInterval);
327
- this.heartbeatInterval = undefined;
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
- onChange(callback) {
331
- this.callbacks.add(callback);
332
- return () => this.callbacks.delete(callback);
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
- reportRequestSuccess() {
351
- if (this.consecutiveFailures > 0) {
352
- this.consecutiveFailures = 0;
353
- if (this.state === "degraded") {
354
- this._setState("online", "Requests succeeding again");
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
- _detectInitialState() {
359
- if (typeof navigator !== "undefined" && !navigator.onLine) {
360
- this.state = "offline";
361
- }
362
- }
363
- _handleOnline = () => {
364
- this.consecutiveFailures = 0;
365
- this._setState("online", "Browser online event");
366
- };
367
- _handleOffline = () => {
368
- this._setState("offline", "Browser offline event");
369
- };
370
- _startHeartbeat() {
371
- this._performHeartbeat();
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
- } catch (error) {
398
- this._handleHeartbeatFailure(error instanceof Error ? error.message : "Heartbeat failed");
399
- }
400
- }
401
- _handleHeartbeatFailure(reason) {
402
- this.consecutiveFailures++;
403
- if (this.consecutiveFailures >= this.config.failureThreshold) {
404
- if (typeof navigator !== "undefined" && !navigator.onLine) {
405
- this._setState("offline", reason);
406
- } else {
407
- this._setState("degraded", reason);
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
- _setState(newState, reason) {
412
- if (this.state === newState)
413
- return;
414
- const oldState = this.state;
415
- this.state = newState;
416
- console.debug(`[ConnectionMonitor] ${oldState} → ${newState}: ${reason}`);
417
- this.callbacks.forEach((callback) => {
418
- try {
419
- callback(newState, reason);
420
- } catch (error) {
421
- console.error("[ConnectionMonitor] Error in callback:", error);
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
- class PlaycademyMessaging {
446
- listeners = new Map;
447
- send(type, payload, options) {
448
- if (options?.target) {
449
- this.sendViaPostMessage(type, payload, options.target, options.origin || "*");
450
- return;
451
- }
452
- const context = this.getMessagingContext(type);
453
- if (context.shouldUsePostMessage) {
454
- this.sendViaPostMessage(type, payload, context.target, context.origin);
455
- } else {
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
- listen(type, handler) {
460
- const postMessageListener = (event) => {
461
- const messageEvent = event;
462
- if (messageEvent.data?.type === type) {
463
- handler(messageEvent.data.payload || messageEvent.data);
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
- const listenerMap = this.listeners.get(type);
473
- listenerMap.set(handler, {
474
- postMessage: postMessageListener,
475
- customEvent: customEventListener
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
- window.addEventListener("message", postMessageListener);
478
- window.addEventListener(type, customEventListener);
479
- }
480
- unlisten(type, handler) {
481
- const typeListeners = this.listeners.get(type);
482
- if (!typeListeners || !typeListeners.has(handler)) {
483
- return;
484
- }
485
- const listeners = typeListeners.get(handler);
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
- getMessagingContext(eventType) {
494
- const isIframe = typeof window !== "undefined" && window.self !== window.top;
495
- const iframeToParentEvents = [
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
- shouldUsePostMessage,
505
- target: shouldUsePostMessage ? window.parent : undefined,
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
- url(pathOrStrings, ...values) {
1248
- const gameUrl = client["initPayload"]?.gameUrl;
1249
- let path;
1250
- if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
1251
- const strings = pathOrStrings;
1252
- path = strings.reduce((acc, str, i) => {
1253
- return acc + str + (values[i] != null ? String(values[i]) : "");
1254
- }, "");
1255
- } else {
1256
- path = pathOrStrings;
1257
- }
1258
- if (!gameUrl) {
1259
- return path.startsWith("./") ? path : "./" + path;
1260
- }
1261
- const cleanPath = path.startsWith("./") ? path.slice(2) : path;
1262
- return gameUrl + cleanPath;
1263
- },
1264
- fetch: async (path, options) => {
1265
- const gameUrl = client["initPayload"]?.gameUrl;
1266
- if (!gameUrl) {
1267
- const relativePath = path.startsWith("./") ? path : "./" + path;
1268
- return fetch(relativePath, options);
1269
- }
1270
- const cleanPath = path.startsWith("./") ? path.slice(2) : path;
1271
- return fetch(gameUrl + cleanPath, options);
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/permanent-cache.ts
1333
- function createPermanentCache(keyPrefix) {
1321
+ // src/core/cache/ttl-cache.ts
1322
+ function createTTLCache(options) {
1334
1323
  const cache = new Map;
1335
- async function get(key, loader) {
1324
+ const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
1325
+ async function get(key, loader, config) {
1336
1326
  const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1337
- const existing = cache.get(fullKey);
1338
- if (existing)
1339
- return existing;
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, promise);
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 keys() {
1363
- const result = [];
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
- result.push(fullKey.substring(prefixLen));
1374
+ keys.push(fullKey.substring(prefixLen));
1367
1375
  }
1368
- return result;
1376
+ return keys;
1369
1377
  }
1370
- return { get, clear, has, size, keys };
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/users.ts
1374
- function createUsersNamespace(client) {
1375
- const itemIdCache = createPermanentCache("items");
1376
- const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1377
- const resolveItemId = async (identifier) => {
1378
- if (UUID_REGEX.test(identifier))
1379
- return identifier;
1380
- const gameId = client["gameId"];
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
- me: async () => {
1392
- return client["request"]("/users/me", "GET");
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
- inventory: {
1395
- get: async () => client["request"](`/inventory`, "GET"),
1396
- add: async (identifier, qty) => {
1397
- const itemId = await resolveItemId(identifier);
1398
- const res = await client["request"](`/inventory/add`, "POST", { body: { itemId, qty } });
1399
- client["emit"]("inventoryChange", {
1400
- itemId,
1401
- delta: qty,
1402
- newTotal: res.newTotal
1403
- });
1404
- return res;
1405
- },
1406
- remove: async (identifier, qty) => {
1407
- const itemId = await resolveItemId(identifier);
1408
- const res = await client["request"](`/inventory/remove`, "POST", { body: { itemId, qty } });
1409
- client["emit"]("inventoryChange", {
1410
- itemId,
1411
- delta: -qty,
1412
- newTotal: res.newTotal
1413
- });
1414
- return res;
1415
- },
1416
- quantity: async (identifier) => {
1417
- const itemId = await resolveItemId(identifier);
1418
- const inventory = await client["request"](`/inventory`, "GET");
1419
- const item = inventory.find((inv) => inv.item?.id === itemId);
1420
- return item?.quantity ?? 0;
1421
- },
1422
- has: async (identifier, minQuantity = 1) => {
1423
- const itemId = await resolveItemId(identifier);
1424
- const inventory = await client["request"](`/inventory`, "GET");
1425
- const item = inventory.find((inv) => inv.item?.id === itemId);
1426
- const qty = item?.quantity ?? 0;
1427
- return qty >= minQuantity;
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
- // ../constants/src/overworld.ts
1433
- var ITEM_SLUGS = {
1434
- PLAYCADEMY_CREDITS: "PLAYCADEMY_CREDITS",
1435
- PLAYCADEMY_XP: "PLAYCADEMY_XP",
1436
- FOUNDING_MEMBER_BADGE: "FOUNDING_MEMBER_BADGE",
1437
- EARLY_ADOPTER_BADGE: "EARLY_ADOPTER_BADGE",
1438
- FIRST_GAME_BADGE: "FIRST_GAME_BADGE",
1439
- COMMON_SWORD: "COMMON_SWORD",
1440
- SMALL_HEALTH_POTION: "SMALL_HEALTH_POTION",
1441
- SMALL_BACKPACK: "SMALL_BACKPACK",
1442
- LAVA_LAMP: "LAVA_LAMP",
1443
- BOOMBOX: "BOOMBOX",
1444
- CABIN_BED: "CABIN_BED"
1445
- };
1446
- var CURRENCIES = {
1447
- PRIMARY: ITEM_SLUGS.PLAYCADEMY_CREDITS,
1448
- XP: ITEM_SLUGS.PLAYCADEMY_XP
1449
- };
1450
- var BADGES = {
1451
- FOUNDING_MEMBER: ITEM_SLUGS.FOUNDING_MEMBER_BADGE,
1452
- EARLY_ADOPTER: ITEM_SLUGS.EARLY_ADOPTER_BADGE,
1453
- FIRST_GAME: ITEM_SLUGS.FIRST_GAME_BADGE
1454
- };
1455
- // ../constants/src/timeback.ts
1456
- var TIMEBACK_ROUTES = {
1457
- END_ACTIVITY: "/integrations/timeback/end-activity"
1458
- };
1459
- // src/core/cache/singleton-cache.ts
1460
- function createSingletonCache() {
1461
- let cachedValue;
1462
- let hasValue = false;
1463
- async function get(loader) {
1464
- if (hasValue) {
1465
- return cachedValue;
1466
- }
1467
- const value = await loader();
1468
- cachedValue = value;
1469
- hasValue = true;
1470
- return value;
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
- function clear() {
1473
- cachedValue = undefined;
1474
- hasValue = false;
1557
+ }
1558
+ function createAuthStrategy(token, tokenType) {
1559
+ if (!token) {
1560
+ return new NoAuth;
1475
1561
  }
1476
- function has() {
1477
- return hasValue;
1562
+ if (tokenType === "apiKey") {
1563
+ return new ApiKeyAuth(token);
1478
1564
  }
1479
- return { get, clear, has };
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/namespaces/game/credits.ts
1483
- function createCreditsNamespace(client) {
1484
- const creditsIdCache = createSingletonCache();
1485
- const getCreditsItemId = async () => {
1486
- return creditsIdCache.get(async () => {
1487
- const queryParams = new URLSearchParams({ slug: CURRENCIES.PRIMARY });
1488
- const creditsItem = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
1489
- if (!creditsItem || !creditsItem.id) {
1490
- throw new Error("Playcademy Credits item not found in catalog");
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
- return creditsItem.id;
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
- return {
1496
- balance: async () => {
1497
- const inventory = await client["request"]("/inventory", "GET");
1498
- const primaryCurrencyInventoryItem = inventory.find((item) => item.item?.slug === CURRENCIES.PRIMARY);
1499
- return primaryCurrencyInventoryItem?.quantity ?? 0;
1500
- },
1501
- add: async (amount) => {
1502
- if (amount <= 0) {
1503
- throw new Error("Amount must be positive");
1504
- }
1505
- const creditsItemId = await getCreditsItemId();
1506
- const result = await client["request"]("/inventory/add", "POST", {
1507
- body: {
1508
- itemId: creditsItemId,
1509
- qty: amount
1510
- }
1511
- });
1512
- client["emit"]("inventoryChange", {
1513
- itemId: creditsItemId,
1514
- delta: amount,
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
- return result.newTotal;
1518
- },
1519
- spend: async (amount) => {
1520
- if (amount <= 0) {
1521
- throw new Error("Amount must be positive");
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
- client["emit"]("inventoryChange", {
1531
- itemId: creditsItemId,
1532
- delta: -amount,
1533
- newTotal: result.newTotal
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/namespaces/game/scores.ts
1540
- function createScoresNamespace(client) {
1541
- return {
1542
- submit: async (gameId, score, metadata) => {
1543
- return client["request"](`/games/${gameId}/scores`, "POST", {
1544
- body: {
1545
- score,
1546
- metadata
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
- class RealtimeChannelClient {
1559
- gameId;
1560
- _channelName;
1561
- getToken;
1562
- baseUrl;
1563
- ws;
1564
- listeners = new Set;
1565
- isClosing = false;
1566
- tokenRefreshUnsubscribe;
1567
- constructor(gameId, _channelName, getToken, baseUrl) {
1568
- this.gameId = gameId;
1569
- this._channelName = _channelName;
1570
- this.getToken = getToken;
1571
- this.baseUrl = baseUrl;
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
- setupEventHandlers() {
1629
- if (!this.ws)
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
- setupTokenRefreshListener() {
1675
- const tokenRefreshHandler = async ({ token }) => {
1676
- log.debug("[RealtimeChannelClient] Token refresh received, reconnecting", {
1677
- channel: this._channelName
1678
- });
1679
- try {
1680
- await this.reconnectWithNewToken(token);
1681
- } catch (error) {
1682
- log.error("[RealtimeChannelClient] Token refresh reconnection failed", {
1683
- channel: this._channelName,
1684
- error
1685
- });
1686
- }
1687
- };
1688
- messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
1689
- this.tokenRefreshUnsubscribe = () => {
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
- async reconnectWithNewToken(_token) {
1694
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
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
- send(data) {
1710
- if (this.ws?.readyState === WebSocket.OPEN) {
1711
- try {
1712
- const message = JSON.stringify(data);
1713
- this.ws.send(message);
1714
- } catch (error) {
1715
- log.error("[RealtimeChannelClient] Failed to send message", {
1716
- channel: this._channelName,
1717
- error,
1718
- data
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
- } else {
1722
- log.warn("[RealtimeChannelClient] Cannot send message - connection not open", {
1723
- channel: this._channelName,
1724
- readyState: this.ws?.readyState
1791
+ this.additionalDisconnectHandlers.forEach((handler) => {
1792
+ handler(context);
1725
1793
  });
1726
1794
  }
1727
1795
  }
1728
- onMessage(callback) {
1729
- this.listeners.add(callback);
1730
- return () => this.listeners.delete(callback);
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
- close() {
1733
- this.isClosing = true;
1734
- if (this.tokenRefreshUnsubscribe) {
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
- this.listeners.clear();
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
- get isConnected() {
1751
- return this.ws?.readyState === WebSocket.OPEN;
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
- // src/namespaces/game/realtime.ts
1756
- function createRealtimeNamespace(client) {
1757
- return {
1758
- token: {
1759
- get: async () => {
1760
- const endpoint = client["gameId"] ? `/games/${client["gameId"]}/realtime/token` : "/realtime/token";
1761
- return client["request"](endpoint, "POST");
1762
- }
1763
- },
1764
- async open(channel = "default", url) {
1765
- if (!client["gameId"]) {
1766
- throw new Error("gameId is required for realtime channels");
1767
- }
1768
- let wsBaseUrl = url;
1769
- if (!wsBaseUrl && typeof window !== "undefined") {
1770
- const ctx = window.PLAYCADEMY;
1771
- if (ctx?.realtimeUrl) {
1772
- wsBaseUrl = ctx.realtimeUrl;
1773
- }
1774
- }
1775
- const realtimeClient = new RealtimeChannelClient(client["gameId"], channel, () => client.realtime.token.get().then((r) => r.token), wsBaseUrl ?? client.getBaseUrl());
1776
- return realtimeClient.connect();
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
- // src/namespaces/game/timeback.ts
1781
- function createTimebackNamespace(client) {
1782
- let currentActivity = null;
1783
- return {
1784
- startActivity: (metadata) => {
1785
- currentActivity = {
1786
- startTime: Date.now(),
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
- // src/clients/public.ts
1850
- class PlaycademyClient {
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);