playcademy 0.16.4 → 0.16.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1299,7 +1299,7 @@ var ACHIEVEMENT_DEFINITIONS = [
1299
1299
  ];
1300
1300
 
1301
1301
  // ../constants/src/typescript.ts
1302
- var TSC_PACKAGE = "@typescript/native-preview";
1302
+ var TSC_PACKAGE = "typescript";
1303
1303
  var USE_NATIVE_TSC = TSC_PACKAGE.includes("native-preview");
1304
1304
 
1305
1305
  // ../constants/src/overworld.ts
@@ -1818,7 +1818,7 @@ var init_achievements = __esm2(() => {
1818
1818
  });
1819
1819
  var init_auth = () => {
1820
1820
  };
1821
- var TSC_PACKAGE2 = "@typescript/native-preview";
1821
+ var TSC_PACKAGE2 = "typescript";
1822
1822
  var USE_NATIVE_TSC2;
1823
1823
  var init_typescript = __esm2(() => {
1824
1824
  USE_NATIVE_TSC2 = TSC_PACKAGE2.includes("native-preview");
@@ -2965,7 +2965,7 @@ import { join as join13 } from "path";
2965
2965
  // package.json with { type: 'json' }
2966
2966
  var package_default2 = {
2967
2967
  name: "playcademy",
2968
- version: "0.16.3",
2968
+ version: "0.16.5",
2969
2969
  type: "module",
2970
2970
  exports: {
2971
2971
  ".": {
@@ -32,6 +32,7 @@ export const TIMEBACK_CALIPER_SENSORS = [
32
32
  */
33
33
  export const TIMEBACK_ROUTES = {
34
34
  END_ACTIVITY: '/integrations/timeback/end-activity',
35
+ GET_XP: '/integrations/timeback/xp',
35
36
  } as const
36
37
 
37
38
  /**
@@ -11,7 +11,10 @@
11
11
  * - '@typescript/native-preview' - Native TypeScript compiler (faster)
12
12
  * - 'typescript' - Standard TypeScript compiler
13
13
  */
14
- export const TSC_PACKAGE = '@typescript/native-preview'
14
+ // Temporarily using regular TypeScript because @typescript/native-preview daily
15
+ // dev builds can break unexpectedly (e.g., 7.0.0-e3a5e657689e43fb from Dec 18 hung).
16
+ // TODO: Switch back to native-preview once stable releases are available.
17
+ export const TSC_PACKAGE = 'typescript'
15
18
 
16
19
  /**
17
20
  * Whether using the native TypeScript compiler.
package/dist/constants.js CHANGED
@@ -387,7 +387,7 @@ var ACHIEVEMENT_DEFINITIONS = [
387
387
  ];
388
388
 
389
389
  // ../constants/src/typescript.ts
390
- var TSC_PACKAGE = "@typescript/native-preview";
390
+ var TSC_PACKAGE = "typescript";
391
391
  var USE_NATIVE_TSC = TSC_PACKAGE.includes("native-preview");
392
392
 
393
393
  // ../constants/src/domains.ts
package/dist/db.js CHANGED
@@ -311,7 +311,7 @@ var ACHIEVEMENT_DEFINITIONS = [
311
311
  ];
312
312
 
313
313
  // ../constants/src/typescript.ts
314
- var TSC_PACKAGE = "@typescript/native-preview";
314
+ var TSC_PACKAGE = "typescript";
315
315
  var USE_NATIVE_TSC = TSC_PACKAGE.includes("native-preview");
316
316
 
317
317
  // ../constants/src/overworld.ts
@@ -22,5 +22,6 @@ export const ROUTES = {
22
22
  /** TimeBack integration routes */
23
23
  TIMEBACK: {
24
24
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
25
+ GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
25
26
  },
26
27
  } as const
@@ -0,0 +1,45 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * Shared error handling utilities for edge-play routes.
4
+ */
5
+
6
+ import type { Context } from 'hono'
7
+ import type { HonoEnv } from '../types'
8
+
9
+ /**
10
+ * Check if running in development mode.
11
+ * Dev mode is detected by localhost or .dev. in the base URL.
12
+ */
13
+ export function isDev(c: Context<HonoEnv>): boolean {
14
+ const baseUrl = c.env.PLAYCADEMY_BASE_URL || ''
15
+ return baseUrl.includes('localhost') || baseUrl.includes('.dev.')
16
+ }
17
+
18
+ /**
19
+ * Build an error response object with optional stack trace (dev only).
20
+ */
21
+ export function buildErrorResponse(
22
+ c: Context<HonoEnv>,
23
+ error: unknown,
24
+ fallbackMessage: string,
25
+ ): { error: string; message: string; stack?: string } {
26
+ const message = error instanceof Error ? error.message : String(error)
27
+ const stack = error instanceof Error ? error.stack : undefined
28
+
29
+ return {
30
+ error: fallbackMessage,
31
+ message,
32
+ ...(isDev(c) && stack && { stack }),
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Log an error with a prefix tag for consistent logging.
38
+ */
39
+ export function logError(tag: string, error: unknown): void {
40
+ const message = error instanceof Error ? error.message : String(error)
41
+ const stack = error instanceof Error ? error.stack : undefined
42
+
43
+ if (message) console.error(`[${tag}] Error:`, message)
44
+ if (stack) console.error(`[${tag}] Stack:`, stack)
45
+ }
@@ -0,0 +1,2 @@
1
+ export * from './errors'
2
+ export * from './validation'
@@ -0,0 +1,189 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ isValidGrade,
5
+ isValidSubject,
6
+ validateCourseConfig,
7
+ VALID_GRADES,
8
+ VALID_SUBJECTS,
9
+ } from './validation'
10
+
11
+ import type { PlaycademyConfig } from '@playcademy/sdk/server'
12
+
13
+ describe('isValidGrade', () => {
14
+ it('accepts valid grade levels', () => {
15
+ for (const grade of VALID_GRADES) {
16
+ expect(isValidGrade(grade)).toBe(true)
17
+ }
18
+ })
19
+
20
+ it('accepts Pre-K (-1), Kindergarten (0), and AP (13)', () => {
21
+ expect(isValidGrade(-1)).toBe(true) // Pre-K
22
+ expect(isValidGrade(0)).toBe(true) // Kindergarten
23
+ expect(isValidGrade(13)).toBe(true) // AP
24
+ })
25
+
26
+ it('rejects invalid grade levels', () => {
27
+ expect(isValidGrade(-2)).toBe(false)
28
+ expect(isValidGrade(14)).toBe(false)
29
+ expect(isValidGrade(100)).toBe(false)
30
+ })
31
+
32
+ it('rejects non-integer values', () => {
33
+ expect(isValidGrade(3.5)).toBe(false)
34
+ expect(isValidGrade(NaN)).toBe(false)
35
+ expect(isValidGrade(Infinity)).toBe(false)
36
+ })
37
+
38
+ it('rejects non-number values', () => {
39
+ expect(isValidGrade('3')).toBe(false)
40
+ expect(isValidGrade(null)).toBe(false)
41
+ expect(isValidGrade(undefined)).toBe(false)
42
+ expect(isValidGrade({})).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('isValidSubject', () => {
47
+ it('accepts valid subjects', () => {
48
+ for (const subject of VALID_SUBJECTS) {
49
+ expect(isValidSubject(subject)).toBe(true)
50
+ }
51
+ })
52
+
53
+ it('accepts common subjects', () => {
54
+ expect(isValidSubject('Math')).toBe(true)
55
+ expect(isValidSubject('FastMath')).toBe(true)
56
+ expect(isValidSubject('Reading')).toBe(true)
57
+ expect(isValidSubject('Science')).toBe(true)
58
+ })
59
+
60
+ it('rejects invalid subjects', () => {
61
+ expect(isValidSubject('History')).toBe(false)
62
+ expect(isValidSubject('Art')).toBe(false)
63
+ expect(isValidSubject('math')).toBe(false) // case-sensitive
64
+ expect(isValidSubject('MATH')).toBe(false)
65
+ })
66
+
67
+ it('rejects non-string values', () => {
68
+ expect(isValidSubject(123)).toBe(false)
69
+ expect(isValidSubject(null)).toBe(false)
70
+ expect(isValidSubject(undefined)).toBe(false)
71
+ expect(isValidSubject({})).toBe(false)
72
+ })
73
+ })
74
+
75
+ describe('validateCourseConfig', () => {
76
+ /**
77
+ * Simulates a typical playcademy.config.js for a math game
78
+ * covering grades 3-5.
79
+ */
80
+ const mathGameConfig: PlaycademyConfig = {
81
+ name: 'Math Adventure',
82
+ integrations: {
83
+ timeback: {
84
+ courses: [
85
+ { subject: 'Math', grade: 3, totalXp: 1000, masterableUnits: 10 },
86
+ { subject: 'Math', grade: 4, totalXp: 1200, masterableUnits: 12 },
87
+ { subject: 'Math', grade: 5, totalXp: 1500, masterableUnits: 15 },
88
+ ],
89
+ },
90
+ },
91
+ }
92
+
93
+ /**
94
+ * Simulates a multi-subject game config.
95
+ */
96
+ const multiSubjectConfig: PlaycademyConfig = {
97
+ name: 'Learning Hub',
98
+ integrations: {
99
+ timeback: {
100
+ courses: [
101
+ { subject: 'Math', grade: 3, totalXp: 500, masterableUnits: 5 },
102
+ { subject: 'Reading', grade: 3, totalXp: 500, masterableUnits: 5 },
103
+ { subject: 'Science', grade: 4, totalXp: 600, masterableUnits: 6 },
104
+ ],
105
+ },
106
+ },
107
+ }
108
+
109
+ it('returns null for configured grade/subject combinations', () => {
110
+ expect(validateCourseConfig({ grade: 3, subject: 'Math', config: mathGameConfig })).toBeNull()
111
+ expect(validateCourseConfig({ grade: 4, subject: 'Math', config: mathGameConfig })).toBeNull()
112
+ expect(validateCourseConfig({ grade: 5, subject: 'Math', config: mathGameConfig })).toBeNull()
113
+ })
114
+
115
+ it('returns error for unconfigured grade', () => {
116
+ const result = validateCourseConfig({ grade: 6, subject: 'Math', config: mathGameConfig })
117
+
118
+ expect(result).not.toBeNull()
119
+ expect(result?.error).toContain('Invalid grade/subject combination')
120
+ expect(result?.error).toContain('Math (Grade 6)')
121
+ expect(result?.error).toContain('Configured courses:')
122
+ })
123
+
124
+ it('returns error for unconfigured subject', () => {
125
+ const result = validateCourseConfig({ grade: 3, subject: 'Reading', config: mathGameConfig })
126
+
127
+ expect(result).not.toBeNull()
128
+ expect(result?.error).toContain('Invalid grade/subject combination')
129
+ expect(result?.error).toContain('Reading (Grade 3)')
130
+ })
131
+
132
+ it('lists all configured courses in error message', () => {
133
+ const result = validateCourseConfig({ grade: 1, subject: 'Math', config: mathGameConfig })
134
+
135
+ expect(result?.error).toContain('Math (Grade 3)')
136
+ expect(result?.error).toContain('Math (Grade 4)')
137
+ expect(result?.error).toContain('Math (Grade 5)')
138
+ })
139
+
140
+ it('validates multi-subject configs correctly', () => {
141
+ // Configured combinations
142
+ expect(
143
+ validateCourseConfig({ grade: 3, subject: 'Math', config: multiSubjectConfig }),
144
+ ).toBeNull()
145
+ expect(
146
+ validateCourseConfig({ grade: 3, subject: 'Reading', config: multiSubjectConfig }),
147
+ ).toBeNull()
148
+ expect(
149
+ validateCourseConfig({ grade: 4, subject: 'Science', config: multiSubjectConfig }),
150
+ ).toBeNull()
151
+
152
+ // Not configured: Math grade 4 (only Science is grade 4)
153
+ const result = validateCourseConfig({
154
+ grade: 4,
155
+ subject: 'Math',
156
+ config: multiSubjectConfig,
157
+ })
158
+ expect(result).not.toBeNull()
159
+ expect(result?.error).toContain('Math (Grade 4)')
160
+ })
161
+
162
+ it('handles config with no courses', () => {
163
+ const emptyConfig: PlaycademyConfig = {
164
+ name: 'Empty Game',
165
+ integrations: {
166
+ timeback: {
167
+ courses: [],
168
+ },
169
+ },
170
+ }
171
+
172
+ const result = validateCourseConfig({ grade: 3, subject: 'Math', config: emptyConfig })
173
+
174
+ expect(result).not.toBeNull()
175
+ expect(result?.error).toContain('Configured courses: none')
176
+ })
177
+
178
+ it('handles config with no timeback integration', () => {
179
+ const noTimebackConfig: PlaycademyConfig = {
180
+ name: 'No Timeback',
181
+ integrations: {},
182
+ }
183
+
184
+ const result = validateCourseConfig({ grade: 3, subject: 'Math', config: noTimebackConfig })
185
+
186
+ expect(result).not.toBeNull()
187
+ expect(result?.error).toContain('Configured courses: none')
188
+ })
189
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared validation utilities for edge-play routes.
3
+ */
4
+
5
+ import type { PlaycademyConfig } from '@playcademy/sdk/server'
6
+
7
+ /** Valid grade levels: -1 (Pre-K), 0 (Kindergarten), 1-12 (Grades), 13 (AP) */
8
+ export const VALID_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] as const
9
+
10
+ /** Valid TimeBack subject values */
11
+ export const VALID_SUBJECTS = [
12
+ 'Reading',
13
+ 'Language',
14
+ 'Vocabulary',
15
+ 'Social Studies',
16
+ 'Writing',
17
+ 'Science',
18
+ 'FastMath',
19
+ 'Math',
20
+ 'None',
21
+ ] as const
22
+
23
+ export type ValidGrade = (typeof VALID_GRADES)[number]
24
+ export type ValidSubject = (typeof VALID_SUBJECTS)[number]
25
+
26
+ export function isValidGrade(value: unknown): value is ValidGrade {
27
+ return (
28
+ typeof value === 'number' &&
29
+ Number.isInteger(value) &&
30
+ VALID_GRADES.includes(value as ValidGrade)
31
+ )
32
+ }
33
+
34
+ export function isValidSubject(value: unknown): value is ValidSubject {
35
+ return typeof value === 'string' && VALID_SUBJECTS.includes(value as ValidSubject)
36
+ }
37
+
38
+ /**
39
+ * Validate that a grade/subject combination is configured for this game.
40
+ * Returns an error object if invalid, null if valid.
41
+ */
42
+ export function validateCourseConfig(params: {
43
+ grade: number
44
+ subject: string
45
+ config: PlaycademyConfig
46
+ }): { error: string } | null {
47
+ const { grade, subject, config } = params
48
+ const timebackConfig = config.integrations?.timeback
49
+ const configuredCourse = timebackConfig?.courses?.find(
50
+ course => course.grade === grade && course.subject === subject,
51
+ )
52
+ if (!configuredCourse) {
53
+ const configured = timebackConfig?.courses
54
+ ?.map(c => `${c.subject} (Grade ${c.grade})`)
55
+ .join(', ')
56
+ return {
57
+ error: `Invalid grade/subject combination: ${subject} (Grade ${grade}). Configured courses: ${configured || 'none'}`,
58
+ }
59
+ }
60
+ return null
61
+ }
@@ -27,12 +27,12 @@ export async function registerBuiltinRoutes(app: Hono<HonoEnv>, integrations?: I
27
27
 
28
28
  // TimeBack integration
29
29
  if (integrations?.timeback) {
30
- const [endActivity] = await Promise.all([
30
+ const [endActivity, getXp] = await Promise.all([
31
31
  import('./routes/integrations/timeback/end-activity'),
32
- // ... other routes
32
+ import('./routes/integrations/timeback/get-xp'),
33
33
  ])
34
34
  app.post(ROUTES.TIMEBACK.END_ACTIVITY, endActivity.POST)
35
- // ... other routes
35
+ app.get(ROUTES.TIMEBACK.GET_XP, getXp.GET)
36
36
  } else if (integrations?.timeback === null) {
37
37
  app.post('/api/integrations/timeback/end-activity', async c => {
38
38
  return c.json({
@@ -40,6 +40,13 @@ export async function registerBuiltinRoutes(app: Hono<HonoEnv>, integrations?: I
40
40
  __playcademyDevWarning: 'timeback-not-configured',
41
41
  })
42
42
  })
43
+ app.get('/api/integrations/timeback/xp', async c => {
44
+ return c.json({
45
+ timebackId: '',
46
+ totalXp: 0,
47
+ __playcademyDevWarning: 'timeback-not-configured',
48
+ })
49
+ })
43
50
  }
44
51
 
45
52
  // TODO: Auth integration
@@ -1,3 +1,13 @@
1
+ import {
2
+ buildErrorResponse,
3
+ isValidGrade,
4
+ isValidSubject,
5
+ logError,
6
+ VALID_GRADES,
7
+ VALID_SUBJECTS,
8
+ validateCourseConfig,
9
+ } from '../../../lib'
10
+
1
11
  import type { Context } from 'hono'
2
12
  import type { PlaycademyConfig } from '@playcademy/sdk/server'
3
13
  import type { ActivityData } from '@playcademy/types/timeback'
@@ -17,37 +27,6 @@ import type { HonoEnv } from '../../../types'
17
27
  * their own backend infrastructure. The SDK handles config enrichment and metadata.
18
28
  */
19
29
 
20
- /** Valid grade levels: -1 (Pre-K), 0 (Kindergarten), 1-12 (Grades), 13 (AP) */
21
- const VALID_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] as const
22
-
23
- /** Valid TimeBack subject values */
24
- const VALID_SUBJECTS = [
25
- 'Reading',
26
- 'Language',
27
- 'Vocabulary',
28
- 'Social Studies',
29
- 'Writing',
30
- 'Science',
31
- 'FastMath',
32
- 'Math',
33
- 'None',
34
- ] as const
35
-
36
- function isValidGrade(value: unknown): value is (typeof VALID_GRADES)[number] {
37
- return (
38
- typeof value === 'number' &&
39
- Number.isInteger(value) &&
40
- VALID_GRADES.includes(value as (typeof VALID_GRADES)[number])
41
- )
42
- }
43
-
44
- function isValidSubject(value: unknown): value is (typeof VALID_SUBJECTS)[number] {
45
- return (
46
- typeof value === 'string' &&
47
- VALID_SUBJECTS.includes(value as (typeof VALID_SUBJECTS)[number])
48
- )
49
- }
50
-
51
30
  function getConfig(c: Context<HonoEnv>): PlaycademyConfig {
52
31
  const config = c.get('config')
53
32
  const timebackConfig = config?.integrations?.timeback
@@ -88,27 +67,6 @@ function validateRequestBody(body: {
88
67
  return null
89
68
  }
90
69
 
91
- function validateCourse(params: {
92
- grade: number
93
- subject: string
94
- config: PlaycademyConfig
95
- }): { error: string } | null {
96
- const { grade, subject, config } = params
97
- const timebackConfig = config.integrations?.timeback
98
- const configuredCourse = timebackConfig?.courses?.find(
99
- course => course.grade === grade && course.subject === subject,
100
- )
101
- if (!configuredCourse) {
102
- const configured = timebackConfig?.courses
103
- ?.map(c => `${c.subject} (Grade ${c.grade})`)
104
- .join(', ')
105
- return {
106
- error: `Invalid grade/subject combination: ${subject} (Grade ${grade}). Configured courses: ${configured || 'none'}`,
107
- }
108
- }
109
- return null
110
- }
111
-
112
70
  function enrichActivityData(params: {
113
71
  activityData: ActivityData
114
72
  config: PlaycademyConfig
@@ -163,7 +121,7 @@ export async function POST(c: Context<HonoEnv>): Promise<Response> {
163
121
 
164
122
  // 6. Validate grade/subject against configured courses
165
123
  const { grade, subject } = activityData
166
- const courseValidationError = validateCourse({ grade, subject, config })
124
+ const courseValidationError = validateCourseConfig({ grade, subject, config })
167
125
 
168
126
  if (courseValidationError) {
169
127
  const message = courseValidationError.error
@@ -193,10 +151,7 @@ export async function POST(c: Context<HonoEnv>): Promise<Response> {
193
151
 
194
152
  return c.json(result)
195
153
  } catch (error) {
196
- const message = error instanceof Error ? error.message : String(error)
197
- const stack = error instanceof Error ? error.stack : undefined
198
- if (message) console.error('[TimeBack End Activity] Error:', message)
199
- if (stack) console.error('[TimeBack End Activity] Stack:', stack)
200
- return c.json({ error: 'Failed to end activity', message, stack }, 500)
154
+ logError('TimeBack End Activity', error)
155
+ return c.json(buildErrorResponse(c, error, 'Failed to end activity'), 500)
201
156
  }
202
157
  }
@@ -0,0 +1,147 @@
1
+ import {
2
+ buildErrorResponse,
3
+ isValidGrade,
4
+ isValidSubject,
5
+ logError,
6
+ VALID_GRADES,
7
+ VALID_SUBJECTS,
8
+ validateCourseConfig,
9
+ } from '../../../lib'
10
+
11
+ import type { Context } from 'hono'
12
+ import type { PlaycademyConfig } from '@playcademy/sdk/server'
13
+ import type { HonoEnv } from '../../../types'
14
+
15
+ /**
16
+ * TimeBack integration - Get student XP
17
+ * Route: GET /api/integrations/timeback/xp
18
+ * Auto-generated when integrations.timeback is configured
19
+ *
20
+ * Flow:
21
+ * 1. Game frontend calls this route on deployed backend ({slug}.playcademy.gg/api/integrations/timeback/xp)
22
+ * 2. This route calls Playcademy platform API via SDK (hub.playcademy.net/api/timeback/student-xp/:timebackId)
23
+ * 3. Platform API fetches XP data from TimeBack EduBridge analytics API
24
+ *
25
+ * By default, returns XP for all courses configured for this game/app.
26
+ * Use `grade` and `subject` to query a specific course instead.
27
+ *
28
+ * Query Parameters:
29
+ *
30
+ * @param {number} [grade] - Grade level to filter (-1 to 13).
31
+ * Must be used together with `subject`.
32
+ * Example: `?grade=3&subject=Math`
33
+ *
34
+ * @param {string} [subject] - Subject to filter.
35
+ * Must be used together with `grade`.
36
+ * Valid values: Reading, Language, Vocabulary, Social Studies, Writing, Science, FastMath, Math, None
37
+ * Example: `?grade=3&subject=Math`
38
+ *
39
+ * @param {string} [include] - Comma-separated list of additional data to include.
40
+ * Available options:
41
+ * - `perCourse` - Include per-course XP breakdown with grade, subject, and xp
42
+ * - `today` - Include today's XP (both total and per-course if perCourse is set)
43
+ * Example: `?include=perCourse,today`
44
+ *
45
+ * Response:
46
+ * ```json
47
+ * {
48
+ * "totalXp": 1500,
49
+ * "todayXp": 50, // Only if include contains 'today'
50
+ * "courses": [ // Only if include contains 'perCourse'
51
+ * {
52
+ * "grade": 3,
53
+ * "subject": "Math",
54
+ * "title": "Math Grade 3",
55
+ * "totalXp": 1000,
56
+ * "todayXp": 30 // Only if include contains 'today'
57
+ * }
58
+ * ]
59
+ * }
60
+ * ```
61
+ */
62
+
63
+ /**
64
+ * Get config and validate that the game has TimeBack configured.
65
+ */
66
+ function getConfig(c: Context<HonoEnv>): PlaycademyConfig {
67
+ const config = c.get('config')
68
+ const timebackConfig = config?.integrations?.timeback
69
+ if (!timebackConfig) throw new Error('TimeBack integration not found')
70
+ return config
71
+ }
72
+
73
+ export async function GET(c: Context<HonoEnv>): Promise<Response> {
74
+ try {
75
+ // 1. Get authenticated user from middleware
76
+ const user = c.get('playcademyUser')
77
+ if (!user) return c.json({ error: 'Unauthorized' }, 401)
78
+
79
+ // 2. Ensure user has TimeBack integration
80
+ if (!user.timeback_id) {
81
+ const message = 'User does not have TimeBack integration'
82
+ console.error('[TimeBack Get XP] Error:', message)
83
+ return c.json({ error: message }, 400)
84
+ }
85
+
86
+ // 3. Validate TimeBack is configured for this game
87
+ const config = getConfig(c)
88
+
89
+ // 4. Parse query parameters
90
+ const url = new URL(c.req.url)
91
+ const gradeParam = url.searchParams.get('grade')
92
+ const subjectParam = url.searchParams.get('subject')
93
+ const includeParam = url.searchParams.get('include')
94
+ const include = includeParam
95
+ ? (includeParam.split(',').map(s => s.trim()) as ('perCourse' | 'today')[])
96
+ : undefined
97
+
98
+ let grade: number | undefined
99
+ let subject: string | undefined
100
+
101
+ if (gradeParam !== null || subjectParam !== null) {
102
+ if (gradeParam === null || subjectParam === null) {
103
+ return c.json({ error: 'Both grade and subject must be provided together' }, 400)
104
+ }
105
+
106
+ grade = parseInt(gradeParam, 10)
107
+ subject = subjectParam
108
+
109
+ if (!isValidGrade(grade)) {
110
+ return c.json(
111
+ { error: `grade must be a valid grade level (${VALID_GRADES.join(', ')})` },
112
+ 400,
113
+ )
114
+ }
115
+
116
+ if (!isValidSubject(subject)) {
117
+ return c.json(
118
+ { error: `subject must be a valid subject (${VALID_SUBJECTS.join(', ')})` },
119
+ 400,
120
+ )
121
+ }
122
+
123
+ // 5. Validate grade/subject against configured courses
124
+ const courseValidationError = validateCourseConfig({ grade, subject, config })
125
+
126
+ if (courseValidationError) {
127
+ return c.json({ error: courseValidationError.error }, 400)
128
+ }
129
+ }
130
+
131
+ // 6. Get SDK client from context
132
+ const sdk = c.get('sdk')
133
+
134
+ // 7. Fetch student XP from platform API
135
+ // The SDK automatically passes gameId, so XP is filtered to this game's courses
136
+ const result = await sdk.timeback.getStudentXp(user.timeback_id, {
137
+ grade,
138
+ subject,
139
+ include,
140
+ })
141
+
142
+ return c.json(result)
143
+ } catch (error) {
144
+ logError('TimeBack Get XP', error)
145
+ return c.json(buildErrorResponse(c, error, 'Failed to get XP'), 500)
146
+ }
147
+ }
package/dist/index.d.ts CHANGED
@@ -731,7 +731,7 @@ declare const games: drizzle_orm_pg_core.PgTableWithColumns<{
731
731
  tableName: "games";
732
732
  dataType: "string";
733
733
  columnType: "PgEnumColumn";
734
- data: "external" | "hosted";
734
+ data: "hosted" | "external";
735
735
  driverParam: string;
736
736
  notNull: true;
737
737
  hasDefault: true;
@@ -782,7 +782,7 @@ declare const games: drizzle_orm_pg_core.PgTableWithColumns<{
782
782
  tableName: "games";
783
783
  dataType: "string";
784
784
  columnType: "PgEnumColumn";
785
- data: "godot" | "unity" | "web";
785
+ data: "web" | "godot" | "unity";
786
786
  driverParam: string;
787
787
  notNull: true;
788
788
  hasDefault: true;
package/dist/index.js CHANGED
@@ -700,7 +700,7 @@ var ACHIEVEMENT_DEFINITIONS = [
700
700
  ];
701
701
 
702
702
  // ../constants/src/typescript.ts
703
- var TSC_PACKAGE = "@typescript/native-preview";
703
+ var TSC_PACKAGE = "typescript";
704
704
  var USE_NATIVE_TSC = TSC_PACKAGE.includes("native-preview");
705
705
 
706
706
  // ../constants/src/domains.ts
@@ -747,7 +747,8 @@ var BADGES = {
747
747
 
748
748
  // ../constants/src/timeback.ts
749
749
  var TIMEBACK_ROUTES = {
750
- END_ACTIVITY: "/integrations/timeback/end-activity"
750
+ END_ACTIVITY: "/integrations/timeback/end-activity",
751
+ GET_XP: "/integrations/timeback/xp"
751
752
  };
752
753
 
753
754
  // ../constants/src/workers.ts
@@ -2397,7 +2398,7 @@ var init_achievements = __esm2(() => {
2397
2398
  });
2398
2399
  var init_auth = () => {
2399
2400
  };
2400
- var TSC_PACKAGE2 = "@typescript/native-preview";
2401
+ var TSC_PACKAGE2 = "typescript";
2401
2402
  var USE_NATIVE_TSC2;
2402
2403
  var init_typescript = __esm2(() => {
2403
2404
  USE_NATIVE_TSC2 = TSC_PACKAGE2.includes("native-preview");
@@ -2801,7 +2802,7 @@ var init_achievements2 = __esm3(() => {
2801
2802
  });
2802
2803
  var init_auth2 = () => {
2803
2804
  };
2804
- var TSC_PACKAGE3 = "@typescript/native-preview";
2805
+ var TSC_PACKAGE3 = "typescript";
2805
2806
  var USE_NATIVE_TSC3;
2806
2807
  var init_typescript2 = __esm3(() => {
2807
2808
  USE_NATIVE_TSC3 = TSC_PACKAGE3.includes("native-preview");
@@ -3995,7 +3996,7 @@ import { join as join12 } from "path";
3995
3996
  // package.json with { type: 'json' }
3996
3997
  var package_default2 = {
3997
3998
  name: "playcademy",
3998
- version: "0.16.3",
3999
+ version: "0.16.5",
3999
4000
  type: "module",
4000
4001
  exports: {
4001
4002
  ".": {
@@ -6332,7 +6333,8 @@ var ROUTES = {
6332
6333
  HEALTH: "/api/health",
6333
6334
  /** TimeBack integration routes */
6334
6335
  TIMEBACK: {
6335
- END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`
6336
+ END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
6337
+ GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`
6336
6338
  }
6337
6339
  };
6338
6340
 
@@ -8421,9 +8423,11 @@ async function analyzeChanges(context2) {
8421
8423
  deployedGameInfo
8422
8424
  } = context2;
8423
8425
  let secretsDiff = void 0;
8424
- if (localSecrets && Object.keys(localSecrets).length > 0) {
8426
+ const hasLocalSecrets = Object.keys(localSecrets ?? {}).length > 0;
8427
+ const hasRemoteSecrets = remoteSecretKeys && remoteSecretKeys.length > 0;
8428
+ if (hasLocalSecrets || hasRemoteSecrets) {
8425
8429
  secretsDiff = computeSecretsDiff({
8426
- localSecrets,
8430
+ localSecrets: localSecrets ?? {},
8427
8431
  remoteKeys: remoteSecretKeys ?? [],
8428
8432
  cachedHashes: deployedGameInfo?.secretsHashes
8429
8433
  });
@@ -8514,8 +8518,8 @@ async function analyzeChanges(context2) {
8514
8518
  async function calculateDeploymentPlan(context2, changes) {
8515
8519
  const { config, isGameDeployed: isGameDeployed2, projectPath, deployBackend, forceBackend } = context2;
8516
8520
  if (!isGameDeployed2) {
8517
- const needsBackend2 = hasLocalCustomRoutes(projectPath, context2.fullConfig) || !!context2.fullConfig?.integrations;
8518
- const shouldDeployBackend2 = deployBackend && needsBackend2;
8521
+ const needsBackend3 = hasLocalCustomRoutes(projectPath, context2.fullConfig) || !!context2.fullConfig?.integrations;
8522
+ const shouldDeployBackend2 = deployBackend && needsBackend3;
8519
8523
  return {
8520
8524
  action: "deploy-new",
8521
8525
  gameType: config.gameType ?? "hosted",
@@ -8541,7 +8545,8 @@ async function calculateDeploymentPlan(context2, changes) {
8541
8545
  }
8542
8546
  const shouldUpdateGame = buildHasChanges || configHasChanges;
8543
8547
  const shouldUploadBuild = buildHasChanges;
8544
- const shouldDeployBackend = deployBackend && (backendHasChanges || forceBackend);
8548
+ const needsBackend2 = hasLocalCustomRoutes(projectPath, context2.fullConfig) || !!context2.fullConfig?.integrations;
8549
+ const shouldDeployBackend = deployBackend && (backendHasChanges || forceBackend || shouldUploadBuild && needsBackend2);
8545
8550
  return {
8546
8551
  action: "update-existing",
8547
8552
  gameType: config.gameType ?? "hosted",
@@ -10675,8 +10680,11 @@ async function executeDeployment(plan, changes, context2, environment) {
10675
10680
  return { game, backendMetadata };
10676
10681
  }
10677
10682
  async function runDeployment(options) {
10678
- const environment = ensureEnvironment(options.env);
10679
- await selectEnvironment({ env: environment, dryRun: options.dryRun });
10683
+ if (options.env) {
10684
+ ensureEnvironment(options.env);
10685
+ }
10686
+ await selectEnvironment({ env: options.env, dryRun: options.dryRun });
10687
+ const environment = ensureEnvironment(void 0);
10680
10688
  const context2 = await prepareDeploymentContext(options);
10681
10689
  displayCurrentConfiguration(context2);
10682
10690
  const didPrompt = await promptForMissingConfig(context2);
@@ -10991,7 +10999,7 @@ var getStatusCommand = new Command13("status").description("Check your developer
10991
10999
  });
10992
11000
 
10993
11001
  // package.json
10994
- var version2 = "0.16.3";
11002
+ var version2 = "0.16.5";
10995
11003
 
10996
11004
  // src/commands/dev/server.ts
10997
11005
  function setupCleanupHandlers(workspace, getServer) {
@@ -14503,18 +14511,18 @@ async function runSecretsPush(options = {}) {
14503
14511
  }
14504
14512
  const localSecrets = await readEnvFile(workspace);
14505
14513
  const localKeys = Object.keys(localSecrets);
14506
- if (localKeys.length === 0) {
14507
- logger.admonition("info", "No Secrets", [
14508
- `Your <.env> file is empty or contains no valid secrets`
14509
- ]);
14510
- logger.newLine();
14511
- return false;
14512
- }
14513
14514
  const remoteKeys = await runStep(
14514
14515
  `Fetching secrets from ${environment}`,
14515
14516
  () => client.dev.games.secrets.list(game.slug),
14516
14517
  (keys) => `Found ${keys.length} existing ${pluralize(keys.length, "secret")} on ${environment}`
14517
14518
  );
14519
+ const noSyncNecessary = localKeys.length === 0 && remoteKeys.length === 0;
14520
+ if (noSyncNecessary) {
14521
+ logger.newLine();
14522
+ logger.success("No secrets to sync");
14523
+ logger.newLine();
14524
+ return true;
14525
+ }
14518
14526
  const cachedHashes = deployedGame.secretsHashes;
14519
14527
  const diff = computeSecretsDiff({
14520
14528
  localSecrets,
package/dist/utils.js CHANGED
@@ -616,7 +616,7 @@ var ACHIEVEMENT_DEFINITIONS = [
616
616
  ];
617
617
 
618
618
  // ../constants/src/typescript.ts
619
- var TSC_PACKAGE = "@typescript/native-preview";
619
+ var TSC_PACKAGE = "typescript";
620
620
  var USE_NATIVE_TSC = TSC_PACKAGE.includes("native-preview");
621
621
 
622
622
  // ../constants/src/overworld.ts
@@ -652,7 +652,8 @@ var BADGES = {
652
652
 
653
653
  // ../constants/src/timeback.ts
654
654
  var TIMEBACK_ROUTES = {
655
- END_ACTIVITY: "/integrations/timeback/end-activity"
655
+ END_ACTIVITY: "/integrations/timeback/end-activity",
656
+ GET_XP: "/integrations/timeback/xp"
656
657
  };
657
658
 
658
659
  // ../constants/src/workers.ts
@@ -1327,7 +1328,8 @@ var ROUTES = {
1327
1328
  HEALTH: "/api/health",
1328
1329
  /** TimeBack integration routes */
1329
1330
  TIMEBACK: {
1330
- END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`
1331
+ END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
1332
+ GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`
1331
1333
  }
1332
1334
  };
1333
1335
 
@@ -2356,7 +2358,7 @@ import { join as join12 } from "path";
2356
2358
  // package.json with { type: 'json' }
2357
2359
  var package_default2 = {
2358
2360
  name: "playcademy",
2359
- version: "0.16.3",
2361
+ version: "0.16.5",
2360
2362
  type: "module",
2361
2363
  exports: {
2362
2364
  ".": {
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // package.json with { type: 'json' }
2
2
  var package_default = {
3
3
  name: "playcademy",
4
- version: "0.16.3",
4
+ version: "0.16.5",
5
5
  type: "module",
6
6
  exports: {
7
7
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playcademy",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@inquirer/prompts": "^7.8.6",
53
- "@playcademy/sdk": "0.2.11",
53
+ "@playcademy/sdk": "0.2.12",
54
54
  "chokidar": "^4.0.3",
55
55
  "colorette": "^2.0.20",
56
56
  "commander": "^14.0.1",