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