playcademy 0.18.0 → 0.18.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/dist/cli.js +1 -1
- package/dist/index.d.ts +24 -12
- package/dist/index.js +465 -255
- package/dist/utils.js +558 -351
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/constants/src/achievements.ts +0 -107
- package/dist/constants/src/auth.ts +0 -13
- package/dist/constants/src/character.ts +0 -16
- package/dist/constants/src/domains.ts +0 -50
- package/dist/constants/src/env-vars.ts +0 -20
- package/dist/constants/src/index.ts +0 -18
- package/dist/constants/src/overworld.ts +0 -330
- package/dist/constants/src/system.ts +0 -10
- package/dist/constants/src/timeback.ts +0 -118
- package/dist/constants/src/typescript.ts +0 -21
- package/dist/constants/src/workers.ts +0 -36
- package/dist/edge-play/src/constants.ts +0 -27
- package/dist/edge-play/src/entry/middleware.ts +0 -247
- package/dist/edge-play/src/entry/queue.test.ts +0 -279
- package/dist/edge-play/src/entry/queue.ts +0 -107
- package/dist/edge-play/src/entry/session.ts +0 -45
- package/dist/edge-play/src/entry/setup.ts +0 -78
- package/dist/edge-play/src/entry/types.ts +0 -30
- package/dist/edge-play/src/entry.ts +0 -94
- package/dist/edge-play/src/html.d.ts +0 -5
- package/dist/edge-play/src/index.ts +0 -4
- package/dist/edge-play/src/lib/errors.ts +0 -51
- package/dist/edge-play/src/lib/index.ts +0 -3
- package/dist/edge-play/src/lib/self-dispatch.test.ts +0 -244
- package/dist/edge-play/src/lib/self-dispatch.ts +0 -41
- package/dist/edge-play/src/lib/validation.test.ts +0 -190
- package/dist/edge-play/src/lib/validation.ts +0 -64
- package/dist/edge-play/src/polyfills.js +0 -54
- package/dist/edge-play/src/register-routes.ts +0 -59
- package/dist/edge-play/src/routes/health.ts +0 -104
- package/dist/edge-play/src/routes/index.ts +0 -66
- package/dist/edge-play/src/routes/integrations/timeback/end-activity.ts +0 -181
- package/dist/edge-play/src/routes/integrations/timeback/get-xp.ts +0 -159
- package/dist/edge-play/src/routes/root.html +0 -253
- package/dist/edge-play/src/routes/root.ts +0 -22
- package/dist/edge-play/src/stub-entry.ts +0 -161
- package/dist/edge-play/src/types.ts +0 -124
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
normalizeSelfFetchInput,
|
|
5
|
-
resolveRawSelfWorker,
|
|
6
|
-
wrapSelfWorkerWithPathResolution,
|
|
7
|
-
} from './self-dispatch'
|
|
8
|
-
|
|
9
|
-
import type { HonoEnv, SelfWorker } from '../types'
|
|
10
|
-
|
|
11
|
-
describe('normalizeSelfFetchInput', () => {
|
|
12
|
-
const requestUrl = 'https://vocabulon-staging.playcademy.gg/api/admin/genai/sprint'
|
|
13
|
-
|
|
14
|
-
it('resolves root-relative paths to the current origin', () => {
|
|
15
|
-
const input = normalizeSelfFetchInput('/api/internal/process-batch', requestUrl)
|
|
16
|
-
|
|
17
|
-
expect(input).toBeInstanceOf(URL)
|
|
18
|
-
expect((input as URL).toString()).toBe(
|
|
19
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
|
|
20
|
-
)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('treats bare paths as root-relative', () => {
|
|
24
|
-
const input = normalizeSelfFetchInput('api/internal/process-batch', requestUrl)
|
|
25
|
-
|
|
26
|
-
expect(input).toBeInstanceOf(URL)
|
|
27
|
-
expect((input as URL).toString()).toBe(
|
|
28
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
|
|
29
|
-
)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('preserves absolute URL strings', () => {
|
|
33
|
-
const input = normalizeSelfFetchInput(
|
|
34
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
|
|
35
|
-
requestUrl,
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
expect(input).toBeInstanceOf(URL)
|
|
39
|
-
expect((input as URL).toString()).toBe(
|
|
40
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
|
|
41
|
-
)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('preserves query and hash on normalized paths', () => {
|
|
45
|
-
const input = normalizeSelfFetchInput('/api/internal/process-batch?attempt=2#step', requestUrl)
|
|
46
|
-
|
|
47
|
-
expect(input).toBeInstanceOf(URL)
|
|
48
|
-
expect((input as URL).toString()).toBe(
|
|
49
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process-batch?attempt=2#step',
|
|
50
|
-
)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('passes through URL objects unchanged', () => {
|
|
54
|
-
const url = new URL('https://vocabulon-staging.playcademy.gg/api/internal/process-batch')
|
|
55
|
-
const input = normalizeSelfFetchInput(url, requestUrl)
|
|
56
|
-
|
|
57
|
-
expect(input).toBe(url)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('supports non-http absolute schemes', () => {
|
|
61
|
-
const input = normalizeSelfFetchInput('mailto:test@example.com', requestUrl)
|
|
62
|
-
|
|
63
|
-
expect(input).toBeInstanceOf(URL)
|
|
64
|
-
expect((input as URL).toString()).toBe('mailto:test@example.com')
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('does not allow protocol-relative input to escape origin', () => {
|
|
68
|
-
const input = normalizeSelfFetchInput('//evil.example.com/path', requestUrl)
|
|
69
|
-
|
|
70
|
-
expect(input).toBeInstanceOf(URL)
|
|
71
|
-
expect((input as URL).toString()).toBe('https://vocabulon-staging.playcademy.gg/evil.example.com/path')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('passes through Request objects unchanged', () => {
|
|
75
|
-
const request = new Request('https://vocabulon-staging.playcademy.gg/api/internal/process-batch')
|
|
76
|
-
const input = normalizeSelfFetchInput(request, requestUrl)
|
|
77
|
-
|
|
78
|
-
expect(input).toBe(request)
|
|
79
|
-
})
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
describe('wrapSelfWorkerWithPathResolution', () => {
|
|
83
|
-
const requestUrl = 'https://vocabulon-staging.playcademy.gg/api/admin/genai/sprint'
|
|
84
|
-
|
|
85
|
-
it('normalizes string path inputs before delegating to raw self worker', async () => {
|
|
86
|
-
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
|
|
87
|
-
const raw: SelfWorker = {
|
|
88
|
-
fetch: (input, init) => {
|
|
89
|
-
calls.push({ input, init })
|
|
90
|
-
|
|
91
|
-
return Promise.resolve(new Response(null, { status: 202 }))
|
|
92
|
-
},
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const self = wrapSelfWorkerWithPathResolution(raw, requestUrl)
|
|
96
|
-
const init = { method: 'POST' }
|
|
97
|
-
const response = await self.fetch('api/internal/process-batch', init)
|
|
98
|
-
|
|
99
|
-
expect(response.status).toBe(202)
|
|
100
|
-
expect(calls).toHaveLength(1)
|
|
101
|
-
expect(calls[0]?.input).toBeInstanceOf(URL)
|
|
102
|
-
expect((calls[0]!.input as URL).toString()).toBe(
|
|
103
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
|
|
104
|
-
)
|
|
105
|
-
expect(calls[0]?.init).toEqual(init)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('passes Request objects through to the raw self worker', async () => {
|
|
109
|
-
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
|
|
110
|
-
const raw: SelfWorker = {
|
|
111
|
-
fetch: (input, init) => {
|
|
112
|
-
calls.push({ input, init })
|
|
113
|
-
|
|
114
|
-
return Promise.resolve(new Response(null, { status: 204 }))
|
|
115
|
-
},
|
|
116
|
-
}
|
|
117
|
-
const self = wrapSelfWorkerWithPathResolution(raw, requestUrl)
|
|
118
|
-
const req = new Request('https://vocabulon-staging.playcademy.gg/api/internal/process-batch')
|
|
119
|
-
|
|
120
|
-
await self.fetch(req)
|
|
121
|
-
|
|
122
|
-
expect(calls).toHaveLength(1)
|
|
123
|
-
expect(calls[0]?.input).toBe(req)
|
|
124
|
-
expect(calls[0]?.init).toBeUndefined()
|
|
125
|
-
})
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
describe('concurrent request isolation (spread-then-mutate)', () => {
|
|
129
|
-
it('two spread copies resolve SELF paths using their own request URL', async () => {
|
|
130
|
-
const calls: (RequestInfo | URL)[] = []
|
|
131
|
-
const raw: SelfWorker = {
|
|
132
|
-
fetch: (input: RequestInfo | URL) => {
|
|
133
|
-
calls.push(input)
|
|
134
|
-
|
|
135
|
-
return Promise.resolve(new Response('ok', { status: 200 }))
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const sharedEnv = {
|
|
140
|
-
__PLAYCADEMY_DISPATCH: { get: (_name: string) => raw },
|
|
141
|
-
__PLAYCADEMY_WORKER_NAME: 'staging-vocabulon',
|
|
142
|
-
} as unknown as HonoEnv['Bindings']
|
|
143
|
-
|
|
144
|
-
const env1 = { ...sharedEnv } as HonoEnv['Bindings']
|
|
145
|
-
|
|
146
|
-
env1.SELF = wrapSelfWorkerWithPathResolution(resolveRawSelfWorker(env1), 'https://one.example.com/api/a')
|
|
147
|
-
|
|
148
|
-
const env2 = { ...sharedEnv } as HonoEnv['Bindings']
|
|
149
|
-
|
|
150
|
-
env2.SELF = wrapSelfWorkerWithPathResolution(resolveRawSelfWorker(env2), 'https://two.example.com/api/b')
|
|
151
|
-
|
|
152
|
-
await env1.SELF!.fetch('api/test/ping')
|
|
153
|
-
await env2.SELF!.fetch('api/test/ping')
|
|
154
|
-
await env1.SELF!.fetch('api/test/ping')
|
|
155
|
-
|
|
156
|
-
expect(sharedEnv.SELF).toBeUndefined()
|
|
157
|
-
expect(calls).toHaveLength(3)
|
|
158
|
-
expect((calls[0] as URL).toString()).toBe('https://one.example.com/api/test/ping')
|
|
159
|
-
expect((calls[1] as URL).toString()).toBe('https://two.example.com/api/test/ping')
|
|
160
|
-
expect((calls[2] as URL).toString()).toBe('https://one.example.com/api/test/ping')
|
|
161
|
-
})
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
describe('resolveRawSelfWorker', () => {
|
|
165
|
-
beforeEach(() => {
|
|
166
|
-
mock.restore()
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
it('uses dispatch namespace binding when available', async () => {
|
|
170
|
-
const targetWorker: SelfWorker = {
|
|
171
|
-
fetch: () => Promise.resolve(new Response('ok')),
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const env = {
|
|
175
|
-
__PLAYCADEMY_DISPATCH: {
|
|
176
|
-
get: (name: string) => {
|
|
177
|
-
expect(name).toBe('staging-vocabulon')
|
|
178
|
-
|
|
179
|
-
return targetWorker
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
__PLAYCADEMY_WORKER_NAME: 'staging-vocabulon',
|
|
183
|
-
} as unknown as HonoEnv['Bindings']
|
|
184
|
-
|
|
185
|
-
const resolved = resolveRawSelfWorker(env)
|
|
186
|
-
const response = await resolved.fetch('https://vocabulon-staging.playcademy.gg/api/internal/process')
|
|
187
|
-
|
|
188
|
-
expect(response.status).toBe(200)
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
it('falls back to global fetch when dispatch binding is unavailable', async () => {
|
|
192
|
-
const fetchMock = mock(() => Promise.resolve(new Response('fallback', { status: 204 })))
|
|
193
|
-
const originalFetch = globalThis.fetch
|
|
194
|
-
|
|
195
|
-
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
const env = {} as HonoEnv['Bindings']
|
|
199
|
-
const resolved = resolveRawSelfWorker(env)
|
|
200
|
-
const response = await resolved.fetch(
|
|
201
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process',
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
expect(response.status).toBe(204)
|
|
205
|
-
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
206
|
-
|
|
207
|
-
const firstCall = fetchMock.mock.calls[0] as unknown[] | undefined
|
|
208
|
-
|
|
209
|
-
expect(firstCall).toBeDefined()
|
|
210
|
-
|
|
211
|
-
const arg = firstCall?.[0]
|
|
212
|
-
|
|
213
|
-
expect(arg).toBeInstanceOf(Request)
|
|
214
|
-
} finally {
|
|
215
|
-
globalThis.fetch = originalFetch
|
|
216
|
-
}
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
it('falls back to global fetch when worker name is missing', async () => {
|
|
220
|
-
const fetchMock = mock(() => Promise.resolve(new Response('fallback', { status: 206 })))
|
|
221
|
-
const originalFetch = globalThis.fetch
|
|
222
|
-
|
|
223
|
-
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
const env = {
|
|
227
|
-
__PLAYCADEMY_DISPATCH: {
|
|
228
|
-
get: () => {
|
|
229
|
-
throw new Error('should not be called when worker name is missing')
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
} as unknown as HonoEnv['Bindings']
|
|
233
|
-
const resolved = resolveRawSelfWorker(env)
|
|
234
|
-
const response = await resolved.fetch(
|
|
235
|
-
'https://vocabulon-staging.playcademy.gg/api/internal/process',
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
expect(response.status).toBe(206)
|
|
239
|
-
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
240
|
-
} finally {
|
|
241
|
-
globalThis.fetch = originalFetch
|
|
242
|
-
}
|
|
243
|
-
})
|
|
244
|
-
})
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { HonoEnv, SelfWorker } from '../types'
|
|
2
|
-
|
|
3
|
-
function isAbsoluteUrl(value: string): boolean {
|
|
4
|
-
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value)
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function normalizeSelfFetchInput(
|
|
8
|
-
input: RequestInfo | URL,
|
|
9
|
-
requestUrl: string,
|
|
10
|
-
): RequestInfo | URL {
|
|
11
|
-
if (typeof input !== 'string') {
|
|
12
|
-
return input
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (isAbsoluteUrl(input)) {
|
|
16
|
-
return new URL(input)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const origin = new URL(requestUrl).origin
|
|
20
|
-
const sanitized = input.startsWith('//') ? `/${input.replace(/^\/+/, '')}` : input
|
|
21
|
-
const pathname = sanitized.startsWith('/') ? sanitized : `/${sanitized}`
|
|
22
|
-
|
|
23
|
-
return new URL(pathname, origin)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function wrapSelfWorkerWithPathResolution(raw: SelfWorker, requestUrl: string): SelfWorker {
|
|
27
|
-
return {
|
|
28
|
-
fetch: (input, init) => raw.fetch(normalizeSelfFetchInput(input, requestUrl), init),
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function resolveRawSelfWorker(env: HonoEnv['Bindings']): SelfWorker {
|
|
33
|
-
const dispatchBinding = env.__PLAYCADEMY_DISPATCH
|
|
34
|
-
const workerName = env.__PLAYCADEMY_WORKER_NAME
|
|
35
|
-
|
|
36
|
-
if (dispatchBinding && workerName) {
|
|
37
|
-
return dispatchBinding.get(workerName)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return { fetch: (input, init) => fetch(new Request(input, init)) }
|
|
41
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
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
|
-
|
|
159
|
-
expect(result).not.toBeNull()
|
|
160
|
-
expect(result?.error).toContain('Math (Grade 4)')
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
it('handles config with no courses', () => {
|
|
164
|
-
const emptyConfig: PlaycademyConfig = {
|
|
165
|
-
name: 'Empty Game',
|
|
166
|
-
integrations: {
|
|
167
|
-
timeback: {
|
|
168
|
-
courses: [],
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const result = validateCourseConfig({ grade: 3, subject: 'Math', config: emptyConfig })
|
|
174
|
-
|
|
175
|
-
expect(result).not.toBeNull()
|
|
176
|
-
expect(result?.error).toContain('Configured courses: none')
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('handles config with no timeback integration', () => {
|
|
180
|
-
const noTimebackConfig: PlaycademyConfig = {
|
|
181
|
-
name: 'No Timeback',
|
|
182
|
-
integrations: {},
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const result = validateCourseConfig({ grade: 3, subject: 'Math', config: noTimebackConfig })
|
|
186
|
-
|
|
187
|
-
expect(result).not.toBeNull()
|
|
188
|
-
expect(result?.error).toContain('Configured courses: none')
|
|
189
|
-
})
|
|
190
|
-
})
|
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
|
|
53
|
-
if (!configuredCourse) {
|
|
54
|
-
const configured = timebackConfig?.courses
|
|
55
|
-
?.map(c => `${c.subject} (Grade ${c.grade})`)
|
|
56
|
-
.join(', ')
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
error: `Invalid grade/subject combination: ${subject} (Grade ${grade}). Configured courses: ${configured || 'none'}`,
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return null
|
|
64
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Polyfills for Node.js modules in edge runtimes
|
|
3
|
-
*
|
|
4
|
-
* Provides minimal implementations to allow bundling.
|
|
5
|
-
* These should NOT be called at runtime (config is provided directly).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const notAvailable = name => {
|
|
9
|
-
throw new Error(`${name} not available in Worker runtime - config should be provided directly`)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// fs exports
|
|
13
|
-
export const existsSync = () => notAvailable('fs.existsSync')
|
|
14
|
-
export const readdirSync = () => notAvailable('fs.readdirSync')
|
|
15
|
-
export const statSync = () => notAvailable('fs.statSync')
|
|
16
|
-
export const readFile = () => notAvailable('fs/promises.readFile')
|
|
17
|
-
|
|
18
|
-
// path exports
|
|
19
|
-
export const resolve = (...args) => args.join('/')
|
|
20
|
-
export const dirname = p => p.split('/').slice(0, -1).join('/')
|
|
21
|
-
export const parse = p => ({
|
|
22
|
-
dir: dirname(p),
|
|
23
|
-
name: p.split('/').pop(),
|
|
24
|
-
base: p.split('/').pop(),
|
|
25
|
-
ext: '',
|
|
26
|
-
root: '/',
|
|
27
|
-
})
|
|
28
|
-
export const join = (...args) => args.join('/')
|
|
29
|
-
|
|
30
|
-
// os exports
|
|
31
|
-
export const tmpdir = () => '/tmp'
|
|
32
|
-
export const homedir = () => notAvailable('os.homedir')
|
|
33
|
-
|
|
34
|
-
// Default export for 'process' module
|
|
35
|
-
export default {
|
|
36
|
-
env: {
|
|
37
|
-
// These will be set by the Worker environment
|
|
38
|
-
// Cloudflare Workers uses env bindings, but we access via process.env
|
|
39
|
-
PLAYCADEMY_API_KEY: globalThis.PLAYCADEMY_API_KEY || '',
|
|
40
|
-
GAME_ID: globalThis.GAME_ID || '',
|
|
41
|
-
PLAYCADEMY_BASE_URL: globalThis.PLAYCADEMY_BASE_URL || '',
|
|
42
|
-
},
|
|
43
|
-
cwd: () => '/',
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Named export as well for compatibility
|
|
47
|
-
export const process = {
|
|
48
|
-
env: {
|
|
49
|
-
PLAYCADEMY_API_KEY: globalThis.PLAYCADEMY_API_KEY || '',
|
|
50
|
-
GAME_ID: globalThis.GAME_ID || '',
|
|
51
|
-
PLAYCADEMY_BASE_URL: globalThis.PLAYCADEMY_BASE_URL || '',
|
|
52
|
-
},
|
|
53
|
-
cwd: () => '/',
|
|
54
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Route Registration Helper
|
|
3
|
-
*
|
|
4
|
-
* Conditionally registers built-in integration routes based on enabled integrations.
|
|
5
|
-
* Uses dynamic imports to enable tree-shaking (unused integrations are removed from bundle).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { ROUTES } from './constants'
|
|
9
|
-
|
|
10
|
-
import type { Hono } from 'hono'
|
|
11
|
-
|
|
12
|
-
import type { HonoEnv, Integrations } from './types'
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Registers all enabled built-in integration routes
|
|
16
|
-
*
|
|
17
|
-
* @param app - Hono application instance
|
|
18
|
-
* @param integrations - Enabled integrations from config
|
|
19
|
-
*/
|
|
20
|
-
export async function registerBuiltinRoutes(app: Hono<HonoEnv>, integrations?: Integrations) {
|
|
21
|
-
// Route discovery (always included)
|
|
22
|
-
const routesIndex = await import('./routes/index')
|
|
23
|
-
|
|
24
|
-
app.get(ROUTES.INDEX, routesIndex.GET)
|
|
25
|
-
|
|
26
|
-
// Health check (always included)
|
|
27
|
-
const health = await import('./routes/health')
|
|
28
|
-
|
|
29
|
-
app.get(ROUTES.HEALTH, health.GET)
|
|
30
|
-
|
|
31
|
-
// TimeBack integration
|
|
32
|
-
if (integrations?.timeback) {
|
|
33
|
-
const [endActivity, getXp] = await Promise.all([
|
|
34
|
-
import('./routes/integrations/timeback/end-activity'),
|
|
35
|
-
import('./routes/integrations/timeback/get-xp'),
|
|
36
|
-
])
|
|
37
|
-
|
|
38
|
-
app.post(ROUTES.TIMEBACK.END_ACTIVITY, endActivity.POST)
|
|
39
|
-
app.get(ROUTES.TIMEBACK.GET_XP, getXp.GET)
|
|
40
|
-
} else if (integrations?.timeback === null) {
|
|
41
|
-
app.post('/api/integrations/timeback/end-activity', async c =>
|
|
42
|
-
c.json({
|
|
43
|
-
status: 'ok',
|
|
44
|
-
__playcademyDevWarning: 'timeback-not-configured',
|
|
45
|
-
}),
|
|
46
|
-
)
|
|
47
|
-
app.get('/api/integrations/timeback/xp', async c =>
|
|
48
|
-
c.json({
|
|
49
|
-
timebackId: '',
|
|
50
|
-
totalXp: 0,
|
|
51
|
-
__playcademyDevWarning: 'timeback-not-configured',
|
|
52
|
-
}),
|
|
53
|
-
)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// TODO: Auth integration
|
|
57
|
-
// TODO: Storage integration
|
|
58
|
-
// TODO: Realtime integration
|
|
59
|
-
}
|