@playcademy/sandbox 0.4.1-beta.1 → 0.4.1-beta.2

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/cli.js CHANGED
@@ -243,7 +243,7 @@ var init_platform = __esm(() => {
243
243
  var PLATFORM_TIMEZONE = "America/New_York";
244
244
 
245
245
  // ../constants/src/timeback.ts
246
- var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS;
246
+ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
247
247
  var init_timeback2 = __esm(() => {
248
248
  TIMEBACK_ROUTES = {
249
249
  END_ACTIVITY: "/integrations/timeback/end-activity",
@@ -288,6 +288,17 @@ var init_timeback2 = __esm(() => {
288
288
  sortOrder: 1,
289
289
  lessonType: "quiz"
290
290
  };
291
+ TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
292
+ xp: 1,
293
+ mastery: 0,
294
+ score: 2
295
+ };
296
+ TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
297
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
298
+ mastery: 0,
299
+ time: 60,
300
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
301
+ };
291
302
  });
292
303
 
293
304
  // ../constants/src/cloudflare.ts
@@ -1065,7 +1076,7 @@ var package_default;
1065
1076
  var init_package = __esm(() => {
1066
1077
  package_default = {
1067
1078
  name: "@playcademy/sandbox",
1068
- version: "0.4.1-beta.1",
1079
+ version: "0.4.1-beta.2",
1069
1080
  description: "Local development server for Playcademy game development",
1070
1081
  type: "module",
1071
1082
  exports: {
@@ -29891,7 +29902,7 @@ function isValidAdminAttributionDate(value) {
29891
29902
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29892
29903
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29893
29904
  }
29894
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameActivityMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29905
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29895
29906
  var init_schemas4 = __esm(() => {
29896
29907
  init_drizzle_zod();
29897
29908
  init_esm();
@@ -29966,22 +29977,22 @@ var init_schemas4 = __esm(() => {
29966
29977
  message: "Cannot provide both masteredUnits and masteredUnitsAbsolute",
29967
29978
  path: ["masteredUnitsAbsolute"]
29968
29979
  });
29969
- GameActivityMetricsSchema = exports_external.object({
29980
+ GameRunMetricsSchema = exports_external.object({
29981
+ runId: exports_external.string().uuid(),
29970
29982
  activityId: exports_external.string().min(1),
29971
29983
  activityName: exports_external.string().optional(),
29972
- totalXp: exports_external.number().nonnegative(),
29973
- masteredUnits: exports_external.number().int().nonnegative(),
29974
- activeTimeSeconds: exports_external.number().nonnegative(),
29975
- completionCount: exports_external.number().int().nonnegative(),
29976
- lastCompletedAt: exports_external.string().datetime().optional()
29984
+ totalXp: exports_external.number().nonnegative().optional(),
29985
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
29986
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
29987
+ score: exports_external.number().min(0).max(100).optional()
29977
29988
  });
29978
29989
  GameCourseMetricsSchema = exports_external.object({
29979
29990
  grade: TimebackGradeSchema,
29980
29991
  subject: TimebackSubjectSchema,
29981
- totalXp: exports_external.number().nonnegative(),
29982
- masteredUnits: exports_external.number().int().nonnegative(),
29983
- activeTimeSeconds: exports_external.number().nonnegative(),
29984
- activities: exports_external.array(GameActivityMetricsSchema).optional()
29992
+ totalXp: exports_external.number().nonnegative().optional(),
29993
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
29994
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
29995
+ activities: exports_external.array(GameRunMetricsSchema).optional()
29985
29996
  });
29986
29997
  GameMetricsResponseSchema = exports_external.object({
29987
29998
  studentId: exports_external.string().min(1),
@@ -30258,6 +30269,124 @@ var init_timeback_admin_util = __esm(() => {
30258
30269
  init_errors();
30259
30270
  });
30260
30271
 
30272
+ // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30273
+ function createMetricRow(definition) {
30274
+ const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30275
+ if (timebackValue === undefined && gameValue === undefined) {
30276
+ return null;
30277
+ }
30278
+ if (gameValue === undefined) {
30279
+ return {
30280
+ metric,
30281
+ kind,
30282
+ status: "not_reported_by_game",
30283
+ ...timebackValue !== undefined ? { timebackValue } : {}
30284
+ };
30285
+ }
30286
+ if (timebackValue === undefined) {
30287
+ return {
30288
+ metric,
30289
+ kind,
30290
+ status: "not_recorded_by_timeback",
30291
+ gameValue
30292
+ };
30293
+ }
30294
+ const delta = gameValue - timebackValue;
30295
+ const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30296
+ return {
30297
+ metric,
30298
+ kind,
30299
+ status: isDiscrepant ? "discrepant" : "matched",
30300
+ timebackValue,
30301
+ gameValue,
30302
+ delta
30303
+ };
30304
+ }
30305
+ function createRunComparison(activity, gameRun) {
30306
+ const runId = activity.runId ?? "";
30307
+ if (!gameRun) {
30308
+ return {
30309
+ runId,
30310
+ status: "not_reported",
30311
+ discrepancyCount: 0,
30312
+ rows: []
30313
+ };
30314
+ }
30315
+ const rows = [
30316
+ createMetricRow({
30317
+ metric: "xp",
30318
+ kind: "number",
30319
+ timebackValue: activity.xpDelta,
30320
+ gameValue: gameRun.totalXp,
30321
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30322
+ }),
30323
+ createMetricRow({
30324
+ metric: "mastery",
30325
+ kind: "number",
30326
+ timebackValue: activity.masteredUnitsDelta,
30327
+ gameValue: gameRun.masteredUnits,
30328
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30329
+ }),
30330
+ createMetricRow({
30331
+ metric: "time",
30332
+ kind: "time",
30333
+ timebackValue: activity.timeDeltaSeconds,
30334
+ gameValue: gameRun.activeTimeSeconds,
30335
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30336
+ }),
30337
+ createMetricRow({
30338
+ metric: "score",
30339
+ kind: "percent",
30340
+ timebackValue: activity.score,
30341
+ gameValue: gameRun.score,
30342
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30343
+ })
30344
+ ].filter((row) => row !== null);
30345
+ const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30346
+ return {
30347
+ runId,
30348
+ status: discrepancyCount > 0 ? "discrepant" : "matched",
30349
+ discrepancyCount,
30350
+ rows
30351
+ };
30352
+ }
30353
+ function summarizeGameRunMetricsComparison(comparison) {
30354
+ return {
30355
+ runId: comparison.runId,
30356
+ status: comparison.status,
30357
+ discrepancyCount: comparison.discrepancyCount,
30358
+ ...comparison.reason ? { reason: comparison.reason } : {}
30359
+ };
30360
+ }
30361
+ function buildGameRunMetricComparisons(activities, course, response) {
30362
+ const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
30363
+ const comparisons = new Map;
30364
+ if (activitiesWithRunIds.length === 0) {
30365
+ return comparisons;
30366
+ }
30367
+ if (!response.supported) {
30368
+ for (const activity of activitiesWithRunIds) {
30369
+ comparisons.set(activity.runId, {
30370
+ runId: activity.runId,
30371
+ status: "unavailable",
30372
+ discrepancyCount: 0,
30373
+ reason: response.reason,
30374
+ rows: []
30375
+ });
30376
+ }
30377
+ return comparisons;
30378
+ }
30379
+ const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
30380
+ const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
30381
+ for (const activity of activitiesWithRunIds) {
30382
+ comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
30383
+ }
30384
+ return comparisons;
30385
+ }
30386
+ var init_timeback_game_metrics_comparison_util = __esm(() => {
30387
+ init_src();
30388
+ });
30389
+
30261
30390
  // ../api-core/src/utils/timeback-mastery-completion.util.ts
30262
30391
  async function upsertMasteryCompletionEntry(params) {
30263
30392
  const { client, courseId, studentId, appName, action } = params;
@@ -30648,6 +30777,8 @@ class TimebackAdminService {
30648
30777
  static ANALYTICS_CONCURRENCY = 8;
30649
30778
  static MASTERABLE_UNITS_CONCURRENCY = 4;
30650
30779
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
30780
+ static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
30781
+ static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
30651
30782
  constructor(deps) {
30652
30783
  this.deps = deps;
30653
30784
  }
@@ -30657,13 +30788,42 @@ class TimebackAdminService {
30657
30788
  }
30658
30789
  return this.deps.config.localGameUrls[slug2] ?? deployedUrl;
30659
30790
  }
30660
- static resolveGameMetricsUrl(baseUrl) {
30791
+ static resolveGameMetricsUrl(baseUrl, runIds) {
30661
30792
  try {
30662
- return new URL("/__playcademy/metrics", baseUrl);
30793
+ const url2 = new URL("/__playcademy/metrics", baseUrl);
30794
+ for (const runId of runIds ?? []) {
30795
+ url2.searchParams.append("runId", runId);
30796
+ }
30797
+ return url2;
30663
30798
  } catch {
30664
30799
  return null;
30665
30800
  }
30666
30801
  }
30802
+ static normalizeRunIds(runIds, limit = Number.POSITIVE_INFINITY) {
30803
+ const normalized = [];
30804
+ const seen = new Set;
30805
+ for (const runId of runIds ?? []) {
30806
+ const value = runId.trim().toLowerCase();
30807
+ if (isValidUUID(value) && !seen.has(value)) {
30808
+ seen.add(value);
30809
+ normalized.push(value);
30810
+ if (normalized.length >= limit) {
30811
+ break;
30812
+ }
30813
+ }
30814
+ }
30815
+ return normalized;
30816
+ }
30817
+ static chunkRunIds(runIds) {
30818
+ const chunks = [];
30819
+ for (let index2 = 0;index2 < runIds.length; index2 += this.GAME_METRICS_RUN_IDS_PER_REQUEST) {
30820
+ chunks.push(runIds.slice(index2, index2 + this.GAME_METRICS_RUN_IDS_PER_REQUEST));
30821
+ }
30822
+ return chunks;
30823
+ }
30824
+ static isAbortError(error) {
30825
+ return error instanceof Error && error.name === "AbortError";
30826
+ }
30667
30827
  static roundXpToTenths(value) {
30668
30828
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
30669
30829
  return Object.is(rounded, -0) ? 0 : rounded;
@@ -30824,6 +30984,56 @@ class TimebackAdminService {
30824
30984
  const remediationItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
30825
30985
  return [...groupedGameplayItems, ...remediationItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
30826
30986
  }
30987
+ async getGameMetricComparisonsForActivities(user, options) {
30988
+ const runIds = TimebackAdminService.normalizeRunIds(options.activities.map((activity) => activity.runId).filter((runId) => Boolean(runId)));
30989
+ if (runIds.length === 0) {
30990
+ return new Map;
30991
+ }
30992
+ const activitiesByRunId = new Map(options.activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0).map((activity) => [activity.runId.toLowerCase(), activity]));
30993
+ const comparisons = new Map;
30994
+ await Promise.all(TimebackAdminService.chunkRunIds(runIds).map(async (chunk) => {
30995
+ const activities = [];
30996
+ for (const runId of chunk) {
30997
+ const activity = activitiesByRunId.get(runId);
30998
+ if (activity) {
30999
+ activities.push(activity);
31000
+ }
31001
+ }
31002
+ if (activities.length === 0) {
31003
+ return;
31004
+ }
31005
+ let response;
31006
+ try {
31007
+ response = await this.getGameMetrics(options.gameId, options.studentId, user, {
31008
+ runIds: chunk,
31009
+ timeoutMs: options.timeoutMs
31010
+ });
31011
+ } catch (error) {
31012
+ response = {
31013
+ supported: false,
31014
+ reason: "fetch_failed",
31015
+ details: error instanceof Error ? error.message : String(error)
31016
+ };
31017
+ }
31018
+ for (const [runId, comparison] of buildGameRunMetricComparisons(activities, options.course, response)) {
31019
+ comparisons.set(runId, comparison);
31020
+ }
31021
+ }));
31022
+ return comparisons;
31023
+ }
31024
+ async attachGameMetricSummariesToActivities(user, options) {
31025
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, options);
31026
+ if (comparisons.size === 0) {
31027
+ return [...options.activities];
31028
+ }
31029
+ return options.activities.map((activity) => {
31030
+ const comparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31031
+ return comparison ? {
31032
+ ...activity,
31033
+ gameMetricsComparison: summarizeGameRunMetricsComparison(comparison)
31034
+ } : activity;
31035
+ });
31036
+ }
30827
31037
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
30828
31038
  const enrollments = new Map;
30829
31039
  const allEnrollments = new Map;
@@ -30983,7 +31193,7 @@ class TimebackAdminService {
30983
31193
  });
30984
31194
  return { gameId, courseId, students: deduped };
30985
31195
  }
30986
- async getGameMetrics(gameId, timebackId, user) {
31196
+ async getGameMetrics(gameId, timebackId, user, options) {
30987
31197
  const client = this.requireClient();
30988
31198
  await this.deps.validateGameManagementAccess(user, gameId);
30989
31199
  const [targetUser, integrations, game2, deployment] = await Promise.all([
@@ -31018,7 +31228,8 @@ class TimebackAdminService {
31018
31228
  if (!metricsBaseUrl) {
31019
31229
  return { supported: false, reason: "no_active_deployment" };
31020
31230
  }
31021
- const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl);
31231
+ const runIds = TimebackAdminService.normalizeRunIds(options?.runIds, TimebackAdminService.GAME_METRICS_RUN_IDS_PER_REQUEST);
31232
+ const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl, runIds);
31022
31233
  if (!metricsUrl) {
31023
31234
  return {
31024
31235
  supported: false,
@@ -31028,7 +31239,7 @@ class TimebackAdminService {
31028
31239
  }
31029
31240
  const token = await this.deps.mintPlatformServiceToken(gameId, targetUser.id);
31030
31241
  const controller = new AbortController;
31031
- const timeout = setTimeout(() => controller.abort(), TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
31242
+ const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
31032
31243
  let response;
31033
31244
  try {
31034
31245
  response = await fetch(metricsUrl, {
@@ -31040,10 +31251,19 @@ class TimebackAdminService {
31040
31251
  signal: controller.signal
31041
31252
  });
31042
31253
  } catch (error) {
31254
+ const timedOut = TimebackAdminService.isAbortError(error);
31255
+ let details;
31256
+ if (timedOut) {
31257
+ details = "Game metrics request timed out";
31258
+ } else if (error instanceof Error) {
31259
+ details = error.message;
31260
+ } else {
31261
+ details = String(error);
31262
+ }
31043
31263
  return {
31044
31264
  supported: false,
31045
- reason: "fetch_failed",
31046
- details: error instanceof Error ? error.message : String(error)
31265
+ reason: timedOut ? "timeout" : "fetch_failed",
31266
+ details
31047
31267
  };
31048
31268
  } finally {
31049
31269
  clearTimeout(timeout);
@@ -31147,7 +31367,14 @@ class TimebackAdminService {
31147
31367
  const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
31148
31368
  const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
31149
31369
  const hasMore = allActivities.length > safeOffset + safeLimit;
31150
- return { activities, hasMore };
31370
+ const activitiesWithGameMetrics = await this.attachGameMetricSummariesToActivities(user, {
31371
+ gameId,
31372
+ studentId,
31373
+ course: { grade: integration.grade, subject: integration.subject },
31374
+ activities,
31375
+ timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31376
+ });
31377
+ return { activities: activitiesWithGameMetrics, hasMore };
31151
31378
  }
31152
31379
  async getActivityDetail(user, options) {
31153
31380
  const { gameId, studentId, courseId, activityId, runId } = options;
@@ -31183,7 +31410,22 @@ class TimebackAdminService {
31183
31410
  if (!activity) {
31184
31411
  throw new NotFoundError("Activity", activityId);
31185
31412
  }
31186
- return { activity, rawEvents: matchedEvents };
31413
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, {
31414
+ gameId,
31415
+ studentId,
31416
+ course: { grade: integration.grade, subject: integration.subject },
31417
+ activities: [activity]
31418
+ });
31419
+ const gameMetricsComparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31420
+ const activityWithGameMetrics = gameMetricsComparison ? {
31421
+ ...activity,
31422
+ gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison)
31423
+ } : activity;
31424
+ return {
31425
+ activity: activityWithGameMetrics,
31426
+ rawEvents: matchedEvents,
31427
+ ...gameMetricsComparison ? { gameMetricsComparison } : {}
31428
+ };
31187
31429
  }
31188
31430
  async grantManualXp(data, user) {
31189
31431
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
@@ -31471,6 +31713,7 @@ var init_timeback_admin_service = __esm(() => {
31471
31713
  init_errors();
31472
31714
  init_timeback_admin_metrics_util();
31473
31715
  init_timeback_admin_util();
31716
+ init_timeback_game_metrics_comparison_util();
31474
31717
  init_timeback_mastery_completion_util();
31475
31718
  init_timeback_util();
31476
31719
  logger17 = log.scope("TimebackAdminService");
@@ -94207,15 +94450,19 @@ var init_timeback_controller = __esm(() => {
94207
94450
  getGameMetrics = requireGameManagementAccess(async (ctx) => {
94208
94451
  const gameId = ctx.params.gameId;
94209
94452
  const timebackId = ctx.params.timebackId;
94453
+ const runIds = [
94454
+ ...new Set(ctx.url.searchParams.getAll("runId").map((runId) => runId.trim().toLowerCase()).filter(isValidUUID))
94455
+ ];
94210
94456
  if (!gameId || !timebackId) {
94211
94457
  throw ApiError.badRequest("Missing gameId or timebackId path parameter");
94212
94458
  }
94213
94459
  logger45.debug("Getting game metrics", {
94214
94460
  requesterId: ctx.user.id,
94215
94461
  gameId,
94216
- timebackId
94462
+ timebackId,
94463
+ runIds
94217
94464
  });
94218
- return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user);
94465
+ return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user, { runIds });
94219
94466
  });
94220
94467
  getStudentActivity = requireGameManagementAccess(async (ctx) => {
94221
94468
  const timebackId = ctx.params.timebackId;
package/dist/constants.js CHANGED
@@ -78,7 +78,7 @@ var init_platform = __esm(() => {
78
78
  var PLATFORM_TIMEZONE = "America/New_York";
79
79
 
80
80
  // ../constants/src/timeback.ts
81
- var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS;
81
+ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
82
82
  var init_timeback = __esm(() => {
83
83
  TIMEBACK_ROUTES = {
84
84
  END_ACTIVITY: "/integrations/timeback/end-activity",
@@ -123,6 +123,17 @@ var init_timeback = __esm(() => {
123
123
  sortOrder: 1,
124
124
  lessonType: "quiz"
125
125
  };
126
+ TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
127
+ xp: 1,
128
+ mastery: 0,
129
+ score: 2
130
+ };
131
+ TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
132
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
133
+ mastery: 0,
134
+ time: 60,
135
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
136
+ };
126
137
  });
127
138
 
128
139
  // ../constants/src/cloudflare.ts
package/dist/server.js CHANGED
@@ -242,7 +242,7 @@ var init_platform = __esm(() => {
242
242
  var PLATFORM_TIMEZONE = "America/New_York";
243
243
 
244
244
  // ../constants/src/timeback.ts
245
- var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS;
245
+ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
246
246
  var init_timeback2 = __esm(() => {
247
247
  TIMEBACK_ROUTES = {
248
248
  END_ACTIVITY: "/integrations/timeback/end-activity",
@@ -287,6 +287,17 @@ var init_timeback2 = __esm(() => {
287
287
  sortOrder: 1,
288
288
  lessonType: "quiz"
289
289
  };
290
+ TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
291
+ xp: 1,
292
+ mastery: 0,
293
+ score: 2
294
+ };
295
+ TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
296
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
297
+ mastery: 0,
298
+ time: 60,
299
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
300
+ };
290
301
  });
291
302
 
292
303
  // ../constants/src/cloudflare.ts
@@ -1064,7 +1075,7 @@ var package_default;
1064
1075
  var init_package = __esm(() => {
1065
1076
  package_default = {
1066
1077
  name: "@playcademy/sandbox",
1067
- version: "0.4.1-beta.1",
1078
+ version: "0.4.1-beta.2",
1068
1079
  description: "Local development server for Playcademy game development",
1069
1080
  type: "module",
1070
1081
  exports: {
@@ -29890,7 +29901,7 @@ function isValidAdminAttributionDate(value) {
29890
29901
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29891
29902
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29892
29903
  }
29893
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameActivityMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29904
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29894
29905
  var init_schemas4 = __esm(() => {
29895
29906
  init_drizzle_zod();
29896
29907
  init_esm();
@@ -29965,22 +29976,22 @@ var init_schemas4 = __esm(() => {
29965
29976
  message: "Cannot provide both masteredUnits and masteredUnitsAbsolute",
29966
29977
  path: ["masteredUnitsAbsolute"]
29967
29978
  });
29968
- GameActivityMetricsSchema = exports_external.object({
29979
+ GameRunMetricsSchema = exports_external.object({
29980
+ runId: exports_external.string().uuid(),
29969
29981
  activityId: exports_external.string().min(1),
29970
29982
  activityName: exports_external.string().optional(),
29971
- totalXp: exports_external.number().nonnegative(),
29972
- masteredUnits: exports_external.number().int().nonnegative(),
29973
- activeTimeSeconds: exports_external.number().nonnegative(),
29974
- completionCount: exports_external.number().int().nonnegative(),
29975
- lastCompletedAt: exports_external.string().datetime().optional()
29983
+ totalXp: exports_external.number().nonnegative().optional(),
29984
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
29985
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
29986
+ score: exports_external.number().min(0).max(100).optional()
29976
29987
  });
29977
29988
  GameCourseMetricsSchema = exports_external.object({
29978
29989
  grade: TimebackGradeSchema,
29979
29990
  subject: TimebackSubjectSchema,
29980
- totalXp: exports_external.number().nonnegative(),
29981
- masteredUnits: exports_external.number().int().nonnegative(),
29982
- activeTimeSeconds: exports_external.number().nonnegative(),
29983
- activities: exports_external.array(GameActivityMetricsSchema).optional()
29991
+ totalXp: exports_external.number().nonnegative().optional(),
29992
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
29993
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
29994
+ activities: exports_external.array(GameRunMetricsSchema).optional()
29984
29995
  });
29985
29996
  GameMetricsResponseSchema = exports_external.object({
29986
29997
  studentId: exports_external.string().min(1),
@@ -30257,6 +30268,124 @@ var init_timeback_admin_util = __esm(() => {
30257
30268
  init_errors();
30258
30269
  });
30259
30270
 
30271
+ // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30272
+ function createMetricRow(definition) {
30273
+ const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30274
+ if (timebackValue === undefined && gameValue === undefined) {
30275
+ return null;
30276
+ }
30277
+ if (gameValue === undefined) {
30278
+ return {
30279
+ metric,
30280
+ kind,
30281
+ status: "not_reported_by_game",
30282
+ ...timebackValue !== undefined ? { timebackValue } : {}
30283
+ };
30284
+ }
30285
+ if (timebackValue === undefined) {
30286
+ return {
30287
+ metric,
30288
+ kind,
30289
+ status: "not_recorded_by_timeback",
30290
+ gameValue
30291
+ };
30292
+ }
30293
+ const delta = gameValue - timebackValue;
30294
+ const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30295
+ return {
30296
+ metric,
30297
+ kind,
30298
+ status: isDiscrepant ? "discrepant" : "matched",
30299
+ timebackValue,
30300
+ gameValue,
30301
+ delta
30302
+ };
30303
+ }
30304
+ function createRunComparison(activity, gameRun) {
30305
+ const runId = activity.runId ?? "";
30306
+ if (!gameRun) {
30307
+ return {
30308
+ runId,
30309
+ status: "not_reported",
30310
+ discrepancyCount: 0,
30311
+ rows: []
30312
+ };
30313
+ }
30314
+ const rows = [
30315
+ createMetricRow({
30316
+ metric: "xp",
30317
+ kind: "number",
30318
+ timebackValue: activity.xpDelta,
30319
+ gameValue: gameRun.totalXp,
30320
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30321
+ }),
30322
+ createMetricRow({
30323
+ metric: "mastery",
30324
+ kind: "number",
30325
+ timebackValue: activity.masteredUnitsDelta,
30326
+ gameValue: gameRun.masteredUnits,
30327
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30328
+ }),
30329
+ createMetricRow({
30330
+ metric: "time",
30331
+ kind: "time",
30332
+ timebackValue: activity.timeDeltaSeconds,
30333
+ gameValue: gameRun.activeTimeSeconds,
30334
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30335
+ }),
30336
+ createMetricRow({
30337
+ metric: "score",
30338
+ kind: "percent",
30339
+ timebackValue: activity.score,
30340
+ gameValue: gameRun.score,
30341
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30342
+ })
30343
+ ].filter((row) => row !== null);
30344
+ const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30345
+ return {
30346
+ runId,
30347
+ status: discrepancyCount > 0 ? "discrepant" : "matched",
30348
+ discrepancyCount,
30349
+ rows
30350
+ };
30351
+ }
30352
+ function summarizeGameRunMetricsComparison(comparison) {
30353
+ return {
30354
+ runId: comparison.runId,
30355
+ status: comparison.status,
30356
+ discrepancyCount: comparison.discrepancyCount,
30357
+ ...comparison.reason ? { reason: comparison.reason } : {}
30358
+ };
30359
+ }
30360
+ function buildGameRunMetricComparisons(activities, course, response) {
30361
+ const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
30362
+ const comparisons = new Map;
30363
+ if (activitiesWithRunIds.length === 0) {
30364
+ return comparisons;
30365
+ }
30366
+ if (!response.supported) {
30367
+ for (const activity of activitiesWithRunIds) {
30368
+ comparisons.set(activity.runId, {
30369
+ runId: activity.runId,
30370
+ status: "unavailable",
30371
+ discrepancyCount: 0,
30372
+ reason: response.reason,
30373
+ rows: []
30374
+ });
30375
+ }
30376
+ return comparisons;
30377
+ }
30378
+ const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
30379
+ const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
30380
+ for (const activity of activitiesWithRunIds) {
30381
+ comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
30382
+ }
30383
+ return comparisons;
30384
+ }
30385
+ var init_timeback_game_metrics_comparison_util = __esm(() => {
30386
+ init_src();
30387
+ });
30388
+
30260
30389
  // ../api-core/src/utils/timeback-mastery-completion.util.ts
30261
30390
  async function upsertMasteryCompletionEntry(params) {
30262
30391
  const { client, courseId, studentId, appName, action } = params;
@@ -30647,6 +30776,8 @@ class TimebackAdminService {
30647
30776
  static ANALYTICS_CONCURRENCY = 8;
30648
30777
  static MASTERABLE_UNITS_CONCURRENCY = 4;
30649
30778
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
30779
+ static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
30780
+ static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
30650
30781
  constructor(deps) {
30651
30782
  this.deps = deps;
30652
30783
  }
@@ -30656,13 +30787,42 @@ class TimebackAdminService {
30656
30787
  }
30657
30788
  return this.deps.config.localGameUrls[slug2] ?? deployedUrl;
30658
30789
  }
30659
- static resolveGameMetricsUrl(baseUrl) {
30790
+ static resolveGameMetricsUrl(baseUrl, runIds) {
30660
30791
  try {
30661
- return new URL("/__playcademy/metrics", baseUrl);
30792
+ const url2 = new URL("/__playcademy/metrics", baseUrl);
30793
+ for (const runId of runIds ?? []) {
30794
+ url2.searchParams.append("runId", runId);
30795
+ }
30796
+ return url2;
30662
30797
  } catch {
30663
30798
  return null;
30664
30799
  }
30665
30800
  }
30801
+ static normalizeRunIds(runIds, limit = Number.POSITIVE_INFINITY) {
30802
+ const normalized = [];
30803
+ const seen = new Set;
30804
+ for (const runId of runIds ?? []) {
30805
+ const value = runId.trim().toLowerCase();
30806
+ if (isValidUUID(value) && !seen.has(value)) {
30807
+ seen.add(value);
30808
+ normalized.push(value);
30809
+ if (normalized.length >= limit) {
30810
+ break;
30811
+ }
30812
+ }
30813
+ }
30814
+ return normalized;
30815
+ }
30816
+ static chunkRunIds(runIds) {
30817
+ const chunks = [];
30818
+ for (let index2 = 0;index2 < runIds.length; index2 += this.GAME_METRICS_RUN_IDS_PER_REQUEST) {
30819
+ chunks.push(runIds.slice(index2, index2 + this.GAME_METRICS_RUN_IDS_PER_REQUEST));
30820
+ }
30821
+ return chunks;
30822
+ }
30823
+ static isAbortError(error) {
30824
+ return error instanceof Error && error.name === "AbortError";
30825
+ }
30666
30826
  static roundXpToTenths(value) {
30667
30827
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
30668
30828
  return Object.is(rounded, -0) ? 0 : rounded;
@@ -30823,6 +30983,56 @@ class TimebackAdminService {
30823
30983
  const remediationItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
30824
30984
  return [...groupedGameplayItems, ...remediationItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
30825
30985
  }
30986
+ async getGameMetricComparisonsForActivities(user, options) {
30987
+ const runIds = TimebackAdminService.normalizeRunIds(options.activities.map((activity) => activity.runId).filter((runId) => Boolean(runId)));
30988
+ if (runIds.length === 0) {
30989
+ return new Map;
30990
+ }
30991
+ const activitiesByRunId = new Map(options.activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0).map((activity) => [activity.runId.toLowerCase(), activity]));
30992
+ const comparisons = new Map;
30993
+ await Promise.all(TimebackAdminService.chunkRunIds(runIds).map(async (chunk) => {
30994
+ const activities = [];
30995
+ for (const runId of chunk) {
30996
+ const activity = activitiesByRunId.get(runId);
30997
+ if (activity) {
30998
+ activities.push(activity);
30999
+ }
31000
+ }
31001
+ if (activities.length === 0) {
31002
+ return;
31003
+ }
31004
+ let response;
31005
+ try {
31006
+ response = await this.getGameMetrics(options.gameId, options.studentId, user, {
31007
+ runIds: chunk,
31008
+ timeoutMs: options.timeoutMs
31009
+ });
31010
+ } catch (error) {
31011
+ response = {
31012
+ supported: false,
31013
+ reason: "fetch_failed",
31014
+ details: error instanceof Error ? error.message : String(error)
31015
+ };
31016
+ }
31017
+ for (const [runId, comparison] of buildGameRunMetricComparisons(activities, options.course, response)) {
31018
+ comparisons.set(runId, comparison);
31019
+ }
31020
+ }));
31021
+ return comparisons;
31022
+ }
31023
+ async attachGameMetricSummariesToActivities(user, options) {
31024
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, options);
31025
+ if (comparisons.size === 0) {
31026
+ return [...options.activities];
31027
+ }
31028
+ return options.activities.map((activity) => {
31029
+ const comparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31030
+ return comparison ? {
31031
+ ...activity,
31032
+ gameMetricsComparison: summarizeGameRunMetricsComparison(comparison)
31033
+ } : activity;
31034
+ });
31035
+ }
30826
31036
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
30827
31037
  const enrollments = new Map;
30828
31038
  const allEnrollments = new Map;
@@ -30982,7 +31192,7 @@ class TimebackAdminService {
30982
31192
  });
30983
31193
  return { gameId, courseId, students: deduped };
30984
31194
  }
30985
- async getGameMetrics(gameId, timebackId, user) {
31195
+ async getGameMetrics(gameId, timebackId, user, options) {
30986
31196
  const client = this.requireClient();
30987
31197
  await this.deps.validateGameManagementAccess(user, gameId);
30988
31198
  const [targetUser, integrations, game2, deployment] = await Promise.all([
@@ -31017,7 +31227,8 @@ class TimebackAdminService {
31017
31227
  if (!metricsBaseUrl) {
31018
31228
  return { supported: false, reason: "no_active_deployment" };
31019
31229
  }
31020
- const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl);
31230
+ const runIds = TimebackAdminService.normalizeRunIds(options?.runIds, TimebackAdminService.GAME_METRICS_RUN_IDS_PER_REQUEST);
31231
+ const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl, runIds);
31021
31232
  if (!metricsUrl) {
31022
31233
  return {
31023
31234
  supported: false,
@@ -31027,7 +31238,7 @@ class TimebackAdminService {
31027
31238
  }
31028
31239
  const token = await this.deps.mintPlatformServiceToken(gameId, targetUser.id);
31029
31240
  const controller = new AbortController;
31030
- const timeout = setTimeout(() => controller.abort(), TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
31241
+ const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
31031
31242
  let response;
31032
31243
  try {
31033
31244
  response = await fetch(metricsUrl, {
@@ -31039,10 +31250,19 @@ class TimebackAdminService {
31039
31250
  signal: controller.signal
31040
31251
  });
31041
31252
  } catch (error) {
31253
+ const timedOut = TimebackAdminService.isAbortError(error);
31254
+ let details;
31255
+ if (timedOut) {
31256
+ details = "Game metrics request timed out";
31257
+ } else if (error instanceof Error) {
31258
+ details = error.message;
31259
+ } else {
31260
+ details = String(error);
31261
+ }
31042
31262
  return {
31043
31263
  supported: false,
31044
- reason: "fetch_failed",
31045
- details: error instanceof Error ? error.message : String(error)
31264
+ reason: timedOut ? "timeout" : "fetch_failed",
31265
+ details
31046
31266
  };
31047
31267
  } finally {
31048
31268
  clearTimeout(timeout);
@@ -31146,7 +31366,14 @@ class TimebackAdminService {
31146
31366
  const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
31147
31367
  const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
31148
31368
  const hasMore = allActivities.length > safeOffset + safeLimit;
31149
- return { activities, hasMore };
31369
+ const activitiesWithGameMetrics = await this.attachGameMetricSummariesToActivities(user, {
31370
+ gameId,
31371
+ studentId,
31372
+ course: { grade: integration.grade, subject: integration.subject },
31373
+ activities,
31374
+ timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31375
+ });
31376
+ return { activities: activitiesWithGameMetrics, hasMore };
31150
31377
  }
31151
31378
  async getActivityDetail(user, options) {
31152
31379
  const { gameId, studentId, courseId, activityId, runId } = options;
@@ -31182,7 +31409,22 @@ class TimebackAdminService {
31182
31409
  if (!activity) {
31183
31410
  throw new NotFoundError("Activity", activityId);
31184
31411
  }
31185
- return { activity, rawEvents: matchedEvents };
31412
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, {
31413
+ gameId,
31414
+ studentId,
31415
+ course: { grade: integration.grade, subject: integration.subject },
31416
+ activities: [activity]
31417
+ });
31418
+ const gameMetricsComparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31419
+ const activityWithGameMetrics = gameMetricsComparison ? {
31420
+ ...activity,
31421
+ gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison)
31422
+ } : activity;
31423
+ return {
31424
+ activity: activityWithGameMetrics,
31425
+ rawEvents: matchedEvents,
31426
+ ...gameMetricsComparison ? { gameMetricsComparison } : {}
31427
+ };
31186
31428
  }
31187
31429
  async grantManualXp(data, user) {
31188
31430
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
@@ -31470,6 +31712,7 @@ var init_timeback_admin_service = __esm(() => {
31470
31712
  init_errors();
31471
31713
  init_timeback_admin_metrics_util();
31472
31714
  init_timeback_admin_util();
31715
+ init_timeback_game_metrics_comparison_util();
31473
31716
  init_timeback_mastery_completion_util();
31474
31717
  init_timeback_util();
31475
31718
  logger17 = log.scope("TimebackAdminService");
@@ -94206,15 +94449,19 @@ var init_timeback_controller = __esm(() => {
94206
94449
  getGameMetrics = requireGameManagementAccess(async (ctx) => {
94207
94450
  const gameId = ctx.params.gameId;
94208
94451
  const timebackId = ctx.params.timebackId;
94452
+ const runIds = [
94453
+ ...new Set(ctx.url.searchParams.getAll("runId").map((runId) => runId.trim().toLowerCase()).filter(isValidUUID))
94454
+ ];
94209
94455
  if (!gameId || !timebackId) {
94210
94456
  throw ApiError.badRequest("Missing gameId or timebackId path parameter");
94211
94457
  }
94212
94458
  logger45.debug("Getting game metrics", {
94213
94459
  requesterId: ctx.user.id,
94214
94460
  gameId,
94215
- timebackId
94461
+ timebackId,
94462
+ runIds
94216
94463
  });
94217
- return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user);
94464
+ return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user, { runIds });
94218
94465
  });
94219
94466
  getStudentActivity = requireGameManagementAccess(async (ctx) => {
94220
94467
  const timebackId = ctx.params.timebackId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.4.1-beta.1",
3
+ "version": "0.4.1-beta.2",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {