@playcademy/sdk 0.16.1-beta.4 → 0.16.1-beta.6

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