@playcademy/sdk 0.4.1-beta.3 → 0.4.1-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/index.d.ts +86 -69
- package/dist/index.js +327 -61
- package/dist/internal.d.ts +1775 -1753
- package/dist/internal.js +337 -61
- package/dist/types.d.ts +776 -759
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -381,74 +381,6 @@ declare function parseOAuthState(state: string): {
|
|
|
381
381
|
/** Permitted HTTP verbs */
|
|
382
382
|
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
383
383
|
|
|
384
|
-
/**
|
|
385
|
-
* User Types
|
|
386
|
-
*
|
|
387
|
-
* Enums, DTOs and API response types. Database row types are in @playcademy/data/types.
|
|
388
|
-
*
|
|
389
|
-
* @module types/user
|
|
390
|
-
*/
|
|
391
|
-
type UserRoleEnumType = 'admin' | 'player' | 'developer';
|
|
392
|
-
type DeveloperStatusEnumType = 'none' | 'pending' | 'approved';
|
|
393
|
-
type TimebackUserRole = 'administrator' | 'aide' | 'guardian' | 'parent' | 'proctor' | 'relative' | 'student' | 'teacher';
|
|
394
|
-
type TimebackOrgType = 'department' | 'school' | 'district' | 'local' | 'state' | 'national';
|
|
395
|
-
interface UserEnrollment {
|
|
396
|
-
gameId?: string;
|
|
397
|
-
courseId: string;
|
|
398
|
-
grade: number;
|
|
399
|
-
subject: string;
|
|
400
|
-
orgId?: string;
|
|
401
|
-
}
|
|
402
|
-
interface UserOrganization {
|
|
403
|
-
id: string;
|
|
404
|
-
name: string | null;
|
|
405
|
-
type: TimebackOrgType | string;
|
|
406
|
-
isPrimary: boolean;
|
|
407
|
-
}
|
|
408
|
-
interface TimebackStudentProfile {
|
|
409
|
-
role: TimebackUserRole;
|
|
410
|
-
organizations: UserOrganization[];
|
|
411
|
-
}
|
|
412
|
-
interface UserTimebackData extends TimebackStudentProfile {
|
|
413
|
-
id: string;
|
|
414
|
-
enrollments: UserEnrollment[];
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* OpenID Connect UserInfo claims (NOT a database row).
|
|
418
|
-
*/
|
|
419
|
-
interface UserInfo {
|
|
420
|
-
sub: string;
|
|
421
|
-
email: string;
|
|
422
|
-
name: string | null;
|
|
423
|
-
email_verified?: boolean;
|
|
424
|
-
given_name?: string;
|
|
425
|
-
family_name?: string;
|
|
426
|
-
issuer?: string;
|
|
427
|
-
lti_roles?: unknown;
|
|
428
|
-
lti_context?: unknown;
|
|
429
|
-
lti_resource_link?: unknown;
|
|
430
|
-
timeback_id?: string;
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Authenticated user for API responses.
|
|
434
|
-
* Differs from UserRow: omits timebackId, adds hasTimebackAccount and timeback.
|
|
435
|
-
*/
|
|
436
|
-
interface AuthenticatedUser {
|
|
437
|
-
id: string;
|
|
438
|
-
email: string;
|
|
439
|
-
emailVerified: boolean;
|
|
440
|
-
name: string | null;
|
|
441
|
-
image: string | null;
|
|
442
|
-
username: string | null;
|
|
443
|
-
role: UserRoleEnumType;
|
|
444
|
-
developerStatus: DeveloperStatusEnumType;
|
|
445
|
-
characterCreated: boolean;
|
|
446
|
-
createdAt: Date;
|
|
447
|
-
updatedAt: Date;
|
|
448
|
-
hasTimebackAccount: boolean;
|
|
449
|
-
timeback?: UserTimebackData;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
384
|
/**
|
|
453
385
|
* TimeBack Enums & Literal Types
|
|
454
386
|
*
|
|
@@ -551,6 +483,74 @@ interface EndActivityResponse {
|
|
|
551
483
|
inProgress?: string;
|
|
552
484
|
}
|
|
553
485
|
|
|
486
|
+
/**
|
|
487
|
+
* User Types
|
|
488
|
+
*
|
|
489
|
+
* Enums, DTOs and API response types. Database row types are in @playcademy/data/types.
|
|
490
|
+
*
|
|
491
|
+
* @module types/user
|
|
492
|
+
*/
|
|
493
|
+
type UserRoleEnumType = 'admin' | 'player' | 'developer' | 'teacher';
|
|
494
|
+
type DeveloperStatusEnumType = 'none' | 'pending' | 'approved';
|
|
495
|
+
type TimebackUserRole = 'administrator' | 'aide' | 'guardian' | 'parent' | 'proctor' | 'relative' | 'student' | 'teacher';
|
|
496
|
+
type TimebackOrgType = 'department' | 'school' | 'district' | 'local' | 'state' | 'national';
|
|
497
|
+
interface UserEnrollment {
|
|
498
|
+
gameId?: string;
|
|
499
|
+
courseId: string;
|
|
500
|
+
grade: number;
|
|
501
|
+
subject: string;
|
|
502
|
+
orgId?: string;
|
|
503
|
+
}
|
|
504
|
+
interface UserOrganization {
|
|
505
|
+
id: string;
|
|
506
|
+
name: string | null;
|
|
507
|
+
type: TimebackOrgType | string;
|
|
508
|
+
isPrimary: boolean;
|
|
509
|
+
}
|
|
510
|
+
interface TimebackStudentProfile {
|
|
511
|
+
role: TimebackUserRole;
|
|
512
|
+
organizations: UserOrganization[];
|
|
513
|
+
}
|
|
514
|
+
interface UserTimebackData extends TimebackStudentProfile {
|
|
515
|
+
id: string;
|
|
516
|
+
enrollments: UserEnrollment[];
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* OpenID Connect UserInfo claims (NOT a database row).
|
|
520
|
+
*/
|
|
521
|
+
interface UserInfo {
|
|
522
|
+
sub: string;
|
|
523
|
+
email: string;
|
|
524
|
+
name: string | null;
|
|
525
|
+
email_verified?: boolean;
|
|
526
|
+
given_name?: string;
|
|
527
|
+
family_name?: string;
|
|
528
|
+
issuer?: string;
|
|
529
|
+
lti_roles?: unknown;
|
|
530
|
+
lti_context?: unknown;
|
|
531
|
+
lti_resource_link?: unknown;
|
|
532
|
+
timeback_id?: string;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Authenticated user for API responses.
|
|
536
|
+
* Differs from UserRow: omits timebackId, adds hasTimebackAccount and timeback.
|
|
537
|
+
*/
|
|
538
|
+
interface AuthenticatedUser {
|
|
539
|
+
id: string;
|
|
540
|
+
email: string;
|
|
541
|
+
emailVerified: boolean;
|
|
542
|
+
name: string | null;
|
|
543
|
+
image: string | null;
|
|
544
|
+
username: string | null;
|
|
545
|
+
role: UserRoleEnumType;
|
|
546
|
+
developerStatus: DeveloperStatusEnumType;
|
|
547
|
+
characterCreated: boolean;
|
|
548
|
+
createdAt: Date;
|
|
549
|
+
updatedAt: Date;
|
|
550
|
+
hasTimebackAccount: boolean;
|
|
551
|
+
timeback?: UserTimebackData;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
554
|
declare const items: drizzle_orm_pg_core.PgTableWithColumns<{
|
|
555
555
|
name: "items";
|
|
556
556
|
schema: undefined;
|
|
@@ -982,6 +982,23 @@ declare abstract class PlaycademyBaseClient {
|
|
|
982
982
|
};
|
|
983
983
|
}
|
|
984
984
|
|
|
985
|
+
/**
|
|
986
|
+
* Options for configuring activity tracking behavior.
|
|
987
|
+
*/
|
|
988
|
+
interface StartActivityOptions {
|
|
989
|
+
/**
|
|
990
|
+
* How long the tab can stay hidden before the timing window resets on return.
|
|
991
|
+
* Defaults to 10 minutes. Set to `Infinity` to disable.
|
|
992
|
+
*/
|
|
993
|
+
hiddenTimeoutMs?: number;
|
|
994
|
+
/**
|
|
995
|
+
* How often to flush periodic heartbeats with accumulated time data.
|
|
996
|
+
* Defaults to 15 seconds. Set to `Infinity` to disable the interval;
|
|
997
|
+
* final unload/endActivity flushes still run.
|
|
998
|
+
*/
|
|
999
|
+
heartbeatIntervalMs?: number;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
985
1002
|
/**
|
|
986
1003
|
* Auto-initializes a PlaycademyClient with context from the environment.
|
|
987
1004
|
* Works in both iframe mode (production/development) and standalone mode (local dev).
|
|
@@ -1119,7 +1136,7 @@ declare class PlaycademyClient extends PlaycademyBaseClient {
|
|
|
1119
1136
|
*/
|
|
1120
1137
|
timeback: {
|
|
1121
1138
|
readonly user: TimebackUser;
|
|
1122
|
-
startActivity: (metadata: ActivityData) => void;
|
|
1139
|
+
startActivity: (metadata: ActivityData, options?: StartActivityOptions | undefined) => void;
|
|
1123
1140
|
pauseActivity: () => void;
|
|
1124
1141
|
resumeActivity: () => void;
|
|
1125
1142
|
endActivity: (data: EndActivityScoreData) => Promise<EndActivityResponse>;
|
package/dist/index.js
CHANGED
|
@@ -1274,7 +1274,8 @@ var BADGES = {
|
|
|
1274
1274
|
// ../constants/src/timeback.ts
|
|
1275
1275
|
var TIMEBACK_ROUTES = {
|
|
1276
1276
|
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
1277
|
-
GET_XP: "/integrations/timeback/xp"
|
|
1277
|
+
GET_XP: "/integrations/timeback/xp",
|
|
1278
|
+
HEARTBEAT: "/integrations/timeback/heartbeat"
|
|
1278
1279
|
};
|
|
1279
1280
|
// src/core/cache/singleton-cache.ts
|
|
1280
1281
|
function createSingletonCache() {
|
|
@@ -1378,6 +1379,325 @@ function createRealtimeNamespace(client) {
|
|
|
1378
1379
|
}
|
|
1379
1380
|
};
|
|
1380
1381
|
}
|
|
1382
|
+
// src/core/activity-tracker.ts
|
|
1383
|
+
var DEFAULT_HIDDEN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
1384
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
|
|
1385
|
+
function getCurrentPausedTotal(activity, now = Date.now()) {
|
|
1386
|
+
if (activity.pauseStartTime === null) {
|
|
1387
|
+
return activity.pausedTime;
|
|
1388
|
+
}
|
|
1389
|
+
return activity.pausedTime + (now - activity.pauseStartTime);
|
|
1390
|
+
}
|
|
1391
|
+
function computeWindowAndReset(activity) {
|
|
1392
|
+
const now = Date.now();
|
|
1393
|
+
const totalPaused = getCurrentPausedTotal(activity, now);
|
|
1394
|
+
if (activity.pauseStartTime !== null) {
|
|
1395
|
+
activity.pausedTime = totalPaused;
|
|
1396
|
+
activity.pauseStartTime = now;
|
|
1397
|
+
}
|
|
1398
|
+
const windowPaused = totalPaused - activity.windowPausedAtStart;
|
|
1399
|
+
const windowElapsed = now - activity.windowStartTime;
|
|
1400
|
+
const activeMs = Math.max(0, windowElapsed - windowPaused);
|
|
1401
|
+
activity.windowStartTime = now;
|
|
1402
|
+
activity.windowPausedAtStart = totalPaused;
|
|
1403
|
+
activity.windowSequence++;
|
|
1404
|
+
return { activeMs, pausedMs: windowPaused };
|
|
1405
|
+
}
|
|
1406
|
+
function markPersistedTiming(activity, timing) {
|
|
1407
|
+
activity.totalPersistedActiveMs += timing.activeMs;
|
|
1408
|
+
activity.totalPersistedPausedMs += timing.pausedMs;
|
|
1409
|
+
}
|
|
1410
|
+
function queueHeartbeatFlush(activity, timing, flush) {
|
|
1411
|
+
markPersistedTiming(activity, timing);
|
|
1412
|
+
async function doFlush() {
|
|
1413
|
+
await flush();
|
|
1414
|
+
}
|
|
1415
|
+
if (activity.flushInFlight) {
|
|
1416
|
+
const previousFlush = activity.flushInFlight.catch(() => {
|
|
1417
|
+
return;
|
|
1418
|
+
});
|
|
1419
|
+
activity.flushInFlight = previousFlush.then(doFlush);
|
|
1420
|
+
} else {
|
|
1421
|
+
activity.flushInFlight = doFlush();
|
|
1422
|
+
}
|
|
1423
|
+
return activity.flushInFlight;
|
|
1424
|
+
}
|
|
1425
|
+
function stopHeartbeatInterval(activity) {
|
|
1426
|
+
if (activity.heartbeatIntervalId === null) {
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
clearInterval(activity.heartbeatIntervalId);
|
|
1430
|
+
activity.heartbeatIntervalId = null;
|
|
1431
|
+
}
|
|
1432
|
+
function createTimebackActivityTracker(client) {
|
|
1433
|
+
let currentActivity = null;
|
|
1434
|
+
let boundVisibilityHandler = null;
|
|
1435
|
+
let boundShellPauseHandler = null;
|
|
1436
|
+
let boundShellResumeHandler = null;
|
|
1437
|
+
let boundPageHideHandler = null;
|
|
1438
|
+
function startHeartbeatInterval(activity) {
|
|
1439
|
+
if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
activity.heartbeatIntervalId = setInterval(() => {
|
|
1443
|
+
flushHeartbeat();
|
|
1444
|
+
}, activity.heartbeatIntervalMs);
|
|
1445
|
+
}
|
|
1446
|
+
function addPauseReason(reason) {
|
|
1447
|
+
if (!currentActivity) {
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const wasPaused = currentActivity.pauseReasons.size > 0;
|
|
1451
|
+
currentActivity.pauseReasons.add(reason);
|
|
1452
|
+
if (!wasPaused && currentActivity.pauseReasons.size > 0) {
|
|
1453
|
+
currentActivity.pauseStartTime = Date.now();
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
function removePauseReason(reason) {
|
|
1457
|
+
if (!currentActivity) {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
currentActivity.pauseReasons.delete(reason);
|
|
1461
|
+
if (currentActivity.pauseReasons.size === 0 && currentActivity.pauseStartTime !== null) {
|
|
1462
|
+
currentActivity.pausedTime += Date.now() - currentActivity.pauseStartTime;
|
|
1463
|
+
currentActivity.pauseStartTime = null;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
function handleVisibilityChange() {
|
|
1467
|
+
if (!currentActivity) {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (document.visibilityState === "hidden") {
|
|
1471
|
+
addPauseReason("hidden");
|
|
1472
|
+
if (currentActivity.hiddenTimeoutMs !== Infinity) {
|
|
1473
|
+
if (currentActivity.hiddenTimeoutId !== null) {
|
|
1474
|
+
clearTimeout(currentActivity.hiddenTimeoutId);
|
|
1475
|
+
}
|
|
1476
|
+
const activity = currentActivity;
|
|
1477
|
+
currentActivity.hiddenTimeoutId = setTimeout(() => {
|
|
1478
|
+
if (currentActivity === activity) {
|
|
1479
|
+
activity.hiddenTimeoutId = null;
|
|
1480
|
+
activity.hiddenTimedOut = true;
|
|
1481
|
+
stopHeartbeatInterval(activity);
|
|
1482
|
+
}
|
|
1483
|
+
}, activity.hiddenTimeoutMs);
|
|
1484
|
+
}
|
|
1485
|
+
} else {
|
|
1486
|
+
const shouldResetWindow = currentActivity.hiddenTimedOut;
|
|
1487
|
+
if (currentActivity.hiddenTimeoutId !== null) {
|
|
1488
|
+
clearTimeout(currentActivity.hiddenTimeoutId);
|
|
1489
|
+
currentActivity.hiddenTimeoutId = null;
|
|
1490
|
+
}
|
|
1491
|
+
removePauseReason("hidden");
|
|
1492
|
+
if (shouldResetWindow) {
|
|
1493
|
+
const now = Date.now();
|
|
1494
|
+
const pausedAtReset = getCurrentPausedTotal(currentActivity, now);
|
|
1495
|
+
currentActivity.hiddenTimedOut = false;
|
|
1496
|
+
currentActivity.windowStartTime = now;
|
|
1497
|
+
currentActivity.windowPausedAtStart = pausedAtReset;
|
|
1498
|
+
startHeartbeatInterval(currentActivity);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
function handleShellPause() {
|
|
1503
|
+
addPauseReason("shell");
|
|
1504
|
+
}
|
|
1505
|
+
function handleShellResume() {
|
|
1506
|
+
removePauseReason("shell");
|
|
1507
|
+
}
|
|
1508
|
+
function buildHeartbeatBody(activity, timing, sequence, isFinal) {
|
|
1509
|
+
return {
|
|
1510
|
+
runId: activity.runId,
|
|
1511
|
+
activityData: activity.metadata,
|
|
1512
|
+
timingData: { activeMs: timing.activeMs, pausedMs: timing.pausedMs },
|
|
1513
|
+
windowSequence: sequence,
|
|
1514
|
+
isFinal
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
async function flushHeartbeat(isFinal) {
|
|
1518
|
+
const activity = currentActivity;
|
|
1519
|
+
if (!activity) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const trackedActivity = activity;
|
|
1523
|
+
const sequence = trackedActivity.windowSequence;
|
|
1524
|
+
const timing = computeWindowAndReset(trackedActivity);
|
|
1525
|
+
if (timing.activeMs === 0 && timing.pausedMs === 0) {
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const body = buildHeartbeatBody(trackedActivity, timing, sequence, isFinal);
|
|
1529
|
+
await queueHeartbeatFlush(trackedActivity, timing, async () => {
|
|
1530
|
+
try {
|
|
1531
|
+
await client["requestGameBackend"](TIMEBACK_ROUTES.HEARTBEAT, "POST", body);
|
|
1532
|
+
} catch {}
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
function handlePageHide() {
|
|
1536
|
+
const activity = currentActivity;
|
|
1537
|
+
if (!activity) {
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const sequence = activity.windowSequence;
|
|
1541
|
+
const timing = computeWindowAndReset(activity);
|
|
1542
|
+
if (timing.activeMs === 0 && timing.pausedMs === 0) {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const body = buildHeartbeatBody(activity, timing, sequence, true);
|
|
1546
|
+
queueHeartbeatFlush(activity, timing, async () => {
|
|
1547
|
+
try {
|
|
1548
|
+
const baseUrl = client["getGameBackendUrl"]();
|
|
1549
|
+
const url = `${baseUrl}${TIMEBACK_ROUTES.HEARTBEAT}`;
|
|
1550
|
+
const headers = {
|
|
1551
|
+
"Content-Type": "application/json",
|
|
1552
|
+
...client["authStrategy"].getHeaders()
|
|
1553
|
+
};
|
|
1554
|
+
const response = await fetch(url, {
|
|
1555
|
+
method: "POST",
|
|
1556
|
+
headers,
|
|
1557
|
+
body: JSON.stringify(body),
|
|
1558
|
+
keepalive: true
|
|
1559
|
+
});
|
|
1560
|
+
if (response.ok) {
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
} catch {}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
function cleanupListeners() {
|
|
1567
|
+
if (boundVisibilityHandler && typeof document !== "undefined") {
|
|
1568
|
+
document.removeEventListener("visibilitychange", boundVisibilityHandler);
|
|
1569
|
+
boundVisibilityHandler = null;
|
|
1570
|
+
}
|
|
1571
|
+
if (boundShellPauseHandler) {
|
|
1572
|
+
messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
1573
|
+
boundShellPauseHandler = null;
|
|
1574
|
+
}
|
|
1575
|
+
if (boundShellResumeHandler) {
|
|
1576
|
+
messaging.unlisten("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
1577
|
+
boundShellResumeHandler = null;
|
|
1578
|
+
}
|
|
1579
|
+
if (boundPageHideHandler && typeof globalThis.window !== "undefined") {
|
|
1580
|
+
globalThis.window.removeEventListener("pagehide", boundPageHideHandler);
|
|
1581
|
+
boundPageHideHandler = null;
|
|
1582
|
+
}
|
|
1583
|
+
if (currentActivity?.hiddenTimeoutId != null) {
|
|
1584
|
+
clearTimeout(currentActivity.hiddenTimeoutId);
|
|
1585
|
+
}
|
|
1586
|
+
if (currentActivity?.heartbeatIntervalId != null) {
|
|
1587
|
+
stopHeartbeatInterval(currentActivity);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return {
|
|
1591
|
+
startActivity(metadata, options) {
|
|
1592
|
+
cleanupListeners();
|
|
1593
|
+
const now = Date.now();
|
|
1594
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
1595
|
+
currentActivity = {
|
|
1596
|
+
runId: crypto.randomUUID(),
|
|
1597
|
+
startTime: now,
|
|
1598
|
+
metadata,
|
|
1599
|
+
pausedTime: 0,
|
|
1600
|
+
pauseStartTime: null,
|
|
1601
|
+
pauseReasons: new Set,
|
|
1602
|
+
hiddenTimeoutId: null,
|
|
1603
|
+
hiddenTimedOut: false,
|
|
1604
|
+
hiddenTimeoutMs: options?.hiddenTimeoutMs ?? DEFAULT_HIDDEN_TIMEOUT_MS,
|
|
1605
|
+
windowStartTime: now,
|
|
1606
|
+
windowPausedAtStart: 0,
|
|
1607
|
+
windowSequence: 0,
|
|
1608
|
+
heartbeatIntervalId: null,
|
|
1609
|
+
heartbeatIntervalMs,
|
|
1610
|
+
flushInFlight: null,
|
|
1611
|
+
totalPersistedActiveMs: 0,
|
|
1612
|
+
totalPersistedPausedMs: 0
|
|
1613
|
+
};
|
|
1614
|
+
if (typeof document !== "undefined") {
|
|
1615
|
+
boundVisibilityHandler = handleVisibilityChange;
|
|
1616
|
+
document.addEventListener("visibilitychange", boundVisibilityHandler);
|
|
1617
|
+
if (document.visibilityState === "hidden") {
|
|
1618
|
+
handleVisibilityChange();
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
startHeartbeatInterval(currentActivity);
|
|
1622
|
+
if (typeof globalThis.window !== "undefined") {
|
|
1623
|
+
boundPageHideHandler = handlePageHide;
|
|
1624
|
+
globalThis.window.addEventListener("pagehide", boundPageHideHandler);
|
|
1625
|
+
}
|
|
1626
|
+
boundShellPauseHandler = handleShellPause;
|
|
1627
|
+
boundShellResumeHandler = handleShellResume;
|
|
1628
|
+
messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
|
|
1629
|
+
messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
|
|
1630
|
+
},
|
|
1631
|
+
pauseActivity() {
|
|
1632
|
+
if (!currentActivity) {
|
|
1633
|
+
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
1634
|
+
}
|
|
1635
|
+
if (currentActivity.pauseReasons.has("manual")) {
|
|
1636
|
+
throw new Error("Activity is already paused.");
|
|
1637
|
+
}
|
|
1638
|
+
addPauseReason("manual");
|
|
1639
|
+
},
|
|
1640
|
+
resumeActivity() {
|
|
1641
|
+
if (!currentActivity) {
|
|
1642
|
+
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
1643
|
+
}
|
|
1644
|
+
if (!currentActivity.pauseReasons.has("manual")) {
|
|
1645
|
+
throw new Error("Activity is not paused.");
|
|
1646
|
+
}
|
|
1647
|
+
removePauseReason("manual");
|
|
1648
|
+
},
|
|
1649
|
+
async endActivity(data) {
|
|
1650
|
+
if (!currentActivity) {
|
|
1651
|
+
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
1652
|
+
}
|
|
1653
|
+
const activity = currentActivity;
|
|
1654
|
+
cleanupListeners();
|
|
1655
|
+
await flushHeartbeat(true);
|
|
1656
|
+
if (activity.pauseStartTime !== null) {
|
|
1657
|
+
activity.pausedTime += Date.now() - activity.pauseStartTime;
|
|
1658
|
+
activity.pauseStartTime = null;
|
|
1659
|
+
}
|
|
1660
|
+
const endTime = Date.now();
|
|
1661
|
+
const totalElapsed = endTime - activity.startTime;
|
|
1662
|
+
const activeTime = Math.max(0, totalElapsed - activity.pausedTime);
|
|
1663
|
+
const durationSeconds = Math.floor(activeTime / 1000);
|
|
1664
|
+
const unreportedActiveMs = Math.max(0, activeTime - activity.totalPersistedActiveMs);
|
|
1665
|
+
const unreportedPausedMs = Math.max(0, activity.pausedTime - activity.totalPersistedPausedMs);
|
|
1666
|
+
const { correctQuestions, totalQuestions } = data;
|
|
1667
|
+
const request = {
|
|
1668
|
+
runId: activity.runId,
|
|
1669
|
+
activityData: activity.metadata,
|
|
1670
|
+
scoreData: {
|
|
1671
|
+
correctQuestions,
|
|
1672
|
+
totalQuestions
|
|
1673
|
+
},
|
|
1674
|
+
timingData: {
|
|
1675
|
+
durationSeconds
|
|
1676
|
+
},
|
|
1677
|
+
sessionTimingData: {
|
|
1678
|
+
activeSeconds: unreportedActiveMs / 1000,
|
|
1679
|
+
...unreportedPausedMs > 0 ? { inactiveSeconds: unreportedPausedMs / 1000 } : {}
|
|
1680
|
+
},
|
|
1681
|
+
xpEarned: data.xpAwarded,
|
|
1682
|
+
masteredUnits: data.masteredUnits,
|
|
1683
|
+
extensions: data.extensions
|
|
1684
|
+
};
|
|
1685
|
+
try {
|
|
1686
|
+
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request);
|
|
1687
|
+
if (currentActivity === activity) {
|
|
1688
|
+
currentActivity = null;
|
|
1689
|
+
}
|
|
1690
|
+
return response;
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
if (currentActivity === activity) {
|
|
1693
|
+
currentActivity = null;
|
|
1694
|
+
}
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1381
1701
|
// src/core/cache/ttl-cache.ts
|
|
1382
1702
|
function createTTLCache(options) {
|
|
1383
1703
|
const cache = new Map;
|
|
@@ -1473,7 +1793,7 @@ function isValidSubject(value) {
|
|
|
1473
1793
|
|
|
1474
1794
|
// src/namespaces/game/timeback.ts
|
|
1475
1795
|
function createTimebackNamespace(client) {
|
|
1476
|
-
|
|
1796
|
+
const activityTracker = createTimebackActivityTracker(client);
|
|
1477
1797
|
const userCache = createTTLCache({
|
|
1478
1798
|
ttl: 5 * 60 * 1000,
|
|
1479
1799
|
keyPrefix: "game.timeback.user"
|
|
@@ -1558,70 +1878,16 @@ function createTimebackNamespace(client) {
|
|
|
1558
1878
|
}
|
|
1559
1879
|
};
|
|
1560
1880
|
},
|
|
1561
|
-
startActivity: (metadata) => {
|
|
1562
|
-
|
|
1563
|
-
startTime: Date.now(),
|
|
1564
|
-
metadata,
|
|
1565
|
-
pausedTime: 0,
|
|
1566
|
-
pauseStartTime: null
|
|
1567
|
-
};
|
|
1881
|
+
startActivity: (metadata, options) => {
|
|
1882
|
+
activityTracker.startActivity(metadata, options);
|
|
1568
1883
|
},
|
|
1569
1884
|
pauseActivity: () => {
|
|
1570
|
-
|
|
1571
|
-
throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
|
|
1572
|
-
}
|
|
1573
|
-
if (currentActivity.pauseStartTime !== null) {
|
|
1574
|
-
throw new Error("Activity is already paused.");
|
|
1575
|
-
}
|
|
1576
|
-
currentActivity.pauseStartTime = Date.now();
|
|
1885
|
+
activityTracker.pauseActivity();
|
|
1577
1886
|
},
|
|
1578
1887
|
resumeActivity: () => {
|
|
1579
|
-
|
|
1580
|
-
throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
|
|
1581
|
-
}
|
|
1582
|
-
if (currentActivity.pauseStartTime === null) {
|
|
1583
|
-
throw new Error("Activity is not paused.");
|
|
1584
|
-
}
|
|
1585
|
-
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
1586
|
-
currentActivity.pausedTime += pauseDuration;
|
|
1587
|
-
currentActivity.pauseStartTime = null;
|
|
1888
|
+
activityTracker.resumeActivity();
|
|
1588
1889
|
},
|
|
1589
|
-
endActivity: async (data) =>
|
|
1590
|
-
if (!currentActivity) {
|
|
1591
|
-
throw new Error("No activity in progress. Call startActivity() before endActivity().");
|
|
1592
|
-
}
|
|
1593
|
-
if (currentActivity.pauseStartTime !== null) {
|
|
1594
|
-
const pauseDuration = Date.now() - currentActivity.pauseStartTime;
|
|
1595
|
-
currentActivity.pausedTime += pauseDuration;
|
|
1596
|
-
currentActivity.pauseStartTime = null;
|
|
1597
|
-
}
|
|
1598
|
-
const endTime = Date.now();
|
|
1599
|
-
const totalElapsed = endTime - currentActivity.startTime;
|
|
1600
|
-
const activeTime = totalElapsed - currentActivity.pausedTime;
|
|
1601
|
-
const durationSeconds = Math.floor(activeTime / 1000);
|
|
1602
|
-
const { correctQuestions, totalQuestions } = data;
|
|
1603
|
-
const request = {
|
|
1604
|
-
activityData: currentActivity.metadata,
|
|
1605
|
-
scoreData: {
|
|
1606
|
-
correctQuestions,
|
|
1607
|
-
totalQuestions
|
|
1608
|
-
},
|
|
1609
|
-
timingData: {
|
|
1610
|
-
durationSeconds
|
|
1611
|
-
},
|
|
1612
|
-
xpEarned: data.xpAwarded,
|
|
1613
|
-
masteredUnits: data.masteredUnits,
|
|
1614
|
-
extensions: data.extensions
|
|
1615
|
-
};
|
|
1616
|
-
try {
|
|
1617
|
-
const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request);
|
|
1618
|
-
currentActivity = null;
|
|
1619
|
-
return response;
|
|
1620
|
-
} catch (error) {
|
|
1621
|
-
currentActivity = null;
|
|
1622
|
-
throw error;
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1890
|
+
endActivity: async (data) => activityTracker.endActivity(data)
|
|
1625
1891
|
};
|
|
1626
1892
|
}
|
|
1627
1893
|
// src/core/auth/strategies.ts
|