castle-web-sdk 0.4.3 → 0.4.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.
@@ -1,7 +1,10 @@
1
- import { getAuth, requireAuthToken } from "./auth";
2
- import { isEdit, requireDeckId } from "./context";
1
+ import {
2
+ type RawLeaderboard,
3
+ type RawLeaderboardEntry,
4
+ } from "./commands";
5
+ import { isEdit } from "./context";
3
6
  import { CastleError } from "./errors";
4
- import { graphqlRequest, type GraphqlVariables } from "./graphql";
7
+ import { hostRequest } from "./transport";
5
8
 
6
9
  export type LeaderboardSort = "high" | "low";
7
10
  export type LeaderboardScope = string;
@@ -23,17 +26,7 @@ export interface LeaderboardData {
23
26
  playerValue?: number;
24
27
  }
25
28
 
26
- interface LeaderboardRequest {
27
- deckId: string;
28
- variable: string;
29
- type: LeaderboardSort;
30
- scope: string | null;
31
- token: string;
32
- userId: string | null;
33
- }
34
-
35
29
  interface PendingLeaderboardWrite {
36
- deckId: string;
37
30
  variable: string;
38
31
  scope: string | null;
39
32
  highScore: number;
@@ -48,32 +41,10 @@ interface LeaderboardWriteJob {
48
41
  score: number;
49
42
  }
50
43
 
51
- interface GraphqlLeaderboardUser {
52
- userId?: string | null;
53
- username?: string | null;
54
- }
55
-
56
- interface GraphqlLeaderboardEntry {
57
- place?: string | number | null;
58
- score?: string | number | null;
59
- user?: GraphqlLeaderboardUser | null;
60
- }
61
-
62
- interface GraphqlLeaderboard {
63
- list?: GraphqlLeaderboardEntry[] | null;
64
- yourScore?: GraphqlLeaderboardEntry | null;
65
- }
66
-
67
- interface LeaderboardQueryData {
68
- leaderboard: GraphqlLeaderboard;
69
- }
70
-
71
- interface SaveLeaderboardData {
72
- saveVariableToLeaderboard: null;
73
- }
74
-
75
44
  const LEADERBOARD_FLUSH_INTERVAL_MS = 5000;
76
45
 
46
+ // Host stamps deckId, so one deck session = one set of leaderboards; key by
47
+ // variable+scope only and keep the best high/low score per key until flush.
77
48
  const leaderboardWrites = new Map<string, PendingLeaderboardWrite>();
78
49
  let leaderboardFlushTimer: ReturnType<typeof setTimeout> | null = null;
79
50
  let leaderboardFlushPromise: Promise<void> | null = null;
@@ -103,9 +74,11 @@ function writeLeaderboard(
103
74
  options: LeaderboardOptions,
104
75
  ): void {
105
76
  if (isEdit()) return;
106
- void bufferLeaderboardWrite(variable, score, options).catch(
107
- reportLeaderboardError,
108
- );
77
+ try {
78
+ bufferLeaderboardWrite(variable, score, options);
79
+ } catch (error) {
80
+ reportLeaderboardError(error);
81
+ }
109
82
  }
110
83
 
111
84
  async function fetchLeaderboardData(
@@ -113,47 +86,81 @@ async function fetchLeaderboardData(
113
86
  type: LeaderboardSort,
114
87
  options: LeaderboardOptions,
115
88
  ): Promise<LeaderboardData> {
116
- const request = await leaderboardRequest(
117
- "Leaderboard.fetch",
89
+ assertLeaderboardVariable(variable, "Leaderboard.fetch");
90
+ assertLeaderboardType(type, "Leaderboard.fetch");
91
+ const scope = leaderboardScope(options);
92
+ // If the deck has written a score for this variable+scope this session, send
93
+ // it so the host writes-and-reads via leaderboardV2 and the player's own
94
+ // score shows up immediately (mirrors getLeaderboard in
95
+ // core/src/leaderboards.cpp — presence of a buffered score, not dirtiness,
96
+ // gates the write-through). Otherwise a plain read of the settled board.
97
+ const record = leaderboardWrites.get(leaderboardWriteKey(variable, scope));
98
+ const score = record
99
+ ? type === "high"
100
+ ? record.highScore
101
+ : record.lowScore
102
+ : null;
103
+ const { leaderboard, currentUserId } = await hostRequest("leaderboard.fetch", {
118
104
  variable,
119
105
  type,
120
- options,
121
- );
122
- return normalizeLeaderboard(await fetchLeaderboard(request), request.userId);
106
+ scope,
107
+ ...(score === null ? {} : { score }),
108
+ });
109
+ if (record && score !== null) {
110
+ clearLeaderboardDirtyAfterFetch(record, type, score);
111
+ }
112
+ return normalizeLeaderboard(leaderboard, currentUserId);
123
113
  }
124
114
 
125
- async function bufferLeaderboardWrite(
115
+ // After a write-through fetch, clear the dirty flag for the side we just sent
116
+ // (so the periodic flush won't re-send it via saveVariableToLeaderboard). If
117
+ // the other side's buffered value matches what we sent (the common single-score
118
+ // case where high == low), clear it too. The equality guards skip clearing if a
119
+ // concurrent write bumped the buffered score while the fetch was in flight —
120
+ // that newer score still needs flushing. Mirrors leaderboards.cpp.
121
+ function clearLeaderboardDirtyAfterFetch(
122
+ record: PendingLeaderboardWrite,
123
+ type: LeaderboardSort,
124
+ sentScore: number,
125
+ ): void {
126
+ if (type === "high") {
127
+ if (record.highScore === sentScore) {
128
+ record.isHighDirty = false;
129
+ if (record.lowScore === sentScore) record.isLowDirty = false;
130
+ }
131
+ } else {
132
+ if (record.lowScore === sentScore) {
133
+ record.isLowDirty = false;
134
+ if (record.highScore === sentScore) record.isHighDirty = false;
135
+ }
136
+ }
137
+ }
138
+
139
+ function bufferLeaderboardWrite(
126
140
  variable: string,
127
141
  score: number,
128
142
  options: LeaderboardOptions,
129
- ): Promise<void> {
143
+ ): void {
130
144
  assertLeaderboardVariable(variable, "Leaderboard.write");
131
145
  assertLeaderboardScore(score, "Leaderboard.write");
132
- const deckId = await requireDeckId("Leaderboard.write");
133
- await requireAuthToken("Leaderboard.write");
134
146
  const scope = leaderboardScope(options);
135
- const key = leaderboardWriteKey(deckId, variable, scope);
147
+ const key = leaderboardWriteKey(variable, scope);
136
148
  const record = leaderboardWrites.get(key);
137
149
  if (record) {
138
150
  updatePendingLeaderboardWrite(record, score);
139
151
  } else {
140
- leaderboardWrites.set(
141
- key,
142
- newPendingLeaderboardWrite(deckId, variable, scope, score),
143
- );
152
+ leaderboardWrites.set(key, newPendingLeaderboardWrite(variable, scope, score));
144
153
  }
145
154
  ensureLeaderboardUnloadFlush();
146
155
  scheduleLeaderboardFlush();
147
156
  }
148
157
 
149
158
  function newPendingLeaderboardWrite(
150
- deckId: string,
151
159
  variable: string,
152
160
  scope: string | null,
153
161
  score: number,
154
162
  ): PendingLeaderboardWrite {
155
163
  return {
156
- deckId,
157
164
  variable,
158
165
  scope,
159
166
  highScore: score,
@@ -203,11 +210,8 @@ async function flushLeaderboardWrites(): Promise<void> {
203
210
  }
204
211
 
205
212
  async function flushLeaderboardWritesOnce(): Promise<void> {
206
- const jobs = leaderboardWriteJobs();
207
- if (jobs.length === 0) return;
208
- const token = await requireAuthToken("Leaderboard.write");
209
- for (const job of jobs) {
210
- await saveLeaderboardScore(job.record, job.score, token);
213
+ for (const job of leaderboardWriteJobs()) {
214
+ await saveLeaderboardScore(job.record, job.score);
211
215
  markLeaderboardWriteClean(job);
212
216
  }
213
217
  }
@@ -248,131 +252,19 @@ function hasDirtyLeaderboardWrites(): boolean {
248
252
  return false;
249
253
  }
250
254
 
251
- async function leaderboardRequest(
252
- operation: string,
253
- variable: string,
254
- type: LeaderboardSort,
255
- options: LeaderboardOptions,
256
- ): Promise<LeaderboardRequest> {
257
- assertLeaderboardVariable(variable, operation);
258
- assertLeaderboardType(type, operation);
259
- const [deckId, token, auth] = await Promise.all([
260
- requireDeckId(operation),
261
- requireAuthToken(operation),
262
- getAuth(),
263
- ]);
264
- return {
265
- deckId,
266
- variable,
267
- type,
268
- scope: leaderboardScope(options),
269
- token,
270
- userId: auth.userId ?? null,
271
- };
272
- }
273
-
274
- async function fetchLeaderboard(
275
- request: LeaderboardRequest,
276
- ): Promise<GraphqlLeaderboard> {
277
- const data = await graphqlRequest<LeaderboardQueryData>(
278
- `
279
- query CastleLeaderboard(
280
- $deckId: ID!
281
- $variable: String!
282
- $type: LeaderboardType!
283
- $filter: LeaderboardFilter!
284
- $includeFollowList: Boolean
285
- $includeParties: Boolean
286
- $scope: String
287
- ) {
288
- leaderboard(
289
- deckId: $deckId
290
- variable: $variable
291
- type: $type
292
- filter: $filter
293
- includeFollowList: $includeFollowList
294
- includeParties: $includeParties
295
- scope: $scope
296
- ) ${leaderboardFields()}
297
- }
298
- `,
299
- leaderboardVariables(request),
300
- {
301
- operation: "CastleLeaderboard",
302
- token: request.token,
303
- requireAuth: true,
304
- },
305
- );
306
- return data.leaderboard;
307
- }
308
-
309
255
  async function saveLeaderboardScore(
310
256
  record: PendingLeaderboardWrite,
311
257
  score: number,
312
- token: string,
313
258
  ): Promise<void> {
314
- await graphqlRequest<SaveLeaderboardData>(
315
- `
316
- mutation CastleSaveVariableToLeaderboard(
317
- $deckId: ID!
318
- $variable: String!
319
- $score: Float!
320
- $scope: String
321
- ) {
322
- saveVariableToLeaderboard(
323
- deckId: $deckId
324
- variable: $variable
325
- score: $score
326
- scope: $scope
327
- )
328
- }
329
- `,
330
- {
331
- deckId: record.deckId,
332
- variable: record.variable,
333
- score,
334
- scope: record.scope,
335
- },
336
- {
337
- operation: "CastleSaveVariableToLeaderboard",
338
- token,
339
- requireAuth: true,
340
- },
341
- );
342
- }
343
-
344
- function leaderboardVariables(request: LeaderboardRequest): GraphqlVariables {
345
- return {
346
- deckId: request.deckId,
347
- variable: request.variable,
348
- type: request.type,
349
- filter: "dedupUsers",
350
- includeFollowList: false,
351
- includeParties: false,
352
- scope: request.scope,
353
- };
354
- }
355
-
356
- function leaderboardFields(): string {
357
- return `{
358
- yourScore { score }
359
- list {
360
- place
361
- score
362
- user {
363
- userId
364
- username
365
- }
366
- }
367
- }`;
368
- }
369
-
370
- function leaderboardScope(options: LeaderboardOptions): string | null {
371
- return options.scope ?? null;
259
+ await hostRequest("leaderboard.save", {
260
+ variable: record.variable,
261
+ score,
262
+ scope: record.scope,
263
+ });
372
264
  }
373
265
 
374
266
  function normalizeLeaderboard(
375
- leaderboard: GraphqlLeaderboard,
267
+ leaderboard: RawLeaderboard,
376
268
  userId: string | null,
377
269
  ): LeaderboardData {
378
270
  const list = (leaderboard.list ?? []).map(normalizeLeaderboardEntry);
@@ -386,7 +278,7 @@ function normalizeLeaderboard(
386
278
  }
387
279
 
388
280
  function normalizeLeaderboardEntry(
389
- entry: GraphqlLeaderboardEntry,
281
+ entry: RawLeaderboardEntry,
390
282
  ): LeaderboardEntry {
391
283
  const userId = entry.user?.userId ?? undefined;
392
284
  return {
@@ -416,12 +308,12 @@ function scoreNumber(
416
308
  return Number.isFinite(parsed) ? parsed : undefined;
417
309
  }
418
310
 
419
- function leaderboardWriteKey(
420
- deckId: string,
421
- variable: string,
422
- scope: string | null,
423
- ): string {
424
- return `${deckId}::${variable}${scope ? `::${scope}` : ""}`;
311
+ function leaderboardWriteKey(variable: string, scope: string | null): string {
312
+ return `${variable}${scope ? `::${scope}` : ""}`;
313
+ }
314
+
315
+ function leaderboardScope(options: LeaderboardOptions): string | null {
316
+ return options.scope ?? null;
425
317
  }
426
318
 
427
319
  function assertLeaderboardVariable(variable: string, operation: string): void {
package/src/passes.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Pass — a deck-facing capability for selling a creator "pass" to the player.
2
+ //
3
+ // The deck stays capability-AGNOSTIC: it just offers a pass to the player and
4
+ // gets back one normalized outcome regardless of platform. The host decides what
5
+ // UI to show and whether a real transaction can happen:
6
+ // - mobile app : renders the native bricks purchase sheet over the deck
7
+ // - web player : shows an "open in the app" upsell, returns `unavailable`
8
+ // - dev CLI : no host UI surface, so the SDK shows a minimal in-page
9
+ // notice itself (kept deliberately tiny), returns `unavailable`
10
+ // There is no capability check for the deck to make — every platform returns a
11
+ // PassOfferResult, so a single code path handles them all.
12
+
13
+ import type { PassOfferResult, PassOfferStatus } from "./commands";
14
+ import { CastleError } from "./errors";
15
+ import { getCommandChannel, hostRequest } from "./transport";
16
+
17
+ export type { PassOfferResult, PassOfferStatus } from "./commands";
18
+
19
+ export interface CastlePassApi {
20
+ has(passId: string): Promise<boolean>;
21
+ offer(passId: string): Promise<PassOfferResult>;
22
+ }
23
+
24
+ export const Pass: CastlePassApi = {
25
+ has,
26
+ offer,
27
+ };
28
+
29
+ // Pure read — does the current player already own this pass? No UI, every
30
+ // platform answers it the same way (a GraphQL query). Use it to gate content or
31
+ // to decide whether to bother calling `offer`.
32
+ async function has(passId: string): Promise<boolean> {
33
+ if (typeof passId !== "string" || passId.length === 0) {
34
+ throw new CastleError({
35
+ code: "INVALID_ARGUMENT",
36
+ message: "Pass.has requires a passId.",
37
+ operation: "Pass.has",
38
+ });
39
+ }
40
+ const { hasPass } = await hostRequest("pass.has", { passId });
41
+ return hasPass;
42
+ }
43
+
44
+ async function offer(passId: string): Promise<PassOfferResult> {
45
+ if (typeof passId !== "string" || passId.length === 0) {
46
+ throw new CastleError({
47
+ code: "INVALID_ARGUMENT",
48
+ message: "Pass.offer requires a passId.",
49
+ operation: "Pass.offer",
50
+ });
51
+ }
52
+ const result = await hostRequest("pass.offer", { passId });
53
+ // On the dev server there's no host chrome to explain why nothing happened,
54
+ // so surface a small built-in notice. The mobile/web hosts render their own
55
+ // UI, so the SDK stays silent there.
56
+ if (result.status === "unavailable" && getCommandChannel() === "local") {
57
+ showDevUnavailableNotice();
58
+ }
59
+ return result;
60
+ }
61
+
62
+ let devNoticeEl: HTMLDivElement | null = null;
63
+ let devNoticeTimer: ReturnType<typeof setTimeout> | null = null;
64
+
65
+ // Minimal, dependency-free toast. Dev-only affordance — not the place for a
66
+ // designed purchase UI.
67
+ function showDevUnavailableNotice(): void {
68
+ if (typeof document === "undefined") return;
69
+ if (!devNoticeEl) {
70
+ devNoticeEl = document.createElement("div");
71
+ devNoticeEl.textContent = "Passes can only be purchased in the Castle app.";
72
+ devNoticeEl.style.cssText = [
73
+ "position:fixed",
74
+ "left:50%",
75
+ "bottom:24px",
76
+ "transform:translateX(-50%)",
77
+ "max-width:80vw",
78
+ "padding:10px 16px",
79
+ "border-radius:8px",
80
+ "background:rgba(0,0,0,0.82)",
81
+ "color:#fff",
82
+ "font:500 13px/1.4 system-ui,sans-serif",
83
+ "text-align:center",
84
+ "z-index:2147483647",
85
+ "pointer-events:none",
86
+ "transition:opacity 0.3s ease",
87
+ ].join(";");
88
+ document.body.appendChild(devNoticeEl);
89
+ }
90
+ devNoticeEl.style.opacity = "1";
91
+ if (devNoticeTimer) clearTimeout(devNoticeTimer);
92
+ devNoticeTimer = setTimeout(() => {
93
+ if (devNoticeEl) devNoticeEl.style.opacity = "0";
94
+ }, 3200);
95
+ }
package/src/runtime.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ CASTLE_SDK_PROTOCOL,
3
+ type CommandName,
4
+ type CommandParams,
5
+ type CommandResponseEnvelope,
6
+ } from "./commands";
1
7
  import { getCastleEmbed, isEdit } from "./context";
2
8
 
3
9
  export const CARD_RATIO = 5 / 7;
@@ -37,6 +43,17 @@ let logBuffer: OutgoingMessage[] = [];
37
43
  let nextRequestId = 1;
38
44
  const pendingRequests = new Map<string, PendingRequest>();
39
45
 
46
+ // Local-dev command channel: the `castle-web serve` dev server is the host, so
47
+ // SDK commands ride the same websocket runtime.ts already uses for
48
+ // logs/screenshots/restart. Correlated by requestId, separate from the
49
+ // screenshot/write_file request map above.
50
+ const COMMAND_TIMEOUT_MS = 15000;
51
+ const SOCKET_WAIT_TIMEOUT_MS = 10000;
52
+ const pendingCommands = new Map<
53
+ string,
54
+ (env: CommandResponseEnvelope) => void
55
+ >();
56
+
40
57
  const origLog = console.log;
41
58
  const origWarn = console.warn;
42
59
  const origError = console.error;
@@ -175,6 +192,66 @@ function sendLocalRequest(msg: OutgoingMessage): Promise<LocalResponse> {
175
192
  });
176
193
  }
177
194
 
195
+ // Send an SDK command to the dev server and resolve with the raw response
196
+ // envelope (ok/data/error). transport.ts interprets it — error reconstruction
197
+ // stays uniform across all three channels there. Waits for the socket to open
198
+ // so a command issued during startup isn't dropped.
199
+ export function sendLocalCommand<C extends CommandName>(
200
+ command: C,
201
+ params: CommandParams[C],
202
+ ): Promise<CommandResponseEnvelope> {
203
+ const requestId = `cmd_${nextRequestId++}`;
204
+ return new Promise<CommandResponseEnvelope>((resolve, reject) => {
205
+ const timeout = setTimeout(() => {
206
+ pendingCommands.delete(requestId);
207
+ reject(new Error(`Timed out waiting for command ${command}.`));
208
+ }, COMMAND_TIMEOUT_MS);
209
+ pendingCommands.set(requestId, (env) => {
210
+ clearTimeout(timeout);
211
+ pendingCommands.delete(requestId);
212
+ resolve(env);
213
+ });
214
+ waitForSocket()
215
+ .then((socket) => {
216
+ socket.send(
217
+ JSON.stringify({
218
+ type: "castle_command",
219
+ castleSdk: CASTLE_SDK_PROTOCOL,
220
+ requestId,
221
+ command,
222
+ params,
223
+ }),
224
+ );
225
+ })
226
+ .catch((error: unknown) => {
227
+ clearTimeout(timeout);
228
+ pendingCommands.delete(requestId);
229
+ reject(error instanceof Error ? error : new Error(String(error)));
230
+ });
231
+ });
232
+ }
233
+
234
+ function waitForSocket(): Promise<WebSocket> {
235
+ if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve(ws);
236
+ return new Promise<WebSocket>((resolve, reject) => {
237
+ const start = Date.now();
238
+ const poll = setInterval(() => {
239
+ if (ws && ws.readyState === WebSocket.OPEN) {
240
+ clearInterval(poll);
241
+ resolve(ws);
242
+ } else if (Date.now() - start > SOCKET_WAIT_TIMEOUT_MS) {
243
+ clearInterval(poll);
244
+ reject(new Error("Castle dev server is not connected."));
245
+ }
246
+ }, 100);
247
+ });
248
+ }
249
+
250
+ function resolveLocalCommand(msg: CommandResponseEnvelope): void {
251
+ const pending = pendingCommands.get(msg.requestId);
252
+ if (pending) pending(msg);
253
+ }
254
+
178
255
  function resolveLocalRequest(msg: LocalResponse): boolean {
179
256
  if (!msg.requestId) return false;
180
257
  const pending = pendingRequests.get(msg.requestId);
@@ -332,12 +409,41 @@ function handleLocalMessage(msg: IncomingMessage): void {
332
409
  });
333
410
  });
334
411
  } else if (msg.type === "restart") {
335
- location.reload();
412
+ scheduleRestart();
336
413
  } else if (msg.type === "write_file_response") {
337
414
  resolveLocalRequest(msg);
415
+ } else if (msg.type === "castle_command_response") {
416
+ resolveLocalCommand(msg as unknown as CommandResponseEnvelope);
338
417
  }
339
418
  }
340
419
 
420
+ // Restart (from `castle-web restart` / task agents) is debounced so a burst
421
+ // of reload requests -- several tasks finishing close together -- produces
422
+ // one reload. Before reloading, registered hooks run (the kit editor flushes
423
+ // its debounced unsaved edits there) so in-flight work isn't lost.
424
+ const RESTART_DEBOUNCE_MS = 1500;
425
+ let restartTimer: ReturnType<typeof setTimeout> | null = null;
426
+ const beforeRestartHooks = new Set<() => void | Promise<void>>();
427
+
428
+ export function onBeforeRestart(hook: () => void | Promise<void>): () => void {
429
+ beforeRestartHooks.add(hook);
430
+ return () => beforeRestartHooks.delete(hook);
431
+ }
432
+
433
+ function scheduleRestart(): void {
434
+ if (restartTimer !== null) clearTimeout(restartTimer);
435
+ restartTimer = setTimeout(() => {
436
+ void (async () => {
437
+ try {
438
+ await Promise.all([...beforeRestartHooks].map(async (hook) => hook()));
439
+ } catch {
440
+ // a failed flush shouldn't block the reload
441
+ }
442
+ location.reload();
443
+ })();
444
+ }, RESTART_DEBOUNCE_MS);
445
+ }
446
+
341
447
  function localWsUrl(path: string): string {
342
448
  const url = new URL(path, location.href);
343
449
  url.protocol = location.protocol === "https:" ? "wss:" : "ws:";