castle-web-sdk 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,321 @@
1
+ # Castle Web SDK Reference
2
+
3
+ `castle-web-sdk` lets a deck use services provided by the Castle
4
+ platform — for example, a deck can save per-player data, post and read
5
+ scores on a leaderboard, or get a server-synced time for daily content.
6
+
7
+ Comes with `castle-web init`. Import what you need from
8
+ `castle-web-sdk`:
9
+
10
+ ```js
11
+ import { setup, initCard, Storage, Leaderboard } from "castle-web-sdk";
12
+ ```
13
+
14
+ ## Contents
15
+
16
+ - [Storage](#storage)
17
+ - [SharedStorage](#sharedstorage)
18
+ - [Leaderboard](#leaderboard)
19
+ - [Time](#time)
20
+ - [User](#user)
21
+ - [Pass](#pass)
22
+ - [Setup](#setup)
23
+ - [CastleError](#castleerror)
24
+
25
+ ## Storage
26
+
27
+ `Storage` saves data for the current player. Nobody else can read it.
28
+ Use it for save files, settings, progress.
29
+
30
+ ### `Storage.get<T>(key): Promise<T | null>`
31
+
32
+ Returns the value at `key`, or `null` if not set.
33
+
34
+ ```js
35
+ const level = (await Storage.get("level")) ?? 1;
36
+ ```
37
+
38
+ ### `Storage.set(key, value)`
39
+
40
+ Sets `key` to `value`. `value` must be something that can convert to
41
+ JSON (`null`, booleans, finite numbers, strings, arrays, plain
42
+ objects). The next `get(key)` returns the new value immediately. Writes
43
+ save in the background.
44
+
45
+ ```js
46
+ Storage.set("level", 7);
47
+ Storage.set("settings", { sound: true, music: false });
48
+ ```
49
+
50
+ ### `Storage.remove(key)`
51
+
52
+ Removes `key`.
53
+
54
+ ## SharedStorage
55
+
56
+ `SharedStorage` saves data that other players can read. Values must
57
+ be something that can convert to JSON, same as `Storage`.
58
+
59
+ Scopes:
60
+
61
+ - `'deck'` — one shared bucket for the whole deck. Any player can read
62
+ or write.
63
+ - `'user'` — a per-player public bucket. Any player can read; only the
64
+ owning player can write.
65
+
66
+ ### `SharedStorage.get(scope, key): Promise<T | null>`
67
+
68
+ Reads a shared value. For `'user'`, omit the user id to read the
69
+ current player's bucket, or pass one to read someone else's:
70
+
71
+ ```js
72
+ const worldHighScore = await SharedStorage.get("deck", "highScore");
73
+ const myColor = await SharedStorage.get("user", "color");
74
+ const theirColor = await SharedStorage.get("user", otherUserId, "color");
75
+ ```
76
+
77
+ ### `SharedStorage.set(scope, key, value)`
78
+
79
+ Writes a shared value. `'user'` writes always go to the current
80
+ player's bucket. Writes save in the background.
81
+
82
+ ```js
83
+ SharedStorage.set("deck", "highScore", 9001);
84
+ SharedStorage.set("user", "color", "red");
85
+ ```
86
+
87
+ ### `SharedStorage.remove(scope, key)`
88
+
89
+ Removes a shared value.
90
+
91
+ ## Leaderboard
92
+
93
+ `Leaderboard` ranks players by a numeric score, per deck and per
94
+ variable name. Pick a variable name for each leaderboard the deck has
95
+ (e.g. `'score'`, `'time'`).
96
+
97
+ ### `Leaderboard.write(variable, score, options?)`
98
+
99
+ Submits a score. Only the player's best score for that `variable` (and
100
+ scope) is kept. In the editor it does nothing — safe to call from
101
+ gameplay code unconditionally.
102
+
103
+ `options` is `{ scope?: string }`. By default the score goes to the
104
+ deck's global leaderboard for `variable`; pass `scope` to write to a
105
+ separate leaderboard (for example a daily one, or a custom id):
106
+
107
+ ```js
108
+ Leaderboard.write("score", 1200);
109
+
110
+ // A daily leaderboard: scope by the current Castle day so each day gets
111
+ // its own board (see Time.getServerDate).
112
+ const { daysSinceCastleEpoch } = await Time.getServerDate();
113
+ Leaderboard.write("score", 1200, { scope: `daily-${daysSinceCastleEpoch}` });
114
+ ```
115
+
116
+ ### `Leaderboard.fetch(variable, type, options?): Promise<LeaderboardData>`
117
+
118
+ Fetches the leaderboard for `variable`. `type` is `'high'` (highest
119
+ first) or `'low'` (lowest first). `options.scope` works the same as in
120
+ `write`.
121
+
122
+ If the player has written a score this session, a `fetch` reflects
123
+ **their own** new score right away — you can `write` then `fetch` and
124
+ show the result without waiting. Other players' recent scores still
125
+ appear on their own normal timing.
126
+
127
+ The returned `LeaderboardData` has:
128
+
129
+ - `list` — array of entries, each `{ place, value, username, userId? }`.
130
+ - `playerRank` — the current player's place on the board (if they have
131
+ a score).
132
+ - `playerValue` — the current player's score (if they have one).
133
+
134
+ ```js
135
+ const data = await Leaderboard.fetch("score", "high");
136
+ for (const entry of data.list) {
137
+ console.log(`${entry.place}. ${entry.username} — ${entry.value}`);
138
+ }
139
+ if (data.playerRank) {
140
+ console.log(`you are #${data.playerRank} with ${data.playerValue}`);
141
+ }
142
+ ```
143
+
144
+ ## Time
145
+
146
+ ### `Time.getServerTime(): Promise<number>`
147
+
148
+ Returns the current server time as a Unix timestamp in seconds.
149
+
150
+ ```js
151
+ const now = await Time.getServerTime();
152
+ ```
153
+
154
+ ### `Time.getServerDate(timezone?): Promise<CastleDateParts>`
155
+
156
+ Returns the current server time broken into date parts. `timezone` is
157
+ `'Castle'` (default; Castle's server timezone, same for every player)
158
+ or `'player'` (the player's local timezone).
159
+
160
+ The returned `CastleDateParts` has:
161
+
162
+ - `sec`, `min`, `hour` — time of day.
163
+ - `day` (1-31), `month` (1-12), `year` — date.
164
+ - `wday` — day of the week (1-7, Sunday = 1).
165
+ - `yday` — day of the year (1-366).
166
+ - `daysSinceCastleEpoch` — a day number that increments every day.
167
+ Use it for daily content.
168
+
169
+ ```js
170
+ const date = await Time.getServerDate("player");
171
+ const dailyPuzzle = (date.daysSinceCastleEpoch % 30) + 1;
172
+ ```
173
+
174
+ ## User
175
+
176
+ ### `User.getCurrent(): Promise<CastleUser>`
177
+
178
+ Returns the signed-in player. Throws `CastleError`
179
+ (`LOGIN_REQUIRED`) when nobody is signed in.
180
+
181
+ The returned `CastleUser` has `userId`, `username`, and `isActive`.
182
+
183
+ ```js
184
+ const me = await User.getCurrent();
185
+ greet(me.username);
186
+ ```
187
+
188
+ ## Pass
189
+
190
+ A pass is something a creator sells to players for Castle bricks (the
191
+ in-app currency): buy it once, own it for good. Use one to gate part of a
192
+ deck behind a purchase — bonus levels, a cosmetic, supporting the
193
+ creator. Set up the pass (name, art, price) on Castle; a deck refers to
194
+ it by id.
195
+
196
+ ### `Pass.has(passId): Promise<boolean>`
197
+
198
+ Returns `true` if the current player owns the pass. No UI, nothing
199
+ charged — use it to gate content.
200
+
201
+ ```js
202
+ if (await Pass.has(bonusLevelsPassId)) {
203
+ showBonusLevels();
204
+ }
205
+ ```
206
+
207
+ ### `Pass.offer(passId): Promise<PassOfferResult>`
208
+
209
+ Presents the pass for the player to buy; resolves when they're done.
210
+ Bricks cost real money, so this only works in the Castle mobile app —
211
+ elsewhere (the website, the dev server) it resolves `unavailable`.
212
+ `PassOfferResult` has a `status`:
213
+
214
+ - `'purchased'` — just bought it; grant access.
215
+ - `'alreadyOwned'` — already had it (not charged); grant access.
216
+ - `'cancelled'` — dismissed without buying.
217
+ - `'unavailable'` — can't buy here (e.g. the website).
218
+
219
+ ```js
220
+ const { status } = await Pass.offer(bonusLevelsPassId);
221
+ if (status === "purchased" || status === "alreadyOwned") {
222
+ showBonusLevels();
223
+ }
224
+ ```
225
+
226
+ ## Setup
227
+
228
+ Startup, editor-mode check, and a file-write call for editor UI.
229
+
230
+ ### `setup()`
231
+
232
+ Call this once at the start of the deck, before any other SDK call.
233
+ `setup()` initializes the SDK so the rest of the API is usable and
234
+ mounts the centered 5:7 card shell around whatever the deck renders
235
+ into `#root` (when the deck is being played standalone in a browser).
236
+ While running locally with `castle-web serve`, it also forwards
237
+ `console` output to the CLI and reloads the page when `castle-web
238
+ restart` runs.
239
+
240
+ ```js
241
+ import { setup } from "castle-web-sdk";
242
+
243
+ setup();
244
+ ```
245
+
246
+ ### `initCard(): HTMLDivElement`
247
+
248
+ Use this when the deck draws into a `<canvas>` (or anything else)
249
+ rather than into the React tree at `#root`. Returns a centered,
250
+ viewport-sized `<div>` with the standard Castle 5:7 card aspect ratio.
251
+ The div resizes itself when the window resizes.
252
+
253
+ ```js
254
+ import { setup, initCard } from "castle-web-sdk";
255
+
256
+ setup();
257
+ const card = initCard();
258
+
259
+ const canvas = document.createElement("canvas");
260
+ canvas.style.cssText = "width: 100%; height: 100%; display: block;";
261
+ card.appendChild(canvas);
262
+ ```
263
+
264
+ If the deck mounts a React tree into `#root` instead, you don't need
265
+ `initCard()` — `setup()` already wraps `#root`'s children in a card.
266
+
267
+ ### `CARD_RATIO`
268
+
269
+ The card aspect ratio (`5 / 7`). Use this if you need to size something
270
+ to match the card.
271
+
272
+ ### `isEdit(): boolean`
273
+
274
+ `true` when the deck is being edited, `false` when it's being played.
275
+ Use this to show editor UI only in edit mode.
276
+
277
+ ```js
278
+ import { isEdit, setup } from "castle-web-sdk";
279
+
280
+ setup();
281
+ if (isEdit()) {
282
+ mountEditor();
283
+ } else {
284
+ startGame();
285
+ }
286
+ ```
287
+
288
+ ### `writeFile(path, contents): Promise<void>`
289
+
290
+ Writes a file in the deck directory. `path` is relative to the deck
291
+ root, `contents` is a string. Use this from editor UI to save scenes,
292
+ drawings, or generated source.
293
+
294
+ ```js
295
+ import { writeFile } from "castle-web-sdk";
296
+
297
+ await writeFile("scenes/main.scene", JSON.stringify(scene, null, 2));
298
+ ```
299
+
300
+ Only works while editing locally with `castle-web serve`. Calls from a
301
+ published deck fail.
302
+
303
+ ## CastleError
304
+
305
+ Every error the SDK throws is a `CastleError`. Check `code` to tell
306
+ the kinds apart.
307
+
308
+ Common codes:
309
+
310
+ - `LOGIN_REQUIRED` — the player needs to be signed in.
311
+ - `MISSING_DECK_ID` — the deck hasn't been saved to Castle yet, so it
312
+ has no id.
313
+ - `CASTLE_STORAGE_SERIALIZE_FAILED` — value isn't plain JSON (e.g. a
314
+ class instance, a function, a non-finite number, or a cycle).
315
+ - `INVALID_LEADERBOARD_VARIABLE`, `INVALID_LEADERBOARD_SCORE`,
316
+ `INVALID_LEADERBOARD_TYPE` — bad argument to a `Leaderboard` call.
317
+ - `UNSUPPORTED_TIMEZONE` — `Time.getServerDate` got a zone other than
318
+ `'Castle'` or `'player'`.
319
+ - `CASTLE_HOST_UNAVAILABLE` — the Castle host (the app or website running
320
+ the deck) didn't handle the request — e.g. it timed out or wasn't
321
+ reachable. Usually transient; retry or surface a gentle error.
package/dist/castle.d.ts CHANGED
@@ -2,6 +2,8 @@ export { isEdit } from "./context";
2
2
  export { CastleError } from "./errors";
3
3
  export { Leaderboard } from "./leaderboard";
4
4
  export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardSort, } from "./leaderboard";
5
+ export { Pass } from "./passes";
6
+ export type { CastlePassApi, PassOfferResult, PassOfferStatus, } from "./passes";
5
7
  export { CARD_RATIO, initCard, onBeforeRestart, setup, writeFile } from "./runtime";
6
8
  export { SharedStorage, Storage } from "./storage";
7
9
  export { Time } from "./time";
package/dist/castle.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export { isEdit } from "./context";
3
3
  export { CastleError } from "./errors";
4
4
  export { Leaderboard } from "./leaderboard";
5
+ export { Pass } from "./passes";
5
6
  export { CARD_RATIO, initCard, onBeforeRestart, setup, writeFile } from "./runtime";
6
7
  export { SharedStorage, Storage } from "./storage";
7
8
  export { Time } from "./time";
@@ -0,0 +1,117 @@
1
+ import type { Json } from "./types";
2
+ export declare const CASTLE_SDK_PROTOCOL = 1;
3
+ export type StorageBlob = Record<string, string>;
4
+ export type SharedScope = "deck" | "user";
5
+ export interface StorageUpdate {
6
+ key: string;
7
+ value: string | null;
8
+ }
9
+ export interface RawLeaderboardEntry {
10
+ place?: string | number | null;
11
+ score?: string | number | null;
12
+ user?: {
13
+ userId?: string | null;
14
+ username?: string | null;
15
+ } | null;
16
+ }
17
+ export interface RawLeaderboard {
18
+ list?: RawLeaderboardEntry[] | null;
19
+ yourScore?: {
20
+ score?: string | number | null;
21
+ } | null;
22
+ }
23
+ export type PassOfferStatus = "purchased" | "alreadyOwned" | "cancelled" | "unavailable";
24
+ export interface PassOfferResult {
25
+ status: PassOfferStatus;
26
+ }
27
+ export interface CommandParams {
28
+ "deckStorage.load": Record<string, never>;
29
+ "deckStorage.update": {
30
+ updates: StorageUpdate[];
31
+ };
32
+ "sharedDeckStorage.load": {
33
+ scope: SharedScope;
34
+ userId?: string | null;
35
+ keys: string[];
36
+ };
37
+ "sharedDeckStorage.update": {
38
+ scope: SharedScope;
39
+ updates: StorageUpdate[];
40
+ };
41
+ "leaderboard.fetch": {
42
+ variable: string;
43
+ type: "high" | "low";
44
+ scope?: string | null;
45
+ score?: number | null;
46
+ };
47
+ "leaderboard.save": {
48
+ variable: string;
49
+ score: number;
50
+ scope?: string | null;
51
+ };
52
+ "user.getCurrent": Record<string, never>;
53
+ "time.getServerTime": Record<string, never>;
54
+ "pass.has": {
55
+ passId: string;
56
+ };
57
+ "pass.offer": {
58
+ passId: string;
59
+ };
60
+ }
61
+ export interface CommandResult {
62
+ "deckStorage.load": {
63
+ blob: StorageBlob;
64
+ };
65
+ "deckStorage.update": {
66
+ blob: StorageBlob;
67
+ };
68
+ "sharedDeckStorage.load": {
69
+ blob: StorageBlob;
70
+ };
71
+ "sharedDeckStorage.update": {
72
+ ok: true;
73
+ };
74
+ "leaderboard.fetch": {
75
+ leaderboard: RawLeaderboard;
76
+ currentUserId: string | null;
77
+ };
78
+ "leaderboard.save": {
79
+ ok: true;
80
+ };
81
+ "user.getCurrent": {
82
+ user: {
83
+ userId: string;
84
+ username: string;
85
+ } | null;
86
+ };
87
+ "time.getServerTime": {
88
+ timestamp: number;
89
+ timezoneOffset: number;
90
+ castleEpochData: Json;
91
+ };
92
+ "pass.has": {
93
+ hasPass: boolean;
94
+ };
95
+ "pass.offer": PassOfferResult;
96
+ }
97
+ export type CommandName = keyof CommandParams;
98
+ export interface SerializedCommandError {
99
+ code: string;
100
+ message: string;
101
+ command?: string;
102
+ extensions?: Record<string, unknown>;
103
+ }
104
+ export interface CommandRequestEnvelope {
105
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
106
+ requestId: string;
107
+ command: CommandName;
108
+ params: unknown;
109
+ }
110
+ export interface CommandResponseEnvelope {
111
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
112
+ requestId: string;
113
+ ok: boolean;
114
+ data?: unknown;
115
+ error?: SerializedCommandError;
116
+ }
117
+ export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
@@ -0,0 +1,16 @@
1
+ // The SDK↔host command contract. This is the single source of truth for the
2
+ // wire protocol shared by the deck-side command-poster (`transport.ts`) and the
3
+ // host-side executor (`host.ts`). It carries ONLY names and types — no GraphQL
4
+ // query strings and no auth — so importing it into a deck bundle reveals
5
+ // nothing privileged.
6
+ // Marker + protocol version. Doubles as a discriminator so host messages can't
7
+ // be confused with embed.js `castlexyz:` strings or RN console messages.
8
+ export const CASTLE_SDK_PROTOCOL = 1;
9
+ export function isResponseEnvelope(value) {
10
+ if (typeof value !== "object" || value === null)
11
+ return false;
12
+ const record = value;
13
+ return (record.castleSdk === CASTLE_SDK_PROTOCOL &&
14
+ typeof record.requestId === "string" &&
15
+ typeof record.ok === "boolean");
16
+ }
package/dist/context.d.ts CHANGED
@@ -1,21 +1,8 @@
1
- export interface CastleDeckContext {
2
- deckId?: string | null;
3
- cardId?: string | null;
4
- sessionId?: string | null;
5
- }
6
- interface CastleEmbedAuth {
7
- token?: string | null;
8
- userId?: string | null;
9
- }
1
+ export type CastleHost = "web" | "mobile" | "dev";
10
2
  export interface CastleEmbed {
11
3
  edit?: boolean;
12
4
  feed?: boolean;
13
- auth?: CastleEmbedAuth;
14
- deck?: CastleDeckContext;
15
- deckId?: string;
16
- cardId?: string;
17
- sessionId?: string | null;
18
- graphqlEndpoint?: string;
5
+ host?: CastleHost;
19
6
  }
20
7
  declare global {
21
8
  interface Window {
@@ -24,7 +11,3 @@ declare global {
24
11
  }
25
12
  export declare function getCastleEmbed(): CastleEmbed | undefined;
26
13
  export declare function isEdit(): boolean;
27
- export declare function configureDeckContext(context: CastleDeckContext): void;
28
- export declare function getDeckContext(): Promise<CastleDeckContext>;
29
- export declare function requireDeckId(operation?: string): Promise<string>;
30
- export {};
package/dist/context.js CHANGED
@@ -1,7 +1,3 @@
1
- import { CastleError } from "./errors";
2
- let configuredDeckContext = {};
3
- let cachedDeckContext = null;
4
- let cachedDeckContextPromise = null;
5
1
  export function getCastleEmbed() {
6
2
  return typeof window === "undefined" ? undefined : window.CastleEmbed;
7
3
  }
@@ -17,70 +13,3 @@ export function isEdit() {
17
13
  }
18
14
  return !!getCastleEmbed()?.edit;
19
15
  }
20
- export function configureDeckContext(context) {
21
- configuredDeckContext = cleanDeckContext(context);
22
- cachedDeckContext = null;
23
- cachedDeckContextPromise = null;
24
- }
25
- export async function getDeckContext() {
26
- if (cachedDeckContext)
27
- return cachedDeckContext;
28
- cachedDeckContextPromise ??= resolveDeckContext();
29
- return cachedDeckContextPromise;
30
- }
31
- export async function requireDeckId(operation = "Castle API request") {
32
- const context = await getDeckContext();
33
- if (!context.deckId) {
34
- throw new CastleError({
35
- code: "MISSING_DECK_ID",
36
- message: "This Castle API needs a deck id.",
37
- operation,
38
- });
39
- }
40
- return context.deckId;
41
- }
42
- async function resolveDeckContext() {
43
- const embedded = embeddedDeckContext();
44
- const local = await localDeckContext();
45
- const context = mergeDeckContexts(configuredDeckContext, embedded, local);
46
- cachedDeckContext = context;
47
- return context;
48
- }
49
- function embeddedDeckContext() {
50
- const embed = getCastleEmbed();
51
- return cleanDeckContext({
52
- deckId: embed?.deck?.deckId ?? embed?.deckId,
53
- cardId: embed?.deck?.cardId ?? embed?.cardId,
54
- sessionId: embed?.deck?.sessionId ?? embed?.sessionId,
55
- });
56
- }
57
- async function localDeckContext() {
58
- try {
59
- const res = await fetch("/__castle/context");
60
- if (!res.ok)
61
- return {};
62
- return cleanDeckContext((await res.json()));
63
- }
64
- catch {
65
- return {};
66
- }
67
- }
68
- function cleanDeckContext(context) {
69
- return {
70
- deckId: stringOrNull(context.deckId),
71
- cardId: stringOrNull(context.cardId),
72
- sessionId: stringOrNull(context.sessionId),
73
- };
74
- }
75
- function mergeDeckContexts(...contexts) {
76
- return cleanDeckContext({
77
- deckId: contexts.find((context) => context.deckId)?.deckId,
78
- cardId: contexts.find((context) => context.cardId)?.cardId,
79
- sessionId: contexts.find((context) => context.sessionId)?.sessionId,
80
- });
81
- }
82
- function stringOrNull(value) {
83
- if (typeof value !== "string")
84
- return null;
85
- return value.length > 0 ? value : null;
86
- }