@playcademy/sdk 0.1.18 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/index.d.ts +224 -3558
- package/dist/index.js +737 -1826
- package/dist/internal.d.ts +6598 -0
- package/dist/internal.js +3006 -0
- package/dist/server.d.ts +86 -40
- package/dist/server.js +9 -35
- package/dist/types.d.ts +589 -2219
- package/package.json +7 -2
package/dist/internal.js
ADDED
|
@@ -0,0 +1,3006 @@
|
|
|
1
|
+
// src/namespaces/platform/auth.ts
|
|
2
|
+
function createAuthNamespace(client) {
|
|
3
|
+
return {
|
|
4
|
+
login: async (credentials) => {
|
|
5
|
+
try {
|
|
6
|
+
const response = await client["request"]("/auth/sign-in/email", "POST", { body: credentials });
|
|
7
|
+
client.setToken(response.token, "session");
|
|
8
|
+
return {
|
|
9
|
+
success: true,
|
|
10
|
+
token: response.token,
|
|
11
|
+
user: response.user,
|
|
12
|
+
expiresAt: response.expiresAt
|
|
13
|
+
};
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return {
|
|
16
|
+
success: false,
|
|
17
|
+
error: error instanceof Error ? error.message : "Authentication failed"
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
logout: async () => {
|
|
22
|
+
try {
|
|
23
|
+
await client["request"]("/auth/sign-out", "POST");
|
|
24
|
+
} catch {}
|
|
25
|
+
client.setToken(null);
|
|
26
|
+
},
|
|
27
|
+
apiKeys: {
|
|
28
|
+
create: async (options) => {
|
|
29
|
+
return client["request"]("/dev/api-keys", "POST", {
|
|
30
|
+
body: {
|
|
31
|
+
name: options?.name || `SDK Key - ${new Date().toISOString()}`,
|
|
32
|
+
expiresIn: options?.expiresIn !== undefined ? options.expiresIn : null,
|
|
33
|
+
permissions: options?.permissions || {
|
|
34
|
+
games: ["read", "write", "delete"],
|
|
35
|
+
users: ["read:self", "write:self"],
|
|
36
|
+
dev: ["read", "write"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
list: async () => {
|
|
42
|
+
return client["request"]("/auth/api-key/list", "GET");
|
|
43
|
+
},
|
|
44
|
+
revoke: async (keyId) => {
|
|
45
|
+
await client["request"]("/auth/api-key/revoke", "POST", {
|
|
46
|
+
body: { id: keyId }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// src/namespaces/platform/admin.ts
|
|
53
|
+
function createAdminNamespace(client) {
|
|
54
|
+
return {
|
|
55
|
+
games: {
|
|
56
|
+
pauseGame: (gameId) => client["request"](`/admin/games/${gameId}/pause`, "POST"),
|
|
57
|
+
resumeGame: (gameId) => client["request"](`/admin/games/${gameId}/resume`, "POST")
|
|
58
|
+
},
|
|
59
|
+
items: {
|
|
60
|
+
create: (props) => client["request"]("/items", "POST", { body: props }),
|
|
61
|
+
get: (itemId) => client["request"](`/items/${itemId}`, "GET"),
|
|
62
|
+
list: () => client["request"]("/items", "GET"),
|
|
63
|
+
update: (itemId, props) => client["request"](`/items/${itemId}`, "PATCH", { body: props }),
|
|
64
|
+
delete: (itemId) => client["request"](`/items/${itemId}`, "DELETE")
|
|
65
|
+
},
|
|
66
|
+
currencies: {
|
|
67
|
+
create: (props) => client["request"]("/currencies", "POST", { body: props }),
|
|
68
|
+
get: (currencyId) => client["request"](`/currencies/${currencyId}`, "GET"),
|
|
69
|
+
list: () => client["request"]("/currencies", "GET"),
|
|
70
|
+
update: (currencyId, props) => client["request"](`/currencies/${currencyId}`, "PATCH", { body: props }),
|
|
71
|
+
delete: (currencyId) => client["request"](`/currencies/${currencyId}`, "DELETE")
|
|
72
|
+
},
|
|
73
|
+
shopListings: {
|
|
74
|
+
create: (props) => client["request"]("/shop-listings", "POST", { body: props }),
|
|
75
|
+
get: (listingId) => client["request"](`/shop-listings/${listingId}`, "GET"),
|
|
76
|
+
list: () => client["request"]("/shop-listings", "GET"),
|
|
77
|
+
update: (listingId, props) => client["request"](`/shop-listings/${listingId}`, "PATCH", {
|
|
78
|
+
body: props
|
|
79
|
+
}),
|
|
80
|
+
delete: (listingId) => client["request"](`/shop-listings/${listingId}`, "DELETE")
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// src/namespaces/platform/dev.ts
|
|
85
|
+
function createDevNamespace(client) {
|
|
86
|
+
return {
|
|
87
|
+
status: {
|
|
88
|
+
apply: () => client["request"]("/dev/apply", "POST"),
|
|
89
|
+
get: async () => {
|
|
90
|
+
const response = await client["request"]("/dev/status", "GET");
|
|
91
|
+
return response.status;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
games: {
|
|
95
|
+
deploy: async (slug, options) => {
|
|
96
|
+
const { metadata, file, backend, hooks } = options;
|
|
97
|
+
hooks?.onEvent?.({ type: "init" });
|
|
98
|
+
let game;
|
|
99
|
+
if (metadata) {
|
|
100
|
+
game = await client["request"](`/games/${slug}`, "PUT", {
|
|
101
|
+
body: metadata
|
|
102
|
+
});
|
|
103
|
+
if (metadata.gameType === "external" && !file && !backend) {
|
|
104
|
+
return game;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let uploadToken;
|
|
108
|
+
if (file) {
|
|
109
|
+
const fileName = file instanceof File ? file.name : "game.zip";
|
|
110
|
+
const initiateResponse = await client["request"]("/games/uploads/initiate/", "POST", {
|
|
111
|
+
body: {
|
|
112
|
+
fileName,
|
|
113
|
+
gameId: game?.id || slug
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
uploadToken = initiateResponse.uploadToken;
|
|
117
|
+
if (hooks?.onEvent && typeof XMLHttpRequest !== "undefined") {
|
|
118
|
+
await new Promise((resolve, reject) => {
|
|
119
|
+
const xhr = new XMLHttpRequest;
|
|
120
|
+
xhr.open("PUT", initiateResponse.presignedUrl, true);
|
|
121
|
+
const contentType = file.type || "application/octet-stream";
|
|
122
|
+
try {
|
|
123
|
+
xhr.setRequestHeader("Content-Type", contentType);
|
|
124
|
+
} catch {}
|
|
125
|
+
xhr.upload.onprogress = (event) => {
|
|
126
|
+
if (event.lengthComputable) {
|
|
127
|
+
const percent = event.loaded / event.total;
|
|
128
|
+
hooks.onEvent?.({
|
|
129
|
+
type: "s3Progress",
|
|
130
|
+
loaded: event.loaded,
|
|
131
|
+
total: event.total,
|
|
132
|
+
percent
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
xhr.onload = () => {
|
|
137
|
+
if (xhr.status >= 200 && xhr.status < 300)
|
|
138
|
+
resolve();
|
|
139
|
+
else
|
|
140
|
+
reject(new Error(`File upload failed: ${xhr.status} ${xhr.statusText}`));
|
|
141
|
+
};
|
|
142
|
+
xhr.onerror = () => reject(new Error("File upload failed: network error"));
|
|
143
|
+
xhr.send(file);
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
const uploadResponse = await fetch(initiateResponse.presignedUrl, {
|
|
147
|
+
method: "PUT",
|
|
148
|
+
body: file,
|
|
149
|
+
headers: {
|
|
150
|
+
"Content-Type": file.type || "application/octet-stream"
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
if (!uploadResponse.ok) {
|
|
154
|
+
throw new Error(`File upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (uploadToken || backend) {
|
|
159
|
+
const deployUrl = `${client.baseUrl}/games/${slug}/deploy`;
|
|
160
|
+
const authToken = client.getToken();
|
|
161
|
+
const tokenType = client.getTokenType();
|
|
162
|
+
const headers = {
|
|
163
|
+
"Content-Type": "application/json"
|
|
164
|
+
};
|
|
165
|
+
if (authToken) {
|
|
166
|
+
if (tokenType === "apiKey") {
|
|
167
|
+
headers["x-api-key"] = authToken;
|
|
168
|
+
} else {
|
|
169
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const requestBody = {};
|
|
173
|
+
if (uploadToken)
|
|
174
|
+
requestBody.uploadToken = uploadToken;
|
|
175
|
+
if (metadata)
|
|
176
|
+
requestBody.metadata = metadata;
|
|
177
|
+
if (backend) {
|
|
178
|
+
requestBody.code = backend.code;
|
|
179
|
+
requestBody.config = backend.config;
|
|
180
|
+
if (backend.bindings)
|
|
181
|
+
requestBody.bindings = backend.bindings;
|
|
182
|
+
if (backend.schema)
|
|
183
|
+
requestBody.schema = backend.schema;
|
|
184
|
+
if (backend.secrets)
|
|
185
|
+
requestBody.secrets = backend.secrets;
|
|
186
|
+
}
|
|
187
|
+
const finalizeResponse = await fetch(deployUrl, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers,
|
|
190
|
+
body: JSON.stringify(requestBody),
|
|
191
|
+
credentials: "omit"
|
|
192
|
+
});
|
|
193
|
+
if (!finalizeResponse.ok) {
|
|
194
|
+
const errText = await finalizeResponse.text().catch(() => "");
|
|
195
|
+
throw new Error(`Deploy request failed: ${finalizeResponse.status} ${finalizeResponse.statusText}${errText ? ` - ${errText}` : ""}`);
|
|
196
|
+
}
|
|
197
|
+
if (!finalizeResponse.body) {
|
|
198
|
+
throw new Error("Deploy response body missing");
|
|
199
|
+
}
|
|
200
|
+
hooks?.onEvent?.({ type: "finalizeStart" });
|
|
201
|
+
let sawAnyServerEvent = false;
|
|
202
|
+
const reader = finalizeResponse.body.pipeThrough(new TextDecoderStream).getReader();
|
|
203
|
+
let buffer = "";
|
|
204
|
+
while (true) {
|
|
205
|
+
const { done, value } = await reader.read();
|
|
206
|
+
if (done) {
|
|
207
|
+
if (!sawAnyServerEvent) {
|
|
208
|
+
hooks?.onClose?.();
|
|
209
|
+
hooks?.onEvent?.({ type: "close" });
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
buffer += value;
|
|
214
|
+
let eolIndex;
|
|
215
|
+
while ((eolIndex = buffer.indexOf(`
|
|
216
|
+
|
|
217
|
+
`)) >= 0) {
|
|
218
|
+
const message = buffer.slice(0, eolIndex);
|
|
219
|
+
buffer = buffer.slice(eolIndex + 2);
|
|
220
|
+
const eventLine = message.match(/^event: (.*)$/m);
|
|
221
|
+
const dataLine = message.match(/^data: (.*)$/m);
|
|
222
|
+
if (eventLine && dataLine) {
|
|
223
|
+
const eventType = eventLine[1];
|
|
224
|
+
const eventData = JSON.parse(dataLine[1]);
|
|
225
|
+
sawAnyServerEvent = true;
|
|
226
|
+
switch (eventType) {
|
|
227
|
+
case "uploadProgress": {
|
|
228
|
+
const percent = (eventData.value ?? 0) / 100;
|
|
229
|
+
hooks?.onEvent?.({
|
|
230
|
+
type: "finalizeProgress",
|
|
231
|
+
percent,
|
|
232
|
+
currentFileLabel: eventData.currentFileLabel || ""
|
|
233
|
+
});
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "status": {
|
|
237
|
+
if (eventData.message) {
|
|
238
|
+
hooks?.onEvent?.({
|
|
239
|
+
type: "finalizeStatus",
|
|
240
|
+
message: eventData.message
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "complete": {
|
|
246
|
+
reader.cancel();
|
|
247
|
+
return eventData;
|
|
248
|
+
}
|
|
249
|
+
case "error": {
|
|
250
|
+
reader.cancel();
|
|
251
|
+
const status = typeof eventData.status === "number" ? eventData.status : 500;
|
|
252
|
+
const message2 = eventData.message ?? "Deployment error";
|
|
253
|
+
const details = eventData.details;
|
|
254
|
+
throw new ApiError(status, message2, details);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
throw new Error("Deployment completed but no final game data received");
|
|
261
|
+
}
|
|
262
|
+
if (game) {
|
|
263
|
+
return game;
|
|
264
|
+
}
|
|
265
|
+
throw new Error("No deployment actions specified (need metadata, file, or backend)");
|
|
266
|
+
},
|
|
267
|
+
seed: async (slug, code, environment) => {
|
|
268
|
+
return client["request"](`/games/${slug}/seed`, "POST", {
|
|
269
|
+
body: { code, environment }
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
upsert: async (slug, metadata) => client["request"](`/games/${slug}`, "PUT", { body: metadata }),
|
|
273
|
+
delete: (gameId) => client["request"](`/games/${gameId}`, "DELETE"),
|
|
274
|
+
secrets: {
|
|
275
|
+
set: async (slug, secrets) => {
|
|
276
|
+
const result = await client["request"](`/games/${slug}/secrets`, "POST", { body: secrets });
|
|
277
|
+
return result.keys;
|
|
278
|
+
},
|
|
279
|
+
list: async (slug) => {
|
|
280
|
+
const result = await client["request"](`/games/${slug}/secrets`, "GET");
|
|
281
|
+
return result.keys;
|
|
282
|
+
},
|
|
283
|
+
get: async (slug) => {
|
|
284
|
+
const result = await client["request"](`/games/${slug}/secrets/values`, "GET");
|
|
285
|
+
return result.secrets;
|
|
286
|
+
},
|
|
287
|
+
delete: async (slug, key) => {
|
|
288
|
+
await client["request"](`/games/${slug}/secrets/${key}`, "DELETE");
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
database: {
|
|
292
|
+
reset: async (slug, schema) => {
|
|
293
|
+
return client["request"](`/games/${slug}/database/reset`, "POST", {
|
|
294
|
+
body: { schema }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
bucket: {
|
|
299
|
+
list: async (slug, prefix) => {
|
|
300
|
+
const params = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
|
|
301
|
+
const result = await client["request"](`/games/${slug}/bucket${params}`, "GET");
|
|
302
|
+
return result.files;
|
|
303
|
+
},
|
|
304
|
+
get: async (slug, key) => {
|
|
305
|
+
const res = await client["request"](`/games/${slug}/bucket/${encodeURIComponent(key)}`, "GET", { raw: true });
|
|
306
|
+
if (!res.ok) {
|
|
307
|
+
let errorMessage = res.statusText;
|
|
308
|
+
try {
|
|
309
|
+
const errorData = await res.json();
|
|
310
|
+
if (errorData.error) {
|
|
311
|
+
errorMessage = errorData.error;
|
|
312
|
+
} else if (errorData.message) {
|
|
313
|
+
errorMessage = errorData.message;
|
|
314
|
+
}
|
|
315
|
+
} catch {}
|
|
316
|
+
throw new Error(errorMessage);
|
|
317
|
+
}
|
|
318
|
+
return res.arrayBuffer();
|
|
319
|
+
},
|
|
320
|
+
put: async (slug, key, content, contentType) => {
|
|
321
|
+
await client["request"](`/games/${slug}/bucket/${encodeURIComponent(key)}`, "PUT", {
|
|
322
|
+
body: content,
|
|
323
|
+
headers: contentType ? { "Content-Type": contentType } : undefined
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
delete: async (slug, key) => {
|
|
327
|
+
await client["request"](`/games/${slug}/bucket/${encodeURIComponent(key)}`, "DELETE");
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
domains: {
|
|
331
|
+
add: async (slug, hostname) => {
|
|
332
|
+
return client["request"](`/games/${slug}/domains`, "POST", {
|
|
333
|
+
body: { hostname }
|
|
334
|
+
});
|
|
335
|
+
},
|
|
336
|
+
list: async (slug) => {
|
|
337
|
+
const result = await client["request"](`/games/${slug}/domains`, "GET");
|
|
338
|
+
return result.domains;
|
|
339
|
+
},
|
|
340
|
+
status: async (slug, hostname, refresh) => {
|
|
341
|
+
const params = refresh ? "?refresh=true" : "";
|
|
342
|
+
return client["request"](`/games/${slug}/domains/${hostname}${params}`, "GET");
|
|
343
|
+
},
|
|
344
|
+
delete: async (slug, hostname) => {
|
|
345
|
+
await client["request"](`/games/${slug}/domains/${hostname}`, "DELETE");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
items: {
|
|
350
|
+
create: (gameId, slug, itemData) => client["request"](`/games/${gameId}/items`, "POST", {
|
|
351
|
+
body: {
|
|
352
|
+
slug,
|
|
353
|
+
...itemData
|
|
354
|
+
}
|
|
355
|
+
}),
|
|
356
|
+
update: (gameId, itemId, updates) => client["request"](`/games/${gameId}/items/${itemId}`, "PATCH", {
|
|
357
|
+
body: updates
|
|
358
|
+
}),
|
|
359
|
+
list: (gameId) => client["request"](`/games/${gameId}/items`, "GET"),
|
|
360
|
+
get: (gameId, slug) => {
|
|
361
|
+
const queryParams = new URLSearchParams({ slug, gameId });
|
|
362
|
+
return client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
|
|
363
|
+
},
|
|
364
|
+
delete: (gameId, itemId) => client["request"](`/games/${gameId}/items/${itemId}`, "DELETE"),
|
|
365
|
+
shop: {
|
|
366
|
+
create: (gameId, itemId, listingData) => {
|
|
367
|
+
return client["request"](`/games/${gameId}/items/${itemId}/shop-listing`, "POST", { body: listingData });
|
|
368
|
+
},
|
|
369
|
+
get: (gameId, itemId) => {
|
|
370
|
+
return client["request"](`/games/${gameId}/items/${itemId}/shop-listing`, "GET");
|
|
371
|
+
},
|
|
372
|
+
update: (gameId, itemId, updates) => {
|
|
373
|
+
return client["request"](`/games/${gameId}/items/${itemId}/shop-listing`, "PATCH", { body: updates });
|
|
374
|
+
},
|
|
375
|
+
delete: (gameId, itemId) => {
|
|
376
|
+
return client["request"](`/games/${gameId}/items/${itemId}/shop-listing`, "DELETE");
|
|
377
|
+
},
|
|
378
|
+
list: (gameId) => {
|
|
379
|
+
return client["request"](`/games/${gameId}/shop-listings`, "GET");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
// src/core/cache/ttl-cache.ts
|
|
386
|
+
function createTTLCache(options) {
|
|
387
|
+
const cache = new Map;
|
|
388
|
+
const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
|
|
389
|
+
async function get(key, loader, config) {
|
|
390
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
|
|
393
|
+
const force = config?.force || false;
|
|
394
|
+
const skipCache = config?.skipCache || false;
|
|
395
|
+
if (effectiveTTL === 0 || skipCache) {
|
|
396
|
+
return loader();
|
|
397
|
+
}
|
|
398
|
+
if (!force) {
|
|
399
|
+
const cached = cache.get(fullKey);
|
|
400
|
+
if (cached && cached.expiresAt > now) {
|
|
401
|
+
return cached.value;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const promise = loader().catch((error) => {
|
|
405
|
+
cache.delete(fullKey);
|
|
406
|
+
throw error;
|
|
407
|
+
});
|
|
408
|
+
cache.set(fullKey, {
|
|
409
|
+
value: promise,
|
|
410
|
+
expiresAt: now + effectiveTTL
|
|
411
|
+
});
|
|
412
|
+
return promise;
|
|
413
|
+
}
|
|
414
|
+
function clear(key) {
|
|
415
|
+
if (key === undefined) {
|
|
416
|
+
cache.clear();
|
|
417
|
+
onClear?.();
|
|
418
|
+
} else {
|
|
419
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
420
|
+
cache.delete(fullKey);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function size() {
|
|
424
|
+
return cache.size;
|
|
425
|
+
}
|
|
426
|
+
function prune() {
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
for (const [key, entry] of cache.entries()) {
|
|
429
|
+
if (entry.expiresAt <= now) {
|
|
430
|
+
cache.delete(key);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function getKeys() {
|
|
435
|
+
const keys = [];
|
|
436
|
+
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
437
|
+
for (const fullKey of cache.keys()) {
|
|
438
|
+
keys.push(fullKey.substring(prefixLen));
|
|
439
|
+
}
|
|
440
|
+
return keys;
|
|
441
|
+
}
|
|
442
|
+
function has(key) {
|
|
443
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
444
|
+
const cached = cache.get(fullKey);
|
|
445
|
+
if (!cached)
|
|
446
|
+
return false;
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
if (cached.expiresAt <= now) {
|
|
449
|
+
cache.delete(fullKey);
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
return { get, clear, size, prune, getKeys, has };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ../logger/src/index.ts
|
|
458
|
+
var isBrowser = () => {
|
|
459
|
+
const g = globalThis;
|
|
460
|
+
return typeof g.window !== "undefined" && typeof g.document !== "undefined";
|
|
461
|
+
};
|
|
462
|
+
var isProduction = () => {
|
|
463
|
+
return typeof process !== "undefined" && false;
|
|
464
|
+
};
|
|
465
|
+
var isDevelopment = () => {
|
|
466
|
+
return typeof process !== "undefined" && true;
|
|
467
|
+
};
|
|
468
|
+
var isInteractiveTTY = () => {
|
|
469
|
+
return typeof process !== "undefined" && Boolean(process.stdout && process.stdout.isTTY);
|
|
470
|
+
};
|
|
471
|
+
var isSilent = () => {
|
|
472
|
+
return typeof process !== "undefined" && process.env.LOG_SILENT === "true";
|
|
473
|
+
};
|
|
474
|
+
var detectOutputFormat = () => {
|
|
475
|
+
if (isBrowser()) {
|
|
476
|
+
return "browser";
|
|
477
|
+
}
|
|
478
|
+
if (typeof process !== "undefined" && process.env.LOG_FORMAT === "json") {
|
|
479
|
+
return "json-single-line";
|
|
480
|
+
}
|
|
481
|
+
if (typeof process !== "undefined" && process.env.LOG_PRETTY === "true" && isDevelopment()) {
|
|
482
|
+
return "json-pretty";
|
|
483
|
+
}
|
|
484
|
+
const colorPreference = typeof process !== "undefined" ? (process.env.LOG_COLOR ?? "auto").toLowerCase() : "auto";
|
|
485
|
+
if (colorPreference === "always" && !isProduction()) {
|
|
486
|
+
return "color-tty";
|
|
487
|
+
}
|
|
488
|
+
if (colorPreference === "never") {
|
|
489
|
+
return "json-single-line";
|
|
490
|
+
}
|
|
491
|
+
if (isProduction()) {
|
|
492
|
+
return "json-single-line";
|
|
493
|
+
}
|
|
494
|
+
if (isDevelopment() && isInteractiveTTY()) {
|
|
495
|
+
return "color-tty";
|
|
496
|
+
}
|
|
497
|
+
return "json-single-line";
|
|
498
|
+
};
|
|
499
|
+
var colors = {
|
|
500
|
+
reset: "\x1B[0m",
|
|
501
|
+
dim: "\x1B[2m",
|
|
502
|
+
red: "\x1B[31m",
|
|
503
|
+
yellow: "\x1B[33m",
|
|
504
|
+
blue: "\x1B[34m",
|
|
505
|
+
cyan: "\x1B[36m",
|
|
506
|
+
gray: "\x1B[90m"
|
|
507
|
+
};
|
|
508
|
+
var getLevelColor = (level) => {
|
|
509
|
+
switch (level) {
|
|
510
|
+
case "debug":
|
|
511
|
+
return colors.blue;
|
|
512
|
+
case "info":
|
|
513
|
+
return colors.cyan;
|
|
514
|
+
case "warn":
|
|
515
|
+
return colors.yellow;
|
|
516
|
+
case "error":
|
|
517
|
+
return colors.red;
|
|
518
|
+
default:
|
|
519
|
+
return colors.reset;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
var formatBrowserOutput = (level, message, context) => {
|
|
523
|
+
const timestamp = new Date().toISOString();
|
|
524
|
+
const levelUpper = level.toUpperCase();
|
|
525
|
+
const consoleMethod = getConsoleMethod(level);
|
|
526
|
+
if (context && Object.keys(context).length > 0) {
|
|
527
|
+
consoleMethod(`[${timestamp}] ${levelUpper}`, message, context);
|
|
528
|
+
} else {
|
|
529
|
+
consoleMethod(`[${timestamp}] ${levelUpper}`, message);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
var formatColorTTY = (level, message, context) => {
|
|
533
|
+
const timestamp = new Date().toISOString();
|
|
534
|
+
const levelColor = getLevelColor(level);
|
|
535
|
+
const levelUpper = level.toUpperCase().padEnd(5);
|
|
536
|
+
const consoleMethod = getConsoleMethod(level);
|
|
537
|
+
const coloredPrefix = `${colors.dim}[${timestamp}]${colors.reset} ${levelColor}${levelUpper}${colors.reset}`;
|
|
538
|
+
if (context && Object.keys(context).length > 0) {
|
|
539
|
+
consoleMethod(`${coloredPrefix} ${message}`, context);
|
|
540
|
+
} else {
|
|
541
|
+
consoleMethod(`${coloredPrefix} ${message}`);
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
var formatJSONSingleLine = (level, message, context) => {
|
|
545
|
+
const timestamp = new Date().toISOString();
|
|
546
|
+
const logEntry = {
|
|
547
|
+
timestamp,
|
|
548
|
+
level: level.toUpperCase(),
|
|
549
|
+
message,
|
|
550
|
+
...context && Object.keys(context).length > 0 && { context }
|
|
551
|
+
};
|
|
552
|
+
const consoleMethod = getConsoleMethod(level);
|
|
553
|
+
consoleMethod(JSON.stringify(logEntry));
|
|
554
|
+
};
|
|
555
|
+
var formatJSONPretty = (level, message, context) => {
|
|
556
|
+
const timestamp = new Date().toISOString();
|
|
557
|
+
const logEntry = {
|
|
558
|
+
timestamp,
|
|
559
|
+
level: level.toUpperCase(),
|
|
560
|
+
message,
|
|
561
|
+
...context && Object.keys(context).length > 0 && { context }
|
|
562
|
+
};
|
|
563
|
+
const consoleMethod = getConsoleMethod(level);
|
|
564
|
+
consoleMethod(JSON.stringify(logEntry, null, 2));
|
|
565
|
+
};
|
|
566
|
+
var getConsoleMethod = (level) => {
|
|
567
|
+
switch (level) {
|
|
568
|
+
case "debug":
|
|
569
|
+
return console.debug;
|
|
570
|
+
case "info":
|
|
571
|
+
return console.info;
|
|
572
|
+
case "warn":
|
|
573
|
+
return console.warn;
|
|
574
|
+
case "error":
|
|
575
|
+
return console.error;
|
|
576
|
+
default:
|
|
577
|
+
return console.log;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
var levelPriority = {
|
|
581
|
+
debug: 0,
|
|
582
|
+
info: 1,
|
|
583
|
+
warn: 2,
|
|
584
|
+
error: 3
|
|
585
|
+
};
|
|
586
|
+
var getMinimumLogLevel = () => {
|
|
587
|
+
const envLevel = typeof process !== "undefined" ? (process.env.LOG_LEVEL ?? "").toLowerCase() : "";
|
|
588
|
+
if (envLevel && ["debug", "info", "warn", "error"].includes(envLevel)) {
|
|
589
|
+
return envLevel;
|
|
590
|
+
}
|
|
591
|
+
return isProduction() ? "info" : "debug";
|
|
592
|
+
};
|
|
593
|
+
var shouldLog = (level) => {
|
|
594
|
+
if (isSilent())
|
|
595
|
+
return false;
|
|
596
|
+
const minLevel = getMinimumLogLevel();
|
|
597
|
+
return levelPriority[level] >= levelPriority[minLevel];
|
|
598
|
+
};
|
|
599
|
+
var customHandler;
|
|
600
|
+
var performLog = (level, message, context) => {
|
|
601
|
+
if (!shouldLog(level))
|
|
602
|
+
return;
|
|
603
|
+
if (customHandler) {
|
|
604
|
+
customHandler(level, message, context);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const outputFormat = detectOutputFormat();
|
|
608
|
+
switch (outputFormat) {
|
|
609
|
+
case "browser":
|
|
610
|
+
formatBrowserOutput(level, message, context);
|
|
611
|
+
break;
|
|
612
|
+
case "color-tty":
|
|
613
|
+
formatColorTTY(level, message, context);
|
|
614
|
+
break;
|
|
615
|
+
case "json-single-line":
|
|
616
|
+
formatJSONSingleLine(level, message, context);
|
|
617
|
+
break;
|
|
618
|
+
case "json-pretty":
|
|
619
|
+
formatJSONPretty(level, message, context);
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
var createLogger = () => {
|
|
624
|
+
return {
|
|
625
|
+
debug: (message, context) => performLog("debug", message, context),
|
|
626
|
+
info: (message, context) => performLog("info", message, context),
|
|
627
|
+
warn: (message, context) => performLog("warn", message, context),
|
|
628
|
+
error: (message, context) => performLog("error", message, context),
|
|
629
|
+
log: performLog
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
var log = createLogger();
|
|
633
|
+
|
|
634
|
+
// src/core/errors.ts
|
|
635
|
+
class PlaycademyError extends Error {
|
|
636
|
+
constructor(message) {
|
|
637
|
+
super(message);
|
|
638
|
+
this.name = "PlaycademyError";
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
class ApiError extends Error {
|
|
643
|
+
status;
|
|
644
|
+
details;
|
|
645
|
+
constructor(status, message, details) {
|
|
646
|
+
super(`${status} ${message}`);
|
|
647
|
+
this.status = status;
|
|
648
|
+
this.details = details;
|
|
649
|
+
Object.setPrototypeOf(this, ApiError.prototype);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function extractApiErrorInfo(error) {
|
|
653
|
+
if (!(error instanceof ApiError)) {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
const info = {
|
|
657
|
+
status: error.status,
|
|
658
|
+
statusText: error.message
|
|
659
|
+
};
|
|
660
|
+
if (error.details) {
|
|
661
|
+
info.details = error.details;
|
|
662
|
+
}
|
|
663
|
+
return info;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/core/request.ts
|
|
667
|
+
function checkDevWarnings(data) {
|
|
668
|
+
if (!data || typeof data !== "object")
|
|
669
|
+
return;
|
|
670
|
+
const response = data;
|
|
671
|
+
const warningType = response.__playcademyDevWarning;
|
|
672
|
+
if (!warningType)
|
|
673
|
+
return;
|
|
674
|
+
switch (warningType) {
|
|
675
|
+
case "timeback-not-configured":
|
|
676
|
+
console.warn("%c⚠️ TimeBack Not Configured", "background: #f59e0b; color: white; padding: 6px 12px; border-radius: 4px; font-weight: bold; font-size: 13px");
|
|
677
|
+
console.log("%cTimeBack is configured in playcademy.config.js but the sandbox does not have TimeBack credentials.", "color: #f59e0b; font-weight: 500");
|
|
678
|
+
console.log("To test TimeBack locally:");
|
|
679
|
+
console.log(" Set the following environment variables:");
|
|
680
|
+
console.log(" • %cTIMEBACK_ONEROSTER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
681
|
+
console.log(" • %cTIMEBACK_CALIPER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
682
|
+
console.log(" • %cTIMEBACK_API_CLIENT_ID/SECRET", "color: #0ea5e9; font-weight: 600; font-family: monospace");
|
|
683
|
+
console.log(" Or deploy your game: %cplaycademy deploy", "color: #10b981; font-weight: 600; font-family: monospace");
|
|
684
|
+
console.log(" Or wait for %c@superbuilders/timeback-local%c (coming soon)", "color: #8b5cf6; font-weight: 600; font-family: monospace", "color: inherit");
|
|
685
|
+
break;
|
|
686
|
+
default:
|
|
687
|
+
console.warn(`[Playcademy Dev Warning] ${warningType}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function prepareRequestBody(body, headers) {
|
|
691
|
+
if (body instanceof FormData) {
|
|
692
|
+
return body;
|
|
693
|
+
}
|
|
694
|
+
if (body instanceof ArrayBuffer || body instanceof Blob || ArrayBuffer.isView(body)) {
|
|
695
|
+
if (!headers["Content-Type"]) {
|
|
696
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
697
|
+
}
|
|
698
|
+
return body;
|
|
699
|
+
}
|
|
700
|
+
if (body !== undefined && body !== null) {
|
|
701
|
+
headers["Content-Type"] = "application/json";
|
|
702
|
+
return JSON.stringify(body);
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
async function request({
|
|
707
|
+
path,
|
|
708
|
+
baseUrl,
|
|
709
|
+
method = "GET",
|
|
710
|
+
body,
|
|
711
|
+
extraHeaders = {},
|
|
712
|
+
raw = false
|
|
713
|
+
}) {
|
|
714
|
+
const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
|
|
715
|
+
const headers = { ...extraHeaders };
|
|
716
|
+
const payload = prepareRequestBody(body, headers);
|
|
717
|
+
const res = await fetch(url, {
|
|
718
|
+
method,
|
|
719
|
+
headers,
|
|
720
|
+
body: payload,
|
|
721
|
+
credentials: "omit"
|
|
722
|
+
});
|
|
723
|
+
if (raw) {
|
|
724
|
+
return res;
|
|
725
|
+
}
|
|
726
|
+
if (!res.ok) {
|
|
727
|
+
const clonedRes = res.clone();
|
|
728
|
+
const errorBody = await clonedRes.json().catch(() => clonedRes.text().catch(() => {
|
|
729
|
+
return;
|
|
730
|
+
})) ?? undefined;
|
|
731
|
+
throw new ApiError(res.status, res.statusText, errorBody);
|
|
732
|
+
}
|
|
733
|
+
if (res.status === 204)
|
|
734
|
+
return;
|
|
735
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
736
|
+
if (contentType.includes("application/json")) {
|
|
737
|
+
try {
|
|
738
|
+
const parsed = await res.json();
|
|
739
|
+
checkDevWarnings(parsed);
|
|
740
|
+
return parsed;
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (err instanceof SyntaxError)
|
|
743
|
+
return;
|
|
744
|
+
throw err;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const rawText = await res.text().catch(() => "");
|
|
748
|
+
return rawText && rawText.length > 0 ? rawText : undefined;
|
|
749
|
+
}
|
|
750
|
+
async function fetchManifest(deploymentUrl) {
|
|
751
|
+
const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
|
|
752
|
+
try {
|
|
753
|
+
const response = await fetch(manifestUrl);
|
|
754
|
+
if (!response.ok) {
|
|
755
|
+
log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
|
|
756
|
+
throw new PlaycademyError(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
|
|
757
|
+
}
|
|
758
|
+
return await response.json();
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (error instanceof PlaycademyError) {
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
log.error(`[Playcademy SDK] Error fetching or parsing manifest from ${manifestUrl}:`, {
|
|
764
|
+
error
|
|
765
|
+
});
|
|
766
|
+
throw new PlaycademyError("Failed to load or parse game manifest");
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/namespaces/platform/games.ts
|
|
771
|
+
function createGamesNamespace(client) {
|
|
772
|
+
const gamesListCache = createTTLCache({
|
|
773
|
+
ttl: 60 * 1000,
|
|
774
|
+
keyPrefix: "games.list"
|
|
775
|
+
});
|
|
776
|
+
const gameFetchCache = createTTLCache({
|
|
777
|
+
ttl: 60 * 1000,
|
|
778
|
+
keyPrefix: "games.fetch"
|
|
779
|
+
});
|
|
780
|
+
return {
|
|
781
|
+
fetch: async (gameIdOrSlug, options) => {
|
|
782
|
+
const promise = client["request"](`/games/${gameIdOrSlug}`, "GET");
|
|
783
|
+
return gameFetchCache.get(gameIdOrSlug, async () => {
|
|
784
|
+
const baseGameData = await promise;
|
|
785
|
+
if (baseGameData.gameType === "hosted" && baseGameData.deploymentUrl !== null && baseGameData.deploymentUrl !== "") {
|
|
786
|
+
const manifestData = await fetchManifest(baseGameData.deploymentUrl);
|
|
787
|
+
return { ...baseGameData, manifest: manifestData };
|
|
788
|
+
}
|
|
789
|
+
return baseGameData;
|
|
790
|
+
}, options);
|
|
791
|
+
},
|
|
792
|
+
list: (options) => {
|
|
793
|
+
return gamesListCache.get("all", () => client["request"]("/games", "GET"), options);
|
|
794
|
+
},
|
|
795
|
+
saveState: async (state) => {
|
|
796
|
+
const gameId = client["_ensureGameId"]();
|
|
797
|
+
await client["request"](`/games/${gameId}/state`, "POST", { body: state });
|
|
798
|
+
},
|
|
799
|
+
loadState: async () => {
|
|
800
|
+
const gameId = client["_ensureGameId"]();
|
|
801
|
+
return client["request"](`/games/${gameId}/state`, "GET");
|
|
802
|
+
},
|
|
803
|
+
startSession: async (gameId) => {
|
|
804
|
+
const idToUse = gameId ?? client["_ensureGameId"]();
|
|
805
|
+
return client["request"](`/games/${idToUse}/sessions`, "POST", {});
|
|
806
|
+
},
|
|
807
|
+
endSession: async (sessionId, gameId) => {
|
|
808
|
+
const effectiveGameIdToEnd = gameId ?? client["_ensureGameId"]();
|
|
809
|
+
if (client["internalClientSessionId"] && sessionId === client["internalClientSessionId"] && effectiveGameIdToEnd === client["gameId"]) {
|
|
810
|
+
client["internalClientSessionId"] = undefined;
|
|
811
|
+
}
|
|
812
|
+
await client["request"](`/games/${effectiveGameIdToEnd}/sessions/${sessionId}/end`, "POST");
|
|
813
|
+
},
|
|
814
|
+
token: {
|
|
815
|
+
create: async (gameId, options) => {
|
|
816
|
+
const res = await client["request"](`/games/${gameId}/token`, "POST");
|
|
817
|
+
if (options?.apply) {
|
|
818
|
+
client.setToken(res.token);
|
|
819
|
+
}
|
|
820
|
+
return res;
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
leaderboard: {
|
|
824
|
+
get: async (gameId, options) => {
|
|
825
|
+
const params = new URLSearchParams;
|
|
826
|
+
if (options?.limit)
|
|
827
|
+
params.append("limit", String(options.limit));
|
|
828
|
+
if (options?.offset)
|
|
829
|
+
params.append("offset", String(options.offset));
|
|
830
|
+
const queryString = params.toString();
|
|
831
|
+
const path = queryString ? `/games/${gameId}/leaderboard?${queryString}` : `/games/${gameId}/leaderboard`;
|
|
832
|
+
return client["request"](path, "GET");
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
// src/namespaces/platform/character.ts
|
|
838
|
+
function createCharacterNamespace(client) {
|
|
839
|
+
const componentCache = createTTLCache({
|
|
840
|
+
ttl: 5 * 60 * 1000,
|
|
841
|
+
keyPrefix: "character.components"
|
|
842
|
+
});
|
|
843
|
+
return {
|
|
844
|
+
get: async (userId) => {
|
|
845
|
+
try {
|
|
846
|
+
const path = userId ? `/character/${userId}` : "/character";
|
|
847
|
+
return await client["request"](path, "GET");
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (error instanceof Error) {
|
|
850
|
+
if (error.message.includes("404")) {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
create: async (characterData) => {
|
|
858
|
+
return client["request"]("/character", "POST", { body: characterData });
|
|
859
|
+
},
|
|
860
|
+
update: async (updates) => {
|
|
861
|
+
return client["request"]("/character", "PATCH", { body: updates });
|
|
862
|
+
},
|
|
863
|
+
components: {
|
|
864
|
+
list: async (options) => {
|
|
865
|
+
const cacheKey = options?.level === undefined ? "all" : String(options.level);
|
|
866
|
+
return componentCache.get(cacheKey, async () => {
|
|
867
|
+
const path = options?.level !== undefined ? `/character/components?level=${options.level}` : "/character/components";
|
|
868
|
+
const components = await client["request"](path, "GET");
|
|
869
|
+
return components || [];
|
|
870
|
+
}, options);
|
|
871
|
+
},
|
|
872
|
+
clearCache: (key) => componentCache.clear(key),
|
|
873
|
+
getCacheKeys: () => componentCache.getKeys()
|
|
874
|
+
},
|
|
875
|
+
accessories: {
|
|
876
|
+
equip: async (slot, componentId) => {
|
|
877
|
+
return client["request"]("/character/accessories/equip", "POST", { body: { slot, accessoryComponentId: componentId } });
|
|
878
|
+
},
|
|
879
|
+
remove: async (slot) => {
|
|
880
|
+
return client["request"](`/character/accessories/${slot}`, "DELETE");
|
|
881
|
+
},
|
|
882
|
+
list: async () => {
|
|
883
|
+
const character = await client.character.get();
|
|
884
|
+
return character?.accessories || [];
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
// src/namespaces/platform/achievements.ts
|
|
890
|
+
function createAchievementsNamespace(client) {
|
|
891
|
+
const achievementsListCache = createTTLCache({
|
|
892
|
+
ttl: 5 * 1000,
|
|
893
|
+
keyPrefix: "achievements.list"
|
|
894
|
+
});
|
|
895
|
+
const achievementsHistoryCache = createTTLCache({
|
|
896
|
+
ttl: 5 * 1000,
|
|
897
|
+
keyPrefix: "achievements.history"
|
|
898
|
+
});
|
|
899
|
+
return {
|
|
900
|
+
list: (options) => {
|
|
901
|
+
return achievementsListCache.get("current", () => client["request"]("/achievements/current", "GET"), options);
|
|
902
|
+
},
|
|
903
|
+
history: {
|
|
904
|
+
list: async (queryOptions, cacheOptions) => {
|
|
905
|
+
const params = new URLSearchParams;
|
|
906
|
+
if (queryOptions?.limit)
|
|
907
|
+
params.append("limit", String(queryOptions.limit));
|
|
908
|
+
const qs = params.toString();
|
|
909
|
+
const path = qs ? `/achievements/history?${qs}` : "/achievements/history";
|
|
910
|
+
const cacheKey = qs ? `history-${qs}` : "history";
|
|
911
|
+
return achievementsHistoryCache.get(cacheKey, () => client["request"](path, "GET"), cacheOptions);
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
progress: {
|
|
915
|
+
submit: async (achievementId) => client["request"]("/achievements/progress", "POST", {
|
|
916
|
+
body: { achievementId }
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
// src/namespaces/platform/leaderboard.ts
|
|
922
|
+
function createLeaderboardNamespace(client) {
|
|
923
|
+
return {
|
|
924
|
+
fetch: async (options) => {
|
|
925
|
+
const params = new URLSearchParams({
|
|
926
|
+
timeframe: options?.timeframe || "all_time",
|
|
927
|
+
limit: String(options?.limit || 10),
|
|
928
|
+
offset: String(options?.offset || 0)
|
|
929
|
+
});
|
|
930
|
+
if (options?.gameId) {
|
|
931
|
+
params.append("gameId", options.gameId);
|
|
932
|
+
}
|
|
933
|
+
return client["request"](`/leaderboard?${params}`, "GET");
|
|
934
|
+
},
|
|
935
|
+
getUserRank: async (gameId, userId) => {
|
|
936
|
+
return client["request"](`/games/${gameId}/users/${userId}/rank`, "GET");
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
// src/core/cache/cooldown-cache.ts
|
|
941
|
+
function createCooldownCache(defaultCooldownMs) {
|
|
942
|
+
const lastFetchTime = new Map;
|
|
943
|
+
const pendingRequests = new Map;
|
|
944
|
+
const lastResults = new Map;
|
|
945
|
+
async function get(key, loader, config) {
|
|
946
|
+
const now = Date.now();
|
|
947
|
+
const lastFetch = lastFetchTime.get(key) || 0;
|
|
948
|
+
const timeSinceLastFetch = now - lastFetch;
|
|
949
|
+
const effectiveCooldown = config?.cooldown !== undefined ? config.cooldown : defaultCooldownMs;
|
|
950
|
+
const force = config?.force || false;
|
|
951
|
+
const pending = pendingRequests.get(key);
|
|
952
|
+
if (pending) {
|
|
953
|
+
return pending;
|
|
954
|
+
}
|
|
955
|
+
if (!force && timeSinceLastFetch < effectiveCooldown) {
|
|
956
|
+
const cachedResult = lastResults.get(key);
|
|
957
|
+
if (cachedResult !== undefined) {
|
|
958
|
+
return Promise.resolve(cachedResult);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const promise = loader().then((result) => {
|
|
962
|
+
pendingRequests.delete(key);
|
|
963
|
+
lastFetchTime.set(key, Date.now());
|
|
964
|
+
lastResults.set(key, result);
|
|
965
|
+
return result;
|
|
966
|
+
}).catch((error) => {
|
|
967
|
+
pendingRequests.delete(key);
|
|
968
|
+
throw error;
|
|
969
|
+
});
|
|
970
|
+
pendingRequests.set(key, promise);
|
|
971
|
+
return promise;
|
|
972
|
+
}
|
|
973
|
+
function clear(key) {
|
|
974
|
+
if (key === undefined) {
|
|
975
|
+
lastFetchTime.clear();
|
|
976
|
+
pendingRequests.clear();
|
|
977
|
+
lastResults.clear();
|
|
978
|
+
} else {
|
|
979
|
+
lastFetchTime.delete(key);
|
|
980
|
+
pendingRequests.delete(key);
|
|
981
|
+
lastResults.delete(key);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return { get, clear };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/namespaces/platform/levels.ts
|
|
988
|
+
function createLevelsNamespace(client) {
|
|
989
|
+
const progressCache = createCooldownCache(5000);
|
|
990
|
+
return {
|
|
991
|
+
get: async () => {
|
|
992
|
+
return client["request"]("/users/level", "GET");
|
|
993
|
+
},
|
|
994
|
+
progress: async (options) => {
|
|
995
|
+
return progressCache.get("user-progress", () => client["request"]("/users/level/progress", "GET"), options);
|
|
996
|
+
},
|
|
997
|
+
config: {
|
|
998
|
+
list: async () => {
|
|
999
|
+
return client["request"]("/levels/config", "GET");
|
|
1000
|
+
},
|
|
1001
|
+
get: async (level) => {
|
|
1002
|
+
return client["request"](`/levels/config/${level}`, "GET");
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
// src/namespaces/platform/shop.ts
|
|
1008
|
+
function createShopNamespace(client) {
|
|
1009
|
+
return {
|
|
1010
|
+
view: () => {
|
|
1011
|
+
return client["request"]("/shop/view", "GET");
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
// src/namespaces/platform/notifications.ts
|
|
1016
|
+
function createNotificationsNamespace(client) {
|
|
1017
|
+
const notificationsListCache = createTTLCache({
|
|
1018
|
+
ttl: 5 * 1000,
|
|
1019
|
+
keyPrefix: "notifications.list"
|
|
1020
|
+
});
|
|
1021
|
+
const notificationStatsCache = createTTLCache({
|
|
1022
|
+
ttl: 30 * 1000,
|
|
1023
|
+
keyPrefix: "notifications.stats"
|
|
1024
|
+
});
|
|
1025
|
+
return {
|
|
1026
|
+
list: async (queryOptions, cacheOptions) => {
|
|
1027
|
+
const params = new URLSearchParams;
|
|
1028
|
+
if (queryOptions?.status)
|
|
1029
|
+
params.append("status", queryOptions.status);
|
|
1030
|
+
if (queryOptions?.type)
|
|
1031
|
+
params.append("type", queryOptions.type);
|
|
1032
|
+
if (queryOptions?.limit)
|
|
1033
|
+
params.append("limit", String(queryOptions.limit));
|
|
1034
|
+
if (queryOptions?.offset)
|
|
1035
|
+
params.append("offset", String(queryOptions.offset));
|
|
1036
|
+
const qs = params.toString();
|
|
1037
|
+
const path = qs ? `/notifications?${qs}` : "/notifications";
|
|
1038
|
+
const cacheKey = qs ? `list-${qs}` : "list";
|
|
1039
|
+
return notificationsListCache.get(cacheKey, () => client["request"](path, "GET"), cacheOptions);
|
|
1040
|
+
},
|
|
1041
|
+
markAsSeen: async (notificationId) => {
|
|
1042
|
+
const result = await client["request"](`/notifications/${notificationId}/status`, "PATCH", {
|
|
1043
|
+
body: {
|
|
1044
|
+
id: notificationId,
|
|
1045
|
+
status: "seen"
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
notificationsListCache.clear();
|
|
1049
|
+
return result;
|
|
1050
|
+
},
|
|
1051
|
+
markAsClicked: async (notificationId) => {
|
|
1052
|
+
const result = await client["request"](`/notifications/${notificationId}/status`, "PATCH", {
|
|
1053
|
+
body: {
|
|
1054
|
+
id: notificationId,
|
|
1055
|
+
status: "clicked"
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
notificationsListCache.clear();
|
|
1059
|
+
return result;
|
|
1060
|
+
},
|
|
1061
|
+
dismiss: async (notificationId) => {
|
|
1062
|
+
const result = await client["request"](`/notifications/${notificationId}/status`, "PATCH", {
|
|
1063
|
+
body: {
|
|
1064
|
+
id: notificationId,
|
|
1065
|
+
status: "dismissed"
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
notificationsListCache.clear();
|
|
1069
|
+
return result;
|
|
1070
|
+
},
|
|
1071
|
+
stats: {
|
|
1072
|
+
get: async (queryOptions, cacheOptions) => {
|
|
1073
|
+
const user = await client.users.me();
|
|
1074
|
+
const params = new URLSearchParams;
|
|
1075
|
+
if (queryOptions?.from)
|
|
1076
|
+
params.append("from", queryOptions.from);
|
|
1077
|
+
if (queryOptions?.to)
|
|
1078
|
+
params.append("to", queryOptions.to);
|
|
1079
|
+
const qs = params.toString();
|
|
1080
|
+
const path = qs ? `/notifications/stats/${user.id}?${qs}` : `/notifications/stats/${user.id}`;
|
|
1081
|
+
const cacheKey = qs ? `stats-${qs}` : "stats";
|
|
1082
|
+
return notificationStatsCache.get(cacheKey, () => client["request"](path, "GET"), cacheOptions);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
// src/namespaces/platform/maps.ts
|
|
1088
|
+
function createMapsNamespace(client) {
|
|
1089
|
+
const mapDataCache = createTTLCache({
|
|
1090
|
+
ttl: 5 * 60 * 1000,
|
|
1091
|
+
keyPrefix: "maps.data"
|
|
1092
|
+
});
|
|
1093
|
+
const mapElementsCache = createTTLCache({
|
|
1094
|
+
ttl: 60 * 1000,
|
|
1095
|
+
keyPrefix: "maps.elements"
|
|
1096
|
+
});
|
|
1097
|
+
return {
|
|
1098
|
+
get: (identifier, options) => mapDataCache.get(identifier, () => client["request"](`/maps/${identifier}`, "GET"), options),
|
|
1099
|
+
elements: (mapId, options) => mapElementsCache.get(mapId, () => client["request"](`/map/elements?mapId=${mapId}`, "GET"), options),
|
|
1100
|
+
objects: {
|
|
1101
|
+
list: (mapId) => client["request"](`/maps/${mapId}/objects`, "GET"),
|
|
1102
|
+
create: (mapId, objectData) => client["request"](`/maps/${mapId}/objects`, "POST", {
|
|
1103
|
+
body: objectData
|
|
1104
|
+
}),
|
|
1105
|
+
delete: (mapId, objectId) => client["request"](`/maps/${mapId}/objects/${objectId}`, "DELETE")
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
// src/core/cache/permanent-cache.ts
|
|
1110
|
+
function createPermanentCache(keyPrefix) {
|
|
1111
|
+
const cache = new Map;
|
|
1112
|
+
async function get(key, loader) {
|
|
1113
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1114
|
+
const existing = cache.get(fullKey);
|
|
1115
|
+
if (existing)
|
|
1116
|
+
return existing;
|
|
1117
|
+
const promise = loader().catch((error) => {
|
|
1118
|
+
cache.delete(fullKey);
|
|
1119
|
+
throw error;
|
|
1120
|
+
});
|
|
1121
|
+
cache.set(fullKey, promise);
|
|
1122
|
+
return promise;
|
|
1123
|
+
}
|
|
1124
|
+
function clear(key) {
|
|
1125
|
+
if (key === undefined) {
|
|
1126
|
+
cache.clear();
|
|
1127
|
+
} else {
|
|
1128
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1129
|
+
cache.delete(fullKey);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function has(key) {
|
|
1133
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1134
|
+
return cache.has(fullKey);
|
|
1135
|
+
}
|
|
1136
|
+
function size() {
|
|
1137
|
+
return cache.size;
|
|
1138
|
+
}
|
|
1139
|
+
function keys() {
|
|
1140
|
+
const result = [];
|
|
1141
|
+
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
1142
|
+
for (const fullKey of cache.keys()) {
|
|
1143
|
+
result.push(fullKey.substring(prefixLen));
|
|
1144
|
+
}
|
|
1145
|
+
return result;
|
|
1146
|
+
}
|
|
1147
|
+
return { get, clear, has, size, keys };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/namespaces/platform/sprites.ts
|
|
1151
|
+
function createSpritesNamespace(client) {
|
|
1152
|
+
const templateUrlCache = createPermanentCache("sprite-template-urls");
|
|
1153
|
+
return {
|
|
1154
|
+
templates: {
|
|
1155
|
+
get: async (slug) => {
|
|
1156
|
+
if (!slug)
|
|
1157
|
+
throw new Error("Sprite template slug is required");
|
|
1158
|
+
const templateMeta = await templateUrlCache.get(slug, async () => {
|
|
1159
|
+
return client["request"](`/sprites/templates/${slug}`, "GET");
|
|
1160
|
+
});
|
|
1161
|
+
if (!templateMeta.url) {
|
|
1162
|
+
throw new Error(`Template ${slug} has no URL in database`);
|
|
1163
|
+
}
|
|
1164
|
+
const response = await fetch(templateMeta.url);
|
|
1165
|
+
if (!response.ok) {
|
|
1166
|
+
throw new Error(`Failed to fetch template JSON from ${templateMeta.url}: ${response.statusText}`);
|
|
1167
|
+
}
|
|
1168
|
+
return await response.json();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
// src/namespaces/platform/telemetry.ts
|
|
1174
|
+
function createTelemetryNamespace(client) {
|
|
1175
|
+
return {
|
|
1176
|
+
pushMetrics: (metrics) => client["request"](`/telemetry/metrics`, "POST", { body: metrics })
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
// src/namespaces/game/scores.ts
|
|
1180
|
+
function createScoresNamespace(client) {
|
|
1181
|
+
return {
|
|
1182
|
+
submit: async (gameId, score, metadata) => {
|
|
1183
|
+
return client["request"](`/games/${gameId}/scores`, "POST", {
|
|
1184
|
+
body: {
|
|
1185
|
+
score,
|
|
1186
|
+
metadata
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// src/namespaces/platform/scores.ts
|
|
1194
|
+
function createScoresNamespace2(client) {
|
|
1195
|
+
const publicScores = createScoresNamespace(client);
|
|
1196
|
+
return {
|
|
1197
|
+
...publicScores,
|
|
1198
|
+
getByUser: async (gameId, userId, options) => {
|
|
1199
|
+
const params = new URLSearchParams;
|
|
1200
|
+
if (options?.limit) {
|
|
1201
|
+
params.append("limit", String(options.limit));
|
|
1202
|
+
}
|
|
1203
|
+
const queryString = params.toString();
|
|
1204
|
+
const path = queryString ? `/games/${gameId}/users/${userId}/scores?${queryString}` : `/games/${gameId}/users/${userId}/scores`;
|
|
1205
|
+
const response = await client["request"](path, "GET");
|
|
1206
|
+
return response.map((score) => ({
|
|
1207
|
+
...score,
|
|
1208
|
+
achievedAt: new Date(score.achievedAt)
|
|
1209
|
+
}));
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
// src/namespaces/platform/timeback.ts
|
|
1214
|
+
var NOT_SUPPORTED = "Activity tracking is not available on the platform client. Use the game SDK instead.";
|
|
1215
|
+
function createTimebackNamespace(client) {
|
|
1216
|
+
const enrollmentsCache = createTTLCache({
|
|
1217
|
+
ttl: 30 * 1000,
|
|
1218
|
+
keyPrefix: "platform.timeback.enrollments"
|
|
1219
|
+
});
|
|
1220
|
+
return {
|
|
1221
|
+
startActivity: (_metadata) => {
|
|
1222
|
+
throw new Error(NOT_SUPPORTED);
|
|
1223
|
+
},
|
|
1224
|
+
pauseActivity: () => {
|
|
1225
|
+
throw new Error(NOT_SUPPORTED);
|
|
1226
|
+
},
|
|
1227
|
+
resumeActivity: () => {
|
|
1228
|
+
throw new Error(NOT_SUPPORTED);
|
|
1229
|
+
},
|
|
1230
|
+
endActivity: async (_data) => {
|
|
1231
|
+
throw new Error(NOT_SUPPORTED);
|
|
1232
|
+
},
|
|
1233
|
+
management: {
|
|
1234
|
+
setup: (request2) => {
|
|
1235
|
+
return client["request"]("/timeback/setup", "POST", {
|
|
1236
|
+
body: request2
|
|
1237
|
+
});
|
|
1238
|
+
},
|
|
1239
|
+
verify: (gameId) => {
|
|
1240
|
+
return client["request"](`/timeback/verify/${gameId}`, "GET");
|
|
1241
|
+
},
|
|
1242
|
+
cleanup: (gameId) => {
|
|
1243
|
+
return client["request"](`/timeback/integrations/${gameId}`, "DELETE");
|
|
1244
|
+
},
|
|
1245
|
+
get: (gameId) => {
|
|
1246
|
+
return client["request"](`/timeback/integrations/${gameId}`, "GET");
|
|
1247
|
+
},
|
|
1248
|
+
getConfig: (gameId) => {
|
|
1249
|
+
return client["request"](`/timeback/config/${gameId}`, "GET");
|
|
1250
|
+
}
|
|
1251
|
+
},
|
|
1252
|
+
xp: {
|
|
1253
|
+
today: async (options) => {
|
|
1254
|
+
const params = new URLSearchParams;
|
|
1255
|
+
if (options?.date)
|
|
1256
|
+
params.set("date", options.date);
|
|
1257
|
+
if (options?.timezone)
|
|
1258
|
+
params.set("tz", options.timezone);
|
|
1259
|
+
const query = params.toString();
|
|
1260
|
+
const endpoint = query ? `/timeback/xp/today?${query}` : "/timeback/xp/today";
|
|
1261
|
+
return client["request"](endpoint, "GET");
|
|
1262
|
+
},
|
|
1263
|
+
total: async () => {
|
|
1264
|
+
return client["request"]("/timeback/xp/total", "GET");
|
|
1265
|
+
},
|
|
1266
|
+
history: async (options) => {
|
|
1267
|
+
const params = new URLSearchParams;
|
|
1268
|
+
if (options?.startDate)
|
|
1269
|
+
params.set("startDate", options.startDate);
|
|
1270
|
+
if (options?.endDate)
|
|
1271
|
+
params.set("endDate", options.endDate);
|
|
1272
|
+
const query = params.toString();
|
|
1273
|
+
const endpoint = query ? `/timeback/xp/history?${query}` : "/timeback/xp/history";
|
|
1274
|
+
return client["request"](endpoint, "GET");
|
|
1275
|
+
},
|
|
1276
|
+
summary: async (options) => {
|
|
1277
|
+
const [today, total] = await Promise.all([
|
|
1278
|
+
client["request"]((() => {
|
|
1279
|
+
const params = new URLSearchParams;
|
|
1280
|
+
if (options?.date)
|
|
1281
|
+
params.set("date", options.date);
|
|
1282
|
+
if (options?.timezone)
|
|
1283
|
+
params.set("tz", options.timezone);
|
|
1284
|
+
const query = params.toString();
|
|
1285
|
+
return query ? `/timeback/xp/today?${query}` : "/timeback/xp/today";
|
|
1286
|
+
})(), "GET"),
|
|
1287
|
+
client["request"]("/timeback/xp/total", "GET")
|
|
1288
|
+
]);
|
|
1289
|
+
return { today, total };
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
enrollments: {
|
|
1293
|
+
get: async (timebackId, options) => {
|
|
1294
|
+
return enrollmentsCache.get(timebackId, async () => {
|
|
1295
|
+
const response = await client["request"](`/timeback/enrollments/${timebackId}`, "GET");
|
|
1296
|
+
return response.enrollments;
|
|
1297
|
+
}, options);
|
|
1298
|
+
},
|
|
1299
|
+
clearCache: (timebackId) => {
|
|
1300
|
+
enrollmentsCache.clear(timebackId);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
// src/core/auth/strategies.ts
|
|
1306
|
+
class ApiKeyAuth {
|
|
1307
|
+
apiKey;
|
|
1308
|
+
constructor(apiKey) {
|
|
1309
|
+
this.apiKey = apiKey;
|
|
1310
|
+
}
|
|
1311
|
+
getToken() {
|
|
1312
|
+
return this.apiKey;
|
|
1313
|
+
}
|
|
1314
|
+
getType() {
|
|
1315
|
+
return "apiKey";
|
|
1316
|
+
}
|
|
1317
|
+
getHeaders() {
|
|
1318
|
+
return { "x-api-key": this.apiKey };
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
class SessionAuth {
|
|
1323
|
+
sessionToken;
|
|
1324
|
+
constructor(sessionToken) {
|
|
1325
|
+
this.sessionToken = sessionToken;
|
|
1326
|
+
}
|
|
1327
|
+
getToken() {
|
|
1328
|
+
return this.sessionToken;
|
|
1329
|
+
}
|
|
1330
|
+
getType() {
|
|
1331
|
+
return "session";
|
|
1332
|
+
}
|
|
1333
|
+
getHeaders() {
|
|
1334
|
+
return { Authorization: `Bearer ${this.sessionToken}` };
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
class GameJwtAuth {
|
|
1339
|
+
gameToken;
|
|
1340
|
+
constructor(gameToken) {
|
|
1341
|
+
this.gameToken = gameToken;
|
|
1342
|
+
}
|
|
1343
|
+
getToken() {
|
|
1344
|
+
return this.gameToken;
|
|
1345
|
+
}
|
|
1346
|
+
getType() {
|
|
1347
|
+
return "gameJwt";
|
|
1348
|
+
}
|
|
1349
|
+
getHeaders() {
|
|
1350
|
+
return { Authorization: `Bearer ${this.gameToken}` };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
class NoAuth {
|
|
1355
|
+
getToken() {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
getType() {
|
|
1359
|
+
return "session";
|
|
1360
|
+
}
|
|
1361
|
+
getHeaders() {
|
|
1362
|
+
return {};
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
function createAuthStrategy(token, tokenType) {
|
|
1366
|
+
if (!token) {
|
|
1367
|
+
return new NoAuth;
|
|
1368
|
+
}
|
|
1369
|
+
if (tokenType === "apiKey") {
|
|
1370
|
+
return new ApiKeyAuth(token);
|
|
1371
|
+
}
|
|
1372
|
+
if (tokenType === "session") {
|
|
1373
|
+
return new SessionAuth(token);
|
|
1374
|
+
}
|
|
1375
|
+
if (tokenType === "gameJwt") {
|
|
1376
|
+
return new GameJwtAuth(token);
|
|
1377
|
+
}
|
|
1378
|
+
if (token.startsWith("cademy")) {
|
|
1379
|
+
return new ApiKeyAuth(token);
|
|
1380
|
+
}
|
|
1381
|
+
return new GameJwtAuth(token);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// src/core/auth/utils.ts
|
|
1385
|
+
function openPopupWindow(url, name = "auth-popup", width = 500, height = 600) {
|
|
1386
|
+
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
1387
|
+
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
1388
|
+
const features = [
|
|
1389
|
+
`width=${width}`,
|
|
1390
|
+
`height=${height}`,
|
|
1391
|
+
`left=${left}`,
|
|
1392
|
+
`top=${top}`,
|
|
1393
|
+
"toolbar=no",
|
|
1394
|
+
"menubar=no",
|
|
1395
|
+
"location=yes",
|
|
1396
|
+
"status=yes",
|
|
1397
|
+
"scrollbars=yes",
|
|
1398
|
+
"resizable=yes"
|
|
1399
|
+
].join(",");
|
|
1400
|
+
return window.open(url, name, features);
|
|
1401
|
+
}
|
|
1402
|
+
function isInIframe() {
|
|
1403
|
+
if (typeof window === "undefined") {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
try {
|
|
1407
|
+
return window.self !== window.top;
|
|
1408
|
+
} catch {
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// src/core/connection/monitor.ts
|
|
1414
|
+
class ConnectionMonitor {
|
|
1415
|
+
state = "online";
|
|
1416
|
+
callbacks = new Set;
|
|
1417
|
+
heartbeatInterval;
|
|
1418
|
+
consecutiveFailures = 0;
|
|
1419
|
+
isMonitoring = false;
|
|
1420
|
+
config;
|
|
1421
|
+
constructor(config) {
|
|
1422
|
+
this.config = {
|
|
1423
|
+
baseUrl: config.baseUrl,
|
|
1424
|
+
heartbeatInterval: config.heartbeatInterval ?? 1e4,
|
|
1425
|
+
heartbeatTimeout: config.heartbeatTimeout ?? 5000,
|
|
1426
|
+
failureThreshold: config.failureThreshold ?? 2,
|
|
1427
|
+
enableHeartbeat: config.enableHeartbeat ?? true,
|
|
1428
|
+
enableOfflineEvents: config.enableOfflineEvents ?? true
|
|
1429
|
+
};
|
|
1430
|
+
this._detectInitialState();
|
|
1431
|
+
}
|
|
1432
|
+
start() {
|
|
1433
|
+
if (this.isMonitoring)
|
|
1434
|
+
return;
|
|
1435
|
+
this.isMonitoring = true;
|
|
1436
|
+
if (this.config.enableOfflineEvents && typeof window !== "undefined") {
|
|
1437
|
+
window.addEventListener("online", this._handleOnline);
|
|
1438
|
+
window.addEventListener("offline", this._handleOffline);
|
|
1439
|
+
}
|
|
1440
|
+
if (this.config.enableHeartbeat) {
|
|
1441
|
+
this._startHeartbeat();
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
stop() {
|
|
1445
|
+
if (!this.isMonitoring)
|
|
1446
|
+
return;
|
|
1447
|
+
this.isMonitoring = false;
|
|
1448
|
+
if (typeof window !== "undefined") {
|
|
1449
|
+
window.removeEventListener("online", this._handleOnline);
|
|
1450
|
+
window.removeEventListener("offline", this._handleOffline);
|
|
1451
|
+
}
|
|
1452
|
+
if (this.heartbeatInterval) {
|
|
1453
|
+
clearInterval(this.heartbeatInterval);
|
|
1454
|
+
this.heartbeatInterval = undefined;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
onChange(callback) {
|
|
1458
|
+
this.callbacks.add(callback);
|
|
1459
|
+
return () => this.callbacks.delete(callback);
|
|
1460
|
+
}
|
|
1461
|
+
getState() {
|
|
1462
|
+
return this.state;
|
|
1463
|
+
}
|
|
1464
|
+
async checkNow() {
|
|
1465
|
+
await this._performHeartbeat();
|
|
1466
|
+
return this.state;
|
|
1467
|
+
}
|
|
1468
|
+
reportRequestFailure(error) {
|
|
1469
|
+
const isNetworkError = error instanceof TypeError || error instanceof Error && error.message.includes("fetch");
|
|
1470
|
+
if (!isNetworkError)
|
|
1471
|
+
return;
|
|
1472
|
+
this.consecutiveFailures++;
|
|
1473
|
+
if (this.consecutiveFailures >= this.config.failureThreshold) {
|
|
1474
|
+
this._setState("degraded", "Multiple consecutive request failures");
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
reportRequestSuccess() {
|
|
1478
|
+
if (this.consecutiveFailures > 0) {
|
|
1479
|
+
this.consecutiveFailures = 0;
|
|
1480
|
+
if (this.state === "degraded") {
|
|
1481
|
+
this._setState("online", "Requests succeeding again");
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
_detectInitialState() {
|
|
1486
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
1487
|
+
this.state = "offline";
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
_handleOnline = () => {
|
|
1491
|
+
this.consecutiveFailures = 0;
|
|
1492
|
+
this._setState("online", "Browser online event");
|
|
1493
|
+
};
|
|
1494
|
+
_handleOffline = () => {
|
|
1495
|
+
this._setState("offline", "Browser offline event");
|
|
1496
|
+
};
|
|
1497
|
+
_startHeartbeat() {
|
|
1498
|
+
this._performHeartbeat();
|
|
1499
|
+
this.heartbeatInterval = setInterval(() => {
|
|
1500
|
+
this._performHeartbeat();
|
|
1501
|
+
}, this.config.heartbeatInterval);
|
|
1502
|
+
}
|
|
1503
|
+
async _performHeartbeat() {
|
|
1504
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
try {
|
|
1508
|
+
const controller = new AbortController;
|
|
1509
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.heartbeatTimeout);
|
|
1510
|
+
const response = await fetch(`${this.config.baseUrl}/ping`, {
|
|
1511
|
+
method: "GET",
|
|
1512
|
+
signal: controller.signal,
|
|
1513
|
+
cache: "no-store"
|
|
1514
|
+
});
|
|
1515
|
+
clearTimeout(timeoutId);
|
|
1516
|
+
if (response.ok) {
|
|
1517
|
+
this.consecutiveFailures = 0;
|
|
1518
|
+
if (this.state !== "online") {
|
|
1519
|
+
this._setState("online", "Heartbeat successful");
|
|
1520
|
+
}
|
|
1521
|
+
} else {
|
|
1522
|
+
this._handleHeartbeatFailure("Heartbeat returned non-OK status");
|
|
1523
|
+
}
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
this._handleHeartbeatFailure(error instanceof Error ? error.message : "Heartbeat failed");
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
_handleHeartbeatFailure(reason) {
|
|
1529
|
+
this.consecutiveFailures++;
|
|
1530
|
+
if (this.consecutiveFailures >= this.config.failureThreshold) {
|
|
1531
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
1532
|
+
this._setState("offline", reason);
|
|
1533
|
+
} else {
|
|
1534
|
+
this._setState("degraded", reason);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
_setState(newState, reason) {
|
|
1539
|
+
if (this.state === newState)
|
|
1540
|
+
return;
|
|
1541
|
+
const oldState = this.state;
|
|
1542
|
+
this.state = newState;
|
|
1543
|
+
console.debug(`[ConnectionMonitor] ${oldState} → ${newState}: ${reason}`);
|
|
1544
|
+
this.callbacks.forEach((callback) => {
|
|
1545
|
+
try {
|
|
1546
|
+
callback(newState, reason);
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
console.error("[ConnectionMonitor] Error in callback:", error);
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
// src/messaging.ts
|
|
1554
|
+
var MessageEvents;
|
|
1555
|
+
((MessageEvents2) => {
|
|
1556
|
+
MessageEvents2["INIT"] = "PLAYCADEMY_INIT";
|
|
1557
|
+
MessageEvents2["TOKEN_REFRESH"] = "PLAYCADEMY_TOKEN_REFRESH";
|
|
1558
|
+
MessageEvents2["PAUSE"] = "PLAYCADEMY_PAUSE";
|
|
1559
|
+
MessageEvents2["RESUME"] = "PLAYCADEMY_RESUME";
|
|
1560
|
+
MessageEvents2["FORCE_EXIT"] = "PLAYCADEMY_FORCE_EXIT";
|
|
1561
|
+
MessageEvents2["OVERLAY"] = "PLAYCADEMY_OVERLAY";
|
|
1562
|
+
MessageEvents2["CONNECTION_STATE"] = "PLAYCADEMY_CONNECTION_STATE";
|
|
1563
|
+
MessageEvents2["READY"] = "PLAYCADEMY_READY";
|
|
1564
|
+
MessageEvents2["EXIT"] = "PLAYCADEMY_EXIT";
|
|
1565
|
+
MessageEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
|
|
1566
|
+
MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
|
|
1567
|
+
MessageEvents2["DISPLAY_ALERT"] = "PLAYCADEMY_DISPLAY_ALERT";
|
|
1568
|
+
MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
|
|
1569
|
+
MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
|
|
1570
|
+
})(MessageEvents ||= {});
|
|
1571
|
+
|
|
1572
|
+
class PlaycademyMessaging {
|
|
1573
|
+
listeners = new Map;
|
|
1574
|
+
send(type, payload, options) {
|
|
1575
|
+
if (options?.target) {
|
|
1576
|
+
this.sendViaPostMessage(type, payload, options.target, options.origin || "*");
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
const context = this.getMessagingContext(type);
|
|
1580
|
+
if (context.shouldUsePostMessage) {
|
|
1581
|
+
this.sendViaPostMessage(type, payload, context.target, context.origin);
|
|
1582
|
+
} else {
|
|
1583
|
+
this.sendViaCustomEvent(type, payload);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
listen(type, handler) {
|
|
1587
|
+
const postMessageListener = (event) => {
|
|
1588
|
+
const messageEvent = event;
|
|
1589
|
+
if (messageEvent.data?.type === type) {
|
|
1590
|
+
handler(messageEvent.data.payload || messageEvent.data);
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
const customEventListener = (event) => {
|
|
1594
|
+
handler(event.detail);
|
|
1595
|
+
};
|
|
1596
|
+
if (!this.listeners.has(type)) {
|
|
1597
|
+
this.listeners.set(type, new Map);
|
|
1598
|
+
}
|
|
1599
|
+
const listenerMap = this.listeners.get(type);
|
|
1600
|
+
listenerMap.set(handler, {
|
|
1601
|
+
postMessage: postMessageListener,
|
|
1602
|
+
customEvent: customEventListener
|
|
1603
|
+
});
|
|
1604
|
+
window.addEventListener("message", postMessageListener);
|
|
1605
|
+
window.addEventListener(type, customEventListener);
|
|
1606
|
+
}
|
|
1607
|
+
unlisten(type, handler) {
|
|
1608
|
+
const typeListeners = this.listeners.get(type);
|
|
1609
|
+
if (!typeListeners || !typeListeners.has(handler)) {
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
const listeners = typeListeners.get(handler);
|
|
1613
|
+
window.removeEventListener("message", listeners.postMessage);
|
|
1614
|
+
window.removeEventListener(type, listeners.customEvent);
|
|
1615
|
+
typeListeners.delete(handler);
|
|
1616
|
+
if (typeListeners.size === 0) {
|
|
1617
|
+
this.listeners.delete(type);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
getMessagingContext(eventType) {
|
|
1621
|
+
const isIframe = typeof window !== "undefined" && window.self !== window.top;
|
|
1622
|
+
const iframeToParentEvents = [
|
|
1623
|
+
"PLAYCADEMY_READY" /* READY */,
|
|
1624
|
+
"PLAYCADEMY_EXIT" /* EXIT */,
|
|
1625
|
+
"PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
|
|
1626
|
+
"PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
|
|
1627
|
+
"PLAYCADEMY_DISPLAY_ALERT" /* DISPLAY_ALERT */
|
|
1628
|
+
];
|
|
1629
|
+
const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
|
|
1630
|
+
return {
|
|
1631
|
+
shouldUsePostMessage,
|
|
1632
|
+
target: shouldUsePostMessage ? window.parent : undefined,
|
|
1633
|
+
origin: "*"
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
sendViaPostMessage(type, payload, target = window.parent, origin = "*") {
|
|
1637
|
+
const messageData = { type };
|
|
1638
|
+
if (payload !== undefined) {
|
|
1639
|
+
messageData.payload = payload;
|
|
1640
|
+
}
|
|
1641
|
+
target.postMessage(messageData, origin);
|
|
1642
|
+
}
|
|
1643
|
+
sendViaCustomEvent(type, payload) {
|
|
1644
|
+
window.dispatchEvent(new CustomEvent(type, { detail: payload }));
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
var messaging = new PlaycademyMessaging;
|
|
1648
|
+
|
|
1649
|
+
// src/core/connection/utils.ts
|
|
1650
|
+
function createDisplayAlert(authContext) {
|
|
1651
|
+
return (message, options) => {
|
|
1652
|
+
if (authContext?.isInIframe && typeof window !== "undefined" && window.parent !== window) {
|
|
1653
|
+
window.parent.postMessage({
|
|
1654
|
+
type: "PLAYCADEMY_DISPLAY_ALERT",
|
|
1655
|
+
message,
|
|
1656
|
+
options
|
|
1657
|
+
}, "*");
|
|
1658
|
+
} else {
|
|
1659
|
+
const prefix = options?.type === "error" ? "❌" : options?.type === "warning" ? "⚠️" : "ℹ️";
|
|
1660
|
+
console.log(`${prefix} ${message}`);
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// src/core/connection/manager.ts
|
|
1666
|
+
class ConnectionManager {
|
|
1667
|
+
monitor;
|
|
1668
|
+
authContext;
|
|
1669
|
+
disconnectHandler;
|
|
1670
|
+
connectionChangeCallback;
|
|
1671
|
+
currentState = "online";
|
|
1672
|
+
additionalDisconnectHandlers = new Set;
|
|
1673
|
+
constructor(config) {
|
|
1674
|
+
this.authContext = config.authContext;
|
|
1675
|
+
this.disconnectHandler = config.onDisconnect;
|
|
1676
|
+
this.connectionChangeCallback = config.onConnectionChange;
|
|
1677
|
+
if (config.authContext?.isInIframe) {
|
|
1678
|
+
this._setupPlatformListener();
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
getState() {
|
|
1682
|
+
return this.monitor?.getState() ?? this.currentState;
|
|
1683
|
+
}
|
|
1684
|
+
async checkNow() {
|
|
1685
|
+
if (!this.monitor) {
|
|
1686
|
+
return this.currentState;
|
|
1687
|
+
}
|
|
1688
|
+
return await this.monitor.checkNow();
|
|
1689
|
+
}
|
|
1690
|
+
reportRequestSuccess() {
|
|
1691
|
+
this.monitor?.reportRequestSuccess();
|
|
1692
|
+
}
|
|
1693
|
+
reportRequestFailure(error) {
|
|
1694
|
+
this.monitor?.reportRequestFailure(error);
|
|
1695
|
+
}
|
|
1696
|
+
onDisconnect(callback) {
|
|
1697
|
+
this.additionalDisconnectHandlers.add(callback);
|
|
1698
|
+
return () => {
|
|
1699
|
+
this.additionalDisconnectHandlers.delete(callback);
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
stop() {
|
|
1703
|
+
this.monitor?.stop();
|
|
1704
|
+
}
|
|
1705
|
+
_setupPlatformListener() {
|
|
1706
|
+
messaging.listen("PLAYCADEMY_CONNECTION_STATE" /* CONNECTION_STATE */, ({ state, reason }) => {
|
|
1707
|
+
this.currentState = state;
|
|
1708
|
+
this._handleConnectionChange(state, reason);
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
_handleConnectionChange(state, reason) {
|
|
1712
|
+
this.connectionChangeCallback?.(state, reason);
|
|
1713
|
+
if (state === "offline" || state === "degraded") {
|
|
1714
|
+
const context = {
|
|
1715
|
+
state,
|
|
1716
|
+
reason,
|
|
1717
|
+
timestamp: Date.now(),
|
|
1718
|
+
displayAlert: createDisplayAlert(this.authContext)
|
|
1719
|
+
};
|
|
1720
|
+
if (this.disconnectHandler) {
|
|
1721
|
+
this.disconnectHandler(context);
|
|
1722
|
+
}
|
|
1723
|
+
this.additionalDisconnectHandlers.forEach((handler) => {
|
|
1724
|
+
handler(context);
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
// src/core/static/init.ts
|
|
1730
|
+
async function getPlaycademyConfig(allowedParentOrigins) {
|
|
1731
|
+
const preloaded = window.PLAYCADEMY;
|
|
1732
|
+
if (preloaded?.token) {
|
|
1733
|
+
return preloaded;
|
|
1734
|
+
}
|
|
1735
|
+
if (window.self !== window.top) {
|
|
1736
|
+
return await waitForPlaycademyInit(allowedParentOrigins);
|
|
1737
|
+
} else {
|
|
1738
|
+
return createStandaloneConfig();
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
function getReferrerOrigin() {
|
|
1742
|
+
try {
|
|
1743
|
+
return document.referrer ? new URL(document.referrer).origin : null;
|
|
1744
|
+
} catch {
|
|
1745
|
+
return null;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
function buildAllowedOrigins(explicit) {
|
|
1749
|
+
if (Array.isArray(explicit) && explicit.length > 0)
|
|
1750
|
+
return explicit;
|
|
1751
|
+
const ref = getReferrerOrigin();
|
|
1752
|
+
return ref ? [ref] : [];
|
|
1753
|
+
}
|
|
1754
|
+
function isOriginAllowed(origin, allowlist) {
|
|
1755
|
+
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
if (!allowlist || allowlist.length === 0) {
|
|
1759
|
+
console.error("[Playcademy SDK] No allowed origins configured. Consider passing allowedParentOrigins explicitly to init().");
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
return allowlist.includes(origin);
|
|
1763
|
+
}
|
|
1764
|
+
async function waitForPlaycademyInit(allowedParentOrigins) {
|
|
1765
|
+
return new Promise((resolve, reject) => {
|
|
1766
|
+
let contextReceived = false;
|
|
1767
|
+
const timeoutDuration = 5000;
|
|
1768
|
+
const allowlist = buildAllowedOrigins(allowedParentOrigins);
|
|
1769
|
+
let hasWarnedAboutUntrustedOrigin = false;
|
|
1770
|
+
function warnAboutUntrustedOrigin(origin) {
|
|
1771
|
+
if (hasWarnedAboutUntrustedOrigin)
|
|
1772
|
+
return;
|
|
1773
|
+
hasWarnedAboutUntrustedOrigin = true;
|
|
1774
|
+
console.warn("[Playcademy SDK] Ignoring INIT from untrusted origin:", origin);
|
|
1775
|
+
}
|
|
1776
|
+
const handleMessage = (event) => {
|
|
1777
|
+
if (event.data?.type !== "PLAYCADEMY_INIT" /* INIT */)
|
|
1778
|
+
return;
|
|
1779
|
+
if (!isOriginAllowed(event.origin, allowlist)) {
|
|
1780
|
+
warnAboutUntrustedOrigin(event.origin);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
contextReceived = true;
|
|
1784
|
+
window.removeEventListener("message", handleMessage);
|
|
1785
|
+
clearTimeout(timeoutId);
|
|
1786
|
+
window.PLAYCADEMY = event.data.payload;
|
|
1787
|
+
resolve(event.data.payload);
|
|
1788
|
+
};
|
|
1789
|
+
window.addEventListener("message", handleMessage);
|
|
1790
|
+
const timeoutId = setTimeout(() => {
|
|
1791
|
+
if (!contextReceived) {
|
|
1792
|
+
window.removeEventListener("message", handleMessage);
|
|
1793
|
+
reject(new Error(`${"PLAYCADEMY_INIT" /* INIT */} not received within ${timeoutDuration}ms`));
|
|
1794
|
+
}
|
|
1795
|
+
}, timeoutDuration);
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
function createStandaloneConfig() {
|
|
1799
|
+
console.debug("[Playcademy SDK] Standalone mode detected, creating mock context for sandbox development");
|
|
1800
|
+
const mockConfig = {
|
|
1801
|
+
baseUrl: "http://localhost:4321",
|
|
1802
|
+
gameUrl: window.location.origin,
|
|
1803
|
+
token: "mock-game-token-for-local-dev",
|
|
1804
|
+
gameId: "mock-game-id-from-template",
|
|
1805
|
+
realtimeUrl: undefined
|
|
1806
|
+
};
|
|
1807
|
+
window.PLAYCADEMY = mockConfig;
|
|
1808
|
+
return mockConfig;
|
|
1809
|
+
}
|
|
1810
|
+
async function init(options) {
|
|
1811
|
+
if (typeof window === "undefined") {
|
|
1812
|
+
throw new Error("Playcademy SDK must run in a browser context");
|
|
1813
|
+
}
|
|
1814
|
+
const config = await getPlaycademyConfig(options?.allowedParentOrigins);
|
|
1815
|
+
if (options?.baseUrl) {
|
|
1816
|
+
config.baseUrl = options.baseUrl;
|
|
1817
|
+
}
|
|
1818
|
+
const client = new this({
|
|
1819
|
+
baseUrl: config.baseUrl,
|
|
1820
|
+
gameUrl: config.gameUrl,
|
|
1821
|
+
token: config.token,
|
|
1822
|
+
gameId: config.gameId,
|
|
1823
|
+
autoStartSession: window.self !== window.top,
|
|
1824
|
+
onDisconnect: options?.onDisconnect,
|
|
1825
|
+
enableConnectionMonitoring: options?.enableConnectionMonitoring
|
|
1826
|
+
});
|
|
1827
|
+
client["initPayload"] = config;
|
|
1828
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, ({ token }) => client.setToken(token));
|
|
1829
|
+
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
1830
|
+
return client;
|
|
1831
|
+
}
|
|
1832
|
+
// src/core/static/login.ts
|
|
1833
|
+
async function login(baseUrl, email, password) {
|
|
1834
|
+
let url = baseUrl;
|
|
1835
|
+
if (baseUrl.startsWith("/") && typeof window !== "undefined") {
|
|
1836
|
+
url = window.location.origin + baseUrl;
|
|
1837
|
+
}
|
|
1838
|
+
url = url + "/auth/login";
|
|
1839
|
+
const response = await fetch(url, {
|
|
1840
|
+
method: "POST",
|
|
1841
|
+
headers: {
|
|
1842
|
+
"Content-Type": "application/json"
|
|
1843
|
+
},
|
|
1844
|
+
body: JSON.stringify({ email, password })
|
|
1845
|
+
});
|
|
1846
|
+
if (!response.ok) {
|
|
1847
|
+
try {
|
|
1848
|
+
const errorData = await response.json();
|
|
1849
|
+
const errorMessage = errorData && errorData.message ? String(errorData.message) : response.statusText;
|
|
1850
|
+
throw new PlaycademyError(errorMessage);
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
log.error("[Playcademy SDK] Failed to parse error response JSON, using status text instead:", { error });
|
|
1853
|
+
throw new PlaycademyError(response.statusText);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
return response.json();
|
|
1857
|
+
}
|
|
1858
|
+
// ../utils/src/random.ts
|
|
1859
|
+
async function generateSecureRandomString(length) {
|
|
1860
|
+
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
1861
|
+
const randomValues = new Uint8Array(length);
|
|
1862
|
+
globalThis.crypto.getRandomValues(randomValues);
|
|
1863
|
+
return Array.from(randomValues).map((byte) => charset[byte % charset.length]).join("");
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// src/core/auth/oauth.ts
|
|
1867
|
+
function getTimebackConfig() {
|
|
1868
|
+
return {
|
|
1869
|
+
authorizationEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/authorize",
|
|
1870
|
+
tokenEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/token",
|
|
1871
|
+
scope: "openid email phone"
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
var OAUTH_CONFIGS = {
|
|
1875
|
+
get TIMEBACK() {
|
|
1876
|
+
return getTimebackConfig;
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
async function generateOAuthState(data) {
|
|
1880
|
+
const csrfToken = await generateSecureRandomString(32);
|
|
1881
|
+
if (data && Object.keys(data).length > 0) {
|
|
1882
|
+
const jsonStr = JSON.stringify(data);
|
|
1883
|
+
const base64 = btoa(jsonStr).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
1884
|
+
return `${csrfToken}.${base64}`;
|
|
1885
|
+
}
|
|
1886
|
+
return csrfToken;
|
|
1887
|
+
}
|
|
1888
|
+
function parseOAuthState(state) {
|
|
1889
|
+
const lastDotIndex = state.lastIndexOf(".");
|
|
1890
|
+
if (lastDotIndex > 0 && lastDotIndex < state.length - 1) {
|
|
1891
|
+
try {
|
|
1892
|
+
const csrfToken = state.substring(0, lastDotIndex);
|
|
1893
|
+
const base64 = state.substring(lastDotIndex + 1);
|
|
1894
|
+
const base64WithPadding = base64.replace(/-/g, "+").replace(/_/g, "/");
|
|
1895
|
+
const paddedBase64 = base64WithPadding + "=".repeat((4 - base64WithPadding.length % 4) % 4);
|
|
1896
|
+
const jsonStr = atob(paddedBase64);
|
|
1897
|
+
const data = JSON.parse(jsonStr);
|
|
1898
|
+
return { csrfToken, data };
|
|
1899
|
+
} catch {}
|
|
1900
|
+
}
|
|
1901
|
+
return { csrfToken: state };
|
|
1902
|
+
}
|
|
1903
|
+
function getOAuthConfig(provider) {
|
|
1904
|
+
const configGetter = OAUTH_CONFIGS[provider];
|
|
1905
|
+
if (!configGetter)
|
|
1906
|
+
throw new Error(`Unsupported auth provider: ${provider}`);
|
|
1907
|
+
return configGetter();
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/core/static/identity.ts
|
|
1911
|
+
var identity = {
|
|
1912
|
+
parseOAuthState
|
|
1913
|
+
};
|
|
1914
|
+
// src/core/auth/flows/popup.ts
|
|
1915
|
+
async function initiatePopupFlow(options) {
|
|
1916
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
1917
|
+
try {
|
|
1918
|
+
onStateChange?.({
|
|
1919
|
+
status: "opening_popup",
|
|
1920
|
+
message: "Opening authentication window..."
|
|
1921
|
+
});
|
|
1922
|
+
const defaults = getOAuthConfig(provider);
|
|
1923
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
1924
|
+
if (!config.clientId) {
|
|
1925
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
1926
|
+
}
|
|
1927
|
+
const stateData = options.stateData;
|
|
1928
|
+
const state = await generateOAuthState(stateData);
|
|
1929
|
+
const params = new URLSearchParams({
|
|
1930
|
+
response_type: "code",
|
|
1931
|
+
client_id: config.clientId,
|
|
1932
|
+
redirect_uri: callbackUrl,
|
|
1933
|
+
state
|
|
1934
|
+
});
|
|
1935
|
+
if (config.scope) {
|
|
1936
|
+
params.set("scope", config.scope);
|
|
1937
|
+
}
|
|
1938
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
1939
|
+
const popup = openPopupWindow(authUrl, "playcademy-auth");
|
|
1940
|
+
if (!popup || popup.closed) {
|
|
1941
|
+
throw new Error("Popup blocked. Please enable popups and try again.");
|
|
1942
|
+
}
|
|
1943
|
+
onStateChange?.({
|
|
1944
|
+
status: "exchanging_token",
|
|
1945
|
+
message: "Waiting for authentication..."
|
|
1946
|
+
});
|
|
1947
|
+
return await waitForServerMessage(popup, onStateChange);
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
|
1950
|
+
onStateChange?.({
|
|
1951
|
+
status: "error",
|
|
1952
|
+
message: errorMessage,
|
|
1953
|
+
error: error instanceof Error ? error : new Error(errorMessage)
|
|
1954
|
+
});
|
|
1955
|
+
throw error;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
async function waitForServerMessage(popup, onStateChange) {
|
|
1959
|
+
return new Promise((resolve) => {
|
|
1960
|
+
let resolved = false;
|
|
1961
|
+
const handleMessage = (event) => {
|
|
1962
|
+
if (event.origin !== window.location.origin)
|
|
1963
|
+
return;
|
|
1964
|
+
const data = event.data;
|
|
1965
|
+
if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
|
|
1966
|
+
resolved = true;
|
|
1967
|
+
window.removeEventListener("message", handleMessage);
|
|
1968
|
+
if (data.authenticated && data.user) {
|
|
1969
|
+
onStateChange?.({
|
|
1970
|
+
status: "complete",
|
|
1971
|
+
message: "Authentication successful"
|
|
1972
|
+
});
|
|
1973
|
+
resolve({
|
|
1974
|
+
success: true,
|
|
1975
|
+
user: data.user
|
|
1976
|
+
});
|
|
1977
|
+
} else {
|
|
1978
|
+
const error = new Error(data.error || "Authentication failed");
|
|
1979
|
+
onStateChange?.({
|
|
1980
|
+
status: "error",
|
|
1981
|
+
message: error.message,
|
|
1982
|
+
error
|
|
1983
|
+
});
|
|
1984
|
+
resolve({
|
|
1985
|
+
success: false,
|
|
1986
|
+
error
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
window.addEventListener("message", handleMessage);
|
|
1992
|
+
const checkClosed = setInterval(() => {
|
|
1993
|
+
if (popup.closed && !resolved) {
|
|
1994
|
+
clearInterval(checkClosed);
|
|
1995
|
+
window.removeEventListener("message", handleMessage);
|
|
1996
|
+
const error = new Error("Authentication cancelled");
|
|
1997
|
+
onStateChange?.({
|
|
1998
|
+
status: "error",
|
|
1999
|
+
message: error.message,
|
|
2000
|
+
error
|
|
2001
|
+
});
|
|
2002
|
+
resolve({
|
|
2003
|
+
success: false,
|
|
2004
|
+
error
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
}, 500);
|
|
2008
|
+
setTimeout(() => {
|
|
2009
|
+
if (!resolved) {
|
|
2010
|
+
window.removeEventListener("message", handleMessage);
|
|
2011
|
+
clearInterval(checkClosed);
|
|
2012
|
+
const error = new Error("Authentication timeout");
|
|
2013
|
+
onStateChange?.({
|
|
2014
|
+
status: "error",
|
|
2015
|
+
message: error.message,
|
|
2016
|
+
error
|
|
2017
|
+
});
|
|
2018
|
+
resolve({
|
|
2019
|
+
success: false,
|
|
2020
|
+
error
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
}, 5 * 60 * 1000);
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// src/core/auth/flows/redirect.ts
|
|
2028
|
+
async function initiateRedirectFlow(options) {
|
|
2029
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
2030
|
+
try {
|
|
2031
|
+
onStateChange?.({
|
|
2032
|
+
status: "opening_popup",
|
|
2033
|
+
message: "Redirecting to authentication provider..."
|
|
2034
|
+
});
|
|
2035
|
+
const defaults = getOAuthConfig(provider);
|
|
2036
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
2037
|
+
if (!config.clientId) {
|
|
2038
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
2039
|
+
}
|
|
2040
|
+
const stateData = options.stateData;
|
|
2041
|
+
const state = await generateOAuthState(stateData);
|
|
2042
|
+
const params = new URLSearchParams({
|
|
2043
|
+
response_type: "code",
|
|
2044
|
+
client_id: config.clientId,
|
|
2045
|
+
redirect_uri: callbackUrl,
|
|
2046
|
+
state
|
|
2047
|
+
});
|
|
2048
|
+
if (config.scope) {
|
|
2049
|
+
params.set("scope", config.scope);
|
|
2050
|
+
}
|
|
2051
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
2052
|
+
window.location.href = authUrl;
|
|
2053
|
+
return new Promise(() => {});
|
|
2054
|
+
} catch (error) {
|
|
2055
|
+
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
|
2056
|
+
onStateChange?.({
|
|
2057
|
+
status: "error",
|
|
2058
|
+
message: errorMessage,
|
|
2059
|
+
error: error instanceof Error ? error : new Error(errorMessage)
|
|
2060
|
+
});
|
|
2061
|
+
throw error;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/core/auth/flows/unified.ts
|
|
2066
|
+
async function initiateUnifiedFlow(options) {
|
|
2067
|
+
const { mode = "auto" } = options;
|
|
2068
|
+
const effectiveMode = mode === "auto" ? isInIframe() ? "popup" : "redirect" : mode;
|
|
2069
|
+
switch (effectiveMode) {
|
|
2070
|
+
case "popup":
|
|
2071
|
+
return initiatePopupFlow(options);
|
|
2072
|
+
case "redirect":
|
|
2073
|
+
return initiateRedirectFlow(options);
|
|
2074
|
+
default:
|
|
2075
|
+
throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// src/core/auth/login.ts
|
|
2080
|
+
async function login2(client, options) {
|
|
2081
|
+
try {
|
|
2082
|
+
let stateData = options.stateData;
|
|
2083
|
+
if (!stateData) {
|
|
2084
|
+
try {
|
|
2085
|
+
const currentUser = await client.users.me();
|
|
2086
|
+
if (currentUser?.id) {
|
|
2087
|
+
stateData = { playcademy_user_id: currentUser.id };
|
|
2088
|
+
}
|
|
2089
|
+
} catch {
|
|
2090
|
+
log.debug("[Playcademy SDK] No current user available for state data");
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
log.debug("[Playcademy SDK] Starting OAuth login", {
|
|
2094
|
+
provider: options.provider,
|
|
2095
|
+
mode: options.mode || "auto",
|
|
2096
|
+
callbackUrl: options.callbackUrl,
|
|
2097
|
+
hasStateData: !!stateData
|
|
2098
|
+
});
|
|
2099
|
+
const optionsWithState = {
|
|
2100
|
+
...options,
|
|
2101
|
+
stateData
|
|
2102
|
+
};
|
|
2103
|
+
const result = await initiateUnifiedFlow(optionsWithState);
|
|
2104
|
+
if (result.success && result.user) {
|
|
2105
|
+
log.debug("[Playcademy SDK] OAuth login successful", {
|
|
2106
|
+
userId: result.user.sub
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
return result;
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
2112
|
+
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
2113
|
+
return {
|
|
2114
|
+
success: false,
|
|
2115
|
+
error: authError
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// src/namespaces/game/identity.ts
|
|
2121
|
+
function createIdentityNamespace(client) {
|
|
2122
|
+
return {
|
|
2123
|
+
connect: (options) => login2(client, options),
|
|
2124
|
+
_getContext: () => ({
|
|
2125
|
+
isInIframe: client["authContext"]?.isInIframe ?? false
|
|
2126
|
+
})
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
// src/namespaces/game/runtime.ts
|
|
2130
|
+
function createRuntimeNamespace(client) {
|
|
2131
|
+
const eventListeners = new Map;
|
|
2132
|
+
const trackListener = (eventType, handler) => {
|
|
2133
|
+
if (!eventListeners.has(eventType)) {
|
|
2134
|
+
eventListeners.set(eventType, new Set);
|
|
2135
|
+
}
|
|
2136
|
+
eventListeners.get(eventType).add(handler);
|
|
2137
|
+
};
|
|
2138
|
+
const untrackListener = (eventType, handler) => {
|
|
2139
|
+
const listeners = eventListeners.get(eventType);
|
|
2140
|
+
if (listeners) {
|
|
2141
|
+
listeners.delete(handler);
|
|
2142
|
+
if (listeners.size === 0) {
|
|
2143
|
+
eventListeners.delete(eventType);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
if (typeof window !== "undefined" && window.self !== window.top) {
|
|
2148
|
+
const playcademyConfig = window.PLAYCADEMY;
|
|
2149
|
+
const forwardKeys = Array.isArray(playcademyConfig?.forwardKeys) ? playcademyConfig.forwardKeys : ["Escape"];
|
|
2150
|
+
const keySet = new Set(forwardKeys.map((k) => k.toLowerCase()));
|
|
2151
|
+
const keyListener = (event) => {
|
|
2152
|
+
if (keySet.has(event.key?.toLowerCase() ?? "") || keySet.has(event.code?.toLowerCase() ?? "")) {
|
|
2153
|
+
messaging.send("PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */, {
|
|
2154
|
+
key: event.key,
|
|
2155
|
+
code: event.code,
|
|
2156
|
+
type: event.type
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
};
|
|
2160
|
+
window.addEventListener("keydown", keyListener);
|
|
2161
|
+
window.addEventListener("keyup", keyListener);
|
|
2162
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, () => {
|
|
2163
|
+
window.removeEventListener("keydown", keyListener);
|
|
2164
|
+
window.removeEventListener("keyup", keyListener);
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
return {
|
|
2168
|
+
getGameToken: async (gameId, options) => {
|
|
2169
|
+
const res = await client["request"](`/games/${gameId}/token`, "POST");
|
|
2170
|
+
if (options?.apply) {
|
|
2171
|
+
client.setToken(res.token);
|
|
2172
|
+
}
|
|
2173
|
+
return res;
|
|
2174
|
+
},
|
|
2175
|
+
exit: async () => {
|
|
2176
|
+
if (client["internalClientSessionId"] && client["gameId"]) {
|
|
2177
|
+
try {
|
|
2178
|
+
await client["_sessionManager"].endSession(client["internalClientSessionId"], client["gameId"]);
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
log.error("[Playcademy SDK] Failed to auto-end session:", {
|
|
2181
|
+
sessionId: client["internalClientSessionId"],
|
|
2182
|
+
error
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
messaging.send("PLAYCADEMY_EXIT" /* EXIT */, undefined);
|
|
2187
|
+
},
|
|
2188
|
+
onInit: (handler) => {
|
|
2189
|
+
messaging.listen("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2190
|
+
trackListener("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2191
|
+
},
|
|
2192
|
+
onTokenRefresh: (handler) => {
|
|
2193
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2194
|
+
trackListener("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2195
|
+
},
|
|
2196
|
+
onPause: (handler) => {
|
|
2197
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2198
|
+
trackListener("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2199
|
+
},
|
|
2200
|
+
onResume: (handler) => {
|
|
2201
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2202
|
+
trackListener("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2203
|
+
},
|
|
2204
|
+
onForceExit: (handler) => {
|
|
2205
|
+
messaging.listen("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2206
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2207
|
+
},
|
|
2208
|
+
onOverlay: (handler) => {
|
|
2209
|
+
messaging.listen("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2210
|
+
trackListener("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2211
|
+
},
|
|
2212
|
+
ready: () => {
|
|
2213
|
+
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
2214
|
+
},
|
|
2215
|
+
sendTelemetry: (data) => {
|
|
2216
|
+
messaging.send("PLAYCADEMY_TELEMETRY" /* TELEMETRY */, data);
|
|
2217
|
+
},
|
|
2218
|
+
removeListener: (eventType, handler) => {
|
|
2219
|
+
messaging.unlisten(eventType, handler);
|
|
2220
|
+
untrackListener(eventType, handler);
|
|
2221
|
+
},
|
|
2222
|
+
removeAllListeners: () => {
|
|
2223
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2224
|
+
for (const handler of handlers) {
|
|
2225
|
+
messaging.unlisten(eventType, handler);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
eventListeners.clear();
|
|
2229
|
+
},
|
|
2230
|
+
getListenerCounts: () => {
|
|
2231
|
+
const counts = {};
|
|
2232
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2233
|
+
counts[eventType] = handlers.size;
|
|
2234
|
+
}
|
|
2235
|
+
return counts;
|
|
2236
|
+
},
|
|
2237
|
+
assets: {
|
|
2238
|
+
url(pathOrStrings, ...values) {
|
|
2239
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2240
|
+
let path;
|
|
2241
|
+
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
2242
|
+
const strings = pathOrStrings;
|
|
2243
|
+
path = strings.reduce((acc, str, i) => {
|
|
2244
|
+
return acc + str + (values[i] != null ? String(values[i]) : "");
|
|
2245
|
+
}, "");
|
|
2246
|
+
} else {
|
|
2247
|
+
path = pathOrStrings;
|
|
2248
|
+
}
|
|
2249
|
+
if (!gameUrl) {
|
|
2250
|
+
return path.startsWith("./") ? path : "./" + path;
|
|
2251
|
+
}
|
|
2252
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2253
|
+
return gameUrl + cleanPath;
|
|
2254
|
+
},
|
|
2255
|
+
fetch: async (path, options) => {
|
|
2256
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2257
|
+
if (!gameUrl) {
|
|
2258
|
+
const relativePath = path.startsWith("./") ? path : "./" + path;
|
|
2259
|
+
return fetch(relativePath, options);
|
|
2260
|
+
}
|
|
2261
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2262
|
+
return fetch(gameUrl + cleanPath, options);
|
|
2263
|
+
},
|
|
2264
|
+
json: async (path) => {
|
|
2265
|
+
const response = await client.runtime.assets.fetch(path);
|
|
2266
|
+
return response.json();
|
|
2267
|
+
},
|
|
2268
|
+
blob: async (path) => {
|
|
2269
|
+
const response = await client.runtime.assets.fetch(path);
|
|
2270
|
+
return response.blob();
|
|
2271
|
+
},
|
|
2272
|
+
text: async (path) => {
|
|
2273
|
+
const response = await client.runtime.assets.fetch(path);
|
|
2274
|
+
return response.text();
|
|
2275
|
+
},
|
|
2276
|
+
arrayBuffer: async (path) => {
|
|
2277
|
+
const response = await client.runtime.assets.fetch(path);
|
|
2278
|
+
return response.arrayBuffer();
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
// src/namespaces/game/backend.ts
|
|
2284
|
+
function createBackendNamespace(client) {
|
|
2285
|
+
function normalizePath(path) {
|
|
2286
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
2287
|
+
}
|
|
2288
|
+
return {
|
|
2289
|
+
async get(path, headers) {
|
|
2290
|
+
return client["requestGameBackend"](normalizePath(path), "GET", undefined, headers);
|
|
2291
|
+
},
|
|
2292
|
+
async post(path, body, headers) {
|
|
2293
|
+
return client["requestGameBackend"](normalizePath(path), "POST", body, headers);
|
|
2294
|
+
},
|
|
2295
|
+
async put(path, body, headers) {
|
|
2296
|
+
return client["requestGameBackend"](normalizePath(path), "PUT", body, headers);
|
|
2297
|
+
},
|
|
2298
|
+
async patch(path, body, headers) {
|
|
2299
|
+
return client["requestGameBackend"](normalizePath(path), "PATCH", body, headers);
|
|
2300
|
+
},
|
|
2301
|
+
async delete(path, headers) {
|
|
2302
|
+
return client["requestGameBackend"](normalizePath(path), "DELETE", undefined, headers);
|
|
2303
|
+
},
|
|
2304
|
+
async request(path, method, body, headers) {
|
|
2305
|
+
return client["requestGameBackend"](normalizePath(path), method, body, headers);
|
|
2306
|
+
},
|
|
2307
|
+
async download(path, method = "GET", body, headers) {
|
|
2308
|
+
return client["requestGameBackend"](normalizePath(path), method, body, headers, true);
|
|
2309
|
+
},
|
|
2310
|
+
url(pathOrStrings, ...values) {
|
|
2311
|
+
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
2312
|
+
const strings = pathOrStrings;
|
|
2313
|
+
const path2 = strings.reduce((acc, str, i) => {
|
|
2314
|
+
return acc + str + (values[i] != null ? String(values[i]) : "");
|
|
2315
|
+
}, "");
|
|
2316
|
+
return `${client.gameUrl}/api${path2.startsWith("/") ? path2 : `/${path2}`}`;
|
|
2317
|
+
}
|
|
2318
|
+
const path = pathOrStrings;
|
|
2319
|
+
return `${client.gameUrl}/api${path.startsWith("/") ? path : `/${path}`}`;
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
// src/namespaces/game/users.ts
|
|
2324
|
+
function createUsersNamespace(client) {
|
|
2325
|
+
const itemIdCache = createPermanentCache("items");
|
|
2326
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2327
|
+
const resolveItemId = async (identifier) => {
|
|
2328
|
+
if (UUID_REGEX.test(identifier))
|
|
2329
|
+
return identifier;
|
|
2330
|
+
const gameId = client["gameId"];
|
|
2331
|
+
const cacheKey = gameId ? `${identifier}:${gameId}` : identifier;
|
|
2332
|
+
return itemIdCache.get(cacheKey, async () => {
|
|
2333
|
+
const queryParams = new URLSearchParams({ slug: identifier });
|
|
2334
|
+
if (gameId)
|
|
2335
|
+
queryParams.append("gameId", gameId);
|
|
2336
|
+
const item = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
|
|
2337
|
+
return item.id;
|
|
2338
|
+
});
|
|
2339
|
+
};
|
|
2340
|
+
return {
|
|
2341
|
+
me: async () => {
|
|
2342
|
+
return client["request"]("/users/me", "GET");
|
|
2343
|
+
},
|
|
2344
|
+
inventory: {
|
|
2345
|
+
get: async () => client["request"](`/inventory`, "GET"),
|
|
2346
|
+
add: async (identifier, qty) => {
|
|
2347
|
+
const itemId = await resolveItemId(identifier);
|
|
2348
|
+
const res = await client["request"](`/inventory/add`, "POST", { body: { itemId, qty } });
|
|
2349
|
+
client["emit"]("inventoryChange", {
|
|
2350
|
+
itemId,
|
|
2351
|
+
delta: qty,
|
|
2352
|
+
newTotal: res.newTotal
|
|
2353
|
+
});
|
|
2354
|
+
return res;
|
|
2355
|
+
},
|
|
2356
|
+
remove: async (identifier, qty) => {
|
|
2357
|
+
const itemId = await resolveItemId(identifier);
|
|
2358
|
+
const res = await client["request"](`/inventory/remove`, "POST", { body: { itemId, qty } });
|
|
2359
|
+
client["emit"]("inventoryChange", {
|
|
2360
|
+
itemId,
|
|
2361
|
+
delta: -qty,
|
|
2362
|
+
newTotal: res.newTotal
|
|
2363
|
+
});
|
|
2364
|
+
return res;
|
|
2365
|
+
},
|
|
2366
|
+
quantity: async (identifier) => {
|
|
2367
|
+
const itemId = await resolveItemId(identifier);
|
|
2368
|
+
const inventory = await client["request"](`/inventory`, "GET");
|
|
2369
|
+
const item = inventory.find((inv) => inv.item?.id === itemId);
|
|
2370
|
+
return item?.quantity ?? 0;
|
|
2371
|
+
},
|
|
2372
|
+
has: async (identifier, minQuantity = 1) => {
|
|
2373
|
+
const itemId = await resolveItemId(identifier);
|
|
2374
|
+
const inventory = await client["request"](`/inventory`, "GET");
|
|
2375
|
+
const item = inventory.find((inv) => inv.item?.id === itemId);
|
|
2376
|
+
const qty = item?.quantity ?? 0;
|
|
2377
|
+
return qty >= minQuantity;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
// ../constants/src/overworld.ts
|
|
2383
|
+
var ITEM_SLUGS = {
|
|
2384
|
+
PLAYCADEMY_CREDITS: "PLAYCADEMY_CREDITS",
|
|
2385
|
+
PLAYCADEMY_XP: "PLAYCADEMY_XP",
|
|
2386
|
+
FOUNDING_MEMBER_BADGE: "FOUNDING_MEMBER_BADGE",
|
|
2387
|
+
EARLY_ADOPTER_BADGE: "EARLY_ADOPTER_BADGE",
|
|
2388
|
+
FIRST_GAME_BADGE: "FIRST_GAME_BADGE",
|
|
2389
|
+
COMMON_SWORD: "COMMON_SWORD",
|
|
2390
|
+
SMALL_HEALTH_POTION: "SMALL_HEALTH_POTION",
|
|
2391
|
+
SMALL_BACKPACK: "SMALL_BACKPACK",
|
|
2392
|
+
LAVA_LAMP: "LAVA_LAMP",
|
|
2393
|
+
BOOMBOX: "BOOMBOX",
|
|
2394
|
+
CABIN_BED: "CABIN_BED"
|
|
2395
|
+
};
|
|
2396
|
+
var CURRENCIES = {
|
|
2397
|
+
PRIMARY: ITEM_SLUGS.PLAYCADEMY_CREDITS,
|
|
2398
|
+
XP: ITEM_SLUGS.PLAYCADEMY_XP
|
|
2399
|
+
};
|
|
2400
|
+
var BADGES = {
|
|
2401
|
+
FOUNDING_MEMBER: ITEM_SLUGS.FOUNDING_MEMBER_BADGE,
|
|
2402
|
+
EARLY_ADOPTER: ITEM_SLUGS.EARLY_ADOPTER_BADGE,
|
|
2403
|
+
FIRST_GAME: ITEM_SLUGS.FIRST_GAME_BADGE
|
|
2404
|
+
};
|
|
2405
|
+
// ../constants/src/timeback.ts
|
|
2406
|
+
var TIMEBACK_ROUTES = {
|
|
2407
|
+
END_ACTIVITY: "/integrations/timeback/end-activity"
|
|
2408
|
+
};
|
|
2409
|
+
// src/core/cache/singleton-cache.ts
|
|
2410
|
+
function createSingletonCache() {
|
|
2411
|
+
let cachedValue;
|
|
2412
|
+
let hasValue = false;
|
|
2413
|
+
async function get(loader) {
|
|
2414
|
+
if (hasValue) {
|
|
2415
|
+
return cachedValue;
|
|
2416
|
+
}
|
|
2417
|
+
const value = await loader();
|
|
2418
|
+
cachedValue = value;
|
|
2419
|
+
hasValue = true;
|
|
2420
|
+
return value;
|
|
2421
|
+
}
|
|
2422
|
+
function clear() {
|
|
2423
|
+
cachedValue = undefined;
|
|
2424
|
+
hasValue = false;
|
|
2425
|
+
}
|
|
2426
|
+
function has() {
|
|
2427
|
+
return hasValue;
|
|
2428
|
+
}
|
|
2429
|
+
return { get, clear, has };
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// src/namespaces/game/credits.ts
|
|
2433
|
+
function createCreditsNamespace(client) {
|
|
2434
|
+
const creditsIdCache = createSingletonCache();
|
|
2435
|
+
const getCreditsItemId = async () => {
|
|
2436
|
+
return creditsIdCache.get(async () => {
|
|
2437
|
+
const queryParams = new URLSearchParams({ slug: CURRENCIES.PRIMARY });
|
|
2438
|
+
const creditsItem = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
|
|
2439
|
+
if (!creditsItem || !creditsItem.id) {
|
|
2440
|
+
throw new Error("Playcademy Credits item not found in catalog");
|
|
2441
|
+
}
|
|
2442
|
+
return creditsItem.id;
|
|
2443
|
+
});
|
|
2444
|
+
};
|
|
2445
|
+
return {
|
|
2446
|
+
balance: async () => {
|
|
2447
|
+
const inventory = await client["request"]("/inventory", "GET");
|
|
2448
|
+
const primaryCurrencyInventoryItem = inventory.find((item) => item.item?.slug === CURRENCIES.PRIMARY);
|
|
2449
|
+
return primaryCurrencyInventoryItem?.quantity ?? 0;
|
|
2450
|
+
},
|
|
2451
|
+
add: async (amount) => {
|
|
2452
|
+
if (amount <= 0) {
|
|
2453
|
+
throw new Error("Amount must be positive");
|
|
2454
|
+
}
|
|
2455
|
+
const creditsItemId = await getCreditsItemId();
|
|
2456
|
+
const result = await client["request"]("/inventory/add", "POST", {
|
|
2457
|
+
body: {
|
|
2458
|
+
itemId: creditsItemId,
|
|
2459
|
+
qty: amount
|
|
2460
|
+
}
|
|
2461
|
+
});
|
|
2462
|
+
client["emit"]("inventoryChange", {
|
|
2463
|
+
itemId: creditsItemId,
|
|
2464
|
+
delta: amount,
|
|
2465
|
+
newTotal: result.newTotal
|
|
2466
|
+
});
|
|
2467
|
+
return result.newTotal;
|
|
2468
|
+
},
|
|
2469
|
+
spend: async (amount) => {
|
|
2470
|
+
if (amount <= 0) {
|
|
2471
|
+
throw new Error("Amount must be positive");
|
|
2472
|
+
}
|
|
2473
|
+
const creditsItemId = await getCreditsItemId();
|
|
2474
|
+
const result = await client["request"]("/inventory/remove", "POST", {
|
|
2475
|
+
body: {
|
|
2476
|
+
itemId: creditsItemId,
|
|
2477
|
+
qty: amount
|
|
2478
|
+
}
|
|
2479
|
+
});
|
|
2480
|
+
client["emit"]("inventoryChange", {
|
|
2481
|
+
itemId: creditsItemId,
|
|
2482
|
+
delta: -amount,
|
|
2483
|
+
newTotal: result.newTotal
|
|
2484
|
+
});
|
|
2485
|
+
return result.newTotal;
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
// src/namespaces/game/realtime.client.ts
|
|
2490
|
+
var CLOSE_CODES = {
|
|
2491
|
+
NORMAL_CLOSURE: 1000,
|
|
2492
|
+
TOKEN_REFRESH: 4000
|
|
2493
|
+
};
|
|
2494
|
+
|
|
2495
|
+
class RealtimeChannelClient {
|
|
2496
|
+
gameId;
|
|
2497
|
+
_channelName;
|
|
2498
|
+
getToken;
|
|
2499
|
+
baseUrl;
|
|
2500
|
+
ws;
|
|
2501
|
+
listeners = new Set;
|
|
2502
|
+
isClosing = false;
|
|
2503
|
+
tokenRefreshUnsubscribe;
|
|
2504
|
+
constructor(gameId, _channelName, getToken, baseUrl) {
|
|
2505
|
+
this.gameId = gameId;
|
|
2506
|
+
this._channelName = _channelName;
|
|
2507
|
+
this.getToken = getToken;
|
|
2508
|
+
this.baseUrl = baseUrl;
|
|
2509
|
+
}
|
|
2510
|
+
async connect() {
|
|
2511
|
+
try {
|
|
2512
|
+
const token = await this.getToken();
|
|
2513
|
+
let wsBase;
|
|
2514
|
+
if (/^ws(s)?:\/\//.test(this.baseUrl)) {
|
|
2515
|
+
wsBase = this.baseUrl;
|
|
2516
|
+
} else if (/^http(s)?:\/\//.test(this.baseUrl)) {
|
|
2517
|
+
wsBase = this.baseUrl.replace(/^http/, "ws");
|
|
2518
|
+
} else {
|
|
2519
|
+
const isBrowser2 = typeof window !== "undefined";
|
|
2520
|
+
if (isBrowser2) {
|
|
2521
|
+
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
2522
|
+
wsBase = `${proto}://${window.location.host}${this.baseUrl}`;
|
|
2523
|
+
} else {
|
|
2524
|
+
wsBase = `ws://${this.baseUrl.replace(/^\//, "")}`;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
const url = new URL(wsBase);
|
|
2528
|
+
url.searchParams.set("token", token);
|
|
2529
|
+
url.searchParams.set("c", this._channelName);
|
|
2530
|
+
this.ws = new WebSocket(url);
|
|
2531
|
+
this.setupEventHandlers();
|
|
2532
|
+
this.setupTokenRefreshListener();
|
|
2533
|
+
await new Promise((resolve, reject) => {
|
|
2534
|
+
if (!this.ws) {
|
|
2535
|
+
reject(new Error("WebSocket creation failed"));
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
const onOpen = () => {
|
|
2539
|
+
this.ws?.removeEventListener("open", onOpen);
|
|
2540
|
+
this.ws?.removeEventListener("error", onError);
|
|
2541
|
+
resolve();
|
|
2542
|
+
};
|
|
2543
|
+
const onError = (event) => {
|
|
2544
|
+
this.ws?.removeEventListener("open", onOpen);
|
|
2545
|
+
this.ws?.removeEventListener("error", onError);
|
|
2546
|
+
reject(new Error(`WebSocket connection failed: ${event}`));
|
|
2547
|
+
};
|
|
2548
|
+
this.ws.addEventListener("open", onOpen);
|
|
2549
|
+
this.ws.addEventListener("error", onError);
|
|
2550
|
+
});
|
|
2551
|
+
log.debug("[RealtimeChannelClient] Connected to channel", {
|
|
2552
|
+
gameId: this.gameId,
|
|
2553
|
+
channel: this._channelName
|
|
2554
|
+
});
|
|
2555
|
+
return this;
|
|
2556
|
+
} catch (error) {
|
|
2557
|
+
log.error("[RealtimeChannelClient] Connection failed", {
|
|
2558
|
+
gameId: this.gameId,
|
|
2559
|
+
channel: this._channelName,
|
|
2560
|
+
error
|
|
2561
|
+
});
|
|
2562
|
+
throw error;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
setupEventHandlers() {
|
|
2566
|
+
if (!this.ws)
|
|
2567
|
+
return;
|
|
2568
|
+
this.ws.onmessage = (event) => {
|
|
2569
|
+
try {
|
|
2570
|
+
const data = JSON.parse(event.data);
|
|
2571
|
+
this.listeners.forEach((callback) => {
|
|
2572
|
+
try {
|
|
2573
|
+
callback(data);
|
|
2574
|
+
} catch (error) {
|
|
2575
|
+
log.warn("[RealtimeChannelClient] Message listener error", {
|
|
2576
|
+
channel: this._channelName,
|
|
2577
|
+
error
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
});
|
|
2581
|
+
} catch (error) {
|
|
2582
|
+
log.warn("[RealtimeChannelClient] Failed to parse message", {
|
|
2583
|
+
channel: this._channelName,
|
|
2584
|
+
message: event.data,
|
|
2585
|
+
error
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
};
|
|
2589
|
+
this.ws.onclose = (event) => {
|
|
2590
|
+
log.debug("[RealtimeChannelClient] Connection closed", {
|
|
2591
|
+
channel: this._channelName,
|
|
2592
|
+
code: event.code,
|
|
2593
|
+
reason: event.reason,
|
|
2594
|
+
wasClean: event.wasClean
|
|
2595
|
+
});
|
|
2596
|
+
if (!this.isClosing && event.code !== CLOSE_CODES.TOKEN_REFRESH) {
|
|
2597
|
+
log.warn("[RealtimeChannelClient] Unexpected disconnection", {
|
|
2598
|
+
channel: this._channelName,
|
|
2599
|
+
code: event.code,
|
|
2600
|
+
reason: event.reason
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
};
|
|
2604
|
+
this.ws.onerror = (event) => {
|
|
2605
|
+
log.error("[RealtimeChannelClient] WebSocket error", {
|
|
2606
|
+
channel: this._channelName,
|
|
2607
|
+
event
|
|
2608
|
+
});
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
setupTokenRefreshListener() {
|
|
2612
|
+
const tokenRefreshHandler = async ({ token }) => {
|
|
2613
|
+
log.debug("[RealtimeChannelClient] Token refresh received, reconnecting", {
|
|
2614
|
+
channel: this._channelName
|
|
2615
|
+
});
|
|
2616
|
+
try {
|
|
2617
|
+
await this.reconnectWithNewToken(token);
|
|
2618
|
+
} catch (error) {
|
|
2619
|
+
log.error("[RealtimeChannelClient] Token refresh reconnection failed", {
|
|
2620
|
+
channel: this._channelName,
|
|
2621
|
+
error
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
};
|
|
2625
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
|
|
2626
|
+
this.tokenRefreshUnsubscribe = () => {
|
|
2627
|
+
messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
async reconnectWithNewToken(_token) {
|
|
2631
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
2632
|
+
this.ws.close(CLOSE_CODES.TOKEN_REFRESH, "token_refresh");
|
|
2633
|
+
await new Promise((resolve) => {
|
|
2634
|
+
const checkClosed = () => {
|
|
2635
|
+
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
|
2636
|
+
resolve();
|
|
2637
|
+
} else {
|
|
2638
|
+
setTimeout(checkClosed, 10);
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
checkClosed();
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
await this.connect();
|
|
2645
|
+
}
|
|
2646
|
+
send(data) {
|
|
2647
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
2648
|
+
try {
|
|
2649
|
+
const message = JSON.stringify(data);
|
|
2650
|
+
this.ws.send(message);
|
|
2651
|
+
} catch (error) {
|
|
2652
|
+
log.error("[RealtimeChannelClient] Failed to send message", {
|
|
2653
|
+
channel: this._channelName,
|
|
2654
|
+
error,
|
|
2655
|
+
data
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
} else {
|
|
2659
|
+
log.warn("[RealtimeChannelClient] Cannot send message - connection not open", {
|
|
2660
|
+
channel: this._channelName,
|
|
2661
|
+
readyState: this.ws?.readyState
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
onMessage(callback) {
|
|
2666
|
+
this.listeners.add(callback);
|
|
2667
|
+
return () => this.listeners.delete(callback);
|
|
2668
|
+
}
|
|
2669
|
+
close() {
|
|
2670
|
+
this.isClosing = true;
|
|
2671
|
+
if (this.tokenRefreshUnsubscribe) {
|
|
2672
|
+
this.tokenRefreshUnsubscribe();
|
|
2673
|
+
this.tokenRefreshUnsubscribe = undefined;
|
|
2674
|
+
}
|
|
2675
|
+
if (this.ws) {
|
|
2676
|
+
this.ws.close(CLOSE_CODES.NORMAL_CLOSURE, "client_close");
|
|
2677
|
+
this.ws = undefined;
|
|
2678
|
+
}
|
|
2679
|
+
this.listeners.clear();
|
|
2680
|
+
log.debug("[RealtimeChannelClient] Channel closed", {
|
|
2681
|
+
channel: this._channelName
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
get channelName() {
|
|
2685
|
+
return this._channelName;
|
|
2686
|
+
}
|
|
2687
|
+
get isConnected() {
|
|
2688
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// src/namespaces/game/realtime.ts
|
|
2693
|
+
function createRealtimeNamespace(client) {
|
|
2694
|
+
return {
|
|
2695
|
+
token: {
|
|
2696
|
+
get: async () => {
|
|
2697
|
+
const endpoint = client["gameId"] ? `/games/${client["gameId"]}/realtime/token` : "/realtime/token";
|
|
2698
|
+
return client["request"](endpoint, "POST");
|
|
2699
|
+
}
|
|
2700
|
+
},
|
|
2701
|
+
async open(channel = "default", url) {
|
|
2702
|
+
if (!client["gameId"]) {
|
|
2703
|
+
throw new Error("gameId is required for realtime channels");
|
|
2704
|
+
}
|
|
2705
|
+
let wsBaseUrl = url;
|
|
2706
|
+
if (!wsBaseUrl && typeof window !== "undefined") {
|
|
2707
|
+
const ctx = window.PLAYCADEMY;
|
|
2708
|
+
if (ctx?.realtimeUrl) {
|
|
2709
|
+
wsBaseUrl = ctx.realtimeUrl;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
const realtimeClient = new RealtimeChannelClient(client["gameId"], channel, () => client.realtime.token.get().then((r) => r.token), wsBaseUrl ?? client.getBaseUrl());
|
|
2713
|
+
return realtimeClient.connect();
|
|
2714
|
+
}
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
// src/namespaces/game/timeback.ts
|
|
2718
|
+
function createTimebackNamespace2(client) {
|
|
2719
|
+
let currentActivity = null;
|
|
2720
|
+
return {
|
|
2721
|
+
startActivity: (metadata) => {
|
|
2722
|
+
currentActivity = {
|
|
2723
|
+
startTime: Date.now(),
|
|
2724
|
+
metadata,
|
|
2725
|
+
pausedTime: 0,
|
|
2726
|
+
pauseStartTime: null
|
|
2727
|
+
};
|
|
2728
|
+
},
|
|
2729
|
+
pauseActivity: () => {
|
|
2730
|
+
if (!currentActivity) {
|
|
2731
|
+
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
2732
|
+
}
|
|
2733
|
+
if (currentActivity.pauseStartTime !== null) {
|
|
2734
|
+
throw new Error("Activity is already paused.");
|
|
2735
|
+
}
|
|
2736
|
+
currentActivity.pauseStartTime = Date.now();
|
|
2737
|
+
},
|
|
2738
|
+
resumeActivity: () => {
|
|
2739
|
+
if (!currentActivity) {
|
|
2740
|
+
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
2741
|
+
}
|
|
2742
|
+
if (currentActivity.pauseStartTime === null) {
|
|
2743
|
+
throw new Error("Activity is not paused.");
|
|
2744
|
+
}
|
|
2745
|
+
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
2746
|
+
currentActivity.pausedTime += pauseDuration;
|
|
2747
|
+
currentActivity.pauseStartTime = null;
|
|
2748
|
+
},
|
|
2749
|
+
endActivity: async (data) => {
|
|
2750
|
+
if (!currentActivity) {
|
|
2751
|
+
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
2752
|
+
}
|
|
2753
|
+
if (currentActivity.pauseStartTime !== null) {
|
|
2754
|
+
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
2755
|
+
currentActivity.pausedTime += pauseDuration;
|
|
2756
|
+
currentActivity.pauseStartTime = null;
|
|
2757
|
+
}
|
|
2758
|
+
const endTime = Date.now();
|
|
2759
|
+
const totalElapsed = endTime - currentActivity.startTime;
|
|
2760
|
+
const activeTime = totalElapsed - currentActivity.pausedTime;
|
|
2761
|
+
const durationSeconds = Math.floor(activeTime / 1000);
|
|
2762
|
+
const { correctQuestions, totalQuestions } = data;
|
|
2763
|
+
const request2 = {
|
|
2764
|
+
activityData: currentActivity.metadata,
|
|
2765
|
+
scoreData: {
|
|
2766
|
+
correctQuestions,
|
|
2767
|
+
totalQuestions
|
|
2768
|
+
},
|
|
2769
|
+
timingData: {
|
|
2770
|
+
durationSeconds
|
|
2771
|
+
},
|
|
2772
|
+
xpEarned: data.xpAwarded,
|
|
2773
|
+
masteredUnits: data.masteredUnits
|
|
2774
|
+
};
|
|
2775
|
+
try {
|
|
2776
|
+
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request2);
|
|
2777
|
+
currentActivity = null;
|
|
2778
|
+
return response;
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
currentActivity = null;
|
|
2781
|
+
throw error;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
// src/clients/public.ts
|
|
2787
|
+
class PlaycademyClient {
|
|
2788
|
+
baseUrl;
|
|
2789
|
+
gameUrl;
|
|
2790
|
+
authStrategy;
|
|
2791
|
+
gameId;
|
|
2792
|
+
config;
|
|
2793
|
+
listeners = {};
|
|
2794
|
+
internalClientSessionId;
|
|
2795
|
+
authContext;
|
|
2796
|
+
initPayload;
|
|
2797
|
+
connectionManager;
|
|
2798
|
+
_sessionManager = {
|
|
2799
|
+
startSession: async (gameId) => {
|
|
2800
|
+
return this.request(`/games/${gameId}/sessions`, "POST");
|
|
2801
|
+
},
|
|
2802
|
+
endSession: async (sessionId, gameId) => {
|
|
2803
|
+
return this.request(`/games/${gameId}/sessions/${sessionId}`, "DELETE");
|
|
2804
|
+
}
|
|
2805
|
+
};
|
|
2806
|
+
constructor(config) {
|
|
2807
|
+
this.baseUrl = config?.baseUrl?.endsWith("/api") ? config.baseUrl : `${config?.baseUrl}/api`;
|
|
2808
|
+
this.gameUrl = config?.gameUrl;
|
|
2809
|
+
this.gameId = config?.gameId;
|
|
2810
|
+
this.config = config || {};
|
|
2811
|
+
this.authStrategy = createAuthStrategy(config?.token ?? null, config?.tokenType);
|
|
2812
|
+
this._detectAuthContext();
|
|
2813
|
+
this._initializeInternalSession().catch(() => {});
|
|
2814
|
+
this._initializeConnectionMonitor();
|
|
2815
|
+
}
|
|
2816
|
+
getBaseUrl() {
|
|
2817
|
+
const isRelative = this.baseUrl.startsWith("/");
|
|
2818
|
+
const isBrowser2 = typeof window !== "undefined";
|
|
2819
|
+
return isRelative && isBrowser2 ? `${window.location.origin}${this.baseUrl}` : this.baseUrl;
|
|
2820
|
+
}
|
|
2821
|
+
getGameBackendUrl() {
|
|
2822
|
+
if (!this.gameUrl) {
|
|
2823
|
+
throw new PlaycademyError("Game backend URL not configured. gameUrl must be set to use game backend features.");
|
|
2824
|
+
}
|
|
2825
|
+
const isRelative = this.gameUrl.startsWith("/");
|
|
2826
|
+
const isBrowser2 = typeof window !== "undefined";
|
|
2827
|
+
const effectiveGameUrl = isRelative && isBrowser2 ? `${window.location.origin}${this.gameUrl}` : this.gameUrl;
|
|
2828
|
+
return `${effectiveGameUrl}/api`;
|
|
2829
|
+
}
|
|
2830
|
+
ping() {
|
|
2831
|
+
return "pong";
|
|
2832
|
+
}
|
|
2833
|
+
setToken(token, tokenType) {
|
|
2834
|
+
this.authStrategy = createAuthStrategy(token, tokenType);
|
|
2835
|
+
this.emit("authChange", { token });
|
|
2836
|
+
}
|
|
2837
|
+
getTokenType() {
|
|
2838
|
+
return this.authStrategy.getType();
|
|
2839
|
+
}
|
|
2840
|
+
getToken() {
|
|
2841
|
+
return this.authStrategy.getToken();
|
|
2842
|
+
}
|
|
2843
|
+
isAuthenticated() {
|
|
2844
|
+
return this.authStrategy.getToken() !== null;
|
|
2845
|
+
}
|
|
2846
|
+
onAuthChange(callback) {
|
|
2847
|
+
this.on("authChange", (payload) => callback(payload.token));
|
|
2848
|
+
}
|
|
2849
|
+
onDisconnect(callback) {
|
|
2850
|
+
if (!this.connectionManager) {
|
|
2851
|
+
return () => {};
|
|
2852
|
+
}
|
|
2853
|
+
return this.connectionManager.onDisconnect(callback);
|
|
2854
|
+
}
|
|
2855
|
+
getConnectionState() {
|
|
2856
|
+
return this.connectionManager?.getState() ?? "unknown";
|
|
2857
|
+
}
|
|
2858
|
+
async checkConnection() {
|
|
2859
|
+
if (!this.connectionManager)
|
|
2860
|
+
return "unknown";
|
|
2861
|
+
return await this.connectionManager.checkNow();
|
|
2862
|
+
}
|
|
2863
|
+
_setAuthContext(context) {
|
|
2864
|
+
this.authContext = context;
|
|
2865
|
+
}
|
|
2866
|
+
on(event, callback) {
|
|
2867
|
+
this.listeners[event] = this.listeners[event] ?? [];
|
|
2868
|
+
this.listeners[event].push(callback);
|
|
2869
|
+
}
|
|
2870
|
+
emit(event, payload) {
|
|
2871
|
+
(this.listeners[event] ?? []).forEach((listener) => {
|
|
2872
|
+
listener(payload);
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
async request(path, method, options) {
|
|
2876
|
+
const effectiveHeaders = {
|
|
2877
|
+
...options?.headers,
|
|
2878
|
+
...this.authStrategy.getHeaders()
|
|
2879
|
+
};
|
|
2880
|
+
try {
|
|
2881
|
+
const result = await request({
|
|
2882
|
+
path,
|
|
2883
|
+
method,
|
|
2884
|
+
body: options?.body,
|
|
2885
|
+
baseUrl: this.baseUrl,
|
|
2886
|
+
extraHeaders: effectiveHeaders,
|
|
2887
|
+
raw: options?.raw
|
|
2888
|
+
});
|
|
2889
|
+
this.connectionManager?.reportRequestSuccess();
|
|
2890
|
+
return result;
|
|
2891
|
+
} catch (error) {
|
|
2892
|
+
this.connectionManager?.reportRequestFailure(error);
|
|
2893
|
+
throw error;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
async requestGameBackend(path, method, body, headers, raw) {
|
|
2897
|
+
const effectiveHeaders = {
|
|
2898
|
+
...headers,
|
|
2899
|
+
...this.authStrategy.getHeaders()
|
|
2900
|
+
};
|
|
2901
|
+
try {
|
|
2902
|
+
const result = await request({
|
|
2903
|
+
path,
|
|
2904
|
+
method,
|
|
2905
|
+
body,
|
|
2906
|
+
baseUrl: this.getGameBackendUrl(),
|
|
2907
|
+
extraHeaders: effectiveHeaders,
|
|
2908
|
+
raw
|
|
2909
|
+
});
|
|
2910
|
+
this.connectionManager?.reportRequestSuccess();
|
|
2911
|
+
return result;
|
|
2912
|
+
} catch (error) {
|
|
2913
|
+
this.connectionManager?.reportRequestFailure(error);
|
|
2914
|
+
throw error;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
_ensureGameId() {
|
|
2918
|
+
if (!this.gameId) {
|
|
2919
|
+
throw new PlaycademyError("This operation requires a gameId, but none was provided when initializing the client.");
|
|
2920
|
+
}
|
|
2921
|
+
return this.gameId;
|
|
2922
|
+
}
|
|
2923
|
+
_detectAuthContext() {
|
|
2924
|
+
this.authContext = { isInIframe: isInIframe() };
|
|
2925
|
+
}
|
|
2926
|
+
_initializeConnectionMonitor() {
|
|
2927
|
+
if (typeof window === "undefined")
|
|
2928
|
+
return;
|
|
2929
|
+
const isEnabled = this.config.enableConnectionMonitoring ?? true;
|
|
2930
|
+
if (!isEnabled)
|
|
2931
|
+
return;
|
|
2932
|
+
try {
|
|
2933
|
+
this.connectionManager = new ConnectionManager({
|
|
2934
|
+
baseUrl: this.baseUrl,
|
|
2935
|
+
authContext: this.authContext,
|
|
2936
|
+
onDisconnect: this.config.onDisconnect,
|
|
2937
|
+
onConnectionChange: (state, reason) => {
|
|
2938
|
+
this.emit("connectionChange", { state, reason });
|
|
2939
|
+
}
|
|
2940
|
+
});
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
log.error("[Playcademy SDK] Failed to initialize connection manager:", { error });
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
async _initializeInternalSession() {
|
|
2946
|
+
if (!this.gameId || this.internalClientSessionId)
|
|
2947
|
+
return;
|
|
2948
|
+
const shouldAutoStart = this.config.autoStartSession ?? true;
|
|
2949
|
+
if (!shouldAutoStart)
|
|
2950
|
+
return;
|
|
2951
|
+
try {
|
|
2952
|
+
const response = await this._sessionManager.startSession(this.gameId);
|
|
2953
|
+
this.internalClientSessionId = response.sessionId;
|
|
2954
|
+
log.debug("[Playcademy SDK] Auto-started game session", {
|
|
2955
|
+
gameId: this.gameId,
|
|
2956
|
+
sessionId: this.internalClientSessionId
|
|
2957
|
+
});
|
|
2958
|
+
} catch (error) {
|
|
2959
|
+
log.error("[Playcademy SDK] Auto-starting session failed for game", {
|
|
2960
|
+
gameId: this.gameId,
|
|
2961
|
+
error
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
identity = createIdentityNamespace(this);
|
|
2966
|
+
runtime = createRuntimeNamespace(this);
|
|
2967
|
+
users = createUsersNamespace(this);
|
|
2968
|
+
timeback = createTimebackNamespace2(this);
|
|
2969
|
+
credits = createCreditsNamespace(this);
|
|
2970
|
+
scores = createScoresNamespace(this);
|
|
2971
|
+
realtime = createRealtimeNamespace(this);
|
|
2972
|
+
backend = createBackendNamespace(this);
|
|
2973
|
+
static init = init;
|
|
2974
|
+
static login = login;
|
|
2975
|
+
static identity = identity;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
// src/clients/internal.ts
|
|
2979
|
+
class PlaycademyInternalClient extends PlaycademyClient {
|
|
2980
|
+
auth = createAuthNamespace(this);
|
|
2981
|
+
admin = createAdminNamespace(this);
|
|
2982
|
+
dev = createDevNamespace(this);
|
|
2983
|
+
games = createGamesNamespace(this);
|
|
2984
|
+
character = createCharacterNamespace(this);
|
|
2985
|
+
achievements = createAchievementsNamespace(this);
|
|
2986
|
+
leaderboard = createLeaderboardNamespace(this);
|
|
2987
|
+
levels = createLevelsNamespace(this);
|
|
2988
|
+
shop = createShopNamespace(this);
|
|
2989
|
+
notifications = createNotificationsNamespace(this);
|
|
2990
|
+
maps = createMapsNamespace(this);
|
|
2991
|
+
sprites = createSpritesNamespace(this);
|
|
2992
|
+
telemetry = createTelemetryNamespace(this);
|
|
2993
|
+
scores = createScoresNamespace2(this);
|
|
2994
|
+
timeback = createTimebackNamespace(this);
|
|
2995
|
+
}
|
|
2996
|
+
export {
|
|
2997
|
+
messaging,
|
|
2998
|
+
extractApiErrorInfo,
|
|
2999
|
+
PlaycademyInternalClient,
|
|
3000
|
+
PlaycademyError,
|
|
3001
|
+
PlaycademyInternalClient as PlaycademyClient,
|
|
3002
|
+
MessageEvents,
|
|
3003
|
+
ConnectionMonitor,
|
|
3004
|
+
ConnectionManager,
|
|
3005
|
+
ApiError
|
|
3006
|
+
};
|