@playcademy/vite-plugin 0.3.2 → 0.3.3-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.
Files changed (2) hide show
  1. package/dist/index.js +548 -37
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -23746,7 +23746,7 @@ import path from "node:path";
23746
23746
  // package.json
23747
23747
  var package_default = {
23748
23748
  name: "@playcademy/vite-plugin",
23749
- version: "0.3.2",
23749
+ version: "0.3.3-beta.2",
23750
23750
  type: "module",
23751
23751
  exports: {
23752
23752
  ".": {
@@ -24339,10 +24339,13 @@ var TIMEBACK_COURSE_DEFAULTS;
24339
24339
  var TIMEBACK_RESOURCE_DEFAULTS;
24340
24340
  var TIMEBACK_COMPONENT_DEFAULTS;
24341
24341
  var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS;
24342
+ var TIMEBACK_GAME_METRIC_DECIMAL_PLACES;
24343
+ var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
24342
24344
  var init_timeback2 = __esm(() => {
24343
24345
  TIMEBACK_ROUTES = {
24344
24346
  END_ACTIVITY: "/integrations/timeback/end-activity",
24345
24347
  GET_XP: "/integrations/timeback/xp",
24348
+ GET_MASTERY: "/integrations/timeback/mastery",
24346
24349
  HEARTBEAT: "/integrations/timeback/heartbeat",
24347
24350
  ADVANCE_COURSE: "/integrations/timeback/advance-course"
24348
24351
  };
@@ -24382,6 +24385,17 @@ var init_timeback2 = __esm(() => {
24382
24385
  sortOrder: 1,
24383
24386
  lessonType: "quiz"
24384
24387
  };
24388
+ TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
24389
+ xp: 1,
24390
+ mastery: 0,
24391
+ score: 2
24392
+ };
24393
+ TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
24394
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
24395
+ mastery: 0,
24396
+ time: 60,
24397
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
24398
+ };
24385
24399
  });
24386
24400
  var WORKER_NAMING;
24387
24401
  var SECRETS_PREFIX = "secrets_";
@@ -25176,7 +25190,7 @@ var package_default2;
25176
25190
  var init_package = __esm(() => {
25177
25191
  package_default2 = {
25178
25192
  name: "@playcademy/sandbox",
25179
- version: "0.4.0",
25193
+ version: "0.4.1-beta.2",
25180
25194
  description: "Local development server for Playcademy game development",
25181
25195
  type: "module",
25182
25196
  exports: {
@@ -52124,6 +52138,7 @@ var init_constants3 = __esm(() => {
52124
52138
  TIMEBACK: {
52125
52139
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
52126
52140
  GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
52141
+ GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
52127
52142
  HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
52128
52143
  ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`
52129
52144
  }
@@ -53871,7 +53886,7 @@ var CourseGoalsSchema;
53871
53886
  var UpdateGameTimebackIntegrationRequestSchema;
53872
53887
  var TimebackActivityDataSchema;
53873
53888
  var EndActivityRequestSchema;
53874
- var GameActivityMetricsSchema;
53889
+ var GameRunMetricsSchema;
53875
53890
  var GameCourseMetricsSchema;
53876
53891
  var GameMetricsResponseSchema;
53877
53892
  var AdvanceCourseRequestSchema;
@@ -53964,24 +53979,28 @@ var init_schemas4 = __esm(() => {
53964
53979
  }).optional(),
53965
53980
  xpEarned: exports_external.number().optional(),
53966
53981
  masteredUnits: exports_external.number().optional(),
53982
+ masteredUnitsAbsolute: exports_external.number().int().nonnegative().optional(),
53967
53983
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
53984
+ }).refine((data) => !(data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined), {
53985
+ message: "Cannot provide both masteredUnits and masteredUnitsAbsolute",
53986
+ path: ["masteredUnitsAbsolute"]
53968
53987
  });
53969
- GameActivityMetricsSchema = exports_external.object({
53988
+ GameRunMetricsSchema = exports_external.object({
53989
+ runId: exports_external.string().uuid(),
53970
53990
  activityId: exports_external.string().min(1),
53971
53991
  activityName: exports_external.string().optional(),
53972
- totalXp: exports_external.number().nonnegative(),
53973
- masteredUnits: exports_external.number().int().nonnegative(),
53974
- activeTimeSeconds: exports_external.number().nonnegative(),
53975
- completionCount: exports_external.number().int().nonnegative(),
53976
- lastCompletedAt: exports_external.string().datetime().optional()
53992
+ totalXp: exports_external.number().nonnegative().optional(),
53993
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
53994
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
53995
+ score: exports_external.number().min(0).max(100).optional()
53977
53996
  });
53978
53997
  GameCourseMetricsSchema = exports_external.object({
53979
53998
  grade: TimebackGradeSchema,
53980
53999
  subject: TimebackSubjectSchema,
53981
- totalXp: exports_external.number().nonnegative(),
53982
- masteredUnits: exports_external.number().int().nonnegative(),
53983
- activeTimeSeconds: exports_external.number().nonnegative(),
53984
- activities: exports_external.array(GameActivityMetricsSchema).optional()
54000
+ totalXp: exports_external.number().nonnegative().optional(),
54001
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
54002
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
54003
+ activities: exports_external.array(GameRunMetricsSchema).optional()
53985
54004
  });
53986
54005
  GameMetricsResponseSchema = exports_external.object({
53987
54006
  studentId: exports_external.string().min(1),
@@ -54251,6 +54270,122 @@ function compareEnrollmentsByRecency(a, b) {
54251
54270
  var init_timeback_admin_util = __esm(() => {
54252
54271
  init_errors();
54253
54272
  });
54273
+ function createMetricRow(definition) {
54274
+ const { gameValue, kind, metric, timebackValue, tolerance } = definition;
54275
+ if (timebackValue === undefined && gameValue === undefined) {
54276
+ return null;
54277
+ }
54278
+ if (gameValue === undefined) {
54279
+ return {
54280
+ metric,
54281
+ kind,
54282
+ status: "not_reported_by_game",
54283
+ ...timebackValue !== undefined ? { timebackValue } : {}
54284
+ };
54285
+ }
54286
+ if (timebackValue === undefined) {
54287
+ return {
54288
+ metric,
54289
+ kind,
54290
+ status: "not_recorded_by_timeback",
54291
+ gameValue
54292
+ };
54293
+ }
54294
+ const delta = gameValue - timebackValue;
54295
+ const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
54296
+ return {
54297
+ metric,
54298
+ kind,
54299
+ status: isDiscrepant ? "discrepant" : "matched",
54300
+ timebackValue,
54301
+ gameValue,
54302
+ delta
54303
+ };
54304
+ }
54305
+ function createRunComparison(activity, gameRun) {
54306
+ const runId = activity.runId ?? "";
54307
+ if (!gameRun) {
54308
+ return {
54309
+ runId,
54310
+ status: "not_reported",
54311
+ discrepancyCount: 0,
54312
+ rows: []
54313
+ };
54314
+ }
54315
+ const rows = [
54316
+ createMetricRow({
54317
+ metric: "xp",
54318
+ kind: "number",
54319
+ timebackValue: activity.xpDelta,
54320
+ gameValue: gameRun.totalXp,
54321
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
54322
+ }),
54323
+ createMetricRow({
54324
+ metric: "mastery",
54325
+ kind: "number",
54326
+ timebackValue: activity.masteredUnitsDelta,
54327
+ gameValue: gameRun.masteredUnits,
54328
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
54329
+ }),
54330
+ createMetricRow({
54331
+ metric: "time",
54332
+ kind: "time",
54333
+ timebackValue: activity.timeDeltaSeconds,
54334
+ gameValue: gameRun.activeTimeSeconds,
54335
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
54336
+ }),
54337
+ createMetricRow({
54338
+ metric: "score",
54339
+ kind: "percent",
54340
+ timebackValue: activity.score,
54341
+ gameValue: gameRun.score,
54342
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
54343
+ })
54344
+ ].filter((row) => row !== null);
54345
+ const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
54346
+ return {
54347
+ runId,
54348
+ status: discrepancyCount > 0 ? "discrepant" : "matched",
54349
+ discrepancyCount,
54350
+ rows
54351
+ };
54352
+ }
54353
+ function summarizeGameRunMetricsComparison(comparison) {
54354
+ return {
54355
+ runId: comparison.runId,
54356
+ status: comparison.status,
54357
+ discrepancyCount: comparison.discrepancyCount,
54358
+ ...comparison.reason ? { reason: comparison.reason } : {}
54359
+ };
54360
+ }
54361
+ function buildGameRunMetricComparisons(activities, course, response) {
54362
+ const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
54363
+ const comparisons = new Map;
54364
+ if (activitiesWithRunIds.length === 0) {
54365
+ return comparisons;
54366
+ }
54367
+ if (!response.supported) {
54368
+ for (const activity of activitiesWithRunIds) {
54369
+ comparisons.set(activity.runId, {
54370
+ runId: activity.runId,
54371
+ status: "unavailable",
54372
+ discrepancyCount: 0,
54373
+ reason: response.reason,
54374
+ rows: []
54375
+ });
54376
+ }
54377
+ return comparisons;
54378
+ }
54379
+ const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
54380
+ const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
54381
+ for (const activity of activitiesWithRunIds) {
54382
+ comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
54383
+ }
54384
+ return comparisons;
54385
+ }
54386
+ var init_timeback_game_metrics_comparison_util = __esm(() => {
54387
+ init_src();
54388
+ });
54254
54389
  async function upsertMasteryCompletionEntry(params) {
54255
54390
  const { client, courseId, studentId, appName, action } = params;
54256
54391
  const ids = deriveSourcedIds(courseId);
@@ -54325,7 +54460,7 @@ function mapEnrollmentsToUserEnrollments(enrollments, integrations) {
54325
54460
  subject: integration.subject,
54326
54461
  courseId: integration.courseId,
54327
54462
  orgId: courseToSchool.get(integration.courseId),
54328
- ...enrollment ? { enrollmentIds: { active: enrollment.sourcedId } } : {}
54463
+ ...enrollment ? { id: enrollment.sourcedId } : {}
54329
54464
  };
54330
54465
  });
54331
54466
  }
@@ -54637,6 +54772,8 @@ class TimebackAdminService {
54637
54772
  static ANALYTICS_CONCURRENCY = 8;
54638
54773
  static MASTERABLE_UNITS_CONCURRENCY = 4;
54639
54774
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
54775
+ static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
54776
+ static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
54640
54777
  constructor(deps) {
54641
54778
  this.deps = deps;
54642
54779
  }
@@ -54646,13 +54783,42 @@ class TimebackAdminService {
54646
54783
  }
54647
54784
  return this.deps.config.localGameUrls[slug2] ?? deployedUrl;
54648
54785
  }
54649
- static resolveGameMetricsUrl(baseUrl) {
54786
+ static resolveGameMetricsUrl(baseUrl, runIds) {
54650
54787
  try {
54651
- return new URL("/__playcademy/metrics", baseUrl);
54788
+ const url2 = new URL("/__playcademy/metrics", baseUrl);
54789
+ for (const runId of runIds ?? []) {
54790
+ url2.searchParams.append("runId", runId);
54791
+ }
54792
+ return url2;
54652
54793
  } catch {
54653
54794
  return null;
54654
54795
  }
54655
54796
  }
54797
+ static normalizeRunIds(runIds, limit = Number.POSITIVE_INFINITY) {
54798
+ const normalized = [];
54799
+ const seen = new Set;
54800
+ for (const runId of runIds ?? []) {
54801
+ const value = runId.trim().toLowerCase();
54802
+ if (isValidUUID(value) && !seen.has(value)) {
54803
+ seen.add(value);
54804
+ normalized.push(value);
54805
+ if (normalized.length >= limit) {
54806
+ break;
54807
+ }
54808
+ }
54809
+ }
54810
+ return normalized;
54811
+ }
54812
+ static chunkRunIds(runIds) {
54813
+ const chunks = [];
54814
+ for (let index2 = 0;index2 < runIds.length; index2 += this.GAME_METRICS_RUN_IDS_PER_REQUEST) {
54815
+ chunks.push(runIds.slice(index2, index2 + this.GAME_METRICS_RUN_IDS_PER_REQUEST));
54816
+ }
54817
+ return chunks;
54818
+ }
54819
+ static isAbortError(error) {
54820
+ return error instanceof Error && error.name === "AbortError";
54821
+ }
54656
54822
  static roundXpToTenths(value) {
54657
54823
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
54658
54824
  return Object.is(rounded, -0) ? 0 : rounded;
@@ -54813,6 +54979,56 @@ class TimebackAdminService {
54813
54979
  const remediationItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
54814
54980
  return [...groupedGameplayItems, ...remediationItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
54815
54981
  }
54982
+ async getGameMetricComparisonsForActivities(user, options) {
54983
+ const runIds = TimebackAdminService.normalizeRunIds(options.activities.map((activity) => activity.runId).filter((runId) => Boolean(runId)));
54984
+ if (runIds.length === 0) {
54985
+ return new Map;
54986
+ }
54987
+ const activitiesByRunId = new Map(options.activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0).map((activity) => [activity.runId.toLowerCase(), activity]));
54988
+ const comparisons = new Map;
54989
+ await Promise.all(TimebackAdminService.chunkRunIds(runIds).map(async (chunk) => {
54990
+ const activities = [];
54991
+ for (const runId of chunk) {
54992
+ const activity = activitiesByRunId.get(runId);
54993
+ if (activity) {
54994
+ activities.push(activity);
54995
+ }
54996
+ }
54997
+ if (activities.length === 0) {
54998
+ return;
54999
+ }
55000
+ let response;
55001
+ try {
55002
+ response = await this.getGameMetrics(options.gameId, options.studentId, user, {
55003
+ runIds: chunk,
55004
+ timeoutMs: options.timeoutMs
55005
+ });
55006
+ } catch (error) {
55007
+ response = {
55008
+ supported: false,
55009
+ reason: "fetch_failed",
55010
+ details: error instanceof Error ? error.message : String(error)
55011
+ };
55012
+ }
55013
+ for (const [runId, comparison] of buildGameRunMetricComparisons(activities, options.course, response)) {
55014
+ comparisons.set(runId, comparison);
55015
+ }
55016
+ }));
55017
+ return comparisons;
55018
+ }
55019
+ async attachGameMetricSummariesToActivities(user, options) {
55020
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, options);
55021
+ if (comparisons.size === 0) {
55022
+ return [...options.activities];
55023
+ }
55024
+ return options.activities.map((activity) => {
55025
+ const comparison = activity.runId ? comparisons.get(activity.runId) : undefined;
55026
+ return comparison ? {
55027
+ ...activity,
55028
+ gameMetricsComparison: summarizeGameRunMetricsComparison(comparison)
55029
+ } : activity;
55030
+ });
55031
+ }
54816
55032
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
54817
55033
  const enrollments = new Map;
54818
55034
  const allEnrollments = new Map;
@@ -54972,7 +55188,7 @@ class TimebackAdminService {
54972
55188
  });
54973
55189
  return { gameId, courseId, students: deduped };
54974
55190
  }
54975
- async getGameMetrics(gameId, timebackId, user) {
55191
+ async getGameMetrics(gameId, timebackId, user, options) {
54976
55192
  const client = this.requireClient();
54977
55193
  await this.deps.validateGameManagementAccess(user, gameId);
54978
55194
  const [targetUser, integrations, game2, deployment] = await Promise.all([
@@ -55007,7 +55223,8 @@ class TimebackAdminService {
55007
55223
  if (!metricsBaseUrl) {
55008
55224
  return { supported: false, reason: "no_active_deployment" };
55009
55225
  }
55010
- const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl);
55226
+ const runIds = TimebackAdminService.normalizeRunIds(options?.runIds, TimebackAdminService.GAME_METRICS_RUN_IDS_PER_REQUEST);
55227
+ const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl, runIds);
55011
55228
  if (!metricsUrl) {
55012
55229
  return {
55013
55230
  supported: false,
@@ -55017,7 +55234,7 @@ class TimebackAdminService {
55017
55234
  }
55018
55235
  const token = await this.deps.mintPlatformServiceToken(gameId, targetUser.id);
55019
55236
  const controller = new AbortController;
55020
- const timeout = setTimeout(() => controller.abort(), TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
55237
+ const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
55021
55238
  let response;
55022
55239
  try {
55023
55240
  response = await fetch(metricsUrl, {
@@ -55029,10 +55246,19 @@ class TimebackAdminService {
55029
55246
  signal: controller.signal
55030
55247
  });
55031
55248
  } catch (error) {
55249
+ const timedOut = TimebackAdminService.isAbortError(error);
55250
+ let details;
55251
+ if (timedOut) {
55252
+ details = "Game metrics request timed out";
55253
+ } else if (error instanceof Error) {
55254
+ details = error.message;
55255
+ } else {
55256
+ details = String(error);
55257
+ }
55032
55258
  return {
55033
55259
  supported: false,
55034
- reason: "fetch_failed",
55035
- details: error instanceof Error ? error.message : String(error)
55260
+ reason: timedOut ? "timeout" : "fetch_failed",
55261
+ details
55036
55262
  };
55037
55263
  } finally {
55038
55264
  clearTimeout(timeout);
@@ -55136,7 +55362,14 @@ class TimebackAdminService {
55136
55362
  const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
55137
55363
  const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
55138
55364
  const hasMore = allActivities.length > safeOffset + safeLimit;
55139
- return { activities, hasMore };
55365
+ const activitiesWithGameMetrics = await this.attachGameMetricSummariesToActivities(user, {
55366
+ gameId,
55367
+ studentId,
55368
+ course: { grade: integration.grade, subject: integration.subject },
55369
+ activities,
55370
+ timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
55371
+ });
55372
+ return { activities: activitiesWithGameMetrics, hasMore };
55140
55373
  }
55141
55374
  async getActivityDetail(user, options) {
55142
55375
  const { gameId, studentId, courseId, activityId, runId } = options;
@@ -55172,7 +55405,22 @@ class TimebackAdminService {
55172
55405
  if (!activity) {
55173
55406
  throw new NotFoundError("Activity", activityId);
55174
55407
  }
55175
- return { activity, rawEvents: matchedEvents };
55408
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, {
55409
+ gameId,
55410
+ studentId,
55411
+ course: { grade: integration.grade, subject: integration.subject },
55412
+ activities: [activity]
55413
+ });
55414
+ const gameMetricsComparison = activity.runId ? comparisons.get(activity.runId) : undefined;
55415
+ const activityWithGameMetrics = gameMetricsComparison ? {
55416
+ ...activity,
55417
+ gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison)
55418
+ } : activity;
55419
+ return {
55420
+ activity: activityWithGameMetrics,
55421
+ rawEvents: matchedEvents,
55422
+ ...gameMetricsComparison ? { gameMetricsComparison } : {}
55423
+ };
55176
55424
  }
55177
55425
  async grantManualXp(data, user) {
55178
55426
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
@@ -55460,6 +55708,7 @@ var init_timeback_admin_service = __esm(() => {
55460
55708
  init_errors();
55461
55709
  init_timeback_admin_metrics_util();
55462
55710
  init_timeback_admin_util();
55711
+ init_timeback_game_metrics_comparison_util();
55463
55712
  init_timeback_mastery_completion_util();
55464
55713
  init_timeback_util();
55465
55714
  logger17 = log.scope("TimebackAdminService");
@@ -56904,6 +57153,7 @@ var init_timeback_service = __esm(() => {
56904
57153
  sessionTimingData,
56905
57154
  xpEarned,
56906
57155
  masteredUnits,
57156
+ masteredUnitsAbsolute,
56907
57157
  extensions,
56908
57158
  user
56909
57159
  }) {
@@ -56927,6 +57177,7 @@ var init_timeback_service = __esm(() => {
56927
57177
  durationSeconds: timingData.durationSeconds,
56928
57178
  xpEarned,
56929
57179
  masteredUnits,
57180
+ masteredUnitsAbsolute,
56930
57181
  extensions: extensionsWithResumeId,
56931
57182
  activityId: activityData.activityId,
56932
57183
  activityName: activityData.activityName,
@@ -57175,6 +57426,46 @@ var init_timeback_service = __esm(() => {
57175
57426
  });
57176
57427
  return result;
57177
57428
  }
57429
+ async getStudentMastery(timebackId, user, options) {
57430
+ const client = this.requireClient();
57431
+ const db2 = this.deps.db;
57432
+ await this.deps.validateDeveloperAccess(user, options.gameId);
57433
+ const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
57434
+ if (options.grade !== undefined && options.subject) {
57435
+ conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
57436
+ conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
57437
+ }
57438
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
57439
+ where: and(...conditions2)
57440
+ });
57441
+ const courseIds = integrations.map((i2) => i2.courseId);
57442
+ if (courseIds.length === 0) {
57443
+ logger20.debug("No integrations found for game, returning empty mastery", {
57444
+ timebackId,
57445
+ gameId: options.gameId,
57446
+ grade: options.grade,
57447
+ subject: options.subject
57448
+ });
57449
+ return {
57450
+ totalMasteredUnits: 0,
57451
+ totalMasterableUnits: 0,
57452
+ ...options.include?.perCourse && { courses: [] }
57453
+ };
57454
+ }
57455
+ const result = await client.getStudentMastery(timebackId, {
57456
+ courseIds,
57457
+ include: options.include
57458
+ });
57459
+ logger20.debug("Retrieved student mastery", {
57460
+ timebackId,
57461
+ gameId: options.gameId,
57462
+ grade: options.grade,
57463
+ subject: options.subject,
57464
+ totalMasteredUnits: result.totalMasteredUnits,
57465
+ courseCount: result.courses?.length
57466
+ });
57467
+ return result;
57468
+ }
57178
57469
  };
57179
57470
  });
57180
57471
 
@@ -59481,15 +59772,18 @@ class MasteryTracker {
59481
59772
  this.edubridgeNamespace = edubridgeNamespace;
59482
59773
  }
59483
59774
  async checkProgress(input) {
59484
- const { studentId, courseId, resourceId, masteredUnits } = input;
59485
- if (typeof masteredUnits !== "number" || masteredUnits === 0) {
59775
+ const { studentId, courseId, resourceId, masteredUnits, masteredUnitsAbsolute } = input;
59776
+ const hasIncremental = typeof masteredUnits === "number" && masteredUnits !== 0;
59777
+ const hasAbsolute = typeof masteredUnitsAbsolute === "number";
59778
+ if (!hasIncremental && !hasAbsolute) {
59486
59779
  return;
59487
59780
  }
59488
59781
  const status = await this.calculateStatus({
59489
59782
  studentId,
59490
59783
  courseId,
59491
59784
  resourceId,
59492
- additionalMasteredUnits: masteredUnits
59785
+ additionalMasteredUnits: hasAbsolute ? 0 : masteredUnits,
59786
+ absoluteMasteredUnits: hasAbsolute ? masteredUnitsAbsolute : undefined
59493
59787
  });
59494
59788
  if (!status) {
59495
59789
  return;
@@ -59498,7 +59792,8 @@ class MasteryTracker {
59498
59792
  return {
59499
59793
  pctCompleteApp: status.pctCompleteApp,
59500
59794
  masteryAchieved: !wasComplete && status.isComplete,
59501
- masteryRevoked: wasComplete && !status.isComplete
59795
+ masteryRevoked: wasComplete && !status.isComplete,
59796
+ effectiveDelta: status.effectiveDelta
59502
59797
  };
59503
59798
  }
59504
59799
  async getStatus(input) {
@@ -59520,7 +59815,8 @@ class MasteryTracker {
59520
59815
  studentId,
59521
59816
  courseId,
59522
59817
  resourceId,
59523
- additionalMasteredUnits
59818
+ additionalMasteredUnits,
59819
+ absoluteMasteredUnits
59524
59820
  }) {
59525
59821
  const masterableUnits = await this.resolveMasterableUnits(resourceId);
59526
59822
  if (!masterableUnits || masterableUnits <= 0) {
@@ -59539,7 +59835,15 @@ class MasteryTracker {
59539
59835
  return;
59540
59836
  }
59541
59837
  const historicalMasteredUnits = this.sumAnalyticsMetric(facts, "masteredUnits");
59542
- const totalMastered = Math.max(0, historicalMasteredUnits + additionalMasteredUnits);
59838
+ let totalMastered;
59839
+ let effectiveDelta;
59840
+ if (absoluteMasteredUnits !== undefined) {
59841
+ totalMastered = Math.max(0, absoluteMasteredUnits);
59842
+ effectiveDelta = totalMastered - historicalMasteredUnits;
59843
+ } else {
59844
+ effectiveDelta = additionalMasteredUnits;
59845
+ totalMastered = Math.max(0, historicalMasteredUnits + effectiveDelta);
59846
+ }
59543
59847
  const rawPct = totalMastered / masterableUnits * 100;
59544
59848
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
59545
59849
  return {
@@ -59547,7 +59851,8 @@ class MasteryTracker {
59547
59851
  masterableUnits,
59548
59852
  pctCompleteApp,
59549
59853
  isComplete: totalMastered >= masterableUnits,
59550
- historicalMasteredUnits
59854
+ historicalMasteredUnits,
59855
+ effectiveDelta
59551
59856
  };
59552
59857
  }
59553
59858
  async createCompletionEntry(studentId, courseId, classId, appName) {
@@ -59776,7 +60081,7 @@ class ProgressRecorder {
59776
60081
  validateProgressData(progressData);
59777
60082
  const { ids, activityId, activityName, courseName, student } = await this.resolveContext(courseId, studentIdentifier, progressData);
59778
60083
  const { id: studentId, email: studentEmail } = student;
59779
- const { score, totalQuestions, correctQuestions, xpEarned, masteredUnits, attemptNumber } = progressData;
60084
+ const { score, totalQuestions, correctQuestions, xpEarned, attemptNumber } = progressData;
59780
60085
  const actualLineItemId = await this.resolveAssessmentLineItem(activityId, activityName, progressData.classId, ids);
59781
60086
  const currentAttemptNumber = await this.resolveAttemptNumber(attemptNumber, score, studentId, actualLineItemId);
59782
60087
  const calculatedXp = this.calculateXpForProgress(progressData, totalQuestions, correctQuestions, xpEarned, currentAttemptNumber);
@@ -59785,8 +60090,10 @@ class ProgressRecorder {
59785
60090
  studentId,
59786
60091
  courseId,
59787
60092
  resourceId: ids.resource,
59788
- masteredUnits: progressData.masteredUnits ?? 0
60093
+ masteredUnits: progressData.masteredUnits ?? 0,
60094
+ masteredUnitsAbsolute: progressData.masteredUnitsAbsolute
59789
60095
  });
60096
+ const effectiveMasteredUnits = masteryProgress ? masteryProgress.effectiveDelta : progressData.masteredUnits ?? 0;
59790
60097
  let pctCompleteApp;
59791
60098
  let masteryAchieved = false;
59792
60099
  let scoreStatus = SCORE_STATUS5.fullyGraded;
@@ -59814,7 +60121,7 @@ class ProgressRecorder {
59814
60121
  appName: progressData.appName,
59815
60122
  totalQuestions,
59816
60123
  correctQuestions,
59817
- masteredUnits,
60124
+ masteredUnits: effectiveMasteredUnits || undefined,
59818
60125
  pctCompleteApp
59819
60126
  });
59820
60127
  } else {
@@ -59852,7 +60159,7 @@ class ProgressRecorder {
59852
60159
  totalQuestions,
59853
60160
  correctQuestions,
59854
60161
  xpEarned: calculatedXp,
59855
- masteredUnits,
60162
+ masteredUnits: effectiveMasteredUnits || undefined,
59856
60163
  attemptNumber: currentAttemptNumber,
59857
60164
  progressData,
59858
60165
  extensions,
@@ -59861,7 +60168,7 @@ class ProgressRecorder {
59861
60168
  return {
59862
60169
  xpAwarded: calculatedXp,
59863
60170
  attemptNumber: currentAttemptNumber,
59864
- masteredUnitsApplied: progressData.masteredUnits ?? 0,
60171
+ masteredUnitsApplied: effectiveMasteredUnits,
59865
60172
  pctCompleteApp,
59866
60173
  scoreStatus,
59867
60174
  inProgress
@@ -60396,6 +60703,65 @@ class TimebackClient {
60396
60703
  resourceId: ids.resource
60397
60704
  });
60398
60705
  }
60706
+ async getStudentMastery(studentId, options) {
60707
+ await this._ensureAuthenticated();
60708
+ const enrollments = await this.edubridge.enrollments.listByUser(studentId);
60709
+ const filteredEnrollments = options?.courseIds?.length ? enrollments.filter((e) => options.courseIds.includes(e.course.id)) : enrollments;
60710
+ if (filteredEnrollments.length === 0) {
60711
+ return {
60712
+ totalMasteredUnits: 0,
60713
+ totalMasterableUnits: 0,
60714
+ ...options?.include?.perCourse && { courses: [] }
60715
+ };
60716
+ }
60717
+ const masteryResults = await Promise.all(filteredEnrollments.map(async (enrollment) => {
60718
+ try {
60719
+ const ids = deriveSourcedIds2(enrollment.course.id);
60720
+ const status = await this.masteryTracker.getStatus({
60721
+ studentId,
60722
+ courseId: enrollment.course.id,
60723
+ resourceId: ids.resource
60724
+ });
60725
+ return { enrollment, status };
60726
+ } catch (error) {
60727
+ log.warn("[TimebackClient] Failed to fetch mastery for enrollment", {
60728
+ enrollmentId: enrollment.id,
60729
+ error
60730
+ });
60731
+ return { enrollment, status: undefined };
60732
+ }
60733
+ }));
60734
+ let totalMasteredUnits = 0;
60735
+ let totalMasterableUnits = 0;
60736
+ const courses = [];
60737
+ for (const { enrollment, status } of masteryResults) {
60738
+ const masteredUnits = status?.masteredUnits ?? 0;
60739
+ const masterableUnits = status?.masterableUnits ?? 0;
60740
+ totalMasteredUnits += masteredUnits;
60741
+ totalMasterableUnits += masterableUnits;
60742
+ if (options?.include?.perCourse) {
60743
+ const gradeStr = enrollment.course.grades?.[0];
60744
+ const parsedGrade = gradeStr ? parseInt(gradeStr, 10) : 0;
60745
+ const grade = isTimebackGrade3(parsedGrade) ? parsedGrade : 0;
60746
+ const subjectStr = enrollment.course.subjects?.[0];
60747
+ const subject = subjectStr && isTimebackSubject3(subjectStr) ? subjectStr : "None";
60748
+ courses.push({
60749
+ grade,
60750
+ subject,
60751
+ title: enrollment.course.title,
60752
+ masteredUnits,
60753
+ masterableUnits,
60754
+ pctComplete: status?.pctCompleteApp ?? 0,
60755
+ isComplete: status?.isComplete ?? false
60756
+ });
60757
+ }
60758
+ }
60759
+ return {
60760
+ totalMasteredUnits,
60761
+ totalMasterableUnits,
60762
+ ...options?.include?.perCourse && { courses }
60763
+ };
60764
+ }
60399
60765
  async getStudentXp(studentId, options) {
60400
60766
  await this._ensureAuthenticated();
60401
60767
  const enrollments = await this.edubridge.enrollments.listByUser(studentId);
@@ -120401,6 +120767,7 @@ var endActivity;
120401
120767
  var heartbeat;
120402
120768
  var advanceCourse;
120403
120769
  var getStudentXp;
120770
+ var getStudentMastery;
120404
120771
  var getRoster;
120405
120772
  var getStudentOverview;
120406
120773
  var getGameMetrics;
@@ -120585,6 +120952,7 @@ var init_timeback_controller = __esm(() => {
120585
120952
  sessionTimingData,
120586
120953
  xpEarned,
120587
120954
  masteredUnits,
120955
+ masteredUnitsAbsolute,
120588
120956
  extensions
120589
120957
  } = body2;
120590
120958
  logger45.debug("Ending activity", { userId: ctx.user.id, gameId });
@@ -120599,6 +120967,7 @@ var init_timeback_controller = __esm(() => {
120599
120967
  sessionTimingData,
120600
120968
  xpEarned,
120601
120969
  masteredUnits,
120970
+ masteredUnitsAbsolute,
120602
120971
  extensions,
120603
120972
  user: ctx.user
120604
120973
  });
@@ -120708,6 +121077,53 @@ var init_timeback_controller = __esm(() => {
120708
121077
  include
120709
121078
  });
120710
121079
  });
121080
+ getStudentMastery = requireDeveloper(async (ctx) => {
121081
+ const timebackId = ctx.params.timebackId;
121082
+ if (!timebackId) {
121083
+ throw ApiError.badRequest("Missing timebackId parameter");
121084
+ }
121085
+ const gameId = ctx.url.searchParams.get("gameId");
121086
+ if (!gameId) {
121087
+ throw ApiError.badRequest("Missing required gameId query parameter");
121088
+ }
121089
+ const gradeParam = ctx.url.searchParams.get("grade");
121090
+ const subjectParam = ctx.url.searchParams.get("subject");
121091
+ if (gradeParam !== null !== (subjectParam !== null)) {
121092
+ throw ApiError.badRequest("Both grade and subject must be provided together");
121093
+ }
121094
+ let grade;
121095
+ let subject;
121096
+ if (gradeParam !== null && subjectParam !== null) {
121097
+ const parsedGrade = parseInt(gradeParam, 10);
121098
+ if (!isTimebackGrade2(parsedGrade)) {
121099
+ throw ApiError.badRequest(`Invalid grade: ${gradeParam}. Valid grades: ${TIMEBACK_GRADES.join(", ")}`);
121100
+ }
121101
+ if (!isTimebackSubject2(subjectParam)) {
121102
+ throw ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
121103
+ }
121104
+ grade = parsedGrade;
121105
+ subject = subjectParam;
121106
+ }
121107
+ const includeParam = ctx.url.searchParams.get("include");
121108
+ const includeOptions = includeParam ? includeParam.split(",").map((opt) => opt.trim().toLowerCase()) : [];
121109
+ const include = {
121110
+ perCourse: includeOptions.includes("percourse")
121111
+ };
121112
+ logger45.debug("Getting student mastery", {
121113
+ requesterId: ctx.user.id,
121114
+ timebackId,
121115
+ gameId,
121116
+ grade,
121117
+ subject,
121118
+ include
121119
+ });
121120
+ return ctx.services.timeback.getStudentMastery(timebackId, ctx.user, {
121121
+ gameId,
121122
+ grade,
121123
+ subject,
121124
+ include
121125
+ });
121126
+ });
120711
121127
  getRoster = requireGameManagementAccess(async (ctx) => {
120712
121128
  const gameId = ctx.params.gameId;
120713
121129
  const courseId = ctx.params.courseId;
@@ -120743,15 +121159,19 @@ var init_timeback_controller = __esm(() => {
120743
121159
  getGameMetrics = requireGameManagementAccess(async (ctx) => {
120744
121160
  const gameId = ctx.params.gameId;
120745
121161
  const timebackId = ctx.params.timebackId;
121162
+ const runIds = [
121163
+ ...new Set(ctx.url.searchParams.getAll("runId").map((runId) => runId.trim().toLowerCase()).filter(isValidUUID))
121164
+ ];
120746
121165
  if (!gameId || !timebackId) {
120747
121166
  throw ApiError.badRequest("Missing gameId or timebackId path parameter");
120748
121167
  }
120749
121168
  logger45.debug("Getting game metrics", {
120750
121169
  requesterId: ctx.user.id,
120751
121170
  gameId,
120752
- timebackId
121171
+ timebackId,
121172
+ runIds
120753
121173
  });
120754
- return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user);
121174
+ return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user, { runIds });
120755
121175
  });
120756
121176
  getStudentActivity = requireGameManagementAccess(async (ctx) => {
120757
121177
  const timebackId = ctx.params.timebackId;
@@ -121040,6 +121460,7 @@ var init_timeback_controller = __esm(() => {
121040
121460
  heartbeat,
121041
121461
  advanceCourse,
121042
121462
  getStudentXp,
121463
+ getStudentMastery,
121043
121464
  getRoster,
121044
121465
  getStudentOverview,
121045
121466
  getGameMetrics,
@@ -121890,6 +122311,7 @@ var init_timeback6 = __esm(() => {
121890
122311
  init_controllers();
121891
122312
  init_errors();
121892
122313
  init_utils11();
122314
+ init_schemas_index();
121893
122315
  init_api();
121894
122316
  init_error_handler();
121895
122317
  init_timeback5();
@@ -121969,6 +122391,14 @@ var init_timeback6 = __esm(() => {
121969
122391
  }
121970
122392
  if (gradeParam !== null && subjectParam !== null) {
121971
122393
  const grade = parseInt(gradeParam, 10);
122394
+ if (!Number.isFinite(grade) || !isTimebackGrade2(grade)) {
122395
+ const error2 = ApiError.badRequest(`Invalid grade: ${gradeParam}. Valid grades: ${TIMEBACK_GRADES.join(", ")}`);
122396
+ return c2.json(createErrorResponse(error2), error2.status);
122397
+ }
122398
+ if (!isTimebackSubject2(subjectParam)) {
122399
+ const error2 = ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
122400
+ return c2.json(createErrorResponse(error2), error2.status);
122401
+ }
121972
122402
  enrollments = enrollments.filter((e) => e.grade === grade && e.subject === subjectParam);
121973
122403
  }
121974
122404
  const mockCourses = enrollments.map((e) => {
@@ -121993,6 +122423,62 @@ var init_timeback6 = __esm(() => {
121993
122423
  }
121994
122424
  return handle2(timeback2.getStudentXp)(c2);
121995
122425
  });
122426
+ timebackRouter.get("/student-mastery/:timebackId", async (c2) => {
122427
+ const user = c2.get("user");
122428
+ if (!user) {
122429
+ const error2 = ApiError.unauthorized("Must be logged in to get student mastery");
122430
+ return c2.json(createErrorResponse(error2), error2.status);
122431
+ }
122432
+ if (shouldMockTimeback()) {
122433
+ const url2 = new URL(c2.req.url);
122434
+ const gradeParam = url2.searchParams.get("grade");
122435
+ const subjectParam = url2.searchParams.get("subject");
122436
+ const includeParam = url2.searchParams.get("include") || "";
122437
+ const includeOptions = includeParam.split(",").map((opt) => opt.trim().toLowerCase());
122438
+ const includePerCourse = includeOptions.includes("percourse");
122439
+ const db2 = c2.get("db");
122440
+ let enrollments = await getMockEnrollments(db2);
122441
+ if (gradeParam !== null !== (subjectParam !== null)) {
122442
+ const error2 = ApiError.badRequest("Both grade and subject must be provided together");
122443
+ return c2.json(createErrorResponse(error2), error2.status);
122444
+ }
122445
+ if (gradeParam !== null && subjectParam !== null) {
122446
+ const grade = parseInt(gradeParam, 10);
122447
+ if (!Number.isFinite(grade) || !isTimebackGrade2(grade)) {
122448
+ const error2 = ApiError.badRequest(`Invalid grade: ${gradeParam}. Valid grades: ${TIMEBACK_GRADES.join(", ")}`);
122449
+ return c2.json(createErrorResponse(error2), error2.status);
122450
+ }
122451
+ if (!isTimebackSubject2(subjectParam)) {
122452
+ const error2 = ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
122453
+ return c2.json(createErrorResponse(error2), error2.status);
122454
+ }
122455
+ enrollments = enrollments.filter((e) => e.grade === grade && e.subject === subjectParam);
122456
+ }
122457
+ const mockCourses = enrollments.map((e) => {
122458
+ const seed3 = hashCode(`${e.grade}-${e.subject}`);
122459
+ const masterableUnits = 5 + seed3 % 16;
122460
+ const masteredUnits = seed3 % (masterableUnits + 1);
122461
+ const pctComplete = masterableUnits > 0 ? Math.round(masteredUnits / masterableUnits * 1e4) / 100 : 0;
122462
+ return {
122463
+ grade: e.grade,
122464
+ subject: e.subject,
122465
+ title: `${e.subject} ${formatGradeLabel(e.grade)}`,
122466
+ masteredUnits,
122467
+ masterableUnits,
122468
+ pctComplete,
122469
+ isComplete: masteredUnits >= masterableUnits
122470
+ };
122471
+ });
122472
+ const totalMasteredUnits = mockCourses.reduce((sum2, course) => sum2 + course.masteredUnits, 0);
122473
+ const totalMasterableUnits = mockCourses.reduce((sum2, course) => sum2 + course.masterableUnits, 0);
122474
+ return c2.json({
122475
+ totalMasteredUnits,
122476
+ totalMasterableUnits,
122477
+ ...includePerCourse && { courses: mockCourses }
122478
+ });
122479
+ }
122480
+ return handle2(timeback2.getStudentMastery)(c2);
122481
+ });
121996
122482
  });
121997
122483
  function verifyMockToken(idToken) {
121998
122484
  if (!idToken.startsWith("mock:")) {
@@ -122528,6 +123014,17 @@ var POSTHOG_CONFIG = {
122528
123014
  var TIMEBACK_ORG_SOURCED_ID2 = "PLAYCADEMY";
122529
123015
  var TIMEBACK_ORG_NAME2 = "Playcademy Studios";
122530
123016
  var TIMEBACK_ORG_TYPE2 = "department";
123017
+ var TIMEBACK_GAME_METRIC_DECIMAL_PLACES2 = {
123018
+ xp: 1,
123019
+ mastery: 0,
123020
+ score: 2
123021
+ };
123022
+ var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE2 = {
123023
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES2.xp,
123024
+ mastery: 0,
123025
+ time: 60,
123026
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES2.score
123027
+ };
122531
123028
  // src/lib/sandbox/timeback.ts
122532
123029
  function detectTimebackOptions() {
122533
123030
  if (process.env.TIMEBACK_LOCAL === "true") {
@@ -122744,10 +123241,13 @@ var TIMEBACK_COURSE_DEFAULTS2;
122744
123241
  var TIMEBACK_RESOURCE_DEFAULTS2;
122745
123242
  var TIMEBACK_COMPONENT_DEFAULTS2;
122746
123243
  var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS2;
123244
+ var TIMEBACK_GAME_METRIC_DECIMAL_PLACES3;
123245
+ var TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE3;
122747
123246
  var init_timeback7 = __esm8(() => {
122748
123247
  TIMEBACK_ROUTES2 = {
122749
123248
  END_ACTIVITY: "/integrations/timeback/end-activity",
122750
123249
  GET_XP: "/integrations/timeback/xp",
123250
+ GET_MASTERY: "/integrations/timeback/mastery",
122751
123251
  HEARTBEAT: "/integrations/timeback/heartbeat",
122752
123252
  ADVANCE_COURSE: "/integrations/timeback/advance-course"
122753
123253
  };
@@ -122787,6 +123287,17 @@ var init_timeback7 = __esm8(() => {
122787
123287
  sortOrder: 1,
122788
123288
  lessonType: "quiz"
122789
123289
  };
123290
+ TIMEBACK_GAME_METRIC_DECIMAL_PLACES3 = {
123291
+ xp: 1,
123292
+ mastery: 0,
123293
+ score: 2
123294
+ };
123295
+ TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE3 = {
123296
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES3.xp,
123297
+ mastery: 0,
123298
+ time: 60,
123299
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES3.score
123300
+ };
122790
123301
  });
122791
123302
  var WORKER_NAMING2;
122792
123303
  var init_cloudflare2 = __esm8(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/vite-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.3.3-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {