@playcademy/sdk 0.16.1-beta.8 → 0.17.0

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
@@ -800,6 +800,8 @@ var DEFAULT_PERSONAL_API_KEY_PERMISSIONS = {
800
800
  };
801
801
  // ../constants/src/platform.ts
802
802
  var PLAYCADEMY_BROWSER_TIME_ZONE_HEADER = "x-playcademy-browser-time-zone";
803
+ var PLAYCADEMY_ASSESSMENT_ASSET_SHA256_HEADER = "x-playcademy-content-sha256";
804
+ var ASSESSMENT_ASSET_MAX_BYTES = 4 * 1024 * 1024;
803
805
  // ../constants/src/timeback.ts
804
806
  var TIMEBACK_ROUTES = {
805
807
  END_ACTIVITY: "/integrations/timeback/end-activity",
@@ -823,10 +825,10 @@ var TIMEBACK_SUBJECTS = [
823
825
  "Math",
824
826
  "None"
825
827
  ];
826
- var ASSESSMENT_PURPOSES = ["end_of_course", "diagnostic", "review"];
828
+ var ASSESSMENT_PURPOSES = ["end_of_course", "diagnostic", "review", "mastery"];
827
829
  var TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS = {
828
830
  standards: 20,
829
- itemsPerStandard: 5,
831
+ candidateItemsPerStandard: 2,
830
832
  standardFieldLength: 128
831
833
  };
832
834
  var VALID_E_LEVELS = ["E1", "E2", "E3", "E4"];
@@ -1119,6 +1121,9 @@ class ChildSession {
1119
1121
  this.#abandon();
1120
1122
  this.#teardown();
1121
1123
  }
1124
+ get runId() {
1125
+ return this.#parentRunId;
1126
+ }
1122
1127
  get checkpoint() {
1123
1128
  if (!this.#hasCheckpoint) {
1124
1129
  return null;
@@ -1203,8 +1208,11 @@ class ChildSession {
1203
1208
  }
1204
1209
  this.#markOpenWindowFlushable();
1205
1210
  this.#forwardDirtyWindows();
1206
- window.removeEventListener("message", this.#onChildMessage);
1207
- window.removeEventListener("pagehide", this.#onPageHide);
1211
+ const win = globalThis.window;
1212
+ if (win) {
1213
+ win.removeEventListener("message", this.#onChildMessage);
1214
+ win.removeEventListener("pagehide", this.#onPageHide);
1215
+ }
1208
1216
  messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, this.#onTokenRefresh);
1209
1217
  messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, this.#onLauncherPause);
1210
1218
  messaging.unlisten("PLAYCADEMY_RESUME" /* RESUME */, this.#onLauncherResume);
@@ -1227,6 +1235,9 @@ class ChildSession {
1227
1235
  status: "abandoned",
1228
1236
  timing: this.#relayedTiming()
1229
1237
  };
1238
+ if (this.#parentRunId) {
1239
+ activity.runId = this.#parentRunId;
1240
+ }
1230
1241
  const resume = this.checkpoint;
1231
1242
  if (resume) {
1232
1243
  activity.resume = resume;
@@ -1251,9 +1262,11 @@ class ChildSession {
1251
1262
  }
1252
1263
  this.#settled = true;
1253
1264
  this.#clearResume();
1265
+ this.#ensureBridgeRun();
1254
1266
  let endMemo = null;
1255
1267
  this.#resolveFinished({
1256
1268
  status: "completed",
1269
+ runId: this.#parentRunId ?? undefined,
1257
1270
  correct: childReport.scoreData.correctQuestions,
1258
1271
  total: childReport.scoreData.totalQuestions,
1259
1272
  timing: this.#completedTiming(childReport),
@@ -1722,6 +1735,29 @@ function createTimebackActivityTracker(client) {
1722
1735
  const resumeRunId = client["initPayload"]?.parent?.resumeRunId;
1723
1736
  return typeof resumeRunId === "string" && isValidUUID(resumeRunId) ? resumeRunId : undefined;
1724
1737
  }
1738
+ function clockAvailability(owner) {
1739
+ if (owner.kind === "assessment" && currentActivity?.owner.kind === "assessment" && currentActivity.owner.attemptId === owner.attemptId) {
1740
+ return "held-by-this-owner";
1741
+ }
1742
+ if (hasLiveSession(client)) {
1743
+ return { unavailable: "embedded-child" };
1744
+ }
1745
+ if (!currentActivity) {
1746
+ return "available";
1747
+ }
1748
+ if (owner.kind === "activity") {
1749
+ return currentActivity.owner.kind === "assessment" ? {
1750
+ unavailable: "other-assessment",
1751
+ runId: currentActivity.runId,
1752
+ attemptId: currentActivity.owner.attemptId
1753
+ } : "available";
1754
+ }
1755
+ return currentActivity.owner.kind === "assessment" ? {
1756
+ unavailable: "other-assessment",
1757
+ runId: currentActivity.runId,
1758
+ attemptId: currentActivity.owner.attemptId
1759
+ } : { unavailable: "ordinary-activity", runId: currentActivity.runId };
1760
+ }
1725
1761
  function startHeartbeatInterval(activity) {
1726
1762
  if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
1727
1763
  return;
@@ -2033,6 +2069,110 @@ function createTimebackActivityTracker(client) {
2033
2069
  }
2034
2070
  stopRelayInterval();
2035
2071
  }
2072
+ async function finishCurrentActivity(discardRelayedTiming) {
2073
+ const activity = currentActivity;
2074
+ if (activity.finishInFlight) {
2075
+ return activity.finishInFlight;
2076
+ }
2077
+ activity.finishInFlight = (async () => {
2078
+ applyOverdueInactivity();
2079
+ cleanupListeners();
2080
+ const relayedWithoutReconciliation = discardRelayedTiming && relaysToParent();
2081
+ await flushHeartbeat(true);
2082
+ if (activity.pauseStartTime !== null) {
2083
+ activity.pausedTime += Date.now() - activity.pauseStartTime;
2084
+ activity.pauseStartTime = null;
2085
+ }
2086
+ const endTime = Date.now();
2087
+ const totalElapsed = endTime - activity.startTime;
2088
+ const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
2089
+ const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
2090
+ const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
2091
+ if (currentActivity === activity) {
2092
+ currentActivity = null;
2093
+ }
2094
+ return {
2095
+ runId: activity.runId,
2096
+ resumeId: activity.resumeId,
2097
+ activityData: activity.metadata,
2098
+ durationSeconds: Math.floor(activeTime / 1000),
2099
+ sessionTimingData: relayedWithoutReconciliation ? { activeSeconds: 0 } : {
2100
+ activeSeconds: unreportedActiveMs / 1000,
2101
+ ...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
2102
+ }
2103
+ };
2104
+ })();
2105
+ return activity.finishInFlight;
2106
+ }
2107
+ function assertValidRunId(options) {
2108
+ if (options?.runId !== undefined && !isValidUUID(options.runId)) {
2109
+ throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
2110
+ }
2111
+ }
2112
+ function beginActivity(rawMetadata, options, owner) {
2113
+ const metadata = jsonProjection(rawMetadata);
2114
+ cleanupListeners();
2115
+ const now = Date.now();
2116
+ const runId = owner.kind === "assessment" ? owner.attemptId : options?.runId ?? adoptResumedRunId() ?? crypto.randomUUID();
2117
+ const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
2118
+ const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
2119
+ const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
2120
+ currentActivity = {
2121
+ runId,
2122
+ resumeId: crypto.randomUUID(),
2123
+ owner,
2124
+ startTime: now,
2125
+ metadata,
2126
+ pausedTime: 0,
2127
+ pauseStartTime: null,
2128
+ pauseReasons: new Set,
2129
+ pausedHeartbeatTimeoutId: null,
2130
+ pausedHeartbeatTimedOut: false,
2131
+ pausedHeartbeatTimeoutMs,
2132
+ windowStartTime: now,
2133
+ windowPausedAtStart: 0,
2134
+ heartbeatIntervalId: null,
2135
+ heartbeatIntervalMs,
2136
+ inactivityTimeoutId: null,
2137
+ inactivityTimeoutMs,
2138
+ inactivityTimerStartedAt: null,
2139
+ remainingInactivityMs: inactivityTimeoutMs,
2140
+ flushInFlight: null,
2141
+ finishInFlight: null,
2142
+ totalPersistedActiveMs: 0,
2143
+ totalPersistedPausedMs: 0
2144
+ };
2145
+ if (typeof document !== "undefined") {
2146
+ boundVisibilityHandler = handleVisibilityChange;
2147
+ document.addEventListener("visibilitychange", boundVisibilityHandler);
2148
+ boundUserInteractionHandler = handleUserInteraction;
2149
+ for (const eventName of USER_ACTIVITY_EVENTS) {
2150
+ document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
2151
+ }
2152
+ if (document.visibilityState === "hidden") {
2153
+ handleVisibilityChange();
2154
+ }
2155
+ }
2156
+ startHeartbeatInterval(currentActivity);
2157
+ startRelayInterval();
2158
+ if (typeof globalThis.window !== "undefined") {
2159
+ boundPageHideHandler = handlePageHide;
2160
+ globalThis.window.addEventListener("pagehide", boundPageHideHandler);
2161
+ }
2162
+ boundShellPauseHandler = handleShellPause;
2163
+ boundShellResumeHandler = handleShellResume;
2164
+ messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
2165
+ messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
2166
+ syncInactivityTracking();
2167
+ if (relaysToParent()) {
2168
+ messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
2169
+ runId,
2170
+ resumeId: currentActivity.resumeId,
2171
+ activityData: metadata
2172
+ });
2173
+ }
2174
+ return { runId };
2175
+ }
2036
2176
  return {
2037
2177
  currentRunId() {
2038
2178
  return currentActivity?.runId;
@@ -2040,74 +2180,40 @@ function createTimebackActivityTracker(client) {
2040
2180
  isActivityManuallyPaused() {
2041
2181
  return currentActivity?.pauseReasons.has("manual") ?? false;
2042
2182
  },
2043
- startActivity(rawMetadata, options) {
2044
- if (hasLiveSession(client)) {
2183
+ startActivity(rawMetadata, options, owner = { kind: "activity" }) {
2184
+ const availability = clockAvailability(owner);
2185
+ if (typeof availability === "object" && availability.unavailable === "embedded-child") {
2045
2186
  throw new PlaycademyError("startActivity() is unavailable while an embedded child session is live: the child owns the clock, and a second clock would double-count the lesson. Await the session's finished (or close() it) first.");
2046
2187
  }
2047
- if (options?.runId !== undefined && !isValidUUID(options.runId)) {
2048
- throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
2188
+ assertValidRunId(options);
2189
+ if (availability === "held-by-this-owner") {
2190
+ return { runId: currentActivity.runId };
2049
2191
  }
2050
- const metadata = jsonProjection(rawMetadata);
2051
- cleanupListeners();
2052
- const now = Date.now();
2053
- const resumedRunId = adoptResumedRunId();
2054
- const runId = options?.runId ?? resumedRunId ?? crypto.randomUUID();
2055
- const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
2056
- const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
2057
- const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
2058
- currentActivity = {
2059
- runId,
2060
- resumeId: crypto.randomUUID(),
2061
- startTime: now,
2062
- metadata,
2063
- pausedTime: 0,
2064
- pauseStartTime: null,
2065
- pauseReasons: new Set,
2066
- pausedHeartbeatTimeoutId: null,
2067
- pausedHeartbeatTimedOut: false,
2068
- pausedHeartbeatTimeoutMs,
2069
- windowStartTime: now,
2070
- windowPausedAtStart: 0,
2071
- heartbeatIntervalId: null,
2072
- heartbeatIntervalMs,
2073
- inactivityTimeoutId: null,
2074
- inactivityTimeoutMs,
2075
- inactivityTimerStartedAt: null,
2076
- remainingInactivityMs: inactivityTimeoutMs,
2077
- flushInFlight: null,
2078
- totalPersistedActiveMs: 0,
2079
- totalPersistedPausedMs: 0
2080
- };
2081
- if (typeof document !== "undefined") {
2082
- boundVisibilityHandler = handleVisibilityChange;
2083
- document.addEventListener("visibilitychange", boundVisibilityHandler);
2084
- boundUserInteractionHandler = handleUserInteraction;
2085
- for (const eventName of USER_ACTIVITY_EVENTS) {
2086
- document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
2087
- }
2088
- if (document.visibilityState === "hidden") {
2089
- handleVisibilityChange();
2192
+ if (typeof availability === "object") {
2193
+ if (owner.kind === "assessment") {
2194
+ throw new Error(`Cannot track assessment ${owner.attemptId} while activity run ${availability.runId} is active. End the current activity first.`);
2090
2195
  }
2196
+ const blockingAssessment = availability;
2197
+ throw new Error(`Cannot start an activity while assessment ${blockingAssessment.attemptId} is in progress. Submit the assessment first.`);
2091
2198
  }
2092
- startHeartbeatInterval(currentActivity);
2093
- startRelayInterval();
2094
- if (typeof globalThis.window !== "undefined") {
2095
- boundPageHideHandler = handlePageHide;
2096
- globalThis.window.addEventListener("pagehide", boundPageHideHandler);
2199
+ return beginActivity(rawMetadata, options, owner);
2200
+ },
2201
+ tryStartAssessmentTracking(metadata, attemptId) {
2202
+ if (!isValidUUID(attemptId)) {
2203
+ return { status: "untrackable", reason: "invalid-attempt-id" };
2097
2204
  }
2098
- boundShellPauseHandler = handleShellPause;
2099
- boundShellResumeHandler = handleShellResume;
2100
- messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
2101
- messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
2102
- syncInactivityTracking();
2103
- if (relaysToParent()) {
2104
- messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
2105
- runId,
2106
- resumeId: currentActivity.resumeId,
2107
- activityData: metadata
2108
- });
2205
+ const owner = { kind: "assessment", attemptId };
2206
+ const availability = clockAvailability(owner);
2207
+ if (typeof availability === "object") {
2208
+ return { status: "unavailable", reason: availability.unavailable };
2209
+ }
2210
+ if (availability === "held-by-this-owner") {
2211
+ return { status: "already-attached", runId: currentActivity.runId };
2109
2212
  }
2110
- return { runId };
2213
+ return {
2214
+ status: "attached",
2215
+ runId: beginActivity(metadata, { runId: attemptId }, owner).runId
2216
+ };
2111
2217
  },
2112
2218
  pauseActivity() {
2113
2219
  if (!currentActivity) {
@@ -2130,47 +2236,40 @@ function createTimebackActivityTracker(client) {
2130
2236
  }
2131
2237
  removePauseReason("manual");
2132
2238
  },
2239
+ async finishAssessmentTracking(expectedAttemptId) {
2240
+ if (!currentActivity || currentActivity.owner.kind !== "assessment" || currentActivity.owner.attemptId !== expectedAttemptId) {
2241
+ return;
2242
+ }
2243
+ return finishCurrentActivity(true);
2244
+ },
2133
2245
  async endActivity(rawData) {
2134
2246
  if (!currentActivity) {
2135
2247
  throw new Error("No activity in progress. Call startActivity() before endActivity().");
2136
2248
  }
2249
+ if (currentActivity.owner.kind === "assessment") {
2250
+ throw new Error(`endActivity() cannot finalize assessment ${currentActivity.owner.attemptId}. Submit it with timeback.assessments.submit() instead.`);
2251
+ }
2137
2252
  const data = jsonProjection(rawData);
2138
2253
  if (!relaysToParent() && typeof data.xpAwarded !== "number") {
2139
2254
  throw new Error("endActivity() requires xpAwarded when reporting directly. It is optional only in child mode, where the parent game decides the award.");
2140
2255
  }
2141
- const activity = currentActivity;
2142
- applyOverdueInactivity();
2143
- cleanupListeners();
2144
- await flushHeartbeat(true);
2145
- if (activity.pauseStartTime !== null) {
2146
- activity.pausedTime += Date.now() - activity.pauseStartTime;
2147
- activity.pauseStartTime = null;
2148
- }
2149
- const endTime = Date.now();
2150
- const totalElapsed = endTime - activity.startTime;
2151
- const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
2152
- const durationSeconds = Math.floor(activeTime / 1000);
2153
- const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
2154
- const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
2155
2256
  const { correctQuestions, totalQuestions } = data;
2156
2257
  if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
2157
2258
  throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
2158
2259
  }
2260
+ const tracking = await finishCurrentActivity(false);
2159
2261
  const request = {
2160
- runId: activity.runId,
2161
- resumeId: activity.resumeId,
2162
- activityData: activity.metadata,
2262
+ runId: tracking.runId,
2263
+ resumeId: tracking.resumeId,
2264
+ activityData: tracking.activityData,
2163
2265
  scoreData: {
2164
2266
  correctQuestions,
2165
2267
  totalQuestions
2166
2268
  },
2167
2269
  timingData: {
2168
- durationSeconds
2169
- },
2170
- sessionTimingData: {
2171
- activeSeconds: unreportedActiveMs / 1000,
2172
- ...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
2270
+ durationSeconds: tracking.durationSeconds
2173
2271
  },
2272
+ sessionTimingData: tracking.sessionTimingData,
2174
2273
  xpEarned: data.xpAwarded,
2175
2274
  masteredUnits: data.masteredUnits,
2176
2275
  masteredUnitsAbsolute: data.masteredUnitsAbsolute,
@@ -2178,23 +2277,9 @@ function createTimebackActivityTracker(client) {
2178
2277
  };
2179
2278
  if (relaysToParent()) {
2180
2279
  messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */, request);
2181
- if (currentActivity === activity) {
2182
- currentActivity = null;
2183
- }
2184
- return { status: "relayed", runId: activity.runId };
2185
- }
2186
- try {
2187
- const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
2188
- if (currentActivity === activity) {
2189
- currentActivity = null;
2190
- }
2191
- return response;
2192
- } catch (error) {
2193
- if (currentActivity === activity) {
2194
- currentActivity = null;
2195
- }
2196
- throw error;
2280
+ return { status: "relayed", runId: tracking.runId };
2197
2281
  }
2282
+ return client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
2198
2283
  }
2199
2284
  };
2200
2285
  }
@@ -2927,8 +3012,10 @@ function createTimebackEngine(client) {
2927
3012
  currentRunId: activityTracker.currentRunId,
2928
3013
  isManuallyPaused: activityTracker.isActivityManuallyPaused,
2929
3014
  start: activityTracker.startActivity,
3015
+ tryStartAssessment: activityTracker.tryStartAssessmentTracking,
2930
3016
  pause: activityTracker.pauseActivity,
2931
3017
  resume: activityTracker.resumeActivity,
3018
+ finishAssessment: activityTracker.finishAssessmentTracking,
2932
3019
  end: activityTracker.endActivity
2933
3020
  },
2934
3021
  course: {
@@ -2955,9 +3042,10 @@ function createTimebackEngine(client) {
2955
3042
  var VALID_XP_INCLUDE_OPTIONS = ["perCourse", "today"];
2956
3043
  var VALID_MASTERY_INCLUDE_OPTIONS = ["perCourse"];
2957
3044
  var ASSESSMENTS_ROUTE = TIMEBACK_ROUTES.ASSESSMENTS;
3045
+ var ASSESSMENT_SUBMIT_RETRY_POLICY = END_ACTIVITY_RETRY_POLICY;
2958
3046
  function validateAssessmentFilters(options) {
2959
3047
  if (!isAssessmentPurpose(options?.purpose)) {
2960
- throw new Error("purpose must be end_of_course, diagnostic, or review");
3048
+ throw new Error("purpose must be end_of_course, diagnostic, review, or mastery");
2961
3049
  }
2962
3050
  if (options.grade !== undefined && !isValidGrade(options.grade)) {
2963
3051
  throw new Error(`Invalid grade: ${options.grade}. Valid grades: ${VALID_GRADES.join(", ")}`);
@@ -2965,9 +3053,99 @@ function validateAssessmentFilters(options) {
2965
3053
  if (options.subject !== undefined && !isValidSubject(options.subject)) {
2966
3054
  throw new Error(`Invalid subject: ${options.subject}. Valid subjects: ${VALID_SUBJECTS.join(", ")}`);
2967
3055
  }
3056
+ if (options.purpose === "mastery") {
3057
+ const { standard } = options;
3058
+ const framework = typeof standard?.framework === "string" ? standard.framework.trim() : "";
3059
+ const identifier = typeof standard?.identifier === "string" ? standard.identifier.trim() : "";
3060
+ if (!framework || !identifier) {
3061
+ throw new Error("mastery standard requires a canonical framework and identifier");
3062
+ }
3063
+ if (framework.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength || identifier.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength) {
3064
+ throw new Error(`mastery standard fields must be at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
3065
+ }
3066
+ }
2968
3067
  }
2969
3068
  function createTimebackNamespace(client) {
2970
3069
  const engine = createTimebackEngine(client);
3070
+ const pendingAssessmentSubmissions = new Map;
3071
+ const warnedAssessmentTrackingSkips = new Map;
3072
+ function clearAssessmentTrackingWarnings(attemptId) {
3073
+ warnedAssessmentTrackingSkips.delete(attemptId);
3074
+ }
3075
+ function warnAssessmentTrackingSkipped(attemptId, reason) {
3076
+ const warnedReasons = warnedAssessmentTrackingSkips.get(attemptId) ?? new Set;
3077
+ if (warnedReasons.has(reason)) {
3078
+ return;
3079
+ }
3080
+ warnedReasons.add(reason);
3081
+ warnedAssessmentTrackingSkips.set(attemptId, warnedReasons);
3082
+ const remedy = reason === "invalid-attempt-id" ? "The canonical attempt remains usable and submittable, but this sitting cannot be timed. Report the malformed id." : "The canonical attempt remains available; release the current clock owner, then call timeback.assessments.get(attemptId) to retry tracking.";
3083
+ console.warn(`[Playcademy SDK] timeback.assessments: active-time tracking was not attached to assessment ${attemptId} (${reason}). ${remedy}`);
3084
+ }
3085
+ function releasePendingSubmission(attemptId, submissionId) {
3086
+ if (pendingAssessmentSubmissions.get(attemptId)?.submissionId === submissionId) {
3087
+ pendingAssessmentSubmissions.delete(attemptId);
3088
+ }
3089
+ }
3090
+ function loadAttemptSnapshot(attemptId) {
3091
+ return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
3092
+ }
3093
+ async function syncAssessmentTracking(snapshot) {
3094
+ if (pendingAssessmentSubmissions.has(snapshot.attemptId)) {
3095
+ return snapshot;
3096
+ }
3097
+ if (snapshot.status === "completed") {
3098
+ await engine.activity.finishAssessment(snapshot.attemptId);
3099
+ clearAssessmentTrackingWarnings(snapshot.attemptId);
3100
+ return snapshot;
3101
+ }
3102
+ if (!snapshot.activityData) {
3103
+ return snapshot;
3104
+ }
3105
+ const attachment = engine.activity.tryStartAssessment(snapshot.activityData, snapshot.attemptId);
3106
+ if (attachment.status === "unavailable" || attachment.status === "untrackable") {
3107
+ warnAssessmentTrackingSkipped(snapshot.attemptId, attachment.reason);
3108
+ } else {
3109
+ clearAssessmentTrackingWarnings(snapshot.attemptId);
3110
+ }
3111
+ return snapshot;
3112
+ }
3113
+ async function resumeAfterDefinitiveFailure(attemptId, submissionId) {
3114
+ if (pendingAssessmentSubmissions.get(attemptId)?.submissionId !== submissionId) {
3115
+ return;
3116
+ }
3117
+ pendingAssessmentSubmissions.delete(attemptId);
3118
+ try {
3119
+ await syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
3120
+ } catch {}
3121
+ }
3122
+ async function assessmentSubmissionContext(attemptId, submissionId) {
3123
+ const pending = pendingAssessmentSubmissions.get(attemptId);
3124
+ if (pending) {
3125
+ if (pending.submissionId !== submissionId && !pending.detached) {
3126
+ throw new Error(`Assessment ${attemptId} already has a pending submission. Retry with the original submissionId.`);
3127
+ }
3128
+ pending.detached = false;
3129
+ return {
3130
+ submissionId: pending.submissionId,
3131
+ session: await pending.session
3132
+ };
3133
+ }
3134
+ const session = engine.activity.finishAssessment(attemptId).then((finished) => finished ? {
3135
+ runId: finished.runId,
3136
+ resumeId: finished.resumeId,
3137
+ ...finished.sessionTimingData
3138
+ } : undefined).catch((error) => {
3139
+ releasePendingSubmission(attemptId, submissionId);
3140
+ throw error;
3141
+ });
3142
+ pendingAssessmentSubmissions.set(attemptId, {
3143
+ submissionId,
3144
+ session,
3145
+ detached: false
3146
+ });
3147
+ return { submissionId, session: await session };
3148
+ }
2971
3149
  registerPauseProbe(client, () => engine.activity.isManuallyPaused());
2972
3150
  return {
2973
3151
  assessments: {
@@ -2977,6 +3155,12 @@ function createTimebackNamespace(client) {
2977
3155
  throw new Error("activityId is required");
2978
3156
  }
2979
3157
  validateAssessmentFilters(input);
3158
+ if (input.purpose === "diagnostic" && !input.diagnosticKey?.trim()) {
3159
+ throw new Error("diagnosticKey is required for diagnostic assessments");
3160
+ }
3161
+ if (input.purpose === "diagnostic" && input.diagnosticKey.trim().length > 200) {
3162
+ throw new Error("diagnosticKey must contain at most 200 characters");
3163
+ }
2980
3164
  if (input.purpose === "review") {
2981
3165
  if (!Array.isArray(input.standards) || input.standards.length === 0) {
2982
3166
  throw new Error("standards must contain at least one standard for review");
@@ -2992,11 +3176,12 @@ function createTimebackNamespace(client) {
2992
3176
  throw new Error(`review standard fields must be at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
2993
3177
  }
2994
3178
  }
2995
- if (input.itemsPerStandard !== undefined && (!Number.isInteger(input.itemsPerStandard) || input.itemsPerStandard <= 0 || input.itemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard)) {
2996
- throw new Error(`itemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard}`);
3179
+ if (input.candidateItemsPerStandard !== undefined && (!Number.isInteger(input.candidateItemsPerStandard) || input.candidateItemsPerStandard <= 0 || input.candidateItemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard)) {
3180
+ throw new Error(`candidateItemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.candidateItemsPerStandard}`);
2997
3181
  }
2998
3182
  }
2999
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3183
+ const snapshot = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3184
+ return syncAssessmentTracking(snapshot);
3000
3185
  },
3001
3186
  latest: async (options) => {
3002
3187
  assertPlatformMode(client, "timeback.assessments.latest()");
@@ -3008,6 +3193,10 @@ function createTimebackNamespace(client) {
3008
3193
  if (options.grade !== undefined) {
3009
3194
  params.set("grade", String(options.grade));
3010
3195
  }
3196
+ if (options.purpose === "mastery") {
3197
+ params.set("standardFramework", options.standard.framework);
3198
+ params.set("standardIdentifier", options.standard.identifier);
3199
+ }
3011
3200
  return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/latest?${params.toString()}`, "GET");
3012
3201
  },
3013
3202
  get: async (attemptId) => {
@@ -3015,7 +3204,20 @@ function createTimebackNamespace(client) {
3015
3204
  if (!attemptId?.trim()) {
3016
3205
  throw new Error("attemptId is required");
3017
3206
  }
3018
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
3207
+ return syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
3208
+ },
3209
+ stop: async (attemptId) => {
3210
+ assertPlatformMode(client, "timeback.assessments.stop()");
3211
+ if (!attemptId?.trim()) {
3212
+ throw new Error("attemptId is required");
3213
+ }
3214
+ const pending = pendingAssessmentSubmissions.get(attemptId);
3215
+ if (pending) {
3216
+ pending.detached = true;
3217
+ }
3218
+ await engine.activity.finishAssessment(attemptId);
3219
+ clearAssessmentTrackingWarnings(attemptId);
3220
+ return { attemptId };
3019
3221
  },
3020
3222
  save: async (attemptId, input) => {
3021
3223
  assertPlatformMode(client, "timeback.assessments.save()");
@@ -3027,6 +3229,25 @@ function createTimebackNamespace(client) {
3027
3229
  }
3028
3230
  return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/save`, "POST", input);
3029
3231
  },
3232
+ submitItem: async (attemptId, input) => {
3233
+ assertPlatformMode(client, "timeback.assessments.submitItem()");
3234
+ if (!attemptId?.trim()) {
3235
+ throw new Error("attemptId is required");
3236
+ }
3237
+ if (!Number.isInteger(input.expectedResponseVersion) || input.expectedResponseVersion < 0) {
3238
+ throw new Error("expectedResponseVersion must be a non-negative integer");
3239
+ }
3240
+ if (!input.submissionId?.trim()) {
3241
+ throw new Error("submissionId is required");
3242
+ }
3243
+ if (!input.itemIdentifier?.trim()) {
3244
+ throw new Error("itemIdentifier is required");
3245
+ }
3246
+ if (input.routingNodeKey !== undefined && !input.routingNodeKey.trim()) {
3247
+ throw new Error("routingNodeKey must be non-empty when provided");
3248
+ }
3249
+ return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit-item`, "POST", input);
3250
+ },
3030
3251
  submit: async (attemptId, input) => {
3031
3252
  assertPlatformMode(client, "timeback.assessments.submit()");
3032
3253
  if (!attemptId?.trim()) {
@@ -3038,7 +3259,25 @@ function createTimebackNamespace(client) {
3038
3259
  if (!input.submissionId?.trim()) {
3039
3260
  throw new Error("submissionId is required");
3040
3261
  }
3041
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", input);
3262
+ const { expectedResponseVersion } = input;
3263
+ const { submissionId, session } = await assessmentSubmissionContext(attemptId, input.submissionId);
3264
+ const request = {
3265
+ expectedResponseVersion,
3266
+ submissionId,
3267
+ ...session ? { session } : {}
3268
+ };
3269
+ try {
3270
+ const result = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", request, undefined, { retryPolicy: ASSESSMENT_SUBMIT_RETRY_POLICY });
3271
+ releasePendingSubmission(attemptId, submissionId);
3272
+ await engine.activity.finishAssessment(attemptId);
3273
+ clearAssessmentTrackingWarnings(attemptId);
3274
+ return result;
3275
+ } catch (error) {
3276
+ if (error instanceof ApiError && !error.isRetryable()) {
3277
+ await resumeAfterDefinitiveFailure(attemptId, submissionId);
3278
+ }
3279
+ throw error;
3280
+ }
3042
3281
  }
3043
3282
  },
3044
3283
  get user() {
@@ -3502,7 +3741,8 @@ function createDevNamespace(client) {
3502
3741
  if (options.delimiter) {
3503
3742
  params.set("delimiter", options.delimiter);
3504
3743
  }
3505
- const query = params.size > 0 ? `?${params.toString()}` : "";
3744
+ const search = params.toString();
3745
+ const query = search === "" ? "" : `?${search}`;
3506
3746
  return client["request"](`/games/${slug}/bucket${query}`, "GET");
3507
3747
  }
3508
3748
  return {
@@ -3854,6 +4094,20 @@ function createScoresNamespace2(client) {
3854
4094
  }
3855
4095
  };
3856
4096
  }
4097
+ // src/namespaces/platform/assessment-assets.ts
4098
+ var ASSESSMENT_ASSET_UPLOAD_RETRY_POLICY = {
4099
+ retryableMethods: ["POST"]
4100
+ };
4101
+ function createAssessmentAssetsNamespace(client) {
4102
+ return {
4103
+ upload: (body, sha256) => client["request"]("/timeback/assessment-assets", "POST", {
4104
+ body,
4105
+ headers: { [PLAYCADEMY_ASSESSMENT_ASSET_SHA256_HEADER]: sha256 },
4106
+ retryPolicy: ASSESSMENT_ASSET_UPLOAD_RETRY_POLICY
4107
+ })
4108
+ };
4109
+ }
4110
+
3857
4111
  // src/namespaces/platform/assessments.ts
3858
4112
  function assessmentPath(...segments) {
3859
4113
  return `/timeback/assessments/${segments.map(encodeURIComponent).join("/")}`;
@@ -3879,16 +4133,20 @@ function createAssessmentNamespace(client) {
3879
4133
  return {
3880
4134
  list: (gameId, courseId) => client["request"](assessmentPath(gameId, courseId), "GET"),
3881
4135
  create: (gameId, courseId, data) => client["request"](assessmentPath(gameId, courseId), "POST", { body: data }),
4136
+ attachExisting: (gameId, courseId, manifest) => client["request"](assessmentPath(gameId, courseId, "attachments"), "POST", {
4137
+ body: manifest
4138
+ }),
3882
4139
  update: (gameId, courseId, testIdentifier, data) => client["request"](assessmentPath(gameId, courseId, testIdentifier), "PATCH", {
3883
4140
  body: data
3884
4141
  }),
4142
+ updateReviewMapping: (gameId, courseId, testIdentifier) => client["request"](assessmentPath(gameId, courseId, testIdentifier, "review-mapping"), "POST"),
3885
4143
  reorder: (gameId, courseId, purpose, testIdentifiers) => client["request"](assessmentPath(gameId, courseId, "order"), "PUT", {
3886
4144
  body: { purpose, testIdentifiers }
3887
4145
  }),
3888
4146
  remove: (gameId, courseId, testIdentifier) => client["request"](assessmentPath(gameId, courseId, testIdentifier), "DELETE"),
3889
4147
  listTestLibrary: (gameId, courseId, options = {}) => client["request"](`${assessmentPath(gameId, courseId, "test-library")}${buildQtiLibraryQueryString(options)}`, "GET"),
3890
- copy: (gameId, courseId, testIdentifier, purpose) => client["request"](assessmentPath(gameId, courseId, "copies"), "POST", {
3891
- body: { testIdentifier, purpose }
4148
+ copy: (gameId, courseId, data) => client["request"](assessmentPath(gameId, courseId, "copies"), "POST", {
4149
+ body: data
3892
4150
  }),
3893
4151
  listQuestions: (gameId, courseId, testIdentifier) => client["request"](assessmentPath(gameId, courseId, testIdentifier, "questions"), "GET"),
3894
4152
  listQuestionLibrary: (gameId, courseId, options = {}) => client["request"](`${assessmentPath(gameId, courseId, "question-library")}${buildQtiLibraryQueryString(options)}`, "GET"),
@@ -4078,7 +4336,8 @@ function createTimebackNamespace2(client) {
4078
4336
  body: request
4079
4337
  })
4080
4338
  },
4081
- assessments: createAssessmentNamespace(client)
4339
+ assessments: createAssessmentNamespace(client),
4340
+ assessmentAssets: createAssessmentAssetsNamespace(client)
4082
4341
  };
4083
4342
  }
4084
4343
  // src/core/auth/strategies.ts
@@ -4320,7 +4579,7 @@ async function request({
4320
4579
  return rawText && rawText.length > 0 ? rawText : undefined;
4321
4580
  }
4322
4581
  // src/version.ts
4323
- var SDK_VERSION = "0.16.1-beta.8";
4582
+ var SDK_VERSION = "0.17.0";
4324
4583
 
4325
4584
  // src/clients/base.ts
4326
4585
  class PlaycademyBaseClient {