@playcademy/sdk 0.6.1-beta.2 → 0.6.1-beta.4

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 CHANGED
@@ -476,6 +476,14 @@ interface EndActivityScoreData {
476
476
  * @module types/timeback/api
477
477
  */
478
478
 
479
+ type TimebackPromotionStatus = 'promoted' | 'no-next-course' | 'already-promoted' | 'not-enrolled' | 'not-mastered';
480
+ interface TimebackPromotionResult {
481
+ status: TimebackPromotionStatus;
482
+ currentCourseId: string;
483
+ nextCourseId?: string;
484
+ masteredUnits?: number;
485
+ masterableUnits?: number;
486
+ }
479
487
  interface EndActivityResponse {
480
488
  status: 'ok';
481
489
  courseId: string;
@@ -485,6 +493,10 @@ interface EndActivityResponse {
485
493
  scoreStatus?: string;
486
494
  inProgress?: string;
487
495
  }
496
+ interface AdvanceCourseResponse {
497
+ status: 'ok';
498
+ promotion: TimebackPromotionResult;
499
+ }
488
500
 
489
501
  /**
490
502
  * User Types
@@ -1802,6 +1814,9 @@ declare class PlaycademyClient extends PlaycademyBaseClient {
1802
1814
  pauseActivity: () => void;
1803
1815
  resumeActivity: () => void;
1804
1816
  endActivity: (data: EndActivityScoreData) => Promise<EndActivityResponse>;
1817
+ advanceCourse: (options?: {
1818
+ subject?: TimebackSubject | undefined;
1819
+ } | undefined) => Promise<AdvanceCourseResponse>;
1805
1820
  };
1806
1821
  /**
1807
1822
  * Playcademy Credits (platform currency) management.
package/dist/index.js CHANGED
@@ -1330,7 +1330,8 @@ var BADGES = {
1330
1330
  var TIMEBACK_ROUTES = {
1331
1331
  END_ACTIVITY: "/integrations/timeback/end-activity",
1332
1332
  GET_XP: "/integrations/timeback/xp",
1333
- HEARTBEAT: "/integrations/timeback/heartbeat"
1333
+ HEARTBEAT: "/integrations/timeback/heartbeat",
1334
+ ADVANCE_COURSE: "/integrations/timeback/advance-course"
1334
1335
  };
1335
1336
  // src/core/cache/singleton-cache.ts
1336
1337
  function createSingletonCache() {
@@ -1441,6 +1442,99 @@ function createRealtimeNamespace(client) {
1441
1442
  }
1442
1443
  };
1443
1444
  }
1445
+ // src/core/guards.ts
1446
+ var VALID_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
1447
+ var VALID_SUBJECTS = [
1448
+ "Reading",
1449
+ "Language",
1450
+ "Vocabulary",
1451
+ "Social Studies",
1452
+ "Writing",
1453
+ "Science",
1454
+ "FastMath",
1455
+ "Math",
1456
+ "None"
1457
+ ];
1458
+ function isValidGrade(value) {
1459
+ return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
1460
+ }
1461
+ function isValidSubject(value) {
1462
+ return typeof value === "string" && VALID_SUBJECTS.includes(value);
1463
+ }
1464
+
1465
+ // src/core/cache/ttl-cache.ts
1466
+ function createTTLCache(options) {
1467
+ const cache = new Map;
1468
+ const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
1469
+ async function get(key, loader, config) {
1470
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1471
+ const now = Date.now();
1472
+ const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
1473
+ const force = config?.force || false;
1474
+ const skipCache = config?.skipCache || false;
1475
+ if (effectiveTTL === 0 || skipCache) {
1476
+ return loader();
1477
+ }
1478
+ if (!force) {
1479
+ const cached = cache.get(fullKey);
1480
+ if (cached && cached.expiresAt > now) {
1481
+ return cached.value;
1482
+ }
1483
+ }
1484
+ const promise = loader().catch((error) => {
1485
+ cache.delete(fullKey);
1486
+ throw error;
1487
+ });
1488
+ cache.set(fullKey, {
1489
+ value: promise,
1490
+ expiresAt: now + effectiveTTL
1491
+ });
1492
+ return promise;
1493
+ }
1494
+ function clear(key) {
1495
+ if (key === undefined) {
1496
+ cache.clear();
1497
+ onClear?.();
1498
+ } else {
1499
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1500
+ cache.delete(fullKey);
1501
+ }
1502
+ }
1503
+ function size() {
1504
+ return cache.size;
1505
+ }
1506
+ function prune() {
1507
+ const now = Date.now();
1508
+ for (const [key, entry] of cache.entries()) {
1509
+ if (entry.expiresAt <= now) {
1510
+ cache.delete(key);
1511
+ }
1512
+ }
1513
+ }
1514
+ function getKeys() {
1515
+ const keys = [];
1516
+ const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
1517
+ for (const fullKey of cache.keys()) {
1518
+ keys.push(fullKey.substring(prefixLen));
1519
+ }
1520
+ return keys;
1521
+ }
1522
+ function has(key) {
1523
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1524
+ const cached = cache.get(fullKey);
1525
+ if (!cached) {
1526
+ return false;
1527
+ }
1528
+ const now = Date.now();
1529
+ if (cached.expiresAt <= now) {
1530
+ cache.delete(fullKey);
1531
+ return false;
1532
+ }
1533
+ return true;
1534
+ }
1535
+ return { get, clear, size, prune, getKeys, has };
1536
+ }
1537
+
1444
1538
  // ../utils/src/uuid.ts
1445
1539
  var UUID_REGEX = /^[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}$/;
1446
1540
  function isValidUUID(value) {
@@ -1450,7 +1544,7 @@ function isValidUUID(value) {
1450
1544
  return UUID_REGEX.test(value);
1451
1545
  }
1452
1546
 
1453
- // src/core/activity-tracker.ts
1547
+ // src/core/timeback/activity-tracker.ts
1454
1548
  var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
1455
1549
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
1456
1550
  var DEFAULT_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
@@ -1923,102 +2017,29 @@ function createTimebackActivityTracker(client) {
1923
2017
  };
1924
2018
  }
1925
2019
 
1926
- // src/core/cache/ttl-cache.ts
1927
- function createTTLCache(options) {
1928
- const cache = new Map;
1929
- const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
1930
- async function get(key, loader, config) {
1931
- const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1932
- const now = Date.now();
1933
- const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
1934
- const force = config?.force || false;
1935
- const skipCache = config?.skipCache || false;
1936
- if (effectiveTTL === 0 || skipCache) {
1937
- return loader();
1938
- }
1939
- if (!force) {
1940
- const cached = cache.get(fullKey);
1941
- if (cached && cached.expiresAt > now) {
1942
- return cached.value;
1943
- }
1944
- }
1945
- const promise = loader().catch((error) => {
1946
- cache.delete(fullKey);
1947
- throw error;
1948
- });
1949
- cache.set(fullKey, {
1950
- value: promise,
1951
- expiresAt: now + effectiveTTL
1952
- });
1953
- return promise;
1954
- }
1955
- function clear(key) {
1956
- if (key === undefined) {
1957
- cache.clear();
1958
- onClear?.();
1959
- } else {
1960
- const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1961
- cache.delete(fullKey);
1962
- }
1963
- }
1964
- function size() {
1965
- return cache.size;
1966
- }
1967
- function prune() {
1968
- const now = Date.now();
1969
- for (const [key, entry] of cache.entries()) {
1970
- if (entry.expiresAt <= now) {
1971
- cache.delete(key);
1972
- }
1973
- }
1974
- }
1975
- function getKeys() {
1976
- const keys = [];
1977
- const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
1978
- for (const fullKey of cache.keys()) {
1979
- keys.push(fullKey.substring(prefixLen));
1980
- }
1981
- return keys;
1982
- }
1983
- function has(key) {
1984
- const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1985
- const cached = cache.get(fullKey);
1986
- if (!cached) {
1987
- return false;
1988
- }
1989
- const now = Date.now();
1990
- if (cached.expiresAt <= now) {
1991
- cache.delete(fullKey);
1992
- return false;
2020
+ // src/core/timeback/user.ts
2021
+ function createTimebackUserStore(client) {
2022
+ let override;
2023
+ return {
2024
+ snapshot() {
2025
+ return override ?? client["initPayload"]?.timeback;
2026
+ },
2027
+ async refresh() {
2028
+ const response = await client["request"]("/timeback/user", "GET");
2029
+ override = response;
2030
+ return {
2031
+ id: response.id,
2032
+ role: response.role,
2033
+ enrollments: response.enrollments,
2034
+ organizations: response.organizations
2035
+ };
1993
2036
  }
1994
- return true;
1995
- }
1996
- return { get, clear, size, prune, getKeys, has };
1997
- }
1998
-
1999
- // src/core/guards.ts
2000
- var VALID_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
2001
- var VALID_SUBJECTS = [
2002
- "Reading",
2003
- "Language",
2004
- "Vocabulary",
2005
- "Social Studies",
2006
- "Writing",
2007
- "Science",
2008
- "FastMath",
2009
- "Math",
2010
- "None"
2011
- ];
2012
- function isValidGrade(value) {
2013
- return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
2014
- }
2015
- function isValidSubject(value) {
2016
- return typeof value === "string" && VALID_SUBJECTS.includes(value);
2037
+ };
2017
2038
  }
2018
2039
 
2019
- // src/namespaces/game/timeback.ts
2020
- function createTimebackNamespace(client) {
2021
- const activityTracker = createTimebackActivityTracker(client);
2040
+ // src/core/timeback/engine.ts
2041
+ function createTimebackEngine(client) {
2042
+ const userStore = createTimebackUserStore(client);
2022
2043
  const userCache = createTTLCache({
2023
2044
  ttl: 5 * 60 * 1000,
2024
2045
  keyPrefix: "game.timeback.user"
@@ -2027,42 +2048,84 @@ function createTimebackNamespace(client) {
2027
2048
  ttl: 5000,
2028
2049
  keyPrefix: "game.timeback.xp"
2029
2050
  });
2030
- function getTimeback() {
2031
- return client["initPayload"]?.timeback;
2051
+ async function applyPromotion(promotion) {
2052
+ if (promotion.status !== "promoted" && promotion.status !== "already-promoted") {
2053
+ return;
2054
+ }
2055
+ userCache.clear("current");
2056
+ try {
2057
+ await userStore.refresh();
2058
+ } catch {}
2032
2059
  }
2060
+ const activityTracker = createTimebackActivityTracker(client);
2061
+ return {
2062
+ user: {
2063
+ snapshot: () => userStore.snapshot(),
2064
+ fetch: (options) => userCache.get("current", () => userStore.refresh(), options),
2065
+ xp: {
2066
+ fetch: (options) => {
2067
+ const cacheKey = [
2068
+ options.grade ?? "",
2069
+ options.subject ?? "",
2070
+ options.include?.toSorted().join(",") ?? ""
2071
+ ].join(":");
2072
+ return xpCache.get(cacheKey, async () => {
2073
+ const params = new URLSearchParams;
2074
+ if (options.grade !== undefined) {
2075
+ params.set("grade", String(options.grade));
2076
+ }
2077
+ if (options.subject !== undefined) {
2078
+ params.set("subject", options.subject);
2079
+ }
2080
+ if (options.include?.length) {
2081
+ params.set("include", options.include.join(","));
2082
+ }
2083
+ const queryString = params.toString();
2084
+ const endpoint = `${TIMEBACK_ROUTES.GET_XP}${queryString ? `?${queryString}` : ""}`;
2085
+ return client["requestGameBackend"](endpoint, "GET");
2086
+ }, { force: options.force });
2087
+ }
2088
+ }
2089
+ },
2090
+ activity: {
2091
+ start: activityTracker.startActivity,
2092
+ pause: activityTracker.pauseActivity,
2093
+ resume: activityTracker.resumeActivity,
2094
+ end: activityTracker.endActivity
2095
+ },
2096
+ async advanceCourse(params) {
2097
+ const response = await client["requestGameBackend"](TIMEBACK_ROUTES.ADVANCE_COURSE, "POST", params?.subject !== undefined ? { subject: params.subject } : {});
2098
+ await applyPromotion(response.promotion);
2099
+ return response;
2100
+ }
2101
+ };
2102
+ }
2103
+
2104
+ // src/namespaces/game/timeback.ts
2105
+ var VALID_INCLUDE_OPTIONS = ["perCourse", "today"];
2106
+ function createTimebackNamespace(client) {
2107
+ const engine = createTimebackEngine(client);
2033
2108
  return {
2034
2109
  get user() {
2035
2110
  assertPlatformMode(client, "timeback.user");
2036
2111
  return {
2037
2112
  get id() {
2038
- return getTimeback()?.id;
2113
+ return engine.user.snapshot()?.id;
2039
2114
  },
2040
2115
  get role() {
2041
- return getTimeback()?.role;
2116
+ return engine.user.snapshot()?.role;
2042
2117
  },
2043
2118
  get enrollments() {
2044
- return getTimeback()?.enrollments ?? [];
2119
+ return engine.user.snapshot()?.enrollments ?? [];
2045
2120
  },
2046
2121
  get organizations() {
2047
- return getTimeback()?.organizations ?? [];
2122
+ return engine.user.snapshot()?.organizations ?? [];
2048
2123
  },
2049
- fetch: async (options) => userCache.get("current", async () => {
2050
- const response = await client["request"]("/timeback/user", "GET");
2051
- const initPayload = client["initPayload"];
2052
- if (initPayload) {
2053
- initPayload.timeback = response;
2054
- }
2055
- return {
2056
- id: response.id,
2057
- role: response.role,
2058
- enrollments: response.enrollments,
2059
- organizations: response.organizations
2060
- };
2061
- }, options),
2124
+ fetch: (options) => engine.user.fetch(options),
2062
2125
  xp: {
2063
- fetch: async (options) => {
2064
- const hasGrade = options?.grade !== undefined;
2065
- const hasSubject = options?.subject !== undefined;
2126
+ fetch: async (options = {}) => {
2127
+ const hasGrade = options.grade !== undefined;
2128
+ const hasSubject = options.subject !== undefined;
2066
2129
  if (hasGrade !== hasSubject) {
2067
2130
  throw new Error("Both grade and subject must be provided together");
2068
2131
  }
@@ -2072,53 +2135,40 @@ function createTimebackNamespace(client) {
2072
2135
  if (hasSubject && !isValidSubject(options.subject)) {
2073
2136
  throw new Error(`Invalid subject: ${options.subject}. Valid subjects: ${VALID_SUBJECTS.join(", ")}`);
2074
2137
  }
2075
- const validIncludeOptions = ["perCourse", "today"];
2076
- if (options?.include?.length) {
2138
+ if (options.include?.length) {
2077
2139
  for (const opt of options.include) {
2078
- if (!validIncludeOptions.includes(opt)) {
2079
- throw new Error(`Invalid include option: ${opt}. Valid options: ${validIncludeOptions.join(", ")}`);
2140
+ if (!VALID_INCLUDE_OPTIONS.includes(opt)) {
2141
+ throw new Error(`Invalid include option: ${opt}. Valid options: ${VALID_INCLUDE_OPTIONS.join(", ")}`);
2080
2142
  }
2081
2143
  }
2082
2144
  }
2083
- const cacheKey = [
2084
- options?.grade ?? "",
2085
- options?.subject ?? "",
2086
- options?.include?.toSorted().join(",") ?? ""
2087
- ].join(":");
2088
- return xpCache.get(cacheKey, async () => {
2089
- const params = new URLSearchParams;
2090
- if (hasGrade) {
2091
- params.set("grade", String(options.grade));
2092
- }
2093
- if (hasSubject) {
2094
- params.set("subject", options.subject);
2095
- }
2096
- if (options?.include?.length) {
2097
- params.set("include", options.include.join(","));
2098
- }
2099
- const queryString = params.toString();
2100
- const endpoint = `${TIMEBACK_ROUTES.GET_XP}${queryString ? `?${queryString}` : ""}`;
2101
- return client["requestGameBackend"](endpoint, "GET");
2102
- }, { force: options?.force });
2145
+ return engine.user.xp.fetch(options);
2103
2146
  }
2104
2147
  }
2105
2148
  };
2106
2149
  },
2107
2150
  startActivity: (metadata, options) => {
2108
2151
  assertPlatformMode(client, "timeback.startActivity()");
2109
- activityTracker.startActivity(metadata, options);
2152
+ engine.activity.start(metadata, options);
2110
2153
  },
2111
2154
  pauseActivity: () => {
2112
2155
  assertPlatformMode(client, "timeback.pauseActivity()");
2113
- activityTracker.pauseActivity();
2156
+ engine.activity.pause();
2114
2157
  },
2115
2158
  resumeActivity: () => {
2116
2159
  assertPlatformMode(client, "timeback.resumeActivity()");
2117
- activityTracker.resumeActivity();
2160
+ engine.activity.resume();
2118
2161
  },
2119
2162
  endActivity: async (data) => {
2120
2163
  assertPlatformMode(client, "timeback.endActivity()");
2121
- return activityTracker.endActivity(data);
2164
+ return engine.activity.end(data);
2165
+ },
2166
+ advanceCourse: async (options) => {
2167
+ assertPlatformMode(client, "timeback.advanceCourse()");
2168
+ if (options?.subject !== undefined && !isValidSubject(options.subject)) {
2169
+ throw new Error(`Invalid subject: ${options.subject}. Valid subjects: ${VALID_SUBJECTS.join(", ")}`);
2170
+ }
2171
+ return engine.advanceCourse(options);
2122
2172
  }
2123
2173
  };
2124
2174
  }
package/dist/internal.js CHANGED
@@ -1330,7 +1330,8 @@ var BADGES = {
1330
1330
  var TIMEBACK_ROUTES = {
1331
1331
  END_ACTIVITY: "/integrations/timeback/end-activity",
1332
1332
  GET_XP: "/integrations/timeback/xp",
1333
- HEARTBEAT: "/integrations/timeback/heartbeat"
1333
+ HEARTBEAT: "/integrations/timeback/heartbeat",
1334
+ ADVANCE_COURSE: "/integrations/timeback/advance-course"
1334
1335
  };
1335
1336
  // src/core/cache/singleton-cache.ts
1336
1337
  function createSingletonCache() {
@@ -1441,6 +1442,99 @@ function createRealtimeNamespace(client) {
1441
1442
  }
1442
1443
  };
1443
1444
  }
1445
+ // src/core/guards.ts
1446
+ var VALID_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
1447
+ var VALID_SUBJECTS = [
1448
+ "Reading",
1449
+ "Language",
1450
+ "Vocabulary",
1451
+ "Social Studies",
1452
+ "Writing",
1453
+ "Science",
1454
+ "FastMath",
1455
+ "Math",
1456
+ "None"
1457
+ ];
1458
+ function isValidGrade(value) {
1459
+ return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
1460
+ }
1461
+ function isValidSubject(value) {
1462
+ return typeof value === "string" && VALID_SUBJECTS.includes(value);
1463
+ }
1464
+
1465
+ // src/core/cache/ttl-cache.ts
1466
+ function createTTLCache(options) {
1467
+ const cache = new Map;
1468
+ const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
1469
+ async function get(key, loader, config) {
1470
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1471
+ const now = Date.now();
1472
+ const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
1473
+ const force = config?.force || false;
1474
+ const skipCache = config?.skipCache || false;
1475
+ if (effectiveTTL === 0 || skipCache) {
1476
+ return loader();
1477
+ }
1478
+ if (!force) {
1479
+ const cached = cache.get(fullKey);
1480
+ if (cached && cached.expiresAt > now) {
1481
+ return cached.value;
1482
+ }
1483
+ }
1484
+ const promise = loader().catch((error) => {
1485
+ cache.delete(fullKey);
1486
+ throw error;
1487
+ });
1488
+ cache.set(fullKey, {
1489
+ value: promise,
1490
+ expiresAt: now + effectiveTTL
1491
+ });
1492
+ return promise;
1493
+ }
1494
+ function clear(key) {
1495
+ if (key === undefined) {
1496
+ cache.clear();
1497
+ onClear?.();
1498
+ } else {
1499
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1500
+ cache.delete(fullKey);
1501
+ }
1502
+ }
1503
+ function size() {
1504
+ return cache.size;
1505
+ }
1506
+ function prune() {
1507
+ const now = Date.now();
1508
+ for (const [key, entry] of cache.entries()) {
1509
+ if (entry.expiresAt <= now) {
1510
+ cache.delete(key);
1511
+ }
1512
+ }
1513
+ }
1514
+ function getKeys() {
1515
+ const keys = [];
1516
+ const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
1517
+ for (const fullKey of cache.keys()) {
1518
+ keys.push(fullKey.substring(prefixLen));
1519
+ }
1520
+ return keys;
1521
+ }
1522
+ function has(key) {
1523
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1524
+ const cached = cache.get(fullKey);
1525
+ if (!cached) {
1526
+ return false;
1527
+ }
1528
+ const now = Date.now();
1529
+ if (cached.expiresAt <= now) {
1530
+ cache.delete(fullKey);
1531
+ return false;
1532
+ }
1533
+ return true;
1534
+ }
1535
+ return { get, clear, size, prune, getKeys, has };
1536
+ }
1537
+
1444
1538
  // ../utils/src/uuid.ts
1445
1539
  var UUID_REGEX = /^[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}$/;
1446
1540
  function isValidUUID(value) {
@@ -1450,7 +1544,7 @@ function isValidUUID(value) {
1450
1544
  return UUID_REGEX.test(value);
1451
1545
  }
1452
1546
 
1453
- // src/core/activity-tracker.ts
1547
+ // src/core/timeback/activity-tracker.ts
1454
1548
  var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
1455
1549
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
1456
1550
  var DEFAULT_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
@@ -1923,102 +2017,29 @@ function createTimebackActivityTracker(client) {
1923
2017
  };
1924
2018
  }
1925
2019
 
1926
- // src/core/cache/ttl-cache.ts
1927
- function createTTLCache(options) {
1928
- const cache = new Map;
1929
- const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
1930
- async function get(key, loader, config) {
1931
- const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1932
- const now = Date.now();
1933
- const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
1934
- const force = config?.force || false;
1935
- const skipCache = config?.skipCache || false;
1936
- if (effectiveTTL === 0 || skipCache) {
1937
- return loader();
1938
- }
1939
- if (!force) {
1940
- const cached = cache.get(fullKey);
1941
- if (cached && cached.expiresAt > now) {
1942
- return cached.value;
1943
- }
1944
- }
1945
- const promise = loader().catch((error) => {
1946
- cache.delete(fullKey);
1947
- throw error;
1948
- });
1949
- cache.set(fullKey, {
1950
- value: promise,
1951
- expiresAt: now + effectiveTTL
1952
- });
1953
- return promise;
1954
- }
1955
- function clear(key) {
1956
- if (key === undefined) {
1957
- cache.clear();
1958
- onClear?.();
1959
- } else {
1960
- const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1961
- cache.delete(fullKey);
1962
- }
1963
- }
1964
- function size() {
1965
- return cache.size;
1966
- }
1967
- function prune() {
1968
- const now = Date.now();
1969
- for (const [key, entry] of cache.entries()) {
1970
- if (entry.expiresAt <= now) {
1971
- cache.delete(key);
1972
- }
1973
- }
1974
- }
1975
- function getKeys() {
1976
- const keys = [];
1977
- const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
1978
- for (const fullKey of cache.keys()) {
1979
- keys.push(fullKey.substring(prefixLen));
1980
- }
1981
- return keys;
1982
- }
1983
- function has(key) {
1984
- const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1985
- const cached = cache.get(fullKey);
1986
- if (!cached) {
1987
- return false;
1988
- }
1989
- const now = Date.now();
1990
- if (cached.expiresAt <= now) {
1991
- cache.delete(fullKey);
1992
- return false;
2020
+ // src/core/timeback/user.ts
2021
+ function createTimebackUserStore(client) {
2022
+ let override;
2023
+ return {
2024
+ snapshot() {
2025
+ return override ?? client["initPayload"]?.timeback;
2026
+ },
2027
+ async refresh() {
2028
+ const response = await client["request"]("/timeback/user", "GET");
2029
+ override = response;
2030
+ return {
2031
+ id: response.id,
2032
+ role: response.role,
2033
+ enrollments: response.enrollments,
2034
+ organizations: response.organizations
2035
+ };
1993
2036
  }
1994
- return true;
1995
- }
1996
- return { get, clear, size, prune, getKeys, has };
1997
- }
1998
-
1999
- // src/core/guards.ts
2000
- var VALID_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
2001
- var VALID_SUBJECTS = [
2002
- "Reading",
2003
- "Language",
2004
- "Vocabulary",
2005
- "Social Studies",
2006
- "Writing",
2007
- "Science",
2008
- "FastMath",
2009
- "Math",
2010
- "None"
2011
- ];
2012
- function isValidGrade(value) {
2013
- return typeof value === "number" && Number.isInteger(value) && VALID_GRADES.includes(value);
2014
- }
2015
- function isValidSubject(value) {
2016
- return typeof value === "string" && VALID_SUBJECTS.includes(value);
2037
+ };
2017
2038
  }
2018
2039
 
2019
- // src/namespaces/game/timeback.ts
2020
- function createTimebackNamespace(client) {
2021
- const activityTracker = createTimebackActivityTracker(client);
2040
+ // src/core/timeback/engine.ts
2041
+ function createTimebackEngine(client) {
2042
+ const userStore = createTimebackUserStore(client);
2022
2043
  const userCache = createTTLCache({
2023
2044
  ttl: 5 * 60 * 1000,
2024
2045
  keyPrefix: "game.timeback.user"
@@ -2027,42 +2048,84 @@ function createTimebackNamespace(client) {
2027
2048
  ttl: 5000,
2028
2049
  keyPrefix: "game.timeback.xp"
2029
2050
  });
2030
- function getTimeback() {
2031
- return client["initPayload"]?.timeback;
2051
+ async function applyPromotion(promotion) {
2052
+ if (promotion.status !== "promoted" && promotion.status !== "already-promoted") {
2053
+ return;
2054
+ }
2055
+ userCache.clear("current");
2056
+ try {
2057
+ await userStore.refresh();
2058
+ } catch {}
2032
2059
  }
2060
+ const activityTracker = createTimebackActivityTracker(client);
2061
+ return {
2062
+ user: {
2063
+ snapshot: () => userStore.snapshot(),
2064
+ fetch: (options) => userCache.get("current", () => userStore.refresh(), options),
2065
+ xp: {
2066
+ fetch: (options) => {
2067
+ const cacheKey = [
2068
+ options.grade ?? "",
2069
+ options.subject ?? "",
2070
+ options.include?.toSorted().join(",") ?? ""
2071
+ ].join(":");
2072
+ return xpCache.get(cacheKey, async () => {
2073
+ const params = new URLSearchParams;
2074
+ if (options.grade !== undefined) {
2075
+ params.set("grade", String(options.grade));
2076
+ }
2077
+ if (options.subject !== undefined) {
2078
+ params.set("subject", options.subject);
2079
+ }
2080
+ if (options.include?.length) {
2081
+ params.set("include", options.include.join(","));
2082
+ }
2083
+ const queryString = params.toString();
2084
+ const endpoint = `${TIMEBACK_ROUTES.GET_XP}${queryString ? `?${queryString}` : ""}`;
2085
+ return client["requestGameBackend"](endpoint, "GET");
2086
+ }, { force: options.force });
2087
+ }
2088
+ }
2089
+ },
2090
+ activity: {
2091
+ start: activityTracker.startActivity,
2092
+ pause: activityTracker.pauseActivity,
2093
+ resume: activityTracker.resumeActivity,
2094
+ end: activityTracker.endActivity
2095
+ },
2096
+ async advanceCourse(params) {
2097
+ const response = await client["requestGameBackend"](TIMEBACK_ROUTES.ADVANCE_COURSE, "POST", params?.subject !== undefined ? { subject: params.subject } : {});
2098
+ await applyPromotion(response.promotion);
2099
+ return response;
2100
+ }
2101
+ };
2102
+ }
2103
+
2104
+ // src/namespaces/game/timeback.ts
2105
+ var VALID_INCLUDE_OPTIONS = ["perCourse", "today"];
2106
+ function createTimebackNamespace(client) {
2107
+ const engine = createTimebackEngine(client);
2033
2108
  return {
2034
2109
  get user() {
2035
2110
  assertPlatformMode(client, "timeback.user");
2036
2111
  return {
2037
2112
  get id() {
2038
- return getTimeback()?.id;
2113
+ return engine.user.snapshot()?.id;
2039
2114
  },
2040
2115
  get role() {
2041
- return getTimeback()?.role;
2116
+ return engine.user.snapshot()?.role;
2042
2117
  },
2043
2118
  get enrollments() {
2044
- return getTimeback()?.enrollments ?? [];
2119
+ return engine.user.snapshot()?.enrollments ?? [];
2045
2120
  },
2046
2121
  get organizations() {
2047
- return getTimeback()?.organizations ?? [];
2122
+ return engine.user.snapshot()?.organizations ?? [];
2048
2123
  },
2049
- fetch: async (options) => userCache.get("current", async () => {
2050
- const response = await client["request"]("/timeback/user", "GET");
2051
- const initPayload = client["initPayload"];
2052
- if (initPayload) {
2053
- initPayload.timeback = response;
2054
- }
2055
- return {
2056
- id: response.id,
2057
- role: response.role,
2058
- enrollments: response.enrollments,
2059
- organizations: response.organizations
2060
- };
2061
- }, options),
2124
+ fetch: (options) => engine.user.fetch(options),
2062
2125
  xp: {
2063
- fetch: async (options) => {
2064
- const hasGrade = options?.grade !== undefined;
2065
- const hasSubject = options?.subject !== undefined;
2126
+ fetch: async (options = {}) => {
2127
+ const hasGrade = options.grade !== undefined;
2128
+ const hasSubject = options.subject !== undefined;
2066
2129
  if (hasGrade !== hasSubject) {
2067
2130
  throw new Error("Both grade and subject must be provided together");
2068
2131
  }
@@ -2072,53 +2135,40 @@ function createTimebackNamespace(client) {
2072
2135
  if (hasSubject && !isValidSubject(options.subject)) {
2073
2136
  throw new Error(`Invalid subject: ${options.subject}. Valid subjects: ${VALID_SUBJECTS.join(", ")}`);
2074
2137
  }
2075
- const validIncludeOptions = ["perCourse", "today"];
2076
- if (options?.include?.length) {
2138
+ if (options.include?.length) {
2077
2139
  for (const opt of options.include) {
2078
- if (!validIncludeOptions.includes(opt)) {
2079
- throw new Error(`Invalid include option: ${opt}. Valid options: ${validIncludeOptions.join(", ")}`);
2140
+ if (!VALID_INCLUDE_OPTIONS.includes(opt)) {
2141
+ throw new Error(`Invalid include option: ${opt}. Valid options: ${VALID_INCLUDE_OPTIONS.join(", ")}`);
2080
2142
  }
2081
2143
  }
2082
2144
  }
2083
- const cacheKey = [
2084
- options?.grade ?? "",
2085
- options?.subject ?? "",
2086
- options?.include?.toSorted().join(",") ?? ""
2087
- ].join(":");
2088
- return xpCache.get(cacheKey, async () => {
2089
- const params = new URLSearchParams;
2090
- if (hasGrade) {
2091
- params.set("grade", String(options.grade));
2092
- }
2093
- if (hasSubject) {
2094
- params.set("subject", options.subject);
2095
- }
2096
- if (options?.include?.length) {
2097
- params.set("include", options.include.join(","));
2098
- }
2099
- const queryString = params.toString();
2100
- const endpoint = `${TIMEBACK_ROUTES.GET_XP}${queryString ? `?${queryString}` : ""}`;
2101
- return client["requestGameBackend"](endpoint, "GET");
2102
- }, { force: options?.force });
2145
+ return engine.user.xp.fetch(options);
2103
2146
  }
2104
2147
  }
2105
2148
  };
2106
2149
  },
2107
2150
  startActivity: (metadata, options) => {
2108
2151
  assertPlatformMode(client, "timeback.startActivity()");
2109
- activityTracker.startActivity(metadata, options);
2152
+ engine.activity.start(metadata, options);
2110
2153
  },
2111
2154
  pauseActivity: () => {
2112
2155
  assertPlatformMode(client, "timeback.pauseActivity()");
2113
- activityTracker.pauseActivity();
2156
+ engine.activity.pause();
2114
2157
  },
2115
2158
  resumeActivity: () => {
2116
2159
  assertPlatformMode(client, "timeback.resumeActivity()");
2117
- activityTracker.resumeActivity();
2160
+ engine.activity.resume();
2118
2161
  },
2119
2162
  endActivity: async (data) => {
2120
2163
  assertPlatformMode(client, "timeback.endActivity()");
2121
- return activityTracker.endActivity(data);
2164
+ return engine.activity.end(data);
2165
+ },
2166
+ advanceCourse: async (options) => {
2167
+ assertPlatformMode(client, "timeback.advanceCourse()");
2168
+ if (options?.subject !== undefined && !isValidSubject(options.subject)) {
2169
+ throw new Error(`Invalid subject: ${options.subject}. Valid subjects: ${VALID_SUBJECTS.join(", ")}`);
2170
+ }
2171
+ return engine.advanceCourse(options);
2122
2172
  }
2123
2173
  };
2124
2174
  }
package/dist/types.d.ts CHANGED
@@ -332,6 +332,14 @@ interface GameTimebackIntegration {
332
332
  createdAt: Date;
333
333
  updatedAt: Date;
334
334
  }
335
+ type TimebackPromotionStatus = 'promoted' | 'no-next-course' | 'already-promoted' | 'not-enrolled' | 'not-mastered';
336
+ interface TimebackPromotionResult {
337
+ status: TimebackPromotionStatus;
338
+ currentCourseId: string;
339
+ nextCourseId?: string;
340
+ masteredUnits?: number;
341
+ masterableUnits?: number;
342
+ }
335
343
  interface EndActivityResponse {
336
344
  status: 'ok';
337
345
  courseId: string;
@@ -341,6 +349,10 @@ interface EndActivityResponse {
341
349
  scoreStatus?: string;
342
350
  inProgress?: string;
343
351
  }
352
+ interface AdvanceCourseResponse {
353
+ status: 'ok';
354
+ promotion: TimebackPromotionResult;
355
+ }
344
356
 
345
357
  /**
346
358
  * Achievement Types
@@ -5295,6 +5307,9 @@ declare class PlaycademyClient extends PlaycademyBaseClient {
5295
5307
  pauseActivity: () => void;
5296
5308
  resumeActivity: () => void;
5297
5309
  endActivity: (data: EndActivityScoreData) => Promise<EndActivityResponse>;
5310
+ advanceCourse: (options?: {
5311
+ subject?: TimebackSubject | undefined;
5312
+ } | undefined) => Promise<AdvanceCourseResponse>;
5298
5313
  };
5299
5314
  /**
5300
5315
  * Playcademy Credits (platform currency) management.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sdk",
3
- "version": "0.6.1-beta.2",
3
+ "version": "0.6.1-beta.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {