@playcademy/sdk 0.16.0 → 0.16.1-beta.10
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 +79 -0
- package/dist/index.d.ts +607 -146
- package/dist/index.js +1701 -612
- package/dist/internal.d.ts +528 -25
- package/dist/internal.js +1729 -612
- package/dist/server/edge.d.ts +17 -0
- package/dist/server/edge.js +82 -4
- package/dist/server.d.ts +17 -0
- package/dist/server.js +82 -4
- package/dist/types.d.ts +586 -145
- package/package.json +1 -1
package/dist/internal.js
CHANGED
|
@@ -26,9 +26,35 @@ var MessageEvents;
|
|
|
26
26
|
MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
|
|
27
27
|
MessageEvents2["DEMO_END"] = "PLAYCADEMY_DEMO_END";
|
|
28
28
|
MessageEvents2["TIMEBACK_HEARTBEAT_RELAY"] = "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY";
|
|
29
|
+
MessageEvents2["TIMEBACK_ACTIVITY_START"] = "PLAYCADEMY_TIMEBACK_ACTIVITY_START";
|
|
30
|
+
MessageEvents2["TIMEBACK_ACTIVITY_END"] = "PLAYCADEMY_TIMEBACK_ACTIVITY_END";
|
|
31
|
+
MessageEvents2["CHECKPOINT"] = "PLAYCADEMY_CHECKPOINT";
|
|
29
32
|
MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
|
|
30
33
|
MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
|
|
31
34
|
})(MessageEvents ||= {});
|
|
35
|
+
function isFromParentWindow(event) {
|
|
36
|
+
return globalThis.window.parent !== globalThis.window && event.source === globalThis.window.parent;
|
|
37
|
+
}
|
|
38
|
+
var MESSAGE_DIRECTION = {
|
|
39
|
+
["PLAYCADEMY_INIT" /* INIT */]: "launcher-to-game",
|
|
40
|
+
["PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */]: "launcher-to-game",
|
|
41
|
+
["PLAYCADEMY_PAUSE" /* PAUSE */]: "launcher-to-game",
|
|
42
|
+
["PLAYCADEMY_RESUME" /* RESUME */]: "launcher-to-game",
|
|
43
|
+
["PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */]: "launcher-to-game",
|
|
44
|
+
["PLAYCADEMY_OVERLAY" /* OVERLAY */]: "launcher-to-game",
|
|
45
|
+
["PLAYCADEMY_READY" /* READY */]: "game-to-launcher",
|
|
46
|
+
["PLAYCADEMY_INIT_ERROR" /* INIT_ERROR */]: "game-to-launcher",
|
|
47
|
+
["PLAYCADEMY_EXIT" /* EXIT */]: "game-to-launcher",
|
|
48
|
+
["PLAYCADEMY_TELEMETRY" /* TELEMETRY */]: "game-to-launcher",
|
|
49
|
+
["PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */]: "game-to-launcher",
|
|
50
|
+
["PLAYCADEMY_DEMO_END" /* DEMO_END */]: "game-to-launcher",
|
|
51
|
+
["PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */]: "game-to-launcher",
|
|
52
|
+
["PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */]: "game-to-launcher",
|
|
53
|
+
["PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */]: "game-to-launcher",
|
|
54
|
+
["PLAYCADEMY_CHECKPOINT" /* CHECKPOINT */]: "game-to-launcher",
|
|
55
|
+
["PLAYCADEMY_AUTH_STATE_CHANGE" /* AUTH_STATE_CHANGE */]: "unscoped",
|
|
56
|
+
["PLAYCADEMY_AUTH_CALLBACK" /* AUTH_CALLBACK */]: "unscoped"
|
|
57
|
+
};
|
|
32
58
|
|
|
33
59
|
class PlaycademyMessaging {
|
|
34
60
|
listeners = new Map;
|
|
@@ -44,12 +70,17 @@ class PlaycademyMessaging {
|
|
|
44
70
|
this.sendViaCustomEvent(type, payload);
|
|
45
71
|
}
|
|
46
72
|
}
|
|
47
|
-
listen(type, handler) {
|
|
73
|
+
listen(type, handler, options) {
|
|
74
|
+
const gateToParent = options?.fromParent ?? MESSAGE_DIRECTION[type] === "launcher-to-game";
|
|
48
75
|
function postMessageListener(event) {
|
|
49
76
|
const messageEvent = event;
|
|
50
|
-
if (messageEvent.data?.type
|
|
51
|
-
|
|
77
|
+
if (messageEvent.data?.type !== type) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (gateToParent && !isFromParentWindow(messageEvent)) {
|
|
81
|
+
return;
|
|
52
82
|
}
|
|
83
|
+
handler(messageEvent.data.payload || messageEvent.data);
|
|
53
84
|
}
|
|
54
85
|
function customEventListener(event) {
|
|
55
86
|
handler(event.detail);
|
|
@@ -80,16 +111,7 @@ class PlaycademyMessaging {
|
|
|
80
111
|
}
|
|
81
112
|
getMessagingContext(eventType) {
|
|
82
113
|
const isIframe = typeof globalThis.window !== "undefined" && globalThis.self !== window.top;
|
|
83
|
-
const
|
|
84
|
-
"PLAYCADEMY_READY" /* READY */,
|
|
85
|
-
"PLAYCADEMY_INIT_ERROR" /* INIT_ERROR */,
|
|
86
|
-
"PLAYCADEMY_EXIT" /* EXIT */,
|
|
87
|
-
"PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
|
|
88
|
-
"PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
|
|
89
|
-
"PLAYCADEMY_DEMO_END" /* DEMO_END */,
|
|
90
|
-
"PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */
|
|
91
|
-
];
|
|
92
|
-
const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
|
|
114
|
+
const shouldUsePostMessage = isIframe && MESSAGE_DIRECTION[eventType] === "game-to-launcher";
|
|
93
115
|
return {
|
|
94
116
|
shouldUsePostMessage,
|
|
95
117
|
target: shouldUsePostMessage ? window.parent : undefined,
|
|
@@ -108,8 +130,60 @@ class PlaycademyMessaging {
|
|
|
108
130
|
}
|
|
109
131
|
}
|
|
110
132
|
var messaging = new PlaycademyMessaging;
|
|
133
|
+
function isTrustedIframeMessage(event, iframeWindow, origin) {
|
|
134
|
+
if (!iframeWindow || event.source !== iframeWindow || event.origin === "null") {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return origin === "*" || event.origin === origin;
|
|
138
|
+
}
|
|
111
139
|
|
|
112
|
-
// src/core/
|
|
140
|
+
// src/core/launch/handshake.ts
|
|
141
|
+
var INIT_WAIT_TIMEOUT_MS = 25000;
|
|
142
|
+
var HANDSHAKE_RESEND_INTERVAL_MS = 300;
|
|
143
|
+
var HANDSHAKE_MAX_DURATION_MS = 30000;
|
|
144
|
+
function beginInitHandshake({
|
|
145
|
+
iframe,
|
|
146
|
+
origin,
|
|
147
|
+
payload,
|
|
148
|
+
onTimeout,
|
|
149
|
+
onSendError
|
|
150
|
+
}) {
|
|
151
|
+
let intervalId = null;
|
|
152
|
+
let timeoutId = null;
|
|
153
|
+
function sendInitOnce() {
|
|
154
|
+
if (!iframe.contentWindow) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
messaging.send("PLAYCADEMY_INIT" /* INIT */, payload, {
|
|
159
|
+
target: iframe.contentWindow,
|
|
160
|
+
origin
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
stop();
|
|
164
|
+
onSendError?.(error);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function stop() {
|
|
168
|
+
if (intervalId) {
|
|
169
|
+
clearInterval(intervalId);
|
|
170
|
+
intervalId = null;
|
|
171
|
+
}
|
|
172
|
+
if (timeoutId) {
|
|
173
|
+
clearTimeout(timeoutId);
|
|
174
|
+
timeoutId = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
sendInitOnce();
|
|
178
|
+
intervalId = setInterval(sendInitOnce, HANDSHAKE_RESEND_INTERVAL_MS);
|
|
179
|
+
timeoutId = setTimeout(() => {
|
|
180
|
+
stop();
|
|
181
|
+
onTimeout();
|
|
182
|
+
}, HANDSHAKE_MAX_DURATION_MS);
|
|
183
|
+
return stop;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/core/launch/context.ts
|
|
113
187
|
async function getPlaycademyConfig(allowedParentOrigins) {
|
|
114
188
|
const preloaded = globalThis.PLAYCADEMY;
|
|
115
189
|
if (preloaded?.token) {
|
|
@@ -148,7 +222,6 @@ function isOriginAllowed(origin, allowlist) {
|
|
|
148
222
|
async function waitForPlaycademyInit(allowedParentOrigins) {
|
|
149
223
|
return new Promise((resolve, reject) => {
|
|
150
224
|
let contextReceived = false;
|
|
151
|
-
const timeoutDuration = 25000;
|
|
152
225
|
const allowlist = buildAllowedOrigins(allowedParentOrigins);
|
|
153
226
|
let hasWarnedAboutUntrustedOrigin = false;
|
|
154
227
|
function warnAboutUntrustedOrigin(origin) {
|
|
@@ -179,11 +252,11 @@ async function waitForPlaycademyInit(allowedParentOrigins) {
|
|
|
179
252
|
const timeoutId = setTimeout(() => {
|
|
180
253
|
if (!contextReceived) {
|
|
181
254
|
window.removeEventListener("message", handleMessage);
|
|
182
|
-
const reason = `${"PLAYCADEMY_INIT" /* INIT */} not received within ${
|
|
255
|
+
const reason = `${"PLAYCADEMY_INIT" /* INIT */} not received within ${INIT_WAIT_TIMEOUT_MS}ms`;
|
|
183
256
|
messaging.send("PLAYCADEMY_INIT_ERROR" /* INIT_ERROR */, { reason });
|
|
184
257
|
reject(new Error(reason));
|
|
185
258
|
}
|
|
186
|
-
},
|
|
259
|
+
}, INIT_WAIT_TIMEOUT_MS);
|
|
187
260
|
});
|
|
188
261
|
}
|
|
189
262
|
function createStandaloneConfig() {
|
|
@@ -198,6 +271,8 @@ function createStandaloneConfig() {
|
|
|
198
271
|
globalThis.PLAYCADEMY = mockConfig;
|
|
199
272
|
return mockConfig;
|
|
200
273
|
}
|
|
274
|
+
|
|
275
|
+
// src/core/static/init.ts
|
|
201
276
|
async function init(options) {
|
|
202
277
|
if (typeof globalThis.window === "undefined") {
|
|
203
278
|
throw new Error("Playcademy SDK must run in a browser context");
|
|
@@ -677,6 +752,16 @@ function assertPlatformMode(client, operation) {
|
|
|
677
752
|
throw new PlaycademyError(`${operation} requires platform mode (current: ${client.mode}). Check client.mode before calling.`);
|
|
678
753
|
}
|
|
679
754
|
}
|
|
755
|
+
function assertNonAnonymousMode(client, operation) {
|
|
756
|
+
if (client.mode !== "platform" && client.mode !== "child") {
|
|
757
|
+
throw new PlaycademyError(`${operation} requires a real user (platform or child mode; current: ${client.mode}). Check client.mode before calling.`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function assertNotChildMode(client, operation, hint) {
|
|
761
|
+
if (client.mode === "child") {
|
|
762
|
+
throw new PlaycademyError(`${operation} is not available when child-launched. ${hint}`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
680
765
|
function assertDemoMode(client, operation) {
|
|
681
766
|
if (client.mode !== "demo") {
|
|
682
767
|
throw new PlaycademyError(`${operation} requires demo mode (current: ${client.mode}). Check client.mode before calling.`);
|
|
@@ -707,539 +792,789 @@ function createDemoNamespace(client) {
|
|
|
707
792
|
}
|
|
708
793
|
};
|
|
709
794
|
}
|
|
710
|
-
// src/
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
795
|
+
// ../constants/src/auth.ts
|
|
796
|
+
var DEFAULT_PERSONAL_API_KEY_PERMISSIONS = {
|
|
797
|
+
games: ["read", "write", "delete"],
|
|
798
|
+
users: ["read:self", "write:self"],
|
|
799
|
+
dev: ["read", "write"]
|
|
800
|
+
};
|
|
801
|
+
// ../constants/src/platform.ts
|
|
802
|
+
var PLAYCADEMY_BROWSER_TIME_ZONE_HEADER = "x-playcademy-browser-time-zone";
|
|
803
|
+
// ../constants/src/timeback.ts
|
|
804
|
+
var TIMEBACK_ROUTES = {
|
|
805
|
+
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
806
|
+
GET_XP: "/integrations/timeback/xp",
|
|
807
|
+
GET_MASTERY: "/integrations/timeback/mastery",
|
|
808
|
+
GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
|
|
809
|
+
HEARTBEAT: "/integrations/timeback/heartbeat",
|
|
810
|
+
ADVANCE_COURSE: "/integrations/timeback/advance-course",
|
|
811
|
+
UNENROLL_COURSE: "/integrations/timeback/unenroll-course",
|
|
812
|
+
ASSESSMENTS: "/integrations/timeback/assessments"
|
|
813
|
+
};
|
|
814
|
+
var TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
815
|
+
var TIMEBACK_SUBJECTS = [
|
|
816
|
+
"Reading",
|
|
817
|
+
"Language",
|
|
818
|
+
"Vocabulary",
|
|
819
|
+
"Social Studies",
|
|
820
|
+
"Writing",
|
|
821
|
+
"Science",
|
|
822
|
+
"FastMath",
|
|
823
|
+
"Math",
|
|
824
|
+
"None"
|
|
825
|
+
];
|
|
826
|
+
var ASSESSMENT_PURPOSES = ["end_of_course", "diagnostic", "review", "mastery"];
|
|
827
|
+
var TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS = {
|
|
828
|
+
standards: 20,
|
|
829
|
+
itemsPerStandard: 5,
|
|
830
|
+
standardFieldLength: 128
|
|
831
|
+
};
|
|
832
|
+
var VALID_E_LEVELS = ["E1", "E2", "E3", "E4"];
|
|
833
|
+
var TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
|
|
834
|
+
xp: 1,
|
|
835
|
+
mastery: 0,
|
|
836
|
+
score: 2
|
|
837
|
+
};
|
|
838
|
+
var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
|
|
839
|
+
xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
|
|
840
|
+
mastery: 0,
|
|
841
|
+
time: 60,
|
|
842
|
+
score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
|
|
843
|
+
};
|
|
844
|
+
// ../utils/src/error.ts
|
|
845
|
+
function errorMessage(err) {
|
|
846
|
+
return err instanceof Error ? err.message : String(err);
|
|
727
847
|
}
|
|
728
|
-
|
|
729
|
-
|
|
848
|
+
|
|
849
|
+
// src/core/launch/exclusivity.ts
|
|
850
|
+
var liveSessions = new WeakSet;
|
|
851
|
+
function registerLiveSession(client) {
|
|
852
|
+
liveSessions.add(client);
|
|
853
|
+
}
|
|
854
|
+
function releaseLiveSession(client) {
|
|
855
|
+
liveSessions.delete(client);
|
|
856
|
+
}
|
|
857
|
+
function hasLiveSession(client) {
|
|
858
|
+
return liveSessions.has(client);
|
|
859
|
+
}
|
|
860
|
+
var pauseProbes = new WeakMap;
|
|
861
|
+
function registerPauseProbe(client, probe) {
|
|
862
|
+
pauseProbes.set(client, probe);
|
|
863
|
+
}
|
|
864
|
+
function isActivityManuallyPaused(client) {
|
|
865
|
+
return pauseProbes.get(client)?.() ?? false;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/core/guards.ts
|
|
869
|
+
var VALID_GRADES = TIMEBACK_GRADES;
|
|
870
|
+
var VALID_SUBJECTS = TIMEBACK_SUBJECTS;
|
|
871
|
+
function isValidGrade(value) {
|
|
872
|
+
return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
|
|
873
|
+
}
|
|
874
|
+
function isValidSubject(value) {
|
|
875
|
+
return typeof value === "string" && VALID_SUBJECTS.includes(value);
|
|
876
|
+
}
|
|
877
|
+
function isAssessmentPurpose(value) {
|
|
878
|
+
return typeof value === "string" && ASSESSMENT_PURPOSES.includes(value);
|
|
879
|
+
}
|
|
880
|
+
function isValidELevel(value) {
|
|
881
|
+
return typeof value === "string" && VALID_E_LEVELS.includes(value);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/core/launch/guards.ts
|
|
885
|
+
function isValidLaunchIntent(value) {
|
|
886
|
+
if (typeof value !== "object" || value === null) {
|
|
730
887
|
return false;
|
|
731
888
|
}
|
|
889
|
+
const intent = value;
|
|
890
|
+
return typeof intent.lessonId === "string" && intent.lessonId !== "" && isValidELevel(intent.eLevel);
|
|
891
|
+
}
|
|
892
|
+
function isValidTimebackRecording(recording) {
|
|
893
|
+
return typeof recording.activityId === "string" && recording.activityId !== "" && isValidGrade(recording.grade) && isValidSubject(recording.subject);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/core/launch/resume-store.ts
|
|
897
|
+
function isResumeStore(resume) {
|
|
898
|
+
return "load" in resume && typeof resume.load === "function";
|
|
899
|
+
}
|
|
900
|
+
function resolveResumePolicy(request) {
|
|
901
|
+
const { resume } = request;
|
|
902
|
+
if (resume === false || resume === null) {
|
|
903
|
+
return {};
|
|
904
|
+
}
|
|
905
|
+
if (resume !== undefined) {
|
|
906
|
+
return isResumeStore(resume) ? { store: resume } : { envelope: resume };
|
|
907
|
+
}
|
|
908
|
+
const userId = tokenUserId(request.token);
|
|
909
|
+
if (!userId) {
|
|
910
|
+
console.warn("[Playcademy SDK] embed.launch(): could not identify the user from the token, so this launch will not be resumable. Pass resume: false to opt out explicitly.");
|
|
911
|
+
return {};
|
|
912
|
+
}
|
|
913
|
+
const store = createLocalStorageResumeStore(buildResumeStorageKey({
|
|
914
|
+
userId,
|
|
915
|
+
parentGameId: request.parentGameId,
|
|
916
|
+
slug: request.slug,
|
|
917
|
+
intent: request.intent
|
|
918
|
+
}));
|
|
919
|
+
if (!store) {
|
|
920
|
+
console.warn("[Playcademy SDK] embed.launch(): localStorage is unavailable, so this launch will not be resumable. Pass resume: false to opt out explicitly.");
|
|
921
|
+
return {};
|
|
922
|
+
}
|
|
923
|
+
return { store };
|
|
924
|
+
}
|
|
925
|
+
function tokenUserId(token) {
|
|
926
|
+
const segments = token.split(".");
|
|
927
|
+
if (segments.length !== 3 || !segments[1]) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
732
930
|
try {
|
|
733
|
-
|
|
931
|
+
const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
932
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
933
|
+
const claims = JSON.parse(atob(padded));
|
|
934
|
+
return typeof claims.uid === "string" && claims.uid ? claims.uid : null;
|
|
734
935
|
} catch {
|
|
735
|
-
return
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
function buildResumeStorageKey(identity2) {
|
|
940
|
+
const parts = [
|
|
941
|
+
identity2.userId,
|
|
942
|
+
identity2.parentGameId,
|
|
943
|
+
identity2.slug,
|
|
944
|
+
identity2.intent.lessonId,
|
|
945
|
+
identity2.intent.eLevel
|
|
946
|
+
];
|
|
947
|
+
return `playcademy:embed:resume:v1:${parts.map(encodeURIComponent).join(":")}`;
|
|
948
|
+
}
|
|
949
|
+
function isResumeEnvelope(value) {
|
|
950
|
+
return typeof value === "object" && value !== null && "state" in value;
|
|
951
|
+
}
|
|
952
|
+
function getLocalStorage() {
|
|
953
|
+
try {
|
|
954
|
+
return globalThis.localStorage ?? null;
|
|
955
|
+
} catch {
|
|
956
|
+
return null;
|
|
736
957
|
}
|
|
737
958
|
}
|
|
959
|
+
function createLocalStorageResumeStore(key) {
|
|
960
|
+
const storage = getLocalStorage();
|
|
961
|
+
if (!storage) {
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
let warned = false;
|
|
965
|
+
return {
|
|
966
|
+
load() {
|
|
967
|
+
let raw;
|
|
968
|
+
try {
|
|
969
|
+
raw = storage.getItem(key);
|
|
970
|
+
} catch {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
if (raw === null) {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
const parsed = JSON.parse(raw);
|
|
978
|
+
if (isResumeEnvelope(parsed)) {
|
|
979
|
+
return parsed;
|
|
980
|
+
}
|
|
981
|
+
} catch {}
|
|
982
|
+
try {
|
|
983
|
+
storage.removeItem(key);
|
|
984
|
+
} catch {}
|
|
985
|
+
return null;
|
|
986
|
+
},
|
|
987
|
+
save(envelope) {
|
|
988
|
+
try {
|
|
989
|
+
storage.setItem(key, JSON.stringify(envelope));
|
|
990
|
+
} catch (error) {
|
|
991
|
+
if (!warned) {
|
|
992
|
+
warned = true;
|
|
993
|
+
console.warn("[Playcademy SDK] embed: resume persistence failed; this launch will not be resumable across reloads:", errorMessage(error));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
clear() {
|
|
998
|
+
try {
|
|
999
|
+
storage.removeItem(key);
|
|
1000
|
+
} catch {}
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
738
1004
|
|
|
739
|
-
// src/core/
|
|
740
|
-
|
|
741
|
-
const
|
|
1005
|
+
// src/core/launch/session.ts
|
|
1006
|
+
function buildParentContext(gameId, intent, resume) {
|
|
1007
|
+
const context = { gameId, intent };
|
|
1008
|
+
if (resume) {
|
|
1009
|
+
context.resume = resume.state;
|
|
1010
|
+
}
|
|
1011
|
+
if (resume?.childRunId) {
|
|
1012
|
+
context.resumeRunId = resume.childRunId;
|
|
1013
|
+
}
|
|
1014
|
+
return context;
|
|
1015
|
+
}
|
|
1016
|
+
var EMBED_IFRAME_STYLE = "border:none;width:100%;height:100%;display:block;";
|
|
1017
|
+
var FORWARD_INTERVAL_MS = 15000;
|
|
1018
|
+
var MAX_FORWARDS_PER_TICK = 3;
|
|
1019
|
+
var WATCHDOG_INTERVAL_MS = 5000;
|
|
1020
|
+
function isCheckpointRelay(payload) {
|
|
1021
|
+
return typeof payload === "object" && payload !== null && "state" in payload;
|
|
1022
|
+
}
|
|
1023
|
+
function isValidHeartbeatRelay(windowStartedAtMs, activeMs, pausedMs) {
|
|
1024
|
+
return typeof windowStartedAtMs === "number" && Number.isFinite(windowStartedAtMs) && Number.isFinite(activeMs) && Number.isFinite(pausedMs) && activeMs >= 0 && pausedMs >= 0;
|
|
1025
|
+
}
|
|
1026
|
+
function isValidEndReport(report) {
|
|
1027
|
+
if (!report) {
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
return typeof report.activityData?.activityId === "string" && Number.isFinite(report.scoreData?.correctQuestions) && Number.isFinite(report.scoreData?.totalQuestions) && Number.isFinite(report.timingData?.durationSeconds) && report.scoreData.correctQuestions >= 0 && report.scoreData.totalQuestions >= 0 && report.timingData.durationSeconds >= 0;
|
|
1031
|
+
}
|
|
1032
|
+
function reconcileSessionTiming(totals, forwardedActiveMs, forwardedPausedMs) {
|
|
1033
|
+
const remainder = {
|
|
1034
|
+
activeSeconds: Math.max(0, totals.activeSeconds - forwardedActiveMs / 1000)
|
|
1035
|
+
};
|
|
1036
|
+
if (totals.inactiveSeconds !== undefined) {
|
|
1037
|
+
remainder.inactiveSeconds = Math.max(0, totals.inactiveSeconds - forwardedPausedMs / 1000);
|
|
1038
|
+
}
|
|
1039
|
+
return remainder;
|
|
1040
|
+
}
|
|
1041
|
+
function isValidSessionTiming(timing) {
|
|
1042
|
+
if (timing === undefined) {
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
if (!Number.isFinite(timing.activeSeconds) || timing.activeSeconds < 0) {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
return timing.inactiveSeconds === undefined || Number.isFinite(timing.inactiveSeconds) && timing.inactiveSeconds >= 0;
|
|
1049
|
+
}
|
|
1050
|
+
function isTrackerRelay(type) {
|
|
1051
|
+
return type === "PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */ || type === "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */ || type === "PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */;
|
|
1052
|
+
}
|
|
1053
|
+
async function tryStore(operation, label) {
|
|
742
1054
|
try {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1055
|
+
await operation();
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
console.warn(`[Playcademy SDK] embed: resume ${label} failed:`, errorMessage(error));
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
class ChildSession {
|
|
1062
|
+
iframe;
|
|
1063
|
+
finished;
|
|
1064
|
+
closed;
|
|
1065
|
+
#options;
|
|
1066
|
+
#bridge;
|
|
1067
|
+
#resolveFinished;
|
|
1068
|
+
#resolveClosed;
|
|
1069
|
+
#settled = false;
|
|
1070
|
+
#torndown = false;
|
|
1071
|
+
#childOrigin = "";
|
|
1072
|
+
#childGameId;
|
|
1073
|
+
#stopHandshake = null;
|
|
1074
|
+
#loadDeadline = null;
|
|
1075
|
+
#watchdogTimer = null;
|
|
1076
|
+
#wasConnected = false;
|
|
1077
|
+
#relayedWindows = new Map;
|
|
1078
|
+
#parentRunId = null;
|
|
1079
|
+
#parentResumeId = null;
|
|
1080
|
+
#forwardTimer = null;
|
|
1081
|
+
#dirtyWindows = new Set;
|
|
1082
|
+
#pendingForwards = new Set;
|
|
1083
|
+
#forwardedActiveMs = 0;
|
|
1084
|
+
#forwardedPausedMs = 0;
|
|
1085
|
+
#checkpointState = null;
|
|
1086
|
+
#hasCheckpoint = false;
|
|
1087
|
+
#childAnnouncedRunId = null;
|
|
1088
|
+
#resumeAccepted = false;
|
|
1089
|
+
#resumeEnvelope;
|
|
1090
|
+
#resumeCleared = false;
|
|
1091
|
+
#storeChain = Promise.resolve();
|
|
1092
|
+
#warnedSettledRelay = false;
|
|
1093
|
+
#sawReady = false;
|
|
1094
|
+
#closedWindows = new Set;
|
|
1095
|
+
#currentWindowKey = null;
|
|
1096
|
+
constructor(options) {
|
|
1097
|
+
this.#options = options;
|
|
1098
|
+
this.#resumeEnvelope = options.resume ?? null;
|
|
1099
|
+
this.#bridge = options.timeback && options.reporter ? { recording: options.timeback, reporter: options.reporter } : null;
|
|
1100
|
+
this.iframe = document.createElement("iframe");
|
|
1101
|
+
this.iframe.style.cssText = EMBED_IFRAME_STYLE;
|
|
1102
|
+
options.container.appendChild(this.iframe);
|
|
1103
|
+
this.#watchdogTimer = setInterval(() => {
|
|
1104
|
+
if (this.iframe.isConnected) {
|
|
1105
|
+
this.#wasConnected = true;
|
|
1106
|
+
} else if (this.#wasConnected) {
|
|
1107
|
+
this.close();
|
|
1108
|
+
}
|
|
1109
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
1110
|
+
this.finished = new Promise((resolve) => {
|
|
1111
|
+
this.#resolveFinished = resolve;
|
|
746
1112
|
});
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (!config.clientId) {
|
|
750
|
-
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
751
|
-
}
|
|
752
|
-
const stateData = options.stateData;
|
|
753
|
-
const state = await generateOAuthState(stateData);
|
|
754
|
-
const params = new URLSearchParams({
|
|
755
|
-
response_type: "code",
|
|
756
|
-
client_id: config.clientId,
|
|
757
|
-
redirect_uri: callbackUrl,
|
|
758
|
-
state
|
|
1113
|
+
this.closed = new Promise((resolve) => {
|
|
1114
|
+
this.#resolveClosed = resolve;
|
|
759
1115
|
});
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1116
|
+
this.#open();
|
|
1117
|
+
}
|
|
1118
|
+
close() {
|
|
1119
|
+
this.#abandon();
|
|
1120
|
+
this.#teardown();
|
|
1121
|
+
}
|
|
1122
|
+
get checkpoint() {
|
|
1123
|
+
if (!this.#hasCheckpoint) {
|
|
1124
|
+
return null;
|
|
767
1125
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
message: errorMessage,
|
|
778
|
-
error: error instanceof Error ? error : new Error(errorMessage)
|
|
1126
|
+
return {
|
|
1127
|
+
state: this.#checkpointState,
|
|
1128
|
+
childRunId: this.#childAnnouncedRunId ?? undefined,
|
|
1129
|
+
parentRunId: this.#parentRunId ?? undefined
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
#open() {
|
|
1133
|
+
this.#boot().catch((error) => {
|
|
1134
|
+
this.#fail(error instanceof PlaycademyError ? error : new PlaycademyError(`embed.launch() could not launch '${this.#options.slug}': ${errorMessage(error)}`));
|
|
779
1135
|
});
|
|
780
|
-
throw error;
|
|
781
1136
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1137
|
+
async#boot() {
|
|
1138
|
+
this.#loadDeadline = setTimeout(() => {
|
|
1139
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' did not become ready within ${HANDSHAKE_MAX_DURATION_MS / 1000}s.`));
|
|
1140
|
+
}, HANDSHAKE_MAX_DURATION_MS);
|
|
1141
|
+
const store = this.#options.resumeStore;
|
|
1142
|
+
if (store) {
|
|
1143
|
+
try {
|
|
1144
|
+
this.#resumeEnvelope = await store.load() ?? null;
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
console.warn("[Playcademy SDK] embed: resume load failed; starting fresh:", errorMessage(error));
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (this.#torndown) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const envelope = this.#resumeEnvelope;
|
|
1153
|
+
const { childUrl, payload } = await this.#options.resolveTarget(envelope ? { state: envelope.state, childRunId: envelope.childRunId } : undefined);
|
|
1154
|
+
if (this.#torndown) {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
this.#childOrigin = new URL(childUrl).origin;
|
|
1158
|
+
this.#childGameId = payload.gameId;
|
|
1159
|
+
window.addEventListener("message", this.#onChildMessage);
|
|
1160
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, this.#onTokenRefresh);
|
|
1161
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, this.#onLauncherPause);
|
|
1162
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, this.#onLauncherResume);
|
|
1163
|
+
this.iframe.addEventListener("load", () => {
|
|
1164
|
+
this.#clearLoadDeadline();
|
|
1165
|
+
if (this.#torndown) {
|
|
788
1166
|
return;
|
|
789
1167
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
});
|
|
799
|
-
resolve({
|
|
800
|
-
success: true,
|
|
801
|
-
user: data.user
|
|
802
|
-
});
|
|
803
|
-
} else {
|
|
804
|
-
const error = new Error(data.error || "Authentication failed");
|
|
805
|
-
onStateChange?.({
|
|
806
|
-
status: "error",
|
|
807
|
-
message: error.message,
|
|
808
|
-
error
|
|
809
|
-
});
|
|
810
|
-
resolve({
|
|
811
|
-
success: false,
|
|
812
|
-
error
|
|
813
|
-
});
|
|
1168
|
+
this.#stopHandshake = beginInitHandshake({
|
|
1169
|
+
iframe: this.iframe,
|
|
1170
|
+
origin: this.#childOrigin,
|
|
1171
|
+
payload,
|
|
1172
|
+
onTimeout: () => {
|
|
1173
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' did not answer INIT within ${HANDSHAKE_MAX_DURATION_MS / 1000}s.`));
|
|
1174
|
+
},
|
|
1175
|
+
onSendError: (error) => {
|
|
1176
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' INIT could not be posted: ${errorMessage(error)}. The payload (including any resume state) must be structured-cloneable.`));
|
|
814
1177
|
}
|
|
815
|
-
}
|
|
1178
|
+
});
|
|
1179
|
+
}, { once: true });
|
|
1180
|
+
this.iframe.src = childUrl;
|
|
1181
|
+
}
|
|
1182
|
+
#clearLoadDeadline() {
|
|
1183
|
+
if (this.#loadDeadline) {
|
|
1184
|
+
clearTimeout(this.#loadDeadline);
|
|
1185
|
+
this.#loadDeadline = null;
|
|
816
1186
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1187
|
+
}
|
|
1188
|
+
#teardown() {
|
|
1189
|
+
if (this.#torndown) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
this.#torndown = true;
|
|
1193
|
+
this.#stopHandshake?.();
|
|
1194
|
+
this.#stopHandshake = null;
|
|
1195
|
+
this.#clearLoadDeadline();
|
|
1196
|
+
if (this.#forwardTimer) {
|
|
1197
|
+
clearInterval(this.#forwardTimer);
|
|
1198
|
+
this.#forwardTimer = null;
|
|
1199
|
+
}
|
|
1200
|
+
if (this.#watchdogTimer) {
|
|
1201
|
+
clearInterval(this.#watchdogTimer);
|
|
1202
|
+
this.#watchdogTimer = null;
|
|
1203
|
+
}
|
|
1204
|
+
this.#markOpenWindowFlushable();
|
|
1205
|
+
this.#forwardDirtyWindows();
|
|
1206
|
+
window.removeEventListener("message", this.#onChildMessage);
|
|
1207
|
+
window.removeEventListener("pagehide", this.#onPageHide);
|
|
1208
|
+
messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, this.#onTokenRefresh);
|
|
1209
|
+
messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, this.#onLauncherPause);
|
|
1210
|
+
messaging.unlisten("PLAYCADEMY_RESUME" /* RESUME */, this.#onLauncherResume);
|
|
1211
|
+
this.iframe.remove();
|
|
1212
|
+
this.#resolveClosed();
|
|
1213
|
+
}
|
|
1214
|
+
#fail(error) {
|
|
1215
|
+
if (!this.#settled) {
|
|
1216
|
+
this.#settled = true;
|
|
1217
|
+
this.#resolveFinished({ status: "failed", error });
|
|
1218
|
+
}
|
|
1219
|
+
this.#teardown();
|
|
1220
|
+
}
|
|
1221
|
+
#abandon() {
|
|
1222
|
+
if (this.#settled) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
this.#settled = true;
|
|
1226
|
+
const activity = {
|
|
1227
|
+
status: "abandoned",
|
|
1228
|
+
timing: this.#relayedTiming()
|
|
1229
|
+
};
|
|
1230
|
+
const resume = this.checkpoint;
|
|
1231
|
+
if (resume) {
|
|
1232
|
+
activity.resume = resume;
|
|
1233
|
+
}
|
|
1234
|
+
this.#resolveFinished(activity);
|
|
1235
|
+
}
|
|
1236
|
+
#relayedTiming() {
|
|
1237
|
+
let activeMs = 0;
|
|
1238
|
+
let pausedMs = 0;
|
|
1239
|
+
for (const snapshot of this.#relayedWindows.values()) {
|
|
1240
|
+
activeMs += snapshot.activeMs;
|
|
1241
|
+
pausedMs += snapshot.pausedMs;
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
activeSeconds: Math.round(activeMs / 1000),
|
|
1245
|
+
inactiveSeconds: Math.round(pausedMs / 1000)
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
#complete(childReport) {
|
|
1249
|
+
if (this.#settled) {
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
this.#settled = true;
|
|
1253
|
+
this.#clearResume();
|
|
1254
|
+
let endMemo = null;
|
|
1255
|
+
this.#resolveFinished({
|
|
1256
|
+
status: "completed",
|
|
1257
|
+
correct: childReport.scoreData.correctQuestions,
|
|
1258
|
+
total: childReport.scoreData.totalQuestions,
|
|
1259
|
+
timing: this.#completedTiming(childReport),
|
|
1260
|
+
childReport,
|
|
1261
|
+
end: (scores) => {
|
|
1262
|
+
if (!this.#bridge) {
|
|
1263
|
+
return Promise.reject(new PlaycademyError("end() requires the timeback option at launch; this launch was a pure UX embed."));
|
|
1264
|
+
}
|
|
1265
|
+
endMemo ??= this.#postCompletion(this.#bridge, childReport, scores).catch((error) => {
|
|
1266
|
+
endMemo = null;
|
|
1267
|
+
throw error;
|
|
847
1268
|
});
|
|
1269
|
+
return endMemo;
|
|
848
1270
|
}
|
|
849
|
-
}, 5 * 60 * 1000);
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
// src/core/auth/flows/redirect.ts
|
|
854
|
-
async function initiateRedirectFlow(options) {
|
|
855
|
-
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
856
|
-
try {
|
|
857
|
-
onStateChange?.({
|
|
858
|
-
status: "opening_popup",
|
|
859
|
-
message: "Redirecting to authentication provider..."
|
|
860
1271
|
});
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (
|
|
864
|
-
|
|
1272
|
+
}
|
|
1273
|
+
#completedTiming(childReport) {
|
|
1274
|
+
if (this.#relayedWindows.size > 0) {
|
|
1275
|
+
return this.#relayedTiming();
|
|
865
1276
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
if (config.scope) {
|
|
875
|
-
params.set("scope", config.scope);
|
|
1277
|
+
return {
|
|
1278
|
+
activeSeconds: childReport.timingData.durationSeconds,
|
|
1279
|
+
inactiveSeconds: childReport.sessionTimingData?.inactiveSeconds
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
#ensureBridgeRun() {
|
|
1283
|
+
if (!this.#bridge || this.#parentRunId) {
|
|
1284
|
+
return;
|
|
876
1285
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
message: errorMessage,
|
|
885
|
-
error: error instanceof Error ? error : new Error(errorMessage)
|
|
886
|
-
});
|
|
887
|
-
throw error;
|
|
1286
|
+
this.#parentRunId = this.#resumeAccepted && this.#resumeEnvelope?.parentRunId ? this.#resumeEnvelope.parentRunId : crypto.randomUUID();
|
|
1287
|
+
this.#parentResumeId = crypto.randomUUID();
|
|
1288
|
+
if (this.#torndown) {
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
this.#forwardTimer ??= setInterval(() => this.#forwardDirtyWindows(MAX_FORWARDS_PER_TICK), FORWARD_INTERVAL_MS);
|
|
1292
|
+
window.addEventListener("pagehide", this.#onPageHide);
|
|
888
1293
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1294
|
+
#forwardDirtyWindows(limit = Number.POSITIVE_INFINITY) {
|
|
1295
|
+
const bridge = this.#bridge;
|
|
1296
|
+
if (!bridge) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
this.#drainDirtyWindows(limit, (body, windowStartedAtMs) => {
|
|
1300
|
+
const forward = bridge.reporter.postHeartbeat(body).then(() => {
|
|
1301
|
+
this.#forwardedActiveMs += body.timingData.activeMs;
|
|
1302
|
+
this.#forwardedPausedMs += body.timingData.pausedMs;
|
|
1303
|
+
}).catch((error) => {
|
|
1304
|
+
this.#dirtyWindows.add(windowStartedAtMs);
|
|
1305
|
+
console.warn("[Playcademy SDK] embed heartbeat forward failed:", errorMessage(error));
|
|
1306
|
+
}).finally(() => {
|
|
1307
|
+
this.#pendingForwards.delete(forward);
|
|
1308
|
+
});
|
|
1309
|
+
this.#pendingForwards.add(forward);
|
|
1310
|
+
});
|
|
899
1311
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1312
|
+
#onPageHide = () => {
|
|
1313
|
+
const bridge = this.#bridge;
|
|
1314
|
+
if (!bridge) {
|
|
1315
|
+
return;
|
|
903
1316
|
}
|
|
904
|
-
|
|
905
|
-
|
|
1317
|
+
this.#markOpenWindowFlushable();
|
|
1318
|
+
this.#drainDirtyWindows(Number.POSITIVE_INFINITY, (body) => bridge.reporter.postHeartbeatKeepalive(body));
|
|
1319
|
+
};
|
|
1320
|
+
#markOpenWindowFlushable() {
|
|
1321
|
+
const key = this.#currentWindowKey;
|
|
1322
|
+
if (key === null) {
|
|
1323
|
+
return;
|
|
906
1324
|
}
|
|
907
|
-
|
|
908
|
-
|
|
1325
|
+
const snapshot = this.#relayedWindows.get(key);
|
|
1326
|
+
this.#currentWindowKey = null;
|
|
1327
|
+
this.#closedWindows.add(key);
|
|
1328
|
+
if (this.#bridge && snapshot && (snapshot.activeMs > 0 || snapshot.pausedMs > 0)) {
|
|
1329
|
+
this.#dirtyWindows.add(key);
|
|
909
1330
|
}
|
|
910
1331
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1332
|
+
#drainDirtyWindows(limit, post) {
|
|
1333
|
+
if (!this.#bridge || !this.#parentRunId || this.#dirtyWindows.size === 0) {
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const recording = this.#bridge.recording;
|
|
1337
|
+
const runId = this.#parentRunId;
|
|
1338
|
+
const oldestFirst = [...this.#dirtyWindows].toSorted((a, b) => a - b).slice(0, limit);
|
|
1339
|
+
for (const windowStartedAtMs of oldestFirst) {
|
|
1340
|
+
const snapshot = this.#relayedWindows.get(windowStartedAtMs);
|
|
1341
|
+
this.#dirtyWindows.delete(windowStartedAtMs);
|
|
1342
|
+
if (snapshot) {
|
|
1343
|
+
post({
|
|
1344
|
+
runId,
|
|
1345
|
+
resumeId: this.#parentResumeId ?? undefined,
|
|
1346
|
+
activityData: recording,
|
|
1347
|
+
timingData: { activeMs: snapshot.activeMs, pausedMs: snapshot.pausedMs },
|
|
1348
|
+
windowStartedAtMs
|
|
1349
|
+
}, windowStartedAtMs);
|
|
925
1350
|
}
|
|
926
1351
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1352
|
+
}
|
|
1353
|
+
async#postCompletion(bridge, childReport, scores) {
|
|
1354
|
+
this.#ensureBridgeRun();
|
|
1355
|
+
this.#forwardDirtyWindows();
|
|
1356
|
+
await Promise.allSettled(this.#pendingForwards);
|
|
1357
|
+
this.#dirtyWindows.clear();
|
|
1358
|
+
return bridge.reporter.postEndActivity({
|
|
1359
|
+
runId: this.#parentRunId ?? undefined,
|
|
1360
|
+
resumeId: this.#parentResumeId ?? undefined,
|
|
1361
|
+
activityData: bridge.recording,
|
|
1362
|
+
scoreData: {
|
|
1363
|
+
correctQuestions: scores.correctQuestions,
|
|
1364
|
+
totalQuestions: scores.totalQuestions
|
|
1365
|
+
},
|
|
1366
|
+
timingData: {
|
|
1367
|
+
durationSeconds: childReport.timingData.durationSeconds
|
|
1368
|
+
},
|
|
1369
|
+
sessionTimingData: reconcileSessionTiming(this.#reconciliationTotals(childReport.sessionTimingData), this.#forwardedActiveMs, this.#forwardedPausedMs),
|
|
1370
|
+
xpEarned: scores.xpAwarded,
|
|
1371
|
+
masteredUnits: scores.masteredUnits,
|
|
1372
|
+
masteredUnitsAbsolute: scores.masteredUnitsAbsolute,
|
|
1373
|
+
extensions: {
|
|
1374
|
+
...childReport.extensions,
|
|
1375
|
+
...scores.extensions,
|
|
1376
|
+
childGameId: this.#childGameId,
|
|
1377
|
+
childRunId: childReport.runId,
|
|
1378
|
+
childActivityId: childReport.activityData.activityId,
|
|
1379
|
+
childXpSuggested: childReport.xpEarned
|
|
1380
|
+
}
|
|
932
1381
|
});
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const result = await initiateUnifiedFlow(optionsWithState);
|
|
938
|
-
if (result.success && result.user) {
|
|
939
|
-
log.debug("[Playcademy SDK] OAuth login successful", {
|
|
940
|
-
userId: result.user.sub
|
|
941
|
-
});
|
|
1382
|
+
}
|
|
1383
|
+
#reconciliationTotals(reported) {
|
|
1384
|
+
if (reported) {
|
|
1385
|
+
return reported;
|
|
942
1386
|
}
|
|
943
|
-
|
|
944
|
-
} catch (error) {
|
|
945
|
-
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
946
|
-
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
1387
|
+
const relayed = this.#relayedTiming();
|
|
947
1388
|
return {
|
|
948
|
-
|
|
949
|
-
|
|
1389
|
+
activeSeconds: relayed.activeSeconds,
|
|
1390
|
+
inactiveSeconds: relayed.inactiveSeconds
|
|
950
1391
|
};
|
|
951
1392
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1393
|
+
#onChildMessage = (event) => {
|
|
1394
|
+
if (!isTrustedIframeMessage(event, this.iframe.contentWindow, this.#childOrigin)) {
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const data = event.data;
|
|
1398
|
+
if (!data) {
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
const type = data.type;
|
|
1402
|
+
if (type === "PLAYCADEMY_READY" /* READY */) {
|
|
1403
|
+
this.#sawReady = true;
|
|
1404
|
+
this.#stopHandshake?.();
|
|
1405
|
+
this.#stopHandshake = null;
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (type === "PLAYCADEMY_INIT_ERROR" /* INIT_ERROR */) {
|
|
1409
|
+
const payload = data.payload;
|
|
1410
|
+
const reason = typeof payload?.reason === "string" ? payload.reason : "Child game reported an initialization error";
|
|
1411
|
+
if (this.#sawReady) {
|
|
1412
|
+
console.warn(`[Playcademy SDK] embed: child '${this.#options.slug}' reloaded mid-lesson and could not re-initialize (${reason}); abandoning the launch. A stored checkpoint resumes it on the next launch.`);
|
|
1413
|
+
this.#abandon();
|
|
1414
|
+
this.#teardown();
|
|
1415
|
+
return;
|
|
960
1416
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1417
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' failed to initialize: ${reason}`));
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
if (this.#settled && isTrackerRelay(type)) {
|
|
1421
|
+
if (!this.#warnedSettledRelay) {
|
|
1422
|
+
this.#warnedSettledRelay = true;
|
|
1423
|
+
console.warn("[Playcademy SDK] embed: this launch already reported; ignoring tracker relays from the child. One report per launch: launch again for another lesson.");
|
|
1424
|
+
}
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (type === "PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */) {
|
|
1428
|
+
this.#onActivityStartRelay(data.payload);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
if (type === "PLAYCADEMY_CHECKPOINT" /* CHECKPOINT */) {
|
|
1432
|
+
this.#onCheckpointRelay(data.payload);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (type === "PLAYCADEMY_EXIT" /* EXIT */) {
|
|
1436
|
+
this.#abandon();
|
|
1437
|
+
this.#teardown();
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
if (type === "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */) {
|
|
1441
|
+
this.#onHeartbeatRelay(data.payload);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (type === "PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */) {
|
|
1445
|
+
this.#onEndRelay(data.payload);
|
|
1446
|
+
}
|
|
966
1447
|
};
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1448
|
+
#onActivityStartRelay(payload) {
|
|
1449
|
+
const announced = payload?.runId;
|
|
1450
|
+
if (typeof announced === "string") {
|
|
1451
|
+
this.#childAnnouncedRunId = announced;
|
|
1452
|
+
if (announced === this.#resumeEnvelope?.childRunId) {
|
|
1453
|
+
this.#resumeAccepted = true;
|
|
1454
|
+
}
|
|
974
1455
|
}
|
|
975
|
-
|
|
1456
|
+
this.#ensureBridgeRun();
|
|
1457
|
+
this.#persistCheckpoint();
|
|
976
1458
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
eventListeners.delete(eventType);
|
|
983
|
-
}
|
|
1459
|
+
#onCheckpointRelay(payload) {
|
|
1460
|
+
if (isCheckpointRelay(payload)) {
|
|
1461
|
+
this.#checkpointState = payload.state;
|
|
1462
|
+
this.#hasCheckpoint = true;
|
|
1463
|
+
this.#persistCheckpoint();
|
|
984
1464
|
}
|
|
985
1465
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1466
|
+
#persistCheckpoint() {
|
|
1467
|
+
const store = this.#options.resumeStore;
|
|
1468
|
+
if (!store || this.#resumeCleared) {
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const envelope = this.checkpoint;
|
|
1472
|
+
if (!envelope) {
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
this.#enqueueStore(() => store.save(envelope), "save");
|
|
1476
|
+
}
|
|
1477
|
+
#clearResume() {
|
|
1478
|
+
this.#resumeCleared = true;
|
|
1479
|
+
const store = this.#options.resumeStore;
|
|
1480
|
+
if (!store) {
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
this.#enqueueStore(() => store.clear(), "clear");
|
|
1484
|
+
}
|
|
1485
|
+
#enqueueStore(operation, label) {
|
|
1486
|
+
this.#storeChain = this.#storeChain.then(() => tryStore(operation, label));
|
|
1487
|
+
}
|
|
1488
|
+
#onHeartbeatRelay(payload) {
|
|
1489
|
+
const heartbeat = payload;
|
|
1490
|
+
const windowStartedAtMs = heartbeat?.windowStartedAtMs;
|
|
1491
|
+
const activeMs = heartbeat?.timingData?.activeMs ?? 0;
|
|
1492
|
+
const pausedMs = heartbeat?.timingData?.pausedMs ?? 0;
|
|
1493
|
+
if (!isValidHeartbeatRelay(windowStartedAtMs, activeMs, pausedMs)) {
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
this.#relayedWindows.set(windowStartedAtMs, { activeMs, pausedMs });
|
|
1497
|
+
const closed = heartbeat?.windowClosed === true;
|
|
1498
|
+
if (closed) {
|
|
1499
|
+
this.#closedWindows.add(windowStartedAtMs);
|
|
1500
|
+
if (this.#currentWindowKey === windowStartedAtMs) {
|
|
1501
|
+
this.#currentWindowKey = null;
|
|
994
1502
|
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1503
|
+
} else {
|
|
1504
|
+
const superseded = this.#currentWindowKey;
|
|
1505
|
+
if (superseded !== null && superseded !== windowStartedAtMs && !this.#closedWindows.has(superseded)) {
|
|
1506
|
+
this.#closedWindows.add(superseded);
|
|
1507
|
+
if (this.#bridge) {
|
|
1508
|
+
this.#dirtyWindows.add(superseded);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
this.#currentWindowKey = windowStartedAtMs;
|
|
1512
|
+
}
|
|
1513
|
+
if (this.#bridge) {
|
|
1514
|
+
this.#ensureBridgeRun();
|
|
1515
|
+
if (closed) {
|
|
1516
|
+
this.#dirtyWindows.add(windowStartedAtMs);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
#onEndRelay(payload) {
|
|
1521
|
+
const report = payload;
|
|
1522
|
+
if (!isValidEndReport(report)) {
|
|
1523
|
+
console.warn("[Playcademy SDK] embed: ignoring malformed end-activity relay from the child");
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
this.#complete(isValidSessionTiming(report.sessionTimingData) ? report : { ...report, sessionTimingData: undefined });
|
|
1527
|
+
}
|
|
1528
|
+
#onTokenRefresh = (payload) => {
|
|
1529
|
+
if (this.iframe.contentWindow) {
|
|
1530
|
+
messaging.send("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, payload, {
|
|
1531
|
+
target: this.iframe.contentWindow,
|
|
1532
|
+
origin: this.#childOrigin
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
#onLauncherPause = () => {
|
|
1537
|
+
this.#forwardControlToChild("PLAYCADEMY_PAUSE" /* PAUSE */);
|
|
1538
|
+
};
|
|
1539
|
+
#onLauncherResume = () => {
|
|
1540
|
+
this.#forwardControlToChild("PLAYCADEMY_RESUME" /* RESUME */);
|
|
1541
|
+
};
|
|
1542
|
+
#forwardControlToChild(type) {
|
|
1543
|
+
if (this.iframe.contentWindow) {
|
|
1544
|
+
messaging.send(type, undefined, {
|
|
1545
|
+
target: this.iframe.contentWindow,
|
|
1546
|
+
origin: this.#childOrigin
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// ../utils/src/uuid.ts
|
|
1553
|
+
var UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
1554
|
+
function isValidUUID(value) {
|
|
1555
|
+
if (!value || typeof value !== "string") {
|
|
1556
|
+
return false;
|
|
1557
|
+
}
|
|
1558
|
+
return UUID_REGEX.test(value);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// src/core/timeback/keepalive.ts
|
|
1562
|
+
async function sendHeartbeatKeepalive(client, body) {
|
|
1563
|
+
try {
|
|
1564
|
+
const url = `${client["getGameBackendUrl"]()}${TIMEBACK_ROUTES.HEARTBEAT}`;
|
|
1565
|
+
const response = await fetch(url, {
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
headers: {
|
|
1568
|
+
"Content-Type": "application/json",
|
|
1569
|
+
...client["authStrategy"].getHeaders()
|
|
1570
|
+
},
|
|
1571
|
+
body: JSON.stringify(body),
|
|
1572
|
+
keepalive: true
|
|
1004
1573
|
});
|
|
1574
|
+
return response.ok;
|
|
1575
|
+
} catch {
|
|
1576
|
+
return false;
|
|
1005
1577
|
}
|
|
1006
|
-
return {
|
|
1007
|
-
exit: () => {
|
|
1008
|
-
messaging.send("PLAYCADEMY_EXIT" /* EXIT */, undefined);
|
|
1009
|
-
},
|
|
1010
|
-
onInit: (handler) => {
|
|
1011
|
-
messaging.listen("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
1012
|
-
trackListener("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
1013
|
-
},
|
|
1014
|
-
onTokenRefresh: (handler) => {
|
|
1015
|
-
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
1016
|
-
trackListener("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
1017
|
-
},
|
|
1018
|
-
onPause: (handler) => {
|
|
1019
|
-
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
1020
|
-
trackListener("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
1021
|
-
},
|
|
1022
|
-
onResume: (handler) => {
|
|
1023
|
-
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
1024
|
-
trackListener("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
1025
|
-
},
|
|
1026
|
-
onForceExit: (handler) => {
|
|
1027
|
-
messaging.listen("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
1028
|
-
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
1029
|
-
},
|
|
1030
|
-
onOverlay: (handler) => {
|
|
1031
|
-
messaging.listen("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
1032
|
-
trackListener("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
1033
|
-
},
|
|
1034
|
-
ready: () => {
|
|
1035
|
-
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
1036
|
-
},
|
|
1037
|
-
sendTelemetry: (data) => {
|
|
1038
|
-
messaging.send("PLAYCADEMY_TELEMETRY" /* TELEMETRY */, data);
|
|
1039
|
-
},
|
|
1040
|
-
removeListener: (eventType, handler) => {
|
|
1041
|
-
messaging.unlisten(eventType, handler);
|
|
1042
|
-
untrackListener(eventType, handler);
|
|
1043
|
-
},
|
|
1044
|
-
removeAllListeners: () => {
|
|
1045
|
-
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
1046
|
-
for (const handler of handlers) {
|
|
1047
|
-
messaging.unlisten(eventType, handler);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
eventListeners.clear();
|
|
1051
|
-
},
|
|
1052
|
-
getListenerCounts: () => {
|
|
1053
|
-
const counts = {};
|
|
1054
|
-
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
1055
|
-
counts[eventType] = handlers.size;
|
|
1056
|
-
}
|
|
1057
|
-
return counts;
|
|
1058
|
-
},
|
|
1059
|
-
assets: createAssetsNamespace(client)
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
function createAssetsNamespace(client) {
|
|
1063
|
-
async function fetchAsset(path, options) {
|
|
1064
|
-
const gameUrl = client["initPayload"]?.gameUrl;
|
|
1065
|
-
if (!gameUrl) {
|
|
1066
|
-
const relativePath = path.startsWith("./") ? path : `./${path}`;
|
|
1067
|
-
return fetch(relativePath, options);
|
|
1068
|
-
}
|
|
1069
|
-
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
1070
|
-
return fetch(`${gameUrl}${cleanPath}`, options);
|
|
1071
|
-
}
|
|
1072
|
-
return {
|
|
1073
|
-
url(pathOrStrings, ...values) {
|
|
1074
|
-
const gameUrl = client["initPayload"]?.gameUrl;
|
|
1075
|
-
let path;
|
|
1076
|
-
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
1077
|
-
const strings = pathOrStrings;
|
|
1078
|
-
path = strings.reduce((acc, str, i) => acc + str + (values[i] != null ? String(values[i]) : ""), "");
|
|
1079
|
-
} else {
|
|
1080
|
-
path = pathOrStrings;
|
|
1081
|
-
}
|
|
1082
|
-
if (!gameUrl) {
|
|
1083
|
-
return path.startsWith("./") ? path : `./${path}`;
|
|
1084
|
-
}
|
|
1085
|
-
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
1086
|
-
return `${gameUrl}${cleanPath}`;
|
|
1087
|
-
},
|
|
1088
|
-
fetch: fetchAsset,
|
|
1089
|
-
json: async (path) => {
|
|
1090
|
-
const response = await fetchAsset(path);
|
|
1091
|
-
return await response.json();
|
|
1092
|
-
},
|
|
1093
|
-
blob: async (path) => {
|
|
1094
|
-
const response = await fetchAsset(path);
|
|
1095
|
-
return response.blob();
|
|
1096
|
-
},
|
|
1097
|
-
text: async (path) => {
|
|
1098
|
-
const response = await fetchAsset(path);
|
|
1099
|
-
return response.text();
|
|
1100
|
-
},
|
|
1101
|
-
arrayBuffer: async (path) => {
|
|
1102
|
-
const response = await fetchAsset(path);
|
|
1103
|
-
return response.arrayBuffer();
|
|
1104
|
-
}
|
|
1105
|
-
};
|
|
1106
|
-
}
|
|
1107
|
-
// src/namespaces/game/scores.ts
|
|
1108
|
-
function createScoresNamespace(client) {
|
|
1109
|
-
return {
|
|
1110
|
-
submit: async (score, metadata) => {
|
|
1111
|
-
const gameId = client["_ensureGameId"]();
|
|
1112
|
-
return client["request"](`/games/${gameId}/scores`, "POST", {
|
|
1113
|
-
body: {
|
|
1114
|
-
score,
|
|
1115
|
-
metadata
|
|
1116
|
-
}
|
|
1117
|
-
});
|
|
1118
|
-
}
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
// src/core/guards.ts
|
|
1122
|
-
var VALID_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
1123
|
-
var VALID_SUBJECTS = [
|
|
1124
|
-
"Reading",
|
|
1125
|
-
"Language",
|
|
1126
|
-
"Vocabulary",
|
|
1127
|
-
"Social Studies",
|
|
1128
|
-
"Writing",
|
|
1129
|
-
"Science",
|
|
1130
|
-
"FastMath",
|
|
1131
|
-
"Math",
|
|
1132
|
-
"None"
|
|
1133
|
-
];
|
|
1134
|
-
function isValidGrade(value) {
|
|
1135
|
-
return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
|
|
1136
|
-
}
|
|
1137
|
-
function isValidSubject(value) {
|
|
1138
|
-
return typeof value === "string" && VALID_SUBJECTS.includes(value);
|
|
1139
|
-
}
|
|
1140
|
-
// ../constants/src/platform.ts
|
|
1141
|
-
var PLAYCADEMY_BROWSER_TIME_ZONE_HEADER = "x-playcademy-browser-time-zone";
|
|
1142
|
-
// ../constants/src/timeback.ts
|
|
1143
|
-
var TIMEBACK_ROUTES = {
|
|
1144
|
-
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
1145
|
-
GET_XP: "/integrations/timeback/xp",
|
|
1146
|
-
GET_MASTERY: "/integrations/timeback/mastery",
|
|
1147
|
-
GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
|
|
1148
|
-
HEARTBEAT: "/integrations/timeback/heartbeat",
|
|
1149
|
-
ADVANCE_COURSE: "/integrations/timeback/advance-course",
|
|
1150
|
-
UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
|
|
1151
|
-
};
|
|
1152
|
-
var TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
|
|
1153
|
-
xp: 1,
|
|
1154
|
-
mastery: 0,
|
|
1155
|
-
score: 2
|
|
1156
|
-
};
|
|
1157
|
-
var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
|
|
1158
|
-
xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
|
|
1159
|
-
mastery: 0,
|
|
1160
|
-
time: 60,
|
|
1161
|
-
score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
|
|
1162
|
-
};
|
|
1163
|
-
// src/core/cache/ttl-cache.ts
|
|
1164
|
-
function createTTLCache(options) {
|
|
1165
|
-
const cache = new Map;
|
|
1166
|
-
const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
|
|
1167
|
-
async function get(key, loader, config) {
|
|
1168
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1169
|
-
const now = Date.now();
|
|
1170
|
-
const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
|
|
1171
|
-
const force = config?.force || false;
|
|
1172
|
-
const skipCache = config?.skipCache || false;
|
|
1173
|
-
if (effectiveTTL === 0 || skipCache) {
|
|
1174
|
-
return loader();
|
|
1175
|
-
}
|
|
1176
|
-
if (!force) {
|
|
1177
|
-
const cached = cache.get(fullKey);
|
|
1178
|
-
if (cached && cached.expiresAt > now) {
|
|
1179
|
-
return cached.value;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
const promise = loader().catch((error) => {
|
|
1183
|
-
cache.delete(fullKey);
|
|
1184
|
-
throw error;
|
|
1185
|
-
});
|
|
1186
|
-
cache.set(fullKey, {
|
|
1187
|
-
value: promise,
|
|
1188
|
-
expiresAt: now + effectiveTTL
|
|
1189
|
-
});
|
|
1190
|
-
return promise;
|
|
1191
|
-
}
|
|
1192
|
-
function clear(key) {
|
|
1193
|
-
if (key === undefined) {
|
|
1194
|
-
cache.clear();
|
|
1195
|
-
onClear?.();
|
|
1196
|
-
} else {
|
|
1197
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1198
|
-
cache.delete(fullKey);
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
function size() {
|
|
1202
|
-
return cache.size;
|
|
1203
|
-
}
|
|
1204
|
-
function prune() {
|
|
1205
|
-
const now = Date.now();
|
|
1206
|
-
for (const [key, entry] of cache.entries()) {
|
|
1207
|
-
if (entry.expiresAt <= now) {
|
|
1208
|
-
cache.delete(key);
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
function getKeys() {
|
|
1213
|
-
const keys = [];
|
|
1214
|
-
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
1215
|
-
for (const fullKey of cache.keys()) {
|
|
1216
|
-
keys.push(fullKey.substring(prefixLen));
|
|
1217
|
-
}
|
|
1218
|
-
return keys;
|
|
1219
|
-
}
|
|
1220
|
-
function has(key) {
|
|
1221
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1222
|
-
const cached = cache.get(fullKey);
|
|
1223
|
-
if (!cached) {
|
|
1224
|
-
return false;
|
|
1225
|
-
}
|
|
1226
|
-
const now = Date.now();
|
|
1227
|
-
if (cached.expiresAt <= now) {
|
|
1228
|
-
cache.delete(fullKey);
|
|
1229
|
-
return false;
|
|
1230
|
-
}
|
|
1231
|
-
return true;
|
|
1232
|
-
}
|
|
1233
|
-
return { get, clear, size, prune, getKeys, has };
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// ../utils/src/uuid.ts
|
|
1237
|
-
var UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
1238
|
-
function isValidUUID(value) {
|
|
1239
|
-
if (!value || typeof value !== "string") {
|
|
1240
|
-
return false;
|
|
1241
|
-
}
|
|
1242
|
-
return UUID_REGEX.test(value);
|
|
1243
1578
|
}
|
|
1244
1579
|
|
|
1245
1580
|
// src/core/timeback/activity-tracker.ts
|
|
@@ -1254,6 +1589,9 @@ var END_ACTIVITY_RETRY_POLICY = {
|
|
|
1254
1589
|
};
|
|
1255
1590
|
var USER_ACTIVITY_EVENTS = ["keydown", "pointerdown", "pointermove", "wheel"];
|
|
1256
1591
|
var USER_ACTIVITY_LISTENER_OPTIONS = { capture: true };
|
|
1592
|
+
function jsonProjection(value) {
|
|
1593
|
+
return JSON.parse(JSON.stringify(value));
|
|
1594
|
+
}
|
|
1257
1595
|
function normalizeDelayMs(value, defaultValue, allowZero = true) {
|
|
1258
1596
|
if (value === Infinity) {
|
|
1259
1597
|
return Infinity;
|
|
@@ -1313,6 +1651,17 @@ function queueHeartbeatFlush(activity, timing, flush) {
|
|
|
1313
1651
|
}
|
|
1314
1652
|
return activity.flushInFlight;
|
|
1315
1653
|
}
|
|
1654
|
+
function queueClosedWindowRelay(activity, timing, body) {
|
|
1655
|
+
return queueHeartbeatFlush(activity, timing, async () => {
|
|
1656
|
+
try {
|
|
1657
|
+
messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, {
|
|
1658
|
+
...body,
|
|
1659
|
+
windowClosed: true
|
|
1660
|
+
});
|
|
1661
|
+
} catch {}
|
|
1662
|
+
return false;
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1316
1665
|
function stopHeartbeatInterval(activity) {
|
|
1317
1666
|
if (activity.heartbeatIntervalId === null) {
|
|
1318
1667
|
return;
|
|
@@ -1361,6 +1710,18 @@ function createTimebackActivityTracker(client) {
|
|
|
1361
1710
|
let lastRelayedActiveMs = -1;
|
|
1362
1711
|
let lastRelayedPausedMs = -1;
|
|
1363
1712
|
let lastRelayedWindowStart = -1;
|
|
1713
|
+
function relaysToParent() {
|
|
1714
|
+
return client.parent !== null;
|
|
1715
|
+
}
|
|
1716
|
+
let resumeRunIdSpent = false;
|
|
1717
|
+
function adoptResumedRunId() {
|
|
1718
|
+
if (resumeRunIdSpent) {
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
resumeRunIdSpent = true;
|
|
1722
|
+
const resumeRunId = client["initPayload"]?.parent?.resumeRunId;
|
|
1723
|
+
return typeof resumeRunId === "string" && isValidUUID(resumeRunId) ? resumeRunId : undefined;
|
|
1724
|
+
}
|
|
1364
1725
|
function startHeartbeatInterval(activity) {
|
|
1365
1726
|
if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
|
|
1366
1727
|
return;
|
|
@@ -1538,6 +1899,7 @@ function createTimebackActivityTracker(client) {
|
|
|
1538
1899
|
if (!activity) {
|
|
1539
1900
|
return;
|
|
1540
1901
|
}
|
|
1902
|
+
applyOverdueInactivity();
|
|
1541
1903
|
const timing = computeWindowSnapshot(activity);
|
|
1542
1904
|
if (timing.activeMs === lastRelayedActiveMs && timing.pausedMs === lastRelayedPausedMs && timing.windowStartedAtMs === lastRelayedWindowStart) {
|
|
1543
1905
|
return;
|
|
@@ -1600,6 +1962,10 @@ function createTimebackActivityTracker(client) {
|
|
|
1600
1962
|
return;
|
|
1601
1963
|
}
|
|
1602
1964
|
const body = buildHeartbeatBody(trackedActivity, timing, isFinal);
|
|
1965
|
+
if (relaysToParent()) {
|
|
1966
|
+
await queueClosedWindowRelay(trackedActivity, timing, body);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1603
1969
|
await queueHeartbeatFlush(trackedActivity, timing, async () => {
|
|
1604
1970
|
try {
|
|
1605
1971
|
await client["requestGameBackend"](TIMEBACK_ROUTES.HEARTBEAT, "POST", body, undefined, { retryPolicy: HEARTBEAT_RETRY_POLICY });
|
|
@@ -1621,29 +1987,15 @@ function createTimebackActivityTracker(client) {
|
|
|
1621
1987
|
}
|
|
1622
1988
|
const body = buildHeartbeatBody(activity, timing, true);
|
|
1623
1989
|
stopRelayInterval();
|
|
1990
|
+
if (relaysToParent()) {
|
|
1991
|
+
queueClosedWindowRelay(activity, timing, body);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1624
1994
|
try {
|
|
1625
1995
|
messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, body);
|
|
1626
1996
|
} catch {}
|
|
1627
1997
|
if (!client["initPayload"]?.hasHeartbeatRelay) {
|
|
1628
|
-
queueHeartbeatFlush(activity, timing,
|
|
1629
|
-
try {
|
|
1630
|
-
const baseUrl = client["getGameBackendUrl"]();
|
|
1631
|
-
const url = `${baseUrl}${TIMEBACK_ROUTES.HEARTBEAT}`;
|
|
1632
|
-
const headers = {
|
|
1633
|
-
"Content-Type": "application/json",
|
|
1634
|
-
...client["authStrategy"].getHeaders()
|
|
1635
|
-
};
|
|
1636
|
-
const response = await fetch(url, {
|
|
1637
|
-
method: "POST",
|
|
1638
|
-
headers,
|
|
1639
|
-
body: JSON.stringify(body),
|
|
1640
|
-
keepalive: true
|
|
1641
|
-
});
|
|
1642
|
-
return response.ok;
|
|
1643
|
-
} catch {
|
|
1644
|
-
return false;
|
|
1645
|
-
}
|
|
1646
|
-
});
|
|
1998
|
+
queueHeartbeatFlush(activity, timing, () => sendHeartbeatKeepalive(client, body));
|
|
1647
1999
|
}
|
|
1648
2000
|
}
|
|
1649
2001
|
function handlePageHide() {
|
|
@@ -1685,13 +2037,21 @@ function createTimebackActivityTracker(client) {
|
|
|
1685
2037
|
currentRunId() {
|
|
1686
2038
|
return currentActivity?.runId;
|
|
1687
2039
|
},
|
|
1688
|
-
|
|
2040
|
+
isActivityManuallyPaused() {
|
|
2041
|
+
return currentActivity?.pauseReasons.has("manual") ?? false;
|
|
2042
|
+
},
|
|
2043
|
+
startActivity(rawMetadata, options) {
|
|
2044
|
+
if (hasLiveSession(client)) {
|
|
2045
|
+
throw new PlaycademyError("startActivity() is unavailable while an embedded child session is live: the child owns the clock, and a second clock would double-count the lesson. Await the session's finished (or close() it) first.");
|
|
2046
|
+
}
|
|
1689
2047
|
if (options?.runId !== undefined && !isValidUUID(options.runId)) {
|
|
1690
2048
|
throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
|
|
1691
2049
|
}
|
|
2050
|
+
const metadata = jsonProjection(rawMetadata);
|
|
1692
2051
|
cleanupListeners();
|
|
1693
2052
|
const now = Date.now();
|
|
1694
|
-
const
|
|
2053
|
+
const resumedRunId = adoptResumedRunId();
|
|
2054
|
+
const runId = options?.runId ?? resumedRunId ?? crypto.randomUUID();
|
|
1695
2055
|
const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
|
|
1696
2056
|
const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
|
|
1697
2057
|
const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
|
|
@@ -1740,83 +2100,673 @@ function createTimebackActivityTracker(client) {
|
|
|
1740
2100
|
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
1741
2101
|
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
1742
2102
|
syncInactivityTracking();
|
|
1743
|
-
|
|
2103
|
+
if (relaysToParent()) {
|
|
2104
|
+
messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
|
|
2105
|
+
runId,
|
|
2106
|
+
resumeId: currentActivity.resumeId,
|
|
2107
|
+
activityData: metadata
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
return { runId };
|
|
2111
|
+
},
|
|
2112
|
+
pauseActivity() {
|
|
2113
|
+
if (!currentActivity) {
|
|
2114
|
+
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
2115
|
+
}
|
|
2116
|
+
if (currentActivity.pauseReasons.has("manual")) {
|
|
2117
|
+
throw new Error("Activity is already paused.");
|
|
2118
|
+
}
|
|
2119
|
+
addPauseReason("manual");
|
|
2120
|
+
},
|
|
2121
|
+
resumeActivity() {
|
|
2122
|
+
if (!currentActivity) {
|
|
2123
|
+
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
2124
|
+
}
|
|
2125
|
+
if (!currentActivity.pauseReasons.has("manual")) {
|
|
2126
|
+
throw new Error("Activity is not paused.");
|
|
2127
|
+
}
|
|
2128
|
+
if (hasLiveSession(client)) {
|
|
2129
|
+
throw new PlaycademyError("resumeActivity() is unavailable while an embedded child session is live: the child owns the clock, and resuming this game's own clock would double-count the lesson. Resume after the session finishes.");
|
|
2130
|
+
}
|
|
2131
|
+
removePauseReason("manual");
|
|
2132
|
+
},
|
|
2133
|
+
async endActivity(rawData) {
|
|
2134
|
+
if (!currentActivity) {
|
|
2135
|
+
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
2136
|
+
}
|
|
2137
|
+
const data = jsonProjection(rawData);
|
|
2138
|
+
if (!relaysToParent() && typeof data.xpAwarded !== "number") {
|
|
2139
|
+
throw new Error("endActivity() requires xpAwarded when reporting directly. It is optional only in child mode, where the parent game decides the award.");
|
|
2140
|
+
}
|
|
2141
|
+
const activity = currentActivity;
|
|
2142
|
+
applyOverdueInactivity();
|
|
2143
|
+
cleanupListeners();
|
|
2144
|
+
await flushHeartbeat(true);
|
|
2145
|
+
if (activity.pauseStartTime !== null) {
|
|
2146
|
+
activity.pausedTime += Date.now() - activity.pauseStartTime;
|
|
2147
|
+
activity.pauseStartTime = null;
|
|
2148
|
+
}
|
|
2149
|
+
const endTime = Date.now();
|
|
2150
|
+
const totalElapsed = endTime - activity.startTime;
|
|
2151
|
+
const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
|
|
2152
|
+
const durationSeconds = Math.floor(activeTime / 1000);
|
|
2153
|
+
const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
|
|
2154
|
+
const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
|
|
2155
|
+
const { correctQuestions, totalQuestions } = data;
|
|
2156
|
+
if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
|
|
2157
|
+
throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
|
|
2158
|
+
}
|
|
2159
|
+
const request = {
|
|
2160
|
+
runId: activity.runId,
|
|
2161
|
+
resumeId: activity.resumeId,
|
|
2162
|
+
activityData: activity.metadata,
|
|
2163
|
+
scoreData: {
|
|
2164
|
+
correctQuestions,
|
|
2165
|
+
totalQuestions
|
|
2166
|
+
},
|
|
2167
|
+
timingData: {
|
|
2168
|
+
durationSeconds
|
|
2169
|
+
},
|
|
2170
|
+
sessionTimingData: {
|
|
2171
|
+
activeSeconds: unreportedActiveMs / 1000,
|
|
2172
|
+
...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
|
|
2173
|
+
},
|
|
2174
|
+
xpEarned: data.xpAwarded,
|
|
2175
|
+
masteredUnits: data.masteredUnits,
|
|
2176
|
+
masteredUnitsAbsolute: data.masteredUnitsAbsolute,
|
|
2177
|
+
extensions: data.extensions
|
|
2178
|
+
};
|
|
2179
|
+
if (relaysToParent()) {
|
|
2180
|
+
messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */, request);
|
|
2181
|
+
if (currentActivity === activity) {
|
|
2182
|
+
currentActivity = null;
|
|
2183
|
+
}
|
|
2184
|
+
return { status: "relayed", runId: activity.runId };
|
|
2185
|
+
}
|
|
2186
|
+
try {
|
|
2187
|
+
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
|
|
2188
|
+
if (currentActivity === activity) {
|
|
2189
|
+
currentActivity = null;
|
|
2190
|
+
}
|
|
2191
|
+
return response;
|
|
2192
|
+
} catch (error) {
|
|
2193
|
+
if (currentActivity === activity) {
|
|
2194
|
+
currentActivity = null;
|
|
2195
|
+
}
|
|
2196
|
+
throw error;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// src/namespaces/game/embed.ts
|
|
2203
|
+
function createEmbedNamespace(client) {
|
|
2204
|
+
return {
|
|
2205
|
+
launch(options) {
|
|
2206
|
+
assertPlatformMode(client, "embed.launch()");
|
|
2207
|
+
if (typeof document === "undefined") {
|
|
2208
|
+
throw new PlaycademyError("embed.launch() requires a browser document to mount the child iframe.");
|
|
2209
|
+
}
|
|
2210
|
+
const tokenAtLaunch = client.getToken();
|
|
2211
|
+
if (!tokenAtLaunch) {
|
|
2212
|
+
throw new PlaycademyError("embed.launch() requires an authenticated client; the child session runs on this token.");
|
|
2213
|
+
}
|
|
2214
|
+
if (!isValidLaunchIntent(options.intent)) {
|
|
2215
|
+
throw new PlaycademyError("embed.launch() intent needs a non-empty lessonId and an eLevel of 'E1' to 'E4'.");
|
|
2216
|
+
}
|
|
2217
|
+
if (options.timeback && !isValidTimebackRecording(options.timeback)) {
|
|
2218
|
+
throw new PlaycademyError("embed.launch() timeback option needs a non-empty activityId, a grade from -1 to 13, and a valid subject.");
|
|
2219
|
+
}
|
|
2220
|
+
const openRunId = client.timeback.currentRunId;
|
|
2221
|
+
if (openRunId !== undefined && !isActivityManuallyPaused(client)) {
|
|
2222
|
+
throw new PlaycademyError(`embed.launch() requires no open activity: this game's own run ${openRunId} is still active, and its clock would double-count the lesson. Pause it (client.timeback.pauseActivity()) or end it before launching, and resume it deliberately afterwards if you mean to.`);
|
|
2223
|
+
}
|
|
2224
|
+
if (hasLiveSession(client)) {
|
|
2225
|
+
throw new PlaycademyError("embed.launch() requires no live child session: an earlier launch is still running and its child owns the clock. close() it or await its finished before launching another lesson.");
|
|
2226
|
+
}
|
|
2227
|
+
const parentGameId = client["_ensureGameId"]();
|
|
2228
|
+
const resume = resolveResumePolicy({
|
|
2229
|
+
resume: options.resume,
|
|
2230
|
+
token: tokenAtLaunch,
|
|
2231
|
+
parentGameId,
|
|
2232
|
+
slug: options.slug,
|
|
2233
|
+
intent: options.intent
|
|
2234
|
+
});
|
|
2235
|
+
const session = new ChildSession({
|
|
2236
|
+
slug: options.slug,
|
|
2237
|
+
container: options.container,
|
|
2238
|
+
timeback: options.timeback,
|
|
2239
|
+
resume: resume.envelope,
|
|
2240
|
+
resumeStore: resume.store,
|
|
2241
|
+
reporter: {
|
|
2242
|
+
postHeartbeat: (body) => client["requestGameBackend"](TIMEBACK_ROUTES.HEARTBEAT, "POST", body, undefined, {
|
|
2243
|
+
retryPolicy: HEARTBEAT_RETRY_POLICY
|
|
2244
|
+
}),
|
|
2245
|
+
postHeartbeatKeepalive: (body) => {
|
|
2246
|
+
sendHeartbeatKeepalive(client, body);
|
|
2247
|
+
},
|
|
2248
|
+
postEndActivity: (body) => client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", body, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY })
|
|
2249
|
+
},
|
|
2250
|
+
resolveTarget: async (resumeContext) => {
|
|
2251
|
+
const game2 = await client["request"](`/games/${encodeURIComponent(options.slug)}`, "GET").catch((error) => {
|
|
2252
|
+
if (!options.gameUrl) {
|
|
2253
|
+
throw error;
|
|
2254
|
+
}
|
|
2255
|
+
console.warn(`[Playcademy SDK] embed.launch(): slug '${options.slug}' did not resolve (${errorMessage(error)}). Continuing with gameUrl; the slug stands in as the child's game id.`);
|
|
2256
|
+
return null;
|
|
2257
|
+
});
|
|
2258
|
+
const childGameId = game2?.id ?? options.slug;
|
|
2259
|
+
const deploymentUrl = game2 ? game2.deploymentUrl : undefined;
|
|
2260
|
+
const rawUrl = options.gameUrl ?? deploymentUrl;
|
|
2261
|
+
if (!rawUrl) {
|
|
2262
|
+
throw new PlaycademyError(`Game '${options.slug}' has no deployment URL. Pass gameUrl to embed.launch() to point at one.`);
|
|
2263
|
+
}
|
|
2264
|
+
const childUrl = `${rawUrl.replace(/\/$/, "")}/`;
|
|
2265
|
+
const payload = {
|
|
2266
|
+
baseUrl: client.baseUrl,
|
|
2267
|
+
gameUrl: childUrl,
|
|
2268
|
+
token: client.getToken() ?? tokenAtLaunch,
|
|
2269
|
+
gameId: childGameId,
|
|
2270
|
+
mode: "child",
|
|
2271
|
+
parent: buildParentContext(parentGameId, options.intent, resumeContext),
|
|
2272
|
+
localDay: client["initPayload"]?.localDay,
|
|
2273
|
+
launchId: client["launchId"],
|
|
2274
|
+
hasHeartbeatRelay: true
|
|
2275
|
+
};
|
|
2276
|
+
return { childUrl, payload };
|
|
2277
|
+
}
|
|
2278
|
+
});
|
|
2279
|
+
registerLiveSession(client);
|
|
2280
|
+
session.finished.then(() => releaseLiveSession(client));
|
|
2281
|
+
return session;
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
// src/core/auth/utils.ts
|
|
2286
|
+
function openPopupWindow(url, name = "auth-popup", width = 500, height = 600) {
|
|
2287
|
+
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
2288
|
+
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
2289
|
+
const features = [
|
|
2290
|
+
`width=${width}`,
|
|
2291
|
+
`height=${height}`,
|
|
2292
|
+
`left=${left}`,
|
|
2293
|
+
`top=${top}`,
|
|
2294
|
+
"toolbar=no",
|
|
2295
|
+
"menubar=no",
|
|
2296
|
+
"location=yes",
|
|
2297
|
+
"status=yes",
|
|
2298
|
+
"scrollbars=yes",
|
|
2299
|
+
"resizable=yes"
|
|
2300
|
+
].join(",");
|
|
2301
|
+
return window.open(url, name, features);
|
|
2302
|
+
}
|
|
2303
|
+
function isInIframe() {
|
|
2304
|
+
if (typeof globalThis.window === "undefined") {
|
|
2305
|
+
return false;
|
|
2306
|
+
}
|
|
2307
|
+
try {
|
|
2308
|
+
return globalThis.self !== window.top;
|
|
2309
|
+
} catch {
|
|
2310
|
+
return true;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// src/core/auth/flows/popup.ts
|
|
2315
|
+
async function initiatePopupFlow(options) {
|
|
2316
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
2317
|
+
try {
|
|
2318
|
+
onStateChange?.({
|
|
2319
|
+
status: "opening_popup",
|
|
2320
|
+
message: "Opening authentication window..."
|
|
2321
|
+
});
|
|
2322
|
+
const defaults = getOAuthConfig(provider);
|
|
2323
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
2324
|
+
if (!config.clientId) {
|
|
2325
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
2326
|
+
}
|
|
2327
|
+
const stateData = options.stateData;
|
|
2328
|
+
const state = await generateOAuthState(stateData);
|
|
2329
|
+
const params = new URLSearchParams({
|
|
2330
|
+
response_type: "code",
|
|
2331
|
+
client_id: config.clientId,
|
|
2332
|
+
redirect_uri: callbackUrl,
|
|
2333
|
+
state
|
|
2334
|
+
});
|
|
2335
|
+
if (config.scope) {
|
|
2336
|
+
params.set("scope", config.scope);
|
|
2337
|
+
}
|
|
2338
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
2339
|
+
const popup = openPopupWindow(authUrl, "playcademy-auth");
|
|
2340
|
+
if (!popup || popup.closed) {
|
|
2341
|
+
throw new Error("Popup blocked. Please enable popups and try again.");
|
|
2342
|
+
}
|
|
2343
|
+
onStateChange?.({
|
|
2344
|
+
status: "exchanging_token",
|
|
2345
|
+
message: "Waiting for authentication..."
|
|
2346
|
+
});
|
|
2347
|
+
return await waitForServerMessage(popup, onStateChange);
|
|
2348
|
+
} catch (error) {
|
|
2349
|
+
const errorMessage2 = error instanceof Error ? error.message : "Authentication failed";
|
|
2350
|
+
onStateChange?.({
|
|
2351
|
+
status: "error",
|
|
2352
|
+
message: errorMessage2,
|
|
2353
|
+
error: error instanceof Error ? error : new Error(errorMessage2)
|
|
2354
|
+
});
|
|
2355
|
+
throw error;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
async function waitForServerMessage(popup, onStateChange) {
|
|
2359
|
+
return new Promise((resolve) => {
|
|
2360
|
+
let resolved = false;
|
|
2361
|
+
function handleMessage(event) {
|
|
2362
|
+
if (event.origin !== globalThis.location.origin) {
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
const data = event.data;
|
|
2366
|
+
if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
|
|
2367
|
+
resolved = true;
|
|
2368
|
+
window.removeEventListener("message", handleMessage);
|
|
2369
|
+
if (data.authenticated && data.user) {
|
|
2370
|
+
onStateChange?.({
|
|
2371
|
+
status: "complete",
|
|
2372
|
+
message: "Authentication successful"
|
|
2373
|
+
});
|
|
2374
|
+
resolve({
|
|
2375
|
+
success: true,
|
|
2376
|
+
user: data.user
|
|
2377
|
+
});
|
|
2378
|
+
} else {
|
|
2379
|
+
const error = new Error(data.error || "Authentication failed");
|
|
2380
|
+
onStateChange?.({
|
|
2381
|
+
status: "error",
|
|
2382
|
+
message: error.message,
|
|
2383
|
+
error
|
|
2384
|
+
});
|
|
2385
|
+
resolve({
|
|
2386
|
+
success: false,
|
|
2387
|
+
error
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
window.addEventListener("message", handleMessage);
|
|
2393
|
+
const checkClosed = setInterval(() => {
|
|
2394
|
+
if (popup.closed && !resolved) {
|
|
2395
|
+
clearInterval(checkClosed);
|
|
2396
|
+
window.removeEventListener("message", handleMessage);
|
|
2397
|
+
const error = new Error("Authentication cancelled");
|
|
2398
|
+
onStateChange?.({
|
|
2399
|
+
status: "error",
|
|
2400
|
+
message: error.message,
|
|
2401
|
+
error
|
|
2402
|
+
});
|
|
2403
|
+
resolve({
|
|
2404
|
+
success: false,
|
|
2405
|
+
error
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
}, 500);
|
|
2409
|
+
setTimeout(() => {
|
|
2410
|
+
if (!resolved) {
|
|
2411
|
+
window.removeEventListener("message", handleMessage);
|
|
2412
|
+
clearInterval(checkClosed);
|
|
2413
|
+
const error = new Error("Authentication timeout");
|
|
2414
|
+
onStateChange?.({
|
|
2415
|
+
status: "error",
|
|
2416
|
+
message: error.message,
|
|
2417
|
+
error
|
|
2418
|
+
});
|
|
2419
|
+
resolve({
|
|
2420
|
+
success: false,
|
|
2421
|
+
error
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
}, 5 * 60 * 1000);
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// src/core/auth/flows/redirect.ts
|
|
2429
|
+
async function initiateRedirectFlow(options) {
|
|
2430
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
2431
|
+
try {
|
|
2432
|
+
onStateChange?.({
|
|
2433
|
+
status: "opening_popup",
|
|
2434
|
+
message: "Redirecting to authentication provider..."
|
|
2435
|
+
});
|
|
2436
|
+
const defaults = getOAuthConfig(provider);
|
|
2437
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
2438
|
+
if (!config.clientId) {
|
|
2439
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
2440
|
+
}
|
|
2441
|
+
const stateData = options.stateData;
|
|
2442
|
+
const state = await generateOAuthState(stateData);
|
|
2443
|
+
const params = new URLSearchParams({
|
|
2444
|
+
response_type: "code",
|
|
2445
|
+
client_id: config.clientId,
|
|
2446
|
+
redirect_uri: callbackUrl,
|
|
2447
|
+
state
|
|
2448
|
+
});
|
|
2449
|
+
if (config.scope) {
|
|
2450
|
+
params.set("scope", config.scope);
|
|
2451
|
+
}
|
|
2452
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
2453
|
+
globalThis.location.href = authUrl;
|
|
2454
|
+
return new Promise(() => {});
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
const errorMessage2 = error instanceof Error ? error.message : "Authentication failed";
|
|
2457
|
+
onStateChange?.({
|
|
2458
|
+
status: "error",
|
|
2459
|
+
message: errorMessage2,
|
|
2460
|
+
error: error instanceof Error ? error : new Error(errorMessage2)
|
|
2461
|
+
});
|
|
2462
|
+
throw error;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// src/core/auth/flows/unified.ts
|
|
2467
|
+
async function initiateUnifiedFlow(options) {
|
|
2468
|
+
const { mode = "auto" } = options;
|
|
2469
|
+
let effectiveMode;
|
|
2470
|
+
if (mode === "auto") {
|
|
2471
|
+
effectiveMode = isInIframe() ? "popup" : "redirect";
|
|
2472
|
+
} else {
|
|
2473
|
+
effectiveMode = mode;
|
|
2474
|
+
}
|
|
2475
|
+
switch (effectiveMode) {
|
|
2476
|
+
case "popup": {
|
|
2477
|
+
return initiatePopupFlow(options);
|
|
2478
|
+
}
|
|
2479
|
+
case "redirect": {
|
|
2480
|
+
return initiateRedirectFlow(options);
|
|
2481
|
+
}
|
|
2482
|
+
default: {
|
|
2483
|
+
throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// src/core/auth/login.ts
|
|
2489
|
+
async function login2(client, options) {
|
|
2490
|
+
try {
|
|
2491
|
+
let stateData = options.stateData;
|
|
2492
|
+
if (!stateData) {
|
|
2493
|
+
try {
|
|
2494
|
+
const currentUser = await client.users.me();
|
|
2495
|
+
if (currentUser?.id) {
|
|
2496
|
+
stateData = { playcademy_user_id: currentUser.id };
|
|
2497
|
+
}
|
|
2498
|
+
} catch {
|
|
2499
|
+
log.debug("[Playcademy SDK] No current user available for state data");
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
log.debug("[Playcademy SDK] Starting OAuth login", {
|
|
2503
|
+
provider: options.provider,
|
|
2504
|
+
mode: options.mode || "auto",
|
|
2505
|
+
callbackUrl: options.callbackUrl,
|
|
2506
|
+
hasStateData: Boolean(stateData)
|
|
2507
|
+
});
|
|
2508
|
+
const optionsWithState = {
|
|
2509
|
+
...options,
|
|
2510
|
+
stateData
|
|
2511
|
+
};
|
|
2512
|
+
const result = await initiateUnifiedFlow(optionsWithState);
|
|
2513
|
+
if (result.success && result.user) {
|
|
2514
|
+
log.debug("[Playcademy SDK] OAuth login successful", {
|
|
2515
|
+
userId: result.user.sub
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
return result;
|
|
2519
|
+
} catch (error) {
|
|
2520
|
+
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
2521
|
+
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
2522
|
+
return {
|
|
2523
|
+
success: false,
|
|
2524
|
+
error: authError
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// src/namespaces/game/identity.ts
|
|
2530
|
+
function createIdentityNamespace(client) {
|
|
2531
|
+
return {
|
|
2532
|
+
connect: (options) => {
|
|
2533
|
+
if (client.mode === "demo") {
|
|
2534
|
+
throw new PlaycademyError("identity.connect() is not available in demo mode. Use platform or standalone mode for OAuth flows.");
|
|
2535
|
+
}
|
|
2536
|
+
assertNotChildMode(client, "identity.connect()", "Account connections belong to the hub or parent context.");
|
|
2537
|
+
return login2(client, options);
|
|
2538
|
+
},
|
|
2539
|
+
_getContext: () => ({
|
|
2540
|
+
isInIframe: client["authContext"]?.isInIframe ?? false
|
|
2541
|
+
})
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
// src/namespaces/game/runtime.ts
|
|
2545
|
+
function createRuntimeNamespace(client) {
|
|
2546
|
+
const eventListeners = new Map;
|
|
2547
|
+
function trackListener(eventType, handler) {
|
|
2548
|
+
if (!eventListeners.has(eventType)) {
|
|
2549
|
+
eventListeners.set(eventType, new Set);
|
|
2550
|
+
}
|
|
2551
|
+
eventListeners.get(eventType).add(handler);
|
|
2552
|
+
}
|
|
2553
|
+
function untrackListener(eventType, handler) {
|
|
2554
|
+
const listeners = eventListeners.get(eventType);
|
|
2555
|
+
if (listeners) {
|
|
2556
|
+
listeners.delete(handler);
|
|
2557
|
+
if (listeners.size === 0) {
|
|
2558
|
+
eventListeners.delete(eventType);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
if (typeof globalThis.window !== "undefined" && globalThis.self !== window.top) {
|
|
2563
|
+
let keyListener = function(event) {
|
|
2564
|
+
if (keySet.has(event.key?.toLowerCase() ?? "") || keySet.has(event.code?.toLowerCase() ?? "")) {
|
|
2565
|
+
messaging.send("PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */, {
|
|
2566
|
+
key: event.key,
|
|
2567
|
+
code: event.code,
|
|
2568
|
+
type: event.type
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
};
|
|
2572
|
+
const playcademyConfig = globalThis.PLAYCADEMY;
|
|
2573
|
+
const forwardKeys = Array.isArray(playcademyConfig?.forwardKeys) ? playcademyConfig.forwardKeys : ["Escape"];
|
|
2574
|
+
const keySet = new Set(forwardKeys.map((k) => k.toLowerCase()));
|
|
2575
|
+
globalThis.addEventListener("keydown", keyListener);
|
|
2576
|
+
globalThis.addEventListener("keyup", keyListener);
|
|
2577
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, () => {
|
|
2578
|
+
globalThis.removeEventListener("keydown", keyListener);
|
|
2579
|
+
globalThis.removeEventListener("keyup", keyListener);
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
return {
|
|
2583
|
+
exit: () => {
|
|
2584
|
+
messaging.send("PLAYCADEMY_EXIT" /* EXIT */, undefined);
|
|
2585
|
+
},
|
|
2586
|
+
onInit: (handler) => {
|
|
2587
|
+
messaging.listen("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2588
|
+
trackListener("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2589
|
+
},
|
|
2590
|
+
onTokenRefresh: (handler) => {
|
|
2591
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2592
|
+
trackListener("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2593
|
+
},
|
|
2594
|
+
onPause: (handler) => {
|
|
2595
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2596
|
+
trackListener("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2597
|
+
},
|
|
2598
|
+
onResume: (handler) => {
|
|
2599
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2600
|
+
trackListener("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2601
|
+
},
|
|
2602
|
+
onForceExit: (handler) => {
|
|
2603
|
+
messaging.listen("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2604
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2605
|
+
},
|
|
2606
|
+
onOverlay: (handler) => {
|
|
2607
|
+
messaging.listen("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2608
|
+
trackListener("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2609
|
+
},
|
|
2610
|
+
ready: () => {
|
|
2611
|
+
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
2612
|
+
},
|
|
2613
|
+
sendTelemetry: (data) => {
|
|
2614
|
+
messaging.send("PLAYCADEMY_TELEMETRY" /* TELEMETRY */, data);
|
|
2615
|
+
},
|
|
2616
|
+
removeListener: (eventType, handler) => {
|
|
2617
|
+
messaging.unlisten(eventType, handler);
|
|
2618
|
+
untrackListener(eventType, handler);
|
|
1744
2619
|
},
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
throw new Error("Activity is already paused.");
|
|
2620
|
+
removeAllListeners: () => {
|
|
2621
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2622
|
+
for (const handler of handlers) {
|
|
2623
|
+
messaging.unlisten(eventType, handler);
|
|
2624
|
+
}
|
|
1751
2625
|
}
|
|
1752
|
-
|
|
2626
|
+
eventListeners.clear();
|
|
1753
2627
|
},
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
if (!currentActivity.pauseReasons.has("manual")) {
|
|
1759
|
-
throw new Error("Activity is not paused.");
|
|
2628
|
+
getListenerCounts: () => {
|
|
2629
|
+
const counts = {};
|
|
2630
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2631
|
+
counts[eventType] = handlers.size;
|
|
1760
2632
|
}
|
|
1761
|
-
|
|
2633
|
+
return counts;
|
|
1762
2634
|
},
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
2635
|
+
assets: createAssetsNamespace(client)
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
function createAssetsNamespace(client) {
|
|
2639
|
+
async function fetchAsset(path, options) {
|
|
2640
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2641
|
+
if (!gameUrl) {
|
|
2642
|
+
const relativePath = path.startsWith("./") ? path : `./${path}`;
|
|
2643
|
+
return fetch(relativePath, options);
|
|
2644
|
+
}
|
|
2645
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2646
|
+
return fetch(`${gameUrl}${cleanPath}`, options);
|
|
2647
|
+
}
|
|
2648
|
+
return {
|
|
2649
|
+
url(pathOrStrings, ...values) {
|
|
2650
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2651
|
+
let path;
|
|
2652
|
+
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
2653
|
+
const strings = pathOrStrings;
|
|
2654
|
+
path = strings.reduce((acc, str, i) => acc + str + (values[i] != null ? String(values[i]) : ""), "");
|
|
2655
|
+
} else {
|
|
2656
|
+
path = pathOrStrings;
|
|
1774
2657
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
|
|
1778
|
-
const durationSeconds = Math.floor(activeTime / 1000);
|
|
1779
|
-
const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
|
|
1780
|
-
const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
|
|
1781
|
-
const { correctQuestions, totalQuestions } = data;
|
|
1782
|
-
if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
|
|
1783
|
-
throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
|
|
2658
|
+
if (!gameUrl) {
|
|
2659
|
+
return path.startsWith("./") ? path : `./${path}`;
|
|
1784
2660
|
}
|
|
1785
|
-
const
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
2661
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2662
|
+
return `${gameUrl}${cleanPath}`;
|
|
2663
|
+
},
|
|
2664
|
+
fetch: fetchAsset,
|
|
2665
|
+
json: async (path) => {
|
|
2666
|
+
const response = await fetchAsset(path);
|
|
2667
|
+
return await response.json();
|
|
2668
|
+
},
|
|
2669
|
+
blob: async (path) => {
|
|
2670
|
+
const response = await fetchAsset(path);
|
|
2671
|
+
return response.blob();
|
|
2672
|
+
},
|
|
2673
|
+
text: async (path) => {
|
|
2674
|
+
const response = await fetchAsset(path);
|
|
2675
|
+
return response.text();
|
|
2676
|
+
},
|
|
2677
|
+
arrayBuffer: async (path) => {
|
|
2678
|
+
const response = await fetchAsset(path);
|
|
2679
|
+
return response.arrayBuffer();
|
|
2680
|
+
}
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
// src/namespaces/game/scores.ts
|
|
2684
|
+
function createScoresNamespace(client) {
|
|
2685
|
+
return {
|
|
2686
|
+
submit: async (score, metadata) => {
|
|
2687
|
+
assertNotChildMode(client, "scores.submit()", "The parent game owns reporting; the score already reaches it via the activity relay.");
|
|
2688
|
+
const gameId = client["_ensureGameId"]();
|
|
2689
|
+
return client["request"](`/games/${gameId}/scores`, "POST", {
|
|
2690
|
+
body: {
|
|
2691
|
+
score,
|
|
2692
|
+
metadata
|
|
1814
2693
|
}
|
|
1815
|
-
|
|
1816
|
-
}
|
|
2694
|
+
});
|
|
1817
2695
|
}
|
|
1818
2696
|
};
|
|
1819
2697
|
}
|
|
2698
|
+
// src/core/cache/ttl-cache.ts
|
|
2699
|
+
function createTTLCache(options) {
|
|
2700
|
+
const cache = new Map;
|
|
2701
|
+
const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
|
|
2702
|
+
async function get(key, loader, config) {
|
|
2703
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2704
|
+
const now = Date.now();
|
|
2705
|
+
const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
|
|
2706
|
+
const force = config?.force || false;
|
|
2707
|
+
const skipCache = config?.skipCache || false;
|
|
2708
|
+
if (effectiveTTL === 0 || skipCache) {
|
|
2709
|
+
return loader();
|
|
2710
|
+
}
|
|
2711
|
+
if (!force) {
|
|
2712
|
+
const cached = cache.get(fullKey);
|
|
2713
|
+
if (cached && cached.expiresAt > now) {
|
|
2714
|
+
return cached.value;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
const promise = loader().catch((error) => {
|
|
2718
|
+
cache.delete(fullKey);
|
|
2719
|
+
throw error;
|
|
2720
|
+
});
|
|
2721
|
+
cache.set(fullKey, {
|
|
2722
|
+
value: promise,
|
|
2723
|
+
expiresAt: now + effectiveTTL
|
|
2724
|
+
});
|
|
2725
|
+
return promise;
|
|
2726
|
+
}
|
|
2727
|
+
function clear(key) {
|
|
2728
|
+
if (key === undefined) {
|
|
2729
|
+
cache.clear();
|
|
2730
|
+
onClear?.();
|
|
2731
|
+
} else {
|
|
2732
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2733
|
+
cache.delete(fullKey);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
function size() {
|
|
2737
|
+
return cache.size;
|
|
2738
|
+
}
|
|
2739
|
+
function prune() {
|
|
2740
|
+
const now = Date.now();
|
|
2741
|
+
for (const [key, entry] of cache.entries()) {
|
|
2742
|
+
if (entry.expiresAt <= now) {
|
|
2743
|
+
cache.delete(key);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
function getKeys() {
|
|
2748
|
+
const keys = [];
|
|
2749
|
+
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
2750
|
+
for (const fullKey of cache.keys()) {
|
|
2751
|
+
keys.push(fullKey.substring(prefixLen));
|
|
2752
|
+
}
|
|
2753
|
+
return keys;
|
|
2754
|
+
}
|
|
2755
|
+
function has(key) {
|
|
2756
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2757
|
+
const cached = cache.get(fullKey);
|
|
2758
|
+
if (!cached) {
|
|
2759
|
+
return false;
|
|
2760
|
+
}
|
|
2761
|
+
const now = Date.now();
|
|
2762
|
+
if (cached.expiresAt <= now) {
|
|
2763
|
+
cache.delete(fullKey);
|
|
2764
|
+
return false;
|
|
2765
|
+
}
|
|
2766
|
+
return true;
|
|
2767
|
+
}
|
|
2768
|
+
return { get, clear, size, prune, getKeys, has };
|
|
2769
|
+
}
|
|
1820
2770
|
|
|
1821
2771
|
// src/core/timeback/user.ts
|
|
1822
2772
|
function createTimebackUserStore(client) {
|
|
@@ -1975,6 +2925,7 @@ function createTimebackEngine(client) {
|
|
|
1975
2925
|
},
|
|
1976
2926
|
activity: {
|
|
1977
2927
|
currentRunId: activityTracker.currentRunId,
|
|
2928
|
+
isManuallyPaused: activityTracker.isActivityManuallyPaused,
|
|
1978
2929
|
start: activityTracker.startActivity,
|
|
1979
2930
|
pause: activityTracker.pauseActivity,
|
|
1980
2931
|
resume: activityTracker.resumeActivity,
|
|
@@ -2003,11 +2954,111 @@ function createTimebackEngine(client) {
|
|
|
2003
2954
|
// src/namespaces/game/timeback.ts
|
|
2004
2955
|
var VALID_XP_INCLUDE_OPTIONS = ["perCourse", "today"];
|
|
2005
2956
|
var VALID_MASTERY_INCLUDE_OPTIONS = ["perCourse"];
|
|
2957
|
+
var ASSESSMENTS_ROUTE = TIMEBACK_ROUTES.ASSESSMENTS;
|
|
2958
|
+
function validateAssessmentFilters(options) {
|
|
2959
|
+
if (!isAssessmentPurpose(options?.purpose)) {
|
|
2960
|
+
throw new Error("purpose must be end_of_course, diagnostic, review, or mastery");
|
|
2961
|
+
}
|
|
2962
|
+
if (options.grade !== undefined && !isValidGrade(options.grade)) {
|
|
2963
|
+
throw new Error(`Invalid grade: ${options.grade}. Valid grades: ${VALID_GRADES.join(", ")}`);
|
|
2964
|
+
}
|
|
2965
|
+
if (options.subject !== undefined && !isValidSubject(options.subject)) {
|
|
2966
|
+
throw new Error(`Invalid subject: ${options.subject}. Valid subjects: ${VALID_SUBJECTS.join(", ")}`);
|
|
2967
|
+
}
|
|
2968
|
+
if (options.purpose === "mastery") {
|
|
2969
|
+
const { standard } = options;
|
|
2970
|
+
const framework = typeof standard?.framework === "string" ? standard.framework.trim() : "";
|
|
2971
|
+
const identifier = typeof standard?.identifier === "string" ? standard.identifier.trim() : "";
|
|
2972
|
+
if (!framework || !identifier) {
|
|
2973
|
+
throw new Error("mastery standard requires a canonical framework and identifier");
|
|
2974
|
+
}
|
|
2975
|
+
if (framework.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength || identifier.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength) {
|
|
2976
|
+
throw new Error(`mastery standard fields must be at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2006
2980
|
function createTimebackNamespace(client) {
|
|
2007
2981
|
const engine = createTimebackEngine(client);
|
|
2982
|
+
registerPauseProbe(client, () => engine.activity.isManuallyPaused());
|
|
2008
2983
|
return {
|
|
2984
|
+
assessments: {
|
|
2985
|
+
start: async (input) => {
|
|
2986
|
+
assertPlatformMode(client, "timeback.assessments.start()");
|
|
2987
|
+
if (!input.activityId?.trim()) {
|
|
2988
|
+
throw new Error("activityId is required");
|
|
2989
|
+
}
|
|
2990
|
+
validateAssessmentFilters(input);
|
|
2991
|
+
if (input.purpose === "review") {
|
|
2992
|
+
if (!Array.isArray(input.standards) || input.standards.length === 0) {
|
|
2993
|
+
throw new Error("standards must contain at least one standard for review");
|
|
2994
|
+
}
|
|
2995
|
+
if (input.standards.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards) {
|
|
2996
|
+
throw new Error(`standards must contain at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards} standards`);
|
|
2997
|
+
}
|
|
2998
|
+
for (const standard of input.standards) {
|
|
2999
|
+
if (!standard || typeof standard !== "object" || !standard.framework?.trim() || !standard.identifier?.trim()) {
|
|
3000
|
+
throw new Error("review standards require a framework and identifier");
|
|
3001
|
+
}
|
|
3002
|
+
if (standard.framework.trim().length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength || standard.identifier.trim().length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength) {
|
|
3003
|
+
throw new Error(`review standard fields must be at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
if (input.itemsPerStandard !== undefined && (!Number.isInteger(input.itemsPerStandard) || input.itemsPerStandard <= 0 || input.itemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard)) {
|
|
3007
|
+
throw new Error(`itemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard}`);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
|
|
3011
|
+
},
|
|
3012
|
+
latest: async (options) => {
|
|
3013
|
+
assertPlatformMode(client, "timeback.assessments.latest()");
|
|
3014
|
+
validateAssessmentFilters(options);
|
|
3015
|
+
const params = new URLSearchParams({ purpose: options.purpose });
|
|
3016
|
+
if (options.subject !== undefined) {
|
|
3017
|
+
params.set("subject", options.subject);
|
|
3018
|
+
}
|
|
3019
|
+
if (options.grade !== undefined) {
|
|
3020
|
+
params.set("grade", String(options.grade));
|
|
3021
|
+
}
|
|
3022
|
+
if (options.purpose === "mastery") {
|
|
3023
|
+
params.set("standardFramework", options.standard.framework);
|
|
3024
|
+
params.set("standardIdentifier", options.standard.identifier);
|
|
3025
|
+
}
|
|
3026
|
+
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/latest?${params.toString()}`, "GET");
|
|
3027
|
+
},
|
|
3028
|
+
get: async (attemptId) => {
|
|
3029
|
+
assertPlatformMode(client, "timeback.assessments.get()");
|
|
3030
|
+
if (!attemptId?.trim()) {
|
|
3031
|
+
throw new Error("attemptId is required");
|
|
3032
|
+
}
|
|
3033
|
+
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
|
|
3034
|
+
},
|
|
3035
|
+
save: async (attemptId, input) => {
|
|
3036
|
+
assertPlatformMode(client, "timeback.assessments.save()");
|
|
3037
|
+
if (!attemptId?.trim()) {
|
|
3038
|
+
throw new Error("attemptId is required");
|
|
3039
|
+
}
|
|
3040
|
+
if (!Number.isInteger(input.expectedResponseVersion) || input.expectedResponseVersion < 0) {
|
|
3041
|
+
throw new Error("expectedResponseVersion must be a non-negative integer");
|
|
3042
|
+
}
|
|
3043
|
+
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/save`, "POST", input);
|
|
3044
|
+
},
|
|
3045
|
+
submit: async (attemptId, input) => {
|
|
3046
|
+
assertPlatformMode(client, "timeback.assessments.submit()");
|
|
3047
|
+
if (!attemptId?.trim()) {
|
|
3048
|
+
throw new Error("attemptId is required");
|
|
3049
|
+
}
|
|
3050
|
+
if (!Number.isInteger(input.expectedResponseVersion) || input.expectedResponseVersion < 0) {
|
|
3051
|
+
throw new Error("expectedResponseVersion must be a non-negative integer");
|
|
3052
|
+
}
|
|
3053
|
+
if (!input.submissionId?.trim()) {
|
|
3054
|
+
throw new Error("submissionId is required");
|
|
3055
|
+
}
|
|
3056
|
+
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", input);
|
|
3057
|
+
}
|
|
3058
|
+
},
|
|
2009
3059
|
get user() {
|
|
2010
|
-
|
|
3060
|
+
assertNonAnonymousMode(client, "timeback.user");
|
|
3061
|
+
assertNotChildMode(client, "timeback.user", "Course and user context belong to the parent game; pass what the lesson needs through the launch intent's extensions.");
|
|
2011
3062
|
return {
|
|
2012
3063
|
get id() {
|
|
2013
3064
|
return engine.user.snapshot()?.id;
|
|
@@ -2083,23 +3134,23 @@ function createTimebackNamespace(client) {
|
|
|
2083
3134
|
};
|
|
2084
3135
|
},
|
|
2085
3136
|
get currentRunId() {
|
|
2086
|
-
|
|
3137
|
+
assertNonAnonymousMode(client, "timeback.currentRunId");
|
|
2087
3138
|
return engine.activity.currentRunId();
|
|
2088
3139
|
},
|
|
2089
3140
|
startActivity: (metadata, options) => {
|
|
2090
|
-
|
|
3141
|
+
assertNonAnonymousMode(client, "timeback.startActivity()");
|
|
2091
3142
|
return engine.activity.start(metadata, options);
|
|
2092
3143
|
},
|
|
2093
3144
|
pauseActivity: () => {
|
|
2094
|
-
|
|
3145
|
+
assertNonAnonymousMode(client, "timeback.pauseActivity()");
|
|
2095
3146
|
engine.activity.pause();
|
|
2096
3147
|
},
|
|
2097
3148
|
resumeActivity: () => {
|
|
2098
|
-
|
|
3149
|
+
assertNonAnonymousMode(client, "timeback.resumeActivity()");
|
|
2099
3150
|
engine.activity.resume();
|
|
2100
3151
|
},
|
|
2101
3152
|
endActivity: async (data) => {
|
|
2102
|
-
|
|
3153
|
+
assertNonAnonymousMode(client, "timeback.endActivity()");
|
|
2103
3154
|
return engine.activity.end(data);
|
|
2104
3155
|
},
|
|
2105
3156
|
course: {
|
|
@@ -2127,7 +3178,7 @@ function createTimebackNamespace(client) {
|
|
|
2127
3178
|
function createUsersNamespace(client) {
|
|
2128
3179
|
return {
|
|
2129
3180
|
me: async () => {
|
|
2130
|
-
|
|
3181
|
+
assertNonAnonymousMode(client, "users.me()");
|
|
2131
3182
|
const user = await client["request"]("/users/me", "GET");
|
|
2132
3183
|
const initPayload = client["initPayload"];
|
|
2133
3184
|
if (initPayload) {
|
|
@@ -2162,7 +3213,7 @@ function createAuthNamespace(client) {
|
|
|
2162
3213
|
} catch (error) {
|
|
2163
3214
|
return {
|
|
2164
3215
|
success: false,
|
|
2165
|
-
error: error
|
|
3216
|
+
error: errorMessage(error)
|
|
2166
3217
|
};
|
|
2167
3218
|
}
|
|
2168
3219
|
},
|
|
@@ -2177,11 +3228,7 @@ function createAuthNamespace(client) {
|
|
|
2177
3228
|
body: {
|
|
2178
3229
|
name: options?.name || `SDK Key - ${new Date().toISOString()}`,
|
|
2179
3230
|
expiresIn: options?.expiresIn !== undefined ? options.expiresIn : null,
|
|
2180
|
-
permissions: options?.permissions ||
|
|
2181
|
-
games: ["read", "write", "delete"],
|
|
2182
|
-
users: ["read:self", "write:self"],
|
|
2183
|
-
dev: ["read", "write"]
|
|
2184
|
-
}
|
|
3231
|
+
permissions: options?.permissions || DEFAULT_PERSONAL_API_KEY_PERMISSIONS
|
|
2185
3232
|
}
|
|
2186
3233
|
}),
|
|
2187
3234
|
list: async () => client["request"]("/auth/api-key/list", "GET"),
|
|
@@ -2453,8 +3500,27 @@ class DeployPipeline {
|
|
|
2453
3500
|
}
|
|
2454
3501
|
|
|
2455
3502
|
// src/namespaces/platform/dev.ts
|
|
3503
|
+
var BUCKET_LIST_PAGE_SIZE = 1000;
|
|
2456
3504
|
function createDevNamespace(client) {
|
|
2457
3505
|
const deploy = new DeployPipeline(client);
|
|
3506
|
+
async function fetchBucketPage(slug, options = {}) {
|
|
3507
|
+
const params = new URLSearchParams;
|
|
3508
|
+
if (options.prefix) {
|
|
3509
|
+
params.set("prefix", options.prefix);
|
|
3510
|
+
}
|
|
3511
|
+
if (options.cursor) {
|
|
3512
|
+
params.set("cursor", options.cursor);
|
|
3513
|
+
}
|
|
3514
|
+
if (options.limit !== undefined) {
|
|
3515
|
+
params.set("limit", String(options.limit));
|
|
3516
|
+
}
|
|
3517
|
+
if (options.delimiter) {
|
|
3518
|
+
params.set("delimiter", options.delimiter);
|
|
3519
|
+
}
|
|
3520
|
+
const search = params.toString();
|
|
3521
|
+
const query = search === "" ? "" : `?${search}`;
|
|
3522
|
+
return client["request"](`/games/${slug}/bucket${query}`, "GET");
|
|
3523
|
+
}
|
|
2458
3524
|
return {
|
|
2459
3525
|
status: {
|
|
2460
3526
|
apply: () => client["request"]("/dev/apply", "POST"),
|
|
@@ -2586,10 +3652,22 @@ function createDevNamespace(client) {
|
|
|
2586
3652
|
},
|
|
2587
3653
|
bucket: {
|
|
2588
3654
|
list: async (slug, prefix) => {
|
|
2589
|
-
const
|
|
2590
|
-
|
|
2591
|
-
|
|
3655
|
+
const files = [];
|
|
3656
|
+
let cursor;
|
|
3657
|
+
do {
|
|
3658
|
+
const page = await fetchBucketPage(slug, {
|
|
3659
|
+
prefix,
|
|
3660
|
+
cursor,
|
|
3661
|
+
limit: BUCKET_LIST_PAGE_SIZE
|
|
3662
|
+
});
|
|
3663
|
+
for (const file of page.files) {
|
|
3664
|
+
files.push(file);
|
|
3665
|
+
}
|
|
3666
|
+
cursor = page.cursor;
|
|
3667
|
+
} while (cursor);
|
|
3668
|
+
return files;
|
|
2592
3669
|
},
|
|
3670
|
+
listPage: fetchBucketPage,
|
|
2593
3671
|
get: async (slug, key) => {
|
|
2594
3672
|
const res = await client["request"](`/games/${slug}/bucket/${encodeURIComponent(key)}`, "GET", { raw: true });
|
|
2595
3673
|
if (!res.ok) {
|
|
@@ -2825,8 +3903,8 @@ function createAssessmentNamespace(client) {
|
|
|
2825
3903
|
}),
|
|
2826
3904
|
remove: (gameId, courseId, testIdentifier) => client["request"](assessmentPath(gameId, courseId, testIdentifier), "DELETE"),
|
|
2827
3905
|
listTestLibrary: (gameId, courseId, options = {}) => client["request"](`${assessmentPath(gameId, courseId, "test-library")}${buildQtiLibraryQueryString(options)}`, "GET"),
|
|
2828
|
-
copy: (gameId, courseId, testIdentifier, purpose) => client["request"](assessmentPath(gameId, courseId, "copies"), "POST", {
|
|
2829
|
-
body: { testIdentifier, purpose }
|
|
3906
|
+
copy: (gameId, courseId, testIdentifier, purpose, standard) => client["request"](assessmentPath(gameId, courseId, "copies"), "POST", {
|
|
3907
|
+
body: { testIdentifier, purpose, standard }
|
|
2830
3908
|
}),
|
|
2831
3909
|
listQuestions: (gameId, courseId, testIdentifier) => client["request"](assessmentPath(gameId, courseId, testIdentifier, "questions"), "GET"),
|
|
2832
3910
|
listQuestionLibrary: (gameId, courseId, options = {}) => client["request"](`${assessmentPath(gameId, courseId, "question-library")}${buildQtiLibraryQueryString(options)}`, "GET"),
|
|
@@ -2914,7 +3992,8 @@ function createTimebackNamespace2(client) {
|
|
|
2914
3992
|
deactivateCourse: (gameId, courseId) => client["request"](`/timeback/integrations/${gameId}/${courseId}`, "DELETE"),
|
|
2915
3993
|
reactivateCourse: (gameId, courseId) => client["request"](`/timeback/integrations/${gameId}/${courseId}/reactivate`, "POST"),
|
|
2916
3994
|
getIntegrationConfig: (gameId, courseId) => client["request"](`/timeback/integrations/${gameId}/${courseId}`, "GET"),
|
|
2917
|
-
getConfig: (gameId) => client["request"](`/timeback/config/${gameId}`, "GET")
|
|
3995
|
+
getConfig: (gameId) => client["request"](`/timeback/config/${gameId}`, "GET"),
|
|
3996
|
+
exportAssessmentFixtures: (gameId) => client["request"](`/timeback/assessments/fixtures/${gameId}`, "GET")
|
|
2918
3997
|
},
|
|
2919
3998
|
students: {
|
|
2920
3999
|
get: async (timebackId, options) => studentCache.get(timebackId, async () => client["request"](`/timeback/user/${timebackId}`, "GET"), options),
|
|
@@ -3097,6 +4176,24 @@ function createAuthStrategy(token, tokenType) {
|
|
|
3097
4176
|
return new GameJwtAuth(token);
|
|
3098
4177
|
}
|
|
3099
4178
|
|
|
4179
|
+
// src/core/launch/checkpoint.ts
|
|
4180
|
+
var CHECKPOINT_MAX_CHARS = 64000;
|
|
4181
|
+
function sendCheckpointToParent(state) {
|
|
4182
|
+
let serialized;
|
|
4183
|
+
try {
|
|
4184
|
+
serialized = JSON.stringify(state);
|
|
4185
|
+
} catch {}
|
|
4186
|
+
if (serialized === undefined) {
|
|
4187
|
+
console.warn("[Playcademy SDK] parent.checkpoint() dropped a checkpoint that is not JSON-serializable.");
|
|
4188
|
+
return;
|
|
4189
|
+
}
|
|
4190
|
+
if (serialized.length > CHECKPOINT_MAX_CHARS) {
|
|
4191
|
+
console.warn(`[Playcademy SDK] parent.checkpoint() dropped a ${serialized.length}-char checkpoint; the cap is ${CHECKPOINT_MAX_CHARS}.`);
|
|
4192
|
+
return;
|
|
4193
|
+
}
|
|
4194
|
+
messaging.send("PLAYCADEMY_CHECKPOINT" /* CHECKPOINT */, { state: JSON.parse(serialized) });
|
|
4195
|
+
}
|
|
4196
|
+
|
|
3100
4197
|
// src/core/transport/retry.ts
|
|
3101
4198
|
var RETRY_DELAYS_MS = [500, 1500];
|
|
3102
4199
|
function wait(ms) {
|
|
@@ -3239,7 +4336,7 @@ async function request({
|
|
|
3239
4336
|
return rawText && rawText.length > 0 ? rawText : undefined;
|
|
3240
4337
|
}
|
|
3241
4338
|
// src/version.ts
|
|
3242
|
-
var SDK_VERSION = "0.16.
|
|
4339
|
+
var SDK_VERSION = "0.16.1-beta.10";
|
|
3243
4340
|
|
|
3244
4341
|
// src/clients/base.ts
|
|
3245
4342
|
class PlaycademyBaseClient {
|
|
@@ -3252,6 +4349,7 @@ class PlaycademyBaseClient {
|
|
|
3252
4349
|
listeners = {};
|
|
3253
4350
|
authContext;
|
|
3254
4351
|
initPayload;
|
|
4352
|
+
parentHandle;
|
|
3255
4353
|
launchId;
|
|
3256
4354
|
gameOrigin;
|
|
3257
4355
|
browserTimeZone;
|
|
@@ -3286,6 +4384,20 @@ class PlaycademyBaseClient {
|
|
|
3286
4384
|
get localDay() {
|
|
3287
4385
|
return this.initPayload?.localDay;
|
|
3288
4386
|
}
|
|
4387
|
+
get parent() {
|
|
4388
|
+
if (this.mode !== "child") {
|
|
4389
|
+
return null;
|
|
4390
|
+
}
|
|
4391
|
+
const context = this.initPayload?.parent;
|
|
4392
|
+
if (!context) {
|
|
4393
|
+
return null;
|
|
4394
|
+
}
|
|
4395
|
+
this.parentHandle ??= {
|
|
4396
|
+
...context,
|
|
4397
|
+
checkpoint: (state) => sendCheckpointToParent(state)
|
|
4398
|
+
};
|
|
4399
|
+
return this.parentHandle;
|
|
4400
|
+
}
|
|
3289
4401
|
setToken(token, tokenType) {
|
|
3290
4402
|
this.authStrategy = createAuthStrategy(token, tokenType);
|
|
3291
4403
|
this.emit("authChange", { token });
|
|
@@ -3420,10 +4532,15 @@ class PlaycademyInternalClient extends PlaycademyBaseClient {
|
|
|
3420
4532
|
}
|
|
3421
4533
|
export {
|
|
3422
4534
|
messaging,
|
|
4535
|
+
isTrustedIframeMessage,
|
|
3423
4536
|
extractApiErrorInfo,
|
|
4537
|
+
beginInitHandshake,
|
|
3424
4538
|
PlaycademyInternalClient,
|
|
3425
4539
|
PlaycademyError,
|
|
3426
4540
|
PlaycademyInternalClient as PlaycademyClient,
|
|
3427
4541
|
MessageEvents,
|
|
4542
|
+
INIT_WAIT_TIMEOUT_MS,
|
|
4543
|
+
HANDSHAKE_RESEND_INTERVAL_MS,
|
|
4544
|
+
HANDSHAKE_MAX_DURATION_MS,
|
|
3428
4545
|
ApiError
|
|
3429
4546
|
};
|