@playcademy/sandbox 0.12.2-beta.2 → 0.12.2-beta.3

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 +132 -42
  2. package/dist/server.js +132 -42
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2517,7 +2517,7 @@ var package_default;
2517
2517
  var init_package = __esm(() => {
2518
2518
  package_default = {
2519
2519
  name: "@playcademy/sandbox",
2520
- version: "0.12.2-beta.2",
2520
+ version: "0.12.2-beta.3",
2521
2521
  description: "Local development server for Playcademy game development",
2522
2522
  type: "module",
2523
2523
  exports: {
@@ -22888,6 +22888,9 @@ var init_helpers = __esm(() => {
22888
22888
  function isBaseGameTimebackIntegration() {
22889
22889
  return sql`${gameTimebackIntegrations.kind} = ${BASE_GAME_TIMEBACK_INTEGRATION_KIND}`;
22890
22890
  }
22891
+ function isBaseGameTimebackIntegrationRow(integration) {
22892
+ return integration.kind === BASE_GAME_TIMEBACK_INTEGRATION_KIND;
22893
+ }
22891
22894
  function isActiveGameTimebackIntegrationStatus(statusColumn = gameTimebackIntegrations.status) {
22892
22895
  return sql`${statusColumn} IS DISTINCT FROM ${DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS}`;
22893
22896
  }
@@ -50496,6 +50499,7 @@ var init_schemas4 = __esm(() => {
50496
50499
  appName: stringType().optional(),
50497
50500
  sensorUrl: stringType().url().optional(),
50498
50501
  courseId: stringType().min(1).optional(),
50502
+ enrollmentId: stringType().min(1).optional(),
50499
50503
  courseName: stringType().optional(),
50500
50504
  studentEmail: stringType().email().optional()
50501
50505
  });
@@ -92510,6 +92514,22 @@ var init_timeback_assessments_service = __esm(async () => {
92510
92514
  await init_errors8();
92511
92515
  });
92512
92516
 
92517
+ // ../api-core/src/utils/timeback-attribution.util.ts
92518
+ function resolveRuntimeAttribution(base, holeFilling, requestedCourseId, recordedCourseId) {
92519
+ const recorded = [base, ...holeFilling].find((integration) => integration.courseId === recordedCourseId);
92520
+ if (recorded) {
92521
+ return { integration: recorded, requestedCourseIdStatus: "pinned_by_run" };
92522
+ }
92523
+ if (requestedCourseId === undefined) {
92524
+ return { integration: base, requestedCourseIdStatus: "absent" };
92525
+ }
92526
+ if (requestedCourseId === base.courseId) {
92527
+ return { integration: base, requestedCourseIdStatus: "accepted" };
92528
+ }
92529
+ const requestedHoleFilling = holeFilling.find((integration) => integration.courseId === requestedCourseId);
92530
+ return requestedHoleFilling ? { integration: requestedHoleFilling, requestedCourseIdStatus: "accepted_hole_filling" } : { integration: base, requestedCourseIdStatus: "ignored_mismatch" };
92531
+ }
92532
+
92513
92533
  // ../api-core/src/utils/timeback-create-integration.util.ts
92514
92534
  function buildTimebackBaseConfigFromExistingConfig(config) {
92515
92535
  return {
@@ -92769,13 +92789,13 @@ var init_timeback_service = __esm(async () => {
92769
92789
  static clearInFlightHeartbeatWindow(key) {
92770
92790
  this.inFlightHeartbeatWindows.delete(key);
92771
92791
  }
92772
- static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
92792
+ static buildCompletionCaliperEventId(key, kind, baseCourseId, discriminator = "completion") {
92773
92793
  const hash = createHash3("sha256").update([
92774
92794
  "playcademy",
92775
92795
  "timeback",
92776
92796
  kind,
92777
92797
  key.gameId,
92778
- key.courseId,
92798
+ baseCourseId,
92779
92799
  key.studentId,
92780
92800
  key.runId,
92781
92801
  key.activityId,
@@ -92841,11 +92861,14 @@ var init_timeback_service = __esm(async () => {
92841
92861
  } : null
92842
92862
  };
92843
92863
  }
92844
- static async acquireCompletionReservation(db, key) {
92845
- let reserveOutcome = await this.reserveCompletion(db, key);
92864
+ static async acquireCompletionReservation(db, key, otherCourseIds = []) {
92865
+ function reserve() {
92866
+ return otherCourseIds.length > 0 ? TimebackService.reserveCompletionAcrossCourses(db, key, otherCourseIds) : TimebackService.reserveCompletion(db, key);
92867
+ }
92868
+ let reserveOutcome = await reserve();
92846
92869
  for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
92847
92870
  await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
92848
- reserveOutcome = await this.reserveCompletion(db, key);
92871
+ reserveOutcome = await reserve();
92849
92872
  }
92850
92873
  if (reserveOutcome.status === "duplicate") {
92851
92874
  setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
@@ -92864,8 +92887,24 @@ var init_timeback_service = __esm(async () => {
92864
92887
  }
92865
92888
  return { status: "reserved", token: reserveOutcome.token };
92866
92889
  }
92867
- static async runWithCompletionReservation(db, key, emit, awardFromResult) {
92868
- const acquired = await this.acquireCompletionReservation(db, key);
92890
+ static async reserveCompletionAcrossCourses(db, key, otherCourseIds) {
92891
+ return db.transaction(async (tx) => {
92892
+ const database = tx;
92893
+ const runKey = `timeback-completion:${key.gameId.toLowerCase()}:${key.studentId}:${key.runId.toLowerCase()}:${key.activityId}`;
92894
+ await database.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${runKey}, 0))`);
92895
+ const onOtherCourse = await database.query.gameTimebackActivityCompletions.findFirst({
92896
+ columns: { id: true },
92897
+ where: and(eq(gameTimebackActivityCompletions.gameId, key.gameId), eq(gameTimebackActivityCompletions.studentId, key.studentId), eq(gameTimebackActivityCompletions.runId, key.runId), eq(gameTimebackActivityCompletions.activityId, key.activityId), inArray(gameTimebackActivityCompletions.courseId, [...otherCourseIds]))
92898
+ });
92899
+ if (onOtherCourse) {
92900
+ setAttribute("app.timeback.end_activity_status", "completion_reserved_on_other_course");
92901
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
92902
+ }
92903
+ return this.reserveCompletion(database, key);
92904
+ });
92905
+ }
92906
+ static async runWithCompletionReservation(db, key, emit, awardFromResult, otherCourseIds = []) {
92907
+ const acquired = await this.acquireCompletionReservation(db, key, otherCourseIds);
92869
92908
  if (acquired.status === "duplicate") {
92870
92909
  return acquired;
92871
92910
  }
@@ -92900,6 +92939,9 @@ var init_timeback_service = __esm(async () => {
92900
92939
  await db.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
92901
92940
  }
92902
92941
  static addResumeIdToExtensions(extensions, resumeId) {
92942
+ return TimebackService.addPlaycademyExtensions(extensions, { resumeId });
92943
+ }
92944
+ static addPlaycademyExtensions(extensions, fields) {
92903
92945
  const base = extensions ?? {};
92904
92946
  const existingPlaycademy = base.playcademy;
92905
92947
  const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
@@ -92907,28 +92949,71 @@ var init_timeback_service = __esm(async () => {
92907
92949
  ...base,
92908
92950
  playcademy: {
92909
92951
  ...playcademy,
92910
- resumeId
92952
+ ...fields
92911
92953
  }
92912
92954
  };
92913
92955
  }
92914
- static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
92915
- let requestedCourseIdStatus = "absent";
92916
- if (requestedCourseId === resolvedCourseId) {
92917
- requestedCourseIdStatus = "accepted";
92918
- } else if (requestedCourseId !== undefined) {
92919
- requestedCourseIdStatus = "ignored_mismatch";
92956
+ async resolveRuntimeCourse({
92957
+ gameId,
92958
+ studentId,
92959
+ runId,
92960
+ activityData,
92961
+ extensions
92962
+ }) {
92963
+ const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
92964
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
92965
+ });
92966
+ const base = integrations.find(isBaseGameTimebackIntegrationRow);
92967
+ if (!base) {
92968
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92920
92969
  }
92970
+ const holeFilling = integrations.filter((integration) => !isBaseGameTimebackIntegrationRow(integration));
92971
+ const recordedCourseId = holeFilling.length > 0 && runId ? await this.findRecordedRunCourse({
92972
+ gameId,
92973
+ studentId,
92974
+ runId,
92975
+ activityId: activityData.activityId,
92976
+ courseIds: integrations.map((i2) => i2.courseId)
92977
+ }) : undefined;
92978
+ const { integration, requestedCourseIdStatus } = resolveRuntimeAttribution(base, holeFilling, activityData.courseId, recordedCourseId);
92979
+ const hintUsed = activityData.courseId === integration.courseId;
92980
+ const enrollmentId = hintUsed ? activityData.enrollmentId : undefined;
92981
+ const isHoleFilling = !isBaseGameTimebackIntegrationRow(integration);
92921
92982
  return {
92922
- courseId: resolvedCourseId,
92923
- courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
92983
+ integration,
92984
+ courseId: integration.courseId,
92985
+ baseCourseId: base.courseId,
92986
+ otherCourseIds: holeFilling.length > 0 ? integrations.map((other) => other.courseId).filter((courseId) => courseId !== integration.courseId) : [],
92987
+ courseName: activityData.courseId === undefined || hintUsed ? activityData.courseName : undefined,
92988
+ extensions: isHoleFilling ? TimebackService.addPlaycademyExtensions(extensions, {
92989
+ courseKind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
92990
+ baseCourseId: base.courseId,
92991
+ ...enrollmentId && { enrollmentId }
92992
+ }) : extensions,
92924
92993
  attributes: {
92925
- "app.timeback.course_id": resolvedCourseId,
92926
- "app.timeback.resolved_course_id": resolvedCourseId,
92927
- "app.timeback.requested_course_id": requestedCourseId,
92928
- "app.timeback.requested_course_id_status": requestedCourseIdStatus
92994
+ "app.timeback.course_id": integration.courseId,
92995
+ "app.timeback.resolved_course_id": integration.courseId,
92996
+ "app.timeback.requested_course_id": activityData.courseId,
92997
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus,
92998
+ "app.timeback.course_kind": integration.kind,
92999
+ "app.timeback.enrollment_id": enrollmentId
92929
93000
  }
92930
93001
  };
92931
93002
  }
93003
+ async findRecordedRunCourse({
93004
+ gameId,
93005
+ studentId,
93006
+ runId,
93007
+ activityId,
93008
+ courseIds
93009
+ }) {
93010
+ const recorded = await this.deps.db.query.gameTimebackActivityCompletions.findFirst({
93011
+ columns: { courseId: true },
93012
+ where: and(eq(gameTimebackActivityCompletions.gameId, gameId), eq(gameTimebackActivityCompletions.studentId, studentId), eq(gameTimebackActivityCompletions.runId, runId), eq(gameTimebackActivityCompletions.activityId, activityId), inArray(gameTimebackActivityCompletions.courseId, courseIds)),
93013
+ orderBy: (table, { asc }) => [asc(table.createdAt)]
93014
+ });
93015
+ return recorded?.courseId;
93016
+ }
92932
93017
  static recordRuntimeContext({
92933
93018
  operation,
92934
93019
  studentId,
@@ -93910,17 +93995,21 @@ var init_timeback_service = __esm(async () => {
93910
93995
  activityData
93911
93996
  });
93912
93997
  await this.deps.validateDeveloperAccess(user, gameId);
93913
- const integration = await db.query.gameTimebackIntegrations.findFirst({
93914
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
93915
- });
93916
- if (!integration) {
93917
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
93918
- }
93919
93998
  const {
93999
+ integration,
93920
94000
  courseId: runtimeCourseId,
94001
+ baseCourseId,
94002
+ otherCourseIds,
93921
94003
  courseName: runtimeCourseName,
94004
+ extensions: runtimeExtensions,
93922
94005
  attributes: courseAttributes
93923
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
94006
+ } = await this.resolveRuntimeCourse({
94007
+ gameId,
94008
+ studentId,
94009
+ runId,
94010
+ activityData,
94011
+ extensions: extensionsWithResumeId
94012
+ });
93924
94013
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
93925
94014
  const dedupeKey = runId !== undefined ? {
93926
94015
  gameId,
@@ -93949,12 +94038,12 @@ var init_timeback_service = __esm(async () => {
93949
94038
  courseId: runtimeCourseId,
93950
94039
  courseName: runtimeCourseName,
93951
94040
  studentEmail: activityData.studentEmail,
93952
- extensions: extensionsWithResumeId,
94041
+ extensions: runtimeExtensions,
93953
94042
  ...runId ? { runId } : {}
93954
94043
  };
93955
- const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
93956
- const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
93957
- const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
94044
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion", baseCourseId) : undefined;
94045
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", baseCourseId, effectiveResumeId) : undefined;
94046
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed", baseCourseId) : undefined;
93958
94047
  function emitRecord() {
93959
94048
  return client.activity.record(runtimeCourseId, studentId, {
93960
94049
  ...activityEventContext,
@@ -93978,7 +94067,7 @@ var init_timeback_service = __esm(async () => {
93978
94067
  xpAwarded: emitted.xpAwarded,
93979
94068
  masteredUnitsApplied: emitted.masteredUnitsApplied,
93980
94069
  pctCompleteApp: emitted.pctCompleteApp ?? null
93981
- }));
94070
+ }), otherCourseIds);
93982
94071
  if (outcome.status === "duplicate") {
93983
94072
  blockedByDedupeGuard = true;
93984
94073
  duplicateAward = outcome.award;
@@ -94026,6 +94115,7 @@ var init_timeback_service = __esm(async () => {
94026
94115
  return {
94027
94116
  status: "ok",
94028
94117
  courseId: runtimeCourseId,
94118
+ courseKind: integration.kind,
94029
94119
  xpAwarded: duplicateAward?.xpAwarded ?? 0,
94030
94120
  masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
94031
94121
  ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
@@ -94038,6 +94128,7 @@ var init_timeback_service = __esm(async () => {
94038
94128
  return {
94039
94129
  status: "ok",
94040
94130
  courseId: runtimeCourseId,
94131
+ courseKind: integration.kind,
94041
94132
  xpAwarded: result.xpAwarded,
94042
94133
  masteredUnits: result.masteredUnitsApplied,
94043
94134
  pctCompleteApp: result.pctCompleteApp,
@@ -94214,7 +94305,6 @@ var init_timeback_service = __esm(async () => {
94214
94305
  }) {
94215
94306
  return this.withClientTelemetry(async () => {
94216
94307
  const client = this.requireClient();
94217
- const db = this.deps.db;
94218
94308
  const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
94219
94309
  const hasWindowSequence = windowSequence !== undefined;
94220
94310
  if (hasWindowStartedAtMs === hasWindowSequence) {
@@ -94242,17 +94332,17 @@ var init_timeback_service = __esm(async () => {
94242
94332
  return inFlightHeartbeat;
94243
94333
  }
94244
94334
  const pendingHeartbeat = (async () => {
94245
- const integration = await db.query.gameTimebackIntegrations.findFirst({
94246
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
94247
- });
94248
- if (!integration) {
94249
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
94250
- }
94251
94335
  const {
94252
94336
  courseId: runtimeCourseId,
94253
94337
  courseName: runtimeCourseName,
94338
+ extensions: runtimeExtensions,
94254
94339
  attributes: courseAttributes
94255
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
94340
+ } = await this.resolveRuntimeCourse({
94341
+ gameId,
94342
+ studentId,
94343
+ activityData,
94344
+ extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId)
94345
+ });
94256
94346
  const activeTimeSeconds = timingData.activeMs / 1000;
94257
94347
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
94258
94348
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
@@ -94268,7 +94358,7 @@ var init_timeback_service = __esm(async () => {
94268
94358
  courseId: runtimeCourseId,
94269
94359
  courseName: runtimeCourseName,
94270
94360
  studentEmail: activityData.studentEmail,
94271
- extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
94361
+ extensions: runtimeExtensions,
94272
94362
  ...runId ? { runId } : {}
94273
94363
  });
94274
94364
  }
package/dist/server.js CHANGED
@@ -697,7 +697,7 @@ var package_default;
697
697
  var init_package = __esm(() => {
698
698
  package_default = {
699
699
  name: "@playcademy/sandbox",
700
- version: "0.12.2-beta.2",
700
+ version: "0.12.2-beta.3",
701
701
  description: "Local development server for Playcademy game development",
702
702
  type: "module",
703
703
  exports: {
@@ -21068,6 +21068,9 @@ var init_helpers = __esm(() => {
21068
21068
  function isBaseGameTimebackIntegration() {
21069
21069
  return sql`${gameTimebackIntegrations.kind} = ${BASE_GAME_TIMEBACK_INTEGRATION_KIND}`;
21070
21070
  }
21071
+ function isBaseGameTimebackIntegrationRow(integration) {
21072
+ return integration.kind === BASE_GAME_TIMEBACK_INTEGRATION_KIND;
21073
+ }
21071
21074
  function isActiveGameTimebackIntegrationStatus(statusColumn = gameTimebackIntegrations.status) {
21072
21075
  return sql`${statusColumn} IS DISTINCT FROM ${DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS}`;
21073
21076
  }
@@ -48676,6 +48679,7 @@ var init_schemas4 = __esm(() => {
48676
48679
  appName: stringType().optional(),
48677
48680
  sensorUrl: stringType().url().optional(),
48678
48681
  courseId: stringType().min(1).optional(),
48682
+ enrollmentId: stringType().min(1).optional(),
48679
48683
  courseName: stringType().optional(),
48680
48684
  studentEmail: stringType().email().optional()
48681
48685
  });
@@ -90690,6 +90694,22 @@ var init_timeback_assessments_service = __esm(async () => {
90690
90694
  await init_errors8();
90691
90695
  });
90692
90696
 
90697
+ // ../api-core/src/utils/timeback-attribution.util.ts
90698
+ function resolveRuntimeAttribution(base, holeFilling, requestedCourseId, recordedCourseId) {
90699
+ const recorded = [base, ...holeFilling].find((integration) => integration.courseId === recordedCourseId);
90700
+ if (recorded) {
90701
+ return { integration: recorded, requestedCourseIdStatus: "pinned_by_run" };
90702
+ }
90703
+ if (requestedCourseId === undefined) {
90704
+ return { integration: base, requestedCourseIdStatus: "absent" };
90705
+ }
90706
+ if (requestedCourseId === base.courseId) {
90707
+ return { integration: base, requestedCourseIdStatus: "accepted" };
90708
+ }
90709
+ const requestedHoleFilling = holeFilling.find((integration) => integration.courseId === requestedCourseId);
90710
+ return requestedHoleFilling ? { integration: requestedHoleFilling, requestedCourseIdStatus: "accepted_hole_filling" } : { integration: base, requestedCourseIdStatus: "ignored_mismatch" };
90711
+ }
90712
+
90693
90713
  // ../api-core/src/utils/timeback-create-integration.util.ts
90694
90714
  function buildTimebackBaseConfigFromExistingConfig(config) {
90695
90715
  return {
@@ -90949,13 +90969,13 @@ var init_timeback_service = __esm(async () => {
90949
90969
  static clearInFlightHeartbeatWindow(key) {
90950
90970
  this.inFlightHeartbeatWindows.delete(key);
90951
90971
  }
90952
- static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
90972
+ static buildCompletionCaliperEventId(key, kind, baseCourseId, discriminator = "completion") {
90953
90973
  const hash = createHash3("sha256").update([
90954
90974
  "playcademy",
90955
90975
  "timeback",
90956
90976
  kind,
90957
90977
  key.gameId,
90958
- key.courseId,
90978
+ baseCourseId,
90959
90979
  key.studentId,
90960
90980
  key.runId,
90961
90981
  key.activityId,
@@ -91021,11 +91041,14 @@ var init_timeback_service = __esm(async () => {
91021
91041
  } : null
91022
91042
  };
91023
91043
  }
91024
- static async acquireCompletionReservation(db, key) {
91025
- let reserveOutcome = await this.reserveCompletion(db, key);
91044
+ static async acquireCompletionReservation(db, key, otherCourseIds = []) {
91045
+ function reserve() {
91046
+ return otherCourseIds.length > 0 ? TimebackService.reserveCompletionAcrossCourses(db, key, otherCourseIds) : TimebackService.reserveCompletion(db, key);
91047
+ }
91048
+ let reserveOutcome = await reserve();
91026
91049
  for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
91027
91050
  await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
91028
- reserveOutcome = await this.reserveCompletion(db, key);
91051
+ reserveOutcome = await reserve();
91029
91052
  }
91030
91053
  if (reserveOutcome.status === "duplicate") {
91031
91054
  setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
@@ -91044,8 +91067,24 @@ var init_timeback_service = __esm(async () => {
91044
91067
  }
91045
91068
  return { status: "reserved", token: reserveOutcome.token };
91046
91069
  }
91047
- static async runWithCompletionReservation(db, key, emit, awardFromResult) {
91048
- const acquired = await this.acquireCompletionReservation(db, key);
91070
+ static async reserveCompletionAcrossCourses(db, key, otherCourseIds) {
91071
+ return db.transaction(async (tx) => {
91072
+ const database = tx;
91073
+ const runKey = `timeback-completion:${key.gameId.toLowerCase()}:${key.studentId}:${key.runId.toLowerCase()}:${key.activityId}`;
91074
+ await database.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${runKey}, 0))`);
91075
+ const onOtherCourse = await database.query.gameTimebackActivityCompletions.findFirst({
91076
+ columns: { id: true },
91077
+ where: and(eq(gameTimebackActivityCompletions.gameId, key.gameId), eq(gameTimebackActivityCompletions.studentId, key.studentId), eq(gameTimebackActivityCompletions.runId, key.runId), eq(gameTimebackActivityCompletions.activityId, key.activityId), inArray(gameTimebackActivityCompletions.courseId, [...otherCourseIds]))
91078
+ });
91079
+ if (onOtherCourse) {
91080
+ setAttribute("app.timeback.end_activity_status", "completion_reserved_on_other_course");
91081
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
91082
+ }
91083
+ return this.reserveCompletion(database, key);
91084
+ });
91085
+ }
91086
+ static async runWithCompletionReservation(db, key, emit, awardFromResult, otherCourseIds = []) {
91087
+ const acquired = await this.acquireCompletionReservation(db, key, otherCourseIds);
91049
91088
  if (acquired.status === "duplicate") {
91050
91089
  return acquired;
91051
91090
  }
@@ -91080,6 +91119,9 @@ var init_timeback_service = __esm(async () => {
91080
91119
  await db.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
91081
91120
  }
91082
91121
  static addResumeIdToExtensions(extensions, resumeId) {
91122
+ return TimebackService.addPlaycademyExtensions(extensions, { resumeId });
91123
+ }
91124
+ static addPlaycademyExtensions(extensions, fields) {
91083
91125
  const base = extensions ?? {};
91084
91126
  const existingPlaycademy = base.playcademy;
91085
91127
  const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
@@ -91087,28 +91129,71 @@ var init_timeback_service = __esm(async () => {
91087
91129
  ...base,
91088
91130
  playcademy: {
91089
91131
  ...playcademy,
91090
- resumeId
91132
+ ...fields
91091
91133
  }
91092
91134
  };
91093
91135
  }
91094
- static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
91095
- let requestedCourseIdStatus = "absent";
91096
- if (requestedCourseId === resolvedCourseId) {
91097
- requestedCourseIdStatus = "accepted";
91098
- } else if (requestedCourseId !== undefined) {
91099
- requestedCourseIdStatus = "ignored_mismatch";
91136
+ async resolveRuntimeCourse({
91137
+ gameId,
91138
+ studentId,
91139
+ runId,
91140
+ activityData,
91141
+ extensions
91142
+ }) {
91143
+ const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
91144
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
91145
+ });
91146
+ const base = integrations.find(isBaseGameTimebackIntegrationRow);
91147
+ if (!base) {
91148
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
91100
91149
  }
91150
+ const holeFilling = integrations.filter((integration) => !isBaseGameTimebackIntegrationRow(integration));
91151
+ const recordedCourseId = holeFilling.length > 0 && runId ? await this.findRecordedRunCourse({
91152
+ gameId,
91153
+ studentId,
91154
+ runId,
91155
+ activityId: activityData.activityId,
91156
+ courseIds: integrations.map((i2) => i2.courseId)
91157
+ }) : undefined;
91158
+ const { integration, requestedCourseIdStatus } = resolveRuntimeAttribution(base, holeFilling, activityData.courseId, recordedCourseId);
91159
+ const hintUsed = activityData.courseId === integration.courseId;
91160
+ const enrollmentId = hintUsed ? activityData.enrollmentId : undefined;
91161
+ const isHoleFilling = !isBaseGameTimebackIntegrationRow(integration);
91101
91162
  return {
91102
- courseId: resolvedCourseId,
91103
- courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
91163
+ integration,
91164
+ courseId: integration.courseId,
91165
+ baseCourseId: base.courseId,
91166
+ otherCourseIds: holeFilling.length > 0 ? integrations.map((other) => other.courseId).filter((courseId) => courseId !== integration.courseId) : [],
91167
+ courseName: activityData.courseId === undefined || hintUsed ? activityData.courseName : undefined,
91168
+ extensions: isHoleFilling ? TimebackService.addPlaycademyExtensions(extensions, {
91169
+ courseKind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
91170
+ baseCourseId: base.courseId,
91171
+ ...enrollmentId && { enrollmentId }
91172
+ }) : extensions,
91104
91173
  attributes: {
91105
- "app.timeback.course_id": resolvedCourseId,
91106
- "app.timeback.resolved_course_id": resolvedCourseId,
91107
- "app.timeback.requested_course_id": requestedCourseId,
91108
- "app.timeback.requested_course_id_status": requestedCourseIdStatus
91174
+ "app.timeback.course_id": integration.courseId,
91175
+ "app.timeback.resolved_course_id": integration.courseId,
91176
+ "app.timeback.requested_course_id": activityData.courseId,
91177
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus,
91178
+ "app.timeback.course_kind": integration.kind,
91179
+ "app.timeback.enrollment_id": enrollmentId
91109
91180
  }
91110
91181
  };
91111
91182
  }
91183
+ async findRecordedRunCourse({
91184
+ gameId,
91185
+ studentId,
91186
+ runId,
91187
+ activityId,
91188
+ courseIds
91189
+ }) {
91190
+ const recorded = await this.deps.db.query.gameTimebackActivityCompletions.findFirst({
91191
+ columns: { courseId: true },
91192
+ where: and(eq(gameTimebackActivityCompletions.gameId, gameId), eq(gameTimebackActivityCompletions.studentId, studentId), eq(gameTimebackActivityCompletions.runId, runId), eq(gameTimebackActivityCompletions.activityId, activityId), inArray(gameTimebackActivityCompletions.courseId, courseIds)),
91193
+ orderBy: (table, { asc }) => [asc(table.createdAt)]
91194
+ });
91195
+ return recorded?.courseId;
91196
+ }
91112
91197
  static recordRuntimeContext({
91113
91198
  operation,
91114
91199
  studentId,
@@ -92090,17 +92175,21 @@ var init_timeback_service = __esm(async () => {
92090
92175
  activityData
92091
92176
  });
92092
92177
  await this.deps.validateDeveloperAccess(user, gameId);
92093
- const integration = await db.query.gameTimebackIntegrations.findFirst({
92094
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
92095
- });
92096
- if (!integration) {
92097
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92098
- }
92099
92178
  const {
92179
+ integration,
92100
92180
  courseId: runtimeCourseId,
92181
+ baseCourseId,
92182
+ otherCourseIds,
92101
92183
  courseName: runtimeCourseName,
92184
+ extensions: runtimeExtensions,
92102
92185
  attributes: courseAttributes
92103
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
92186
+ } = await this.resolveRuntimeCourse({
92187
+ gameId,
92188
+ studentId,
92189
+ runId,
92190
+ activityData,
92191
+ extensions: extensionsWithResumeId
92192
+ });
92104
92193
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
92105
92194
  const dedupeKey = runId !== undefined ? {
92106
92195
  gameId,
@@ -92129,12 +92218,12 @@ var init_timeback_service = __esm(async () => {
92129
92218
  courseId: runtimeCourseId,
92130
92219
  courseName: runtimeCourseName,
92131
92220
  studentEmail: activityData.studentEmail,
92132
- extensions: extensionsWithResumeId,
92221
+ extensions: runtimeExtensions,
92133
92222
  ...runId ? { runId } : {}
92134
92223
  };
92135
- const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
92136
- const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
92137
- const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
92224
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion", baseCourseId) : undefined;
92225
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", baseCourseId, effectiveResumeId) : undefined;
92226
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed", baseCourseId) : undefined;
92138
92227
  function emitRecord() {
92139
92228
  return client.activity.record(runtimeCourseId, studentId, {
92140
92229
  ...activityEventContext,
@@ -92158,7 +92247,7 @@ var init_timeback_service = __esm(async () => {
92158
92247
  xpAwarded: emitted.xpAwarded,
92159
92248
  masteredUnitsApplied: emitted.masteredUnitsApplied,
92160
92249
  pctCompleteApp: emitted.pctCompleteApp ?? null
92161
- }));
92250
+ }), otherCourseIds);
92162
92251
  if (outcome.status === "duplicate") {
92163
92252
  blockedByDedupeGuard = true;
92164
92253
  duplicateAward = outcome.award;
@@ -92206,6 +92295,7 @@ var init_timeback_service = __esm(async () => {
92206
92295
  return {
92207
92296
  status: "ok",
92208
92297
  courseId: runtimeCourseId,
92298
+ courseKind: integration.kind,
92209
92299
  xpAwarded: duplicateAward?.xpAwarded ?? 0,
92210
92300
  masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
92211
92301
  ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
@@ -92218,6 +92308,7 @@ var init_timeback_service = __esm(async () => {
92218
92308
  return {
92219
92309
  status: "ok",
92220
92310
  courseId: runtimeCourseId,
92311
+ courseKind: integration.kind,
92221
92312
  xpAwarded: result.xpAwarded,
92222
92313
  masteredUnits: result.masteredUnitsApplied,
92223
92314
  pctCompleteApp: result.pctCompleteApp,
@@ -92394,7 +92485,6 @@ var init_timeback_service = __esm(async () => {
92394
92485
  }) {
92395
92486
  return this.withClientTelemetry(async () => {
92396
92487
  const client = this.requireClient();
92397
- const db = this.deps.db;
92398
92488
  const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
92399
92489
  const hasWindowSequence = windowSequence !== undefined;
92400
92490
  if (hasWindowStartedAtMs === hasWindowSequence) {
@@ -92422,17 +92512,17 @@ var init_timeback_service = __esm(async () => {
92422
92512
  return inFlightHeartbeat;
92423
92513
  }
92424
92514
  const pendingHeartbeat = (async () => {
92425
- const integration = await db.query.gameTimebackIntegrations.findFirst({
92426
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
92427
- });
92428
- if (!integration) {
92429
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92430
- }
92431
92515
  const {
92432
92516
  courseId: runtimeCourseId,
92433
92517
  courseName: runtimeCourseName,
92518
+ extensions: runtimeExtensions,
92434
92519
  attributes: courseAttributes
92435
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
92520
+ } = await this.resolveRuntimeCourse({
92521
+ gameId,
92522
+ studentId,
92523
+ activityData,
92524
+ extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId)
92525
+ });
92436
92526
  const activeTimeSeconds = timingData.activeMs / 1000;
92437
92527
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
92438
92528
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
@@ -92448,7 +92538,7 @@ var init_timeback_service = __esm(async () => {
92448
92538
  courseId: runtimeCourseId,
92449
92539
  courseName: runtimeCourseName,
92450
92540
  studentEmail: activityData.studentEmail,
92451
- extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
92541
+ extensions: runtimeExtensions,
92452
92542
  ...runId ? { runId } : {}
92453
92543
  });
92454
92544
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.12.2-beta.2",
3
+ "version": "0.12.2-beta.3",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {