playcademy 0.14.18 → 0.14.19-alpha.2

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/README.md CHANGED
@@ -213,6 +213,26 @@ Credentials are stored in `~/.playcademy/auth.json`:
213
213
 
214
214
  Set up TimeBack LTI integration for your game.
215
215
 
216
+ **Note:** Before running this command, ensure that `totalXp` is configured for each course in your `playcademy.config.js`:
217
+
218
+ ```js
219
+ integrations: {
220
+ timeback: {
221
+ courses: [
222
+ {
223
+ subject: 'Math',
224
+ grade: 3,
225
+ metadata: {
226
+ metrics: {
227
+ totalXp: 1000, // Required
228
+ },
229
+ },
230
+ },
231
+ ]
232
+ }
233
+ }
234
+ ```
235
+
216
236
  ```bash
217
237
  playcademy timeback setup
218
238
  playcademy timeback setup --dry-run
@@ -278,7 +278,7 @@ declare const CLI_FILES: {
278
278
  declare const DEFAULT_PORTS: {
279
279
  /** Sandbox server (mock platform API) */
280
280
  readonly SANDBOX: 4321;
281
- /** Backend dev server (game backend with HMR) */
281
+ /** Backend dev server (project backend with HMR) */
282
282
  readonly BACKEND: 8788;
283
283
  };
284
284
 
package/dist/constants.js CHANGED
@@ -31,7 +31,6 @@ var package_default = {
31
31
  sharp: "^0.34.2",
32
32
  typedoc: "^0.28.5",
33
33
  "typedoc-plugin-markdown": "^4.7.0",
34
- "typedoc-vitepress-theme": "^1.1.2",
35
34
  "typescript-eslint": "^8.30.1",
36
35
  "yocto-spinner": "^0.2.2"
37
36
  },
@@ -255,7 +254,7 @@ var CLI_FILES = {
255
254
  var DEFAULT_PORTS = {
256
255
  /** Sandbox server (mock platform API) */
257
256
  SANDBOX: 4321,
258
- /** Backend dev server (game backend with HMR) */
257
+ /** Backend dev server (project backend with HMR) */
259
258
  BACKEND: 8788
260
259
  };
261
260
 
package/dist/db.js CHANGED
@@ -1458,7 +1458,6 @@ var package_default = {
1458
1458
  sharp: "^0.34.2",
1459
1459
  typedoc: "^0.28.5",
1460
1460
  "typedoc-plugin-markdown": "^4.7.0",
1461
- "typedoc-vitepress-theme": "^1.1.2",
1462
1461
  "typescript-eslint": "^8.30.1",
1463
1462
  "yocto-spinner": "^0.2.2"
1464
1463
  },
@@ -2017,7 +2016,7 @@ function getRunCommand(pm, script) {
2017
2016
  }
2018
2017
 
2019
2018
  // src/lib/core/client.ts
2020
- import { PlaycademyClient } from "@playcademy/sdk";
2019
+ import { PlaycademyClient } from "@playcademy/sdk/internal";
2021
2020
 
2022
2021
  // src/lib/core/context.ts
2023
2022
  var context = {};
@@ -2043,7 +2042,7 @@ import {
2043
2042
  gray,
2044
2043
  green,
2045
2044
  greenBright,
2046
- red as red2,
2045
+ red,
2047
2046
  yellow,
2048
2047
  yellowBright
2049
2048
  } from "colorette";
@@ -2636,34 +2635,77 @@ var eraseLine = ESC + "2K";
2636
2635
  import colors3 from "yoctocolors-cjs";
2637
2636
 
2638
2637
  // src/lib/core/error.ts
2639
- import { bold as bold2, dim as dim2, red } from "colorette";
2640
- import { ApiError, extractApiErrorInfo } from "@playcademy/sdk";
2638
+ import { bold as bold2, dim as dim2, redBright } from "colorette";
2639
+ import { ApiError, extractApiErrorInfo } from "@playcademy/sdk/internal";
2641
2640
  function isConfigError(error) {
2642
2641
  return error !== null && typeof error === "object" && "name" in error && error.name === "ConfigError" && "message" in error;
2643
2642
  }
2643
+ function extractEmbeddedJson(message) {
2644
+ const jsonMatch = message.match(/(\{.+\})$/);
2645
+ if (!jsonMatch) {
2646
+ return { cleanMessage: message };
2647
+ }
2648
+ try {
2649
+ const json = JSON.parse(jsonMatch[1]);
2650
+ const cleanMessage = message.slice(0, jsonMatch.index).trim();
2651
+ return { cleanMessage, json };
2652
+ } catch {
2653
+ return { cleanMessage: message };
2654
+ }
2655
+ }
2656
+ function cleanMessageSuffix(message) {
2657
+ let cleaned = message.replace(/:\s*\d{3}\s*$/, "").trim();
2658
+ if (cleaned.endsWith(":")) {
2659
+ cleaned = cleaned.slice(0, -1).trim();
2660
+ }
2661
+ return cleaned;
2662
+ }
2663
+ function removeStatusPrefix(message, statusCode) {
2664
+ if (message.startsWith(`${statusCode} `)) {
2665
+ return message.slice(`${statusCode} `.length);
2666
+ }
2667
+ return message;
2668
+ }
2644
2669
  function displayApiError(error, indent) {
2645
2670
  const spaces = " ".repeat(indent);
2646
2671
  const errorInfo = extractApiErrorInfo(error);
2647
- if (errorInfo) {
2648
- console.error(`${spaces}${red("\u2716")} ${bold2(`API Error: ${errorInfo.statusText}`)}`);
2649
- console.error("");
2650
- if (errorInfo.message) {
2651
- console.error(`${spaces} ${errorInfo.message}`);
2652
- } else if (errorInfo.error) {
2653
- console.error(`${spaces} ${errorInfo.error}`);
2672
+ if (!errorInfo) {
2673
+ console.error(`${spaces}${redBright("\u2716")} ${bold2(error.message)}`);
2674
+ if (process.env.DEBUG && error.details) {
2675
+ console.error("");
2676
+ logger.json(error.details, indent + 1);
2677
+ }
2678
+ return;
2679
+ }
2680
+ const statusCode = errorInfo.status;
2681
+ let displayMessage = errorInfo.statusText;
2682
+ displayMessage = removeStatusPrefix(displayMessage, statusCode);
2683
+ const { cleanMessage, json: embeddedJson } = extractEmbeddedJson(displayMessage);
2684
+ displayMessage = cleanMessageSuffix(cleanMessage);
2685
+ let errorCode;
2686
+ if (error.details && typeof error.details === "object") {
2687
+ const details = error.details;
2688
+ if ("code" in details && typeof details.code === "string") {
2689
+ errorCode = details.code;
2654
2690
  }
2655
- if (errorInfo.details) {
2656
- console.error(
2657
- `${spaces} ${dim2("Details:")} ${JSON.stringify(errorInfo.details, null, 2)}`
2658
- );
2691
+ }
2692
+ let errorHeader = "API Error";
2693
+ if (errorCode) {
2694
+ errorHeader += ` ${redBright(`[${errorCode}]`)}`;
2695
+ }
2696
+ errorHeader += `: ${displayMessage} ${redBright(`[${statusCode}]`)}`;
2697
+ console.error(`${spaces}${redBright("\u2716")} ${bold2(errorHeader)}`);
2698
+ if (process.env.DEBUG) {
2699
+ const detailsToShow = embeddedJson || error.details || errorInfo.details;
2700
+ if (detailsToShow) {
2701
+ console.error("");
2702
+ logger.json(detailsToShow, indent + 1);
2659
2703
  }
2660
- } else {
2661
- console.error(`${spaces}${red("\u2716")} ${bold2(error.message)}`);
2662
2704
  }
2663
2705
  }
2664
2706
  function displayConfigError(error, indent) {
2665
2707
  const spaces = " ".repeat(indent);
2666
- console.error(`${spaces}${red("\u2716")} ${bold2(error.message)}`);
2708
+ console.error(`${spaces}${redBright("\u2716")} ${bold2(error.message)}`);
2667
2709
  if (error.field) {
2668
2710
  console.error(`${spaces} ${dim2("Field:")} ${error.field}`);
2669
2711
  }
@@ -2673,7 +2715,7 @@ function displayConfigError(error, indent) {
2673
2715
  }
2674
2716
  function displayGenericError(error, indent) {
2675
2717
  const spaces = " ".repeat(indent);
2676
- console.error(`${spaces}${red("\u2716")} ${bold2(error.message)}`);
2718
+ console.error(`${spaces}${redBright("\u2716")} ${bold2(error.message)}`);
2677
2719
  if (error.stack && process.env.DEBUG) {
2678
2720
  console.error(`${spaces} ${dim2("Stack:")}`);
2679
2721
  console.error(
@@ -2695,7 +2737,7 @@ function formatError(error, indent = 0) {
2695
2737
  displayGenericError(error, indent);
2696
2738
  return;
2697
2739
  }
2698
- console.error(`${spaces}${red("\u2716")} ${bold2(String(error))}`);
2740
+ console.error(`${spaces}${redBright("\u2716")} ${bold2(String(error))}`);
2699
2741
  }
2700
2742
 
2701
2743
  // src/lib/core/logger.ts
@@ -2706,12 +2748,20 @@ function customTransform(text) {
2706
2748
  return result;
2707
2749
  }
2708
2750
  function formatTable(data, title) {
2751
+ const ANSI_REGEX = /\u001B\[[0-9;]*m/g;
2752
+ const stripAnsi2 = (value) => value.replace(ANSI_REGEX, "");
2753
+ const visibleLength = (value) => stripAnsi2(value).length;
2754
+ const padCell = (value, width) => {
2755
+ const length = visibleLength(value);
2756
+ if (length >= width) return value;
2757
+ return value + " ".repeat(width - length);
2758
+ };
2709
2759
  if (data.length === 0) return;
2710
2760
  const keys = Object.keys(data[0]);
2711
2761
  const rows = data.map((item) => keys.map((key) => String(item[key] ?? "")));
2712
2762
  const widths = keys.map((key, i) => {
2713
- const headerWidth = key.length;
2714
- const dataWidth = Math.max(...rows.map((row) => row[i].length));
2763
+ const headerWidth = visibleLength(key);
2764
+ const dataWidth = Math.max(...rows.map((row) => visibleLength(row[i])));
2715
2765
  return Math.max(headerWidth, dataWidth);
2716
2766
  });
2717
2767
  const totalWidth = widths.reduce((sum, w) => sum + w + 3, -1);
@@ -2727,11 +2777,11 @@ function formatTable(data, title) {
2727
2777
  console.log(titleRow);
2728
2778
  console.log(titleSeparator);
2729
2779
  }
2730
- const header = "\u2502 " + keys.map((key, i) => key.padEnd(widths[i])).join(" \u2502 ") + " \u2502";
2780
+ const header = "\u2502 " + keys.map((key, i) => padCell(key, widths[i])).join(" \u2502 ") + " \u2502";
2731
2781
  console.log(header);
2732
2782
  console.log(separator);
2733
2783
  rows.forEach((row) => {
2734
- const dataRow = "\u2502 " + row.map((cell, i) => cell.padEnd(widths[i])).join(" \u2502 ") + " \u2502";
2784
+ const dataRow = "\u2502 " + row.map((cell, i) => padCell(cell, widths[i])).join(" \u2502 ") + " \u2502";
2735
2785
  console.log(dataRow);
2736
2786
  });
2737
2787
  console.log(bottomBorder);
@@ -2790,7 +2840,7 @@ var logger = {
2790
2840
  */
2791
2841
  error: (message, indent = 0) => {
2792
2842
  const spaces = " ".repeat(indent);
2793
- console.error(`${spaces}${red2("\u2716")} ${customTransform(message)}`);
2843
+ console.error(`${spaces}${red("\u2716")} ${customTransform(message)}`);
2794
2844
  },
2795
2845
  bold: (message, indent = 0) => {
2796
2846
  const spaces = " ".repeat(indent);
@@ -2858,7 +2908,7 @@ var logger = {
2858
2908
  const oldSize = formatSize(previousSize);
2859
2909
  const newSize = formatSize(currentSize);
2860
2910
  const delta = dim3(`(${formatDelta(currentSize - previousSize)})`);
2861
- const value = `${red2(oldSize)} \u2192 ${green(newSize)} ${delta}`;
2911
+ const value = `${red(oldSize)} \u2192 ${green(newSize)} ${delta}`;
2862
2912
  console.log(`${spaces}${dim3(label + ":")} ${bold3(value)}`);
2863
2913
  },
2864
2914
  /**
@@ -33,6 +33,13 @@ export async function registerBuiltinRoutes(app: Hono<HonoEnv>, integrations?: I
33
33
  ])
34
34
  app.post(ROUTES.TIMEBACK.END_ACTIVITY, endActivity.POST)
35
35
  // ... other routes
36
+ } else if (integrations?.timeback === null) {
37
+ app.post('/api/integrations/timeback/end-activity', async c => {
38
+ return c.json({
39
+ status: 'ok',
40
+ __playcademyDevWarning: 'timeback-not-configured',
41
+ })
42
+ })
36
43
  }
37
44
 
38
45
  // TODO: Auth integration
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Context } from 'hono'
8
- import type { PlaycademyClient, PlaycademyConfig } from '@playcademy/sdk/server'
8
+ import type { PlaycademyConfig } from '@playcademy/sdk/server'
9
9
  import type { RouteMetadata } from '../entry/types'
10
10
  import type { HonoEnv, ServerEnv } from '../types'
11
11
 
@@ -49,16 +49,27 @@ function formatRoutes(routes: RouteMetadata[]): Array<{ path: string; methods: s
49
49
  /**
50
50
  * Get TimeBack debug info if applicable
51
51
  */
52
- function getTimebackDebugInfo(config?: PlaycademyConfig, sdk?: PlaycademyClient) {
53
- if (config?.integrations?.timeback && sdk?.timeback?.courseId) {
54
- return { timeback: { courseId: sdk.timeback.courseId } }
52
+ function getTimebackDebugInfo(config?: PlaycademyConfig) {
53
+ const timeback = config?.integrations?.timeback
54
+
55
+ if (!timeback || !timeback.courses || timeback.courses.length === 0) {
56
+ return {}
57
+ }
58
+
59
+ const grades = Array.from(new Set(timeback.courses.map(c => c.grade))).sort()
60
+ const subjects = Array.from(new Set(timeback.courses.map(c => c.subject))).sort()
61
+
62
+ return {
63
+ timeback: {
64
+ courseCount: timeback.courses.length,
65
+ grades,
66
+ subjects,
67
+ },
55
68
  }
56
- return {}
57
69
  }
58
70
 
59
71
  export async function GET(c: Context<HonoEnv>): Promise<Response> {
60
72
  const config = c.get('config')
61
- const sdk = c.get('sdk')
62
73
  const routeMetadata = c.get('routeMetadata')
63
74
 
64
75
  return c.json({
@@ -68,6 +79,6 @@ export async function GET(c: Context<HonoEnv>): Promise<Response> {
68
79
  secrets: getSecretsCount(c.env),
69
80
  integrations: getEnabledIntegrations(config),
70
81
  routes: formatRoutes(routeMetadata),
71
- ...getTimebackDebugInfo(config, sdk),
82
+ ...getTimebackDebugInfo(config),
72
83
  })
73
84
  }
@@ -1,5 +1,3 @@
1
- import { verifyGameToken } from '@playcademy/sdk/server'
2
-
3
1
  import type { Context } from 'hono'
4
2
  import type { PlaycademyConfig } from '@playcademy/sdk/server'
5
3
  import type { ActivityData } from '@playcademy/timeback/types'
@@ -26,82 +24,148 @@ function getConfig(c: Context<HonoEnv>): PlaycademyConfig {
26
24
  return config
27
25
  }
28
26
 
29
- function enrichActivityData(
30
- activityData: ActivityData,
31
- config: PlaycademyConfig,
32
- c: Context<HonoEnv>,
33
- ): ActivityData {
27
+ function validateRequestBody(body: {
28
+ activityData?: ActivityData
29
+ scoreData?: { correctQuestions?: number; totalQuestions?: number }
30
+ timingData?: { durationSeconds?: number }
31
+ masteredUnits?: number
32
+ }): { error: string } | null {
33
+ if (!body.activityData?.activityId) {
34
+ return { error: 'activityId is required' }
35
+ }
36
+ if (!body.activityData?.grade) {
37
+ return { error: 'grade is required' }
38
+ }
39
+ if (!body.activityData?.subject) {
40
+ return { error: 'subject is required' }
41
+ }
42
+ if (
43
+ typeof body.scoreData?.correctQuestions !== 'number' ||
44
+ typeof body.scoreData?.totalQuestions !== 'number'
45
+ ) {
46
+ return { error: 'correctQuestions and totalQuestions are required' }
47
+ }
48
+ if (typeof body.timingData?.durationSeconds !== 'number') {
49
+ return { error: 'durationSeconds is required' }
50
+ }
51
+ if (
52
+ body.masteredUnits !== undefined &&
53
+ (typeof body.masteredUnits !== 'number' || body.masteredUnits < 0)
54
+ ) {
55
+ return { error: 'masteredUnits must be a non-negative number when provided' }
56
+ }
57
+ return null
58
+ }
59
+
60
+ function validateCourse(params: {
61
+ grade: number
62
+ subject: string
63
+ config: PlaycademyConfig
64
+ }): { error: string } | null {
65
+ const { grade, subject, config } = params
66
+ const timebackConfig = config.integrations?.timeback
67
+ const configuredCourse = timebackConfig?.courses?.find(
68
+ course => course.grade === grade && course.subject === subject,
69
+ )
70
+ if (!configuredCourse) {
71
+ const configured = timebackConfig?.courses
72
+ ?.map(c => `${c.subject} (Grade ${c.grade})`)
73
+ .join(', ')
74
+ return {
75
+ error: `Invalid grade/subject combination: ${subject} (Grade ${grade}). Configured courses: ${configured || 'none'}`,
76
+ }
77
+ }
78
+ return null
79
+ }
80
+
81
+ function enrichActivityData(params: {
82
+ activityData: ActivityData
83
+ config: PlaycademyConfig
84
+ c: Context<HonoEnv>
85
+ }): { data?: ActivityData; error?: string } {
86
+ const { activityData, config, c } = params
34
87
  const appName = activityData.appName || config?.name
35
- const subject =
36
- activityData.subject ||
37
- config?.integrations?.timeback?.course?.defaultSubject ||
38
- config?.integrations?.timeback?.course?.subjects?.[0]
39
88
  const sensorUrl = activityData.sensorUrl || new URL(c.req.url).origin
40
89
 
41
- if (!appName) throw new Error('App name is required')
42
- if (!subject) throw new Error('Subject is required')
43
- if (!sensorUrl) throw new Error('Sensor URL is required')
90
+ if (!appName) {
91
+ return { error: 'App name is required (missing from activityData and config)' }
92
+ }
93
+ if (!sensorUrl) {
94
+ return { error: 'Sensor URL is required' }
95
+ }
44
96
 
45
- return { ...activityData, appName, subject, sensorUrl }
97
+ return { data: { ...activityData, appName, sensorUrl } }
46
98
  }
47
99
 
48
100
  export async function POST(c: Context<HonoEnv>): Promise<Response> {
49
101
  try {
50
- // 1. Verify game token from frontend (calls platform API for verification)
51
- const token = c.req.header('Authorization')?.replace('Bearer ', '')
52
- if (!token) {
53
- return c.json({ error: 'Unauthorized' }, 401)
54
- }
55
-
56
- const { user } = await verifyGameToken(token, { baseUrl: c.env.PLAYCADEMY_BASE_URL })
102
+ // 1. Get authenticated user from middleware
103
+ const user = c.get('playcademyUser')
104
+ if (!user) return c.json({ error: 'Unauthorized' }, 401)
57
105
 
58
106
  // 2. Ensure user has TimeBack integration
59
107
  if (!user.timeback_id) {
60
- return c.json({ error: 'User does not have TimeBack integration' }, 400)
108
+ const message = 'User does not have TimeBack integration'
109
+ console.error('[TimeBack End Activity] Error:', message)
110
+ return c.json({ error: message }, 400)
61
111
  }
62
112
 
63
113
  // 3. Parse request body
64
- const { activityData, scoreData, timingData, xpEarned } = await c.req.json()
114
+ const { activityData, scoreData, timingData, xpEarned, masteredUnits } = await c.req.json()
65
115
 
66
116
  // 4. Validate required fields
67
- if (!activityData?.activityId) {
68
- return c.json({ error: 'activityId is required' }, 400)
69
- }
70
- if (
71
- typeof scoreData?.correctQuestions !== 'number' ||
72
- typeof scoreData?.totalQuestions !== 'number'
73
- ) {
74
- return c.json({ error: 'correctQuestions and totalQuestions are required' }, 400)
75
- }
76
- if (typeof timingData?.durationSeconds !== 'number') {
77
- return c.json({ error: 'durationSeconds is required' }, 400)
117
+ const bodyValidationError = validateRequestBody({
118
+ activityData,
119
+ scoreData,
120
+ timingData,
121
+ masteredUnits,
122
+ })
123
+
124
+ if (bodyValidationError) {
125
+ const message = bodyValidationError.error
126
+ console.error('[TimeBack End Activity] Error:', message)
127
+ return c.json({ error: message }, 400)
78
128
  }
79
129
 
80
- // 5. Get config and enrich activity data with required Caliper fields
130
+ // 5. Get config
81
131
  const config = getConfig(c)
82
- const enrichedActivityData = enrichActivityData(activityData, config, c)
83
132
 
84
- // 6. Get SDK client from context (initialized once per Worker, reused across requests)
133
+ // 6. Validate grade/subject against configured courses
134
+ const { grade, subject } = activityData
135
+ const courseValidationError = validateCourse({ grade, subject, config })
136
+
137
+ if (courseValidationError) {
138
+ const message = courseValidationError.error
139
+ console.error('[TimeBack End Activity] Error:', message)
140
+ return c.json({ error: message }, 400)
141
+ }
142
+
143
+ // 7. Enrich activity data with required Caliper fields
144
+ const enrichResult = enrichActivityData({ activityData, config, c })
145
+
146
+ if (!enrichResult.data) {
147
+ console.error('[TimeBack End Activity] Error:', enrichResult.error)
148
+ return c.json({ error: enrichResult.error }, 500)
149
+ }
150
+
151
+ // 8. Get SDK client from context (initialized once per Worker, reused across requests)
85
152
  const sdk = c.get('sdk')
86
153
 
87
- // 7. End activity (SDK calculates XP server-side with attempt tracking)
154
+ // 9. End activity (SDK calculates XP server-side with attempt tracking)
88
155
  const result = await sdk.timeback.endActivity(user.timeback_id, {
89
- activityData: enrichedActivityData,
156
+ activityData: enrichResult.data,
90
157
  scoreData,
91
158
  timingData,
92
159
  xpEarned,
160
+ masteredUnits,
93
161
  })
94
162
 
95
163
  return c.json(result)
96
164
  } catch (error) {
97
- console.error('[TimeBack End Activity] Error:', error)
98
- return c.json(
99
- {
100
- error: 'Failed to end activity',
101
- message: error instanceof Error ? error.message : String(error),
102
- stack: error instanceof Error ? error.stack : undefined,
103
- },
104
- 500,
105
- )
165
+ const message = error instanceof Error ? error.message : String(error)
166
+ const stack = error instanceof Error ? error.stack : undefined
167
+ if (message) console.error('[TimeBack End Activity] Error:', message)
168
+ if (stack) console.error('[TimeBack End Activity] Stack:', stack)
169
+ return c.json({ error: 'Failed to end activity', message, stack }, 500)
106
170
  }
107
171
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  /// <reference types="@cloudflare/workers-types" />
9
9
 
10
+ import type { UserInfo } from '@playcademy/data/types'
10
11
  import type { PlaycademyClient, PlaycademyConfig } from '@playcademy/sdk/server'
11
12
  import type { RouteMetadata } from './entry/types'
12
13
 
@@ -70,6 +71,7 @@ export interface HonoVariables {
70
71
  sdk: PlaycademyClient
71
72
  config: PlaycademyConfig
72
73
  routeMetadata: Array<RouteMetadata>
74
+ playcademyUser?: UserInfo
73
75
  [key: string]: unknown
74
76
  }
75
77