@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.
- package/dist/cli.js +132 -42
- package/dist/server.js +132 -42
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
92868
|
-
|
|
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
|
-
|
|
92952
|
+
...fields
|
|
92911
92953
|
}
|
|
92912
92954
|
};
|
|
92913
92955
|
}
|
|
92914
|
-
|
|
92915
|
-
|
|
92916
|
-
|
|
92917
|
-
|
|
92918
|
-
|
|
92919
|
-
|
|
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
|
-
|
|
92923
|
-
|
|
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":
|
|
92926
|
-
"app.timeback.resolved_course_id":
|
|
92927
|
-
"app.timeback.requested_course_id":
|
|
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
|
-
} =
|
|
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:
|
|
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
|
-
} =
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
91048
|
-
|
|
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
|
-
|
|
91132
|
+
...fields
|
|
91091
91133
|
}
|
|
91092
91134
|
};
|
|
91093
91135
|
}
|
|
91094
|
-
|
|
91095
|
-
|
|
91096
|
-
|
|
91097
|
-
|
|
91098
|
-
|
|
91099
|
-
|
|
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
|
-
|
|
91103
|
-
|
|
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":
|
|
91106
|
-
"app.timeback.resolved_course_id":
|
|
91107
|
-
"app.timeback.requested_course_id":
|
|
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
|
-
} =
|
|
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:
|
|
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
|
-
} =
|
|
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:
|
|
92541
|
+
extensions: runtimeExtensions,
|
|
92452
92542
|
...runId ? { runId } : {}
|
|
92453
92543
|
});
|
|
92454
92544
|
}
|