castle-web-sdk 0.4.12 → 0.4.13

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 CHANGED
@@ -95,7 +95,8 @@ Removes a shared value.
95
95
 
96
96
  `Leaderboard` ranks players by a numeric score, per deck and per
97
97
  variable name. Pick a variable name for each leaderboard the deck has
98
- (e.g. `'score'`, `'time'`).
98
+ (e.g. `'score'`, `'time'`). `show` opens Castle's own leaderboard screen;
99
+ reach for `fetch` only if the deck draws the scores itself.
99
100
 
100
101
  ### `Leaderboard.write(variable, score, options?)`
101
102
 
@@ -116,16 +117,42 @@ const { daysSinceCastleEpoch } = await Time.getServerDate();
116
117
  Leaderboard.write("score", 1200, { scope: `daily-${daysSinceCastleEpoch}` });
117
118
  ```
118
119
 
120
+ ### `Leaderboard.show(variable, type, options?): Promise<LeaderboardShowResult>`
121
+
122
+ Opens Castle's own leaderboard screen for `variable` — the same board
123
+ players see on other Castle decks, with FOR YOU and GLOBAL tabs, sharing,
124
+ and profile links. This is the normal way to show a leaderboard: Castle
125
+ draws it and owns the data, so the deck gets no entries back.
126
+
127
+ `type` is `'high'` (highest first) or `'low'` (lowest first). `options` is
128
+ `{ scope?, label? }` — `scope` works as it does in `write`, and `label`
129
+ titles the column of scores (default `'Score'`; e.g. `'Time'`, `'Coins'`).
130
+
131
+ Opening the board also submits the score the player has written this
132
+ session, so their own result is on it right away. The board appears
133
+ immediately, on a loading spinner; the promise resolves once the scores
134
+ are in. The player closes it themselves — there's no `hide`.
135
+
136
+ `LeaderboardShowResult` has a `status`:
137
+
138
+ - `'shown'` — Castle opened the leaderboard.
139
+ - `'unavailable'` — this host has no leaderboard screen, or can't show
140
+ one right now. Nothing was submitted; the score still saves normally.
141
+
142
+ ```js
143
+ Leaderboard.write("score", finalScore);
144
+ gameOverButton.onclick = () => Leaderboard.show("score", "high");
145
+ ```
146
+
119
147
  ### `Leaderboard.fetch(variable, type, options?): Promise<LeaderboardData>`
120
148
 
121
- Fetches the leaderboard for `variable`. `type` is `'high'` (highest
122
- first) or `'low'` (lowest first). `options.scope` works the same as in
123
- `write`.
149
+ Fetches the leaderboard's entries so the deck can draw them itself — use
150
+ this only when Castle's own screen isn't what you want. `type` and
151
+ `options.scope` work as they do in `show`.
124
152
 
125
- If the player has written a score this session, a `fetch` reflects
126
- **their own** new score right away you can `write` then `fetch` and
127
- show the result without waiting. Other players' recent scores still
128
- appear on their own normal timing.
153
+ Like `show`, a fetch reflects **the player's own** score written this
154
+ session right away. Other players' recent scores still appear on their own
155
+ normal timing.
129
156
 
130
157
  The returned `LeaderboardData` has:
131
158
 
package/dist/castle.d.ts CHANGED
@@ -4,7 +4,7 @@ export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckF
4
4
  export { Haptics } from "./haptics";
5
5
  export type { CastleHapticsApi, HapticsResult, HapticsStatus, HapticStyle } from "./haptics";
6
6
  export { Leaderboard } from "./leaderboard";
7
- export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardSort, } from "./leaderboard";
7
+ export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardShowOptions, LeaderboardShowResult, LeaderboardShowStatus, LeaderboardSort, } from "./leaderboard";
8
8
  export { Lifecycle } from "./lifecycle";
9
9
  export type { CastleLifecycleApi } from "./lifecycle";
10
10
  export { Pass } from "./passes";
@@ -20,6 +20,16 @@ export interface RawLeaderboard {
20
20
  score?: string | number | null;
21
21
  } | null;
22
22
  }
23
+ export type LeaderboardShowStatus = "shown" | "unavailable";
24
+ export interface LeaderboardShowResult {
25
+ status: LeaderboardShowStatus;
26
+ }
27
+ export interface LeaderboardStreakCredit {
28
+ deckId: string | null;
29
+ kind: string;
30
+ priorStreak: number;
31
+ newStreak: number;
32
+ }
23
33
  export type PassOfferStatus = "purchased" | "alreadyOwned" | "cancelled" | "unavailable";
24
34
  export interface PassOfferResult {
25
35
  status: PassOfferStatus;
@@ -62,6 +72,13 @@ export interface CommandParams {
62
72
  score: number;
63
73
  scope?: string | null;
64
74
  };
75
+ "leaderboard.show": {
76
+ variable: string;
77
+ type: "high" | "low";
78
+ scope?: string | null;
79
+ label?: string | null;
80
+ score?: number | null;
81
+ };
65
82
  "user.getCurrent": Record<string, never>;
66
83
  "time.getServerTime": Record<string, never>;
67
84
  "pass.has": {
@@ -100,6 +117,7 @@ export interface CommandResult {
100
117
  "leaderboard.save": {
101
118
  ok: true;
102
119
  };
120
+ "leaderboard.show": LeaderboardShowResult;
103
121
  "user.getCurrent": {
104
122
  user: {
105
123
  userId: string;
@@ -1,8 +1,13 @@
1
+ import { type LeaderboardShowResult } from "./commands";
2
+ export type { LeaderboardShowResult, LeaderboardShowStatus } from "./commands";
1
3
  export type LeaderboardSort = "high" | "low";
2
4
  export type LeaderboardScope = string;
3
5
  export interface LeaderboardOptions {
4
6
  scope?: LeaderboardScope | null;
5
7
  }
8
+ export interface LeaderboardShowOptions extends LeaderboardOptions {
9
+ label?: string | null;
10
+ }
6
11
  export interface LeaderboardEntry {
7
12
  place: number;
8
13
  value: number;
@@ -17,4 +22,5 @@ export interface LeaderboardData {
17
22
  export declare const Leaderboard: {
18
23
  readonly write: (variable: string, score: number, options?: LeaderboardOptions) => void;
19
24
  readonly fetch: (variable: string, type: LeaderboardSort, options?: LeaderboardOptions) => Promise<LeaderboardData>;
25
+ readonly show: (variable: string, type: LeaderboardSort, options?: LeaderboardShowOptions) => Promise<LeaderboardShowResult>;
20
26
  };
@@ -15,6 +15,9 @@ export const Leaderboard = {
15
15
  fetch(variable, type, options = {}) {
16
16
  return fetchLeaderboardData(variable, type, options);
17
17
  },
18
+ show(variable, type, options = {}) {
19
+ return showLeaderboard(variable, type, options);
20
+ },
18
21
  };
19
22
  function writeLeaderboard(variable, score, options) {
20
23
  if (isEdit())
@@ -30,17 +33,8 @@ async function fetchLeaderboardData(variable, type, options) {
30
33
  assertLeaderboardVariable(variable, "Leaderboard.fetch");
31
34
  assertLeaderboardType(type, "Leaderboard.fetch");
32
35
  const scope = leaderboardScope(options);
33
- // If the deck has written a score for this variable+scope this session, send
34
- // it so the host writes-and-reads via leaderboardV2 and the player's own
35
- // score shows up immediately (mirrors getLeaderboard in
36
- // core/src/leaderboards.cpp — presence of a buffered score, not dirtiness,
37
- // gates the write-through). Otherwise a plain read of the settled board.
38
- const record = leaderboardWrites.get(leaderboardWriteKey(variable, scope));
39
- const score = record
40
- ? type === "high"
41
- ? record.highScore
42
- : record.lowScore
43
- : null;
36
+ const record = pendingLeaderboardWrite(variable, scope);
37
+ const score = bufferedScore(record, type);
44
38
  const { leaderboard, currentUserId } = await hostRequest("leaderboard.fetch", {
45
39
  variable,
46
40
  type,
@@ -52,6 +46,57 @@ async function fetchLeaderboardData(variable, type, options) {
52
46
  }
53
47
  return normalizeLeaderboard(leaderboard, currentUserId);
54
48
  }
49
+ // Opening the host's leaderboard UI takes the same write-through shortcut a
50
+ // fetch does: the buffered score rides along so the player's own just-played
51
+ // score is on the board immediately, matching what opening the classic
52
+ // leaderboard does. The panel and its data stay host-side — a deck that calls
53
+ // this receives no leaderboard rows, only whether it was presented.
54
+ async function showLeaderboard(variable, type, options) {
55
+ assertLeaderboardVariable(variable, "Leaderboard.show");
56
+ assertLeaderboardType(type, "Leaderboard.show");
57
+ const scope = leaderboardScope(options);
58
+ const record = pendingLeaderboardWrite(variable, scope);
59
+ const score = bufferedScore(record, type);
60
+ const label = typeof options.label === "string" ? options.label : null;
61
+ let result;
62
+ try {
63
+ result = await hostRequest("leaderboard.show", {
64
+ variable,
65
+ type,
66
+ scope,
67
+ ...(label === null ? {} : { label }),
68
+ ...(score === null ? {} : { score }),
69
+ });
70
+ }
71
+ catch (error) {
72
+ // An app build older than this command rejects it outright; that is
73
+ // capability divergence, not a failure the deck should have to handle.
74
+ if (error instanceof CastleError && error.code === "UNKNOWN_COMMAND") {
75
+ return { status: "unavailable" };
76
+ }
77
+ throw error;
78
+ }
79
+ // Only a host that actually presented the board wrote the score, so only
80
+ // then is the buffered write settled; otherwise the periodic flush delivers
81
+ // it and a duplicate write (double streak credit) is avoided either way.
82
+ if (result.status === "shown" && record && score !== null) {
83
+ clearLeaderboardDirtyAfterFetch(record, type, score);
84
+ }
85
+ return result;
86
+ }
87
+ function pendingLeaderboardWrite(variable, scope) {
88
+ return leaderboardWrites.get(leaderboardWriteKey(variable, scope));
89
+ }
90
+ // The value to send along with a read: if the deck has written a score for this
91
+ // variable+scope this session, the host writes-and-reads via leaderboardV2 so
92
+ // the player's own score shows up immediately. Mirrors getLeaderboard in
93
+ // core/src/leaderboards.cpp — presence of a buffered score, not dirtiness,
94
+ // gates the write-through. No buffered score → a plain read of the settled board.
95
+ function bufferedScore(record, type) {
96
+ if (!record)
97
+ return null;
98
+ return type === "high" ? record.highScore : record.lowScore;
99
+ }
55
100
  // After a write-through fetch, clear the dirty flag for the side we just sent
56
101
  // (so the periodic flush won't re-send it via saveVariableToLeaderboard). If
57
102
  // the other side's buffered value matches what we sent (the common single-score
package/dist/runtime.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { CASTLE_SDK_PROTOCOL, } from "./commands";
2
2
  import { getCastleEmbed, isEdit } from "./context";
3
3
  export const CARD_RATIO = 5 / 7;
4
+ // Host -> deck capture request, and the deck's answer. Kept in sync with the
5
+ // editor shell (cli/src/shell/coverApi.ts).
6
+ const HOST_CAPTURE_REQUEST = "castle-capture-cover";
7
+ const HOST_CAPTURE_RESULT = "castle-capture-cover-result";
4
8
  let ws = null;
5
9
  let logBuffer = [];
6
10
  let nextRequestId = 1;
@@ -21,6 +25,8 @@ const dynamicImport = new Function("u", "return import(u)");
21
25
  export function setup() {
22
26
  interceptConsole();
23
27
  connectLocal();
28
+ initHostCapture();
29
+ initPlaySelection();
24
30
  initPlayCard();
25
31
  }
26
32
  export function writeFile(path, contents) {
@@ -64,6 +70,35 @@ export function initCard() {
64
70
  window.addEventListener("resize", resize);
65
71
  return card;
66
72
  }
73
+ // A deck being PLAYED is a game, not a document: dragging across it should move
74
+ // a paddle, not blue-highlight the score, and a long press on a phone should not
75
+ // raise the copy/define callout over the card.
76
+ //
77
+ // It belongs here rather than in a host. Selection is decided inside the deck's
78
+ // own document, so neither the website's iframe nor the app's WebView can reach
79
+ // it from outside -- and doing it once in the SDK covers the play panel,
80
+ // castle.xyz and the feed together.
81
+ //
82
+ // Play mode only: in edit mode the same document hosts kit editors and deck UI,
83
+ // where selecting text is the point. Form fields keep selection either way, so a
84
+ // deck that asks for a name still works.
85
+ function initPlaySelection() {
86
+ if (isEdit())
87
+ return;
88
+ const style = document.createElement("style");
89
+ style.textContent = `
90
+ html, body {
91
+ -webkit-user-select: none;
92
+ user-select: none;
93
+ -webkit-touch-callout: none;
94
+ }
95
+ input, textarea, [contenteditable=""], [contenteditable="true"] {
96
+ -webkit-user-select: text;
97
+ user-select: text;
98
+ }
99
+ `;
100
+ document.head.appendChild(style);
101
+ }
67
102
  // Constrains whatever the deck renders into #root to a 5:7 card in play mode.
68
103
  // Hosts own max size and padding; the SDK only preserves the card aspect ratio.
69
104
  function initPlayCard() {
@@ -240,9 +275,36 @@ function formatConsoleArgs(args) {
240
275
  })
241
276
  .join(" ");
242
277
  }
278
+ // Compositing a card needs html2canvas, and where it comes from decides whether
279
+ // a capture can fail for reasons that have nothing to do with the deck.
280
+ //
281
+ // The serve lends it from its own origin (`castle-web serve`, so: the editor,
282
+ // the dev server, a cloud sandbox) -- same origin, no third party, and it works
283
+ // with no internet at all. Every caller that captures runs against a serve, so
284
+ // this is the path in practice.
285
+ //
286
+ // The CDN is the fallback for a deck with no serve behind it, which today means
287
+ // a published deck -- where nothing captures. Keeping it costs nothing and means
288
+ // a capture from somewhere unforeseen still degrades to "slow" rather than
289
+ // "canvas-only". Loaded once and reused: html2canvas is ~194KB.
290
+ let html2canvasPromise = null;
291
+ function loadHtml2Canvas() {
292
+ if (!html2canvasPromise) {
293
+ const local = new URL("/__castle/vendor/html2canvas.js", location.origin)
294
+ .href;
295
+ html2canvasPromise = dynamicImport(local)
296
+ .catch(() => dynamicImport("https://esm.sh/html2canvas"))
297
+ .catch((err) => {
298
+ // Don't cache a failure -- a later capture may have a serve, or a network.
299
+ html2canvasPromise = null;
300
+ throw err;
301
+ });
302
+ }
303
+ return html2canvasPromise;
304
+ }
243
305
  async function captureWithHtml2Canvas(target) {
244
306
  try {
245
- const mod = (await dynamicImport("https://esm.sh/html2canvas"));
307
+ const mod = await loadHtml2Canvas();
246
308
  const c = await mod.default(target, {
247
309
  backgroundColor: null,
248
310
  scale: devicePixelRatio,
@@ -266,15 +328,54 @@ function cropCanvasToCard(card, canvas) {
266
328
  ctx.drawImage(canvas, dx, dy, canvasRect.width * devicePixelRatio, canvasRect.height * devicePixelRatio);
267
329
  return c.toDataURL("image/png");
268
330
  }
331
+ // Does the card show anything the canvas didn't draw? A deck is free to put raw
332
+ // DOM in the card -- a HUD, a title, a button -- and that DOM is part of what the
333
+ // player sees, so a canvas-only capture would be a cover that doesn't match the
334
+ // deck. Walks the card for any rendered node outside the canvas: an element with
335
+ // a non-empty box, or visible text.
336
+ function hasContentBesidesCanvas(card, canvas) {
337
+ const isRenderedBox = (el) => {
338
+ const style = getComputedStyle(el);
339
+ if (style.display === "none" ||
340
+ style.visibility === "hidden" ||
341
+ style.opacity === "0") {
342
+ return false;
343
+ }
344
+ const rect = el.getBoundingClientRect();
345
+ return rect.width > 0 && rect.height > 0;
346
+ };
347
+ for (const el of Array.from(card.querySelectorAll("*"))) {
348
+ if (el === canvas || canvas.contains(el))
349
+ continue;
350
+ if (isRenderedBox(el))
351
+ return true;
352
+ }
353
+ // Text put straight in the card (or in a zero-box wrapper) renders without any
354
+ // element of its own to measure.
355
+ const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
356
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
357
+ if ((n.textContent || "").trim())
358
+ return true;
359
+ }
360
+ return false;
361
+ }
269
362
  async function captureScreenshot() {
270
363
  const card = document.querySelector("#castle-card, [data-castle-card]");
271
364
  if (card) {
272
365
  const cardCanvas = card.querySelector("canvas");
366
+ // Cropping the canvas is exact and needs no network, but it only tells the
367
+ // truth when the canvas IS the card. Otherwise composite the whole card --
368
+ // html2canvas draws canvases into its output, so nothing is lost by it.
369
+ if (cardCanvas && !hasContentBesidesCanvas(card, cardCanvas)) {
370
+ return cropCanvasToCard(card, cardCanvas);
371
+ }
372
+ const composited = await captureWithHtml2Canvas(card);
373
+ if (composited)
374
+ return composited;
375
+ // html2canvas is fetched at capture time, so it can fail closed (offline, a
376
+ // host CSP). A canvas-only cover beats no cover.
273
377
  if (cardCanvas)
274
378
  return cropCanvasToCard(card, cardCanvas);
275
- const cropped = await captureWithHtml2Canvas(card);
276
- if (cropped)
277
- return cropped;
278
379
  }
279
380
  if (document.body?.dataset.castleScreenshotTarget === "viewport") {
280
381
  const viewportCapture = await captureWithHtml2Canvas(document.body);
@@ -286,6 +387,36 @@ async function captureScreenshot() {
286
387
  return canvas.toDataURL("image/png");
287
388
  return captureWithHtml2Canvas(card || document.body);
288
389
  }
390
+ // A framing host (the editor shell, for a deck's cover image) asks the deck for
391
+ // a capture instead of reaching into the frame for its canvas: the canvas is
392
+ // only part of what a deck renders, and only the deck can composite the rest.
393
+ // Answers the window that asked, falling back to the parent frame.
394
+ function initHostCapture() {
395
+ if (typeof window === "undefined" || window.parent === window)
396
+ return;
397
+ // A host re-asks while it waits, in case its first request landed before this
398
+ // handler existed. Same id means same request, so the repeat is dropped rather
399
+ // than starting a second capture alongside the one already running.
400
+ const inFlight = new Set();
401
+ window.addEventListener("message", (event) => {
402
+ const data = event.data;
403
+ if (!data || data.type !== HOST_CAPTURE_REQUEST)
404
+ return;
405
+ const requestId = typeof data.requestId === "string" ? data.requestId : undefined;
406
+ if (requestId) {
407
+ if (inFlight.has(requestId))
408
+ return;
409
+ inFlight.add(requestId);
410
+ }
411
+ const reply = (dataUrl) => {
412
+ if (requestId)
413
+ inFlight.delete(requestId);
414
+ const target = event.source ?? window.parent;
415
+ target.postMessage({ type: HOST_CAPTURE_RESULT, requestId, dataUrl, ok: !!dataUrl }, "*");
416
+ };
417
+ void captureScreenshot().then(reply, () => reply(null));
418
+ });
419
+ }
289
420
  function connectLocal() {
290
421
  fetch("/__castle/ws-port")
291
422
  .then((r) => r.json())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",
@@ -10,12 +10,14 @@
10
10
  "default": "./dist/castle.js"
11
11
  }
12
12
  },
13
- "//host": "host.{js,d.ts} is the host-side executor deliberately NOT exported and NOT packaged. It is vendored into host repos via scripts/copy-host-module.mjs; decks must never receive it.",
13
+ "//host": "host.{js,d.ts} is the host-side executor and leaderboardPanel.{js,d.ts} is the host-side leaderboard UI \u2014 both deliberately NOT exported and NOT packaged. They are vendored into host repos via scripts/copy-host-module.mjs; decks must never receive them.",
14
14
  "files": [
15
15
  "dist",
16
16
  "README.md",
17
17
  "!dist/host.js",
18
- "!dist/host.d.ts"
18
+ "!dist/host.d.ts",
19
+ "!dist/leaderboardPanel.js",
20
+ "!dist/leaderboardPanel.d.ts"
19
21
  ],
20
22
  "scripts": {
21
23
  "build": "rm -rf dist && tsc",