@playcademy/sdk 0.16.1-beta.1 → 0.16.1-beta.10

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