@playcademy/sdk 0.16.1-beta.2 → 0.16.1-beta.21

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