@playcademy/sdk 0.16.1-beta.2 → 0.16.1-beta.21
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 +59 -3
- package/dist/contracts.d.ts +47 -0
- package/dist/contracts.js +23 -0
- package/dist/index.d.ts +633 -153
- package/dist/index.js +1910 -689
- package/dist/internal.d.ts +565 -25
- package/dist/internal.js +1954 -704
- package/dist/server/edge.d.ts +25 -1
- package/dist/server/edge.js +16 -2
- package/dist/server.d.ts +25 -1
- package/dist/server.js +16 -2
- package/dist/types.d.ts +627 -152
- package/package.json +7 -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,544 +792,769 @@ 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
|
+
candidateItemsPerStandard: 2,
|
|
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 runId() {
|
|
1123
|
+
return this.#parentRunId;
|
|
1124
|
+
}
|
|
1125
|
+
get checkpoint() {
|
|
1126
|
+
if (!this.#hasCheckpoint) {
|
|
1127
|
+
return null;
|
|
767
1128
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
message: errorMessage,
|
|
778
|
-
error: error instanceof Error ? error : new Error(errorMessage)
|
|
1129
|
+
return {
|
|
1130
|
+
state: this.#checkpointState,
|
|
1131
|
+
childRunId: this.#childAnnouncedRunId ?? undefined,
|
|
1132
|
+
parentRunId: this.#parentRunId ?? undefined
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
#open() {
|
|
1136
|
+
this.#boot().catch((error) => {
|
|
1137
|
+
this.#fail(error instanceof PlaycademyError ? error : new PlaycademyError(`embed.launch() could not launch '${this.#options.slug}': ${errorMessage(error)}`));
|
|
779
1138
|
});
|
|
780
|
-
throw error;
|
|
781
1139
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1140
|
+
async#boot() {
|
|
1141
|
+
this.#loadDeadline = setTimeout(() => {
|
|
1142
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' did not become ready within ${HANDSHAKE_MAX_DURATION_MS / 1000}s.`));
|
|
1143
|
+
}, HANDSHAKE_MAX_DURATION_MS);
|
|
1144
|
+
const store = this.#options.resumeStore;
|
|
1145
|
+
if (store) {
|
|
1146
|
+
try {
|
|
1147
|
+
this.#resumeEnvelope = await store.load() ?? null;
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
console.warn("[Playcademy SDK] embed: resume load failed; starting fresh:", errorMessage(error));
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (this.#torndown) {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const envelope = this.#resumeEnvelope;
|
|
1156
|
+
const { childUrl, payload } = await this.#options.resolveTarget(envelope ? { state: envelope.state, childRunId: envelope.childRunId } : undefined);
|
|
1157
|
+
if (this.#torndown) {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
this.#childOrigin = new URL(childUrl).origin;
|
|
1161
|
+
this.#childGameId = payload.gameId;
|
|
1162
|
+
window.addEventListener("message", this.#onChildMessage);
|
|
1163
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, this.#onTokenRefresh);
|
|
1164
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, this.#onLauncherPause);
|
|
1165
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, this.#onLauncherResume);
|
|
1166
|
+
this.iframe.addEventListener("load", () => {
|
|
1167
|
+
this.#clearLoadDeadline();
|
|
1168
|
+
if (this.#torndown) {
|
|
788
1169
|
return;
|
|
789
1170
|
}
|
|
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
|
-
});
|
|
1171
|
+
this.#stopHandshake = beginInitHandshake({
|
|
1172
|
+
iframe: this.iframe,
|
|
1173
|
+
origin: this.#childOrigin,
|
|
1174
|
+
payload,
|
|
1175
|
+
onTimeout: () => {
|
|
1176
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' did not answer INIT within ${HANDSHAKE_MAX_DURATION_MS / 1000}s.`));
|
|
1177
|
+
},
|
|
1178
|
+
onSendError: (error) => {
|
|
1179
|
+
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
1180
|
}
|
|
815
|
-
}
|
|
1181
|
+
});
|
|
1182
|
+
}, { once: true });
|
|
1183
|
+
this.iframe.src = childUrl;
|
|
1184
|
+
}
|
|
1185
|
+
#clearLoadDeadline() {
|
|
1186
|
+
if (this.#loadDeadline) {
|
|
1187
|
+
clearTimeout(this.#loadDeadline);
|
|
1188
|
+
this.#loadDeadline = null;
|
|
816
1189
|
}
|
|
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
|
-
|
|
1190
|
+
}
|
|
1191
|
+
#teardown() {
|
|
1192
|
+
if (this.#torndown) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
this.#torndown = true;
|
|
1196
|
+
this.#stopHandshake?.();
|
|
1197
|
+
this.#stopHandshake = null;
|
|
1198
|
+
this.#clearLoadDeadline();
|
|
1199
|
+
if (this.#forwardTimer) {
|
|
1200
|
+
clearInterval(this.#forwardTimer);
|
|
1201
|
+
this.#forwardTimer = null;
|
|
1202
|
+
}
|
|
1203
|
+
if (this.#watchdogTimer) {
|
|
1204
|
+
clearInterval(this.#watchdogTimer);
|
|
1205
|
+
this.#watchdogTimer = null;
|
|
1206
|
+
}
|
|
1207
|
+
this.#markOpenWindowFlushable();
|
|
1208
|
+
this.#forwardDirtyWindows();
|
|
1209
|
+
window.removeEventListener("message", this.#onChildMessage);
|
|
1210
|
+
window.removeEventListener("pagehide", this.#onPageHide);
|
|
1211
|
+
messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, this.#onTokenRefresh);
|
|
1212
|
+
messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, this.#onLauncherPause);
|
|
1213
|
+
messaging.unlisten("PLAYCADEMY_RESUME" /* RESUME */, this.#onLauncherResume);
|
|
1214
|
+
this.iframe.remove();
|
|
1215
|
+
this.#resolveClosed();
|
|
1216
|
+
}
|
|
1217
|
+
#fail(error) {
|
|
1218
|
+
if (!this.#settled) {
|
|
1219
|
+
this.#settled = true;
|
|
1220
|
+
this.#resolveFinished({ status: "failed", error });
|
|
1221
|
+
}
|
|
1222
|
+
this.#teardown();
|
|
1223
|
+
}
|
|
1224
|
+
#abandon() {
|
|
1225
|
+
if (this.#settled) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
this.#settled = true;
|
|
1229
|
+
const activity = {
|
|
1230
|
+
status: "abandoned",
|
|
1231
|
+
timing: this.#relayedTiming()
|
|
1232
|
+
};
|
|
1233
|
+
if (this.#parentRunId) {
|
|
1234
|
+
activity.runId = this.#parentRunId;
|
|
1235
|
+
}
|
|
1236
|
+
const resume = this.checkpoint;
|
|
1237
|
+
if (resume) {
|
|
1238
|
+
activity.resume = resume;
|
|
1239
|
+
}
|
|
1240
|
+
this.#resolveFinished(activity);
|
|
1241
|
+
}
|
|
1242
|
+
#relayedTiming() {
|
|
1243
|
+
let activeMs = 0;
|
|
1244
|
+
let pausedMs = 0;
|
|
1245
|
+
for (const snapshot of this.#relayedWindows.values()) {
|
|
1246
|
+
activeMs += snapshot.activeMs;
|
|
1247
|
+
pausedMs += snapshot.pausedMs;
|
|
1248
|
+
}
|
|
1249
|
+
return {
|
|
1250
|
+
activeSeconds: Math.round(activeMs / 1000),
|
|
1251
|
+
inactiveSeconds: Math.round(pausedMs / 1000)
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
#complete(childReport) {
|
|
1255
|
+
if (this.#settled) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
this.#settled = true;
|
|
1259
|
+
this.#clearResume();
|
|
1260
|
+
this.#ensureBridgeRun();
|
|
1261
|
+
let endMemo = null;
|
|
1262
|
+
this.#resolveFinished({
|
|
1263
|
+
status: "completed",
|
|
1264
|
+
runId: this.#parentRunId ?? undefined,
|
|
1265
|
+
correct: childReport.scoreData.correctQuestions,
|
|
1266
|
+
total: childReport.scoreData.totalQuestions,
|
|
1267
|
+
timing: this.#completedTiming(childReport),
|
|
1268
|
+
childReport,
|
|
1269
|
+
end: (scores) => {
|
|
1270
|
+
if (!this.#bridge) {
|
|
1271
|
+
return Promise.reject(new PlaycademyError("end() requires the timeback option at launch; this launch was a pure UX embed."));
|
|
1272
|
+
}
|
|
1273
|
+
endMemo ??= this.#postCompletion(this.#bridge, childReport, scores).catch((error) => {
|
|
1274
|
+
endMemo = null;
|
|
1275
|
+
throw error;
|
|
847
1276
|
});
|
|
1277
|
+
return endMemo;
|
|
848
1278
|
}
|
|
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
1279
|
});
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (
|
|
864
|
-
|
|
1280
|
+
}
|
|
1281
|
+
#completedTiming(childReport) {
|
|
1282
|
+
if (this.#relayedWindows.size > 0) {
|
|
1283
|
+
return this.#relayedTiming();
|
|
865
1284
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
if (config.scope) {
|
|
875
|
-
params.set("scope", config.scope);
|
|
1285
|
+
return {
|
|
1286
|
+
activeSeconds: childReport.timingData.durationSeconds,
|
|
1287
|
+
inactiveSeconds: childReport.sessionTimingData?.inactiveSeconds
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
#ensureBridgeRun() {
|
|
1291
|
+
if (!this.#bridge || this.#parentRunId) {
|
|
1292
|
+
return;
|
|
876
1293
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
message: errorMessage,
|
|
885
|
-
error: error instanceof Error ? error : new Error(errorMessage)
|
|
886
|
-
});
|
|
887
|
-
throw error;
|
|
1294
|
+
this.#parentRunId = this.#resumeAccepted && this.#resumeEnvelope?.parentRunId ? this.#resumeEnvelope.parentRunId : crypto.randomUUID();
|
|
1295
|
+
this.#parentResumeId = crypto.randomUUID();
|
|
1296
|
+
if (this.#torndown) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
this.#forwardTimer ??= setInterval(() => this.#forwardDirtyWindows(MAX_FORWARDS_PER_TICK), FORWARD_INTERVAL_MS);
|
|
1300
|
+
window.addEventListener("pagehide", this.#onPageHide);
|
|
888
1301
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1302
|
+
#forwardDirtyWindows(limit = Number.POSITIVE_INFINITY) {
|
|
1303
|
+
const bridge = this.#bridge;
|
|
1304
|
+
if (!bridge) {
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
this.#drainDirtyWindows(limit, (body, windowStartedAtMs) => {
|
|
1308
|
+
const forward = bridge.reporter.postHeartbeat(body).then(() => {
|
|
1309
|
+
this.#forwardedActiveMs += body.timingData.activeMs;
|
|
1310
|
+
this.#forwardedPausedMs += body.timingData.pausedMs;
|
|
1311
|
+
}).catch((error) => {
|
|
1312
|
+
this.#dirtyWindows.add(windowStartedAtMs);
|
|
1313
|
+
console.warn("[Playcademy SDK] embed heartbeat forward failed:", errorMessage(error));
|
|
1314
|
+
}).finally(() => {
|
|
1315
|
+
this.#pendingForwards.delete(forward);
|
|
1316
|
+
});
|
|
1317
|
+
this.#pendingForwards.add(forward);
|
|
1318
|
+
});
|
|
899
1319
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1320
|
+
#onPageHide = () => {
|
|
1321
|
+
const bridge = this.#bridge;
|
|
1322
|
+
if (!bridge) {
|
|
1323
|
+
return;
|
|
903
1324
|
}
|
|
904
|
-
|
|
905
|
-
|
|
1325
|
+
this.#markOpenWindowFlushable();
|
|
1326
|
+
this.#drainDirtyWindows(Number.POSITIVE_INFINITY, (body) => bridge.reporter.postHeartbeatKeepalive(body));
|
|
1327
|
+
};
|
|
1328
|
+
#markOpenWindowFlushable() {
|
|
1329
|
+
const key = this.#currentWindowKey;
|
|
1330
|
+
if (key === null) {
|
|
1331
|
+
return;
|
|
906
1332
|
}
|
|
907
|
-
|
|
908
|
-
|
|
1333
|
+
const snapshot = this.#relayedWindows.get(key);
|
|
1334
|
+
this.#currentWindowKey = null;
|
|
1335
|
+
this.#closedWindows.add(key);
|
|
1336
|
+
if (this.#bridge && snapshot && (snapshot.activeMs > 0 || snapshot.pausedMs > 0)) {
|
|
1337
|
+
this.#dirtyWindows.add(key);
|
|
909
1338
|
}
|
|
910
1339
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1340
|
+
#drainDirtyWindows(limit, post) {
|
|
1341
|
+
if (!this.#bridge || !this.#parentRunId || this.#dirtyWindows.size === 0) {
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
const recording = this.#bridge.recording;
|
|
1345
|
+
const runId = this.#parentRunId;
|
|
1346
|
+
const oldestFirst = [...this.#dirtyWindows].toSorted((a, b) => a - b).slice(0, limit);
|
|
1347
|
+
for (const windowStartedAtMs of oldestFirst) {
|
|
1348
|
+
const snapshot = this.#relayedWindows.get(windowStartedAtMs);
|
|
1349
|
+
this.#dirtyWindows.delete(windowStartedAtMs);
|
|
1350
|
+
if (snapshot) {
|
|
1351
|
+
post({
|
|
1352
|
+
runId,
|
|
1353
|
+
resumeId: this.#parentResumeId ?? undefined,
|
|
1354
|
+
activityData: recording,
|
|
1355
|
+
timingData: { activeMs: snapshot.activeMs, pausedMs: snapshot.pausedMs },
|
|
1356
|
+
windowStartedAtMs
|
|
1357
|
+
}, windowStartedAtMs);
|
|
925
1358
|
}
|
|
926
1359
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1360
|
+
}
|
|
1361
|
+
async#postCompletion(bridge, childReport, scores) {
|
|
1362
|
+
this.#ensureBridgeRun();
|
|
1363
|
+
this.#forwardDirtyWindows();
|
|
1364
|
+
await Promise.allSettled(this.#pendingForwards);
|
|
1365
|
+
this.#dirtyWindows.clear();
|
|
1366
|
+
return bridge.reporter.postEndActivity({
|
|
1367
|
+
runId: this.#parentRunId ?? undefined,
|
|
1368
|
+
resumeId: this.#parentResumeId ?? undefined,
|
|
1369
|
+
activityData: bridge.recording,
|
|
1370
|
+
scoreData: {
|
|
1371
|
+
correctQuestions: scores.correctQuestions,
|
|
1372
|
+
totalQuestions: scores.totalQuestions
|
|
1373
|
+
},
|
|
1374
|
+
timingData: {
|
|
1375
|
+
durationSeconds: childReport.timingData.durationSeconds
|
|
1376
|
+
},
|
|
1377
|
+
sessionTimingData: reconcileSessionTiming(this.#reconciliationTotals(childReport.sessionTimingData), this.#forwardedActiveMs, this.#forwardedPausedMs),
|
|
1378
|
+
xpEarned: scores.xpAwarded,
|
|
1379
|
+
masteredUnits: scores.masteredUnits,
|
|
1380
|
+
masteredUnitsAbsolute: scores.masteredUnitsAbsolute,
|
|
1381
|
+
extensions: {
|
|
1382
|
+
...childReport.extensions,
|
|
1383
|
+
...scores.extensions,
|
|
1384
|
+
childGameId: this.#childGameId,
|
|
1385
|
+
childRunId: childReport.runId,
|
|
1386
|
+
childActivityId: childReport.activityData.activityId,
|
|
1387
|
+
childXpSuggested: childReport.xpEarned
|
|
1388
|
+
}
|
|
932
1389
|
});
|
|
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
|
-
});
|
|
1390
|
+
}
|
|
1391
|
+
#reconciliationTotals(reported) {
|
|
1392
|
+
if (reported) {
|
|
1393
|
+
return reported;
|
|
942
1394
|
}
|
|
943
|
-
|
|
944
|
-
} catch (error) {
|
|
945
|
-
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
946
|
-
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
1395
|
+
const relayed = this.#relayedTiming();
|
|
947
1396
|
return {
|
|
948
|
-
|
|
949
|
-
|
|
1397
|
+
activeSeconds: relayed.activeSeconds,
|
|
1398
|
+
inactiveSeconds: relayed.inactiveSeconds
|
|
950
1399
|
};
|
|
951
1400
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1401
|
+
#onChildMessage = (event) => {
|
|
1402
|
+
if (!isTrustedIframeMessage(event, this.iframe.contentWindow, this.#childOrigin)) {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
const data = event.data;
|
|
1406
|
+
if (!data) {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const type = data.type;
|
|
1410
|
+
if (type === "PLAYCADEMY_READY" /* READY */) {
|
|
1411
|
+
this.#sawReady = true;
|
|
1412
|
+
this.#stopHandshake?.();
|
|
1413
|
+
this.#stopHandshake = null;
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (type === "PLAYCADEMY_INIT_ERROR" /* INIT_ERROR */) {
|
|
1417
|
+
const payload = data.payload;
|
|
1418
|
+
const reason = typeof payload?.reason === "string" ? payload.reason : "Child game reported an initialization error";
|
|
1419
|
+
if (this.#sawReady) {
|
|
1420
|
+
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.`);
|
|
1421
|
+
this.#abandon();
|
|
1422
|
+
this.#teardown();
|
|
1423
|
+
return;
|
|
960
1424
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1425
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' failed to initialize: ${reason}`));
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (this.#settled && isTrackerRelay(type)) {
|
|
1429
|
+
if (!this.#warnedSettledRelay) {
|
|
1430
|
+
this.#warnedSettledRelay = true;
|
|
1431
|
+
console.warn("[Playcademy SDK] embed: this launch already reported; ignoring tracker relays from the child. One report per launch: launch again for another lesson.");
|
|
1432
|
+
}
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (type === "PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */) {
|
|
1436
|
+
this.#onActivityStartRelay(data.payload);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (type === "PLAYCADEMY_CHECKPOINT" /* CHECKPOINT */) {
|
|
1440
|
+
this.#onCheckpointRelay(data.payload);
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
if (type === "PLAYCADEMY_EXIT" /* EXIT */) {
|
|
1444
|
+
this.#abandon();
|
|
1445
|
+
this.#teardown();
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
if (type === "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */) {
|
|
1449
|
+
this.#onHeartbeatRelay(data.payload);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (type === "PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */) {
|
|
1453
|
+
this.#onEndRelay(data.payload);
|
|
1454
|
+
}
|
|
966
1455
|
};
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1456
|
+
#onActivityStartRelay(payload) {
|
|
1457
|
+
const announced = payload?.runId;
|
|
1458
|
+
if (typeof announced === "string") {
|
|
1459
|
+
this.#childAnnouncedRunId = announced;
|
|
1460
|
+
if (announced === this.#resumeEnvelope?.childRunId) {
|
|
1461
|
+
this.#resumeAccepted = true;
|
|
1462
|
+
}
|
|
974
1463
|
}
|
|
975
|
-
|
|
1464
|
+
this.#ensureBridgeRun();
|
|
1465
|
+
this.#persistCheckpoint();
|
|
976
1466
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
eventListeners.delete(eventType);
|
|
983
|
-
}
|
|
1467
|
+
#onCheckpointRelay(payload) {
|
|
1468
|
+
if (isCheckpointRelay(payload)) {
|
|
1469
|
+
this.#checkpointState = payload.state;
|
|
1470
|
+
this.#hasCheckpoint = true;
|
|
1471
|
+
this.#persistCheckpoint();
|
|
984
1472
|
}
|
|
985
1473
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1474
|
+
#persistCheckpoint() {
|
|
1475
|
+
const store = this.#options.resumeStore;
|
|
1476
|
+
if (!store || this.#resumeCleared) {
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const envelope = this.checkpoint;
|
|
1480
|
+
if (!envelope) {
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
this.#enqueueStore(() => store.save(envelope), "save");
|
|
1484
|
+
}
|
|
1485
|
+
#clearResume() {
|
|
1486
|
+
this.#resumeCleared = true;
|
|
1487
|
+
const store = this.#options.resumeStore;
|
|
1488
|
+
if (!store) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
this.#enqueueStore(() => store.clear(), "clear");
|
|
1492
|
+
}
|
|
1493
|
+
#enqueueStore(operation, label) {
|
|
1494
|
+
this.#storeChain = this.#storeChain.then(() => tryStore(operation, label));
|
|
1495
|
+
}
|
|
1496
|
+
#onHeartbeatRelay(payload) {
|
|
1497
|
+
const heartbeat = payload;
|
|
1498
|
+
const windowStartedAtMs = heartbeat?.windowStartedAtMs;
|
|
1499
|
+
const activeMs = heartbeat?.timingData?.activeMs ?? 0;
|
|
1500
|
+
const pausedMs = heartbeat?.timingData?.pausedMs ?? 0;
|
|
1501
|
+
if (!isValidHeartbeatRelay(windowStartedAtMs, activeMs, pausedMs)) {
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
this.#relayedWindows.set(windowStartedAtMs, { activeMs, pausedMs });
|
|
1505
|
+
const closed = heartbeat?.windowClosed === true;
|
|
1506
|
+
if (closed) {
|
|
1507
|
+
this.#closedWindows.add(windowStartedAtMs);
|
|
1508
|
+
if (this.#currentWindowKey === windowStartedAtMs) {
|
|
1509
|
+
this.#currentWindowKey = null;
|
|
994
1510
|
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1511
|
+
} else {
|
|
1512
|
+
const superseded = this.#currentWindowKey;
|
|
1513
|
+
if (superseded !== null && superseded !== windowStartedAtMs && !this.#closedWindows.has(superseded)) {
|
|
1514
|
+
this.#closedWindows.add(superseded);
|
|
1515
|
+
if (this.#bridge) {
|
|
1516
|
+
this.#dirtyWindows.add(superseded);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
this.#currentWindowKey = windowStartedAtMs;
|
|
1520
|
+
}
|
|
1521
|
+
if (this.#bridge) {
|
|
1522
|
+
this.#ensureBridgeRun();
|
|
1523
|
+
if (closed) {
|
|
1524
|
+
this.#dirtyWindows.add(windowStartedAtMs);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1005
1527
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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);
|
|
1528
|
+
#onEndRelay(payload) {
|
|
1529
|
+
const report = payload;
|
|
1530
|
+
if (!isValidEndReport(report)) {
|
|
1531
|
+
console.warn("[Playcademy SDK] embed: ignoring malformed end-activity relay from the child");
|
|
1532
|
+
return;
|
|
1068
1533
|
}
|
|
1069
|
-
|
|
1070
|
-
return fetch(`${gameUrl}${cleanPath}`, options);
|
|
1534
|
+
this.#complete(isValidSessionTiming(report.sessionTimingData) ? report : { ...report, sessionTimingData: undefined });
|
|
1071
1535
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
}
|
|
1536
|
+
#onTokenRefresh = (payload) => {
|
|
1537
|
+
if (this.iframe.contentWindow) {
|
|
1538
|
+
messaging.send("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, payload, {
|
|
1539
|
+
target: this.iframe.contentWindow,
|
|
1540
|
+
origin: this.#childOrigin
|
|
1117
1541
|
});
|
|
1118
1542
|
}
|
|
1119
1543
|
};
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
GET_XP: "/integrations/timeback/xp",
|
|
1133
|
-
GET_MASTERY: "/integrations/timeback/mastery",
|
|
1134
|
-
GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
|
|
1135
|
-
HEARTBEAT: "/integrations/timeback/heartbeat",
|
|
1136
|
-
ADVANCE_COURSE: "/integrations/timeback/advance-course",
|
|
1137
|
-
UNENROLL_COURSE: "/integrations/timeback/unenroll-course",
|
|
1138
|
-
ASSESSMENTS: "/integrations/timeback/assessments"
|
|
1139
|
-
};
|
|
1140
|
-
var TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
1141
|
-
var TIMEBACK_SUBJECTS = [
|
|
1142
|
-
"Reading",
|
|
1143
|
-
"Language",
|
|
1144
|
-
"Vocabulary",
|
|
1145
|
-
"Social Studies",
|
|
1146
|
-
"Writing",
|
|
1147
|
-
"Science",
|
|
1148
|
-
"FastMath",
|
|
1149
|
-
"Math",
|
|
1150
|
-
"None"
|
|
1151
|
-
];
|
|
1152
|
-
var ASSESSMENT_PURPOSES = ["end_of_course", "diagnostic"];
|
|
1153
|
-
var TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
|
|
1154
|
-
xp: 1,
|
|
1155
|
-
mastery: 0,
|
|
1156
|
-
score: 2
|
|
1157
|
-
};
|
|
1158
|
-
var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
|
|
1159
|
-
xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
|
|
1160
|
-
mastery: 0,
|
|
1161
|
-
time: 60,
|
|
1162
|
-
score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
|
|
1163
|
-
};
|
|
1164
|
-
// src/core/guards.ts
|
|
1165
|
-
var VALID_GRADES = TIMEBACK_GRADES;
|
|
1166
|
-
var VALID_SUBJECTS = TIMEBACK_SUBJECTS;
|
|
1167
|
-
function isValidGrade(value) {
|
|
1168
|
-
return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
|
|
1169
|
-
}
|
|
1170
|
-
function isValidSubject(value) {
|
|
1171
|
-
return typeof value === "string" && VALID_SUBJECTS.includes(value);
|
|
1172
|
-
}
|
|
1173
|
-
function isAssessmentPurpose(value) {
|
|
1174
|
-
return typeof value === "string" && ASSESSMENT_PURPOSES.includes(value);
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// src/core/cache/ttl-cache.ts
|
|
1178
|
-
function createTTLCache(options) {
|
|
1179
|
-
const cache = new Map;
|
|
1180
|
-
const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
|
|
1181
|
-
async function get(key, loader, config) {
|
|
1182
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1183
|
-
const now = Date.now();
|
|
1184
|
-
const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
|
|
1185
|
-
const force = config?.force || false;
|
|
1186
|
-
const skipCache = config?.skipCache || false;
|
|
1187
|
-
if (effectiveTTL === 0 || skipCache) {
|
|
1188
|
-
return loader();
|
|
1189
|
-
}
|
|
1190
|
-
if (!force) {
|
|
1191
|
-
const cached = cache.get(fullKey);
|
|
1192
|
-
if (cached && cached.expiresAt > now) {
|
|
1193
|
-
return cached.value;
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
const promise = loader().catch((error) => {
|
|
1197
|
-
cache.delete(fullKey);
|
|
1198
|
-
throw error;
|
|
1199
|
-
});
|
|
1200
|
-
cache.set(fullKey, {
|
|
1201
|
-
value: promise,
|
|
1202
|
-
expiresAt: now + effectiveTTL
|
|
1203
|
-
});
|
|
1204
|
-
return promise;
|
|
1205
|
-
}
|
|
1206
|
-
function clear(key) {
|
|
1207
|
-
if (key === undefined) {
|
|
1208
|
-
cache.clear();
|
|
1209
|
-
onClear?.();
|
|
1210
|
-
} else {
|
|
1211
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1212
|
-
cache.delete(fullKey);
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
function size() {
|
|
1216
|
-
return cache.size;
|
|
1217
|
-
}
|
|
1218
|
-
function prune() {
|
|
1219
|
-
const now = Date.now();
|
|
1220
|
-
for (const [key, entry] of cache.entries()) {
|
|
1221
|
-
if (entry.expiresAt <= now) {
|
|
1222
|
-
cache.delete(key);
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
function getKeys() {
|
|
1227
|
-
const keys = [];
|
|
1228
|
-
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
1229
|
-
for (const fullKey of cache.keys()) {
|
|
1230
|
-
keys.push(fullKey.substring(prefixLen));
|
|
1231
|
-
}
|
|
1232
|
-
return keys;
|
|
1233
|
-
}
|
|
1234
|
-
function has(key) {
|
|
1235
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1236
|
-
const cached = cache.get(fullKey);
|
|
1237
|
-
if (!cached) {
|
|
1238
|
-
return false;
|
|
1239
|
-
}
|
|
1240
|
-
const now = Date.now();
|
|
1241
|
-
if (cached.expiresAt <= now) {
|
|
1242
|
-
cache.delete(fullKey);
|
|
1243
|
-
return false;
|
|
1544
|
+
#onLauncherPause = () => {
|
|
1545
|
+
this.#forwardControlToChild("PLAYCADEMY_PAUSE" /* PAUSE */);
|
|
1546
|
+
};
|
|
1547
|
+
#onLauncherResume = () => {
|
|
1548
|
+
this.#forwardControlToChild("PLAYCADEMY_RESUME" /* RESUME */);
|
|
1549
|
+
};
|
|
1550
|
+
#forwardControlToChild(type) {
|
|
1551
|
+
if (this.iframe.contentWindow) {
|
|
1552
|
+
messaging.send(type, undefined, {
|
|
1553
|
+
target: this.iframe.contentWindow,
|
|
1554
|
+
origin: this.#childOrigin
|
|
1555
|
+
});
|
|
1244
1556
|
}
|
|
1245
|
-
return true;
|
|
1246
1557
|
}
|
|
1247
|
-
return { get, clear, size, prune, getKeys, has };
|
|
1248
1558
|
}
|
|
1249
1559
|
|
|
1250
1560
|
// ../utils/src/uuid.ts
|
|
@@ -1256,6 +1566,25 @@ function isValidUUID(value) {
|
|
|
1256
1566
|
return UUID_REGEX.test(value);
|
|
1257
1567
|
}
|
|
1258
1568
|
|
|
1569
|
+
// src/core/timeback/keepalive.ts
|
|
1570
|
+
async function sendHeartbeatKeepalive(client, body) {
|
|
1571
|
+
try {
|
|
1572
|
+
const url = `${client["getGameBackendUrl"]()}${TIMEBACK_ROUTES.HEARTBEAT}`;
|
|
1573
|
+
const response = await fetch(url, {
|
|
1574
|
+
method: "POST",
|
|
1575
|
+
headers: {
|
|
1576
|
+
"Content-Type": "application/json",
|
|
1577
|
+
...client["authStrategy"].getHeaders()
|
|
1578
|
+
},
|
|
1579
|
+
body: JSON.stringify(body),
|
|
1580
|
+
keepalive: true
|
|
1581
|
+
});
|
|
1582
|
+
return response.ok;
|
|
1583
|
+
} catch {
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1259
1588
|
// src/core/timeback/activity-tracker.ts
|
|
1260
1589
|
var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
1261
1590
|
var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
|
|
@@ -1268,6 +1597,9 @@ var END_ACTIVITY_RETRY_POLICY = {
|
|
|
1268
1597
|
};
|
|
1269
1598
|
var USER_ACTIVITY_EVENTS = ["keydown", "pointerdown", "pointermove", "wheel"];
|
|
1270
1599
|
var USER_ACTIVITY_LISTENER_OPTIONS = { capture: true };
|
|
1600
|
+
function jsonProjection(value) {
|
|
1601
|
+
return JSON.parse(JSON.stringify(value));
|
|
1602
|
+
}
|
|
1271
1603
|
function normalizeDelayMs(value, defaultValue, allowZero = true) {
|
|
1272
1604
|
if (value === Infinity) {
|
|
1273
1605
|
return Infinity;
|
|
@@ -1327,6 +1659,17 @@ function queueHeartbeatFlush(activity, timing, flush) {
|
|
|
1327
1659
|
}
|
|
1328
1660
|
return activity.flushInFlight;
|
|
1329
1661
|
}
|
|
1662
|
+
function queueClosedWindowRelay(activity, timing, body) {
|
|
1663
|
+
return queueHeartbeatFlush(activity, timing, async () => {
|
|
1664
|
+
try {
|
|
1665
|
+
messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, {
|
|
1666
|
+
...body,
|
|
1667
|
+
windowClosed: true
|
|
1668
|
+
});
|
|
1669
|
+
} catch {}
|
|
1670
|
+
return false;
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1330
1673
|
function stopHeartbeatInterval(activity) {
|
|
1331
1674
|
if (activity.heartbeatIntervalId === null) {
|
|
1332
1675
|
return;
|
|
@@ -1375,6 +1718,41 @@ function createTimebackActivityTracker(client) {
|
|
|
1375
1718
|
let lastRelayedActiveMs = -1;
|
|
1376
1719
|
let lastRelayedPausedMs = -1;
|
|
1377
1720
|
let lastRelayedWindowStart = -1;
|
|
1721
|
+
function relaysToParent() {
|
|
1722
|
+
return client.parent !== null;
|
|
1723
|
+
}
|
|
1724
|
+
let resumeRunIdSpent = false;
|
|
1725
|
+
function adoptResumedRunId() {
|
|
1726
|
+
if (resumeRunIdSpent) {
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
resumeRunIdSpent = true;
|
|
1730
|
+
const resumeRunId = client["initPayload"]?.parent?.resumeRunId;
|
|
1731
|
+
return typeof resumeRunId === "string" && isValidUUID(resumeRunId) ? resumeRunId : undefined;
|
|
1732
|
+
}
|
|
1733
|
+
function clockAvailability(owner) {
|
|
1734
|
+
if (owner.kind === "assessment" && currentActivity?.owner.kind === "assessment" && currentActivity.owner.attemptId === owner.attemptId) {
|
|
1735
|
+
return "held-by-this-owner";
|
|
1736
|
+
}
|
|
1737
|
+
if (hasLiveSession(client)) {
|
|
1738
|
+
return { unavailable: "embedded-child" };
|
|
1739
|
+
}
|
|
1740
|
+
if (!currentActivity) {
|
|
1741
|
+
return "available";
|
|
1742
|
+
}
|
|
1743
|
+
if (owner.kind === "activity") {
|
|
1744
|
+
return currentActivity.owner.kind === "assessment" ? {
|
|
1745
|
+
unavailable: "other-assessment",
|
|
1746
|
+
runId: currentActivity.runId,
|
|
1747
|
+
attemptId: currentActivity.owner.attemptId
|
|
1748
|
+
} : "available";
|
|
1749
|
+
}
|
|
1750
|
+
return currentActivity.owner.kind === "assessment" ? {
|
|
1751
|
+
unavailable: "other-assessment",
|
|
1752
|
+
runId: currentActivity.runId,
|
|
1753
|
+
attemptId: currentActivity.owner.attemptId
|
|
1754
|
+
} : { unavailable: "ordinary-activity", runId: currentActivity.runId };
|
|
1755
|
+
}
|
|
1378
1756
|
function startHeartbeatInterval(activity) {
|
|
1379
1757
|
if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
|
|
1380
1758
|
return;
|
|
@@ -1552,6 +1930,7 @@ function createTimebackActivityTracker(client) {
|
|
|
1552
1930
|
if (!activity) {
|
|
1553
1931
|
return;
|
|
1554
1932
|
}
|
|
1933
|
+
applyOverdueInactivity();
|
|
1555
1934
|
const timing = computeWindowSnapshot(activity);
|
|
1556
1935
|
if (timing.activeMs === lastRelayedActiveMs && timing.pausedMs === lastRelayedPausedMs && timing.windowStartedAtMs === lastRelayedWindowStart) {
|
|
1557
1936
|
return;
|
|
@@ -1614,7 +1993,11 @@ function createTimebackActivityTracker(client) {
|
|
|
1614
1993
|
return;
|
|
1615
1994
|
}
|
|
1616
1995
|
const body = buildHeartbeatBody(trackedActivity, timing, isFinal);
|
|
1617
|
-
|
|
1996
|
+
if (relaysToParent()) {
|
|
1997
|
+
await queueClosedWindowRelay(trackedActivity, timing, body);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
await queueHeartbeatFlush(trackedActivity, timing, async () => {
|
|
1618
2001
|
try {
|
|
1619
2002
|
await client["requestGameBackend"](TIMEBACK_ROUTES.HEARTBEAT, "POST", body, undefined, { retryPolicy: HEARTBEAT_RETRY_POLICY });
|
|
1620
2003
|
return true;
|
|
@@ -1635,201 +2018,834 @@ function createTimebackActivityTracker(client) {
|
|
|
1635
2018
|
}
|
|
1636
2019
|
const body = buildHeartbeatBody(activity, timing, true);
|
|
1637
2020
|
stopRelayInterval();
|
|
2021
|
+
if (relaysToParent()) {
|
|
2022
|
+
queueClosedWindowRelay(activity, timing, body);
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
1638
2025
|
try {
|
|
1639
2026
|
messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, body);
|
|
1640
2027
|
} catch {}
|
|
1641
2028
|
if (!client["initPayload"]?.hasHeartbeatRelay) {
|
|
1642
|
-
queueHeartbeatFlush(activity, timing,
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
2029
|
+
queueHeartbeatFlush(activity, timing, () => sendHeartbeatKeepalive(client, body));
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
function handlePageHide() {
|
|
2033
|
+
flushFinalHeartbeatBeacon();
|
|
2034
|
+
}
|
|
2035
|
+
function cleanupListeners() {
|
|
2036
|
+
if (boundVisibilityHandler && typeof document !== "undefined") {
|
|
2037
|
+
document.removeEventListener("visibilitychange", boundVisibilityHandler);
|
|
2038
|
+
boundVisibilityHandler = null;
|
|
2039
|
+
}
|
|
2040
|
+
if (boundUserInteractionHandler && typeof document !== "undefined") {
|
|
2041
|
+
for (const eventName of USER_ACTIVITY_EVENTS) {
|
|
2042
|
+
document.removeEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
|
|
2043
|
+
}
|
|
2044
|
+
boundUserInteractionHandler = null;
|
|
2045
|
+
}
|
|
2046
|
+
if (boundShellPauseHandler) {
|
|
2047
|
+
messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
2048
|
+
boundShellPauseHandler = null;
|
|
2049
|
+
}
|
|
2050
|
+
if (boundShellResumeHandler) {
|
|
2051
|
+
messaging.unlisten("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
2052
|
+
boundShellResumeHandler = null;
|
|
2053
|
+
}
|
|
2054
|
+
if (boundPageHideHandler && typeof globalThis.window !== "undefined") {
|
|
2055
|
+
globalThis.window.removeEventListener("pagehide", boundPageHideHandler);
|
|
2056
|
+
boundPageHideHandler = null;
|
|
2057
|
+
}
|
|
2058
|
+
if (currentActivity) {
|
|
2059
|
+
clearInactivityTimeout(currentActivity);
|
|
2060
|
+
clearPausedHeartbeatTimeout(currentActivity);
|
|
2061
|
+
}
|
|
2062
|
+
if (currentActivity?.heartbeatIntervalId != null) {
|
|
2063
|
+
stopHeartbeatInterval(currentActivity);
|
|
2064
|
+
}
|
|
2065
|
+
stopRelayInterval();
|
|
2066
|
+
}
|
|
2067
|
+
async function finishCurrentActivity(discardRelayedTiming) {
|
|
2068
|
+
const activity = currentActivity;
|
|
2069
|
+
if (activity.finishInFlight) {
|
|
2070
|
+
return activity.finishInFlight;
|
|
2071
|
+
}
|
|
2072
|
+
activity.finishInFlight = (async () => {
|
|
2073
|
+
applyOverdueInactivity();
|
|
2074
|
+
cleanupListeners();
|
|
2075
|
+
const relayedWithoutReconciliation = discardRelayedTiming && relaysToParent();
|
|
2076
|
+
await flushHeartbeat(true);
|
|
2077
|
+
if (activity.pauseStartTime !== null) {
|
|
2078
|
+
activity.pausedTime += Date.now() - activity.pauseStartTime;
|
|
2079
|
+
activity.pauseStartTime = null;
|
|
2080
|
+
}
|
|
2081
|
+
const endTime = Date.now();
|
|
2082
|
+
const totalElapsed = endTime - activity.startTime;
|
|
2083
|
+
const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
|
|
2084
|
+
const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
|
|
2085
|
+
const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
|
|
2086
|
+
if (currentActivity === activity) {
|
|
2087
|
+
currentActivity = null;
|
|
2088
|
+
}
|
|
2089
|
+
return {
|
|
2090
|
+
runId: activity.runId,
|
|
2091
|
+
resumeId: activity.resumeId,
|
|
2092
|
+
activityData: activity.metadata,
|
|
2093
|
+
durationSeconds: Math.floor(activeTime / 1000),
|
|
2094
|
+
sessionTimingData: relayedWithoutReconciliation ? { activeSeconds: 0 } : {
|
|
2095
|
+
activeSeconds: unreportedActiveMs / 1000,
|
|
2096
|
+
...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
})();
|
|
2100
|
+
return activity.finishInFlight;
|
|
2101
|
+
}
|
|
2102
|
+
function assertValidRunId(options) {
|
|
2103
|
+
if (options?.runId !== undefined && !isValidUUID(options.runId)) {
|
|
2104
|
+
throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
function beginActivity(rawMetadata, options, owner) {
|
|
2108
|
+
const metadata = jsonProjection(rawMetadata);
|
|
2109
|
+
cleanupListeners();
|
|
2110
|
+
const now = Date.now();
|
|
2111
|
+
const runId = owner.kind === "assessment" ? owner.attemptId : options?.runId ?? adoptResumedRunId() ?? crypto.randomUUID();
|
|
2112
|
+
const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
|
|
2113
|
+
const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
|
|
2114
|
+
const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
|
|
2115
|
+
currentActivity = {
|
|
2116
|
+
runId,
|
|
2117
|
+
resumeId: crypto.randomUUID(),
|
|
2118
|
+
owner,
|
|
2119
|
+
startTime: now,
|
|
2120
|
+
metadata,
|
|
2121
|
+
pausedTime: 0,
|
|
2122
|
+
pauseStartTime: null,
|
|
2123
|
+
pauseReasons: new Set,
|
|
2124
|
+
pausedHeartbeatTimeoutId: null,
|
|
2125
|
+
pausedHeartbeatTimedOut: false,
|
|
2126
|
+
pausedHeartbeatTimeoutMs,
|
|
2127
|
+
windowStartTime: now,
|
|
2128
|
+
windowPausedAtStart: 0,
|
|
2129
|
+
heartbeatIntervalId: null,
|
|
2130
|
+
heartbeatIntervalMs,
|
|
2131
|
+
inactivityTimeoutId: null,
|
|
2132
|
+
inactivityTimeoutMs,
|
|
2133
|
+
inactivityTimerStartedAt: null,
|
|
2134
|
+
remainingInactivityMs: inactivityTimeoutMs,
|
|
2135
|
+
flushInFlight: null,
|
|
2136
|
+
finishInFlight: null,
|
|
2137
|
+
totalPersistedActiveMs: 0,
|
|
2138
|
+
totalPersistedPausedMs: 0
|
|
2139
|
+
};
|
|
2140
|
+
if (typeof document !== "undefined") {
|
|
2141
|
+
boundVisibilityHandler = handleVisibilityChange;
|
|
2142
|
+
document.addEventListener("visibilitychange", boundVisibilityHandler);
|
|
2143
|
+
boundUserInteractionHandler = handleUserInteraction;
|
|
2144
|
+
for (const eventName of USER_ACTIVITY_EVENTS) {
|
|
2145
|
+
document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
|
|
2146
|
+
}
|
|
2147
|
+
if (document.visibilityState === "hidden") {
|
|
2148
|
+
handleVisibilityChange();
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
startHeartbeatInterval(currentActivity);
|
|
2152
|
+
startRelayInterval();
|
|
2153
|
+
if (typeof globalThis.window !== "undefined") {
|
|
2154
|
+
boundPageHideHandler = handlePageHide;
|
|
2155
|
+
globalThis.window.addEventListener("pagehide", boundPageHideHandler);
|
|
2156
|
+
}
|
|
2157
|
+
boundShellPauseHandler = handleShellPause;
|
|
2158
|
+
boundShellResumeHandler = handleShellResume;
|
|
2159
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
2160
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
2161
|
+
syncInactivityTracking();
|
|
2162
|
+
if (relaysToParent()) {
|
|
2163
|
+
messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
|
|
2164
|
+
runId,
|
|
2165
|
+
resumeId: currentActivity.resumeId,
|
|
2166
|
+
activityData: metadata
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
return { runId };
|
|
2170
|
+
}
|
|
2171
|
+
return {
|
|
2172
|
+
currentRunId() {
|
|
2173
|
+
return currentActivity?.runId;
|
|
2174
|
+
},
|
|
2175
|
+
isActivityManuallyPaused() {
|
|
2176
|
+
return currentActivity?.pauseReasons.has("manual") ?? false;
|
|
2177
|
+
},
|
|
2178
|
+
startActivity(rawMetadata, options, owner = { kind: "activity" }) {
|
|
2179
|
+
const availability = clockAvailability(owner);
|
|
2180
|
+
if (typeof availability === "object" && availability.unavailable === "embedded-child") {
|
|
2181
|
+
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.");
|
|
2182
|
+
}
|
|
2183
|
+
assertValidRunId(options);
|
|
2184
|
+
if (availability === "held-by-this-owner") {
|
|
2185
|
+
return { runId: currentActivity.runId };
|
|
2186
|
+
}
|
|
2187
|
+
if (typeof availability === "object") {
|
|
2188
|
+
if (owner.kind === "assessment") {
|
|
2189
|
+
throw new Error(`Cannot track assessment ${owner.attemptId} while activity run ${availability.runId} is active. End the current activity first.`);
|
|
2190
|
+
}
|
|
2191
|
+
const blockingAssessment = availability;
|
|
2192
|
+
throw new Error(`Cannot start an activity while assessment ${blockingAssessment.attemptId} is in progress. Submit the assessment first.`);
|
|
2193
|
+
}
|
|
2194
|
+
return beginActivity(rawMetadata, options, owner);
|
|
2195
|
+
},
|
|
2196
|
+
tryStartAssessmentTracking(metadata, attemptId) {
|
|
2197
|
+
if (!isValidUUID(attemptId)) {
|
|
2198
|
+
return { status: "untrackable", reason: "invalid-attempt-id" };
|
|
2199
|
+
}
|
|
2200
|
+
const owner = { kind: "assessment", attemptId };
|
|
2201
|
+
const availability = clockAvailability(owner);
|
|
2202
|
+
if (typeof availability === "object") {
|
|
2203
|
+
return { status: "unavailable", reason: availability.unavailable };
|
|
2204
|
+
}
|
|
2205
|
+
if (availability === "held-by-this-owner") {
|
|
2206
|
+
return { status: "already-attached", runId: currentActivity.runId };
|
|
2207
|
+
}
|
|
2208
|
+
return {
|
|
2209
|
+
status: "attached",
|
|
2210
|
+
runId: beginActivity(metadata, { runId: attemptId }, owner).runId
|
|
2211
|
+
};
|
|
2212
|
+
},
|
|
2213
|
+
pauseActivity() {
|
|
2214
|
+
if (!currentActivity) {
|
|
2215
|
+
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
2216
|
+
}
|
|
2217
|
+
if (currentActivity.pauseReasons.has("manual")) {
|
|
2218
|
+
throw new Error("Activity is already paused.");
|
|
2219
|
+
}
|
|
2220
|
+
addPauseReason("manual");
|
|
2221
|
+
},
|
|
2222
|
+
resumeActivity() {
|
|
2223
|
+
if (!currentActivity) {
|
|
2224
|
+
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
2225
|
+
}
|
|
2226
|
+
if (!currentActivity.pauseReasons.has("manual")) {
|
|
2227
|
+
throw new Error("Activity is not paused.");
|
|
2228
|
+
}
|
|
2229
|
+
if (hasLiveSession(client)) {
|
|
2230
|
+
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.");
|
|
2231
|
+
}
|
|
2232
|
+
removePauseReason("manual");
|
|
2233
|
+
},
|
|
2234
|
+
async finishAssessmentTracking(expectedAttemptId) {
|
|
2235
|
+
if (!currentActivity || currentActivity.owner.kind !== "assessment" || currentActivity.owner.attemptId !== expectedAttemptId) {
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
return finishCurrentActivity(true);
|
|
2239
|
+
},
|
|
2240
|
+
async endActivity(rawData) {
|
|
2241
|
+
if (!currentActivity) {
|
|
2242
|
+
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
2243
|
+
}
|
|
2244
|
+
if (currentActivity.owner.kind === "assessment") {
|
|
2245
|
+
throw new Error(`endActivity() cannot finalize assessment ${currentActivity.owner.attemptId}. Submit it with timeback.assessments.submit() instead.`);
|
|
2246
|
+
}
|
|
2247
|
+
const data = jsonProjection(rawData);
|
|
2248
|
+
if (!relaysToParent() && typeof data.xpAwarded !== "number") {
|
|
2249
|
+
throw new Error("endActivity() requires xpAwarded when reporting directly. It is optional only in child mode, where the parent game decides the award.");
|
|
2250
|
+
}
|
|
2251
|
+
const { correctQuestions, totalQuestions } = data;
|
|
2252
|
+
if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
|
|
2253
|
+
throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
|
|
2254
|
+
}
|
|
2255
|
+
const tracking = await finishCurrentActivity(false);
|
|
2256
|
+
const request = {
|
|
2257
|
+
runId: tracking.runId,
|
|
2258
|
+
resumeId: tracking.resumeId,
|
|
2259
|
+
activityData: tracking.activityData,
|
|
2260
|
+
scoreData: {
|
|
2261
|
+
correctQuestions,
|
|
2262
|
+
totalQuestions
|
|
2263
|
+
},
|
|
2264
|
+
timingData: {
|
|
2265
|
+
durationSeconds: tracking.durationSeconds
|
|
2266
|
+
},
|
|
2267
|
+
sessionTimingData: tracking.sessionTimingData,
|
|
2268
|
+
xpEarned: data.xpAwarded,
|
|
2269
|
+
masteredUnits: data.masteredUnits,
|
|
2270
|
+
masteredUnitsAbsolute: data.masteredUnitsAbsolute,
|
|
2271
|
+
extensions: data.extensions
|
|
2272
|
+
};
|
|
2273
|
+
if (relaysToParent()) {
|
|
2274
|
+
messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */, request);
|
|
2275
|
+
return { status: "relayed", runId: tracking.runId };
|
|
2276
|
+
}
|
|
2277
|
+
return client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
|
|
2278
|
+
}
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// src/namespaces/game/embed.ts
|
|
2283
|
+
function createEmbedNamespace(client) {
|
|
2284
|
+
return {
|
|
2285
|
+
launch(options) {
|
|
2286
|
+
assertPlatformMode(client, "embed.launch()");
|
|
2287
|
+
if (typeof document === "undefined") {
|
|
2288
|
+
throw new PlaycademyError("embed.launch() requires a browser document to mount the child iframe.");
|
|
2289
|
+
}
|
|
2290
|
+
const tokenAtLaunch = client.getToken();
|
|
2291
|
+
if (!tokenAtLaunch) {
|
|
2292
|
+
throw new PlaycademyError("embed.launch() requires an authenticated client; the child session runs on this token.");
|
|
2293
|
+
}
|
|
2294
|
+
if (!isValidLaunchIntent(options.intent)) {
|
|
2295
|
+
throw new PlaycademyError("embed.launch() intent needs a non-empty lessonId and an eLevel of 'E1' to 'E4'.");
|
|
2296
|
+
}
|
|
2297
|
+
if (options.timeback && !isValidTimebackRecording(options.timeback)) {
|
|
2298
|
+
throw new PlaycademyError("embed.launch() timeback option needs a non-empty activityId, a grade from -1 to 13, and a valid subject.");
|
|
2299
|
+
}
|
|
2300
|
+
const openRunId = client.timeback.currentRunId;
|
|
2301
|
+
if (openRunId !== undefined && !isActivityManuallyPaused(client)) {
|
|
2302
|
+
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.`);
|
|
2303
|
+
}
|
|
2304
|
+
if (hasLiveSession(client)) {
|
|
2305
|
+
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.");
|
|
2306
|
+
}
|
|
2307
|
+
const parentGameId = client["_ensureGameId"]();
|
|
2308
|
+
const resume = resolveResumePolicy({
|
|
2309
|
+
resume: options.resume,
|
|
2310
|
+
token: tokenAtLaunch,
|
|
2311
|
+
parentGameId,
|
|
2312
|
+
slug: options.slug,
|
|
2313
|
+
intent: options.intent
|
|
2314
|
+
});
|
|
2315
|
+
const session = new ChildSession({
|
|
2316
|
+
slug: options.slug,
|
|
2317
|
+
container: options.container,
|
|
2318
|
+
timeback: options.timeback,
|
|
2319
|
+
resume: resume.envelope,
|
|
2320
|
+
resumeStore: resume.store,
|
|
2321
|
+
reporter: {
|
|
2322
|
+
postHeartbeat: (body) => client["requestGameBackend"](TIMEBACK_ROUTES.HEARTBEAT, "POST", body, undefined, {
|
|
2323
|
+
retryPolicy: HEARTBEAT_RETRY_POLICY
|
|
2324
|
+
}),
|
|
2325
|
+
postHeartbeatKeepalive: (body) => {
|
|
2326
|
+
sendHeartbeatKeepalive(client, body);
|
|
2327
|
+
},
|
|
2328
|
+
postEndActivity: (body) => client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", body, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY })
|
|
2329
|
+
},
|
|
2330
|
+
resolveTarget: async (resumeContext) => {
|
|
2331
|
+
const game2 = await client["request"](`/games/${encodeURIComponent(options.slug)}`, "GET").catch((error) => {
|
|
2332
|
+
if (!options.gameUrl) {
|
|
2333
|
+
throw error;
|
|
2334
|
+
}
|
|
2335
|
+
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.`);
|
|
2336
|
+
return null;
|
|
2337
|
+
});
|
|
2338
|
+
const childGameId = game2?.id ?? options.slug;
|
|
2339
|
+
const deploymentUrl = game2 ? game2.deploymentUrl : undefined;
|
|
2340
|
+
const rawUrl = options.gameUrl ?? deploymentUrl;
|
|
2341
|
+
if (!rawUrl) {
|
|
2342
|
+
throw new PlaycademyError(`Game '${options.slug}' has no deployment URL. Pass gameUrl to embed.launch() to point at one.`);
|
|
2343
|
+
}
|
|
2344
|
+
const childUrl = `${rawUrl.replace(/\/$/, "")}/`;
|
|
2345
|
+
const payload = {
|
|
2346
|
+
baseUrl: client.baseUrl,
|
|
2347
|
+
gameUrl: childUrl,
|
|
2348
|
+
token: client.getToken() ?? tokenAtLaunch,
|
|
2349
|
+
gameId: childGameId,
|
|
2350
|
+
mode: "child",
|
|
2351
|
+
parent: buildParentContext(parentGameId, options.intent, resumeContext),
|
|
2352
|
+
localDay: client["initPayload"]?.localDay,
|
|
2353
|
+
launchId: client["launchId"],
|
|
2354
|
+
hasHeartbeatRelay: true
|
|
1649
2355
|
};
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2356
|
+
return { childUrl, payload };
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
registerLiveSession(client);
|
|
2360
|
+
session.finished.then(() => releaseLiveSession(client));
|
|
2361
|
+
return session;
|
|
2362
|
+
}
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
// src/core/auth/utils.ts
|
|
2366
|
+
function openPopupWindow(url, name = "auth-popup", width = 500, height = 600) {
|
|
2367
|
+
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
2368
|
+
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
2369
|
+
const features = [
|
|
2370
|
+
`width=${width}`,
|
|
2371
|
+
`height=${height}`,
|
|
2372
|
+
`left=${left}`,
|
|
2373
|
+
`top=${top}`,
|
|
2374
|
+
"toolbar=no",
|
|
2375
|
+
"menubar=no",
|
|
2376
|
+
"location=yes",
|
|
2377
|
+
"status=yes",
|
|
2378
|
+
"scrollbars=yes",
|
|
2379
|
+
"resizable=yes"
|
|
2380
|
+
].join(",");
|
|
2381
|
+
return window.open(url, name, features);
|
|
2382
|
+
}
|
|
2383
|
+
function isInIframe() {
|
|
2384
|
+
if (typeof globalThis.window === "undefined") {
|
|
2385
|
+
return false;
|
|
2386
|
+
}
|
|
2387
|
+
try {
|
|
2388
|
+
return globalThis.self !== window.top;
|
|
2389
|
+
} catch {
|
|
2390
|
+
return true;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// src/core/auth/flows/popup.ts
|
|
2395
|
+
async function initiatePopupFlow(options) {
|
|
2396
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
2397
|
+
try {
|
|
2398
|
+
onStateChange?.({
|
|
2399
|
+
status: "opening_popup",
|
|
2400
|
+
message: "Opening authentication window..."
|
|
2401
|
+
});
|
|
2402
|
+
const defaults = getOAuthConfig(provider);
|
|
2403
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
2404
|
+
if (!config.clientId) {
|
|
2405
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
2406
|
+
}
|
|
2407
|
+
const stateData = options.stateData;
|
|
2408
|
+
const state = await generateOAuthState(stateData);
|
|
2409
|
+
const params = new URLSearchParams({
|
|
2410
|
+
response_type: "code",
|
|
2411
|
+
client_id: config.clientId,
|
|
2412
|
+
redirect_uri: callbackUrl,
|
|
2413
|
+
state
|
|
2414
|
+
});
|
|
2415
|
+
if (config.scope) {
|
|
2416
|
+
params.set("scope", config.scope);
|
|
2417
|
+
}
|
|
2418
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
2419
|
+
const popup = openPopupWindow(authUrl, "playcademy-auth");
|
|
2420
|
+
if (!popup || popup.closed) {
|
|
2421
|
+
throw new Error("Popup blocked. Please enable popups and try again.");
|
|
2422
|
+
}
|
|
2423
|
+
onStateChange?.({
|
|
2424
|
+
status: "exchanging_token",
|
|
2425
|
+
message: "Waiting for authentication..."
|
|
2426
|
+
});
|
|
2427
|
+
return await waitForServerMessage(popup, onStateChange);
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
const errorMessage2 = error instanceof Error ? error.message : "Authentication failed";
|
|
2430
|
+
onStateChange?.({
|
|
2431
|
+
status: "error",
|
|
2432
|
+
message: errorMessage2,
|
|
2433
|
+
error: error instanceof Error ? error : new Error(errorMessage2)
|
|
2434
|
+
});
|
|
2435
|
+
throw error;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
async function waitForServerMessage(popup, onStateChange) {
|
|
2439
|
+
return new Promise((resolve) => {
|
|
2440
|
+
let resolved = false;
|
|
2441
|
+
function handleMessage(event) {
|
|
2442
|
+
if (event.origin !== globalThis.location.origin) {
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
const data = event.data;
|
|
2446
|
+
if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
|
|
2447
|
+
resolved = true;
|
|
2448
|
+
window.removeEventListener("message", handleMessage);
|
|
2449
|
+
if (data.authenticated && data.user) {
|
|
2450
|
+
onStateChange?.({
|
|
2451
|
+
status: "complete",
|
|
2452
|
+
message: "Authentication successful"
|
|
1655
2453
|
});
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
2454
|
+
resolve({
|
|
2455
|
+
success: true,
|
|
2456
|
+
user: data.user
|
|
2457
|
+
});
|
|
2458
|
+
} else {
|
|
2459
|
+
const error = new Error(data.error || "Authentication failed");
|
|
2460
|
+
onStateChange?.({
|
|
2461
|
+
status: "error",
|
|
2462
|
+
message: error.message,
|
|
2463
|
+
error
|
|
2464
|
+
});
|
|
2465
|
+
resolve({
|
|
2466
|
+
success: false,
|
|
2467
|
+
error
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
window.addEventListener("message", handleMessage);
|
|
2473
|
+
const checkClosed = setInterval(() => {
|
|
2474
|
+
if (popup.closed && !resolved) {
|
|
2475
|
+
clearInterval(checkClosed);
|
|
2476
|
+
window.removeEventListener("message", handleMessage);
|
|
2477
|
+
const error = new Error("Authentication cancelled");
|
|
2478
|
+
onStateChange?.({
|
|
2479
|
+
status: "error",
|
|
2480
|
+
message: error.message,
|
|
2481
|
+
error
|
|
2482
|
+
});
|
|
2483
|
+
resolve({
|
|
2484
|
+
success: false,
|
|
2485
|
+
error
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
}, 500);
|
|
2489
|
+
setTimeout(() => {
|
|
2490
|
+
if (!resolved) {
|
|
2491
|
+
window.removeEventListener("message", handleMessage);
|
|
2492
|
+
clearInterval(checkClosed);
|
|
2493
|
+
const error = new Error("Authentication timeout");
|
|
2494
|
+
onStateChange?.({
|
|
2495
|
+
status: "error",
|
|
2496
|
+
message: error.message,
|
|
2497
|
+
error
|
|
2498
|
+
});
|
|
2499
|
+
resolve({
|
|
2500
|
+
success: false,
|
|
2501
|
+
error
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
}, 5 * 60 * 1000);
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// src/core/auth/flows/redirect.ts
|
|
2509
|
+
async function initiateRedirectFlow(options) {
|
|
2510
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
2511
|
+
try {
|
|
2512
|
+
onStateChange?.({
|
|
2513
|
+
status: "opening_popup",
|
|
2514
|
+
message: "Redirecting to authentication provider..."
|
|
2515
|
+
});
|
|
2516
|
+
const defaults = getOAuthConfig(provider);
|
|
2517
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
2518
|
+
if (!config.clientId) {
|
|
2519
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
2520
|
+
}
|
|
2521
|
+
const stateData = options.stateData;
|
|
2522
|
+
const state = await generateOAuthState(stateData);
|
|
2523
|
+
const params = new URLSearchParams({
|
|
2524
|
+
response_type: "code",
|
|
2525
|
+
client_id: config.clientId,
|
|
2526
|
+
redirect_uri: callbackUrl,
|
|
2527
|
+
state
|
|
2528
|
+
});
|
|
2529
|
+
if (config.scope) {
|
|
2530
|
+
params.set("scope", config.scope);
|
|
2531
|
+
}
|
|
2532
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
2533
|
+
globalThis.location.href = authUrl;
|
|
2534
|
+
return new Promise(() => {});
|
|
2535
|
+
} catch (error) {
|
|
2536
|
+
const errorMessage2 = error instanceof Error ? error.message : "Authentication failed";
|
|
2537
|
+
onStateChange?.({
|
|
2538
|
+
status: "error",
|
|
2539
|
+
message: errorMessage2,
|
|
2540
|
+
error: error instanceof Error ? error : new Error(errorMessage2)
|
|
2541
|
+
});
|
|
2542
|
+
throw error;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// src/core/auth/flows/unified.ts
|
|
2547
|
+
async function initiateUnifiedFlow(options) {
|
|
2548
|
+
const { mode = "auto" } = options;
|
|
2549
|
+
let effectiveMode;
|
|
2550
|
+
if (mode === "auto") {
|
|
2551
|
+
effectiveMode = isInIframe() ? "popup" : "redirect";
|
|
2552
|
+
} else {
|
|
2553
|
+
effectiveMode = mode;
|
|
2554
|
+
}
|
|
2555
|
+
switch (effectiveMode) {
|
|
2556
|
+
case "popup": {
|
|
2557
|
+
return initiatePopupFlow(options);
|
|
2558
|
+
}
|
|
2559
|
+
case "redirect": {
|
|
2560
|
+
return initiateRedirectFlow(options);
|
|
2561
|
+
}
|
|
2562
|
+
default: {
|
|
2563
|
+
throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// src/core/auth/login.ts
|
|
2569
|
+
async function login2(client, options) {
|
|
2570
|
+
try {
|
|
2571
|
+
let stateData = options.stateData;
|
|
2572
|
+
if (!stateData) {
|
|
2573
|
+
try {
|
|
2574
|
+
const currentUser = await client.users.me();
|
|
2575
|
+
if (currentUser?.id) {
|
|
2576
|
+
stateData = { playcademy_user_id: currentUser.id };
|
|
2577
|
+
}
|
|
2578
|
+
} catch {
|
|
2579
|
+
log.debug("[Playcademy SDK] No current user available for state data");
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
log.debug("[Playcademy SDK] Starting OAuth login", {
|
|
2583
|
+
provider: options.provider,
|
|
2584
|
+
mode: options.mode || "auto",
|
|
2585
|
+
callbackUrl: options.callbackUrl,
|
|
2586
|
+
hasStateData: Boolean(stateData)
|
|
2587
|
+
});
|
|
2588
|
+
const optionsWithState = {
|
|
2589
|
+
...options,
|
|
2590
|
+
stateData
|
|
2591
|
+
};
|
|
2592
|
+
const result = await initiateUnifiedFlow(optionsWithState);
|
|
2593
|
+
if (result.success && result.user) {
|
|
2594
|
+
log.debug("[Playcademy SDK] OAuth login successful", {
|
|
2595
|
+
userId: result.user.sub
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
return result;
|
|
2599
|
+
} catch (error) {
|
|
2600
|
+
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
2601
|
+
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
2602
|
+
return {
|
|
2603
|
+
success: false,
|
|
2604
|
+
error: authError
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// src/namespaces/game/identity.ts
|
|
2610
|
+
function createIdentityNamespace(client) {
|
|
2611
|
+
return {
|
|
2612
|
+
connect: (options) => {
|
|
2613
|
+
if (client.mode === "demo") {
|
|
2614
|
+
throw new PlaycademyError("identity.connect() is not available in demo mode. Use platform or standalone mode for OAuth flows.");
|
|
2615
|
+
}
|
|
2616
|
+
assertNotChildMode(client, "identity.connect()", "Account connections belong to the hub or parent context.");
|
|
2617
|
+
return login2(client, options);
|
|
2618
|
+
},
|
|
2619
|
+
_getContext: () => ({
|
|
2620
|
+
isInIframe: client["authContext"]?.isInIframe ?? false
|
|
2621
|
+
})
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
// src/namespaces/game/runtime.ts
|
|
2625
|
+
function createRuntimeNamespace(client) {
|
|
2626
|
+
const eventListeners = new Map;
|
|
2627
|
+
function trackListener(eventType, handler) {
|
|
2628
|
+
if (!eventListeners.has(eventType)) {
|
|
2629
|
+
eventListeners.set(eventType, new Set);
|
|
2630
|
+
}
|
|
2631
|
+
eventListeners.get(eventType).add(handler);
|
|
2632
|
+
}
|
|
2633
|
+
function untrackListener(eventType, handler) {
|
|
2634
|
+
const listeners = eventListeners.get(eventType);
|
|
2635
|
+
if (listeners) {
|
|
2636
|
+
listeners.delete(handler);
|
|
2637
|
+
if (listeners.size === 0) {
|
|
2638
|
+
eventListeners.delete(eventType);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
if (typeof globalThis.window !== "undefined" && globalThis.self !== window.top) {
|
|
2643
|
+
let keyListener = function(event) {
|
|
2644
|
+
if (keySet.has(event.key?.toLowerCase() ?? "") || keySet.has(event.code?.toLowerCase() ?? "")) {
|
|
2645
|
+
messaging.send("PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */, {
|
|
2646
|
+
key: event.key,
|
|
2647
|
+
code: event.code,
|
|
2648
|
+
type: event.type
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
};
|
|
2652
|
+
const playcademyConfig = globalThis.PLAYCADEMY;
|
|
2653
|
+
const forwardKeys = Array.isArray(playcademyConfig?.forwardKeys) ? playcademyConfig.forwardKeys : ["Escape"];
|
|
2654
|
+
const keySet = new Set(forwardKeys.map((k) => k.toLowerCase()));
|
|
2655
|
+
globalThis.addEventListener("keydown", keyListener);
|
|
2656
|
+
globalThis.addEventListener("keyup", keyListener);
|
|
2657
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, () => {
|
|
2658
|
+
globalThis.removeEventListener("keydown", keyListener);
|
|
2659
|
+
globalThis.removeEventListener("keyup", keyListener);
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
return {
|
|
2663
|
+
exit: () => {
|
|
2664
|
+
messaging.send("PLAYCADEMY_EXIT" /* EXIT */, undefined);
|
|
2665
|
+
},
|
|
2666
|
+
onInit: (handler) => {
|
|
2667
|
+
messaging.listen("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2668
|
+
trackListener("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2669
|
+
},
|
|
2670
|
+
onTokenRefresh: (handler) => {
|
|
2671
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2672
|
+
trackListener("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2673
|
+
},
|
|
2674
|
+
onPause: (handler) => {
|
|
2675
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2676
|
+
trackListener("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2677
|
+
},
|
|
2678
|
+
onResume: (handler) => {
|
|
2679
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2680
|
+
trackListener("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2681
|
+
},
|
|
2682
|
+
onForceExit: (handler) => {
|
|
2683
|
+
messaging.listen("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2684
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2685
|
+
},
|
|
2686
|
+
onOverlay: (handler) => {
|
|
2687
|
+
messaging.listen("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2688
|
+
trackListener("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2689
|
+
},
|
|
2690
|
+
ready: () => {
|
|
2691
|
+
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
2692
|
+
},
|
|
2693
|
+
sendTelemetry: (data) => {
|
|
2694
|
+
messaging.send("PLAYCADEMY_TELEMETRY" /* TELEMETRY */, data);
|
|
2695
|
+
},
|
|
2696
|
+
removeListener: (eventType, handler) => {
|
|
2697
|
+
messaging.unlisten(eventType, handler);
|
|
2698
|
+
untrackListener(eventType, handler);
|
|
2699
|
+
},
|
|
2700
|
+
removeAllListeners: () => {
|
|
2701
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2702
|
+
for (const handler of handlers) {
|
|
2703
|
+
messaging.unlisten(eventType, handler);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
eventListeners.clear();
|
|
2707
|
+
},
|
|
2708
|
+
getListenerCounts: () => {
|
|
2709
|
+
const counts = {};
|
|
2710
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2711
|
+
counts[eventType] = handlers.size;
|
|
2712
|
+
}
|
|
2713
|
+
return counts;
|
|
2714
|
+
},
|
|
2715
|
+
assets: createAssetsNamespace(client)
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
function createAssetsNamespace(client) {
|
|
2719
|
+
async function fetchAsset(path, options) {
|
|
2720
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2721
|
+
if (!gameUrl) {
|
|
2722
|
+
const relativePath = path.startsWith("./") ? path : `./${path}`;
|
|
2723
|
+
return fetch(relativePath, options);
|
|
2724
|
+
}
|
|
2725
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2726
|
+
return fetch(`${gameUrl}${cleanPath}`, options);
|
|
2727
|
+
}
|
|
2728
|
+
return {
|
|
2729
|
+
url(pathOrStrings, ...values) {
|
|
2730
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2731
|
+
let path;
|
|
2732
|
+
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
2733
|
+
const strings = pathOrStrings;
|
|
2734
|
+
path = strings.reduce((acc, str, i) => acc + str + (values[i] != null ? String(values[i]) : ""), "");
|
|
2735
|
+
} else {
|
|
2736
|
+
path = pathOrStrings;
|
|
2737
|
+
}
|
|
2738
|
+
if (!gameUrl) {
|
|
2739
|
+
return path.startsWith("./") ? path : `./${path}`;
|
|
2740
|
+
}
|
|
2741
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2742
|
+
return `${gameUrl}${cleanPath}`;
|
|
2743
|
+
},
|
|
2744
|
+
fetch: fetchAsset,
|
|
2745
|
+
json: async (path) => {
|
|
2746
|
+
const response = await fetchAsset(path);
|
|
2747
|
+
return await response.json();
|
|
2748
|
+
},
|
|
2749
|
+
blob: async (path) => {
|
|
2750
|
+
const response = await fetchAsset(path);
|
|
2751
|
+
return response.blob();
|
|
2752
|
+
},
|
|
2753
|
+
text: async (path) => {
|
|
2754
|
+
const response = await fetchAsset(path);
|
|
2755
|
+
return response.text();
|
|
2756
|
+
},
|
|
2757
|
+
arrayBuffer: async (path) => {
|
|
2758
|
+
const response = await fetchAsset(path);
|
|
2759
|
+
return response.arrayBuffer();
|
|
2760
|
+
}
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
// src/namespaces/game/scores.ts
|
|
2764
|
+
function createScoresNamespace(client) {
|
|
2765
|
+
return {
|
|
2766
|
+
submit: async (score, metadata) => {
|
|
2767
|
+
assertNotChildMode(client, "scores.submit()", "The parent game owns reporting; the score already reaches it via the activity relay.");
|
|
2768
|
+
const gameId = client["_ensureGameId"]();
|
|
2769
|
+
return client["request"](`/games/${gameId}/scores`, "POST", {
|
|
2770
|
+
body: {
|
|
2771
|
+
score,
|
|
2772
|
+
metadata
|
|
1659
2773
|
}
|
|
1660
2774
|
});
|
|
1661
2775
|
}
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
// src/core/cache/ttl-cache.ts
|
|
2779
|
+
function createTTLCache(options) {
|
|
2780
|
+
const cache = new Map;
|
|
2781
|
+
const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
|
|
2782
|
+
async function get(key, loader, config) {
|
|
2783
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2784
|
+
const now = Date.now();
|
|
2785
|
+
const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
|
|
2786
|
+
const force = config?.force || false;
|
|
2787
|
+
const skipCache = config?.skipCache || false;
|
|
2788
|
+
if (effectiveTTL === 0 || skipCache) {
|
|
2789
|
+
return loader();
|
|
1670
2790
|
}
|
|
1671
|
-
if (
|
|
1672
|
-
|
|
1673
|
-
|
|
2791
|
+
if (!force) {
|
|
2792
|
+
const cached = cache.get(fullKey);
|
|
2793
|
+
if (cached && cached.expiresAt > now) {
|
|
2794
|
+
return cached.value;
|
|
1674
2795
|
}
|
|
1675
|
-
boundUserInteractionHandler = null;
|
|
1676
2796
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
2797
|
+
const promise = loader().catch((error) => {
|
|
2798
|
+
cache.delete(fullKey);
|
|
2799
|
+
throw error;
|
|
2800
|
+
});
|
|
2801
|
+
cache.set(fullKey, {
|
|
2802
|
+
value: promise,
|
|
2803
|
+
expiresAt: now + effectiveTTL
|
|
2804
|
+
});
|
|
2805
|
+
return promise;
|
|
2806
|
+
}
|
|
2807
|
+
function clear(key) {
|
|
2808
|
+
if (key === undefined) {
|
|
2809
|
+
cache.clear();
|
|
2810
|
+
onClear?.();
|
|
2811
|
+
} else {
|
|
2812
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2813
|
+
cache.delete(fullKey);
|
|
1680
2814
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
2815
|
+
}
|
|
2816
|
+
function size() {
|
|
2817
|
+
return cache.size;
|
|
2818
|
+
}
|
|
2819
|
+
function prune() {
|
|
2820
|
+
const now = Date.now();
|
|
2821
|
+
for (const [key, entry] of cache.entries()) {
|
|
2822
|
+
if (entry.expiresAt <= now) {
|
|
2823
|
+
cache.delete(key);
|
|
2824
|
+
}
|
|
1684
2825
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
2826
|
+
}
|
|
2827
|
+
function getKeys() {
|
|
2828
|
+
const keys = [];
|
|
2829
|
+
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
2830
|
+
for (const fullKey of cache.keys()) {
|
|
2831
|
+
keys.push(fullKey.substring(prefixLen));
|
|
1688
2832
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
2833
|
+
return keys;
|
|
2834
|
+
}
|
|
2835
|
+
function has(key) {
|
|
2836
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2837
|
+
const cached = cache.get(fullKey);
|
|
2838
|
+
if (!cached) {
|
|
2839
|
+
return false;
|
|
1692
2840
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
2841
|
+
const now = Date.now();
|
|
2842
|
+
if (cached.expiresAt <= now) {
|
|
2843
|
+
cache.delete(fullKey);
|
|
2844
|
+
return false;
|
|
1695
2845
|
}
|
|
1696
|
-
|
|
2846
|
+
return true;
|
|
1697
2847
|
}
|
|
1698
|
-
return {
|
|
1699
|
-
currentRunId() {
|
|
1700
|
-
return currentActivity?.runId;
|
|
1701
|
-
},
|
|
1702
|
-
startActivity(metadata, options) {
|
|
1703
|
-
if (options?.runId !== undefined && !isValidUUID(options.runId)) {
|
|
1704
|
-
throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
|
|
1705
|
-
}
|
|
1706
|
-
cleanupListeners();
|
|
1707
|
-
const now = Date.now();
|
|
1708
|
-
const runId = options?.runId ?? crypto.randomUUID();
|
|
1709
|
-
const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
|
|
1710
|
-
const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
|
|
1711
|
-
const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
|
|
1712
|
-
currentActivity = {
|
|
1713
|
-
runId,
|
|
1714
|
-
resumeId: crypto.randomUUID(),
|
|
1715
|
-
startTime: now,
|
|
1716
|
-
metadata,
|
|
1717
|
-
pausedTime: 0,
|
|
1718
|
-
pauseStartTime: null,
|
|
1719
|
-
pauseReasons: new Set,
|
|
1720
|
-
pausedHeartbeatTimeoutId: null,
|
|
1721
|
-
pausedHeartbeatTimedOut: false,
|
|
1722
|
-
pausedHeartbeatTimeoutMs,
|
|
1723
|
-
windowStartTime: now,
|
|
1724
|
-
windowPausedAtStart: 0,
|
|
1725
|
-
heartbeatIntervalId: null,
|
|
1726
|
-
heartbeatIntervalMs,
|
|
1727
|
-
inactivityTimeoutId: null,
|
|
1728
|
-
inactivityTimeoutMs,
|
|
1729
|
-
inactivityTimerStartedAt: null,
|
|
1730
|
-
remainingInactivityMs: inactivityTimeoutMs,
|
|
1731
|
-
flushInFlight: null,
|
|
1732
|
-
totalPersistedActiveMs: 0,
|
|
1733
|
-
totalPersistedPausedMs: 0
|
|
1734
|
-
};
|
|
1735
|
-
if (typeof document !== "undefined") {
|
|
1736
|
-
boundVisibilityHandler = handleVisibilityChange;
|
|
1737
|
-
document.addEventListener("visibilitychange", boundVisibilityHandler);
|
|
1738
|
-
boundUserInteractionHandler = handleUserInteraction;
|
|
1739
|
-
for (const eventName of USER_ACTIVITY_EVENTS) {
|
|
1740
|
-
document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
|
|
1741
|
-
}
|
|
1742
|
-
if (document.visibilityState === "hidden") {
|
|
1743
|
-
handleVisibilityChange();
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
startHeartbeatInterval(currentActivity);
|
|
1747
|
-
startRelayInterval();
|
|
1748
|
-
if (typeof globalThis.window !== "undefined") {
|
|
1749
|
-
boundPageHideHandler = handlePageHide;
|
|
1750
|
-
globalThis.window.addEventListener("pagehide", boundPageHideHandler);
|
|
1751
|
-
}
|
|
1752
|
-
boundShellPauseHandler = handleShellPause;
|
|
1753
|
-
boundShellResumeHandler = handleShellResume;
|
|
1754
|
-
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
1755
|
-
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
1756
|
-
syncInactivityTracking();
|
|
1757
|
-
return { runId };
|
|
1758
|
-
},
|
|
1759
|
-
pauseActivity() {
|
|
1760
|
-
if (!currentActivity) {
|
|
1761
|
-
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
1762
|
-
}
|
|
1763
|
-
if (currentActivity.pauseReasons.has("manual")) {
|
|
1764
|
-
throw new Error("Activity is already paused.");
|
|
1765
|
-
}
|
|
1766
|
-
addPauseReason("manual");
|
|
1767
|
-
},
|
|
1768
|
-
resumeActivity() {
|
|
1769
|
-
if (!currentActivity) {
|
|
1770
|
-
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
1771
|
-
}
|
|
1772
|
-
if (!currentActivity.pauseReasons.has("manual")) {
|
|
1773
|
-
throw new Error("Activity is not paused.");
|
|
1774
|
-
}
|
|
1775
|
-
removePauseReason("manual");
|
|
1776
|
-
},
|
|
1777
|
-
async endActivity(data) {
|
|
1778
|
-
if (!currentActivity) {
|
|
1779
|
-
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
1780
|
-
}
|
|
1781
|
-
const activity = currentActivity;
|
|
1782
|
-
applyOverdueInactivity();
|
|
1783
|
-
cleanupListeners();
|
|
1784
|
-
await flushHeartbeat(true);
|
|
1785
|
-
if (activity.pauseStartTime !== null) {
|
|
1786
|
-
activity.pausedTime += Date.now() - activity.pauseStartTime;
|
|
1787
|
-
activity.pauseStartTime = null;
|
|
1788
|
-
}
|
|
1789
|
-
const endTime = Date.now();
|
|
1790
|
-
const totalElapsed = endTime - activity.startTime;
|
|
1791
|
-
const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
|
|
1792
|
-
const durationSeconds = Math.floor(activeTime / 1000);
|
|
1793
|
-
const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
|
|
1794
|
-
const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
|
|
1795
|
-
const { correctQuestions, totalQuestions } = data;
|
|
1796
|
-
if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
|
|
1797
|
-
throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
|
|
1798
|
-
}
|
|
1799
|
-
const request = {
|
|
1800
|
-
runId: activity.runId,
|
|
1801
|
-
resumeId: activity.resumeId,
|
|
1802
|
-
activityData: activity.metadata,
|
|
1803
|
-
scoreData: {
|
|
1804
|
-
correctQuestions,
|
|
1805
|
-
totalQuestions
|
|
1806
|
-
},
|
|
1807
|
-
timingData: {
|
|
1808
|
-
durationSeconds
|
|
1809
|
-
},
|
|
1810
|
-
sessionTimingData: {
|
|
1811
|
-
activeSeconds: unreportedActiveMs / 1000,
|
|
1812
|
-
...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
|
|
1813
|
-
},
|
|
1814
|
-
xpEarned: data.xpAwarded,
|
|
1815
|
-
masteredUnits: data.masteredUnits,
|
|
1816
|
-
masteredUnitsAbsolute: data.masteredUnitsAbsolute,
|
|
1817
|
-
extensions: data.extensions
|
|
1818
|
-
};
|
|
1819
|
-
try {
|
|
1820
|
-
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
|
|
1821
|
-
if (currentActivity === activity) {
|
|
1822
|
-
currentActivity = null;
|
|
1823
|
-
}
|
|
1824
|
-
return response;
|
|
1825
|
-
} catch (error) {
|
|
1826
|
-
if (currentActivity === activity) {
|
|
1827
|
-
currentActivity = null;
|
|
1828
|
-
}
|
|
1829
|
-
throw error;
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
};
|
|
2848
|
+
return { get, clear, size, prune, getKeys, has };
|
|
1833
2849
|
}
|
|
1834
2850
|
|
|
1835
2851
|
// src/core/timeback/user.ts
|
|
@@ -1989,9 +3005,12 @@ function createTimebackEngine(client) {
|
|
|
1989
3005
|
},
|
|
1990
3006
|
activity: {
|
|
1991
3007
|
currentRunId: activityTracker.currentRunId,
|
|
3008
|
+
isManuallyPaused: activityTracker.isActivityManuallyPaused,
|
|
1992
3009
|
start: activityTracker.startActivity,
|
|
3010
|
+
tryStartAssessment: activityTracker.tryStartAssessmentTracking,
|
|
1993
3011
|
pause: activityTracker.pauseActivity,
|
|
1994
3012
|
resume: activityTracker.resumeActivity,
|
|
3013
|
+
finishAssessment: activityTracker.finishAssessmentTracking,
|
|
1995
3014
|
end: activityTracker.endActivity
|
|
1996
3015
|
},
|
|
1997
3016
|
course: {
|
|
@@ -2018,9 +3037,10 @@ function createTimebackEngine(client) {
|
|
|
2018
3037
|
var VALID_XP_INCLUDE_OPTIONS = ["perCourse", "today"];
|
|
2019
3038
|
var VALID_MASTERY_INCLUDE_OPTIONS = ["perCourse"];
|
|
2020
3039
|
var ASSESSMENTS_ROUTE = TIMEBACK_ROUTES.ASSESSMENTS;
|
|
3040
|
+
var ASSESSMENT_SUBMIT_RETRY_POLICY = END_ACTIVITY_RETRY_POLICY;
|
|
2021
3041
|
function validateAssessmentFilters(options) {
|
|
2022
3042
|
if (!isAssessmentPurpose(options?.purpose)) {
|
|
2023
|
-
throw new Error("purpose must be end_of_course or
|
|
3043
|
+
throw new Error("purpose must be end_of_course, diagnostic, review, or mastery");
|
|
2024
3044
|
}
|
|
2025
3045
|
if (options.grade !== undefined && !isValidGrade(options.grade)) {
|
|
2026
3046
|
throw new Error(`Invalid grade: ${options.grade}. Valid grades: ${VALID_GRADES.join(", ")}`);
|
|
@@ -2028,9 +3048,100 @@ function validateAssessmentFilters(options) {
|
|
|
2028
3048
|
if (options.subject !== undefined && !isValidSubject(options.subject)) {
|
|
2029
3049
|
throw new Error(`Invalid subject: ${options.subject}. Valid subjects: ${VALID_SUBJECTS.join(", ")}`);
|
|
2030
3050
|
}
|
|
3051
|
+
if (options.purpose === "mastery") {
|
|
3052
|
+
const { standard } = options;
|
|
3053
|
+
const framework = typeof standard?.framework === "string" ? standard.framework.trim() : "";
|
|
3054
|
+
const identifier = typeof standard?.identifier === "string" ? standard.identifier.trim() : "";
|
|
3055
|
+
if (!framework || !identifier) {
|
|
3056
|
+
throw new Error("mastery standard requires a canonical framework and identifier");
|
|
3057
|
+
}
|
|
3058
|
+
if (framework.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength || identifier.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength) {
|
|
3059
|
+
throw new Error(`mastery standard fields must be at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
2031
3062
|
}
|
|
2032
3063
|
function createTimebackNamespace(client) {
|
|
2033
3064
|
const engine = createTimebackEngine(client);
|
|
3065
|
+
const pendingAssessmentSubmissions = new Map;
|
|
3066
|
+
const warnedAssessmentTrackingSkips = new Map;
|
|
3067
|
+
function clearAssessmentTrackingWarnings(attemptId) {
|
|
3068
|
+
warnedAssessmentTrackingSkips.delete(attemptId);
|
|
3069
|
+
}
|
|
3070
|
+
function warnAssessmentTrackingSkipped(attemptId, reason) {
|
|
3071
|
+
const warnedReasons = warnedAssessmentTrackingSkips.get(attemptId) ?? new Set;
|
|
3072
|
+
if (warnedReasons.has(reason)) {
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
warnedReasons.add(reason);
|
|
3076
|
+
warnedAssessmentTrackingSkips.set(attemptId, warnedReasons);
|
|
3077
|
+
const remedy = reason === "invalid-attempt-id" ? "The canonical attempt remains usable and submittable, but this sitting cannot be timed. Report the malformed id." : "The canonical attempt remains available; release the current clock owner, then call timeback.assessments.get(attemptId) to retry tracking.";
|
|
3078
|
+
console.warn(`[Playcademy SDK] timeback.assessments: active-time tracking was not attached to assessment ${attemptId} (${reason}). ${remedy}`);
|
|
3079
|
+
}
|
|
3080
|
+
function releasePendingSubmission(attemptId, submissionId) {
|
|
3081
|
+
if (pendingAssessmentSubmissions.get(attemptId)?.submissionId === submissionId) {
|
|
3082
|
+
pendingAssessmentSubmissions.delete(attemptId);
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
function loadAttemptSnapshot(attemptId) {
|
|
3086
|
+
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
|
|
3087
|
+
}
|
|
3088
|
+
async function syncAssessmentTracking(snapshot) {
|
|
3089
|
+
if (pendingAssessmentSubmissions.has(snapshot.attemptId)) {
|
|
3090
|
+
return snapshot;
|
|
3091
|
+
}
|
|
3092
|
+
if (snapshot.status === "completed") {
|
|
3093
|
+
await engine.activity.finishAssessment(snapshot.attemptId);
|
|
3094
|
+
clearAssessmentTrackingWarnings(snapshot.attemptId);
|
|
3095
|
+
return snapshot;
|
|
3096
|
+
}
|
|
3097
|
+
if (!snapshot.activityData) {
|
|
3098
|
+
return snapshot;
|
|
3099
|
+
}
|
|
3100
|
+
const attachment = engine.activity.tryStartAssessment(snapshot.activityData, snapshot.attemptId);
|
|
3101
|
+
if (attachment.status === "unavailable" || attachment.status === "untrackable") {
|
|
3102
|
+
warnAssessmentTrackingSkipped(snapshot.attemptId, attachment.reason);
|
|
3103
|
+
} else {
|
|
3104
|
+
clearAssessmentTrackingWarnings(snapshot.attemptId);
|
|
3105
|
+
}
|
|
3106
|
+
return snapshot;
|
|
3107
|
+
}
|
|
3108
|
+
async function resumeAfterDefinitiveFailure(attemptId, submissionId) {
|
|
3109
|
+
if (pendingAssessmentSubmissions.get(attemptId)?.submissionId !== submissionId) {
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
pendingAssessmentSubmissions.delete(attemptId);
|
|
3113
|
+
try {
|
|
3114
|
+
await syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
|
|
3115
|
+
} catch {}
|
|
3116
|
+
}
|
|
3117
|
+
async function assessmentSubmissionContext(attemptId, submissionId) {
|
|
3118
|
+
const pending = pendingAssessmentSubmissions.get(attemptId);
|
|
3119
|
+
if (pending) {
|
|
3120
|
+
if (pending.submissionId !== submissionId && !pending.detached) {
|
|
3121
|
+
throw new Error(`Assessment ${attemptId} already has a pending submission. Retry with the original submissionId.`);
|
|
3122
|
+
}
|
|
3123
|
+
pending.detached = false;
|
|
3124
|
+
return {
|
|
3125
|
+
submissionId: pending.submissionId,
|
|
3126
|
+
session: await pending.session
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
const session = engine.activity.finishAssessment(attemptId).then((finished) => finished ? {
|
|
3130
|
+
runId: finished.runId,
|
|
3131
|
+
resumeId: finished.resumeId,
|
|
3132
|
+
...finished.sessionTimingData
|
|
3133
|
+
} : undefined).catch((error) => {
|
|
3134
|
+
releasePendingSubmission(attemptId, submissionId);
|
|
3135
|
+
throw error;
|
|
3136
|
+
});
|
|
3137
|
+
pendingAssessmentSubmissions.set(attemptId, {
|
|
3138
|
+
submissionId,
|
|
3139
|
+
session,
|
|
3140
|
+
detached: false
|
|
3141
|
+
});
|
|
3142
|
+
return { submissionId, session: await session };
|
|
3143
|
+
}
|
|
3144
|
+
registerPauseProbe(client, () => engine.activity.isManuallyPaused());
|
|
2034
3145
|
return {
|
|
2035
3146
|
assessments: {
|
|
2036
3147
|
start: async (input) => {
|
|
@@ -2039,7 +3150,27 @@ function createTimebackNamespace(client) {
|
|
|
2039
3150
|
throw new Error("activityId is required");
|
|
2040
3151
|
}
|
|
2041
3152
|
validateAssessmentFilters(input);
|
|
2042
|
-
|
|
3153
|
+
if (input.purpose === "review") {
|
|
3154
|
+
if (!Array.isArray(input.standards) || input.standards.length === 0) {
|
|
3155
|
+
throw new Error("standards must contain at least one standard for review");
|
|
3156
|
+
}
|
|
3157
|
+
if (input.standards.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards) {
|
|
3158
|
+
throw new Error(`standards must contain at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards} standards`);
|
|
3159
|
+
}
|
|
3160
|
+
for (const standard of input.standards) {
|
|
3161
|
+
if (!standard || typeof standard !== "object" || !standard.framework?.trim() || !standard.identifier?.trim()) {
|
|
3162
|
+
throw new Error("review standards require a framework and identifier");
|
|
3163
|
+
}
|
|
3164
|
+
if (standard.framework.trim().length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength || standard.identifier.trim().length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength) {
|
|
3165
|
+
throw new Error(`review standard fields must be at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
if (input.candidateItemsPerStandard !== undefined && (!Number.isInteger(input.candidateItemsPerStandard) || input.candidateItemsPerStandard <= 0 || input.candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard)) {
|
|
3169
|
+
throw new Error(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
const snapshot = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
|
|
3173
|
+
return syncAssessmentTracking(snapshot);
|
|
2043
3174
|
},
|
|
2044
3175
|
latest: async (options) => {
|
|
2045
3176
|
assertPlatformMode(client, "timeback.assessments.latest()");
|
|
@@ -2051,6 +3182,10 @@ function createTimebackNamespace(client) {
|
|
|
2051
3182
|
if (options.grade !== undefined) {
|
|
2052
3183
|
params.set("grade", String(options.grade));
|
|
2053
3184
|
}
|
|
3185
|
+
if (options.purpose === "mastery") {
|
|
3186
|
+
params.set("standardFramework", options.standard.framework);
|
|
3187
|
+
params.set("standardIdentifier", options.standard.identifier);
|
|
3188
|
+
}
|
|
2054
3189
|
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/latest?${params.toString()}`, "GET");
|
|
2055
3190
|
},
|
|
2056
3191
|
get: async (attemptId) => {
|
|
@@ -2058,7 +3193,20 @@ function createTimebackNamespace(client) {
|
|
|
2058
3193
|
if (!attemptId?.trim()) {
|
|
2059
3194
|
throw new Error("attemptId is required");
|
|
2060
3195
|
}
|
|
2061
|
-
return
|
|
3196
|
+
return syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
|
|
3197
|
+
},
|
|
3198
|
+
stop: async (attemptId) => {
|
|
3199
|
+
assertPlatformMode(client, "timeback.assessments.stop()");
|
|
3200
|
+
if (!attemptId?.trim()) {
|
|
3201
|
+
throw new Error("attemptId is required");
|
|
3202
|
+
}
|
|
3203
|
+
const pending = pendingAssessmentSubmissions.get(attemptId);
|
|
3204
|
+
if (pending) {
|
|
3205
|
+
pending.detached = true;
|
|
3206
|
+
}
|
|
3207
|
+
await engine.activity.finishAssessment(attemptId);
|
|
3208
|
+
clearAssessmentTrackingWarnings(attemptId);
|
|
3209
|
+
return { attemptId };
|
|
2062
3210
|
},
|
|
2063
3211
|
save: async (attemptId, input) => {
|
|
2064
3212
|
assertPlatformMode(client, "timeback.assessments.save()");
|
|
@@ -2070,6 +3218,22 @@ function createTimebackNamespace(client) {
|
|
|
2070
3218
|
}
|
|
2071
3219
|
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/save`, "POST", input);
|
|
2072
3220
|
},
|
|
3221
|
+
submitItem: async (attemptId, input) => {
|
|
3222
|
+
assertPlatformMode(client, "timeback.assessments.submitItem()");
|
|
3223
|
+
if (!attemptId?.trim()) {
|
|
3224
|
+
throw new Error("attemptId is required");
|
|
3225
|
+
}
|
|
3226
|
+
if (!Number.isInteger(input.expectedResponseVersion) || input.expectedResponseVersion < 0) {
|
|
3227
|
+
throw new Error("expectedResponseVersion must be a non-negative integer");
|
|
3228
|
+
}
|
|
3229
|
+
if (!input.submissionId?.trim()) {
|
|
3230
|
+
throw new Error("submissionId is required");
|
|
3231
|
+
}
|
|
3232
|
+
if (!input.itemIdentifier?.trim()) {
|
|
3233
|
+
throw new Error("itemIdentifier is required");
|
|
3234
|
+
}
|
|
3235
|
+
return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit-item`, "POST", input);
|
|
3236
|
+
},
|
|
2073
3237
|
submit: async (attemptId, input) => {
|
|
2074
3238
|
assertPlatformMode(client, "timeback.assessments.submit()");
|
|
2075
3239
|
if (!attemptId?.trim()) {
|
|
@@ -2081,11 +3245,30 @@ function createTimebackNamespace(client) {
|
|
|
2081
3245
|
if (!input.submissionId?.trim()) {
|
|
2082
3246
|
throw new Error("submissionId is required");
|
|
2083
3247
|
}
|
|
2084
|
-
|
|
3248
|
+
const { expectedResponseVersion } = input;
|
|
3249
|
+
const { submissionId, session } = await assessmentSubmissionContext(attemptId, input.submissionId);
|
|
3250
|
+
const request = {
|
|
3251
|
+
expectedResponseVersion,
|
|
3252
|
+
submissionId,
|
|
3253
|
+
...session ? { session } : {}
|
|
3254
|
+
};
|
|
3255
|
+
try {
|
|
3256
|
+
const result = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", request, undefined, { retryPolicy: ASSESSMENT_SUBMIT_RETRY_POLICY });
|
|
3257
|
+
releasePendingSubmission(attemptId, submissionId);
|
|
3258
|
+
await engine.activity.finishAssessment(attemptId);
|
|
3259
|
+
clearAssessmentTrackingWarnings(attemptId);
|
|
3260
|
+
return result;
|
|
3261
|
+
} catch (error) {
|
|
3262
|
+
if (error instanceof ApiError && !error.isRetryable()) {
|
|
3263
|
+
await resumeAfterDefinitiveFailure(attemptId, submissionId);
|
|
3264
|
+
}
|
|
3265
|
+
throw error;
|
|
3266
|
+
}
|
|
2085
3267
|
}
|
|
2086
3268
|
},
|
|
2087
3269
|
get user() {
|
|
2088
|
-
|
|
3270
|
+
assertNonAnonymousMode(client, "timeback.user");
|
|
3271
|
+
assertNotChildMode(client, "timeback.user", "Course and user context belong to the parent game; pass what the lesson needs through the launch intent's extensions.");
|
|
2089
3272
|
return {
|
|
2090
3273
|
get id() {
|
|
2091
3274
|
return engine.user.snapshot()?.id;
|
|
@@ -2161,23 +3344,23 @@ function createTimebackNamespace(client) {
|
|
|
2161
3344
|
};
|
|
2162
3345
|
},
|
|
2163
3346
|
get currentRunId() {
|
|
2164
|
-
|
|
3347
|
+
assertNonAnonymousMode(client, "timeback.currentRunId");
|
|
2165
3348
|
return engine.activity.currentRunId();
|
|
2166
3349
|
},
|
|
2167
3350
|
startActivity: (metadata, options) => {
|
|
2168
|
-
|
|
3351
|
+
assertNonAnonymousMode(client, "timeback.startActivity()");
|
|
2169
3352
|
return engine.activity.start(metadata, options);
|
|
2170
3353
|
},
|
|
2171
3354
|
pauseActivity: () => {
|
|
2172
|
-
|
|
3355
|
+
assertNonAnonymousMode(client, "timeback.pauseActivity()");
|
|
2173
3356
|
engine.activity.pause();
|
|
2174
3357
|
},
|
|
2175
3358
|
resumeActivity: () => {
|
|
2176
|
-
|
|
3359
|
+
assertNonAnonymousMode(client, "timeback.resumeActivity()");
|
|
2177
3360
|
engine.activity.resume();
|
|
2178
3361
|
},
|
|
2179
3362
|
endActivity: async (data) => {
|
|
2180
|
-
|
|
3363
|
+
assertNonAnonymousMode(client, "timeback.endActivity()");
|
|
2181
3364
|
return engine.activity.end(data);
|
|
2182
3365
|
},
|
|
2183
3366
|
course: {
|
|
@@ -2205,7 +3388,7 @@ function createTimebackNamespace(client) {
|
|
|
2205
3388
|
function createUsersNamespace(client) {
|
|
2206
3389
|
return {
|
|
2207
3390
|
me: async () => {
|
|
2208
|
-
|
|
3391
|
+
assertNonAnonymousMode(client, "users.me()");
|
|
2209
3392
|
const user = await client["request"]("/users/me", "GET");
|
|
2210
3393
|
const initPayload = client["initPayload"];
|
|
2211
3394
|
if (initPayload) {
|
|
@@ -2224,11 +3407,6 @@ function createAdminNamespace(client) {
|
|
|
2224
3407
|
}
|
|
2225
3408
|
};
|
|
2226
3409
|
}
|
|
2227
|
-
// ../utils/src/error.ts
|
|
2228
|
-
function errorMessage(err) {
|
|
2229
|
-
return err instanceof Error ? err.message : String(err);
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
3410
|
// src/namespaces/platform/auth.ts
|
|
2233
3411
|
function createAuthNamespace(client) {
|
|
2234
3412
|
return {
|
|
@@ -2532,8 +3710,27 @@ class DeployPipeline {
|
|
|
2532
3710
|
}
|
|
2533
3711
|
|
|
2534
3712
|
// src/namespaces/platform/dev.ts
|
|
3713
|
+
var BUCKET_LIST_PAGE_SIZE = 1000;
|
|
2535
3714
|
function createDevNamespace(client) {
|
|
2536
3715
|
const deploy = new DeployPipeline(client);
|
|
3716
|
+
async function fetchBucketPage(slug, options = {}) {
|
|
3717
|
+
const params = new URLSearchParams;
|
|
3718
|
+
if (options.prefix) {
|
|
3719
|
+
params.set("prefix", options.prefix);
|
|
3720
|
+
}
|
|
3721
|
+
if (options.cursor) {
|
|
3722
|
+
params.set("cursor", options.cursor);
|
|
3723
|
+
}
|
|
3724
|
+
if (options.limit !== undefined) {
|
|
3725
|
+
params.set("limit", String(options.limit));
|
|
3726
|
+
}
|
|
3727
|
+
if (options.delimiter) {
|
|
3728
|
+
params.set("delimiter", options.delimiter);
|
|
3729
|
+
}
|
|
3730
|
+
const search = params.toString();
|
|
3731
|
+
const query = search === "" ? "" : `?${search}`;
|
|
3732
|
+
return client["request"](`/games/${slug}/bucket${query}`, "GET");
|
|
3733
|
+
}
|
|
2537
3734
|
return {
|
|
2538
3735
|
status: {
|
|
2539
3736
|
apply: () => client["request"]("/dev/apply", "POST"),
|
|
@@ -2665,10 +3862,22 @@ function createDevNamespace(client) {
|
|
|
2665
3862
|
},
|
|
2666
3863
|
bucket: {
|
|
2667
3864
|
list: async (slug, prefix) => {
|
|
2668
|
-
const
|
|
2669
|
-
|
|
2670
|
-
|
|
3865
|
+
const files = [];
|
|
3866
|
+
let cursor;
|
|
3867
|
+
do {
|
|
3868
|
+
const page = await fetchBucketPage(slug, {
|
|
3869
|
+
prefix,
|
|
3870
|
+
cursor,
|
|
3871
|
+
limit: BUCKET_LIST_PAGE_SIZE
|
|
3872
|
+
});
|
|
3873
|
+
for (const file of page.files) {
|
|
3874
|
+
files.push(file);
|
|
3875
|
+
}
|
|
3876
|
+
cursor = page.cursor;
|
|
3877
|
+
} while (cursor);
|
|
3878
|
+
return files;
|
|
2671
3879
|
},
|
|
3880
|
+
listPage: fetchBucketPage,
|
|
2672
3881
|
get: async (slug, key) => {
|
|
2673
3882
|
const res = await client["request"](`/games/${slug}/bucket/${encodeURIComponent(key)}`, "GET", { raw: true });
|
|
2674
3883
|
if (!res.ok) {
|
|
@@ -2896,6 +4105,9 @@ function createAssessmentNamespace(client) {
|
|
|
2896
4105
|
return {
|
|
2897
4106
|
list: (gameId, courseId) => client["request"](assessmentPath(gameId, courseId), "GET"),
|
|
2898
4107
|
create: (gameId, courseId, data) => client["request"](assessmentPath(gameId, courseId), "POST", { body: data }),
|
|
4108
|
+
attachExisting: (gameId, courseId, manifest) => client["request"](assessmentPath(gameId, courseId, "attachments"), "POST", {
|
|
4109
|
+
body: manifest
|
|
4110
|
+
}),
|
|
2899
4111
|
update: (gameId, courseId, testIdentifier, data) => client["request"](assessmentPath(gameId, courseId, testIdentifier), "PATCH", {
|
|
2900
4112
|
body: data
|
|
2901
4113
|
}),
|
|
@@ -2904,8 +4116,8 @@ function createAssessmentNamespace(client) {
|
|
|
2904
4116
|
}),
|
|
2905
4117
|
remove: (gameId, courseId, testIdentifier) => client["request"](assessmentPath(gameId, courseId, testIdentifier), "DELETE"),
|
|
2906
4118
|
listTestLibrary: (gameId, courseId, options = {}) => client["request"](`${assessmentPath(gameId, courseId, "test-library")}${buildQtiLibraryQueryString(options)}`, "GET"),
|
|
2907
|
-
copy: (gameId, courseId, testIdentifier, purpose) => client["request"](assessmentPath(gameId, courseId, "copies"), "POST", {
|
|
2908
|
-
body: { testIdentifier, purpose }
|
|
4119
|
+
copy: (gameId, courseId, testIdentifier, purpose, standard) => client["request"](assessmentPath(gameId, courseId, "copies"), "POST", {
|
|
4120
|
+
body: { testIdentifier, purpose, standard }
|
|
2909
4121
|
}),
|
|
2910
4122
|
listQuestions: (gameId, courseId, testIdentifier) => client["request"](assessmentPath(gameId, courseId, testIdentifier, "questions"), "GET"),
|
|
2911
4123
|
listQuestionLibrary: (gameId, courseId, options = {}) => client["request"](`${assessmentPath(gameId, courseId, "question-library")}${buildQtiLibraryQueryString(options)}`, "GET"),
|
|
@@ -3177,6 +4389,24 @@ function createAuthStrategy(token, tokenType) {
|
|
|
3177
4389
|
return new GameJwtAuth(token);
|
|
3178
4390
|
}
|
|
3179
4391
|
|
|
4392
|
+
// src/core/launch/checkpoint.ts
|
|
4393
|
+
var CHECKPOINT_MAX_CHARS = 64000;
|
|
4394
|
+
function sendCheckpointToParent(state) {
|
|
4395
|
+
let serialized;
|
|
4396
|
+
try {
|
|
4397
|
+
serialized = JSON.stringify(state);
|
|
4398
|
+
} catch {}
|
|
4399
|
+
if (serialized === undefined) {
|
|
4400
|
+
console.warn("[Playcademy SDK] parent.checkpoint() dropped a checkpoint that is not JSON-serializable.");
|
|
4401
|
+
return;
|
|
4402
|
+
}
|
|
4403
|
+
if (serialized.length > CHECKPOINT_MAX_CHARS) {
|
|
4404
|
+
console.warn(`[Playcademy SDK] parent.checkpoint() dropped a ${serialized.length}-char checkpoint; the cap is ${CHECKPOINT_MAX_CHARS}.`);
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
messaging.send("PLAYCADEMY_CHECKPOINT" /* CHECKPOINT */, { state: JSON.parse(serialized) });
|
|
4408
|
+
}
|
|
4409
|
+
|
|
3180
4410
|
// src/core/transport/retry.ts
|
|
3181
4411
|
var RETRY_DELAYS_MS = [500, 1500];
|
|
3182
4412
|
function wait(ms) {
|
|
@@ -3319,7 +4549,7 @@ async function request({
|
|
|
3319
4549
|
return rawText && rawText.length > 0 ? rawText : undefined;
|
|
3320
4550
|
}
|
|
3321
4551
|
// src/version.ts
|
|
3322
|
-
var SDK_VERSION = "0.16.1-beta.
|
|
4552
|
+
var SDK_VERSION = "0.16.1-beta.21";
|
|
3323
4553
|
|
|
3324
4554
|
// src/clients/base.ts
|
|
3325
4555
|
class PlaycademyBaseClient {
|
|
@@ -3332,6 +4562,7 @@ class PlaycademyBaseClient {
|
|
|
3332
4562
|
listeners = {};
|
|
3333
4563
|
authContext;
|
|
3334
4564
|
initPayload;
|
|
4565
|
+
parentHandle;
|
|
3335
4566
|
launchId;
|
|
3336
4567
|
gameOrigin;
|
|
3337
4568
|
browserTimeZone;
|
|
@@ -3366,6 +4597,20 @@ class PlaycademyBaseClient {
|
|
|
3366
4597
|
get localDay() {
|
|
3367
4598
|
return this.initPayload?.localDay;
|
|
3368
4599
|
}
|
|
4600
|
+
get parent() {
|
|
4601
|
+
if (this.mode !== "child") {
|
|
4602
|
+
return null;
|
|
4603
|
+
}
|
|
4604
|
+
const context = this.initPayload?.parent;
|
|
4605
|
+
if (!context) {
|
|
4606
|
+
return null;
|
|
4607
|
+
}
|
|
4608
|
+
this.parentHandle ??= {
|
|
4609
|
+
...context,
|
|
4610
|
+
checkpoint: (state) => sendCheckpointToParent(state)
|
|
4611
|
+
};
|
|
4612
|
+
return this.parentHandle;
|
|
4613
|
+
}
|
|
3369
4614
|
setToken(token, tokenType) {
|
|
3370
4615
|
this.authStrategy = createAuthStrategy(token, tokenType);
|
|
3371
4616
|
this.emit("authChange", { token });
|
|
@@ -3500,10 +4745,15 @@ class PlaycademyInternalClient extends PlaycademyBaseClient {
|
|
|
3500
4745
|
}
|
|
3501
4746
|
export {
|
|
3502
4747
|
messaging,
|
|
4748
|
+
isTrustedIframeMessage,
|
|
3503
4749
|
extractApiErrorInfo,
|
|
4750
|
+
beginInitHandshake,
|
|
3504
4751
|
PlaycademyInternalClient,
|
|
3505
4752
|
PlaycademyError,
|
|
3506
4753
|
PlaycademyInternalClient as PlaycademyClient,
|
|
3507
4754
|
MessageEvents,
|
|
4755
|
+
INIT_WAIT_TIMEOUT_MS,
|
|
4756
|
+
HANDSHAKE_RESEND_INTERVAL_MS,
|
|
4757
|
+
HANDSHAKE_MAX_DURATION_MS,
|
|
3508
4758
|
ApiError
|
|
3509
4759
|
};
|