@playcademy/sdk 0.16.1-beta.14 → 0.16.1-beta.16

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.d.ts CHANGED
@@ -1558,6 +1558,9 @@ declare class PlaycademyClient extends PlaycademyBaseClient {
1558
1558
  start: (input: _playcademy_types.StartAssessmentInput) => Promise<_playcademy_types.AssessmentAttemptSnapshot>;
1559
1559
  latest: (options: _playcademy_types.GetLatestAssessmentOptions) => Promise<_playcademy_types.LatestAssessmentResult | null>;
1560
1560
  get: (attemptId: string) => Promise<_playcademy_types.AssessmentAttemptSnapshot>;
1561
+ stop: (attemptId: string) => Promise<{
1562
+ attemptId: string;
1563
+ }>;
1561
1564
  save: (attemptId: string, input: _playcademy_types.SaveAssessmentInput) => Promise<_playcademy_types.AssessmentSaveResult>;
1562
1565
  submitItem: (attemptId: string, input: _playcademy_types.SubmitAssessmentItemInput) => Promise<_playcademy_types.SubmitAssessmentItemResult>;
1563
1566
  submit: (attemptId: string, input: _playcademy_types.SubmitAssessmentInput) => Promise<_playcademy_types.AssessmentSubmitResult>;
package/dist/index.js CHANGED
@@ -1730,6 +1730,29 @@ function createTimebackActivityTracker(client) {
1730
1730
  const resumeRunId = client["initPayload"]?.parent?.resumeRunId;
1731
1731
  return typeof resumeRunId === "string" && isValidUUID(resumeRunId) ? resumeRunId : undefined;
1732
1732
  }
1733
+ function clockAvailability(owner) {
1734
+ if (owner.kind === "assessment" && currentActivity?.owner.kind === "assessment" && currentActivity.owner.attemptId === owner.attemptId) {
1735
+ return "held-by-this-owner";
1736
+ }
1737
+ if (hasLiveSession(client)) {
1738
+ return { unavailable: "embedded-child" };
1739
+ }
1740
+ if (!currentActivity) {
1741
+ return "available";
1742
+ }
1743
+ if (owner.kind === "activity") {
1744
+ return currentActivity.owner.kind === "assessment" ? {
1745
+ unavailable: "other-assessment",
1746
+ runId: currentActivity.runId,
1747
+ attemptId: currentActivity.owner.attemptId
1748
+ } : "available";
1749
+ }
1750
+ return currentActivity.owner.kind === "assessment" ? {
1751
+ unavailable: "other-assessment",
1752
+ runId: currentActivity.runId,
1753
+ attemptId: currentActivity.owner.attemptId
1754
+ } : { unavailable: "ordinary-activity", runId: currentActivity.runId };
1755
+ }
1733
1756
  function startHeartbeatInterval(activity) {
1734
1757
  if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
1735
1758
  return;
@@ -2041,6 +2064,110 @@ function createTimebackActivityTracker(client) {
2041
2064
  }
2042
2065
  stopRelayInterval();
2043
2066
  }
2067
+ async function finishCurrentActivity(discardRelayedTiming) {
2068
+ const activity = currentActivity;
2069
+ if (activity.finishInFlight) {
2070
+ return activity.finishInFlight;
2071
+ }
2072
+ activity.finishInFlight = (async () => {
2073
+ applyOverdueInactivity();
2074
+ cleanupListeners();
2075
+ const relayedWithoutReconciliation = discardRelayedTiming && relaysToParent();
2076
+ await flushHeartbeat(true);
2077
+ if (activity.pauseStartTime !== null) {
2078
+ activity.pausedTime += Date.now() - activity.pauseStartTime;
2079
+ activity.pauseStartTime = null;
2080
+ }
2081
+ const endTime = Date.now();
2082
+ const totalElapsed = endTime - activity.startTime;
2083
+ const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
2084
+ const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
2085
+ const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
2086
+ if (currentActivity === activity) {
2087
+ currentActivity = null;
2088
+ }
2089
+ return {
2090
+ runId: activity.runId,
2091
+ resumeId: activity.resumeId,
2092
+ activityData: activity.metadata,
2093
+ durationSeconds: Math.floor(activeTime / 1000),
2094
+ sessionTimingData: relayedWithoutReconciliation ? { activeSeconds: 0 } : {
2095
+ activeSeconds: unreportedActiveMs / 1000,
2096
+ ...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
2097
+ }
2098
+ };
2099
+ })();
2100
+ return activity.finishInFlight;
2101
+ }
2102
+ function assertValidRunId(options) {
2103
+ if (options?.runId !== undefined && !isValidUUID(options.runId)) {
2104
+ throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
2105
+ }
2106
+ }
2107
+ function beginActivity(rawMetadata, options, owner) {
2108
+ const metadata = jsonProjection(rawMetadata);
2109
+ cleanupListeners();
2110
+ const now = Date.now();
2111
+ const runId = owner.kind === "assessment" ? owner.attemptId : options?.runId ?? adoptResumedRunId() ?? crypto.randomUUID();
2112
+ const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
2113
+ const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
2114
+ const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
2115
+ currentActivity = {
2116
+ runId,
2117
+ resumeId: crypto.randomUUID(),
2118
+ owner,
2119
+ startTime: now,
2120
+ metadata,
2121
+ pausedTime: 0,
2122
+ pauseStartTime: null,
2123
+ pauseReasons: new Set,
2124
+ pausedHeartbeatTimeoutId: null,
2125
+ pausedHeartbeatTimedOut: false,
2126
+ pausedHeartbeatTimeoutMs,
2127
+ windowStartTime: now,
2128
+ windowPausedAtStart: 0,
2129
+ heartbeatIntervalId: null,
2130
+ heartbeatIntervalMs,
2131
+ inactivityTimeoutId: null,
2132
+ inactivityTimeoutMs,
2133
+ inactivityTimerStartedAt: null,
2134
+ remainingInactivityMs: inactivityTimeoutMs,
2135
+ flushInFlight: null,
2136
+ finishInFlight: null,
2137
+ totalPersistedActiveMs: 0,
2138
+ totalPersistedPausedMs: 0
2139
+ };
2140
+ if (typeof document !== "undefined") {
2141
+ boundVisibilityHandler = handleVisibilityChange;
2142
+ document.addEventListener("visibilitychange", boundVisibilityHandler);
2143
+ boundUserInteractionHandler = handleUserInteraction;
2144
+ for (const eventName of USER_ACTIVITY_EVENTS) {
2145
+ document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
2146
+ }
2147
+ if (document.visibilityState === "hidden") {
2148
+ handleVisibilityChange();
2149
+ }
2150
+ }
2151
+ startHeartbeatInterval(currentActivity);
2152
+ startRelayInterval();
2153
+ if (typeof globalThis.window !== "undefined") {
2154
+ boundPageHideHandler = handlePageHide;
2155
+ globalThis.window.addEventListener("pagehide", boundPageHideHandler);
2156
+ }
2157
+ boundShellPauseHandler = handleShellPause;
2158
+ boundShellResumeHandler = handleShellResume;
2159
+ messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
2160
+ messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
2161
+ syncInactivityTracking();
2162
+ if (relaysToParent()) {
2163
+ messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
2164
+ runId,
2165
+ resumeId: currentActivity.resumeId,
2166
+ activityData: metadata
2167
+ });
2168
+ }
2169
+ return { runId };
2170
+ }
2044
2171
  return {
2045
2172
  currentRunId() {
2046
2173
  return currentActivity?.runId;
@@ -2048,74 +2175,40 @@ function createTimebackActivityTracker(client) {
2048
2175
  isActivityManuallyPaused() {
2049
2176
  return currentActivity?.pauseReasons.has("manual") ?? false;
2050
2177
  },
2051
- startActivity(rawMetadata, options) {
2052
- if (hasLiveSession(client)) {
2178
+ startActivity(rawMetadata, options, owner = { kind: "activity" }) {
2179
+ const availability = clockAvailability(owner);
2180
+ if (typeof availability === "object" && availability.unavailable === "embedded-child") {
2053
2181
  throw new PlaycademyError("startActivity() is unavailable while an embedded child session is live: the child owns the clock, and a second clock would double-count the lesson. Await the session's finished (or close() it) first.");
2054
2182
  }
2055
- if (options?.runId !== undefined && !isValidUUID(options.runId)) {
2056
- throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
2183
+ assertValidRunId(options);
2184
+ if (availability === "held-by-this-owner") {
2185
+ return { runId: currentActivity.runId };
2057
2186
  }
2058
- const metadata = jsonProjection(rawMetadata);
2059
- cleanupListeners();
2060
- const now = Date.now();
2061
- const resumedRunId = adoptResumedRunId();
2062
- const runId = options?.runId ?? resumedRunId ?? crypto.randomUUID();
2063
- const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
2064
- const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
2065
- const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
2066
- currentActivity = {
2067
- runId,
2068
- resumeId: crypto.randomUUID(),
2069
- startTime: now,
2070
- metadata,
2071
- pausedTime: 0,
2072
- pauseStartTime: null,
2073
- pauseReasons: new Set,
2074
- pausedHeartbeatTimeoutId: null,
2075
- pausedHeartbeatTimedOut: false,
2076
- pausedHeartbeatTimeoutMs,
2077
- windowStartTime: now,
2078
- windowPausedAtStart: 0,
2079
- heartbeatIntervalId: null,
2080
- heartbeatIntervalMs,
2081
- inactivityTimeoutId: null,
2082
- inactivityTimeoutMs,
2083
- inactivityTimerStartedAt: null,
2084
- remainingInactivityMs: inactivityTimeoutMs,
2085
- flushInFlight: null,
2086
- totalPersistedActiveMs: 0,
2087
- totalPersistedPausedMs: 0
2088
- };
2089
- if (typeof document !== "undefined") {
2090
- boundVisibilityHandler = handleVisibilityChange;
2091
- document.addEventListener("visibilitychange", boundVisibilityHandler);
2092
- boundUserInteractionHandler = handleUserInteraction;
2093
- for (const eventName of USER_ACTIVITY_EVENTS) {
2094
- document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
2095
- }
2096
- if (document.visibilityState === "hidden") {
2097
- handleVisibilityChange();
2187
+ if (typeof availability === "object") {
2188
+ if (owner.kind === "assessment") {
2189
+ throw new Error(`Cannot track assessment ${owner.attemptId} while activity run ${availability.runId} is active. End the current activity first.`);
2098
2190
  }
2191
+ const blockingAssessment = availability;
2192
+ throw new Error(`Cannot start an activity while assessment ${blockingAssessment.attemptId} is in progress. Submit the assessment first.`);
2099
2193
  }
2100
- startHeartbeatInterval(currentActivity);
2101
- startRelayInterval();
2102
- if (typeof globalThis.window !== "undefined") {
2103
- boundPageHideHandler = handlePageHide;
2104
- globalThis.window.addEventListener("pagehide", boundPageHideHandler);
2105
- }
2106
- boundShellPauseHandler = handleShellPause;
2107
- boundShellResumeHandler = handleShellResume;
2108
- messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
2109
- messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
2110
- syncInactivityTracking();
2111
- if (relaysToParent()) {
2112
- messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
2113
- runId,
2114
- resumeId: currentActivity.resumeId,
2115
- activityData: metadata
2116
- });
2194
+ return beginActivity(rawMetadata, options, owner);
2195
+ },
2196
+ tryStartAssessmentTracking(metadata, attemptId) {
2197
+ if (!isValidUUID(attemptId)) {
2198
+ return { status: "untrackable", reason: "invalid-attempt-id" };
2199
+ }
2200
+ const owner = { kind: "assessment", attemptId };
2201
+ const availability = clockAvailability(owner);
2202
+ if (typeof availability === "object") {
2203
+ return { status: "unavailable", reason: availability.unavailable };
2117
2204
  }
2118
- return { runId };
2205
+ if (availability === "held-by-this-owner") {
2206
+ return { status: "already-attached", runId: currentActivity.runId };
2207
+ }
2208
+ return {
2209
+ status: "attached",
2210
+ runId: beginActivity(metadata, { runId: attemptId }, owner).runId
2211
+ };
2119
2212
  },
2120
2213
  pauseActivity() {
2121
2214
  if (!currentActivity) {
@@ -2138,47 +2231,40 @@ function createTimebackActivityTracker(client) {
2138
2231
  }
2139
2232
  removePauseReason("manual");
2140
2233
  },
2234
+ async finishAssessmentTracking(expectedAttemptId) {
2235
+ if (!currentActivity || currentActivity.owner.kind !== "assessment" || currentActivity.owner.attemptId !== expectedAttemptId) {
2236
+ return;
2237
+ }
2238
+ return finishCurrentActivity(true);
2239
+ },
2141
2240
  async endActivity(rawData) {
2142
2241
  if (!currentActivity) {
2143
2242
  throw new Error("No activity in progress. Call startActivity() before endActivity().");
2144
2243
  }
2244
+ if (currentActivity.owner.kind === "assessment") {
2245
+ throw new Error(`endActivity() cannot finalize assessment ${currentActivity.owner.attemptId}. Submit it with timeback.assessments.submit() instead.`);
2246
+ }
2145
2247
  const data = jsonProjection(rawData);
2146
2248
  if (!relaysToParent() && typeof data.xpAwarded !== "number") {
2147
2249
  throw new Error("endActivity() requires xpAwarded when reporting directly. It is optional only in child mode, where the parent game decides the award.");
2148
2250
  }
2149
- const activity = currentActivity;
2150
- applyOverdueInactivity();
2151
- cleanupListeners();
2152
- await flushHeartbeat(true);
2153
- if (activity.pauseStartTime !== null) {
2154
- activity.pausedTime += Date.now() - activity.pauseStartTime;
2155
- activity.pauseStartTime = null;
2156
- }
2157
- const endTime = Date.now();
2158
- const totalElapsed = endTime - activity.startTime;
2159
- const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
2160
- const durationSeconds = Math.floor(activeTime / 1000);
2161
- const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
2162
- const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
2163
2251
  const { correctQuestions, totalQuestions } = data;
2164
2252
  if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
2165
2253
  throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
2166
2254
  }
2255
+ const tracking = await finishCurrentActivity(false);
2167
2256
  const request = {
2168
- runId: activity.runId,
2169
- resumeId: activity.resumeId,
2170
- activityData: activity.metadata,
2257
+ runId: tracking.runId,
2258
+ resumeId: tracking.resumeId,
2259
+ activityData: tracking.activityData,
2171
2260
  scoreData: {
2172
2261
  correctQuestions,
2173
2262
  totalQuestions
2174
2263
  },
2175
2264
  timingData: {
2176
- durationSeconds
2177
- },
2178
- sessionTimingData: {
2179
- activeSeconds: unreportedActiveMs / 1000,
2180
- ...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
2265
+ durationSeconds: tracking.durationSeconds
2181
2266
  },
2267
+ sessionTimingData: tracking.sessionTimingData,
2182
2268
  xpEarned: data.xpAwarded,
2183
2269
  masteredUnits: data.masteredUnits,
2184
2270
  masteredUnitsAbsolute: data.masteredUnitsAbsolute,
@@ -2186,23 +2272,9 @@ function createTimebackActivityTracker(client) {
2186
2272
  };
2187
2273
  if (relaysToParent()) {
2188
2274
  messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */, request);
2189
- if (currentActivity === activity) {
2190
- currentActivity = null;
2191
- }
2192
- return { status: "relayed", runId: activity.runId };
2193
- }
2194
- try {
2195
- const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
2196
- if (currentActivity === activity) {
2197
- currentActivity = null;
2198
- }
2199
- return response;
2200
- } catch (error) {
2201
- if (currentActivity === activity) {
2202
- currentActivity = null;
2203
- }
2204
- throw error;
2275
+ return { status: "relayed", runId: tracking.runId };
2205
2276
  }
2277
+ return client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
2206
2278
  }
2207
2279
  };
2208
2280
  }
@@ -2935,8 +3007,10 @@ function createTimebackEngine(client) {
2935
3007
  currentRunId: activityTracker.currentRunId,
2936
3008
  isManuallyPaused: activityTracker.isActivityManuallyPaused,
2937
3009
  start: activityTracker.startActivity,
3010
+ tryStartAssessment: activityTracker.tryStartAssessmentTracking,
2938
3011
  pause: activityTracker.pauseActivity,
2939
3012
  resume: activityTracker.resumeActivity,
3013
+ finishAssessment: activityTracker.finishAssessmentTracking,
2940
3014
  end: activityTracker.endActivity
2941
3015
  },
2942
3016
  course: {
@@ -2963,6 +3037,7 @@ function createTimebackEngine(client) {
2963
3037
  var VALID_XP_INCLUDE_OPTIONS = ["perCourse", "today"];
2964
3038
  var VALID_MASTERY_INCLUDE_OPTIONS = ["perCourse"];
2965
3039
  var ASSESSMENTS_ROUTE = TIMEBACK_ROUTES.ASSESSMENTS;
3040
+ var ASSESSMENT_SUBMIT_RETRY_POLICY = END_ACTIVITY_RETRY_POLICY;
2966
3041
  function validateAssessmentFilters(options) {
2967
3042
  if (!isAssessmentPurpose(options?.purpose)) {
2968
3043
  throw new Error("purpose must be end_of_course, diagnostic, review, or mastery");
@@ -2987,6 +3062,85 @@ function validateAssessmentFilters(options) {
2987
3062
  }
2988
3063
  function createTimebackNamespace(client) {
2989
3064
  const engine = createTimebackEngine(client);
3065
+ const pendingAssessmentSubmissions = new Map;
3066
+ const warnedAssessmentTrackingSkips = new Map;
3067
+ function clearAssessmentTrackingWarnings(attemptId) {
3068
+ warnedAssessmentTrackingSkips.delete(attemptId);
3069
+ }
3070
+ function warnAssessmentTrackingSkipped(attemptId, reason) {
3071
+ const warnedReasons = warnedAssessmentTrackingSkips.get(attemptId) ?? new Set;
3072
+ if (warnedReasons.has(reason)) {
3073
+ return;
3074
+ }
3075
+ warnedReasons.add(reason);
3076
+ warnedAssessmentTrackingSkips.set(attemptId, warnedReasons);
3077
+ const remedy = reason === "invalid-attempt-id" ? "The canonical attempt remains usable and submittable, but this sitting cannot be timed. Report the malformed id." : "The canonical attempt remains available; release the current clock owner, then call timeback.assessments.get(attemptId) to retry tracking.";
3078
+ console.warn(`[Playcademy SDK] timeback.assessments: active-time tracking was not attached to assessment ${attemptId} (${reason}). ${remedy}`);
3079
+ }
3080
+ function releasePendingSubmission(attemptId, submissionId) {
3081
+ if (pendingAssessmentSubmissions.get(attemptId)?.submissionId === submissionId) {
3082
+ pendingAssessmentSubmissions.delete(attemptId);
3083
+ }
3084
+ }
3085
+ function loadAttemptSnapshot(attemptId) {
3086
+ return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
3087
+ }
3088
+ async function syncAssessmentTracking(snapshot) {
3089
+ if (pendingAssessmentSubmissions.has(snapshot.attemptId)) {
3090
+ return snapshot;
3091
+ }
3092
+ if (snapshot.status === "completed") {
3093
+ await engine.activity.finishAssessment(snapshot.attemptId);
3094
+ clearAssessmentTrackingWarnings(snapshot.attemptId);
3095
+ return snapshot;
3096
+ }
3097
+ if (!snapshot.activityData) {
3098
+ return snapshot;
3099
+ }
3100
+ const attachment = engine.activity.tryStartAssessment(snapshot.activityData, snapshot.attemptId);
3101
+ if (attachment.status === "unavailable" || attachment.status === "untrackable") {
3102
+ warnAssessmentTrackingSkipped(snapshot.attemptId, attachment.reason);
3103
+ } else {
3104
+ clearAssessmentTrackingWarnings(snapshot.attemptId);
3105
+ }
3106
+ return snapshot;
3107
+ }
3108
+ async function resumeAfterDefinitiveFailure(attemptId, submissionId) {
3109
+ if (pendingAssessmentSubmissions.get(attemptId)?.submissionId !== submissionId) {
3110
+ return;
3111
+ }
3112
+ pendingAssessmentSubmissions.delete(attemptId);
3113
+ try {
3114
+ await syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
3115
+ } catch {}
3116
+ }
3117
+ async function assessmentSubmissionContext(attemptId, submissionId) {
3118
+ const pending = pendingAssessmentSubmissions.get(attemptId);
3119
+ if (pending) {
3120
+ if (pending.submissionId !== submissionId && !pending.detached) {
3121
+ throw new Error(`Assessment ${attemptId} already has a pending submission. Retry with the original submissionId.`);
3122
+ }
3123
+ pending.detached = false;
3124
+ return {
3125
+ submissionId: pending.submissionId,
3126
+ session: await pending.session
3127
+ };
3128
+ }
3129
+ const session = engine.activity.finishAssessment(attemptId).then((finished) => finished ? {
3130
+ runId: finished.runId,
3131
+ resumeId: finished.resumeId,
3132
+ ...finished.sessionTimingData
3133
+ } : undefined).catch((error) => {
3134
+ releasePendingSubmission(attemptId, submissionId);
3135
+ throw error;
3136
+ });
3137
+ pendingAssessmentSubmissions.set(attemptId, {
3138
+ submissionId,
3139
+ session,
3140
+ detached: false
3141
+ });
3142
+ return { submissionId, session: await session };
3143
+ }
2990
3144
  registerPauseProbe(client, () => engine.activity.isManuallyPaused());
2991
3145
  return {
2992
3146
  assessments: {
@@ -3015,7 +3169,8 @@ function createTimebackNamespace(client) {
3015
3169
  throw new Error(`itemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard}`);
3016
3170
  }
3017
3171
  }
3018
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3172
+ const snapshot = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3173
+ return syncAssessmentTracking(snapshot);
3019
3174
  },
3020
3175
  latest: async (options) => {
3021
3176
  assertPlatformMode(client, "timeback.assessments.latest()");
@@ -3038,7 +3193,20 @@ function createTimebackNamespace(client) {
3038
3193
  if (!attemptId?.trim()) {
3039
3194
  throw new Error("attemptId is required");
3040
3195
  }
3041
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
3196
+ return syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
3197
+ },
3198
+ stop: async (attemptId) => {
3199
+ assertPlatformMode(client, "timeback.assessments.stop()");
3200
+ if (!attemptId?.trim()) {
3201
+ throw new Error("attemptId is required");
3202
+ }
3203
+ const pending = pendingAssessmentSubmissions.get(attemptId);
3204
+ if (pending) {
3205
+ pending.detached = true;
3206
+ }
3207
+ await engine.activity.finishAssessment(attemptId);
3208
+ clearAssessmentTrackingWarnings(attemptId);
3209
+ return { attemptId };
3042
3210
  },
3043
3211
  save: async (attemptId, input) => {
3044
3212
  assertPlatformMode(client, "timeback.assessments.save()");
@@ -3077,7 +3245,25 @@ function createTimebackNamespace(client) {
3077
3245
  if (!input.submissionId?.trim()) {
3078
3246
  throw new Error("submissionId is required");
3079
3247
  }
3080
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", input);
3248
+ const { expectedResponseVersion } = input;
3249
+ const { submissionId, session } = await assessmentSubmissionContext(attemptId, input.submissionId);
3250
+ const request = {
3251
+ expectedResponseVersion,
3252
+ submissionId,
3253
+ ...session ? { session } : {}
3254
+ };
3255
+ try {
3256
+ const result = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", request, undefined, { retryPolicy: ASSESSMENT_SUBMIT_RETRY_POLICY });
3257
+ releasePendingSubmission(attemptId, submissionId);
3258
+ await engine.activity.finishAssessment(attemptId);
3259
+ clearAssessmentTrackingWarnings(attemptId);
3260
+ return result;
3261
+ } catch (error) {
3262
+ if (error instanceof ApiError && !error.isRetryable()) {
3263
+ await resumeAfterDefinitiveFailure(attemptId, submissionId);
3264
+ }
3265
+ throw error;
3266
+ }
3081
3267
  }
3082
3268
  },
3083
3269
  get user() {
@@ -3484,7 +3670,7 @@ async function request({
3484
3670
  return rawText && rawText.length > 0 ? rawText : undefined;
3485
3671
  }
3486
3672
  // src/version.ts
3487
- var SDK_VERSION = "0.16.1-beta.14";
3673
+ var SDK_VERSION = "0.16.1-beta.16";
3488
3674
 
3489
3675
  // src/clients/base.ts
3490
3676
  class PlaycademyBaseClient {
package/dist/internal.js CHANGED
@@ -1730,6 +1730,29 @@ function createTimebackActivityTracker(client) {
1730
1730
  const resumeRunId = client["initPayload"]?.parent?.resumeRunId;
1731
1731
  return typeof resumeRunId === "string" && isValidUUID(resumeRunId) ? resumeRunId : undefined;
1732
1732
  }
1733
+ function clockAvailability(owner) {
1734
+ if (owner.kind === "assessment" && currentActivity?.owner.kind === "assessment" && currentActivity.owner.attemptId === owner.attemptId) {
1735
+ return "held-by-this-owner";
1736
+ }
1737
+ if (hasLiveSession(client)) {
1738
+ return { unavailable: "embedded-child" };
1739
+ }
1740
+ if (!currentActivity) {
1741
+ return "available";
1742
+ }
1743
+ if (owner.kind === "activity") {
1744
+ return currentActivity.owner.kind === "assessment" ? {
1745
+ unavailable: "other-assessment",
1746
+ runId: currentActivity.runId,
1747
+ attemptId: currentActivity.owner.attemptId
1748
+ } : "available";
1749
+ }
1750
+ return currentActivity.owner.kind === "assessment" ? {
1751
+ unavailable: "other-assessment",
1752
+ runId: currentActivity.runId,
1753
+ attemptId: currentActivity.owner.attemptId
1754
+ } : { unavailable: "ordinary-activity", runId: currentActivity.runId };
1755
+ }
1733
1756
  function startHeartbeatInterval(activity) {
1734
1757
  if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
1735
1758
  return;
@@ -2041,6 +2064,110 @@ function createTimebackActivityTracker(client) {
2041
2064
  }
2042
2065
  stopRelayInterval();
2043
2066
  }
2067
+ async function finishCurrentActivity(discardRelayedTiming) {
2068
+ const activity = currentActivity;
2069
+ if (activity.finishInFlight) {
2070
+ return activity.finishInFlight;
2071
+ }
2072
+ activity.finishInFlight = (async () => {
2073
+ applyOverdueInactivity();
2074
+ cleanupListeners();
2075
+ const relayedWithoutReconciliation = discardRelayedTiming && relaysToParent();
2076
+ await flushHeartbeat(true);
2077
+ if (activity.pauseStartTime !== null) {
2078
+ activity.pausedTime += Date.now() - activity.pauseStartTime;
2079
+ activity.pauseStartTime = null;
2080
+ }
2081
+ const endTime = Date.now();
2082
+ const totalElapsed = endTime - activity.startTime;
2083
+ const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
2084
+ const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
2085
+ const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
2086
+ if (currentActivity === activity) {
2087
+ currentActivity = null;
2088
+ }
2089
+ return {
2090
+ runId: activity.runId,
2091
+ resumeId: activity.resumeId,
2092
+ activityData: activity.metadata,
2093
+ durationSeconds: Math.floor(activeTime / 1000),
2094
+ sessionTimingData: relayedWithoutReconciliation ? { activeSeconds: 0 } : {
2095
+ activeSeconds: unreportedActiveMs / 1000,
2096
+ ...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
2097
+ }
2098
+ };
2099
+ })();
2100
+ return activity.finishInFlight;
2101
+ }
2102
+ function assertValidRunId(options) {
2103
+ if (options?.runId !== undefined && !isValidUUID(options.runId)) {
2104
+ throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
2105
+ }
2106
+ }
2107
+ function beginActivity(rawMetadata, options, owner) {
2108
+ const metadata = jsonProjection(rawMetadata);
2109
+ cleanupListeners();
2110
+ const now = Date.now();
2111
+ const runId = owner.kind === "assessment" ? owner.attemptId : options?.runId ?? adoptResumedRunId() ?? crypto.randomUUID();
2112
+ const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
2113
+ const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
2114
+ const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
2115
+ currentActivity = {
2116
+ runId,
2117
+ resumeId: crypto.randomUUID(),
2118
+ owner,
2119
+ startTime: now,
2120
+ metadata,
2121
+ pausedTime: 0,
2122
+ pauseStartTime: null,
2123
+ pauseReasons: new Set,
2124
+ pausedHeartbeatTimeoutId: null,
2125
+ pausedHeartbeatTimedOut: false,
2126
+ pausedHeartbeatTimeoutMs,
2127
+ windowStartTime: now,
2128
+ windowPausedAtStart: 0,
2129
+ heartbeatIntervalId: null,
2130
+ heartbeatIntervalMs,
2131
+ inactivityTimeoutId: null,
2132
+ inactivityTimeoutMs,
2133
+ inactivityTimerStartedAt: null,
2134
+ remainingInactivityMs: inactivityTimeoutMs,
2135
+ flushInFlight: null,
2136
+ finishInFlight: null,
2137
+ totalPersistedActiveMs: 0,
2138
+ totalPersistedPausedMs: 0
2139
+ };
2140
+ if (typeof document !== "undefined") {
2141
+ boundVisibilityHandler = handleVisibilityChange;
2142
+ document.addEventListener("visibilitychange", boundVisibilityHandler);
2143
+ boundUserInteractionHandler = handleUserInteraction;
2144
+ for (const eventName of USER_ACTIVITY_EVENTS) {
2145
+ document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
2146
+ }
2147
+ if (document.visibilityState === "hidden") {
2148
+ handleVisibilityChange();
2149
+ }
2150
+ }
2151
+ startHeartbeatInterval(currentActivity);
2152
+ startRelayInterval();
2153
+ if (typeof globalThis.window !== "undefined") {
2154
+ boundPageHideHandler = handlePageHide;
2155
+ globalThis.window.addEventListener("pagehide", boundPageHideHandler);
2156
+ }
2157
+ boundShellPauseHandler = handleShellPause;
2158
+ boundShellResumeHandler = handleShellResume;
2159
+ messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
2160
+ messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
2161
+ syncInactivityTracking();
2162
+ if (relaysToParent()) {
2163
+ messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
2164
+ runId,
2165
+ resumeId: currentActivity.resumeId,
2166
+ activityData: metadata
2167
+ });
2168
+ }
2169
+ return { runId };
2170
+ }
2044
2171
  return {
2045
2172
  currentRunId() {
2046
2173
  return currentActivity?.runId;
@@ -2048,74 +2175,40 @@ function createTimebackActivityTracker(client) {
2048
2175
  isActivityManuallyPaused() {
2049
2176
  return currentActivity?.pauseReasons.has("manual") ?? false;
2050
2177
  },
2051
- startActivity(rawMetadata, options) {
2052
- if (hasLiveSession(client)) {
2178
+ startActivity(rawMetadata, options, owner = { kind: "activity" }) {
2179
+ const availability = clockAvailability(owner);
2180
+ if (typeof availability === "object" && availability.unavailable === "embedded-child") {
2053
2181
  throw new PlaycademyError("startActivity() is unavailable while an embedded child session is live: the child owns the clock, and a second clock would double-count the lesson. Await the session's finished (or close() it) first.");
2054
2182
  }
2055
- if (options?.runId !== undefined && !isValidUUID(options.runId)) {
2056
- throw new Error(`startActivity: \`runId\` must be a UUID (received \`${JSON.stringify(options.runId)}\`). Use crypto.randomUUID() or persist a previously-generated UUID.`);
2183
+ assertValidRunId(options);
2184
+ if (availability === "held-by-this-owner") {
2185
+ return { runId: currentActivity.runId };
2057
2186
  }
2058
- const metadata = jsonProjection(rawMetadata);
2059
- cleanupListeners();
2060
- const now = Date.now();
2061
- const resumedRunId = adoptResumedRunId();
2062
- const runId = options?.runId ?? resumedRunId ?? crypto.randomUUID();
2063
- const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
2064
- const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
2065
- const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
2066
- currentActivity = {
2067
- runId,
2068
- resumeId: crypto.randomUUID(),
2069
- startTime: now,
2070
- metadata,
2071
- pausedTime: 0,
2072
- pauseStartTime: null,
2073
- pauseReasons: new Set,
2074
- pausedHeartbeatTimeoutId: null,
2075
- pausedHeartbeatTimedOut: false,
2076
- pausedHeartbeatTimeoutMs,
2077
- windowStartTime: now,
2078
- windowPausedAtStart: 0,
2079
- heartbeatIntervalId: null,
2080
- heartbeatIntervalMs,
2081
- inactivityTimeoutId: null,
2082
- inactivityTimeoutMs,
2083
- inactivityTimerStartedAt: null,
2084
- remainingInactivityMs: inactivityTimeoutMs,
2085
- flushInFlight: null,
2086
- totalPersistedActiveMs: 0,
2087
- totalPersistedPausedMs: 0
2088
- };
2089
- if (typeof document !== "undefined") {
2090
- boundVisibilityHandler = handleVisibilityChange;
2091
- document.addEventListener("visibilitychange", boundVisibilityHandler);
2092
- boundUserInteractionHandler = handleUserInteraction;
2093
- for (const eventName of USER_ACTIVITY_EVENTS) {
2094
- document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
2095
- }
2096
- if (document.visibilityState === "hidden") {
2097
- handleVisibilityChange();
2187
+ if (typeof availability === "object") {
2188
+ if (owner.kind === "assessment") {
2189
+ throw new Error(`Cannot track assessment ${owner.attemptId} while activity run ${availability.runId} is active. End the current activity first.`);
2098
2190
  }
2191
+ const blockingAssessment = availability;
2192
+ throw new Error(`Cannot start an activity while assessment ${blockingAssessment.attemptId} is in progress. Submit the assessment first.`);
2099
2193
  }
2100
- startHeartbeatInterval(currentActivity);
2101
- startRelayInterval();
2102
- if (typeof globalThis.window !== "undefined") {
2103
- boundPageHideHandler = handlePageHide;
2104
- globalThis.window.addEventListener("pagehide", boundPageHideHandler);
2194
+ return beginActivity(rawMetadata, options, owner);
2195
+ },
2196
+ tryStartAssessmentTracking(metadata, attemptId) {
2197
+ if (!isValidUUID(attemptId)) {
2198
+ return { status: "untrackable", reason: "invalid-attempt-id" };
2105
2199
  }
2106
- boundShellPauseHandler = handleShellPause;
2107
- boundShellResumeHandler = handleShellResume;
2108
- messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
2109
- messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
2110
- syncInactivityTracking();
2111
- if (relaysToParent()) {
2112
- messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_START" /* TIMEBACK_ACTIVITY_START */, {
2113
- runId,
2114
- resumeId: currentActivity.resumeId,
2115
- activityData: metadata
2116
- });
2200
+ const owner = { kind: "assessment", attemptId };
2201
+ const availability = clockAvailability(owner);
2202
+ if (typeof availability === "object") {
2203
+ return { status: "unavailable", reason: availability.unavailable };
2204
+ }
2205
+ if (availability === "held-by-this-owner") {
2206
+ return { status: "already-attached", runId: currentActivity.runId };
2117
2207
  }
2118
- return { runId };
2208
+ return {
2209
+ status: "attached",
2210
+ runId: beginActivity(metadata, { runId: attemptId }, owner).runId
2211
+ };
2119
2212
  },
2120
2213
  pauseActivity() {
2121
2214
  if (!currentActivity) {
@@ -2138,47 +2231,40 @@ function createTimebackActivityTracker(client) {
2138
2231
  }
2139
2232
  removePauseReason("manual");
2140
2233
  },
2234
+ async finishAssessmentTracking(expectedAttemptId) {
2235
+ if (!currentActivity || currentActivity.owner.kind !== "assessment" || currentActivity.owner.attemptId !== expectedAttemptId) {
2236
+ return;
2237
+ }
2238
+ return finishCurrentActivity(true);
2239
+ },
2141
2240
  async endActivity(rawData) {
2142
2241
  if (!currentActivity) {
2143
2242
  throw new Error("No activity in progress. Call startActivity() before endActivity().");
2144
2243
  }
2244
+ if (currentActivity.owner.kind === "assessment") {
2245
+ throw new Error(`endActivity() cannot finalize assessment ${currentActivity.owner.attemptId}. Submit it with timeback.assessments.submit() instead.`);
2246
+ }
2145
2247
  const data = jsonProjection(rawData);
2146
2248
  if (!relaysToParent() && typeof data.xpAwarded !== "number") {
2147
2249
  throw new Error("endActivity() requires xpAwarded when reporting directly. It is optional only in child mode, where the parent game decides the award.");
2148
2250
  }
2149
- const activity = currentActivity;
2150
- applyOverdueInactivity();
2151
- cleanupListeners();
2152
- await flushHeartbeat(true);
2153
- if (activity.pauseStartTime !== null) {
2154
- activity.pausedTime += Date.now() - activity.pauseStartTime;
2155
- activity.pauseStartTime = null;
2156
- }
2157
- const endTime = Date.now();
2158
- const totalElapsed = endTime - activity.startTime;
2159
- const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
2160
- const durationSeconds = Math.floor(activeTime / 1000);
2161
- const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
2162
- const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
2163
2251
  const { correctQuestions, totalQuestions } = data;
2164
2252
  if (data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined) {
2165
2253
  throw new Error("Cannot provide both masteredUnits and masteredUnitsAbsolute — use one or the other");
2166
2254
  }
2255
+ const tracking = await finishCurrentActivity(false);
2167
2256
  const request = {
2168
- runId: activity.runId,
2169
- resumeId: activity.resumeId,
2170
- activityData: activity.metadata,
2257
+ runId: tracking.runId,
2258
+ resumeId: tracking.resumeId,
2259
+ activityData: tracking.activityData,
2171
2260
  scoreData: {
2172
2261
  correctQuestions,
2173
2262
  totalQuestions
2174
2263
  },
2175
2264
  timingData: {
2176
- durationSeconds
2177
- },
2178
- sessionTimingData: {
2179
- activeSeconds: unreportedActiveMs / 1000,
2180
- ...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
2265
+ durationSeconds: tracking.durationSeconds
2181
2266
  },
2267
+ sessionTimingData: tracking.sessionTimingData,
2182
2268
  xpEarned: data.xpAwarded,
2183
2269
  masteredUnits: data.masteredUnits,
2184
2270
  masteredUnitsAbsolute: data.masteredUnitsAbsolute,
@@ -2186,23 +2272,9 @@ function createTimebackActivityTracker(client) {
2186
2272
  };
2187
2273
  if (relaysToParent()) {
2188
2274
  messaging.send("PLAYCADEMY_TIMEBACK_ACTIVITY_END" /* TIMEBACK_ACTIVITY_END */, request);
2189
- if (currentActivity === activity) {
2190
- currentActivity = null;
2191
- }
2192
- return { status: "relayed", runId: activity.runId };
2193
- }
2194
- try {
2195
- const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
2196
- if (currentActivity === activity) {
2197
- currentActivity = null;
2198
- }
2199
- return response;
2200
- } catch (error) {
2201
- if (currentActivity === activity) {
2202
- currentActivity = null;
2203
- }
2204
- throw error;
2275
+ return { status: "relayed", runId: tracking.runId };
2205
2276
  }
2277
+ return client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request, undefined, { retryPolicy: END_ACTIVITY_RETRY_POLICY });
2206
2278
  }
2207
2279
  };
2208
2280
  }
@@ -2935,8 +3007,10 @@ function createTimebackEngine(client) {
2935
3007
  currentRunId: activityTracker.currentRunId,
2936
3008
  isManuallyPaused: activityTracker.isActivityManuallyPaused,
2937
3009
  start: activityTracker.startActivity,
3010
+ tryStartAssessment: activityTracker.tryStartAssessmentTracking,
2938
3011
  pause: activityTracker.pauseActivity,
2939
3012
  resume: activityTracker.resumeActivity,
3013
+ finishAssessment: activityTracker.finishAssessmentTracking,
2940
3014
  end: activityTracker.endActivity
2941
3015
  },
2942
3016
  course: {
@@ -2963,6 +3037,7 @@ function createTimebackEngine(client) {
2963
3037
  var VALID_XP_INCLUDE_OPTIONS = ["perCourse", "today"];
2964
3038
  var VALID_MASTERY_INCLUDE_OPTIONS = ["perCourse"];
2965
3039
  var ASSESSMENTS_ROUTE = TIMEBACK_ROUTES.ASSESSMENTS;
3040
+ var ASSESSMENT_SUBMIT_RETRY_POLICY = END_ACTIVITY_RETRY_POLICY;
2966
3041
  function validateAssessmentFilters(options) {
2967
3042
  if (!isAssessmentPurpose(options?.purpose)) {
2968
3043
  throw new Error("purpose must be end_of_course, diagnostic, review, or mastery");
@@ -2987,6 +3062,85 @@ function validateAssessmentFilters(options) {
2987
3062
  }
2988
3063
  function createTimebackNamespace(client) {
2989
3064
  const engine = createTimebackEngine(client);
3065
+ const pendingAssessmentSubmissions = new Map;
3066
+ const warnedAssessmentTrackingSkips = new Map;
3067
+ function clearAssessmentTrackingWarnings(attemptId) {
3068
+ warnedAssessmentTrackingSkips.delete(attemptId);
3069
+ }
3070
+ function warnAssessmentTrackingSkipped(attemptId, reason) {
3071
+ const warnedReasons = warnedAssessmentTrackingSkips.get(attemptId) ?? new Set;
3072
+ if (warnedReasons.has(reason)) {
3073
+ return;
3074
+ }
3075
+ warnedReasons.add(reason);
3076
+ warnedAssessmentTrackingSkips.set(attemptId, warnedReasons);
3077
+ const remedy = reason === "invalid-attempt-id" ? "The canonical attempt remains usable and submittable, but this sitting cannot be timed. Report the malformed id." : "The canonical attempt remains available; release the current clock owner, then call timeback.assessments.get(attemptId) to retry tracking.";
3078
+ console.warn(`[Playcademy SDK] timeback.assessments: active-time tracking was not attached to assessment ${attemptId} (${reason}). ${remedy}`);
3079
+ }
3080
+ function releasePendingSubmission(attemptId, submissionId) {
3081
+ if (pendingAssessmentSubmissions.get(attemptId)?.submissionId === submissionId) {
3082
+ pendingAssessmentSubmissions.delete(attemptId);
3083
+ }
3084
+ }
3085
+ function loadAttemptSnapshot(attemptId) {
3086
+ return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
3087
+ }
3088
+ async function syncAssessmentTracking(snapshot) {
3089
+ if (pendingAssessmentSubmissions.has(snapshot.attemptId)) {
3090
+ return snapshot;
3091
+ }
3092
+ if (snapshot.status === "completed") {
3093
+ await engine.activity.finishAssessment(snapshot.attemptId);
3094
+ clearAssessmentTrackingWarnings(snapshot.attemptId);
3095
+ return snapshot;
3096
+ }
3097
+ if (!snapshot.activityData) {
3098
+ return snapshot;
3099
+ }
3100
+ const attachment = engine.activity.tryStartAssessment(snapshot.activityData, snapshot.attemptId);
3101
+ if (attachment.status === "unavailable" || attachment.status === "untrackable") {
3102
+ warnAssessmentTrackingSkipped(snapshot.attemptId, attachment.reason);
3103
+ } else {
3104
+ clearAssessmentTrackingWarnings(snapshot.attemptId);
3105
+ }
3106
+ return snapshot;
3107
+ }
3108
+ async function resumeAfterDefinitiveFailure(attemptId, submissionId) {
3109
+ if (pendingAssessmentSubmissions.get(attemptId)?.submissionId !== submissionId) {
3110
+ return;
3111
+ }
3112
+ pendingAssessmentSubmissions.delete(attemptId);
3113
+ try {
3114
+ await syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
3115
+ } catch {}
3116
+ }
3117
+ async function assessmentSubmissionContext(attemptId, submissionId) {
3118
+ const pending = pendingAssessmentSubmissions.get(attemptId);
3119
+ if (pending) {
3120
+ if (pending.submissionId !== submissionId && !pending.detached) {
3121
+ throw new Error(`Assessment ${attemptId} already has a pending submission. Retry with the original submissionId.`);
3122
+ }
3123
+ pending.detached = false;
3124
+ return {
3125
+ submissionId: pending.submissionId,
3126
+ session: await pending.session
3127
+ };
3128
+ }
3129
+ const session = engine.activity.finishAssessment(attemptId).then((finished) => finished ? {
3130
+ runId: finished.runId,
3131
+ resumeId: finished.resumeId,
3132
+ ...finished.sessionTimingData
3133
+ } : undefined).catch((error) => {
3134
+ releasePendingSubmission(attemptId, submissionId);
3135
+ throw error;
3136
+ });
3137
+ pendingAssessmentSubmissions.set(attemptId, {
3138
+ submissionId,
3139
+ session,
3140
+ detached: false
3141
+ });
3142
+ return { submissionId, session: await session };
3143
+ }
2990
3144
  registerPauseProbe(client, () => engine.activity.isManuallyPaused());
2991
3145
  return {
2992
3146
  assessments: {
@@ -3015,7 +3169,8 @@ function createTimebackNamespace(client) {
3015
3169
  throw new Error(`itemsPerStandard must be a positive integer at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.itemsPerStandard}`);
3016
3170
  }
3017
3171
  }
3018
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3172
+ const snapshot = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/start`, "POST", input);
3173
+ return syncAssessmentTracking(snapshot);
3019
3174
  },
3020
3175
  latest: async (options) => {
3021
3176
  assertPlatformMode(client, "timeback.assessments.latest()");
@@ -3038,7 +3193,20 @@ function createTimebackNamespace(client) {
3038
3193
  if (!attemptId?.trim()) {
3039
3194
  throw new Error("attemptId is required");
3040
3195
  }
3041
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}`, "GET");
3196
+ return syncAssessmentTracking(await loadAttemptSnapshot(attemptId));
3197
+ },
3198
+ stop: async (attemptId) => {
3199
+ assertPlatformMode(client, "timeback.assessments.stop()");
3200
+ if (!attemptId?.trim()) {
3201
+ throw new Error("attemptId is required");
3202
+ }
3203
+ const pending = pendingAssessmentSubmissions.get(attemptId);
3204
+ if (pending) {
3205
+ pending.detached = true;
3206
+ }
3207
+ await engine.activity.finishAssessment(attemptId);
3208
+ clearAssessmentTrackingWarnings(attemptId);
3209
+ return { attemptId };
3042
3210
  },
3043
3211
  save: async (attemptId, input) => {
3044
3212
  assertPlatformMode(client, "timeback.assessments.save()");
@@ -3077,7 +3245,25 @@ function createTimebackNamespace(client) {
3077
3245
  if (!input.submissionId?.trim()) {
3078
3246
  throw new Error("submissionId is required");
3079
3247
  }
3080
- return client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", input);
3248
+ const { expectedResponseVersion } = input;
3249
+ const { submissionId, session } = await assessmentSubmissionContext(attemptId, input.submissionId);
3250
+ const request = {
3251
+ expectedResponseVersion,
3252
+ submissionId,
3253
+ ...session ? { session } : {}
3254
+ };
3255
+ try {
3256
+ const result = await client["requestGameBackend"](`${ASSESSMENTS_ROUTE}/${encodeURIComponent(attemptId)}/submit`, "POST", request, undefined, { retryPolicy: ASSESSMENT_SUBMIT_RETRY_POLICY });
3257
+ releasePendingSubmission(attemptId, submissionId);
3258
+ await engine.activity.finishAssessment(attemptId);
3259
+ clearAssessmentTrackingWarnings(attemptId);
3260
+ return result;
3261
+ } catch (error) {
3262
+ if (error instanceof ApiError && !error.isRetryable()) {
3263
+ await resumeAfterDefinitiveFailure(attemptId, submissionId);
3264
+ }
3265
+ throw error;
3266
+ }
3081
3267
  }
3082
3268
  },
3083
3269
  get user() {
@@ -4360,7 +4546,7 @@ async function request({
4360
4546
  return rawText && rawText.length > 0 ? rawText : undefined;
4361
4547
  }
4362
4548
  // src/version.ts
4363
- var SDK_VERSION = "0.16.1-beta.14";
4549
+ var SDK_VERSION = "0.16.1-beta.16";
4364
4550
 
4365
4551
  // src/clients/base.ts
4366
4552
  class PlaycademyBaseClient {
@@ -375,7 +375,7 @@ declare class PlaycademyClient {
375
375
  get: (studentId: string, attemptId: string) => Promise<_playcademy_types.AssessmentAttemptSnapshot>;
376
376
  save: (studentId: string, attemptId: string, input: _playcademy_types.SaveAssessmentInput) => Promise<_playcademy_types.AssessmentSaveResult>;
377
377
  submitItem: (studentId: string, attemptId: string, input: _playcademy_types.SubmitAssessmentItemInput) => Promise<_playcademy_types.SubmitAssessmentItemResult>;
378
- submit: (studentId: string, attemptId: string, input: _playcademy_types.SubmitAssessmentInput, context: {
378
+ submit: (studentId: string, attemptId: string, input: _playcademy_types.SubmitAssessmentRequest, context: {
379
379
  sensorUrl: string;
380
380
  }) => Promise<_playcademy_types.AssessmentSubmitResult>;
381
381
  };
@@ -313,7 +313,7 @@ function extractApiErrorInfo(error) {
313
313
  }
314
314
 
315
315
  // src/version.ts
316
- var SDK_VERSION = "0.16.1-beta.14";
316
+ var SDK_VERSION = "0.16.1-beta.16";
317
317
 
318
318
  // src/server/request.ts
319
319
  async function makeApiRequest(opts) {
package/dist/server.d.ts CHANGED
@@ -375,7 +375,7 @@ declare class PlaycademyClient$1 {
375
375
  get: (studentId: string, attemptId: string) => Promise<_playcademy_types.AssessmentAttemptSnapshot>;
376
376
  save: (studentId: string, attemptId: string, input: _playcademy_types.SaveAssessmentInput) => Promise<_playcademy_types.AssessmentSaveResult>;
377
377
  submitItem: (studentId: string, attemptId: string, input: _playcademy_types.SubmitAssessmentItemInput) => Promise<_playcademy_types.SubmitAssessmentItemResult>;
378
- submit: (studentId: string, attemptId: string, input: _playcademy_types.SubmitAssessmentInput, context: {
378
+ submit: (studentId: string, attemptId: string, input: _playcademy_types.SubmitAssessmentRequest, context: {
379
379
  sensorUrl: string;
380
380
  }) => Promise<_playcademy_types.AssessmentSubmitResult>;
381
381
  };
package/dist/server.js CHANGED
@@ -502,7 +502,7 @@ function extractApiErrorInfo(error) {
502
502
  }
503
503
 
504
504
  // src/version.ts
505
- var SDK_VERSION = "0.16.1-beta.14";
505
+ var SDK_VERSION = "0.16.1-beta.16";
506
506
 
507
507
  // src/server/request.ts
508
508
  async function makeApiRequest(opts) {
package/dist/types.d.ts CHANGED
@@ -2014,6 +2014,9 @@ declare class PlaycademyClient extends PlaycademyBaseClient {
2014
2014
  start: (input: _playcademy_types.StartAssessmentInput) => Promise<_playcademy_types.AssessmentAttemptSnapshot>;
2015
2015
  latest: (options: _playcademy_types.GetLatestAssessmentOptions) => Promise<_playcademy_types.LatestAssessmentResult | null>;
2016
2016
  get: (attemptId: string) => Promise<_playcademy_types.AssessmentAttemptSnapshot>;
2017
+ stop: (attemptId: string) => Promise<{
2018
+ attemptId: string;
2019
+ }>;
2017
2020
  save: (attemptId: string, input: _playcademy_types.SaveAssessmentInput) => Promise<_playcademy_types.AssessmentSaveResult>;
2018
2021
  submitItem: (attemptId: string, input: _playcademy_types.SubmitAssessmentItemInput) => Promise<_playcademy_types.SubmitAssessmentItemResult>;
2019
2022
  submit: (attemptId: string, input: _playcademy_types.SubmitAssessmentInput) => Promise<_playcademy_types.AssessmentSubmitResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sdk",
3
- "version": "0.16.1-beta.14",
3
+ "version": "0.16.1-beta.16",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {