castle-web-sdk 0.4.12 → 0.4.14

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() {
@@ -85,6 +120,26 @@ function initPlayCard() {
85
120
  }
86
121
  `;
87
122
  document.head.appendChild(style);
123
+ // Mark whatever this turns into a card, the way `initCard` marks the one it
124
+ // creates. Anything looking for "the card" -- a capture cropping to it, in
125
+ // page or headless -- otherwise finds nothing here and has to guess at the
126
+ // viewport, which is not the same rectangle: standalone chrome insets the
127
+ // card, so a viewport shot carries a border the card does not have.
128
+ //
129
+ // Runs on a timer as well as now because `setup()` is called before a deck
130
+ // renders, so #root is usually still empty at this point; a deck that mounts
131
+ // later still gets its card marked.
132
+ function markCard() {
133
+ const el = document.querySelector("#root > *");
134
+ if (el)
135
+ el.dataset.castleCard = "";
136
+ }
137
+ markCard();
138
+ requestAnimationFrame(markCard);
139
+ new MutationObserver(markCard).observe(document.documentElement, {
140
+ childList: true,
141
+ subtree: true,
142
+ });
88
143
  function resize() {
89
144
  const { w, h } = computeCardSize();
90
145
  document.documentElement.style.setProperty("--castle-card-w", w + "px");
@@ -240,9 +295,36 @@ function formatConsoleArgs(args) {
240
295
  })
241
296
  .join(" ");
242
297
  }
298
+ // Compositing a card needs html2canvas, and where it comes from decides whether
299
+ // a capture can fail for reasons that have nothing to do with the deck.
300
+ //
301
+ // The serve lends it from its own origin (`castle-web serve`, so: the editor,
302
+ // the dev server, a cloud sandbox) -- same origin, no third party, and it works
303
+ // with no internet at all. Every caller that captures runs against a serve, so
304
+ // this is the path in practice.
305
+ //
306
+ // The CDN is the fallback for a deck with no serve behind it, which today means
307
+ // a published deck -- where nothing captures. Keeping it costs nothing and means
308
+ // a capture from somewhere unforeseen still degrades to "slow" rather than
309
+ // "canvas-only". Loaded once and reused: html2canvas is ~194KB.
310
+ let html2canvasPromise = null;
311
+ function loadHtml2Canvas() {
312
+ if (!html2canvasPromise) {
313
+ const local = new URL("/__castle/vendor/html2canvas.js", location.origin)
314
+ .href;
315
+ html2canvasPromise = dynamicImport(local)
316
+ .catch(() => dynamicImport("https://esm.sh/html2canvas"))
317
+ .catch((err) => {
318
+ // Don't cache a failure -- a later capture may have a serve, or a network.
319
+ html2canvasPromise = null;
320
+ throw err;
321
+ });
322
+ }
323
+ return html2canvasPromise;
324
+ }
243
325
  async function captureWithHtml2Canvas(target) {
244
326
  try {
245
- const mod = (await dynamicImport("https://esm.sh/html2canvas"));
327
+ const mod = await loadHtml2Canvas();
246
328
  const c = await mod.default(target, {
247
329
  backgroundColor: null,
248
330
  scale: devicePixelRatio,
@@ -266,15 +348,54 @@ function cropCanvasToCard(card, canvas) {
266
348
  ctx.drawImage(canvas, dx, dy, canvasRect.width * devicePixelRatio, canvasRect.height * devicePixelRatio);
267
349
  return c.toDataURL("image/png");
268
350
  }
351
+ // Does the card show anything the canvas didn't draw? A deck is free to put raw
352
+ // DOM in the card -- a HUD, a title, a button -- and that DOM is part of what the
353
+ // player sees, so a canvas-only capture would be a cover that doesn't match the
354
+ // deck. Walks the card for any rendered node outside the canvas: an element with
355
+ // a non-empty box, or visible text.
356
+ function hasContentBesidesCanvas(card, canvas) {
357
+ const isRenderedBox = (el) => {
358
+ const style = getComputedStyle(el);
359
+ if (style.display === "none" ||
360
+ style.visibility === "hidden" ||
361
+ style.opacity === "0") {
362
+ return false;
363
+ }
364
+ const rect = el.getBoundingClientRect();
365
+ return rect.width > 0 && rect.height > 0;
366
+ };
367
+ for (const el of Array.from(card.querySelectorAll("*"))) {
368
+ if (el === canvas || canvas.contains(el))
369
+ continue;
370
+ if (isRenderedBox(el))
371
+ return true;
372
+ }
373
+ // Text put straight in the card (or in a zero-box wrapper) renders without any
374
+ // element of its own to measure.
375
+ const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
376
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
377
+ if ((n.textContent || "").trim())
378
+ return true;
379
+ }
380
+ return false;
381
+ }
269
382
  async function captureScreenshot() {
270
383
  const card = document.querySelector("#castle-card, [data-castle-card]");
271
384
  if (card) {
272
385
  const cardCanvas = card.querySelector("canvas");
386
+ // Cropping the canvas is exact and needs no network, but it only tells the
387
+ // truth when the canvas IS the card. Otherwise composite the whole card --
388
+ // html2canvas draws canvases into its output, so nothing is lost by it.
389
+ if (cardCanvas && !hasContentBesidesCanvas(card, cardCanvas)) {
390
+ return cropCanvasToCard(card, cardCanvas);
391
+ }
392
+ const composited = await captureWithHtml2Canvas(card);
393
+ if (composited)
394
+ return composited;
395
+ // html2canvas is fetched at capture time, so it can fail closed (offline, a
396
+ // host CSP). A canvas-only cover beats no cover.
273
397
  if (cardCanvas)
274
398
  return cropCanvasToCard(card, cardCanvas);
275
- const cropped = await captureWithHtml2Canvas(card);
276
- if (cropped)
277
- return cropped;
278
399
  }
279
400
  if (document.body?.dataset.castleScreenshotTarget === "viewport") {
280
401
  const viewportCapture = await captureWithHtml2Canvas(document.body);
@@ -286,6 +407,36 @@ async function captureScreenshot() {
286
407
  return canvas.toDataURL("image/png");
287
408
  return captureWithHtml2Canvas(card || document.body);
288
409
  }
410
+ // A framing host (the editor shell, for a deck's cover image) asks the deck for
411
+ // a capture instead of reaching into the frame for its canvas: the canvas is
412
+ // only part of what a deck renders, and only the deck can composite the rest.
413
+ // Answers the window that asked, falling back to the parent frame.
414
+ function initHostCapture() {
415
+ if (typeof window === "undefined" || window.parent === window)
416
+ return;
417
+ // A host re-asks while it waits, in case its first request landed before this
418
+ // handler existed. Same id means same request, so the repeat is dropped rather
419
+ // than starting a second capture alongside the one already running.
420
+ const inFlight = new Set();
421
+ window.addEventListener("message", (event) => {
422
+ const data = event.data;
423
+ if (!data || data.type !== HOST_CAPTURE_REQUEST)
424
+ return;
425
+ const requestId = typeof data.requestId === "string" ? data.requestId : undefined;
426
+ if (requestId) {
427
+ if (inFlight.has(requestId))
428
+ return;
429
+ inFlight.add(requestId);
430
+ }
431
+ const reply = (dataUrl) => {
432
+ if (requestId)
433
+ inFlight.delete(requestId);
434
+ const target = event.source ?? window.parent;
435
+ target.postMessage({ type: HOST_CAPTURE_RESULT, requestId, dataUrl, ok: !!dataUrl }, "*");
436
+ };
437
+ void captureScreenshot().then(reply, () => reply(null));
438
+ });
439
+ }
289
440
  function connectLocal() {
290
441
  fetch("/__castle/ws-port")
291
442
  .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.14",
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",