@playcademy/sdk 0.4.1-beta.4 → 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/internal.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
- let currentActivity = null;
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
- currentActivity = {
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
- if (!currentActivity) {
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
- if (!currentActivity) {
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/namespaces/platform/auth.ts