@playcademy/sdk 0.16.1-beta.9 → 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/index.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",
@@ -826,7 +828,7 @@ var TIMEBACK_SUBJECTS = [
826
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);
2097
- }
2098
- boundShellPauseHandler = handleShellPause;
2099
- boundShellResumeHandler = handleShellResume;
2100
- messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
2101
- messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
2102
- syncInactivityTracking();
2103
- if (relaysToParent()) {
2104
- messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
2105
- runId,
2106
- resumeId: currentActivity.resumeId,
2107
- activityData: metadata
2108
- });
2199
+ return beginActivity(rawMetadata, options, owner);
2200
+ },
2201
+ tryStartAssessmentTracking(metadata, attemptId) {
2202
+ if (!isValidUUID(attemptId)) {
2203
+ return { status: "untrackable", reason: "invalid-attempt-id" };
2204
+ }
2205
+ const owner = { kind: "assessment", attemptId };
2206
+ const availability = clockAvailability(owner);
2207
+ if (typeof availability === "object") {
2208
+ return { status: "unavailable", reason: availability.unavailable };
2109
2209
  }
2110
- return { runId };
2210
+ if (availability === "held-by-this-owner") {
2211
+ return { status: "already-attached", runId: currentActivity.runId };
2212
+ }
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,6 +3042,7 @@ 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
3048
  throw new Error("purpose must be end_of_course, diagnostic, review, or mastery");
@@ -2979,6 +3067,85 @@ function validateAssessmentFilters(options) {
2979
3067
  }
2980
3068
  function createTimebackNamespace(client) {
2981
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
+ }
2982
3149
  registerPauseProbe(client, () => engine.activity.isManuallyPaused());
2983
3150
  return {
2984
3151
  assessments: {
@@ -2988,6 +3155,12 @@ function createTimebackNamespace(client) {
2988
3155
  throw new Error("activityId is required");
2989
3156
  }
2990
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
+ }
2991
3164
  if (input.purpose === "review") {
2992
3165
  if (!Array.isArray(input.standards) || input.standards.length === 0) {
2993
3166
  throw new Error("standards must contain at least one standard for review");
@@ -3003,11 +3176,12 @@ function createTimebackNamespace(client) {
3003
3176
  throw new Error(`review standard fields must be at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength} characters`);
3004
3177
  }
3005
3178
  }
3006
- if (input.itemsPerStandard !== undefined && (!Number.isInteger(input.itemsPerStandard) || input.itemsPerStandard <= 0 || input.itemsPerStandard > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard)) {
3007
- throw new Error(`itemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard}`);
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}`);
3008
3181
  }
3009
3182
  }
3010
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3183
+ const snapshot = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3184
+ return syncAssessmentTracking(snapshot);
3011
3185
  },
3012
3186
  latest: async (options) => {
3013
3187
  assertPlatformMode(client, "timeback.assessments.latest()");
@@ -3030,7 +3204,20 @@ function createTimebackNamespace(client) {
3030
3204
  if (!attemptId?.trim()) {
3031
3205
  throw new Error("attemptId is required");
3032
3206
  }
3033
- 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 };
3034
3221
  },
3035
3222
  save: async (attemptId, input) => {
3036
3223
  assertPlatformMode(client, "timeback.assessments.save()");
@@ -3042,6 +3229,25 @@ function createTimebackNamespace(client) {
3042
3229
  }
3043
3230
  return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/save`, "POST", input);
3044
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
+ },
3045
3251
  submit: async (attemptId, input) => {
3046
3252
  assertPlatformMode(client, "timeback.assessments.submit()");
3047
3253
  if (!attemptId?.trim()) {
@@ -3053,7 +3259,25 @@ function createTimebackNamespace(client) {
3053
3259
  if (!input.submissionId?.trim()) {
3054
3260
  throw new Error("submissionId is required");
3055
3261
  }
3056
- 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
+ }
3057
3281
  }
3058
3282
  },
3059
3283
  get user() {
@@ -3460,7 +3684,7 @@ async function request({
3460
3684
  return rawText && rawText.length > 0 ? rawText : undefined;
3461
3685
  }
3462
3686
  // src/version.ts
3463
- var SDK_VERSION = "0.16.1-beta.9";
3687
+ var SDK_VERSION = "0.17.0";
3464
3688
 
3465
3689
  // src/clients/base.ts
3466
3690
  class PlaycademyBaseClient {