@playcademy/sdk 0.0.1-beta.3 → 0.0.1-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { $ } from 'bun'
2
+ import packageJson from './package.json' assert { type: 'json' }
3
+ import yoctoSpinner from 'yocto-spinner'
4
+
5
+ const startTime = performance.now()
6
+ const spinner = yoctoSpinner({ text: 'Building JavaScript...' }).start()
7
+
8
+ const buildDir = './dist'
9
+ const entrypoints = ['./src/runtime.ts', './src/types.ts']
10
+
11
+ try {
12
+ await $`rm -rf ${buildDir}`
13
+
14
+ const external = [
15
+ // ...Object.keys(packageJson.dependencies), // no deps atm
16
+ ...Object.keys(packageJson.peerDependencies),
17
+ ]
18
+
19
+ await Bun.build({
20
+ entrypoints,
21
+ external,
22
+ outdir: buildDir,
23
+ format: 'esm',
24
+ target: 'node',
25
+ })
26
+
27
+ spinner.text = 'Generating types...'
28
+
29
+ const { stderr } =
30
+ await $`tsc --emitDeclarationOnly --declaration --project tsconfig.types.json --outDir ${buildDir}`
31
+
32
+ if (stderr.toString().length) {
33
+ spinner.error(`Type generation failed:\n${stderr.toString()}`)
34
+ process.exit(1) // Exit with error code
35
+ } else {
36
+ const duration = ((performance.now() - startTime) / 1000).toFixed(2)
37
+ spinner.success(`Build complete in ${duration}s!`)
38
+
39
+ // Log built entrypoints
40
+ for (const entry of entrypoints) {
41
+ console.log(` - ${entry}`)
42
+ }
43
+ }
44
+ process.exit(0)
45
+ } catch (error) {
46
+ const duration = ((performance.now() - startTime) / 1000).toFixed(2)
47
+ console.log({ error })
48
+ spinner.error(`Build failed in ${duration}s: ${error}`)
49
+ process.exit(1) // Exit with error code
50
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playcademy/sdk",
3
3
  "type": "module",
4
- "version": "0.0.1-beta.3",
4
+ "version": "0.0.1-beta.5",
5
5
  "exports": {
6
6
  ".": {
7
7
  "import": "./dist/runtime.js",
@@ -16,17 +16,12 @@
16
16
  },
17
17
  "main": "dist/runtime.js",
18
18
  "module": "dist/runtime.js",
19
- "files": [
20
- "dist"
21
- ],
22
19
  "scripts": {
23
20
  "build": "bun build.js",
24
- "pub": "bun run build && bunx bumpp --no-tag --no-push && bun publish --access public"
25
- },
26
- "dependencies": {
27
- "@playcademy/data": "0.0.1"
21
+ "pub": "bun run build && bunx bumpp --no-tag --no-push -c \"chore(@playcademy/sdk): release v{version}\" && bun publish --access public"
28
22
  },
29
23
  "devDependencies": {
24
+ "@playcademy/types": "latest",
30
25
  "@types/bun": "latest",
31
26
  "typescript": "^5.0.0",
32
27
  "yocto-spinner": "^0.2.1"
package/src/bus.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { GameContextPayload } from './types'
2
+
3
+ export enum BusEvents {
4
+ // Overworld → Game
5
+ INIT = 'PLAYCADEMY_INIT',
6
+ TOKEN_REFRESH = 'PLAYCADEMY_TOKEN_REFRESH',
7
+ PAUSE = 'PLAYCADEMY_PAUSE',
8
+ RESUME = 'PLAYCADEMY_RESUME',
9
+ FORCE_EXIT = 'PLAYCADEMY_FORCE_EXIT',
10
+ OVERLAY = 'PLAYCADEMY_OVERLAY',
11
+
12
+ // Overworld ← Game
13
+ READY = 'PLAYCADEMY_READY',
14
+ EXIT = 'PLAYCADEMY_EXIT',
15
+ TELEMETRY = 'PLAYCADEMY_TELEMETRY',
16
+ }
17
+
18
+ type BusHandler<T = unknown> = (payload: T) => void
19
+
20
+ export type BusEventMap = {
21
+ [BusEvents.INIT]: GameContextPayload
22
+ [BusEvents.TOKEN_REFRESH]: { token: string; exp: number }
23
+ [BusEvents.PAUSE]: void
24
+ [BusEvents.RESUME]: void
25
+ [BusEvents.FORCE_EXIT]: void
26
+ [BusEvents.OVERLAY]: boolean
27
+
28
+ [BusEvents.READY]: void
29
+ [BusEvents.EXIT]: void
30
+ [BusEvents.TELEMETRY]: { fps: number; mem: number }
31
+ // ...
32
+ }
33
+
34
+ interface Bus {
35
+ emit<K extends BusEvents>(type: K, payload: BusEventMap[K]): void
36
+ on<K extends BusEvents>(type: K, handler: BusHandler<BusEventMap[K]>): void
37
+ off<K extends BusEvents>(type: K, handler: BusHandler<BusEventMap[K]>): void
38
+ }
39
+
40
+ const busListeners = new Map<
41
+ BusEvents,
42
+ Map<BusHandler, EventListenerOrEventListenerObject>
43
+ >()
44
+
45
+ export const bus: Bus = {
46
+ emit<K extends BusEvents>(type: K, payload: BusEventMap[K]) {
47
+ window.dispatchEvent(new CustomEvent(type, { detail: payload }))
48
+ },
49
+ on<K extends BusEvents>(type: K, handler: BusHandler<BusEventMap[K]>) {
50
+ const listener = (event: Event) =>
51
+ handler((event as CustomEvent).detail)
52
+
53
+ if (!busListeners.has(type)) {
54
+ busListeners.set(type, new Map())
55
+ }
56
+ busListeners.get(type)!.set(handler as BusHandler, listener)
57
+
58
+ window.addEventListener(type, listener)
59
+ },
60
+ off<K extends BusEvents>(type: K, handler: BusHandler<BusEventMap[K]>) {
61
+ const typeListeners = busListeners.get(type)
62
+ if (!typeListeners || !typeListeners.has(handler as BusHandler)) {
63
+ return
64
+ }
65
+
66
+ const actualListener = typeListeners.get(handler as BusHandler)!
67
+ window.removeEventListener(type, actualListener)
68
+ typeListeners.delete(handler as BusHandler)
69
+
70
+ if (typeListeners.size === 0) {
71
+ busListeners.delete(type)
72
+ }
73
+ },
74
+ }
@@ -0,0 +1,416 @@
1
+ import {
2
+ request as instanceRequest,
3
+ type Method,
4
+ fetchManifest,
5
+ } from './request'
6
+ import { PlaycademyError } from './errors'
7
+ import { bus, BusEvents } from '../bus'
8
+
9
+ import type {
10
+ Game,
11
+ GameWithManifest,
12
+ DeveloperKey,
13
+ DeveloperStatusResponse,
14
+ UpsertGameMetadataInput,
15
+ Reward,
16
+ InsertReward,
17
+ UpdateReward,
18
+ MapElement,
19
+ } from '@playcademy/types'
20
+
21
+ import type {
22
+ User,
23
+ GameState,
24
+ InventoryItemWithReward,
25
+ ClientConfig,
26
+ ClientEvents,
27
+ EventListeners,
28
+ LoginResponse,
29
+ GameTokenResponse,
30
+ StartSessionResponse,
31
+ InventoryMutationResponse,
32
+ DeveloperStatusValue,
33
+ } from '../types'
34
+
35
+ export class PlaycademyClient {
36
+ private baseUrl: string
37
+ private token?: string
38
+ private gameId?: string
39
+ private listeners: EventListeners = {}
40
+
41
+ private internalClientSessionId?: string
42
+
43
+ constructor(config: ClientConfig) {
44
+ this.baseUrl = config.baseUrl
45
+ this.token = config.token
46
+ this.gameId = config.gameId
47
+
48
+ if (this.gameId) {
49
+ this._initializeInternalSession().catch(error => {
50
+ console.error(
51
+ '[SDK] Background initialization of auto-session failed:',
52
+ error,
53
+ )
54
+ })
55
+ }
56
+ }
57
+
58
+ private async _initializeInternalSession(): Promise<void> {
59
+ if (!this.gameId || this.internalClientSessionId) {
60
+ return
61
+ }
62
+
63
+ try {
64
+ const response = await this.games.startSession(this.gameId)
65
+ this.internalClientSessionId = response.sessionId
66
+ } catch (error) {
67
+ console.error(
68
+ '[SDK] Auto-starting session failed for game',
69
+ this.gameId,
70
+ error,
71
+ )
72
+ }
73
+ }
74
+
75
+ // --- Public Accessors ---
76
+
77
+ public getBaseUrl(): string {
78
+ const isRelative = this.baseUrl.startsWith('/')
79
+ const isBrowser = typeof window !== 'undefined'
80
+
81
+ return isRelative && isBrowser
82
+ ? `${window.location.origin}${this.baseUrl}`
83
+ : this.baseUrl
84
+ }
85
+
86
+ // --- Event Handling ---
87
+
88
+ on<E extends keyof ClientEvents>(
89
+ event: E,
90
+ callback: (payload: ClientEvents[E]) => void,
91
+ ) {
92
+ this.listeners[event] = this.listeners[event] ?? []
93
+ this.listeners[event].push(callback)
94
+ }
95
+
96
+ private emit<E extends keyof ClientEvents>(
97
+ event: E,
98
+ payload: ClientEvents[E],
99
+ ) {
100
+ ;(this.listeners[event] ?? []).forEach(listener => {
101
+ ;(listener as (arg: ClientEvents[E]) => void)(payload)
102
+ })
103
+ }
104
+
105
+ // --- State Management ---
106
+
107
+ setToken(token: string | null) {
108
+ this.token = token ?? undefined
109
+ this.emit('authChange', { token: this.token ?? null })
110
+ }
111
+
112
+ onAuthChange(callback: (token: string | null) => void): void {
113
+ this.on('authChange', payload => callback(payload.token))
114
+ }
115
+
116
+ // --- Request Wrapper ---
117
+
118
+ protected async request<T>(
119
+ path: string,
120
+ method: Method,
121
+ body?: unknown,
122
+ headers?: Record<string, string>,
123
+ ) {
124
+ const effectiveHeaders = { ...headers }
125
+
126
+ return instanceRequest<T>({
127
+ path,
128
+ method,
129
+ body,
130
+ baseUrl: this.baseUrl,
131
+ token: this.token,
132
+ extraHeaders: effectiveHeaders,
133
+ })
134
+ }
135
+
136
+ // --- Private Utilities ---
137
+
138
+ private _ensureGameId(): string {
139
+ if (!this.gameId) {
140
+ throw new PlaycademyError(
141
+ 'This operation requires a gameId, but none was provided when initializing the client.',
142
+ )
143
+ }
144
+ return this.gameId
145
+ }
146
+
147
+ // --- Namespaced API Methods ---
148
+
149
+ auth = {
150
+ logout: async (): Promise<void> => {
151
+ await this.request(`/auth/logout`, 'DELETE')
152
+ this.setToken(null)
153
+ },
154
+ }
155
+
156
+ runtime = {
157
+ getGameToken: async (
158
+ gameId: string,
159
+ options?: { apply?: boolean },
160
+ ): Promise<GameTokenResponse> => {
161
+ const res = await this.request<GameTokenResponse>(
162
+ `/games/${gameId}/token`,
163
+ 'POST',
164
+ )
165
+ if (options?.apply) {
166
+ this.setToken(res.token)
167
+ }
168
+ return res
169
+ },
170
+ exit: async (): Promise<void> => {
171
+ if (this.internalClientSessionId && this.gameId) {
172
+ try {
173
+ await this.games.endSession(
174
+ this.internalClientSessionId,
175
+ this.gameId,
176
+ )
177
+ } catch (error) {
178
+ console.error(
179
+ '[SDK] Failed to auto-end session:',
180
+ this.internalClientSessionId,
181
+ error,
182
+ )
183
+ }
184
+ }
185
+ bus.emit(BusEvents.EXIT, undefined)
186
+ },
187
+ }
188
+
189
+ games = {
190
+ fetch: async (gameIdOrSlug: string): Promise<GameWithManifest> => {
191
+ const baseGameData = await this.request<Game>(
192
+ `/games/${gameIdOrSlug}`,
193
+ 'GET',
194
+ )
195
+
196
+ const manifestData = await fetchManifest(
197
+ baseGameData.assetBundleBase,
198
+ )
199
+
200
+ return {
201
+ ...baseGameData,
202
+ manifest: manifestData,
203
+ }
204
+ },
205
+ list: (): Promise<Array<Game>> =>
206
+ this.request<Array<Game>>('/games', 'GET'),
207
+
208
+ saveState: async (state: Record<string, unknown>) => {
209
+ const gameId = this._ensureGameId()
210
+ await this.request<void>(`/games/${gameId}/state`, 'POST', state)
211
+ },
212
+ loadState: async () => {
213
+ const gameId = this._ensureGameId()
214
+ return this.request<GameState>(`/games/${gameId}/state`, 'GET')
215
+ },
216
+ startSession: async (
217
+ gameId?: string,
218
+ ): Promise<StartSessionResponse> => {
219
+ const idToUse = gameId ?? this._ensureGameId()
220
+ return this.request<StartSessionResponse>(
221
+ `/games/${idToUse}/sessions`,
222
+ 'POST',
223
+ {},
224
+ )
225
+ },
226
+ endSession: async (
227
+ sessionId: string,
228
+ gameId?: string,
229
+ ): Promise<void> => {
230
+ const effectiveGameIdToEnd = gameId ?? this._ensureGameId()
231
+
232
+ if (
233
+ this.internalClientSessionId &&
234
+ sessionId === this.internalClientSessionId &&
235
+ effectiveGameIdToEnd === this.gameId
236
+ ) {
237
+ this.internalClientSessionId = undefined
238
+ }
239
+ await this.request<void>(
240
+ `/games/${effectiveGameIdToEnd}/sessions/${sessionId}/end`,
241
+ 'POST',
242
+ )
243
+ },
244
+ }
245
+
246
+ users = {
247
+ me: async () => {
248
+ return this.request<User>('/users/me', 'GET')
249
+ },
250
+
251
+ inventory: {
252
+ get: async () =>
253
+ this.request<Array<InventoryItemWithReward>>(
254
+ `/inventory`,
255
+ 'GET',
256
+ ),
257
+ add: async (
258
+ rewardId: string,
259
+ qty: number,
260
+ ): Promise<InventoryMutationResponse> => {
261
+ const res = await this.request<InventoryMutationResponse>(
262
+ `/inventory/add`,
263
+ 'POST',
264
+ { rewardId, qty },
265
+ )
266
+ this.emit('inventoryChange', {
267
+ rewardId,
268
+ delta: qty,
269
+ newTotal: res.newTotal,
270
+ })
271
+ return res
272
+ },
273
+ spend: async (
274
+ rewardId: string,
275
+ qty: number,
276
+ ): Promise<InventoryMutationResponse> => {
277
+ const res = await this.request<InventoryMutationResponse>(
278
+ `/inventory/spend`,
279
+ 'POST',
280
+ { rewardId, qty },
281
+ )
282
+ this.emit('inventoryChange', {
283
+ rewardId,
284
+ delta: -qty,
285
+ newTotal: res.newTotal,
286
+ })
287
+ return res
288
+ },
289
+ },
290
+ }
291
+
292
+ dev = {
293
+ auth: {
294
+ applyForDeveloper: () => this.request<void>('/dev/apply', 'POST'),
295
+ getDeveloperStatus: async (): Promise<DeveloperStatusValue> => {
296
+ const response = await this.request<DeveloperStatusResponse>(
297
+ '/dev/status',
298
+ 'GET',
299
+ )
300
+ return response.status
301
+ },
302
+ },
303
+
304
+ games: {
305
+ upsert: (
306
+ slug: string,
307
+ metadata: UpsertGameMetadataInput,
308
+ file: File | Blob,
309
+ ): Promise<Game> => {
310
+ const form = new FormData()
311
+ form.append('metadata', JSON.stringify(metadata))
312
+ form.append('file', file)
313
+ return this.request<Game>(`/games/${slug}`, 'PUT', form)
314
+ },
315
+
316
+ update: (gameId: string, props: Partial<Game>) =>
317
+ this.request<void>(`/games/${gameId}`, 'PATCH', props),
318
+
319
+ delete: (gameId: string): Promise<void> =>
320
+ this.request<void>(`/games/${gameId}`, 'DELETE'),
321
+ },
322
+
323
+ keys: {
324
+ createKey: (gameId: string, label?: string) =>
325
+ this.request<DeveloperKey>(
326
+ `/dev/games/${gameId}/keys`,
327
+ 'POST',
328
+ { label },
329
+ ),
330
+
331
+ listKeys: (gameId: string) =>
332
+ this.request<Array<DeveloperKey>>(
333
+ `/dev/games/${gameId}/keys`,
334
+ 'GET',
335
+ ),
336
+
337
+ revokeKey: (keyId: string) =>
338
+ this.request<void>(`/dev/keys/${keyId}`, 'DELETE'),
339
+ },
340
+ }
341
+
342
+ maps = {
343
+ elements: (mapId: string) =>
344
+ this.request<Array<MapElement>>(
345
+ `/map/elements?mapId=${mapId}`,
346
+ 'GET',
347
+ ),
348
+ }
349
+
350
+ admin = {
351
+ games: {
352
+ pauseGame: (gameId: string) =>
353
+ this.request<void>(`/admin/games/${gameId}/pause`, 'POST'),
354
+ resumeGame: (gameId: string) =>
355
+ this.request<void>(`/admin/games/${gameId}/resume`, 'POST'),
356
+ },
357
+ rewards: {
358
+ createReward: (props: InsertReward) =>
359
+ this.request<Reward>('/rewards', 'POST', props),
360
+ getReward: (rewardId: string) =>
361
+ this.request<Reward>(`/rewards/${rewardId}`, 'GET'),
362
+ listRewards: () => this.request<Array<Reward>>('/rewards', 'GET'),
363
+ updateReward: (rewardId: string, props: UpdateReward) =>
364
+ this.request<Reward>(`/rewards/${rewardId}`, 'PATCH', props),
365
+ deleteReward: (rewardId: string) =>
366
+ this.request<void>(`/rewards/${rewardId}`, 'DELETE'),
367
+ },
368
+ }
369
+
370
+ telemetry = {
371
+ pushMetrics: (metrics: Record<string, number>) =>
372
+ this.request<void>(`/telemetry/metrics`, 'POST', metrics),
373
+ }
374
+
375
+ ping(): string {
376
+ return 'pong'
377
+ }
378
+
379
+ public static async login(
380
+ baseUrl: string,
381
+ email: string,
382
+ password: string,
383
+ ): Promise<LoginResponse> {
384
+ let url = baseUrl
385
+ if (baseUrl.startsWith('/') && typeof window !== 'undefined') {
386
+ url = window.location.origin + baseUrl
387
+ }
388
+ url = url + '/auth/login'
389
+
390
+ const response = await fetch(url, {
391
+ method: 'POST',
392
+ headers: {
393
+ 'Content-Type': 'application/json',
394
+ },
395
+ body: JSON.stringify({ email, password }),
396
+ })
397
+
398
+ if (!response.ok) {
399
+ try {
400
+ const errorData = await response.json()
401
+ const errorMessage =
402
+ errorData && errorData.message
403
+ ? String(errorData.message)
404
+ : response.statusText
405
+ throw new PlaycademyError(errorMessage)
406
+ } catch (error) {
407
+ console.error(
408
+ '[SDK] Failed to parse error response JSON, using status text instead:',
409
+ error,
410
+ )
411
+ throw new PlaycademyError(response.statusText)
412
+ }
413
+ }
414
+ return response.json() as Promise<LoginResponse>
415
+ }
416
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Base error class for Cademy SDK specific errors.
3
+ */
4
+ export class PlaycademyError extends Error {
5
+ constructor(message: string) {
6
+ super(message)
7
+ this.name = 'PlaycademyError'
8
+ }
9
+ }
10
+
11
+ export class ApiError extends Error {
12
+ constructor(
13
+ public status: number,
14
+ message: string,
15
+ public details: unknown,
16
+ ) {
17
+ super(`${status} ${message}`)
18
+ Object.setPrototypeOf(this, ApiError.prototype)
19
+ }
20
+ }
@@ -0,0 +1,104 @@
1
+ import { ApiError } from './errors'
2
+ import { PlaycademyError } from './errors'
3
+ import type { ManifestV1 } from '@playcademy/types'
4
+
5
+ /** Permitted HTTP verbs */
6
+ export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
7
+
8
+ export interface RequestOptions {
9
+ path: string
10
+ baseUrl: string
11
+ token?: string | null
12
+ method?: Method
13
+ body?: unknown
14
+ extraHeaders?: Record<string, string>
15
+ }
16
+
17
+ /**
18
+ * Thin wrapper around `fetch` that:
19
+ * • attaches Bearer token if provided
20
+ * • stringifies JSON bodies and sets Content‑Type
21
+ * • passes FormData untouched so the browser adds multipart boundary
22
+ * • normalises non‑2xx responses into ApiError
23
+ * • auto‑parses JSON responses, falls back to text/void
24
+ */
25
+ export async function request<T = unknown>({
26
+ path,
27
+ baseUrl,
28
+ token,
29
+ method = 'GET',
30
+ body,
31
+ extraHeaders = {},
32
+ }: RequestOptions): Promise<T> {
33
+ const url =
34
+ baseUrl.replace(/\/$/, '') + (path.startsWith('/') ? path : `/${path}`)
35
+
36
+ const headers: Record<string, string> = { ...extraHeaders }
37
+ let payload: BodyInit | undefined
38
+
39
+ if (body instanceof FormData) {
40
+ payload = body // let browser add multipart headers
41
+ } else if (body !== undefined && body !== null) {
42
+ payload = JSON.stringify(body)
43
+ headers['Content-Type'] = 'application/json'
44
+ }
45
+
46
+ if (token) headers['Authorization'] = `Bearer ${token}`
47
+
48
+ const res = await fetch(url, {
49
+ method,
50
+ headers,
51
+ body: payload,
52
+ credentials: 'omit',
53
+ })
54
+
55
+ if (!res.ok) {
56
+ const errorBody =
57
+ (await res
58
+ .clone() // clone so we can try json then text
59
+ .json()
60
+ .catch(() => res.text().catch(() => undefined))) ?? undefined
61
+
62
+ throw new ApiError(res.status, res.statusText, errorBody)
63
+ }
64
+
65
+ if (res.status === 204) return undefined as unknown as T
66
+
67
+ const contentType = res.headers.get('content-type') ?? ''
68
+ if (contentType.includes('application/json')) {
69
+ return (await res.json()) as T
70
+ }
71
+
72
+ // TODO: blob/arrayBuffer behaviour can be added if needed later
73
+ return (await res.text()) as unknown as T
74
+ }
75
+
76
+ /**
77
+ * Fetches, parses, and validates the playcademy.manifest.json file from a given base URL.
78
+ */
79
+ export async function fetchManifest(
80
+ assetBundleBase: string,
81
+ ): Promise<ManifestV1> {
82
+ const manifestUrl = `${assetBundleBase.replace(/\/$/, '')}/playcademy.manifest.json`
83
+ try {
84
+ const response = await fetch(manifestUrl)
85
+ if (!response.ok) {
86
+ console.error(
87
+ `[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`,
88
+ )
89
+ throw new PlaycademyError(
90
+ `Failed to fetch manifest: ${response.status} ${response.statusText}`,
91
+ )
92
+ }
93
+ return (await response.json()) as ManifestV1
94
+ } catch (error) {
95
+ if (error instanceof PlaycademyError) {
96
+ throw error
97
+ }
98
+ console.error(
99
+ `[fetchManifest] Error fetching or parsing manifest from ${manifestUrl}:`,
100
+ error,
101
+ )
102
+ throw new PlaycademyError('Failed to load or parse game manifest')
103
+ }
104
+ }