@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 +50 -0
- package/package.json +3 -8
- package/src/bus.ts +74 -0
- package/src/core/client.ts +416 -0
- package/src/core/errors.ts +20 -0
- package/src/core/request.ts +104 -0
- package/src/runtime.ts +61 -0
- package/src/types.ts +55 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +27 -0
- package/tsconfig.types.json +13 -0
- package/dist/bus.d.ts +0 -37
- package/dist/core/client.d.ts +0 -144
- package/dist/core/errors.d.ts +0 -11
- package/dist/core/request.d.ts +0 -24
- package/dist/runtime.d.ts +0 -7
- package/dist/runtime.js +0 -7427
- package/dist/types.d.ts +0 -43
- package/dist/types.js +0 -0
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.
|
|
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
|
+
}
|