@playcademy/sandbox 0.3.16-beta.3 → 0.3.16-beta.5

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.
Files changed (3) hide show
  1. package/dist/cli.js +320 -47
  2. package/dist/server.js +320 -47
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1310,7 +1310,7 @@ var package_default;
1310
1310
  var init_package = __esm(() => {
1311
1311
  package_default = {
1312
1312
  name: "@playcademy/sandbox",
1313
- version: "0.3.16-beta.3",
1313
+ version: "0.3.16-beta.5",
1314
1314
  description: "Local development server for Playcademy game development",
1315
1315
  type: "module",
1316
1316
  exports: {
@@ -26650,9 +26650,37 @@ var init_developer_service = __esm(() => {
26650
26650
  // ../api-core/src/services/game.service.ts
26651
26651
  class GameService {
26652
26652
  deps;
26653
+ static MANIFEST_FETCH_TIMEOUT_MS = 5000;
26654
+ static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
26653
26655
  constructor(deps) {
26654
26656
  this.deps = deps;
26655
26657
  }
26658
+ static getManifestHost(manifestUrl) {
26659
+ try {
26660
+ return new URL(manifestUrl).host;
26661
+ } catch {
26662
+ return manifestUrl;
26663
+ }
26664
+ }
26665
+ static getFetchErrorMessage(error) {
26666
+ let raw;
26667
+ if (error instanceof Error) {
26668
+ raw = error.message;
26669
+ } else if (typeof error === "string") {
26670
+ raw = error;
26671
+ }
26672
+ if (!raw) {
26673
+ return;
26674
+ }
26675
+ const normalized = raw.replace(/\s+/g, " ").trim();
26676
+ if (!normalized) {
26677
+ return;
26678
+ }
26679
+ return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
26680
+ }
26681
+ static isRetryableStatus(status) {
26682
+ return status === 429 || status >= 500;
26683
+ }
26656
26684
  async list(caller) {
26657
26685
  const db2 = this.deps.db;
26658
26686
  const isAdmin = caller?.role === "admin";
@@ -26714,6 +26742,109 @@ class GameService {
26714
26742
  this.enforceVisibility(game, caller, slug);
26715
26743
  return game;
26716
26744
  }
26745
+ async getManifest(gameId, caller) {
26746
+ const game = await this.getById(gameId, caller);
26747
+ if (game.gameType !== "hosted" || !game.deploymentUrl) {
26748
+ throw new BadRequestError("Game does not have a deployment manifest");
26749
+ }
26750
+ const deploymentUrl = game.deploymentUrl;
26751
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
26752
+ const manifestHost = GameService.getManifestHost(manifestUrl);
26753
+ const startedAt = Date.now();
26754
+ const controller = new AbortController;
26755
+ const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
26756
+ function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
26757
+ return {
26758
+ manifestUrl,
26759
+ manifestHost,
26760
+ deploymentUrl,
26761
+ fetchOutcome,
26762
+ retryCount: 0,
26763
+ durationMs: Date.now() - startedAt,
26764
+ manifestErrorKind,
26765
+ ...extra
26766
+ };
26767
+ }
26768
+ let response;
26769
+ try {
26770
+ response = await fetch(manifestUrl, {
26771
+ method: "GET",
26772
+ headers: {
26773
+ Accept: "application/json"
26774
+ },
26775
+ signal: controller.signal
26776
+ });
26777
+ } catch (error) {
26778
+ clearTimeout(timeout);
26779
+ const fetchErrorMessage = GameService.getFetchErrorMessage(error);
26780
+ const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
26781
+ logger5.error("Failed to fetch game manifest", {
26782
+ gameId,
26783
+ manifestUrl,
26784
+ error,
26785
+ details
26786
+ });
26787
+ if (error instanceof Error && error.name === "AbortError") {
26788
+ throw new TimeoutError("Timed out loading game manifest", details);
26789
+ }
26790
+ throw new ServiceUnavailableError("Failed to load game manifest", details);
26791
+ } finally {
26792
+ clearTimeout(timeout);
26793
+ }
26794
+ if (!response.ok) {
26795
+ const resolvedManifestUrl = response.url || manifestUrl;
26796
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26797
+ const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
26798
+ const details = buildDetails("bad_status", manifestErrorKind, {
26799
+ manifestUrl: resolvedManifestUrl,
26800
+ manifestHost: resolvedManifestHost,
26801
+ status: response.status,
26802
+ contentType: response.headers.get("content-type") ?? undefined,
26803
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26804
+ redirected: response.redirected,
26805
+ ...response.redirected ? {
26806
+ originalManifestUrl: manifestUrl,
26807
+ originalManifestHost: manifestHost
26808
+ } : {}
26809
+ });
26810
+ const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
26811
+ logger5.error("Game manifest returned non-ok response", {
26812
+ gameId,
26813
+ manifestUrl,
26814
+ status: response.status,
26815
+ details
26816
+ });
26817
+ if (manifestErrorKind === "temporary") {
26818
+ throw new ServiceUnavailableError(message, details);
26819
+ }
26820
+ throw new BadRequestError(message, details);
26821
+ }
26822
+ try {
26823
+ return await response.json();
26824
+ } catch (error) {
26825
+ const resolvedManifestUrl = response.url || manifestUrl;
26826
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26827
+ const details = buildDetails("invalid_body", "permanent", {
26828
+ manifestUrl: resolvedManifestUrl,
26829
+ manifestHost: resolvedManifestHost,
26830
+ status: response.status,
26831
+ contentType: response.headers.get("content-type") ?? undefined,
26832
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26833
+ redirected: response.redirected,
26834
+ ...response.redirected ? {
26835
+ originalManifestUrl: manifestUrl,
26836
+ originalManifestHost: manifestHost
26837
+ } : {}
26838
+ });
26839
+ logger5.error("Failed to parse game manifest", {
26840
+ gameId,
26841
+ manifestUrl,
26842
+ error,
26843
+ details
26844
+ });
26845
+ throw new BadRequestError("Failed to parse game manifest", details);
26846
+ }
26847
+ }
26717
26848
  enforceVisibility(game, caller, lookupIdentifier) {
26718
26849
  if (game.visibility !== "internal") {
26719
26850
  return;
@@ -30207,16 +30338,7 @@ function mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, cour
30207
30338
  return null;
30208
30339
  }
30209
30340
  if (isMasteryCompletionEntry(assessment)) {
30210
- const metadata3 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30211
- const isResume = Boolean(metadata3?.resumedAt);
30212
- return {
30213
- id: assessment.sourcedId || `${assessment.assessmentLineItem.sourcedId}:${assessment.scoreDate}`,
30214
- kind: "admin-completion",
30215
- occurredAt: assessment.scoreDate,
30216
- courseId,
30217
- title: isResume ? "Course resumed" : "Course marked complete",
30218
- reason: metadata3?.adminAction ? "Admin action" : undefined
30219
- };
30341
+ return null;
30220
30342
  }
30221
30343
  const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30222
30344
  const activityName = getStringValue(metadata2?.activityName);
@@ -30249,6 +30371,7 @@ function parseCaliperEventContext(event, relevantCourseIds) {
30249
30371
  courseId,
30250
30372
  occurredAt,
30251
30373
  eventKind: getStringValue(playcademy?.eventKind),
30374
+ source: getStringValue(playcademy?.source),
30252
30375
  reason: getStringValue(playcademy?.reason),
30253
30376
  titleFromEvent: getStringValue(event.object.activity?.name),
30254
30377
  appName: getStringValue(event.object.app?.name),
@@ -30272,30 +30395,59 @@ function mapTimeSpentRemediation(event, ctx) {
30272
30395
  };
30273
30396
  }
30274
30397
  function mapActivityRemediation(event, ctx) {
30275
- let kind;
30276
30398
  if (ctx.eventKind === "remediation-xp") {
30277
- kind = "remediation-xp";
30278
- } else if (ctx.eventKind === "remediation-mastery") {
30279
- kind = "remediation-mastery";
30280
- } else {
30281
- return null;
30399
+ return {
30400
+ id: event.externalId,
30401
+ kind: "remediation-xp",
30402
+ occurredAt: ctx.occurredAt,
30403
+ courseId: ctx.courseId,
30404
+ title: "XP Adjustment",
30405
+ activityId: ctx.activityId,
30406
+ appName: ctx.appName,
30407
+ reason: ctx.reason,
30408
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30409
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30410
+ };
30282
30411
  }
30283
- const titleMap = {
30284
- "remediation-xp": "XP Adjustment",
30285
- "remediation-mastery": "Mastery Adjustment"
30286
- };
30287
- return {
30288
- id: event.externalId,
30289
- kind,
30290
- occurredAt: ctx.occurredAt,
30291
- courseId: ctx.courseId,
30292
- title: titleMap[kind] || "Remediation Activity",
30293
- activityId: ctx.activityId,
30294
- appName: ctx.appName,
30295
- reason: ctx.reason,
30296
- xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30297
- masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30298
- };
30412
+ if (ctx.eventKind === "remediation-mastery") {
30413
+ return {
30414
+ id: event.externalId,
30415
+ kind: "remediation-mastery",
30416
+ occurredAt: ctx.occurredAt,
30417
+ courseId: ctx.courseId,
30418
+ title: "Mastery Adjustment",
30419
+ activityId: ctx.activityId,
30420
+ appName: ctx.appName,
30421
+ reason: ctx.reason,
30422
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30423
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30424
+ };
30425
+ }
30426
+ if (ctx.eventKind === "course-completed") {
30427
+ return {
30428
+ id: event.externalId,
30429
+ kind: "course-completed",
30430
+ occurredAt: ctx.occurredAt,
30431
+ courseId: ctx.courseId,
30432
+ title: ctx.source === "admin" ? "Course marked complete" : "Course completed",
30433
+ activityId: ctx.activityId,
30434
+ appName: ctx.appName,
30435
+ reason: ctx.reason
30436
+ };
30437
+ }
30438
+ if (ctx.eventKind === "course-resumed") {
30439
+ return {
30440
+ id: event.externalId,
30441
+ kind: "course-resumed",
30442
+ occurredAt: ctx.occurredAt,
30443
+ courseId: ctx.courseId,
30444
+ title: "Course resumed",
30445
+ activityId: ctx.activityId,
30446
+ appName: ctx.appName,
30447
+ reason: ctx.reason
30448
+ };
30449
+ }
30450
+ return null;
30299
30451
  }
30300
30452
  function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
30301
30453
  const ctx = parseCaliperEventContext(event, relevantCourseIds);
@@ -30317,6 +30469,7 @@ var init_timeback_util = __esm(() => {
30317
30469
  // ../api-core/src/services/timeback-admin.service.ts
30318
30470
  class TimebackAdminService {
30319
30471
  deps;
30472
+ static XP_PRECISION_FACTOR = 10;
30320
30473
  static RECENT_ACTIVITY_LIMIT = 20;
30321
30474
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
30322
30475
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
@@ -30328,6 +30481,10 @@ class TimebackAdminService {
30328
30481
  constructor(deps) {
30329
30482
  this.deps = deps;
30330
30483
  }
30484
+ static roundXpToTenths(value) {
30485
+ const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
30486
+ return Object.is(rounded, -0) ? 0 : rounded;
30487
+ }
30331
30488
  requireClient() {
30332
30489
  if (!this.deps.timeback) {
30333
30490
  logger16.error("Timeback client not available in context");
@@ -30335,6 +30492,17 @@ class TimebackAdminService {
30335
30492
  }
30336
30493
  return this.deps.timeback;
30337
30494
  }
30495
+ async recordCourseCompletionHistory(client, data) {
30496
+ await client.recordAdminCourseCompletionChange(data).catch((error) => {
30497
+ logger16.error("Failed to record admin course completion history event", {
30498
+ gameId: data.gameId,
30499
+ courseId: data.courseId,
30500
+ studentId: data.studentId,
30501
+ action: data.action,
30502
+ error: error instanceof Error ? error.message : String(error)
30503
+ });
30504
+ });
30505
+ }
30338
30506
  async resolveAdminMutationContext(gameId, courseId, user, studentId) {
30339
30507
  const client = this.requireClient();
30340
30508
  await this.deps.validateDeveloperAccess(user, gameId);
@@ -30374,8 +30542,8 @@ class TimebackAdminService {
30374
30542
  }
30375
30543
  const today = formatDateYMD();
30376
30544
  const history = [];
30377
- let totalXp = 0;
30378
- let todayXp = 0;
30545
+ let totalXpRaw = 0;
30546
+ let todayXpRaw = 0;
30379
30547
  let activeTimeSeconds = 0;
30380
30548
  let masteredUnits = 0;
30381
30549
  for (const [date3, subjectFacts] of Object.entries(facts)) {
@@ -30392,15 +30560,16 @@ class TimebackAdminService {
30392
30560
  masteredUnitsForDay += masteredUnitsFromFact;
30393
30561
  }
30394
30562
  }
30395
- totalXp += xpForDay;
30563
+ const roundedXpForDay = TimebackAdminService.roundXpToTenths(xpForDay);
30564
+ totalXpRaw += xpForDay;
30396
30565
  activeTimeSeconds += activeSecondsForDay;
30397
30566
  masteredUnits += masteredUnitsForDay;
30398
30567
  if (date3 === today) {
30399
- todayXp += xpForDay;
30568
+ todayXpRaw += xpForDay;
30400
30569
  }
30401
30570
  history.push({
30402
30571
  date: date3,
30403
- xpEarned: xpForDay,
30572
+ xpEarned: roundedXpForDay,
30404
30573
  activeTimeSeconds: activeSecondsForDay,
30405
30574
  masteredUnits: masteredUnitsForDay
30406
30575
  });
@@ -30408,8 +30577,8 @@ class TimebackAdminService {
30408
30577
  history.sort((a, b) => a.date.localeCompare(b.date));
30409
30578
  return {
30410
30579
  analyticsAvailable: true,
30411
- totalXp,
30412
- todayXp,
30580
+ totalXp: TimebackAdminService.roundXpToTenths(totalXpRaw),
30581
+ todayXp: TimebackAdminService.roundXpToTenths(todayXpRaw),
30413
30582
  activeTimeSeconds,
30414
30583
  masteredUnits,
30415
30584
  history
@@ -30751,6 +30920,7 @@ class TimebackAdminService {
30751
30920
  }
30752
30921
  async toggleCourseCompletion(data, user) {
30753
30922
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
30923
+ const historyClient = client;
30754
30924
  const ids = deriveSourcedIds(data.courseId);
30755
30925
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
30756
30926
  const resultId = `${lineItemId}:${data.studentId}:completion`;
@@ -30801,11 +30971,19 @@ class TimebackAdminService {
30801
30971
  inProgress: "false",
30802
30972
  metadata: {
30803
30973
  isMasteryCompletion: true,
30804
- completedAt: new Date().toISOString(),
30805
30974
  adminAction: true,
30806
30975
  appName
30807
30976
  }
30808
30977
  });
30978
+ await this.recordCourseCompletionHistory(historyClient, {
30979
+ gameId: data.gameId,
30980
+ courseId: data.courseId,
30981
+ studentId: data.studentId,
30982
+ action: "complete",
30983
+ actor,
30984
+ appName,
30985
+ sensorUrl
30986
+ });
30809
30987
  } else {
30810
30988
  await client.oneroster.assessmentResults.upsert(resultId, {
30811
30989
  sourcedId: resultId,
@@ -30818,11 +30996,19 @@ class TimebackAdminService {
30818
30996
  inProgress: "true",
30819
30997
  metadata: {
30820
30998
  isMasteryCompletion: true,
30821
- resumedAt: new Date().toISOString(),
30822
30999
  adminAction: true,
30823
31000
  appName
30824
31001
  }
30825
31002
  });
31003
+ await this.recordCourseCompletionHistory(historyClient, {
31004
+ gameId: data.gameId,
31005
+ courseId: data.courseId,
31006
+ studentId: data.studentId,
31007
+ action: "resume",
31008
+ actor,
31009
+ appName,
31010
+ sensorUrl
31011
+ });
30826
31012
  }
30827
31013
  return { status: "ok" };
30828
31014
  }
@@ -35089,7 +35275,8 @@ function buildAdminEventMetadata({
35089
35275
  return {
35090
35276
  playcademy: {
35091
35277
  eventKind,
35092
- reason
35278
+ reason,
35279
+ source: "admin"
35093
35280
  }
35094
35281
  };
35095
35282
  }
@@ -35205,6 +35392,30 @@ class AdminEventRecorder {
35205
35392
  eventExtensions: ctx.metadata
35206
35393
  });
35207
35394
  }
35395
+ async recordCourseCompletionChange(data) {
35396
+ const isResume = data.action === "resume";
35397
+ const ctx = await this.prepareAdminEvent({
35398
+ ...data,
35399
+ defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
35400
+ reason: "Admin action",
35401
+ eventKind: isResume ? "course-resumed" : "course-completed"
35402
+ });
35403
+ await this.caliper.emitActivityEvent({
35404
+ studentId: ctx.student.id,
35405
+ studentEmail: ctx.student.email,
35406
+ activityId: ctx.activityId,
35407
+ activityName: isResume ? "Course resumed" : "Course marked complete",
35408
+ courseId: data.courseId,
35409
+ courseName: ctx.courseContext.courseName,
35410
+ subject: ctx.courseContext.subject,
35411
+ appName: ctx.appName,
35412
+ sensorUrl: ctx.sensorUrl,
35413
+ process: false,
35414
+ includeAttempt: false,
35415
+ generatedExtensions: ctx.metadata,
35416
+ eventExtensions: ctx.metadata
35417
+ });
35418
+ }
35208
35419
  }
35209
35420
 
35210
35421
  class TimebackCache {
@@ -35418,7 +35629,7 @@ class MasteryTracker {
35418
35629
  const totalMastered = historicalMasteredUnits + masteredUnits;
35419
35630
  const rawPct = totalMastered / masterableUnits * 100;
35420
35631
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
35421
- const masteryAchieved = totalMastered >= masterableUnits;
35632
+ const masteryAchieved = historicalMasteredUnits < masterableUnits && totalMastered >= masterableUnits;
35422
35633
  return { pctCompleteApp, masteryAchieved };
35423
35634
  }
35424
35635
  async createCompletionEntry(studentId, courseId, classId, appName) {
@@ -35444,7 +35655,6 @@ class MasteryTracker {
35444
35655
  inProgress: "false",
35445
35656
  metadata: {
35446
35657
  isMasteryCompletion: true,
35447
- completedAt: new Date().toISOString(),
35448
35658
  appName
35449
35659
  }
35450
35660
  });
@@ -35660,6 +35870,16 @@ class ProgressRecorder {
35660
35870
  }
35661
35871
  if (masteryAchieved) {
35662
35872
  await this.masteryTracker.createCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
35873
+ await this.emitCourseCompletionHistoryEvent({
35874
+ studentId,
35875
+ studentEmail,
35876
+ activityId,
35877
+ courseId: ids.course,
35878
+ courseName,
35879
+ subject: progressData.subject,
35880
+ appName: progressData.appName,
35881
+ sensorUrl: progressData.sensorUrl
35882
+ });
35663
35883
  }
35664
35884
  await this.emitCaliperEvent({
35665
35885
  studentId,
@@ -35834,6 +36054,38 @@ class ProgressRecorder {
35834
36054
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
35835
36055
  });
35836
36056
  }
36057
+ async emitCourseCompletionHistoryEvent(data) {
36058
+ await this.caliperNamespace.emitActivityEvent({
36059
+ studentId: data.studentId,
36060
+ studentEmail: data.studentEmail,
36061
+ activityId: data.activityId,
36062
+ activityName: "Course completed",
36063
+ courseId: data.courseId,
36064
+ courseName: data.courseName,
36065
+ subject: data.subject,
36066
+ appName: data.appName,
36067
+ sensorUrl: data.sensorUrl,
36068
+ process: false,
36069
+ includeAttempt: false,
36070
+ eventExtensions: {
36071
+ playcademy: {
36072
+ eventKind: "course-completed",
36073
+ source: "gameplay"
36074
+ }
36075
+ },
36076
+ generatedExtensions: {
36077
+ playcademy: {
36078
+ eventKind: "course-completed",
36079
+ source: "gameplay",
36080
+ activityId: data.activityId
36081
+ }
36082
+ }
36083
+ }).catch((error) => {
36084
+ log.error("[ProgressRecorder] Failed to emit course completion history event", {
36085
+ error
36086
+ });
36087
+ });
36088
+ }
35837
36089
  }
35838
36090
 
35839
36091
  class SessionRecorder {
@@ -36228,6 +36480,10 @@ class TimebackClient {
36228
36480
  await this._ensureAuthenticated();
36229
36481
  return this.adminEventRecorder.recordMasteryAdjustment(data);
36230
36482
  }
36483
+ async recordAdminCourseCompletionChange(data) {
36484
+ await this._ensureAuthenticated();
36485
+ return this.adminEventRecorder.recordCourseCompletionChange(data);
36486
+ }
36231
36487
  clearCaches() {
36232
36488
  this.cacheManager.clearAll();
36233
36489
  }
@@ -94053,7 +94309,7 @@ var init_domain_controller = __esm(() => {
94053
94309
  });
94054
94310
 
94055
94311
  // ../api-core/src/controllers/game.controller.ts
94056
- var logger46, list3, listManageable, getSubjects, getById2, getBySlug, upsertBySlug, remove3, games2;
94312
+ var logger46, list3, listManageable, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
94057
94313
  var init_game_controller = __esm(() => {
94058
94314
  init_esm();
94059
94315
  init_schemas_index();
@@ -94093,6 +94349,21 @@ var init_game_controller = __esm(() => {
94093
94349
  logger46.debug("Getting game by slug", { userId: ctx.user.id, slug: slug2, launchId: ctx.launchId });
94094
94350
  return ctx.services.game.getBySlug(slug2, ctx.user);
94095
94351
  });
94352
+ getManifest = requireAuth(async (ctx) => {
94353
+ const gameId = ctx.params.gameId;
94354
+ if (!gameId) {
94355
+ throw ApiError.badRequest("Missing game ID");
94356
+ }
94357
+ if (!isValidUUID(gameId)) {
94358
+ throw ApiError.unprocessableEntity("gameId must be a valid UUID format");
94359
+ }
94360
+ logger46.debug("Getting game manifest by ID", {
94361
+ userId: ctx.user.id,
94362
+ gameId,
94363
+ launchId: ctx.launchId
94364
+ });
94365
+ return ctx.services.game.getManifest(gameId, ctx.user);
94366
+ });
94096
94367
  upsertBySlug = requireAuth(async (ctx) => {
94097
94368
  const slug2 = ctx.params.slug;
94098
94369
  if (!slug2) {
@@ -94129,6 +94400,7 @@ var init_game_controller = __esm(() => {
94129
94400
  listManageable,
94130
94401
  getSubjects,
94131
94402
  getById: getById2,
94403
+ getManifest,
94132
94404
  getBySlug,
94133
94405
  upsertBySlug,
94134
94406
  remove: remove3
@@ -95956,7 +96228,8 @@ var init_crud = __esm(() => {
95956
96228
  init_api();
95957
96229
  gameCrudRouter = new Hono2;
95958
96230
  gameCrudRouter.get("/", handle2(games2.list));
95959
- gameCrudRouter.get("/:gameId{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}", handle2(games2.getById));
96231
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}/manifest", handle2(games2.getManifest));
96232
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}", handle2(games2.getById));
95960
96233
  gameCrudRouter.get("/:slug", handle2(games2.getBySlug));
95961
96234
  gameCrudRouter.put("/:slug", handle2(games2.upsertBySlug));
95962
96235
  gameCrudRouter.delete("/:gameId", handle2(games2.remove, { status: 204 }));
package/dist/server.js CHANGED
@@ -1309,7 +1309,7 @@ var package_default;
1309
1309
  var init_package = __esm(() => {
1310
1310
  package_default = {
1311
1311
  name: "@playcademy/sandbox",
1312
- version: "0.3.16-beta.3",
1312
+ version: "0.3.16-beta.5",
1313
1313
  description: "Local development server for Playcademy game development",
1314
1314
  type: "module",
1315
1315
  exports: {
@@ -26649,9 +26649,37 @@ var init_developer_service = __esm(() => {
26649
26649
  // ../api-core/src/services/game.service.ts
26650
26650
  class GameService {
26651
26651
  deps;
26652
+ static MANIFEST_FETCH_TIMEOUT_MS = 5000;
26653
+ static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
26652
26654
  constructor(deps) {
26653
26655
  this.deps = deps;
26654
26656
  }
26657
+ static getManifestHost(manifestUrl) {
26658
+ try {
26659
+ return new URL(manifestUrl).host;
26660
+ } catch {
26661
+ return manifestUrl;
26662
+ }
26663
+ }
26664
+ static getFetchErrorMessage(error) {
26665
+ let raw;
26666
+ if (error instanceof Error) {
26667
+ raw = error.message;
26668
+ } else if (typeof error === "string") {
26669
+ raw = error;
26670
+ }
26671
+ if (!raw) {
26672
+ return;
26673
+ }
26674
+ const normalized = raw.replace(/\s+/g, " ").trim();
26675
+ if (!normalized) {
26676
+ return;
26677
+ }
26678
+ return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
26679
+ }
26680
+ static isRetryableStatus(status) {
26681
+ return status === 429 || status >= 500;
26682
+ }
26655
26683
  async list(caller) {
26656
26684
  const db2 = this.deps.db;
26657
26685
  const isAdmin = caller?.role === "admin";
@@ -26713,6 +26741,109 @@ class GameService {
26713
26741
  this.enforceVisibility(game, caller, slug);
26714
26742
  return game;
26715
26743
  }
26744
+ async getManifest(gameId, caller) {
26745
+ const game = await this.getById(gameId, caller);
26746
+ if (game.gameType !== "hosted" || !game.deploymentUrl) {
26747
+ throw new BadRequestError("Game does not have a deployment manifest");
26748
+ }
26749
+ const deploymentUrl = game.deploymentUrl;
26750
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
26751
+ const manifestHost = GameService.getManifestHost(manifestUrl);
26752
+ const startedAt = Date.now();
26753
+ const controller = new AbortController;
26754
+ const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
26755
+ function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
26756
+ return {
26757
+ manifestUrl,
26758
+ manifestHost,
26759
+ deploymentUrl,
26760
+ fetchOutcome,
26761
+ retryCount: 0,
26762
+ durationMs: Date.now() - startedAt,
26763
+ manifestErrorKind,
26764
+ ...extra
26765
+ };
26766
+ }
26767
+ let response;
26768
+ try {
26769
+ response = await fetch(manifestUrl, {
26770
+ method: "GET",
26771
+ headers: {
26772
+ Accept: "application/json"
26773
+ },
26774
+ signal: controller.signal
26775
+ });
26776
+ } catch (error) {
26777
+ clearTimeout(timeout);
26778
+ const fetchErrorMessage = GameService.getFetchErrorMessage(error);
26779
+ const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
26780
+ logger5.error("Failed to fetch game manifest", {
26781
+ gameId,
26782
+ manifestUrl,
26783
+ error,
26784
+ details
26785
+ });
26786
+ if (error instanceof Error && error.name === "AbortError") {
26787
+ throw new TimeoutError("Timed out loading game manifest", details);
26788
+ }
26789
+ throw new ServiceUnavailableError("Failed to load game manifest", details);
26790
+ } finally {
26791
+ clearTimeout(timeout);
26792
+ }
26793
+ if (!response.ok) {
26794
+ const resolvedManifestUrl = response.url || manifestUrl;
26795
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26796
+ const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
26797
+ const details = buildDetails("bad_status", manifestErrorKind, {
26798
+ manifestUrl: resolvedManifestUrl,
26799
+ manifestHost: resolvedManifestHost,
26800
+ status: response.status,
26801
+ contentType: response.headers.get("content-type") ?? undefined,
26802
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26803
+ redirected: response.redirected,
26804
+ ...response.redirected ? {
26805
+ originalManifestUrl: manifestUrl,
26806
+ originalManifestHost: manifestHost
26807
+ } : {}
26808
+ });
26809
+ const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
26810
+ logger5.error("Game manifest returned non-ok response", {
26811
+ gameId,
26812
+ manifestUrl,
26813
+ status: response.status,
26814
+ details
26815
+ });
26816
+ if (manifestErrorKind === "temporary") {
26817
+ throw new ServiceUnavailableError(message, details);
26818
+ }
26819
+ throw new BadRequestError(message, details);
26820
+ }
26821
+ try {
26822
+ return await response.json();
26823
+ } catch (error) {
26824
+ const resolvedManifestUrl = response.url || manifestUrl;
26825
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26826
+ const details = buildDetails("invalid_body", "permanent", {
26827
+ manifestUrl: resolvedManifestUrl,
26828
+ manifestHost: resolvedManifestHost,
26829
+ status: response.status,
26830
+ contentType: response.headers.get("content-type") ?? undefined,
26831
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26832
+ redirected: response.redirected,
26833
+ ...response.redirected ? {
26834
+ originalManifestUrl: manifestUrl,
26835
+ originalManifestHost: manifestHost
26836
+ } : {}
26837
+ });
26838
+ logger5.error("Failed to parse game manifest", {
26839
+ gameId,
26840
+ manifestUrl,
26841
+ error,
26842
+ details
26843
+ });
26844
+ throw new BadRequestError("Failed to parse game manifest", details);
26845
+ }
26846
+ }
26716
26847
  enforceVisibility(game, caller, lookupIdentifier) {
26717
26848
  if (game.visibility !== "internal") {
26718
26849
  return;
@@ -30206,16 +30337,7 @@ function mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, cour
30206
30337
  return null;
30207
30338
  }
30208
30339
  if (isMasteryCompletionEntry(assessment)) {
30209
- const metadata3 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30210
- const isResume = Boolean(metadata3?.resumedAt);
30211
- return {
30212
- id: assessment.sourcedId || `${assessment.assessmentLineItem.sourcedId}:${assessment.scoreDate}`,
30213
- kind: "admin-completion",
30214
- occurredAt: assessment.scoreDate,
30215
- courseId,
30216
- title: isResume ? "Course resumed" : "Course marked complete",
30217
- reason: metadata3?.adminAction ? "Admin action" : undefined
30218
- };
30340
+ return null;
30219
30341
  }
30220
30342
  const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30221
30343
  const activityName = getStringValue(metadata2?.activityName);
@@ -30248,6 +30370,7 @@ function parseCaliperEventContext(event, relevantCourseIds) {
30248
30370
  courseId,
30249
30371
  occurredAt,
30250
30372
  eventKind: getStringValue(playcademy?.eventKind),
30373
+ source: getStringValue(playcademy?.source),
30251
30374
  reason: getStringValue(playcademy?.reason),
30252
30375
  titleFromEvent: getStringValue(event.object.activity?.name),
30253
30376
  appName: getStringValue(event.object.app?.name),
@@ -30271,30 +30394,59 @@ function mapTimeSpentRemediation(event, ctx) {
30271
30394
  };
30272
30395
  }
30273
30396
  function mapActivityRemediation(event, ctx) {
30274
- let kind;
30275
30397
  if (ctx.eventKind === "remediation-xp") {
30276
- kind = "remediation-xp";
30277
- } else if (ctx.eventKind === "remediation-mastery") {
30278
- kind = "remediation-mastery";
30279
- } else {
30280
- return null;
30398
+ return {
30399
+ id: event.externalId,
30400
+ kind: "remediation-xp",
30401
+ occurredAt: ctx.occurredAt,
30402
+ courseId: ctx.courseId,
30403
+ title: "XP Adjustment",
30404
+ activityId: ctx.activityId,
30405
+ appName: ctx.appName,
30406
+ reason: ctx.reason,
30407
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30408
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30409
+ };
30281
30410
  }
30282
- const titleMap = {
30283
- "remediation-xp": "XP Adjustment",
30284
- "remediation-mastery": "Mastery Adjustment"
30285
- };
30286
- return {
30287
- id: event.externalId,
30288
- kind,
30289
- occurredAt: ctx.occurredAt,
30290
- courseId: ctx.courseId,
30291
- title: titleMap[kind] || "Remediation Activity",
30292
- activityId: ctx.activityId,
30293
- appName: ctx.appName,
30294
- reason: ctx.reason,
30295
- xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30296
- masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30297
- };
30411
+ if (ctx.eventKind === "remediation-mastery") {
30412
+ return {
30413
+ id: event.externalId,
30414
+ kind: "remediation-mastery",
30415
+ occurredAt: ctx.occurredAt,
30416
+ courseId: ctx.courseId,
30417
+ title: "Mastery Adjustment",
30418
+ activityId: ctx.activityId,
30419
+ appName: ctx.appName,
30420
+ reason: ctx.reason,
30421
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30422
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30423
+ };
30424
+ }
30425
+ if (ctx.eventKind === "course-completed") {
30426
+ return {
30427
+ id: event.externalId,
30428
+ kind: "course-completed",
30429
+ occurredAt: ctx.occurredAt,
30430
+ courseId: ctx.courseId,
30431
+ title: ctx.source === "admin" ? "Course marked complete" : "Course completed",
30432
+ activityId: ctx.activityId,
30433
+ appName: ctx.appName,
30434
+ reason: ctx.reason
30435
+ };
30436
+ }
30437
+ if (ctx.eventKind === "course-resumed") {
30438
+ return {
30439
+ id: event.externalId,
30440
+ kind: "course-resumed",
30441
+ occurredAt: ctx.occurredAt,
30442
+ courseId: ctx.courseId,
30443
+ title: "Course resumed",
30444
+ activityId: ctx.activityId,
30445
+ appName: ctx.appName,
30446
+ reason: ctx.reason
30447
+ };
30448
+ }
30449
+ return null;
30298
30450
  }
30299
30451
  function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
30300
30452
  const ctx = parseCaliperEventContext(event, relevantCourseIds);
@@ -30316,6 +30468,7 @@ var init_timeback_util = __esm(() => {
30316
30468
  // ../api-core/src/services/timeback-admin.service.ts
30317
30469
  class TimebackAdminService {
30318
30470
  deps;
30471
+ static XP_PRECISION_FACTOR = 10;
30319
30472
  static RECENT_ACTIVITY_LIMIT = 20;
30320
30473
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
30321
30474
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
@@ -30327,6 +30480,10 @@ class TimebackAdminService {
30327
30480
  constructor(deps) {
30328
30481
  this.deps = deps;
30329
30482
  }
30483
+ static roundXpToTenths(value) {
30484
+ const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
30485
+ return Object.is(rounded, -0) ? 0 : rounded;
30486
+ }
30330
30487
  requireClient() {
30331
30488
  if (!this.deps.timeback) {
30332
30489
  logger16.error("Timeback client not available in context");
@@ -30334,6 +30491,17 @@ class TimebackAdminService {
30334
30491
  }
30335
30492
  return this.deps.timeback;
30336
30493
  }
30494
+ async recordCourseCompletionHistory(client, data) {
30495
+ await client.recordAdminCourseCompletionChange(data).catch((error) => {
30496
+ logger16.error("Failed to record admin course completion history event", {
30497
+ gameId: data.gameId,
30498
+ courseId: data.courseId,
30499
+ studentId: data.studentId,
30500
+ action: data.action,
30501
+ error: error instanceof Error ? error.message : String(error)
30502
+ });
30503
+ });
30504
+ }
30337
30505
  async resolveAdminMutationContext(gameId, courseId, user, studentId) {
30338
30506
  const client = this.requireClient();
30339
30507
  await this.deps.validateDeveloperAccess(user, gameId);
@@ -30373,8 +30541,8 @@ class TimebackAdminService {
30373
30541
  }
30374
30542
  const today = formatDateYMD();
30375
30543
  const history = [];
30376
- let totalXp = 0;
30377
- let todayXp = 0;
30544
+ let totalXpRaw = 0;
30545
+ let todayXpRaw = 0;
30378
30546
  let activeTimeSeconds = 0;
30379
30547
  let masteredUnits = 0;
30380
30548
  for (const [date3, subjectFacts] of Object.entries(facts)) {
@@ -30391,15 +30559,16 @@ class TimebackAdminService {
30391
30559
  masteredUnitsForDay += masteredUnitsFromFact;
30392
30560
  }
30393
30561
  }
30394
- totalXp += xpForDay;
30562
+ const roundedXpForDay = TimebackAdminService.roundXpToTenths(xpForDay);
30563
+ totalXpRaw += xpForDay;
30395
30564
  activeTimeSeconds += activeSecondsForDay;
30396
30565
  masteredUnits += masteredUnitsForDay;
30397
30566
  if (date3 === today) {
30398
- todayXp += xpForDay;
30567
+ todayXpRaw += xpForDay;
30399
30568
  }
30400
30569
  history.push({
30401
30570
  date: date3,
30402
- xpEarned: xpForDay,
30571
+ xpEarned: roundedXpForDay,
30403
30572
  activeTimeSeconds: activeSecondsForDay,
30404
30573
  masteredUnits: masteredUnitsForDay
30405
30574
  });
@@ -30407,8 +30576,8 @@ class TimebackAdminService {
30407
30576
  history.sort((a, b) => a.date.localeCompare(b.date));
30408
30577
  return {
30409
30578
  analyticsAvailable: true,
30410
- totalXp,
30411
- todayXp,
30579
+ totalXp: TimebackAdminService.roundXpToTenths(totalXpRaw),
30580
+ todayXp: TimebackAdminService.roundXpToTenths(todayXpRaw),
30412
30581
  activeTimeSeconds,
30413
30582
  masteredUnits,
30414
30583
  history
@@ -30750,6 +30919,7 @@ class TimebackAdminService {
30750
30919
  }
30751
30920
  async toggleCourseCompletion(data, user) {
30752
30921
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
30922
+ const historyClient = client;
30753
30923
  const ids = deriveSourcedIds(data.courseId);
30754
30924
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
30755
30925
  const resultId = `${lineItemId}:${data.studentId}:completion`;
@@ -30800,11 +30970,19 @@ class TimebackAdminService {
30800
30970
  inProgress: "false",
30801
30971
  metadata: {
30802
30972
  isMasteryCompletion: true,
30803
- completedAt: new Date().toISOString(),
30804
30973
  adminAction: true,
30805
30974
  appName
30806
30975
  }
30807
30976
  });
30977
+ await this.recordCourseCompletionHistory(historyClient, {
30978
+ gameId: data.gameId,
30979
+ courseId: data.courseId,
30980
+ studentId: data.studentId,
30981
+ action: "complete",
30982
+ actor,
30983
+ appName,
30984
+ sensorUrl
30985
+ });
30808
30986
  } else {
30809
30987
  await client.oneroster.assessmentResults.upsert(resultId, {
30810
30988
  sourcedId: resultId,
@@ -30817,11 +30995,19 @@ class TimebackAdminService {
30817
30995
  inProgress: "true",
30818
30996
  metadata: {
30819
30997
  isMasteryCompletion: true,
30820
- resumedAt: new Date().toISOString(),
30821
30998
  adminAction: true,
30822
30999
  appName
30823
31000
  }
30824
31001
  });
31002
+ await this.recordCourseCompletionHistory(historyClient, {
31003
+ gameId: data.gameId,
31004
+ courseId: data.courseId,
31005
+ studentId: data.studentId,
31006
+ action: "resume",
31007
+ actor,
31008
+ appName,
31009
+ sensorUrl
31010
+ });
30825
31011
  }
30826
31012
  return { status: "ok" };
30827
31013
  }
@@ -35088,7 +35274,8 @@ function buildAdminEventMetadata({
35088
35274
  return {
35089
35275
  playcademy: {
35090
35276
  eventKind,
35091
- reason
35277
+ reason,
35278
+ source: "admin"
35092
35279
  }
35093
35280
  };
35094
35281
  }
@@ -35204,6 +35391,30 @@ class AdminEventRecorder {
35204
35391
  eventExtensions: ctx.metadata
35205
35392
  });
35206
35393
  }
35394
+ async recordCourseCompletionChange(data) {
35395
+ const isResume = data.action === "resume";
35396
+ const ctx = await this.prepareAdminEvent({
35397
+ ...data,
35398
+ defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
35399
+ reason: "Admin action",
35400
+ eventKind: isResume ? "course-resumed" : "course-completed"
35401
+ });
35402
+ await this.caliper.emitActivityEvent({
35403
+ studentId: ctx.student.id,
35404
+ studentEmail: ctx.student.email,
35405
+ activityId: ctx.activityId,
35406
+ activityName: isResume ? "Course resumed" : "Course marked complete",
35407
+ courseId: data.courseId,
35408
+ courseName: ctx.courseContext.courseName,
35409
+ subject: ctx.courseContext.subject,
35410
+ appName: ctx.appName,
35411
+ sensorUrl: ctx.sensorUrl,
35412
+ process: false,
35413
+ includeAttempt: false,
35414
+ generatedExtensions: ctx.metadata,
35415
+ eventExtensions: ctx.metadata
35416
+ });
35417
+ }
35207
35418
  }
35208
35419
 
35209
35420
  class TimebackCache {
@@ -35417,7 +35628,7 @@ class MasteryTracker {
35417
35628
  const totalMastered = historicalMasteredUnits + masteredUnits;
35418
35629
  const rawPct = totalMastered / masterableUnits * 100;
35419
35630
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
35420
- const masteryAchieved = totalMastered >= masterableUnits;
35631
+ const masteryAchieved = historicalMasteredUnits < masterableUnits && totalMastered >= masterableUnits;
35421
35632
  return { pctCompleteApp, masteryAchieved };
35422
35633
  }
35423
35634
  async createCompletionEntry(studentId, courseId, classId, appName) {
@@ -35443,7 +35654,6 @@ class MasteryTracker {
35443
35654
  inProgress: "false",
35444
35655
  metadata: {
35445
35656
  isMasteryCompletion: true,
35446
- completedAt: new Date().toISOString(),
35447
35657
  appName
35448
35658
  }
35449
35659
  });
@@ -35659,6 +35869,16 @@ class ProgressRecorder {
35659
35869
  }
35660
35870
  if (masteryAchieved) {
35661
35871
  await this.masteryTracker.createCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
35872
+ await this.emitCourseCompletionHistoryEvent({
35873
+ studentId,
35874
+ studentEmail,
35875
+ activityId,
35876
+ courseId: ids.course,
35877
+ courseName,
35878
+ subject: progressData.subject,
35879
+ appName: progressData.appName,
35880
+ sensorUrl: progressData.sensorUrl
35881
+ });
35662
35882
  }
35663
35883
  await this.emitCaliperEvent({
35664
35884
  studentId,
@@ -35833,6 +36053,38 @@ class ProgressRecorder {
35833
36053
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
35834
36054
  });
35835
36055
  }
36056
+ async emitCourseCompletionHistoryEvent(data) {
36057
+ await this.caliperNamespace.emitActivityEvent({
36058
+ studentId: data.studentId,
36059
+ studentEmail: data.studentEmail,
36060
+ activityId: data.activityId,
36061
+ activityName: "Course completed",
36062
+ courseId: data.courseId,
36063
+ courseName: data.courseName,
36064
+ subject: data.subject,
36065
+ appName: data.appName,
36066
+ sensorUrl: data.sensorUrl,
36067
+ process: false,
36068
+ includeAttempt: false,
36069
+ eventExtensions: {
36070
+ playcademy: {
36071
+ eventKind: "course-completed",
36072
+ source: "gameplay"
36073
+ }
36074
+ },
36075
+ generatedExtensions: {
36076
+ playcademy: {
36077
+ eventKind: "course-completed",
36078
+ source: "gameplay",
36079
+ activityId: data.activityId
36080
+ }
36081
+ }
36082
+ }).catch((error) => {
36083
+ log.error("[ProgressRecorder] Failed to emit course completion history event", {
36084
+ error
36085
+ });
36086
+ });
36087
+ }
35836
36088
  }
35837
36089
 
35838
36090
  class SessionRecorder {
@@ -36227,6 +36479,10 @@ class TimebackClient {
36227
36479
  await this._ensureAuthenticated();
36228
36480
  return this.adminEventRecorder.recordMasteryAdjustment(data);
36229
36481
  }
36482
+ async recordAdminCourseCompletionChange(data) {
36483
+ await this._ensureAuthenticated();
36484
+ return this.adminEventRecorder.recordCourseCompletionChange(data);
36485
+ }
36230
36486
  clearCaches() {
36231
36487
  this.cacheManager.clearAll();
36232
36488
  }
@@ -94052,7 +94308,7 @@ var init_domain_controller = __esm(() => {
94052
94308
  });
94053
94309
 
94054
94310
  // ../api-core/src/controllers/game.controller.ts
94055
- var logger46, list3, listManageable, getSubjects, getById2, getBySlug, upsertBySlug, remove3, games2;
94311
+ var logger46, list3, listManageable, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
94056
94312
  var init_game_controller = __esm(() => {
94057
94313
  init_esm();
94058
94314
  init_schemas_index();
@@ -94092,6 +94348,21 @@ var init_game_controller = __esm(() => {
94092
94348
  logger46.debug("Getting game by slug", { userId: ctx.user.id, slug: slug2, launchId: ctx.launchId });
94093
94349
  return ctx.services.game.getBySlug(slug2, ctx.user);
94094
94350
  });
94351
+ getManifest = requireAuth(async (ctx) => {
94352
+ const gameId = ctx.params.gameId;
94353
+ if (!gameId) {
94354
+ throw ApiError.badRequest("Missing game ID");
94355
+ }
94356
+ if (!isValidUUID(gameId)) {
94357
+ throw ApiError.unprocessableEntity("gameId must be a valid UUID format");
94358
+ }
94359
+ logger46.debug("Getting game manifest by ID", {
94360
+ userId: ctx.user.id,
94361
+ gameId,
94362
+ launchId: ctx.launchId
94363
+ });
94364
+ return ctx.services.game.getManifest(gameId, ctx.user);
94365
+ });
94095
94366
  upsertBySlug = requireAuth(async (ctx) => {
94096
94367
  const slug2 = ctx.params.slug;
94097
94368
  if (!slug2) {
@@ -94128,6 +94399,7 @@ var init_game_controller = __esm(() => {
94128
94399
  listManageable,
94129
94400
  getSubjects,
94130
94401
  getById: getById2,
94402
+ getManifest,
94131
94403
  getBySlug,
94132
94404
  upsertBySlug,
94133
94405
  remove: remove3
@@ -95955,7 +96227,8 @@ var init_crud = __esm(() => {
95955
96227
  init_api();
95956
96228
  gameCrudRouter = new Hono2;
95957
96229
  gameCrudRouter.get("/", handle2(games2.list));
95958
- gameCrudRouter.get("/:gameId{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}", handle2(games2.getById));
96230
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}/manifest", handle2(games2.getManifest));
96231
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}", handle2(games2.getById));
95959
96232
  gameCrudRouter.get("/:slug", handle2(games2.getBySlug));
95960
96233
  gameCrudRouter.put("/:slug", handle2(games2.upsertBySlug));
95961
96234
  gameCrudRouter.delete("/:gameId", handle2(games2.remove, { status: 204 }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.16-beta.3",
3
+ "version": "0.3.16-beta.5",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {