@playcademy/sdk 0.16.1-beta.4 → 0.16.1-beta.5
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 +40 -0
- package/dist/index.d.ts +607 -153
- package/dist/index.js +1657 -685
- package/dist/internal.d.ts +490 -23
- package/dist/internal.js +1689 -722
- package/dist/server/edge.js +5 -1
- package/dist/server.js +5 -1
- package/dist/types.d.ts +553 -151
- package/package.json +1 -1
package/dist/index.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,756 @@ 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"];
|
|
827
|
+
var VALID_E_LEVELS = ["E1", "E2", "E3", "E4"];
|
|
828
|
+
var TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
|
|
829
|
+
xp: 1,
|
|
830
|
+
mastery: 0,
|
|
831
|
+
score: 2
|
|
832
|
+
};
|
|
833
|
+
var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
|
|
834
|
+
xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
|
|
835
|
+
mastery: 0,
|
|
836
|
+
time: 60,
|
|
837
|
+
score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
|
|
838
|
+
};
|
|
839
|
+
// ../utils/src/error.ts
|
|
840
|
+
function errorMessage(err) {
|
|
841
|
+
return err instanceof Error ? err.message : String(err);
|
|
727
842
|
}
|
|
728
|
-
|
|
729
|
-
|
|
843
|
+
|
|
844
|
+
// src/core/launch/exclusivity.ts
|
|
845
|
+
var liveSessions = new WeakSet;
|
|
846
|
+
function registerLiveSession(client) {
|
|
847
|
+
liveSessions.add(client);
|
|
848
|
+
}
|
|
849
|
+
function releaseLiveSession(client) {
|
|
850
|
+
liveSessions.delete(client);
|
|
851
|
+
}
|
|
852
|
+
function hasLiveSession(client) {
|
|
853
|
+
return liveSessions.has(client);
|
|
854
|
+
}
|
|
855
|
+
var pauseProbes = new WeakMap;
|
|
856
|
+
function registerPauseProbe(client, probe) {
|
|
857
|
+
pauseProbes.set(client, probe);
|
|
858
|
+
}
|
|
859
|
+
function isActivityManuallyPaused(client) {
|
|
860
|
+
return pauseProbes.get(client)?.() ?? false;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/core/guards.ts
|
|
864
|
+
var VALID_GRADES = TIMEBACK_GRADES;
|
|
865
|
+
var VALID_SUBJECTS = TIMEBACK_SUBJECTS;
|
|
866
|
+
function isValidGrade(value) {
|
|
867
|
+
return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
|
|
868
|
+
}
|
|
869
|
+
function isValidSubject(value) {
|
|
870
|
+
return typeof value === "string" && VALID_SUBJECTS.includes(value);
|
|
871
|
+
}
|
|
872
|
+
function isAssessmentPurpose(value) {
|
|
873
|
+
return typeof value === "string" && ASSESSMENT_PURPOSES.includes(value);
|
|
874
|
+
}
|
|
875
|
+
function isValidELevel(value) {
|
|
876
|
+
return typeof value === "string" && VALID_E_LEVELS.includes(value);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// src/core/launch/guards.ts
|
|
880
|
+
function isValidLaunchIntent(value) {
|
|
881
|
+
if (typeof value !== "object" || value === null) {
|
|
730
882
|
return false;
|
|
731
883
|
}
|
|
884
|
+
const intent = value;
|
|
885
|
+
return typeof intent.lessonId === "string" && intent.lessonId !== "" && isValidELevel(intent.eLevel);
|
|
886
|
+
}
|
|
887
|
+
function isValidTimebackRecording(recording) {
|
|
888
|
+
return typeof recording.activityId === "string" && recording.activityId !== "" && isValidGrade(recording.grade) && isValidSubject(recording.subject);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/core/launch/resume-store.ts
|
|
892
|
+
function isResumeStore(resume) {
|
|
893
|
+
return "load" in resume && typeof resume.load === "function";
|
|
894
|
+
}
|
|
895
|
+
function resolveResumePolicy(request) {
|
|
896
|
+
const { resume } = request;
|
|
897
|
+
if (resume === false || resume === null) {
|
|
898
|
+
return {};
|
|
899
|
+
}
|
|
900
|
+
if (resume !== undefined) {
|
|
901
|
+
return isResumeStore(resume) ? { store: resume } : { envelope: resume };
|
|
902
|
+
}
|
|
903
|
+
const userId = tokenUserId(request.token);
|
|
904
|
+
if (!userId) {
|
|
905
|
+
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.");
|
|
906
|
+
return {};
|
|
907
|
+
}
|
|
908
|
+
const store = createLocalStorageResumeStore(buildResumeStorageKey({
|
|
909
|
+
userId,
|
|
910
|
+
parentGameId: request.parentGameId,
|
|
911
|
+
slug: request.slug,
|
|
912
|
+
intent: request.intent
|
|
913
|
+
}));
|
|
914
|
+
if (!store) {
|
|
915
|
+
console.warn("[Playcademy SDK] embed.launch(): localStorage is unavailable, so this launch will not be resumable. Pass resume: false to opt out explicitly.");
|
|
916
|
+
return {};
|
|
917
|
+
}
|
|
918
|
+
return { store };
|
|
919
|
+
}
|
|
920
|
+
function tokenUserId(token) {
|
|
921
|
+
const segments = token.split(".");
|
|
922
|
+
if (segments.length !== 3 || !segments[1]) {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
732
925
|
try {
|
|
733
|
-
|
|
926
|
+
const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
927
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
928
|
+
const claims = JSON.parse(atob(padded));
|
|
929
|
+
return typeof claims.uid === "string" && claims.uid ? claims.uid : null;
|
|
734
930
|
} catch {
|
|
735
|
-
return
|
|
931
|
+
return null;
|
|
736
932
|
}
|
|
737
933
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
934
|
+
function buildResumeStorageKey(identity2) {
|
|
935
|
+
const parts = [
|
|
936
|
+
identity2.userId,
|
|
937
|
+
identity2.parentGameId,
|
|
938
|
+
identity2.slug,
|
|
939
|
+
identity2.intent.lessonId,
|
|
940
|
+
identity2.intent.eLevel
|
|
941
|
+
];
|
|
942
|
+
return `playcademy:embed:resume:v1:${parts.map(encodeURIComponent).join(":")}`;
|
|
943
|
+
}
|
|
944
|
+
function isResumeEnvelope(value) {
|
|
945
|
+
return typeof value === "object" && value !== null && "state" in value;
|
|
946
|
+
}
|
|
947
|
+
function getLocalStorage() {
|
|
742
948
|
try {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
});
|
|
747
|
-
const defaults = getOAuthConfig(provider);
|
|
748
|
-
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
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
|
|
759
|
-
});
|
|
760
|
-
if (config.scope) {
|
|
761
|
-
params.set("scope", config.scope);
|
|
762
|
-
}
|
|
763
|
-
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
764
|
-
const popup = openPopupWindow(authUrl, "playcademy-auth");
|
|
765
|
-
if (!popup || popup.closed) {
|
|
766
|
-
throw new Error("Popup blocked. Please enable popups and try again.");
|
|
767
|
-
}
|
|
768
|
-
onStateChange?.({
|
|
769
|
-
status: "exchanging_token",
|
|
770
|
-
message: "Waiting for authentication..."
|
|
771
|
-
});
|
|
772
|
-
return await waitForServerMessage(popup, onStateChange);
|
|
773
|
-
} catch (error) {
|
|
774
|
-
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
|
775
|
-
onStateChange?.({
|
|
776
|
-
status: "error",
|
|
777
|
-
message: errorMessage,
|
|
778
|
-
error: error instanceof Error ? error : new Error(errorMessage)
|
|
779
|
-
});
|
|
780
|
-
throw error;
|
|
949
|
+
return globalThis.localStorage ?? null;
|
|
950
|
+
} catch {
|
|
951
|
+
return null;
|
|
781
952
|
}
|
|
782
953
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
954
|
+
function createLocalStorageResumeStore(key) {
|
|
955
|
+
const storage = getLocalStorage();
|
|
956
|
+
if (!storage) {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
let warned = false;
|
|
960
|
+
return {
|
|
961
|
+
load() {
|
|
962
|
+
let raw;
|
|
963
|
+
try {
|
|
964
|
+
raw = storage.getItem(key);
|
|
965
|
+
} catch {
|
|
966
|
+
return null;
|
|
789
967
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
success: false,
|
|
812
|
-
error
|
|
813
|
-
});
|
|
968
|
+
if (raw === null) {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
const parsed = JSON.parse(raw);
|
|
973
|
+
if (isResumeEnvelope(parsed)) {
|
|
974
|
+
return parsed;
|
|
975
|
+
}
|
|
976
|
+
} catch {}
|
|
977
|
+
try {
|
|
978
|
+
storage.removeItem(key);
|
|
979
|
+
} catch {}
|
|
980
|
+
return null;
|
|
981
|
+
},
|
|
982
|
+
save(envelope) {
|
|
983
|
+
try {
|
|
984
|
+
storage.setItem(key, JSON.stringify(envelope));
|
|
985
|
+
} catch (error) {
|
|
986
|
+
if (!warned) {
|
|
987
|
+
warned = true;
|
|
988
|
+
console.warn("[Playcademy SDK] embed: resume persistence failed; this launch will not be resumable across reloads:", errorMessage(error));
|
|
814
989
|
}
|
|
815
990
|
}
|
|
991
|
+
},
|
|
992
|
+
clear() {
|
|
993
|
+
try {
|
|
994
|
+
storage.removeItem(key);
|
|
995
|
+
} catch {}
|
|
816
996
|
}
|
|
817
|
-
|
|
818
|
-
const checkClosed = setInterval(() => {
|
|
819
|
-
if (popup.closed && !resolved) {
|
|
820
|
-
clearInterval(checkClosed);
|
|
821
|
-
window.removeEventListener("message", handleMessage);
|
|
822
|
-
const error = new Error("Authentication cancelled");
|
|
823
|
-
onStateChange?.({
|
|
824
|
-
status: "error",
|
|
825
|
-
message: error.message,
|
|
826
|
-
error
|
|
827
|
-
});
|
|
828
|
-
resolve({
|
|
829
|
-
success: false,
|
|
830
|
-
error
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
|
-
}, 500);
|
|
834
|
-
setTimeout(() => {
|
|
835
|
-
if (!resolved) {
|
|
836
|
-
window.removeEventListener("message", handleMessage);
|
|
837
|
-
clearInterval(checkClosed);
|
|
838
|
-
const error = new Error("Authentication timeout");
|
|
839
|
-
onStateChange?.({
|
|
840
|
-
status: "error",
|
|
841
|
-
message: error.message,
|
|
842
|
-
error
|
|
843
|
-
});
|
|
844
|
-
resolve({
|
|
845
|
-
success: false,
|
|
846
|
-
error
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
}, 5 * 60 * 1000);
|
|
850
|
-
});
|
|
997
|
+
};
|
|
851
998
|
}
|
|
852
999
|
|
|
853
|
-
// src/core/
|
|
854
|
-
|
|
855
|
-
const
|
|
1000
|
+
// src/core/launch/session.ts
|
|
1001
|
+
function buildParentContext(gameId, intent, resume) {
|
|
1002
|
+
const context = { gameId, intent };
|
|
1003
|
+
if (resume) {
|
|
1004
|
+
context.resume = resume.state;
|
|
1005
|
+
}
|
|
1006
|
+
if (resume?.childRunId) {
|
|
1007
|
+
context.resumeRunId = resume.childRunId;
|
|
1008
|
+
}
|
|
1009
|
+
return context;
|
|
1010
|
+
}
|
|
1011
|
+
var EMBED_IFRAME_STYLE = "border:none;width:100%;height:100%;display:block;";
|
|
1012
|
+
var FORWARD_INTERVAL_MS = 15000;
|
|
1013
|
+
var MAX_FORWARDS_PER_TICK = 3;
|
|
1014
|
+
var WATCHDOG_INTERVAL_MS = 5000;
|
|
1015
|
+
function isCheckpointRelay(payload) {
|
|
1016
|
+
return typeof payload === "object" && payload !== null && "state" in payload;
|
|
1017
|
+
}
|
|
1018
|
+
function isValidHeartbeatRelay(windowStartedAtMs, activeMs, pausedMs) {
|
|
1019
|
+
return typeof windowStartedAtMs === "number" && Number.isFinite(windowStartedAtMs) && Number.isFinite(activeMs) && Number.isFinite(pausedMs) && activeMs >= 0 && pausedMs >= 0;
|
|
1020
|
+
}
|
|
1021
|
+
function isValidEndReport(report) {
|
|
1022
|
+
if (!report) {
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
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;
|
|
1026
|
+
}
|
|
1027
|
+
function reconcileSessionTiming(totals, forwardedActiveMs, forwardedPausedMs) {
|
|
1028
|
+
const remainder = {
|
|
1029
|
+
activeSeconds: Math.max(0, totals.activeSeconds - forwardedActiveMs / 1000)
|
|
1030
|
+
};
|
|
1031
|
+
if (totals.inactiveSeconds !== undefined) {
|
|
1032
|
+
remainder.inactiveSeconds = Math.max(0, totals.inactiveSeconds - forwardedPausedMs / 1000);
|
|
1033
|
+
}
|
|
1034
|
+
return remainder;
|
|
1035
|
+
}
|
|
1036
|
+
function isValidSessionTiming(timing) {
|
|
1037
|
+
if (timing === undefined) {
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
if (!Number.isFinite(timing.activeSeconds) || timing.activeSeconds < 0) {
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
return timing.inactiveSeconds === undefined || Number.isFinite(timing.inactiveSeconds) && timing.inactiveSeconds >= 0;
|
|
1044
|
+
}
|
|
1045
|
+
function isTrackerRelay(type) {
|
|
1046
|
+
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 */;
|
|
1047
|
+
}
|
|
1048
|
+
async function tryStore(operation, label) {
|
|
856
1049
|
try {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1050
|
+
await operation();
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
console.warn(`[Playcademy SDK] embed: resume ${label} failed:`, errorMessage(error));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
class ChildSession {
|
|
1057
|
+
iframe;
|
|
1058
|
+
finished;
|
|
1059
|
+
closed;
|
|
1060
|
+
#options;
|
|
1061
|
+
#bridge;
|
|
1062
|
+
#resolveFinished;
|
|
1063
|
+
#resolveClosed;
|
|
1064
|
+
#settled = false;
|
|
1065
|
+
#torndown = false;
|
|
1066
|
+
#childOrigin = "";
|
|
1067
|
+
#childGameId;
|
|
1068
|
+
#stopHandshake = null;
|
|
1069
|
+
#loadDeadline = null;
|
|
1070
|
+
#watchdogTimer = null;
|
|
1071
|
+
#wasConnected = false;
|
|
1072
|
+
#relayedWindows = new Map;
|
|
1073
|
+
#parentRunId = null;
|
|
1074
|
+
#parentResumeId = null;
|
|
1075
|
+
#forwardTimer = null;
|
|
1076
|
+
#dirtyWindows = new Set;
|
|
1077
|
+
#pendingForwards = new Set;
|
|
1078
|
+
#forwardedActiveMs = 0;
|
|
1079
|
+
#forwardedPausedMs = 0;
|
|
1080
|
+
#checkpointState = null;
|
|
1081
|
+
#hasCheckpoint = false;
|
|
1082
|
+
#childAnnouncedRunId = null;
|
|
1083
|
+
#resumeAccepted = false;
|
|
1084
|
+
#resumeEnvelope;
|
|
1085
|
+
#resumeCleared = false;
|
|
1086
|
+
#storeChain = Promise.resolve();
|
|
1087
|
+
#warnedSettledRelay = false;
|
|
1088
|
+
#sawReady = false;
|
|
1089
|
+
#closedWindows = new Set;
|
|
1090
|
+
#currentWindowKey = null;
|
|
1091
|
+
constructor(options) {
|
|
1092
|
+
this.#options = options;
|
|
1093
|
+
this.#resumeEnvelope = options.resume ?? null;
|
|
1094
|
+
this.#bridge = options.timeback && options.reporter ? { recording: options.timeback, reporter: options.reporter } : null;
|
|
1095
|
+
this.iframe = document.createElement("iframe");
|
|
1096
|
+
this.iframe.style.cssText = EMBED_IFRAME_STYLE;
|
|
1097
|
+
options.container.appendChild(this.iframe);
|
|
1098
|
+
this.#watchdogTimer = setInterval(() => {
|
|
1099
|
+
if (this.iframe.isConnected) {
|
|
1100
|
+
this.#wasConnected = true;
|
|
1101
|
+
} else if (this.#wasConnected) {
|
|
1102
|
+
this.close();
|
|
1103
|
+
}
|
|
1104
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
1105
|
+
this.finished = new Promise((resolve) => {
|
|
1106
|
+
this.#resolveFinished = resolve;
|
|
860
1107
|
});
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (!config.clientId) {
|
|
864
|
-
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
865
|
-
}
|
|
866
|
-
const stateData = options.stateData;
|
|
867
|
-
const state = await generateOAuthState(stateData);
|
|
868
|
-
const params = new URLSearchParams({
|
|
869
|
-
response_type: "code",
|
|
870
|
-
client_id: config.clientId,
|
|
871
|
-
redirect_uri: callbackUrl,
|
|
872
|
-
state
|
|
1108
|
+
this.closed = new Promise((resolve) => {
|
|
1109
|
+
this.#resolveClosed = resolve;
|
|
873
1110
|
});
|
|
874
|
-
|
|
875
|
-
|
|
1111
|
+
this.#open();
|
|
1112
|
+
}
|
|
1113
|
+
close() {
|
|
1114
|
+
this.#abandon();
|
|
1115
|
+
this.#teardown();
|
|
1116
|
+
}
|
|
1117
|
+
get checkpoint() {
|
|
1118
|
+
if (!this.#hasCheckpoint) {
|
|
1119
|
+
return null;
|
|
876
1120
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
error
|
|
1121
|
+
return {
|
|
1122
|
+
state: this.#checkpointState,
|
|
1123
|
+
childRunId: this.#childAnnouncedRunId ?? undefined,
|
|
1124
|
+
parentRunId: this.#parentRunId ?? undefined
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
#open() {
|
|
1128
|
+
this.#boot().catch((error) => {
|
|
1129
|
+
this.#fail(error instanceof PlaycademyError ? error : new PlaycademyError(`embed.launch() could not launch '${this.#options.slug}': ${errorMessage(error)}`));
|
|
886
1130
|
});
|
|
887
|
-
throw error;
|
|
888
1131
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1132
|
+
async#boot() {
|
|
1133
|
+
this.#loadDeadline = setTimeout(() => {
|
|
1134
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' did not become ready within ${HANDSHAKE_MAX_DURATION_MS / 1000}s.`));
|
|
1135
|
+
}, HANDSHAKE_MAX_DURATION_MS);
|
|
1136
|
+
const store = this.#options.resumeStore;
|
|
1137
|
+
if (store) {
|
|
1138
|
+
try {
|
|
1139
|
+
this.#resumeEnvelope = await store.load() ?? null;
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
console.warn("[Playcademy SDK] embed: resume load failed; starting fresh:", errorMessage(error));
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (this.#torndown) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const envelope = this.#resumeEnvelope;
|
|
1148
|
+
const { childUrl, payload } = await this.#options.resolveTarget(envelope ? { state: envelope.state, childRunId: envelope.childRunId } : undefined);
|
|
1149
|
+
if (this.#torndown) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
this.#childOrigin = new URL(childUrl).origin;
|
|
1153
|
+
this.#childGameId = payload.gameId;
|
|
1154
|
+
window.addEventListener("message", this.#onChildMessage);
|
|
1155
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, this.#onTokenRefresh);
|
|
1156
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, this.#onLauncherPause);
|
|
1157
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, this.#onLauncherResume);
|
|
1158
|
+
this.iframe.addEventListener("load", () => {
|
|
1159
|
+
this.#clearLoadDeadline();
|
|
1160
|
+
if (this.#torndown) {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
this.#stopHandshake = beginInitHandshake({
|
|
1164
|
+
iframe: this.iframe,
|
|
1165
|
+
origin: this.#childOrigin,
|
|
1166
|
+
payload,
|
|
1167
|
+
onTimeout: () => {
|
|
1168
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' did not answer INIT within ${HANDSHAKE_MAX_DURATION_MS / 1000}s.`));
|
|
1169
|
+
},
|
|
1170
|
+
onSendError: (error) => {
|
|
1171
|
+
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.`));
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
}, { once: true });
|
|
1175
|
+
this.iframe.src = childUrl;
|
|
899
1176
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1177
|
+
#clearLoadDeadline() {
|
|
1178
|
+
if (this.#loadDeadline) {
|
|
1179
|
+
clearTimeout(this.#loadDeadline);
|
|
1180
|
+
this.#loadDeadline = null;
|
|
903
1181
|
}
|
|
904
|
-
|
|
905
|
-
|
|
1182
|
+
}
|
|
1183
|
+
#teardown() {
|
|
1184
|
+
if (this.#torndown) {
|
|
1185
|
+
return;
|
|
906
1186
|
}
|
|
907
|
-
|
|
908
|
-
|
|
1187
|
+
this.#torndown = true;
|
|
1188
|
+
this.#stopHandshake?.();
|
|
1189
|
+
this.#stopHandshake = null;
|
|
1190
|
+
this.#clearLoadDeadline();
|
|
1191
|
+
if (this.#forwardTimer) {
|
|
1192
|
+
clearInterval(this.#forwardTimer);
|
|
1193
|
+
this.#forwardTimer = null;
|
|
1194
|
+
}
|
|
1195
|
+
if (this.#watchdogTimer) {
|
|
1196
|
+
clearInterval(this.#watchdogTimer);
|
|
1197
|
+
this.#watchdogTimer = null;
|
|
1198
|
+
}
|
|
1199
|
+
this.#markOpenWindowFlushable();
|
|
1200
|
+
this.#forwardDirtyWindows();
|
|
1201
|
+
window.removeEventListener("message", this.#onChildMessage);
|
|
1202
|
+
window.removeEventListener("pagehide", this.#onPageHide);
|
|
1203
|
+
messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, this.#onTokenRefresh);
|
|
1204
|
+
messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, this.#onLauncherPause);
|
|
1205
|
+
messaging.unlisten("PLAYCADEMY_RESUME" /* RESUME */, this.#onLauncherResume);
|
|
1206
|
+
this.iframe.remove();
|
|
1207
|
+
this.#resolveClosed();
|
|
1208
|
+
}
|
|
1209
|
+
#fail(error) {
|
|
1210
|
+
if (!this.#settled) {
|
|
1211
|
+
this.#settled = true;
|
|
1212
|
+
this.#resolveFinished({ status: "failed", error });
|
|
1213
|
+
}
|
|
1214
|
+
this.#teardown();
|
|
1215
|
+
}
|
|
1216
|
+
#abandon() {
|
|
1217
|
+
if (this.#settled) {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
this.#settled = true;
|
|
1221
|
+
const activity = {
|
|
1222
|
+
status: "abandoned",
|
|
1223
|
+
timing: this.#relayedTiming()
|
|
1224
|
+
};
|
|
1225
|
+
const resume = this.checkpoint;
|
|
1226
|
+
if (resume) {
|
|
1227
|
+
activity.resume = resume;
|
|
909
1228
|
}
|
|
1229
|
+
this.#resolveFinished(activity);
|
|
910
1230
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1231
|
+
#relayedTiming() {
|
|
1232
|
+
let activeMs = 0;
|
|
1233
|
+
let pausedMs = 0;
|
|
1234
|
+
for (const snapshot of this.#relayedWindows.values()) {
|
|
1235
|
+
activeMs += snapshot.activeMs;
|
|
1236
|
+
pausedMs += snapshot.pausedMs;
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1239
|
+
activeSeconds: Math.round(activeMs / 1000),
|
|
1240
|
+
inactiveSeconds: Math.round(pausedMs / 1000)
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
#complete(childReport) {
|
|
1244
|
+
if (this.#settled) {
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
this.#settled = true;
|
|
1248
|
+
this.#clearResume();
|
|
1249
|
+
let endMemo = null;
|
|
1250
|
+
this.#resolveFinished({
|
|
1251
|
+
status: "completed",
|
|
1252
|
+
correct: childReport.scoreData.correctQuestions,
|
|
1253
|
+
total: childReport.scoreData.totalQuestions,
|
|
1254
|
+
timing: this.#completedTiming(childReport),
|
|
1255
|
+
childReport,
|
|
1256
|
+
end: (scores) => {
|
|
1257
|
+
if (!this.#bridge) {
|
|
1258
|
+
return Promise.reject(new PlaycademyError("end() requires the timeback option at launch; this launch was a pure UX embed."));
|
|
922
1259
|
}
|
|
923
|
-
|
|
924
|
-
|
|
1260
|
+
endMemo ??= this.#postCompletion(this.#bridge, childReport, scores).catch((error) => {
|
|
1261
|
+
endMemo = null;
|
|
1262
|
+
throw error;
|
|
1263
|
+
});
|
|
1264
|
+
return endMemo;
|
|
925
1265
|
}
|
|
926
|
-
}
|
|
927
|
-
log.debug("[Playcademy SDK] Starting OAuth login", {
|
|
928
|
-
provider: options.provider,
|
|
929
|
-
mode: options.mode || "auto",
|
|
930
|
-
callbackUrl: options.callbackUrl,
|
|
931
|
-
hasStateData: Boolean(stateData)
|
|
932
1266
|
});
|
|
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
|
-
});
|
|
1267
|
+
}
|
|
1268
|
+
#completedTiming(childReport) {
|
|
1269
|
+
if (this.#relayedWindows.size > 0) {
|
|
1270
|
+
return this.#relayedTiming();
|
|
942
1271
|
}
|
|
943
|
-
return result;
|
|
944
|
-
} catch (error) {
|
|
945
|
-
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
946
|
-
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
947
1272
|
return {
|
|
948
|
-
|
|
949
|
-
|
|
1273
|
+
activeSeconds: childReport.timingData.durationSeconds,
|
|
1274
|
+
inactiveSeconds: childReport.sessionTimingData?.inactiveSeconds
|
|
950
1275
|
};
|
|
951
1276
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1277
|
+
#ensureBridgeRun() {
|
|
1278
|
+
if (!this.#bridge || this.#parentRunId) {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
this.#parentRunId = this.#resumeAccepted && this.#resumeEnvelope?.parentRunId ? this.#resumeEnvelope.parentRunId : crypto.randomUUID();
|
|
1282
|
+
this.#parentResumeId = crypto.randomUUID();
|
|
1283
|
+
if (this.#torndown) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
this.#forwardTimer ??= setInterval(() => this.#forwardDirtyWindows(MAX_FORWARDS_PER_TICK), FORWARD_INTERVAL_MS);
|
|
1287
|
+
window.addEventListener("pagehide", this.#onPageHide);
|
|
1288
|
+
}
|
|
1289
|
+
#forwardDirtyWindows(limit = Number.POSITIVE_INFINITY) {
|
|
1290
|
+
const bridge = this.#bridge;
|
|
1291
|
+
if (!bridge) {
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
this.#drainDirtyWindows(limit, (body, windowStartedAtMs) => {
|
|
1295
|
+
const forward = bridge.reporter.postHeartbeat(body).then(() => {
|
|
1296
|
+
this.#forwardedActiveMs += body.timingData.activeMs;
|
|
1297
|
+
this.#forwardedPausedMs += body.timingData.pausedMs;
|
|
1298
|
+
}).catch((error) => {
|
|
1299
|
+
this.#dirtyWindows.add(windowStartedAtMs);
|
|
1300
|
+
console.warn("[Playcademy SDK] embed heartbeat forward failed:", errorMessage(error));
|
|
1301
|
+
}).finally(() => {
|
|
1302
|
+
this.#pendingForwards.delete(forward);
|
|
1303
|
+
});
|
|
1304
|
+
this.#pendingForwards.add(forward);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
#onPageHide = () => {
|
|
1308
|
+
const bridge = this.#bridge;
|
|
1309
|
+
if (!bridge) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
this.#markOpenWindowFlushable();
|
|
1313
|
+
this.#drainDirtyWindows(Number.POSITIVE_INFINITY, (body) => bridge.reporter.postHeartbeatKeepalive(body));
|
|
966
1314
|
};
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1315
|
+
#markOpenWindowFlushable() {
|
|
1316
|
+
const key = this.#currentWindowKey;
|
|
1317
|
+
if (key === null) {
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const snapshot = this.#relayedWindows.get(key);
|
|
1321
|
+
this.#currentWindowKey = null;
|
|
1322
|
+
this.#closedWindows.add(key);
|
|
1323
|
+
if (this.#bridge && snapshot && (snapshot.activeMs > 0 || snapshot.pausedMs > 0)) {
|
|
1324
|
+
this.#dirtyWindows.add(key);
|
|
974
1325
|
}
|
|
975
|
-
eventListeners.get(eventType).add(handler);
|
|
976
1326
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1327
|
+
#drainDirtyWindows(limit, post) {
|
|
1328
|
+
if (!this.#bridge || !this.#parentRunId || this.#dirtyWindows.size === 0) {
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const recording = this.#bridge.recording;
|
|
1332
|
+
const runId = this.#parentRunId;
|
|
1333
|
+
const oldestFirst = [...this.#dirtyWindows].toSorted((a, b) => a - b).slice(0, limit);
|
|
1334
|
+
for (const windowStartedAtMs of oldestFirst) {
|
|
1335
|
+
const snapshot = this.#relayedWindows.get(windowStartedAtMs);
|
|
1336
|
+
this.#dirtyWindows.delete(windowStartedAtMs);
|
|
1337
|
+
if (snapshot) {
|
|
1338
|
+
post({
|
|
1339
|
+
runId,
|
|
1340
|
+
resumeId: this.#parentResumeId ?? undefined,
|
|
1341
|
+
activityData: recording,
|
|
1342
|
+
timingData: { activeMs: snapshot.activeMs, pausedMs: snapshot.pausedMs },
|
|
1343
|
+
windowStartedAtMs
|
|
1344
|
+
}, windowStartedAtMs);
|
|
983
1345
|
}
|
|
984
1346
|
}
|
|
985
1347
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1348
|
+
async#postCompletion(bridge, childReport, scores) {
|
|
1349
|
+
this.#ensureBridgeRun();
|
|
1350
|
+
this.#forwardDirtyWindows();
|
|
1351
|
+
await Promise.allSettled(this.#pendingForwards);
|
|
1352
|
+
this.#dirtyWindows.clear();
|
|
1353
|
+
return bridge.reporter.postEndActivity({
|
|
1354
|
+
runId: this.#parentRunId ?? undefined,
|
|
1355
|
+
resumeId: this.#parentResumeId ?? undefined,
|
|
1356
|
+
activityData: bridge.recording,
|
|
1357
|
+
scoreData: {
|
|
1358
|
+
correctQuestions: scores.correctQuestions,
|
|
1359
|
+
totalQuestions: scores.totalQuestions
|
|
1360
|
+
},
|
|
1361
|
+
timingData: {
|
|
1362
|
+
durationSeconds: childReport.timingData.durationSeconds
|
|
1363
|
+
},
|
|
1364
|
+
sessionTimingData: reconcileSessionTiming(this.#reconciliationTotals(childReport.sessionTimingData), this.#forwardedActiveMs, this.#forwardedPausedMs),
|
|
1365
|
+
xpEarned: scores.xpAwarded,
|
|
1366
|
+
masteredUnits: scores.masteredUnits,
|
|
1367
|
+
masteredUnitsAbsolute: scores.masteredUnitsAbsolute,
|
|
1368
|
+
extensions: {
|
|
1369
|
+
...childReport.extensions,
|
|
1370
|
+
...scores.extensions,
|
|
1371
|
+
childGameId: this.#childGameId,
|
|
1372
|
+
childRunId: childReport.runId,
|
|
1373
|
+
childActivityId: childReport.activityData.activityId,
|
|
1374
|
+
childXpSuggested: childReport.xpEarned
|
|
994
1375
|
}
|
|
995
|
-
};
|
|
996
|
-
const playcademyConfig = globalThis.PLAYCADEMY;
|
|
997
|
-
const forwardKeys = Array.isArray(playcademyConfig?.forwardKeys) ? playcademyConfig.forwardKeys : ["Escape"];
|
|
998
|
-
const keySet = new Set(forwardKeys.map((k) => k.toLowerCase()));
|
|
999
|
-
globalThis.addEventListener("keydown", keyListener);
|
|
1000
|
-
globalThis.addEventListener("keyup", keyListener);
|
|
1001
|
-
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, () => {
|
|
1002
|
-
globalThis.removeEventListener("keydown", keyListener);
|
|
1003
|
-
globalThis.removeEventListener("keyup", keyListener);
|
|
1004
1376
|
});
|
|
1005
1377
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
}
|
|
1378
|
+
#reconciliationTotals(reported) {
|
|
1379
|
+
if (reported) {
|
|
1380
|
+
return reported;
|
|
1381
|
+
}
|
|
1382
|
+
const relayed = this.#relayedTiming();
|
|
1383
|
+
return {
|
|
1384
|
+
activeSeconds: relayed.activeSeconds,
|
|
1385
|
+
inactiveSeconds: relayed.inactiveSeconds
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
#onChildMessage = (event) => {
|
|
1389
|
+
if (!isTrustedIframeMessage(event, this.iframe.contentWindow, this.#childOrigin)) {
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
const data = event.data;
|
|
1393
|
+
if (!data) {
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
const type = data.type;
|
|
1397
|
+
if (type === "PLAYCADEMY_READY" /* READY */) {
|
|
1398
|
+
this.#sawReady = true;
|
|
1399
|
+
this.#stopHandshake?.();
|
|
1400
|
+
this.#stopHandshake = null;
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (type === "PLAYCADEMY_INIT_ERROR" /* INIT_ERROR */) {
|
|
1404
|
+
const payload = data.payload;
|
|
1405
|
+
const reason = typeof payload?.reason === "string" ? payload.reason : "Child game reported an initialization error";
|
|
1406
|
+
if (this.#sawReady) {
|
|
1407
|
+
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.`);
|
|
1408
|
+
this.#abandon();
|
|
1409
|
+
this.#teardown();
|
|
1410
|
+
return;
|
|
1049
1411
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1412
|
+
this.#fail(new PlaycademyError(`Child game '${this.#options.slug}' failed to initialize: ${reason}`));
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (this.#settled && isTrackerRelay(type)) {
|
|
1416
|
+
if (!this.#warnedSettledRelay) {
|
|
1417
|
+
this.#warnedSettledRelay = true;
|
|
1418
|
+
console.warn("[Playcademy SDK] embed: this launch already reported; ignoring tracker relays from the child. One report per launch: launch again for another lesson.");
|
|
1056
1419
|
}
|
|
1057
|
-
return
|
|
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);
|
|
1420
|
+
return;
|
|
1068
1421
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
return {
|
|
1073
|
-
url(pathOrStrings, ...values) {
|
|
1074
|
-
const gameUrl = client["initPayload"]?.gameUrl;
|
|
1075
|
-
let path;
|
|
1076
|
-
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
1077
|
-
const strings = pathOrStrings;
|
|
1078
|
-
path = strings.reduce((acc, str, i) => acc + str + (values[i] != null ? String(values[i]) : ""), "");
|
|
1079
|
-
} else {
|
|
1080
|
-
path = pathOrStrings;
|
|
1081
|
-
}
|
|
1082
|
-
if (!gameUrl) {
|
|
1083
|
-
return path.startsWith("./") ? path : `./${path}`;
|
|
1084
|
-
}
|
|
1085
|
-
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
1086
|
-
return `${gameUrl}${cleanPath}`;
|
|
1087
|
-
},
|
|
1088
|
-
fetch: fetchAsset,
|
|
1089
|
-
json: async (path) => {
|
|
1090
|
-
const response = await fetchAsset(path);
|
|
1091
|
-
return await response.json();
|
|
1092
|
-
},
|
|
1093
|
-
blob: async (path) => {
|
|
1094
|
-
const response = await fetchAsset(path);
|
|
1095
|
-
return response.blob();
|
|
1096
|
-
},
|
|
1097
|
-
text: async (path) => {
|
|
1098
|
-
const response = await fetchAsset(path);
|
|
1099
|
-
return response.text();
|
|
1100
|
-
},
|
|
1101
|
-
arrayBuffer: async (path) => {
|
|
1102
|
-
const response = await fetchAsset(path);
|
|
1103
|
-
return response.arrayBuffer();
|
|
1422
|
+
if (type === "PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */) {
|
|
1423
|
+
this.#onActivityStartRelay(data.payload);
|
|
1424
|
+
return;
|
|
1104
1425
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
function createScoresNamespace(client) {
|
|
1109
|
-
return {
|
|
1110
|
-
submit: async (score, metadata) => {
|
|
1111
|
-
const gameId = client["_ensureGameId"]();
|
|
1112
|
-
return client["request"](`/games/${gameId}/scores`, "POST", {
|
|
1113
|
-
body: {
|
|
1114
|
-
score,
|
|
1115
|
-
metadata
|
|
1116
|
-
}
|
|
1117
|
-
});
|
|
1426
|
+
if (type === "PLAYCADEMY_CHECKPOINT" /* CHECKPOINT */) {
|
|
1427
|
+
this.#onCheckpointRelay(data.payload);
|
|
1428
|
+
return;
|
|
1118
1429
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
games: ["read", "write", "delete"],
|
|
1124
|
-
users: ["read:self", "write:self"],
|
|
1125
|
-
dev: ["read", "write"]
|
|
1126
|
-
};
|
|
1127
|
-
// ../constants/src/platform.ts
|
|
1128
|
-
var PLAYCADEMY_BROWSER_TIME_ZONE_HEADER = "x-playcademy-browser-time-zone";
|
|
1129
|
-
// ../constants/src/timeback.ts
|
|
1130
|
-
var TIMEBACK_ROUTES = {
|
|
1131
|
-
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
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();
|
|
1430
|
+
if (type === "PLAYCADEMY_EXIT" /* EXIT */) {
|
|
1431
|
+
this.#abandon();
|
|
1432
|
+
this.#teardown();
|
|
1433
|
+
return;
|
|
1189
1434
|
}
|
|
1190
|
-
if (
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1435
|
+
if (type === "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */) {
|
|
1436
|
+
this.#onHeartbeatRelay(data.payload);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (type === "PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */) {
|
|
1440
|
+
this.#onEndRelay(data.payload);
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
#onActivityStartRelay(payload) {
|
|
1444
|
+
const announced = payload?.runId;
|
|
1445
|
+
if (typeof announced === "string") {
|
|
1446
|
+
this.#childAnnouncedRunId = announced;
|
|
1447
|
+
if (announced === this.#resumeEnvelope?.childRunId) {
|
|
1448
|
+
this.#resumeAccepted = true;
|
|
1194
1449
|
}
|
|
1195
1450
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
throw error;
|
|
1199
|
-
});
|
|
1200
|
-
cache.set(fullKey, {
|
|
1201
|
-
value: promise,
|
|
1202
|
-
expiresAt: now + effectiveTTL
|
|
1203
|
-
});
|
|
1204
|
-
return promise;
|
|
1451
|
+
this.#ensureBridgeRun();
|
|
1452
|
+
this.#persistCheckpoint();
|
|
1205
1453
|
}
|
|
1206
|
-
|
|
1207
|
-
if (
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
1212
|
-
cache.delete(fullKey);
|
|
1454
|
+
#onCheckpointRelay(payload) {
|
|
1455
|
+
if (isCheckpointRelay(payload)) {
|
|
1456
|
+
this.#checkpointState = payload.state;
|
|
1457
|
+
this.#hasCheckpoint = true;
|
|
1458
|
+
this.#persistCheckpoint();
|
|
1213
1459
|
}
|
|
1214
1460
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1461
|
+
#persistCheckpoint() {
|
|
1462
|
+
const store = this.#options.resumeStore;
|
|
1463
|
+
if (!store || this.#resumeCleared) {
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const envelope = this.checkpoint;
|
|
1467
|
+
if (!envelope) {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
this.#enqueueStore(() => store.save(envelope), "save");
|
|
1217
1471
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1472
|
+
#clearResume() {
|
|
1473
|
+
this.#resumeCleared = true;
|
|
1474
|
+
const store = this.#options.resumeStore;
|
|
1475
|
+
if (!store) {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
this.#enqueueStore(() => store.clear(), "clear");
|
|
1479
|
+
}
|
|
1480
|
+
#enqueueStore(operation, label) {
|
|
1481
|
+
this.#storeChain = this.#storeChain.then(() => tryStore(operation, label));
|
|
1482
|
+
}
|
|
1483
|
+
#onHeartbeatRelay(payload) {
|
|
1484
|
+
const heartbeat = payload;
|
|
1485
|
+
const windowStartedAtMs = heartbeat?.windowStartedAtMs;
|
|
1486
|
+
const activeMs = heartbeat?.timingData?.activeMs ?? 0;
|
|
1487
|
+
const pausedMs = heartbeat?.timingData?.pausedMs ?? 0;
|
|
1488
|
+
if (!isValidHeartbeatRelay(windowStartedAtMs, activeMs, pausedMs)) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
this.#relayedWindows.set(windowStartedAtMs, { activeMs, pausedMs });
|
|
1492
|
+
const closed = heartbeat?.windowClosed === true;
|
|
1493
|
+
if (closed) {
|
|
1494
|
+
this.#closedWindows.add(windowStartedAtMs);
|
|
1495
|
+
if (this.#currentWindowKey === windowStartedAtMs) {
|
|
1496
|
+
this.#currentWindowKey = null;
|
|
1497
|
+
}
|
|
1498
|
+
} else {
|
|
1499
|
+
const superseded = this.#currentWindowKey;
|
|
1500
|
+
if (superseded !== null && superseded !== windowStartedAtMs && !this.#closedWindows.has(superseded)) {
|
|
1501
|
+
this.#closedWindows.add(superseded);
|
|
1502
|
+
if (this.#bridge) {
|
|
1503
|
+
this.#dirtyWindows.add(superseded);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
this.#currentWindowKey = windowStartedAtMs;
|
|
1507
|
+
}
|
|
1508
|
+
if (this.#bridge) {
|
|
1509
|
+
this.#ensureBridgeRun();
|
|
1510
|
+
if (closed) {
|
|
1511
|
+
this.#dirtyWindows.add(windowStartedAtMs);
|
|
1223
1512
|
}
|
|
1224
1513
|
}
|
|
1225
1514
|
}
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1515
|
+
#onEndRelay(payload) {
|
|
1516
|
+
const report = payload;
|
|
1517
|
+
if (!isValidEndReport(report)) {
|
|
1518
|
+
console.warn("[Playcademy SDK] embed: ignoring malformed end-activity relay from the child");
|
|
1519
|
+
return;
|
|
1231
1520
|
}
|
|
1232
|
-
|
|
1521
|
+
this.#complete(isValidSessionTiming(report.sessionTimingData) ? report : { ...report, sessionTimingData: undefined });
|
|
1233
1522
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1523
|
+
#onTokenRefresh = (payload) => {
|
|
1524
|
+
if (this.iframe.contentWindow) {
|
|
1525
|
+
messaging.send("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, payload, {
|
|
1526
|
+
target: this.iframe.contentWindow,
|
|
1527
|
+
origin: this.#childOrigin
|
|
1528
|
+
});
|
|
1239
1529
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1530
|
+
};
|
|
1531
|
+
#onLauncherPause = () => {
|
|
1532
|
+
this.#forwardControlToChild("PLAYCADEMY_PAUSE" /* PAUSE */);
|
|
1533
|
+
};
|
|
1534
|
+
#onLauncherResume = () => {
|
|
1535
|
+
this.#forwardControlToChild("PLAYCADEMY_RESUME" /* RESUME */);
|
|
1536
|
+
};
|
|
1537
|
+
#forwardControlToChild(type) {
|
|
1538
|
+
if (this.iframe.contentWindow) {
|
|
1539
|
+
messaging.send(type, undefined, {
|
|
1540
|
+
target: this.iframe.contentWindow,
|
|
1541
|
+
origin: this.#childOrigin
|
|
1542
|
+
});
|
|
1244
1543
|
}
|
|
1245
|
-
return true;
|
|
1246
1544
|
}
|
|
1247
|
-
return { get, clear, size, prune, getKeys, has };
|
|
1248
1545
|
}
|
|
1249
1546
|
|
|
1250
1547
|
// ../utils/src/uuid.ts
|
|
@@ -1256,6 +1553,25 @@ function isValidUUID(value) {
|
|
|
1256
1553
|
return UUID_REGEX.test(value);
|
|
1257
1554
|
}
|
|
1258
1555
|
|
|
1556
|
+
// src/core/timeback/keepalive.ts
|
|
1557
|
+
async function sendHeartbeatKeepalive(client, body) {
|
|
1558
|
+
try {
|
|
1559
|
+
const url = `${client["getGameBackendUrl"]()}${TIMEBACK_ROUTES.HEARTBEAT}`;
|
|
1560
|
+
const response = await fetch(url, {
|
|
1561
|
+
method: "POST",
|
|
1562
|
+
headers: {
|
|
1563
|
+
"Content-Type": "application/json",
|
|
1564
|
+
...client["authStrategy"].getHeaders()
|
|
1565
|
+
},
|
|
1566
|
+
body: JSON.stringify(body),
|
|
1567
|
+
keepalive: true
|
|
1568
|
+
});
|
|
1569
|
+
return response.ok;
|
|
1570
|
+
} catch {
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1259
1575
|
// src/core/timeback/activity-tracker.ts
|
|
1260
1576
|
var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
1261
1577
|
var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
|
|
@@ -1268,6 +1584,9 @@ var END_ACTIVITY_RETRY_POLICY = {
|
|
|
1268
1584
|
};
|
|
1269
1585
|
var USER_ACTIVITY_EVENTS = ["keydown", "pointerdown", "pointermove", "wheel"];
|
|
1270
1586
|
var USER_ACTIVITY_LISTENER_OPTIONS = { capture: true };
|
|
1587
|
+
function jsonProjection(value) {
|
|
1588
|
+
return JSON.parse(JSON.stringify(value));
|
|
1589
|
+
}
|
|
1271
1590
|
function normalizeDelayMs(value, defaultValue, allowZero = true) {
|
|
1272
1591
|
if (value === Infinity) {
|
|
1273
1592
|
return Infinity;
|
|
@@ -1327,6 +1646,17 @@ function queueHeartbeatFlush(activity, timing, flush) {
|
|
|
1327
1646
|
}
|
|
1328
1647
|
return activity.flushInFlight;
|
|
1329
1648
|
}
|
|
1649
|
+
function queueClosedWindowRelay(activity, timing, body) {
|
|
1650
|
+
return queueHeartbeatFlush(activity, timing, async () => {
|
|
1651
|
+
try {
|
|
1652
|
+
messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, {
|
|
1653
|
+
...body,
|
|
1654
|
+
windowClosed: true
|
|
1655
|
+
});
|
|
1656
|
+
} catch {}
|
|
1657
|
+
return false;
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1330
1660
|
function stopHeartbeatInterval(activity) {
|
|
1331
1661
|
if (activity.heartbeatIntervalId === null) {
|
|
1332
1662
|
return;
|
|
@@ -1375,6 +1705,18 @@ function createTimebackActivityTracker(client) {
|
|
|
1375
1705
|
let lastRelayedActiveMs = -1;
|
|
1376
1706
|
let lastRelayedPausedMs = -1;
|
|
1377
1707
|
let lastRelayedWindowStart = -1;
|
|
1708
|
+
function relaysToParent() {
|
|
1709
|
+
return client.parent !== null;
|
|
1710
|
+
}
|
|
1711
|
+
let resumeRunIdSpent = false;
|
|
1712
|
+
function adoptResumedRunId() {
|
|
1713
|
+
if (resumeRunIdSpent) {
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
resumeRunIdSpent = true;
|
|
1717
|
+
const resumeRunId = client["initPayload"]?.parent?.resumeRunId;
|
|
1718
|
+
return typeof resumeRunId === "string" && isValidUUID(resumeRunId) ? resumeRunId : undefined;
|
|
1719
|
+
}
|
|
1378
1720
|
function startHeartbeatInterval(activity) {
|
|
1379
1721
|
if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
|
|
1380
1722
|
return;
|
|
@@ -1552,6 +1894,7 @@ function createTimebackActivityTracker(client) {
|
|
|
1552
1894
|
if (!activity) {
|
|
1553
1895
|
return;
|
|
1554
1896
|
}
|
|
1897
|
+
applyOverdueInactivity();
|
|
1555
1898
|
const timing = computeWindowSnapshot(activity);
|
|
1556
1899
|
if (timing.activeMs === lastRelayedActiveMs && timing.pausedMs === lastRelayedPausedMs && timing.windowStartedAtMs === lastRelayedWindowStart) {
|
|
1557
1900
|
return;
|
|
@@ -1614,6 +1957,10 @@ function createTimebackActivityTracker(client) {
|
|
|
1614
1957
|
return;
|
|
1615
1958
|
}
|
|
1616
1959
|
const body = buildHeartbeatBody(trackedActivity, timing, isFinal);
|
|
1960
|
+
if (relaysToParent()) {
|
|
1961
|
+
await queueClosedWindowRelay(trackedActivity, timing, body);
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1617
1964
|
await queueHeartbeatFlush(trackedActivity, timing, async () => {
|
|
1618
1965
|
try {
|
|
1619
1966
|
await client["requestGameBackend"](TIMEBACK_ROUTES.HEARTBEAT, "POST", body, undefined, { retryPolicy: HEARTBEAT_RETRY_POLICY });
|
|
@@ -1633,203 +1980,787 @@ function createTimebackActivityTracker(client) {
|
|
|
1633
1980
|
if (timing.activeMs === 0 && timing.pausedMs === 0) {
|
|
1634
1981
|
return;
|
|
1635
1982
|
}
|
|
1636
|
-
const body = buildHeartbeatBody(activity, timing, true);
|
|
1637
|
-
stopRelayInterval();
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1983
|
+
const body = buildHeartbeatBody(activity, timing, true);
|
|
1984
|
+
stopRelayInterval();
|
|
1985
|
+
if (relaysToParent()) {
|
|
1986
|
+
queueClosedWindowRelay(activity, timing, body);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
try {
|
|
1990
|
+
messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, body);
|
|
1991
|
+
} catch {}
|
|
1992
|
+
if (!client["initPayload"]?.hasHeartbeatRelay) {
|
|
1993
|
+
queueHeartbeatFlush(activity, timing, () => sendHeartbeatKeepalive(client, body));
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
function handlePageHide() {
|
|
1997
|
+
flushFinalHeartbeatBeacon();
|
|
1998
|
+
}
|
|
1999
|
+
function cleanupListeners() {
|
|
2000
|
+
if (boundVisibilityHandler && typeof document !== "undefined") {
|
|
2001
|
+
document.removeEventListener("visibilitychange", boundVisibilityHandler);
|
|
2002
|
+
boundVisibilityHandler = null;
|
|
2003
|
+
}
|
|
2004
|
+
if (boundUserInteractionHandler && typeof document !== "undefined") {
|
|
2005
|
+
for (const eventName of USER_ACTIVITY_EVENTS) {
|
|
2006
|
+
document.removeEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
|
|
2007
|
+
}
|
|
2008
|
+
boundUserInteractionHandler = null;
|
|
2009
|
+
}
|
|
2010
|
+
if (boundShellPauseHandler) {
|
|
2011
|
+
messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
2012
|
+
boundShellPauseHandler = null;
|
|
2013
|
+
}
|
|
2014
|
+
if (boundShellResumeHandler) {
|
|
2015
|
+
messaging.unlisten("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
2016
|
+
boundShellResumeHandler = null;
|
|
2017
|
+
}
|
|
2018
|
+
if (boundPageHideHandler && typeof globalThis.window !== "undefined") {
|
|
2019
|
+
globalThis.window.removeEventListener("pagehide", boundPageHideHandler);
|
|
2020
|
+
boundPageHideHandler = null;
|
|
2021
|
+
}
|
|
2022
|
+
if (currentActivity) {
|
|
2023
|
+
clearInactivityTimeout(currentActivity);
|
|
2024
|
+
clearPausedHeartbeatTimeout(currentActivity);
|
|
2025
|
+
}
|
|
2026
|
+
if (currentActivity?.heartbeatIntervalId != null) {
|
|
2027
|
+
stopHeartbeatInterval(currentActivity);
|
|
2028
|
+
}
|
|
2029
|
+
stopRelayInterval();
|
|
2030
|
+
}
|
|
2031
|
+
return {
|
|
2032
|
+
currentRunId() {
|
|
2033
|
+
return currentActivity?.runId;
|
|
2034
|
+
},
|
|
2035
|
+
isActivityManuallyPaused() {
|
|
2036
|
+
return currentActivity?.pauseReasons.has("manual") ?? false;
|
|
2037
|
+
},
|
|
2038
|
+
startActivity(rawMetadata, options) {
|
|
2039
|
+
if (hasLiveSession(client)) {
|
|
2040
|
+
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.");
|
|
2041
|
+
}
|
|
2042
|
+
if (options?.runId !== undefined && !isValidUUID(options.runId)) {
|
|
2043
|
+
throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
|
|
2044
|
+
}
|
|
2045
|
+
const metadata = jsonProjection(rawMetadata);
|
|
2046
|
+
cleanupListeners();
|
|
2047
|
+
const now = Date.now();
|
|
2048
|
+
const resumedRunId = adoptResumedRunId();
|
|
2049
|
+
const runId = options?.runId ?? resumedRunId ?? crypto.randomUUID();
|
|
2050
|
+
const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
|
|
2051
|
+
const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
|
|
2052
|
+
const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
|
|
2053
|
+
currentActivity = {
|
|
2054
|
+
runId,
|
|
2055
|
+
resumeId: crypto.randomUUID(),
|
|
2056
|
+
startTime: now,
|
|
2057
|
+
metadata,
|
|
2058
|
+
pausedTime: 0,
|
|
2059
|
+
pauseStartTime: null,
|
|
2060
|
+
pauseReasons: new Set,
|
|
2061
|
+
pausedHeartbeatTimeoutId: null,
|
|
2062
|
+
pausedHeartbeatTimedOut: false,
|
|
2063
|
+
pausedHeartbeatTimeoutMs,
|
|
2064
|
+
windowStartTime: now,
|
|
2065
|
+
windowPausedAtStart: 0,
|
|
2066
|
+
heartbeatIntervalId: null,
|
|
2067
|
+
heartbeatIntervalMs,
|
|
2068
|
+
inactivityTimeoutId: null,
|
|
2069
|
+
inactivityTimeoutMs,
|
|
2070
|
+
inactivityTimerStartedAt: null,
|
|
2071
|
+
remainingInactivityMs: inactivityTimeoutMs,
|
|
2072
|
+
flushInFlight: null,
|
|
2073
|
+
totalPersistedActiveMs: 0,
|
|
2074
|
+
totalPersistedPausedMs: 0
|
|
2075
|
+
};
|
|
2076
|
+
if (typeof document !== "undefined") {
|
|
2077
|
+
boundVisibilityHandler = handleVisibilityChange;
|
|
2078
|
+
document.addEventListener("visibilitychange", boundVisibilityHandler);
|
|
2079
|
+
boundUserInteractionHandler = handleUserInteraction;
|
|
2080
|
+
for (const eventName of USER_ACTIVITY_EVENTS) {
|
|
2081
|
+
document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
|
|
2082
|
+
}
|
|
2083
|
+
if (document.visibilityState === "hidden") {
|
|
2084
|
+
handleVisibilityChange();
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
startHeartbeatInterval(currentActivity);
|
|
2088
|
+
startRelayInterval();
|
|
2089
|
+
if (typeof globalThis.window !== "undefined") {
|
|
2090
|
+
boundPageHideHandler = handlePageHide;
|
|
2091
|
+
globalThis.window.addEventListener("pagehide", boundPageHideHandler);
|
|
2092
|
+
}
|
|
2093
|
+
boundShellPauseHandler = handleShellPause;
|
|
2094
|
+
boundShellResumeHandler = handleShellResume;
|
|
2095
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
2096
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
2097
|
+
syncInactivityTracking();
|
|
2098
|
+
if (relaysToParent()) {
|
|
2099
|
+
messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
|
|
2100
|
+
runId,
|
|
2101
|
+
resumeId: currentActivity.resumeId,
|
|
2102
|
+
activityData: metadata
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
return { runId };
|
|
2106
|
+
},
|
|
2107
|
+
pauseActivity() {
|
|
2108
|
+
if (!currentActivity) {
|
|
2109
|
+
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
2110
|
+
}
|
|
2111
|
+
if (currentActivity.pauseReasons.has("manual")) {
|
|
2112
|
+
throw new Error("Activity is already paused.");
|
|
2113
|
+
}
|
|
2114
|
+
addPauseReason("manual");
|
|
2115
|
+
},
|
|
2116
|
+
resumeActivity() {
|
|
2117
|
+
if (!currentActivity) {
|
|
2118
|
+
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
2119
|
+
}
|
|
2120
|
+
if (!currentActivity.pauseReasons.has("manual")) {
|
|
2121
|
+
throw new Error("Activity is not paused.");
|
|
2122
|
+
}
|
|
2123
|
+
if (hasLiveSession(client)) {
|
|
2124
|
+
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.");
|
|
2125
|
+
}
|
|
2126
|
+
removePauseReason("manual");
|
|
2127
|
+
},
|
|
2128
|
+
async endActivity(rawData) {
|
|
2129
|
+
if (!currentActivity) {
|
|
2130
|
+
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
2131
|
+
}
|
|
2132
|
+
const data = jsonProjection(rawData);
|
|
2133
|
+
if (!relaysToParent() && typeof data.xpAwarded !== "number") {
|
|
2134
|
+
throw new Error("endActivity() requires xpAwarded when reporting directly. It is optional only in child mode, where the parent game decides the award.");
|
|
2135
|
+
}
|
|
2136
|
+
const activity = currentActivity;
|
|
2137
|
+
applyOverdueInactivity();
|
|
2138
|
+
cleanupListeners();
|
|
2139
|
+
await flushHeartbeat(true);
|
|
2140
|
+
if (activity.pauseStartTime !== null) {
|
|
2141
|
+
activity.pausedTime += Date.now() - activity.pauseStartTime;
|
|
2142
|
+
activity.pauseStartTime = null;
|
|
2143
|
+
}
|
|
2144
|
+
const endTime = Date.now();
|
|
2145
|
+
const totalElapsed = endTime - activity.startTime;
|
|
2146
|
+
const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
|
|
2147
|
+
const durationSeconds = Math.floor(activeTime / 1000);
|
|
2148
|
+
const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
|
|
2149
|
+
const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
|
|
2150
|
+
const { correctQuestions, totalQuestions } = data;
|
|
2151
|
+
if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
|
|
2152
|
+
throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
|
|
2153
|
+
}
|
|
2154
|
+
const request = {
|
|
2155
|
+
runId: activity.runId,
|
|
2156
|
+
resumeId: activity.resumeId,
|
|
2157
|
+
activityData: activity.metadata,
|
|
2158
|
+
scoreData: {
|
|
2159
|
+
correctQuestions,
|
|
2160
|
+
totalQuestions
|
|
2161
|
+
},
|
|
2162
|
+
timingData: {
|
|
2163
|
+
durationSeconds
|
|
2164
|
+
},
|
|
2165
|
+
sessionTimingData: {
|
|
2166
|
+
activeSeconds: unreportedActiveMs / 1000,
|
|
2167
|
+
...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
|
|
2168
|
+
},
|
|
2169
|
+
xpEarned: data.xpAwarded,
|
|
2170
|
+
masteredUnits: data.masteredUnits,
|
|
2171
|
+
masteredUnitsAbsolute: data.masteredUnitsAbsolute,
|
|
2172
|
+
extensions: data.extensions
|
|
2173
|
+
};
|
|
2174
|
+
if (relaysToParent()) {
|
|
2175
|
+
messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */, request);
|
|
2176
|
+
if (currentActivity === activity) {
|
|
2177
|
+
currentActivity = null;
|
|
2178
|
+
}
|
|
2179
|
+
return { status: "relayed", runId: activity.runId };
|
|
2180
|
+
}
|
|
2181
|
+
try {
|
|
2182
|
+
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
|
|
2183
|
+
if (currentActivity === activity) {
|
|
2184
|
+
currentActivity = null;
|
|
2185
|
+
}
|
|
2186
|
+
return response;
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
if (currentActivity === activity) {
|
|
2189
|
+
currentActivity = null;
|
|
2190
|
+
}
|
|
2191
|
+
throw error;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// src/namespaces/game/embed.ts
|
|
2198
|
+
function createEmbedNamespace(client) {
|
|
2199
|
+
return {
|
|
2200
|
+
launch(options) {
|
|
2201
|
+
assertPlatformMode(client, "embed.launch()");
|
|
2202
|
+
if (typeof document === "undefined") {
|
|
2203
|
+
throw new PlaycademyError("embed.launch() requires a browser document to mount the child iframe.");
|
|
2204
|
+
}
|
|
2205
|
+
const tokenAtLaunch = client.getToken();
|
|
2206
|
+
if (!tokenAtLaunch) {
|
|
2207
|
+
throw new PlaycademyError("embed.launch() requires an authenticated client; the child session runs on this token.");
|
|
2208
|
+
}
|
|
2209
|
+
if (!isValidLaunchIntent(options.intent)) {
|
|
2210
|
+
throw new PlaycademyError("embed.launch() intent needs a non-empty lessonId and an eLevel of 'E1' to 'E4'.");
|
|
2211
|
+
}
|
|
2212
|
+
if (options.timeback && !isValidTimebackRecording(options.timeback)) {
|
|
2213
|
+
throw new PlaycademyError("embed.launch() timeback option needs a non-empty activityId, a grade from -1 to 13, and a valid subject.");
|
|
2214
|
+
}
|
|
2215
|
+
const openRunId = client.timeback.currentRunId;
|
|
2216
|
+
if (openRunId !== undefined && !isActivityManuallyPaused(client)) {
|
|
2217
|
+
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.`);
|
|
2218
|
+
}
|
|
2219
|
+
if (hasLiveSession(client)) {
|
|
2220
|
+
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.");
|
|
2221
|
+
}
|
|
2222
|
+
const parentGameId = client["_ensureGameId"]();
|
|
2223
|
+
const resume = resolveResumePolicy({
|
|
2224
|
+
resume: options.resume,
|
|
2225
|
+
token: tokenAtLaunch,
|
|
2226
|
+
parentGameId,
|
|
2227
|
+
slug: options.slug,
|
|
2228
|
+
intent: options.intent
|
|
2229
|
+
});
|
|
2230
|
+
const session = new ChildSession({
|
|
2231
|
+
slug: options.slug,
|
|
2232
|
+
container: options.container,
|
|
2233
|
+
timeback: options.timeback,
|
|
2234
|
+
resume: resume.envelope,
|
|
2235
|
+
resumeStore: resume.store,
|
|
2236
|
+
reporter: {
|
|
2237
|
+
postHeartbeat: (body) => client["requestGameBackend"](TIMEBACK_ROUTES.HEARTBEAT, "POST", body, undefined, {
|
|
2238
|
+
retryPolicy: HEARTBEAT_RETRY_POLICY
|
|
2239
|
+
}),
|
|
2240
|
+
postHeartbeatKeepalive: (body) => {
|
|
2241
|
+
sendHeartbeatKeepalive(client, body);
|
|
2242
|
+
},
|
|
2243
|
+
postEndActivity: (body) => client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", body, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY })
|
|
2244
|
+
},
|
|
2245
|
+
resolveTarget: async (resumeContext) => {
|
|
2246
|
+
const game2 = await client["request"](`/games/${encodeURIComponent(options.slug)}`, "GET").catch((error) => {
|
|
2247
|
+
if (!options.gameUrl) {
|
|
2248
|
+
throw error;
|
|
2249
|
+
}
|
|
2250
|
+
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.`);
|
|
2251
|
+
return null;
|
|
2252
|
+
});
|
|
2253
|
+
const childGameId = game2?.id ?? options.slug;
|
|
2254
|
+
const deploymentUrl = game2 ? game2.deploymentUrl : undefined;
|
|
2255
|
+
const rawUrl = options.gameUrl ?? deploymentUrl;
|
|
2256
|
+
if (!rawUrl) {
|
|
2257
|
+
throw new PlaycademyError(`Game '${options.slug}' has no deployment URL. Pass gameUrl to embed.launch() to point at one.`);
|
|
2258
|
+
}
|
|
2259
|
+
const childUrl = `${rawUrl.replace(/\/$/, "")}/`;
|
|
2260
|
+
const payload = {
|
|
2261
|
+
baseUrl: client.baseUrl,
|
|
2262
|
+
gameUrl: childUrl,
|
|
2263
|
+
token: client.getToken() ?? tokenAtLaunch,
|
|
2264
|
+
gameId: childGameId,
|
|
2265
|
+
mode: "child",
|
|
2266
|
+
parent: buildParentContext(parentGameId, options.intent, resumeContext),
|
|
2267
|
+
localDay: client["initPayload"]?.localDay,
|
|
2268
|
+
launchId: client["launchId"],
|
|
2269
|
+
hasHeartbeatRelay: true
|
|
2270
|
+
};
|
|
2271
|
+
return { childUrl, payload };
|
|
2272
|
+
}
|
|
2273
|
+
});
|
|
2274
|
+
registerLiveSession(client);
|
|
2275
|
+
session.finished.then(() => releaseLiveSession(client));
|
|
2276
|
+
return session;
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
// src/core/auth/utils.ts
|
|
2281
|
+
function openPopupWindow(url, name = "auth-popup", width = 500, height = 600) {
|
|
2282
|
+
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
2283
|
+
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
2284
|
+
const features = [
|
|
2285
|
+
`width=${width}`,
|
|
2286
|
+
`height=${height}`,
|
|
2287
|
+
`left=${left}`,
|
|
2288
|
+
`top=${top}`,
|
|
2289
|
+
"toolbar=no",
|
|
2290
|
+
"menubar=no",
|
|
2291
|
+
"location=yes",
|
|
2292
|
+
"status=yes",
|
|
2293
|
+
"scrollbars=yes",
|
|
2294
|
+
"resizable=yes"
|
|
2295
|
+
].join(",");
|
|
2296
|
+
return window.open(url, name, features);
|
|
2297
|
+
}
|
|
2298
|
+
function isInIframe() {
|
|
2299
|
+
if (typeof globalThis.window === "undefined") {
|
|
2300
|
+
return false;
|
|
2301
|
+
}
|
|
2302
|
+
try {
|
|
2303
|
+
return globalThis.self !== window.top;
|
|
2304
|
+
} catch {
|
|
2305
|
+
return true;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// src/core/auth/flows/popup.ts
|
|
2310
|
+
async function initiatePopupFlow(options) {
|
|
2311
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
2312
|
+
try {
|
|
2313
|
+
onStateChange?.({
|
|
2314
|
+
status: "opening_popup",
|
|
2315
|
+
message: "Opening authentication window..."
|
|
2316
|
+
});
|
|
2317
|
+
const defaults = getOAuthConfig(provider);
|
|
2318
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
2319
|
+
if (!config.clientId) {
|
|
2320
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
2321
|
+
}
|
|
2322
|
+
const stateData = options.stateData;
|
|
2323
|
+
const state = await generateOAuthState(stateData);
|
|
2324
|
+
const params = new URLSearchParams({
|
|
2325
|
+
response_type: "code",
|
|
2326
|
+
client_id: config.clientId,
|
|
2327
|
+
redirect_uri: callbackUrl,
|
|
2328
|
+
state
|
|
2329
|
+
});
|
|
2330
|
+
if (config.scope) {
|
|
2331
|
+
params.set("scope", config.scope);
|
|
2332
|
+
}
|
|
2333
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
2334
|
+
const popup = openPopupWindow(authUrl, "playcademy-auth");
|
|
2335
|
+
if (!popup || popup.closed) {
|
|
2336
|
+
throw new Error("Popup blocked. Please enable popups and try again.");
|
|
2337
|
+
}
|
|
2338
|
+
onStateChange?.({
|
|
2339
|
+
status: "exchanging_token",
|
|
2340
|
+
message: "Waiting for authentication..."
|
|
2341
|
+
});
|
|
2342
|
+
return await waitForServerMessage(popup, onStateChange);
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
const errorMessage2 = error instanceof Error ? error.message : "Authentication failed";
|
|
2345
|
+
onStateChange?.({
|
|
2346
|
+
status: "error",
|
|
2347
|
+
message: errorMessage2,
|
|
2348
|
+
error: error instanceof Error ? error : new Error(errorMessage2)
|
|
2349
|
+
});
|
|
2350
|
+
throw error;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
async function waitForServerMessage(popup, onStateChange) {
|
|
2354
|
+
return new Promise((resolve) => {
|
|
2355
|
+
let resolved = false;
|
|
2356
|
+
function handleMessage(event) {
|
|
2357
|
+
if (event.origin !== globalThis.location.origin) {
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
const data = event.data;
|
|
2361
|
+
if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
|
|
2362
|
+
resolved = true;
|
|
2363
|
+
window.removeEventListener("message", handleMessage);
|
|
2364
|
+
if (data.authenticated && data.user) {
|
|
2365
|
+
onStateChange?.({
|
|
2366
|
+
status: "complete",
|
|
2367
|
+
message: "Authentication successful"
|
|
2368
|
+
});
|
|
2369
|
+
resolve({
|
|
2370
|
+
success: true,
|
|
2371
|
+
user: data.user
|
|
2372
|
+
});
|
|
2373
|
+
} else {
|
|
2374
|
+
const error = new Error(data.error || "Authentication failed");
|
|
2375
|
+
onStateChange?.({
|
|
2376
|
+
status: "error",
|
|
2377
|
+
message: error.message,
|
|
2378
|
+
error
|
|
2379
|
+
});
|
|
2380
|
+
resolve({
|
|
2381
|
+
success: false,
|
|
2382
|
+
error
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
window.addEventListener("message", handleMessage);
|
|
2388
|
+
const checkClosed = setInterval(() => {
|
|
2389
|
+
if (popup.closed && !resolved) {
|
|
2390
|
+
clearInterval(checkClosed);
|
|
2391
|
+
window.removeEventListener("message", handleMessage);
|
|
2392
|
+
const error = new Error("Authentication cancelled");
|
|
2393
|
+
onStateChange?.({
|
|
2394
|
+
status: "error",
|
|
2395
|
+
message: error.message,
|
|
2396
|
+
error
|
|
2397
|
+
});
|
|
2398
|
+
resolve({
|
|
2399
|
+
success: false,
|
|
2400
|
+
error
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
}, 500);
|
|
2404
|
+
setTimeout(() => {
|
|
2405
|
+
if (!resolved) {
|
|
2406
|
+
window.removeEventListener("message", handleMessage);
|
|
2407
|
+
clearInterval(checkClosed);
|
|
2408
|
+
const error = new Error("Authentication timeout");
|
|
2409
|
+
onStateChange?.({
|
|
2410
|
+
status: "error",
|
|
2411
|
+
message: error.message,
|
|
2412
|
+
error
|
|
2413
|
+
});
|
|
2414
|
+
resolve({
|
|
2415
|
+
success: false,
|
|
2416
|
+
error
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
}, 5 * 60 * 1000);
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// src/core/auth/flows/redirect.ts
|
|
2424
|
+
async function initiateRedirectFlow(options) {
|
|
2425
|
+
const { provider, callbackUrl, onStateChange, oauth } = options;
|
|
2426
|
+
try {
|
|
2427
|
+
onStateChange?.({
|
|
2428
|
+
status: "opening_popup",
|
|
2429
|
+
message: "Redirecting to authentication provider..."
|
|
2430
|
+
});
|
|
2431
|
+
const defaults = getOAuthConfig(provider);
|
|
2432
|
+
const config = oauth ? { ...defaults, ...oauth } : defaults;
|
|
2433
|
+
if (!config.clientId) {
|
|
2434
|
+
throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
|
|
2435
|
+
}
|
|
2436
|
+
const stateData = options.stateData;
|
|
2437
|
+
const state = await generateOAuthState(stateData);
|
|
2438
|
+
const params = new URLSearchParams({
|
|
2439
|
+
response_type: "code",
|
|
2440
|
+
client_id: config.clientId,
|
|
2441
|
+
redirect_uri: callbackUrl,
|
|
2442
|
+
state
|
|
2443
|
+
});
|
|
2444
|
+
if (config.scope) {
|
|
2445
|
+
params.set("scope", config.scope);
|
|
2446
|
+
}
|
|
2447
|
+
const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
|
|
2448
|
+
globalThis.location.href = authUrl;
|
|
2449
|
+
return new Promise(() => {});
|
|
2450
|
+
} catch (error) {
|
|
2451
|
+
const errorMessage2 = error instanceof Error ? error.message : "Authentication failed";
|
|
2452
|
+
onStateChange?.({
|
|
2453
|
+
status: "error",
|
|
2454
|
+
message: errorMessage2,
|
|
2455
|
+
error: error instanceof Error ? error : new Error(errorMessage2)
|
|
2456
|
+
});
|
|
2457
|
+
throw error;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// src/core/auth/flows/unified.ts
|
|
2462
|
+
async function initiateUnifiedFlow(options) {
|
|
2463
|
+
const { mode = "auto" } = options;
|
|
2464
|
+
let effectiveMode;
|
|
2465
|
+
if (mode === "auto") {
|
|
2466
|
+
effectiveMode = isInIframe() ? "popup" : "redirect";
|
|
2467
|
+
} else {
|
|
2468
|
+
effectiveMode = mode;
|
|
2469
|
+
}
|
|
2470
|
+
switch (effectiveMode) {
|
|
2471
|
+
case "popup": {
|
|
2472
|
+
return initiatePopupFlow(options);
|
|
2473
|
+
}
|
|
2474
|
+
case "redirect": {
|
|
2475
|
+
return initiateRedirectFlow(options);
|
|
2476
|
+
}
|
|
2477
|
+
default: {
|
|
2478
|
+
throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// src/core/auth/login.ts
|
|
2484
|
+
async function login2(client, options) {
|
|
2485
|
+
try {
|
|
2486
|
+
let stateData = options.stateData;
|
|
2487
|
+
if (!stateData) {
|
|
2488
|
+
try {
|
|
2489
|
+
const currentUser = await client.users.me();
|
|
2490
|
+
if (currentUser?.id) {
|
|
2491
|
+
stateData = { playcademy_user_id: currentUser.id };
|
|
2492
|
+
}
|
|
2493
|
+
} catch {
|
|
2494
|
+
log.debug("[Playcademy SDK] No current user available for state data");
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
log.debug("[Playcademy SDK] Starting OAuth login", {
|
|
2498
|
+
provider: options.provider,
|
|
2499
|
+
mode: options.mode || "auto",
|
|
2500
|
+
callbackUrl: options.callbackUrl,
|
|
2501
|
+
hasStateData: Boolean(stateData)
|
|
2502
|
+
});
|
|
2503
|
+
const optionsWithState = {
|
|
2504
|
+
...options,
|
|
2505
|
+
stateData
|
|
2506
|
+
};
|
|
2507
|
+
const result = await initiateUnifiedFlow(optionsWithState);
|
|
2508
|
+
if (result.success && result.user) {
|
|
2509
|
+
log.debug("[Playcademy SDK] OAuth login successful", {
|
|
2510
|
+
userId: result.user.sub
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
return result;
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
log.error("[Playcademy SDK] OAuth login failed", { error });
|
|
2516
|
+
const authError = error instanceof Error ? error : new Error("Authentication failed");
|
|
2517
|
+
return {
|
|
2518
|
+
success: false,
|
|
2519
|
+
error: authError
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// src/namespaces/game/identity.ts
|
|
2525
|
+
function createIdentityNamespace(client) {
|
|
2526
|
+
return {
|
|
2527
|
+
connect: (options) => {
|
|
2528
|
+
if (client.mode === "demo") {
|
|
2529
|
+
throw new PlaycademyError("identity.connect() is not available in demo mode. Use platform or standalone mode for OAuth flows.");
|
|
2530
|
+
}
|
|
2531
|
+
assertNotChildMode(client, "identity.connect()", "Account connections belong to the hub or parent context.");
|
|
2532
|
+
return login2(client, options);
|
|
2533
|
+
},
|
|
2534
|
+
_getContext: () => ({
|
|
2535
|
+
isInIframe: client["authContext"]?.isInIframe ?? false
|
|
2536
|
+
})
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
// src/namespaces/game/runtime.ts
|
|
2540
|
+
function createRuntimeNamespace(client) {
|
|
2541
|
+
const eventListeners = new Map;
|
|
2542
|
+
function trackListener(eventType, handler) {
|
|
2543
|
+
if (!eventListeners.has(eventType)) {
|
|
2544
|
+
eventListeners.set(eventType, new Set);
|
|
2545
|
+
}
|
|
2546
|
+
eventListeners.get(eventType).add(handler);
|
|
2547
|
+
}
|
|
2548
|
+
function untrackListener(eventType, handler) {
|
|
2549
|
+
const listeners = eventListeners.get(eventType);
|
|
2550
|
+
if (listeners) {
|
|
2551
|
+
listeners.delete(handler);
|
|
2552
|
+
if (listeners.size === 0) {
|
|
2553
|
+
eventListeners.delete(eventType);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
if (typeof globalThis.window !== "undefined" && globalThis.self !== window.top) {
|
|
2558
|
+
let keyListener = function(event) {
|
|
2559
|
+
if (keySet.has(event.key?.toLowerCase() ?? "") || keySet.has(event.code?.toLowerCase() ?? "")) {
|
|
2560
|
+
messaging.send("PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */, {
|
|
2561
|
+
key: event.key,
|
|
2562
|
+
code: event.code,
|
|
2563
|
+
type: event.type
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
};
|
|
2567
|
+
const playcademyConfig = globalThis.PLAYCADEMY;
|
|
2568
|
+
const forwardKeys = Array.isArray(playcademyConfig?.forwardKeys) ? playcademyConfig.forwardKeys : ["Escape"];
|
|
2569
|
+
const keySet = new Set(forwardKeys.map((k) => k.toLowerCase()));
|
|
2570
|
+
globalThis.addEventListener("keydown", keyListener);
|
|
2571
|
+
globalThis.addEventListener("keyup", keyListener);
|
|
2572
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, () => {
|
|
2573
|
+
globalThis.removeEventListener("keydown", keyListener);
|
|
2574
|
+
globalThis.removeEventListener("keyup", keyListener);
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
return {
|
|
2578
|
+
exit: () => {
|
|
2579
|
+
messaging.send("PLAYCADEMY_EXIT" /* EXIT */, undefined);
|
|
2580
|
+
},
|
|
2581
|
+
onInit: (handler) => {
|
|
2582
|
+
messaging.listen("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2583
|
+
trackListener("PLAYCADEMY_INIT" /* INIT */, handler);
|
|
2584
|
+
},
|
|
2585
|
+
onTokenRefresh: (handler) => {
|
|
2586
|
+
messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2587
|
+
trackListener("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, handler);
|
|
2588
|
+
},
|
|
2589
|
+
onPause: (handler) => {
|
|
2590
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2591
|
+
trackListener("PLAYCADEMY_PAUSE" /* PAUSE */, handler);
|
|
2592
|
+
},
|
|
2593
|
+
onResume: (handler) => {
|
|
2594
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2595
|
+
trackListener("PLAYCADEMY_RESUME" /* RESUME */, handler);
|
|
2596
|
+
},
|
|
2597
|
+
onForceExit: (handler) => {
|
|
2598
|
+
messaging.listen("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2599
|
+
trackListener("PLAYCADEMY_FORCE_EXIT" /* FORCE_EXIT */, handler);
|
|
2600
|
+
},
|
|
2601
|
+
onOverlay: (handler) => {
|
|
2602
|
+
messaging.listen("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2603
|
+
trackListener("PLAYCADEMY_OVERLAY" /* OVERLAY */, handler);
|
|
2604
|
+
},
|
|
2605
|
+
ready: () => {
|
|
2606
|
+
messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
|
|
2607
|
+
},
|
|
2608
|
+
sendTelemetry: (data) => {
|
|
2609
|
+
messaging.send("PLAYCADEMY_TELEMETRY" /* TELEMETRY */, data);
|
|
2610
|
+
},
|
|
2611
|
+
removeListener: (eventType, handler) => {
|
|
2612
|
+
messaging.unlisten(eventType, handler);
|
|
2613
|
+
untrackListener(eventType, handler);
|
|
2614
|
+
},
|
|
2615
|
+
removeAllListeners: () => {
|
|
2616
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2617
|
+
for (const handler of handlers) {
|
|
2618
|
+
messaging.unlisten(eventType, handler);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
eventListeners.clear();
|
|
2622
|
+
},
|
|
2623
|
+
getListenerCounts: () => {
|
|
2624
|
+
const counts = {};
|
|
2625
|
+
for (const [eventType, handlers] of eventListeners.entries()) {
|
|
2626
|
+
counts[eventType] = handlers.size;
|
|
2627
|
+
}
|
|
2628
|
+
return counts;
|
|
2629
|
+
},
|
|
2630
|
+
assets: createAssetsNamespace(client)
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
function createAssetsNamespace(client) {
|
|
2634
|
+
async function fetchAsset(path, options) {
|
|
2635
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2636
|
+
if (!gameUrl) {
|
|
2637
|
+
const relativePath = path.startsWith("./") ? path : `./${path}`;
|
|
2638
|
+
return fetch(relativePath, options);
|
|
2639
|
+
}
|
|
2640
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2641
|
+
return fetch(`${gameUrl}${cleanPath}`, options);
|
|
2642
|
+
}
|
|
2643
|
+
return {
|
|
2644
|
+
url(pathOrStrings, ...values) {
|
|
2645
|
+
const gameUrl = client["initPayload"]?.gameUrl;
|
|
2646
|
+
let path;
|
|
2647
|
+
if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
|
|
2648
|
+
const strings = pathOrStrings;
|
|
2649
|
+
path = strings.reduce((acc, str, i) => acc + str + (values[i] != null ? String(values[i]) : ""), "");
|
|
2650
|
+
} else {
|
|
2651
|
+
path = pathOrStrings;
|
|
2652
|
+
}
|
|
2653
|
+
if (!gameUrl) {
|
|
2654
|
+
return path.startsWith("./") ? path : `./${path}`;
|
|
2655
|
+
}
|
|
2656
|
+
const cleanPath = path.startsWith("./") ? path.slice(2) : path;
|
|
2657
|
+
return `${gameUrl}${cleanPath}`;
|
|
2658
|
+
},
|
|
2659
|
+
fetch: fetchAsset,
|
|
2660
|
+
json: async (path) => {
|
|
2661
|
+
const response = await fetchAsset(path);
|
|
2662
|
+
return await response.json();
|
|
2663
|
+
},
|
|
2664
|
+
blob: async (path) => {
|
|
2665
|
+
const response = await fetchAsset(path);
|
|
2666
|
+
return response.blob();
|
|
2667
|
+
},
|
|
2668
|
+
text: async (path) => {
|
|
2669
|
+
const response = await fetchAsset(path);
|
|
2670
|
+
return response.text();
|
|
2671
|
+
},
|
|
2672
|
+
arrayBuffer: async (path) => {
|
|
2673
|
+
const response = await fetchAsset(path);
|
|
2674
|
+
return response.arrayBuffer();
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
// src/namespaces/game/scores.ts
|
|
2679
|
+
function createScoresNamespace(client) {
|
|
2680
|
+
return {
|
|
2681
|
+
submit: async (score, metadata) => {
|
|
2682
|
+
assertNotChildMode(client, "scores.submit()", "The parent game owns reporting; the score already reaches it via the activity relay.");
|
|
2683
|
+
const gameId = client["_ensureGameId"]();
|
|
2684
|
+
return client["request"](`/games/${gameId}/scores`, "POST", {
|
|
2685
|
+
body: {
|
|
2686
|
+
score,
|
|
2687
|
+
metadata
|
|
1659
2688
|
}
|
|
1660
2689
|
});
|
|
1661
2690
|
}
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
// src/core/cache/ttl-cache.ts
|
|
2694
|
+
function createTTLCache(options) {
|
|
2695
|
+
const cache = new Map;
|
|
2696
|
+
const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
|
|
2697
|
+
async function get(key, loader, config) {
|
|
2698
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2699
|
+
const now = Date.now();
|
|
2700
|
+
const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
|
|
2701
|
+
const force = config?.force || false;
|
|
2702
|
+
const skipCache = config?.skipCache || false;
|
|
2703
|
+
if (effectiveTTL === 0 || skipCache) {
|
|
2704
|
+
return loader();
|
|
1670
2705
|
}
|
|
1671
|
-
if (
|
|
1672
|
-
|
|
1673
|
-
|
|
2706
|
+
if (!force) {
|
|
2707
|
+
const cached = cache.get(fullKey);
|
|
2708
|
+
if (cached && cached.expiresAt > now) {
|
|
2709
|
+
return cached.value;
|
|
1674
2710
|
}
|
|
1675
|
-
boundUserInteractionHandler = null;
|
|
1676
2711
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
2712
|
+
const promise = loader().catch((error) => {
|
|
2713
|
+
cache.delete(fullKey);
|
|
2714
|
+
throw error;
|
|
2715
|
+
});
|
|
2716
|
+
cache.set(fullKey, {
|
|
2717
|
+
value: promise,
|
|
2718
|
+
expiresAt: now + effectiveTTL
|
|
2719
|
+
});
|
|
2720
|
+
return promise;
|
|
2721
|
+
}
|
|
2722
|
+
function clear(key) {
|
|
2723
|
+
if (key === undefined) {
|
|
2724
|
+
cache.clear();
|
|
2725
|
+
onClear?.();
|
|
2726
|
+
} else {
|
|
2727
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2728
|
+
cache.delete(fullKey);
|
|
1680
2729
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
2730
|
+
}
|
|
2731
|
+
function size() {
|
|
2732
|
+
return cache.size;
|
|
2733
|
+
}
|
|
2734
|
+
function prune() {
|
|
2735
|
+
const now = Date.now();
|
|
2736
|
+
for (const [key, entry] of cache.entries()) {
|
|
2737
|
+
if (entry.expiresAt <= now) {
|
|
2738
|
+
cache.delete(key);
|
|
2739
|
+
}
|
|
1684
2740
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
2741
|
+
}
|
|
2742
|
+
function getKeys() {
|
|
2743
|
+
const keys = [];
|
|
2744
|
+
const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
|
|
2745
|
+
for (const fullKey of cache.keys()) {
|
|
2746
|
+
keys.push(fullKey.substring(prefixLen));
|
|
1688
2747
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
2748
|
+
return keys;
|
|
2749
|
+
}
|
|
2750
|
+
function has(key) {
|
|
2751
|
+
const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
|
|
2752
|
+
const cached = cache.get(fullKey);
|
|
2753
|
+
if (!cached) {
|
|
2754
|
+
return false;
|
|
1692
2755
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
2756
|
+
const now = Date.now();
|
|
2757
|
+
if (cached.expiresAt <= now) {
|
|
2758
|
+
cache.delete(fullKey);
|
|
2759
|
+
return false;
|
|
1695
2760
|
}
|
|
1696
|
-
|
|
2761
|
+
return true;
|
|
1697
2762
|
}
|
|
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
|
-
};
|
|
2763
|
+
return { get, clear, size, prune, getKeys, has };
|
|
1833
2764
|
}
|
|
1834
2765
|
|
|
1835
2766
|
// src/core/timeback/user.ts
|
|
@@ -1989,6 +2920,7 @@ function createTimebackEngine(client) {
|
|
|
1989
2920
|
},
|
|
1990
2921
|
activity: {
|
|
1991
2922
|
currentRunId: activityTracker.currentRunId,
|
|
2923
|
+
isManuallyPaused: activityTracker.isActivityManuallyPaused,
|
|
1992
2924
|
start: activityTracker.startActivity,
|
|
1993
2925
|
pause: activityTracker.pauseActivity,
|
|
1994
2926
|
resume: activityTracker.resumeActivity,
|
|
@@ -2031,6 +2963,7 @@ function validateAssessmentFilters(options) {
|
|
|
2031
2963
|
}
|
|
2032
2964
|
function createTimebackNamespace(client) {
|
|
2033
2965
|
const engine = createTimebackEngine(client);
|
|
2966
|
+
registerPauseProbe(client, () => engine.activity.isManuallyPaused());
|
|
2034
2967
|
return {
|
|
2035
2968
|
assessments: {
|
|
2036
2969
|
start: async (input) => {
|
|
@@ -2085,7 +3018,8 @@ function createTimebackNamespace(client) {
|
|
|
2085
3018
|
}
|
|
2086
3019
|
},
|
|
2087
3020
|
get user() {
|
|
2088
|
-
|
|
3021
|
+
assertNonAnonymousMode(client, "timeback.user");
|
|
3022
|
+
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
3023
|
return {
|
|
2090
3024
|
get id() {
|
|
2091
3025
|
return engine.user.snapshot()?.id;
|
|
@@ -2161,23 +3095,23 @@ function createTimebackNamespace(client) {
|
|
|
2161
3095
|
};
|
|
2162
3096
|
},
|
|
2163
3097
|
get currentRunId() {
|
|
2164
|
-
|
|
3098
|
+
assertNonAnonymousMode(client, "timeback.currentRunId");
|
|
2165
3099
|
return engine.activity.currentRunId();
|
|
2166
3100
|
},
|
|
2167
3101
|
startActivity: (metadata, options) => {
|
|
2168
|
-
|
|
3102
|
+
assertNonAnonymousMode(client, "timeback.startActivity()");
|
|
2169
3103
|
return engine.activity.start(metadata, options);
|
|
2170
3104
|
},
|
|
2171
3105
|
pauseActivity: () => {
|
|
2172
|
-
|
|
3106
|
+
assertNonAnonymousMode(client, "timeback.pauseActivity()");
|
|
2173
3107
|
engine.activity.pause();
|
|
2174
3108
|
},
|
|
2175
3109
|
resumeActivity: () => {
|
|
2176
|
-
|
|
3110
|
+
assertNonAnonymousMode(client, "timeback.resumeActivity()");
|
|
2177
3111
|
engine.activity.resume();
|
|
2178
3112
|
},
|
|
2179
3113
|
endActivity: async (data) => {
|
|
2180
|
-
|
|
3114
|
+
assertNonAnonymousMode(client, "timeback.endActivity()");
|
|
2181
3115
|
return engine.activity.end(data);
|
|
2182
3116
|
},
|
|
2183
3117
|
course: {
|
|
@@ -2205,7 +3139,7 @@ function createTimebackNamespace(client) {
|
|
|
2205
3139
|
function createUsersNamespace(client) {
|
|
2206
3140
|
return {
|
|
2207
3141
|
me: async () => {
|
|
2208
|
-
|
|
3142
|
+
assertNonAnonymousMode(client, "users.me()");
|
|
2209
3143
|
const user = await client["request"]("/users/me", "GET");
|
|
2210
3144
|
const initPayload = client["initPayload"];
|
|
2211
3145
|
if (initPayload) {
|
|
@@ -2327,6 +3261,24 @@ function createAuthStrategy(token, tokenType) {
|
|
|
2327
3261
|
return new GameJwtAuth(token);
|
|
2328
3262
|
}
|
|
2329
3263
|
|
|
3264
|
+
// src/core/launch/checkpoint.ts
|
|
3265
|
+
var CHECKPOINT_MAX_CHARS = 64000;
|
|
3266
|
+
function sendCheckpointToParent(state) {
|
|
3267
|
+
let serialized;
|
|
3268
|
+
try {
|
|
3269
|
+
serialized = JSON.stringify(state);
|
|
3270
|
+
} catch {}
|
|
3271
|
+
if (serialized === undefined) {
|
|
3272
|
+
console.warn("[Playcademy SDK] parent.checkpoint() dropped a checkpoint that is not JSON-serializable.");
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
if (serialized.length > CHECKPOINT_MAX_CHARS) {
|
|
3276
|
+
console.warn(`[Playcademy SDK] parent.checkpoint() dropped a ${serialized.length}-char checkpoint; the cap is ${CHECKPOINT_MAX_CHARS}.`);
|
|
3277
|
+
return;
|
|
3278
|
+
}
|
|
3279
|
+
messaging.send("PLAYCADEMY_CHECKPOINT" /* CHECKPOINT */, { state: JSON.parse(serialized) });
|
|
3280
|
+
}
|
|
3281
|
+
|
|
2330
3282
|
// src/core/transport/retry.ts
|
|
2331
3283
|
var RETRY_DELAYS_MS = [500, 1500];
|
|
2332
3284
|
function wait(ms) {
|
|
@@ -2469,7 +3421,7 @@ async function request({
|
|
|
2469
3421
|
return rawText && rawText.length > 0 ? rawText : undefined;
|
|
2470
3422
|
}
|
|
2471
3423
|
// src/version.ts
|
|
2472
|
-
var SDK_VERSION = "0.16.1-beta.
|
|
3424
|
+
var SDK_VERSION = "0.16.1-beta.5";
|
|
2473
3425
|
|
|
2474
3426
|
// src/clients/base.ts
|
|
2475
3427
|
class PlaycademyBaseClient {
|
|
@@ -2482,6 +3434,7 @@ class PlaycademyBaseClient {
|
|
|
2482
3434
|
listeners = {};
|
|
2483
3435
|
authContext;
|
|
2484
3436
|
initPayload;
|
|
3437
|
+
parentHandle;
|
|
2485
3438
|
launchId;
|
|
2486
3439
|
gameOrigin;
|
|
2487
3440
|
browserTimeZone;
|
|
@@ -2516,6 +3469,20 @@ class PlaycademyBaseClient {
|
|
|
2516
3469
|
get localDay() {
|
|
2517
3470
|
return this.initPayload?.localDay;
|
|
2518
3471
|
}
|
|
3472
|
+
get parent() {
|
|
3473
|
+
if (this.mode !== "child") {
|
|
3474
|
+
return null;
|
|
3475
|
+
}
|
|
3476
|
+
const context = this.initPayload?.parent;
|
|
3477
|
+
if (!context) {
|
|
3478
|
+
return null;
|
|
3479
|
+
}
|
|
3480
|
+
this.parentHandle ??= {
|
|
3481
|
+
...context,
|
|
3482
|
+
checkpoint: (state) => sendCheckpointToParent(state)
|
|
3483
|
+
};
|
|
3484
|
+
return this.parentHandle;
|
|
3485
|
+
}
|
|
2519
3486
|
setToken(token, tokenType) {
|
|
2520
3487
|
this.authStrategy = createAuthStrategy(token, tokenType);
|
|
2521
3488
|
this.emit("authChange", { token });
|
|
@@ -2641,12 +3608,17 @@ class PlaycademyClient extends PlaycademyBaseClient {
|
|
|
2641
3608
|
leaderboard = createLeaderboardFetchNamespace(this);
|
|
2642
3609
|
demo = createDemoNamespace(this);
|
|
2643
3610
|
backend = createBackendNamespace(this);
|
|
3611
|
+
embed = createEmbedNamespace(this);
|
|
2644
3612
|
static init = init;
|
|
2645
3613
|
static login = login;
|
|
2646
3614
|
static identity = identity;
|
|
2647
3615
|
}
|
|
3616
|
+
function isChildLaunched(client) {
|
|
3617
|
+
return client.mode === "child" && client.parent !== null;
|
|
3618
|
+
}
|
|
2648
3619
|
export {
|
|
2649
3620
|
messaging,
|
|
3621
|
+
isChildLaunched,
|
|
2650
3622
|
extractApiErrorInfo,
|
|
2651
3623
|
PlaycademyError,
|
|
2652
3624
|
PlaycademyClient,
|