@playcademy/sdk 0.0.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/bus.d.ts +37 -0
- package/dist/core/client.d.ts +144 -0
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/request.d.ts +24 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +370 -0
- package/dist/types.d.ts +43 -0
- package/dist/types.js +0 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Playcademy SDK
|
|
2
|
+
|
|
3
|
+
A TypeScript SDK for interacting with the Playcademy platform API.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This SDK provides a unified client for various interactions with Playcademy.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @playcademy/sdk
|
|
13
|
+
# or
|
|
14
|
+
npm install @playcademy/sdk
|
|
15
|
+
# or
|
|
16
|
+
yarn add @playcademy/sdk
|
|
17
|
+
# or
|
|
18
|
+
pnpm add @playcademy/sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Initialization
|
|
22
|
+
|
|
23
|
+
### Browser / Mini-Games (Runtime Environment)
|
|
24
|
+
|
|
25
|
+
For code running within the Playcademy game loader environment (e.g., mini-games):
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { initFromWindow } from '@playcademy/sdk'
|
|
29
|
+
|
|
30
|
+
// Reads configuration from window.PLAYCADEMY injected by the loader
|
|
31
|
+
const client = await initFromWindow() // initFromWindow is async
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### General Use (Node.js, Backend, UI Tooling)
|
|
35
|
+
|
|
36
|
+
For server-side code, UI applications, or other environments where you manage configuration manually:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { PlaycademyClient, type LoginResponse } from '@playcademy/sdk'
|
|
40
|
+
|
|
41
|
+
async function initializeAndUseClient() {
|
|
42
|
+
const baseUrl = 'https://api.playcademy.com' // Or your local /api endpoint
|
|
43
|
+
let loginResponse: LoginResponse
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// 1. Authenticate to get a token
|
|
47
|
+
loginResponse = await PlaycademyClient.login(
|
|
48
|
+
baseUrl,
|
|
49
|
+
'user@example.com',
|
|
50
|
+
'password',
|
|
51
|
+
)
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('Login failed:', error)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. Instantiate the client with the token
|
|
58
|
+
// Optionally provide a gameId to enable automatic session management for that game.
|
|
59
|
+
const client = new PlaycademyClient({
|
|
60
|
+
baseUrl: baseUrl,
|
|
61
|
+
token: loginResponse.token,
|
|
62
|
+
// gameId: 'your-game-id' // Optional: if provided, client attempts to auto-start/end session
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// If the token has appropriate permissions, you can access all namespaces:
|
|
66
|
+
// client.dev.games.upsert(...)
|
|
67
|
+
// client.admin.rewards.createReward(...)
|
|
68
|
+
// Calling a method without sufficient token permissions will result in a server error.
|
|
69
|
+
|
|
70
|
+
// Example: Listen for auth changes (e.g., if token is refreshed or cleared by logout)
|
|
71
|
+
client.onAuthChange(token => {
|
|
72
|
+
console.log('Authentication token changed:', token)
|
|
73
|
+
// You might want to update stored token here
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Example: Logout
|
|
77
|
+
// await client.auth.logout();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
initializeAndUseClient()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Quickstart: Mini-Game Example
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { initFromWindow } from '@playcademy/sdk'
|
|
87
|
+
|
|
88
|
+
async function runGame() {
|
|
89
|
+
const client = await initFromWindow()
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// 1) Start a game session (gameId is optional, defaults to client.gameId)
|
|
93
|
+
const { sessionId } = await client.games.startSession()
|
|
94
|
+
console.log('Session started:', sessionId)
|
|
95
|
+
|
|
96
|
+
// 2) Fetch player's inventory/rewards
|
|
97
|
+
const rewards = await client.users.inventory.get()
|
|
98
|
+
console.log('Player inventory:', rewards)
|
|
99
|
+
|
|
100
|
+
// 3) Save game state (uses client.gameId implicitly)
|
|
101
|
+
await client.games.saveState({
|
|
102
|
+
currentLevel: 'level_2', // This is separate from progress, often more transient
|
|
103
|
+
health: 95,
|
|
104
|
+
})
|
|
105
|
+
console.log('Game state saved')
|
|
106
|
+
|
|
107
|
+
// 4) Load game state (uses client.gameId implicitly)
|
|
108
|
+
const loadedState = await client.games.loadState()
|
|
109
|
+
console.log('Game state loaded:', loadedState)
|
|
110
|
+
|
|
111
|
+
// 5) End the session when finished (gameId is optional)
|
|
112
|
+
await client.games.endSession(sessionId)
|
|
113
|
+
console.log('Session ended')
|
|
114
|
+
|
|
115
|
+
// 6) Exit the game view (if embedded)
|
|
116
|
+
client.runtime.exit()
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('An error occurred:', error)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
runGame()
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## API Modules
|
|
126
|
+
|
|
127
|
+
The `PlaycademyClient` instance provides access to all API modules.
|
|
128
|
+
The server will determine if the provided token has sufficient permissions for each operation.
|
|
129
|
+
Internal event handling uses a `BusEvents` enum for type safety.
|
|
130
|
+
All methods returning data are strongly typed.
|
|
131
|
+
|
|
132
|
+
- **`auth`**: User logout. For login, use the static `PlaycademyClient.login()` method.
|
|
133
|
+
- `logout()`: Logs out the current user and clears the token from the client instance.
|
|
134
|
+
- **`onAuthChange(callback)`**: A top-level client method to subscribe to authentication token changes (login, logout, explicit `setToken`).
|
|
135
|
+
- **`users`**:
|
|
136
|
+
- `me()`: Fetch current user details.
|
|
137
|
+
- **`inventory`**:
|
|
138
|
+
- `get()`: Get player inventory.
|
|
139
|
+
- `add(rewardId, qty)`: Add item to player inventory.
|
|
140
|
+
- `spend(rewardId, qty)`: Spend item from player inventory.
|
|
141
|
+
- **`progress`**: Manages persistent progress data for a game (e.g., levels completed, scores, collectibles).
|
|
142
|
+
- `get(gameId?)`: Get the entire progress state for a game. `gameId` is optional and defaults to the client's current game context.
|
|
143
|
+
- `update(data, gameId?)`: Update the progress state for a game. `gameId` is optional. The `data` object can be structured to hold progress for various internal nodes or aspects of the game.
|
|
144
|
+
- **`games`**: Manages game sessions and transient game state.
|
|
145
|
+
- `fetch(gameIdOrSlug)`: Fetch game details with manifest.
|
|
146
|
+
- `list()`: List all games.
|
|
147
|
+
- `saveState(state)`: Save transient game state (e.g., current position, temporary buffs) for the current game context.
|
|
148
|
+
- `loadState()`: Load transient game state for the current game context.
|
|
149
|
+
- `startSession(gameId?)`: Start a game session. `gameId` is optional and defaults to `client.gameId` if set. If the client is managing an automatic session, this can be used to start additional, distinct sessions.
|
|
150
|
+
- `endSession(sessionId, gameId?)`: End a game session. `gameId` is optional. If this ends an automatically managed session, the client will no longer attempt to auto-end it.
|
|
151
|
+
- **`runtime`**:
|
|
152
|
+
- `getGameToken(gameId, options?: { apply?: boolean })`: Fetches a game-specific token. If `options.apply` is true, it sets this token as the active token on the client instance (default is false).
|
|
153
|
+
- `exit()`: Signals the hosting environment to close the game view. If the client is managing an automatic session (because `gameId` was provided at construction), this method will attempt to end that session before signaling exit.
|
|
154
|
+
- **`maps`**:
|
|
155
|
+
- `elements(mapId)`: Fetch elements (like nodes, points of interest) for a specific map.
|
|
156
|
+
- **`dev.auth`**: Apply for developer status, get status.
|
|
157
|
+
- **`dev.games`**: Upsert, update, delete games.
|
|
158
|
+
- **`dev.keys`**: Create, list, revoke API keys for games.
|
|
159
|
+
- **`admin.games`**: Pause/resume games.
|
|
160
|
+
- **`admin.rewards`**: CRUD operations for rewards.
|
|
161
|
+
- **`telemetry`**: Push metrics.
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
PRs welcome. Fork, build, test, send a PR.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
package/dist/bus.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GameContextPayload } from './types';
|
|
2
|
+
export declare enum BusEvents {
|
|
3
|
+
INIT = "PLAYCADEMY_INIT",
|
|
4
|
+
TOKEN_REFRESH = "PLAYCADEMY_TOKEN_REFRESH",
|
|
5
|
+
PAUSE = "PLAYCADEMY_PAUSE",
|
|
6
|
+
RESUME = "PLAYCADEMY_RESUME",
|
|
7
|
+
FORCE_EXIT = "PLAYCADEMY_FORCE_EXIT",
|
|
8
|
+
OVERLAY = "PLAYCADEMY_OVERLAY",
|
|
9
|
+
READY = "PLAYCADEMY_READY",
|
|
10
|
+
EXIT = "PLAYCADEMY_EXIT",
|
|
11
|
+
TELEMETRY = "PLAYCADEMY_TELEMETRY"
|
|
12
|
+
}
|
|
13
|
+
type BusHandler<T = unknown> = (payload: T) => void;
|
|
14
|
+
export type BusEventMap = {
|
|
15
|
+
[BusEvents.INIT]: GameContextPayload;
|
|
16
|
+
[BusEvents.TOKEN_REFRESH]: {
|
|
17
|
+
token: string;
|
|
18
|
+
exp: number;
|
|
19
|
+
};
|
|
20
|
+
[BusEvents.PAUSE]: void;
|
|
21
|
+
[BusEvents.RESUME]: void;
|
|
22
|
+
[BusEvents.FORCE_EXIT]: void;
|
|
23
|
+
[BusEvents.OVERLAY]: boolean;
|
|
24
|
+
[BusEvents.READY]: void;
|
|
25
|
+
[BusEvents.EXIT]: void;
|
|
26
|
+
[BusEvents.TELEMETRY]: {
|
|
27
|
+
fps: number;
|
|
28
|
+
mem: number;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
interface Bus {
|
|
32
|
+
emit<K extends BusEvents>(type: K, payload: BusEventMap[K]): void;
|
|
33
|
+
on<K extends BusEvents>(type: K, handler: BusHandler<BusEventMap[K]>): void;
|
|
34
|
+
off<K extends BusEvents>(type: K, handler: BusHandler<BusEventMap[K]>): void;
|
|
35
|
+
}
|
|
36
|
+
export declare const bus: Bus;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type Method } from './request';
|
|
2
|
+
import { type Game, type GameWithManifest, type UpsertGameMetadataInput, type InsertReward, type UpdateReward } from '@playcademy/data/schemas';
|
|
3
|
+
import type { GameState, InventoryItemWithReward, ClientConfig, ClientEvents, LoginResponse, GameTokenResponse, StartSessionResponse, InventoryMutationResponse, DeveloperStatusValue } from '../types';
|
|
4
|
+
export declare class PlaycademyClient {
|
|
5
|
+
private baseUrl;
|
|
6
|
+
private token?;
|
|
7
|
+
private gameId?;
|
|
8
|
+
private listeners;
|
|
9
|
+
private internalClientSessionId?;
|
|
10
|
+
constructor(config: ClientConfig);
|
|
11
|
+
private _initializeInternalSession;
|
|
12
|
+
getBaseUrl(): string;
|
|
13
|
+
on<E extends keyof ClientEvents>(event: E, callback: (payload: ClientEvents[E]) => void): void;
|
|
14
|
+
private emit;
|
|
15
|
+
setToken(token: string | null): void;
|
|
16
|
+
onAuthChange(callback: (token: string | null) => void): void;
|
|
17
|
+
protected request<T>(path: string, method: Method, body?: unknown, headers?: Record<string, string>): Promise<T>;
|
|
18
|
+
private _ensureGameId;
|
|
19
|
+
auth: {
|
|
20
|
+
logout: () => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
runtime: {
|
|
23
|
+
getGameToken: (gameId: string, options?: {
|
|
24
|
+
apply?: boolean;
|
|
25
|
+
}) => Promise<GameTokenResponse>;
|
|
26
|
+
exit: () => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
games: {
|
|
29
|
+
fetch: (gameIdOrSlug: string) => Promise<GameWithManifest>;
|
|
30
|
+
list: () => Promise<Array<Game>>;
|
|
31
|
+
saveState: (state: Record<string, unknown>) => Promise<void>;
|
|
32
|
+
loadState: () => Promise<GameState>;
|
|
33
|
+
startSession: (gameId?: string) => Promise<StartSessionResponse>;
|
|
34
|
+
endSession: (sessionId: string, gameId?: string) => Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
users: {
|
|
37
|
+
me: () => Promise<{
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
username: string | null;
|
|
41
|
+
email: string;
|
|
42
|
+
emailVerified: boolean;
|
|
43
|
+
image: string | null;
|
|
44
|
+
role: "admin" | "player" | "developer";
|
|
45
|
+
developerStatus: "none" | "pending" | "approved";
|
|
46
|
+
createdAt: Date;
|
|
47
|
+
updatedAt: Date;
|
|
48
|
+
}>;
|
|
49
|
+
inventory: {
|
|
50
|
+
get: () => Promise<InventoryItemWithReward[]>;
|
|
51
|
+
add: (rewardId: string, qty: number) => Promise<InventoryMutationResponse>;
|
|
52
|
+
spend: (rewardId: string, qty: number) => Promise<InventoryMutationResponse>;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
dev: {
|
|
56
|
+
auth: {
|
|
57
|
+
applyForDeveloper: () => Promise<void>;
|
|
58
|
+
getDeveloperStatus: () => Promise<DeveloperStatusValue>;
|
|
59
|
+
};
|
|
60
|
+
games: {
|
|
61
|
+
upsert: (slug: string, metadata: UpsertGameMetadataInput, file: File | Blob) => Promise<Game>;
|
|
62
|
+
update: (gameId: string, props: Partial<Game>) => Promise<void>;
|
|
63
|
+
delete: (gameId: string) => Promise<void>;
|
|
64
|
+
};
|
|
65
|
+
keys: {
|
|
66
|
+
createKey: (gameId: string, label?: string) => Promise<{
|
|
67
|
+
id: string;
|
|
68
|
+
createdAt: Date;
|
|
69
|
+
userId: string;
|
|
70
|
+
label: string | null;
|
|
71
|
+
keyHash: string;
|
|
72
|
+
}>;
|
|
73
|
+
listKeys: (gameId: string) => Promise<{
|
|
74
|
+
id: string;
|
|
75
|
+
createdAt: Date;
|
|
76
|
+
userId: string;
|
|
77
|
+
label: string | null;
|
|
78
|
+
keyHash: string;
|
|
79
|
+
}[]>;
|
|
80
|
+
revokeKey: (keyId: string) => Promise<void>;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
maps: {
|
|
84
|
+
elements: (mapId: string) => Promise<{
|
|
85
|
+
id: string;
|
|
86
|
+
mapId: string | null;
|
|
87
|
+
elementSlug: string;
|
|
88
|
+
interactionType: "game_entry" | "game_registry" | "info" | "teleport" | "door_in" | "door_out" | "npc_interaction" | "quest_trigger";
|
|
89
|
+
metadata: ({
|
|
90
|
+
description?: string | undefined;
|
|
91
|
+
sourceTiledObjects?: Record<string, unknown>[] | undefined;
|
|
92
|
+
} & {
|
|
93
|
+
[k: string]: unknown;
|
|
94
|
+
}) | null;
|
|
95
|
+
gameId: string | null;
|
|
96
|
+
}[]>;
|
|
97
|
+
};
|
|
98
|
+
admin: {
|
|
99
|
+
games: {
|
|
100
|
+
pauseGame: (gameId: string) => Promise<void>;
|
|
101
|
+
resumeGame: (gameId: string) => Promise<void>;
|
|
102
|
+
};
|
|
103
|
+
rewards: {
|
|
104
|
+
createReward: (props: InsertReward) => Promise<{
|
|
105
|
+
id: string;
|
|
106
|
+
type: "currency" | "badge" | "trophy" | "unlock" | "upgrade" | "other";
|
|
107
|
+
displayName: string;
|
|
108
|
+
description: string | null;
|
|
109
|
+
metadata: unknown;
|
|
110
|
+
internalName: string;
|
|
111
|
+
}>;
|
|
112
|
+
getReward: (rewardId: string) => Promise<{
|
|
113
|
+
id: string;
|
|
114
|
+
type: "currency" | "badge" | "trophy" | "unlock" | "upgrade" | "other";
|
|
115
|
+
displayName: string;
|
|
116
|
+
description: string | null;
|
|
117
|
+
metadata: unknown;
|
|
118
|
+
internalName: string;
|
|
119
|
+
}>;
|
|
120
|
+
listRewards: () => Promise<{
|
|
121
|
+
id: string;
|
|
122
|
+
type: "currency" | "badge" | "trophy" | "unlock" | "upgrade" | "other";
|
|
123
|
+
displayName: string;
|
|
124
|
+
description: string | null;
|
|
125
|
+
metadata: unknown;
|
|
126
|
+
internalName: string;
|
|
127
|
+
}[]>;
|
|
128
|
+
updateReward: (rewardId: string, props: UpdateReward) => Promise<{
|
|
129
|
+
id: string;
|
|
130
|
+
type: "currency" | "badge" | "trophy" | "unlock" | "upgrade" | "other";
|
|
131
|
+
displayName: string;
|
|
132
|
+
description: string | null;
|
|
133
|
+
metadata: unknown;
|
|
134
|
+
internalName: string;
|
|
135
|
+
}>;
|
|
136
|
+
deleteReward: (rewardId: string) => Promise<void>;
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
telemetry: {
|
|
140
|
+
pushMetrics: (metrics: Record<string, number>) => Promise<void>;
|
|
141
|
+
};
|
|
142
|
+
ping(): string;
|
|
143
|
+
static login(baseUrl: string, email: string, password: string): Promise<LoginResponse>;
|
|
144
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base error class for Cademy SDK specific errors.
|
|
3
|
+
*/
|
|
4
|
+
export declare class PlaycademyError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class ApiError extends Error {
|
|
8
|
+
status: number;
|
|
9
|
+
details: unknown;
|
|
10
|
+
constructor(status: number, message: string, details: unknown);
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ManifestV1 } from '@playcademy/data/schemas';
|
|
2
|
+
/** Permitted HTTP verbs */
|
|
3
|
+
export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
4
|
+
export interface RequestOptions {
|
|
5
|
+
path: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
token?: string | null;
|
|
8
|
+
method?: Method;
|
|
9
|
+
body?: unknown;
|
|
10
|
+
extraHeaders?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Thin wrapper around `fetch` that:
|
|
14
|
+
* • attaches Bearer token if provided
|
|
15
|
+
* • stringifies JSON bodies and sets Content‑Type
|
|
16
|
+
* • passes FormData untouched so the browser adds multipart boundary
|
|
17
|
+
* • normalises non‑2xx responses into ApiError
|
|
18
|
+
* • auto‑parses JSON responses, falls back to text/void
|
|
19
|
+
*/
|
|
20
|
+
export declare function request<T = unknown>({ path, baseUrl, token, method, body, extraHeaders, }: RequestOptions): Promise<T>;
|
|
21
|
+
/**
|
|
22
|
+
* Fetches, parses, and validates the playcademy.manifest.json file from a given base URL.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fetchManifest(assetBundleBase: string): Promise<ManifestV1>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { PlaycademyClient } from './core/client';
|
|
2
|
+
/** For Node, SSR, dashboards, etc. */
|
|
3
|
+
export { PlaycademyClient } from './core/client';
|
|
4
|
+
/** Factory for code running *inside* the CADEMY loader (games) */
|
|
5
|
+
export declare function initFromWindow(): Promise<PlaycademyClient>;
|
|
6
|
+
export { bus, BusEvents } from './bus';
|
|
7
|
+
export { PlaycademyError } from './core/errors';
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// src/bus.ts
|
|
2
|
+
var BusEvents;
|
|
3
|
+
((BusEvents2) => {
|
|
4
|
+
BusEvents2["INIT"] = "PLAYCADEMY_INIT";
|
|
5
|
+
BusEvents2["TOKEN_REFRESH"] = "PLAYCADEMY_TOKEN_REFRESH";
|
|
6
|
+
BusEvents2["PAUSE"] = "PLAYCADEMY_PAUSE";
|
|
7
|
+
BusEvents2["RESUME"] = "PLAYCADEMY_RESUME";
|
|
8
|
+
BusEvents2["FORCE_EXIT"] = "PLAYCADEMY_FORCE_EXIT";
|
|
9
|
+
BusEvents2["OVERLAY"] = "PLAYCADEMY_OVERLAY";
|
|
10
|
+
BusEvents2["READY"] = "PLAYCADEMY_READY";
|
|
11
|
+
BusEvents2["EXIT"] = "PLAYCADEMY_EXIT";
|
|
12
|
+
BusEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
|
|
13
|
+
})(BusEvents ||= {});
|
|
14
|
+
var busListeners = new Map;
|
|
15
|
+
var bus = {
|
|
16
|
+
emit(type, payload) {
|
|
17
|
+
window.dispatchEvent(new CustomEvent(type, { detail: payload }));
|
|
18
|
+
},
|
|
19
|
+
on(type, handler) {
|
|
20
|
+
const listener = (event) => handler(event.detail);
|
|
21
|
+
if (!busListeners.has(type)) {
|
|
22
|
+
busListeners.set(type, new Map);
|
|
23
|
+
}
|
|
24
|
+
busListeners.get(type).set(handler, listener);
|
|
25
|
+
window.addEventListener(type, listener);
|
|
26
|
+
},
|
|
27
|
+
off(type, handler) {
|
|
28
|
+
const typeListeners = busListeners.get(type);
|
|
29
|
+
if (!typeListeners || !typeListeners.has(handler)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const actualListener = typeListeners.get(handler);
|
|
33
|
+
window.removeEventListener(type, actualListener);
|
|
34
|
+
typeListeners.delete(handler);
|
|
35
|
+
if (typeListeners.size === 0) {
|
|
36
|
+
busListeners.delete(type);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/core/errors.ts
|
|
42
|
+
class PlaycademyError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "PlaycademyError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class ApiError extends Error {
|
|
50
|
+
status;
|
|
51
|
+
details;
|
|
52
|
+
constructor(status, message, details) {
|
|
53
|
+
super(`${status} ${message}`);
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.details = details;
|
|
56
|
+
Object.setPrototypeOf(this, ApiError.prototype);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/core/request.ts
|
|
61
|
+
import { ManifestV1Schema } from "@playcademy/data/schemas";
|
|
62
|
+
async function request({
|
|
63
|
+
path,
|
|
64
|
+
baseUrl,
|
|
65
|
+
token,
|
|
66
|
+
method = "GET",
|
|
67
|
+
body,
|
|
68
|
+
extraHeaders = {}
|
|
69
|
+
}) {
|
|
70
|
+
const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
|
|
71
|
+
const headers = { ...extraHeaders };
|
|
72
|
+
let payload;
|
|
73
|
+
if (body instanceof FormData) {
|
|
74
|
+
payload = body;
|
|
75
|
+
} else if (body !== undefined && body !== null) {
|
|
76
|
+
payload = JSON.stringify(body);
|
|
77
|
+
headers["Content-Type"] = "application/json";
|
|
78
|
+
}
|
|
79
|
+
if (token)
|
|
80
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
method,
|
|
83
|
+
headers,
|
|
84
|
+
body: payload,
|
|
85
|
+
credentials: "omit"
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
const errorBody = await res.clone().json().catch(() => res.text().catch(() => {
|
|
89
|
+
return;
|
|
90
|
+
})) ?? undefined;
|
|
91
|
+
throw new ApiError(res.status, res.statusText, errorBody);
|
|
92
|
+
}
|
|
93
|
+
if (res.status === 204)
|
|
94
|
+
return;
|
|
95
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
96
|
+
if (contentType.includes("application/json")) {
|
|
97
|
+
return await res.json();
|
|
98
|
+
}
|
|
99
|
+
return await res.text();
|
|
100
|
+
}
|
|
101
|
+
async function fetchManifest(assetBundleBase) {
|
|
102
|
+
const manifestUrl = `${assetBundleBase.replace(/\/$/, "")}/playcademy.manifest.json`;
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(manifestUrl);
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
console.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
|
|
107
|
+
throw new PlaycademyError(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
|
|
108
|
+
}
|
|
109
|
+
const manifestJson = await response.json();
|
|
110
|
+
const validationResult = ManifestV1Schema.safeParse(manifestJson);
|
|
111
|
+
if (!validationResult.success) {
|
|
112
|
+
console.error(`[fetchManifest] Invalid manifest file at ${manifestUrl}: `, validationResult.error.flatten());
|
|
113
|
+
throw new PlaycademyError("Invalid manifest file format");
|
|
114
|
+
}
|
|
115
|
+
return validationResult.data;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof PlaycademyError) {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
console.error(`[fetchManifest] Error fetching or parsing manifest from ${manifestUrl}:`, error);
|
|
121
|
+
throw new PlaycademyError("Failed to load or parse game manifest");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/core/client.ts
|
|
126
|
+
class PlaycademyClient {
|
|
127
|
+
baseUrl;
|
|
128
|
+
token;
|
|
129
|
+
gameId;
|
|
130
|
+
listeners = {};
|
|
131
|
+
internalClientSessionId;
|
|
132
|
+
constructor(config) {
|
|
133
|
+
this.baseUrl = config.baseUrl;
|
|
134
|
+
this.token = config.token;
|
|
135
|
+
this.gameId = config.gameId;
|
|
136
|
+
if (this.gameId) {
|
|
137
|
+
this._initializeInternalSession().catch((error) => {
|
|
138
|
+
console.error("[SDK] Background initialization of auto-session failed:", error);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async _initializeInternalSession() {
|
|
143
|
+
if (!this.gameId || this.internalClientSessionId) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const response = await this.games.startSession(this.gameId);
|
|
148
|
+
this.internalClientSessionId = response.sessionId;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error("[SDK] Auto-starting session failed for game", this.gameId, error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
getBaseUrl() {
|
|
154
|
+
const isRelative = this.baseUrl.startsWith("/");
|
|
155
|
+
const isBrowser = typeof window !== "undefined";
|
|
156
|
+
return isRelative && isBrowser ? `${window.location.origin}${this.baseUrl}` : this.baseUrl;
|
|
157
|
+
}
|
|
158
|
+
on(event, callback) {
|
|
159
|
+
this.listeners[event] = this.listeners[event] ?? [];
|
|
160
|
+
this.listeners[event].push(callback);
|
|
161
|
+
}
|
|
162
|
+
emit(event, payload) {
|
|
163
|
+
(this.listeners[event] ?? []).forEach((listener) => {
|
|
164
|
+
listener(payload);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
setToken(token) {
|
|
168
|
+
this.token = token ?? undefined;
|
|
169
|
+
this.emit("authChange", { token: this.token ?? null });
|
|
170
|
+
}
|
|
171
|
+
onAuthChange(callback) {
|
|
172
|
+
this.on("authChange", (payload) => callback(payload.token));
|
|
173
|
+
}
|
|
174
|
+
async request(path, method, body, headers) {
|
|
175
|
+
const effectiveHeaders = { ...headers };
|
|
176
|
+
return request({
|
|
177
|
+
path,
|
|
178
|
+
method,
|
|
179
|
+
body,
|
|
180
|
+
baseUrl: this.baseUrl,
|
|
181
|
+
token: this.token,
|
|
182
|
+
extraHeaders: effectiveHeaders
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
_ensureGameId() {
|
|
186
|
+
if (!this.gameId) {
|
|
187
|
+
throw new PlaycademyError("This operation requires a gameId, but none was provided when initializing the client.");
|
|
188
|
+
}
|
|
189
|
+
return this.gameId;
|
|
190
|
+
}
|
|
191
|
+
auth = {
|
|
192
|
+
logout: async () => {
|
|
193
|
+
await this.request(`/auth/logout`, "DELETE");
|
|
194
|
+
this.setToken(null);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
runtime = {
|
|
198
|
+
getGameToken: async (gameId, options) => {
|
|
199
|
+
const res = await this.request(`/games/${gameId}/token`, "POST");
|
|
200
|
+
if (options?.apply) {
|
|
201
|
+
this.setToken(res.token);
|
|
202
|
+
}
|
|
203
|
+
return res;
|
|
204
|
+
},
|
|
205
|
+
exit: async () => {
|
|
206
|
+
if (this.internalClientSessionId && this.gameId) {
|
|
207
|
+
try {
|
|
208
|
+
await this.games.endSession(this.internalClientSessionId, this.gameId);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error("[SDK] Failed to auto-end session:", this.internalClientSessionId, error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
bus.emit("PLAYCADEMY_EXIT" /* EXIT */, undefined);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
games = {
|
|
217
|
+
fetch: async (gameIdOrSlug) => {
|
|
218
|
+
const baseGameData = await this.request(`/games/${gameIdOrSlug}`, "GET");
|
|
219
|
+
const manifestData = await fetchManifest(baseGameData.assetBundleBase);
|
|
220
|
+
return {
|
|
221
|
+
...baseGameData,
|
|
222
|
+
manifest: manifestData
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
list: () => this.request("/games", "GET"),
|
|
226
|
+
saveState: async (state) => {
|
|
227
|
+
const gameId = this._ensureGameId();
|
|
228
|
+
await this.request(`/games/${gameId}/state`, "POST", state);
|
|
229
|
+
},
|
|
230
|
+
loadState: async () => {
|
|
231
|
+
const gameId = this._ensureGameId();
|
|
232
|
+
return this.request(`/games/${gameId}/state`, "GET");
|
|
233
|
+
},
|
|
234
|
+
startSession: async (gameId) => {
|
|
235
|
+
const idToUse = gameId ?? this._ensureGameId();
|
|
236
|
+
return this.request(`/games/${idToUse}/sessions`, "POST", {});
|
|
237
|
+
},
|
|
238
|
+
endSession: async (sessionId, gameId) => {
|
|
239
|
+
const effectiveGameIdToEnd = gameId ?? this._ensureGameId();
|
|
240
|
+
if (this.internalClientSessionId && sessionId === this.internalClientSessionId && effectiveGameIdToEnd === this.gameId) {
|
|
241
|
+
this.internalClientSessionId = undefined;
|
|
242
|
+
}
|
|
243
|
+
await this.request(`/games/${effectiveGameIdToEnd}/sessions/${sessionId}/end`, "POST");
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
users = {
|
|
247
|
+
me: async () => {
|
|
248
|
+
return this.request("/users/me", "GET");
|
|
249
|
+
},
|
|
250
|
+
inventory: {
|
|
251
|
+
get: async () => this.request(`/inventory`, "GET"),
|
|
252
|
+
add: async (rewardId, qty) => {
|
|
253
|
+
const res = await this.request(`/inventory/add`, "POST", { rewardId, qty });
|
|
254
|
+
this.emit("inventoryChange", {
|
|
255
|
+
rewardId,
|
|
256
|
+
delta: qty,
|
|
257
|
+
newTotal: res.newTotal
|
|
258
|
+
});
|
|
259
|
+
return res;
|
|
260
|
+
},
|
|
261
|
+
spend: async (rewardId, qty) => {
|
|
262
|
+
const res = await this.request(`/inventory/spend`, "POST", { rewardId, qty });
|
|
263
|
+
this.emit("inventoryChange", {
|
|
264
|
+
rewardId,
|
|
265
|
+
delta: -qty,
|
|
266
|
+
newTotal: res.newTotal
|
|
267
|
+
});
|
|
268
|
+
return res;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
dev = {
|
|
273
|
+
auth: {
|
|
274
|
+
applyForDeveloper: () => this.request("/dev/apply", "POST"),
|
|
275
|
+
getDeveloperStatus: async () => {
|
|
276
|
+
const response = await this.request("/dev/status", "GET");
|
|
277
|
+
return response.status;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
games: {
|
|
281
|
+
upsert: (slug, metadata, file) => {
|
|
282
|
+
const form = new FormData;
|
|
283
|
+
form.append("metadata", JSON.stringify(metadata));
|
|
284
|
+
form.append("file", file);
|
|
285
|
+
return this.request(`/games/${slug}`, "PUT", form);
|
|
286
|
+
},
|
|
287
|
+
update: (gameId, props) => this.request(`/games/${gameId}`, "PATCH", props),
|
|
288
|
+
delete: (gameId) => this.request(`/games/${gameId}`, "DELETE")
|
|
289
|
+
},
|
|
290
|
+
keys: {
|
|
291
|
+
createKey: (gameId, label) => this.request(`/dev/games/${gameId}/keys`, "POST", { label }),
|
|
292
|
+
listKeys: (gameId) => this.request(`/dev/games/${gameId}/keys`, "GET"),
|
|
293
|
+
revokeKey: (keyId) => this.request(`/dev/keys/${keyId}`, "DELETE")
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
maps = {
|
|
297
|
+
elements: (mapId) => this.request(`/map/elements?mapId=${mapId}`, "GET")
|
|
298
|
+
};
|
|
299
|
+
admin = {
|
|
300
|
+
games: {
|
|
301
|
+
pauseGame: (gameId) => this.request(`/admin/games/${gameId}/pause`, "POST"),
|
|
302
|
+
resumeGame: (gameId) => this.request(`/admin/games/${gameId}/resume`, "POST")
|
|
303
|
+
},
|
|
304
|
+
rewards: {
|
|
305
|
+
createReward: (props) => this.request("/rewards", "POST", props),
|
|
306
|
+
getReward: (rewardId) => this.request(`/rewards/${rewardId}`, "GET"),
|
|
307
|
+
listRewards: () => this.request("/rewards", "GET"),
|
|
308
|
+
updateReward: (rewardId, props) => this.request(`/rewards/${rewardId}`, "PATCH", props),
|
|
309
|
+
deleteReward: (rewardId) => this.request(`/rewards/${rewardId}`, "DELETE")
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
telemetry = {
|
|
313
|
+
pushMetrics: (metrics) => this.request(`/telemetry/metrics`, "POST", metrics)
|
|
314
|
+
};
|
|
315
|
+
ping() {
|
|
316
|
+
return "pong";
|
|
317
|
+
}
|
|
318
|
+
static async login(baseUrl, email, password) {
|
|
319
|
+
let url = baseUrl;
|
|
320
|
+
if (baseUrl.startsWith("/") && typeof window !== "undefined") {
|
|
321
|
+
url = window.location.origin + baseUrl;
|
|
322
|
+
}
|
|
323
|
+
url = url + "/auth/login";
|
|
324
|
+
const response = await fetch(url, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: {
|
|
327
|
+
"Content-Type": "application/json"
|
|
328
|
+
},
|
|
329
|
+
body: JSON.stringify({ email, password })
|
|
330
|
+
});
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
try {
|
|
333
|
+
const errorData = await response.json();
|
|
334
|
+
const errorMessage = errorData && errorData.message ? String(errorData.message) : response.statusText;
|
|
335
|
+
throw new PlaycademyError(errorMessage);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error("[SDK] Failed to parse error response JSON, using status text instead:", error);
|
|
338
|
+
throw new PlaycademyError(response.statusText);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return response.json();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/runtime.ts
|
|
346
|
+
async function initFromWindow() {
|
|
347
|
+
if (typeof window === "undefined") {
|
|
348
|
+
throw new Error("initFromWindow must run in a browser context");
|
|
349
|
+
}
|
|
350
|
+
const preloaded = window.PLAYCADEMY;
|
|
351
|
+
const config = preloaded?.token ? preloaded : await new Promise((resolve) => bus.on("PLAYCADEMY_INIT" /* INIT */, resolve));
|
|
352
|
+
const client = new PlaycademyClient({
|
|
353
|
+
baseUrl: config.baseUrl,
|
|
354
|
+
token: config.token,
|
|
355
|
+
gameId: config.gameId
|
|
356
|
+
});
|
|
357
|
+
bus.on("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, ({ token }) => client.setToken(token));
|
|
358
|
+
bus.emit("PLAYCADEMY_READY" /* READY */, undefined);
|
|
359
|
+
if (import.meta.env?.MODE === "development") {
|
|
360
|
+
window.PLAYCADEMY_CLIENT = client;
|
|
361
|
+
}
|
|
362
|
+
return client;
|
|
363
|
+
}
|
|
364
|
+
export {
|
|
365
|
+
initFromWindow,
|
|
366
|
+
bus,
|
|
367
|
+
PlaycademyError,
|
|
368
|
+
PlaycademyClient,
|
|
369
|
+
BusEvents
|
|
370
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { User, InventoryItemWithReward, Game, DeveloperKey, DeveloperStatusResponse, MapElement, Reward, InsertReward, ManifestV1, UpdateReward } from '@playcademy/data/schemas';
|
|
2
|
+
export interface ClientConfig {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
gameId?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ClientEvents {
|
|
8
|
+
authChange: {
|
|
9
|
+
token: string | null;
|
|
10
|
+
};
|
|
11
|
+
inventoryChange: {
|
|
12
|
+
rewardId: string;
|
|
13
|
+
delta: number;
|
|
14
|
+
newTotal: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export type GameContextPayload = {
|
|
18
|
+
token: string;
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
gameId: string;
|
|
21
|
+
};
|
|
22
|
+
export type EventListeners = {
|
|
23
|
+
[E in keyof ClientEvents]?: Array<(payload: ClientEvents[E]) => void>;
|
|
24
|
+
};
|
|
25
|
+
export type GameWithManifest = Game & {
|
|
26
|
+
manifest: ManifestV1;
|
|
27
|
+
};
|
|
28
|
+
export type DeveloperStatusValue = DeveloperStatusResponse['status'];
|
|
29
|
+
export type GameState = Record<string, unknown>;
|
|
30
|
+
export type LoginResponse = {
|
|
31
|
+
token: string;
|
|
32
|
+
};
|
|
33
|
+
export type GameTokenResponse = {
|
|
34
|
+
token: string;
|
|
35
|
+
exp: number;
|
|
36
|
+
};
|
|
37
|
+
export type StartSessionResponse = {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
};
|
|
40
|
+
export type InventoryMutationResponse = {
|
|
41
|
+
newTotal: number;
|
|
42
|
+
};
|
|
43
|
+
export type { User, InventoryItemWithReward, Game, ManifestV1, DeveloperKey, DeveloperStatusResponse, MapElement, Reward, InsertReward, UpdateReward, };
|
package/dist/types.js
ADDED
|
File without changes
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@playcademy/sdk",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1-beta.1",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./dist/runtime.js",
|
|
8
|
+
"require": "./dist/runtime.js",
|
|
9
|
+
"types": "./dist/runtime.d.ts"
|
|
10
|
+
},
|
|
11
|
+
"./types": {
|
|
12
|
+
"import": "./src/types.ts",
|
|
13
|
+
"require": "./src/types.ts",
|
|
14
|
+
"types": "./src/types.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"main": "dist/runtime.js",
|
|
18
|
+
"module": "dist/runtime.js",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"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"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "latest",
|
|
31
|
+
"typescript": "^5.0.0",
|
|
32
|
+
"yocto-spinner": "^0.2.1"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"typescript": "^5"
|
|
36
|
+
}
|
|
37
|
+
}
|