castle-web-cli 0.4.119 → 0.4.120

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/dist/api.js CHANGED
@@ -18,10 +18,21 @@ export async function graphql(query, variables) {
18
18
  });
19
19
  return res.json();
20
20
  }
21
+ // Castle masks every ClientError's message to "Unexpected error." and puts the
22
+ // real one in `extensions.code` -- so the bare message is the same string for a
23
+ // permissions failure, a missing deck, and a bad argument. Carry the code in the
24
+ // message too, since that is all most callers (and the top-level handler) print.
21
25
  function handleAPIError(data) {
22
26
  if (data?.errors?.length) {
23
- const err = new Error(data.errors[0]?.message ?? 'GraphQL error');
24
- err.extensions = data.errors[0]?.extensions;
27
+ const first = data.errors[0];
28
+ const code = first?.extensions?.code;
29
+ // `path` names the field that failed, which is the operation for a mutation
30
+ // -- without it "Unexpected error." doesn't even say which call broke.
31
+ const where = Array.isArray(first?.path) ? first.path.join('.') : '';
32
+ const parts = [where, typeof code === 'string' ? code : ''].filter(Boolean);
33
+ const message = first?.message ?? 'GraphQL error';
34
+ const err = new Error(parts.length ? `${message} [${parts.join(': ')}]` : message);
35
+ err.extensions = first?.extensions;
25
36
  throw err;
26
37
  }
27
38
  }
@@ -4,11 +4,15 @@
4
4
  // Node16 resolution rejects. Mirror the public surface of
5
5
  // castle-experimental-web/sdk/src/host.ts; update if that signature changes.
6
6
 
7
+ import type { CastleLeaderboardPayload } from './leaderboardPanel.js';
8
+
7
9
  export interface HostContext {
8
10
  deckId: string | null;
9
11
  sessionId: string | null;
10
12
  userId: string | null;
11
13
  username: string | null;
14
+ isAnonymous?: boolean;
15
+ deepLinkUri?: string | null;
12
16
  }
13
17
 
14
18
  export interface GraphqlError {
@@ -36,6 +40,18 @@ export interface HostCapabilities {
36
40
  graphqlFetch: GraphqlFetch;
37
41
  platformHandler?: PlatformHandler;
38
42
  localPassGrant?: (passId: string) => boolean;
43
+ showLeaderboard?: (payload: CastleLeaderboardPayload) => void;
44
+ canShowLeaderboard?: () => boolean;
45
+ leaderboardShowsParties?: boolean;
46
+ onStreakCredit?: (credit: LeaderboardStreakCredit) => void;
47
+ logAnalyticsEvent?: (event: string, properties: Record<string, unknown>) => void;
48
+ }
49
+
50
+ export interface LeaderboardStreakCredit {
51
+ deckId: string | null;
52
+ kind: string;
53
+ priorStreak: number;
54
+ newStreak: number;
39
55
  }
40
56
 
41
57
  export interface SerializedCommandError {
@@ -1,4 +1,4 @@
1
- // GENERATED — do not edit. Vendored from castle-experimental-web/sdk/dist/host.js
1
+ // GENERATED — do not edit. Vendored from castle-experimental-web/sdk/dist/
2
2
  // Re-run `npm run copy-host` in castle-experimental-web after an SDK change.
3
3
 
4
4
  // Host-side command executor — the shared module run by every trusted host
@@ -22,6 +22,7 @@ const COMMAND_NAMES = [
22
22
  "sharedDeckStorage.update",
23
23
  "leaderboard.fetch",
24
24
  "leaderboard.save",
25
+ "leaderboard.show",
25
26
  "user.getCurrent",
26
27
  "time.getServerTime",
27
28
  "pass.has",
@@ -41,6 +42,10 @@ const PLATFORM_COMMAND_NAMES = [
41
42
  "portal.prefetch",
42
43
  "haptics.play",
43
44
  ];
45
+ // Mirrors CASTLE_LEADERBOARD_PAYLOAD_VERSION in leaderboardPanel.ts. Duplicated
46
+ // rather than imported because every import here has to stay type-only; bump
47
+ // both together — the mobile panel ships in app binaries and will skew.
48
+ const LEADERBOARD_PAYLOAD_VERSION = 1;
44
49
  function isCommandName(value) {
45
50
  return (typeof value === "string" &&
46
51
  COMMAND_NAMES.includes(value));
@@ -94,9 +99,11 @@ function runCommand(ctx, command, params, caps) {
94
99
  case "sharedDeckStorage.update":
95
100
  return sharedDeckStorageUpdate(ctx, params, gql);
96
101
  case "leaderboard.fetch":
97
- return leaderboardFetch(ctx, params, gql);
102
+ return leaderboardFetch(ctx, params, caps);
98
103
  case "leaderboard.save":
99
- return leaderboardSave(ctx, params, gql);
104
+ return leaderboardSave(ctx, params, caps);
105
+ case "leaderboard.show":
106
+ return leaderboardShow(ctx, params, caps);
100
107
  case "user.getCurrent":
101
108
  return Promise.resolve(userGetCurrent(ctx));
102
109
  case "time.getServerTime":
@@ -275,12 +282,12 @@ async function sharedDeckStorageUpdate(ctx, params, gql) {
275
282
  await graphql(gql, UPDATE_SHARED_DECK_STORAGE_MUTATION, { deckId, sessionId: ctx.sessionId, userId, updates: asUpdates(params.updates) }, "sharedDeckStorage.update");
276
283
  return { ok: true };
277
284
  }
278
- async function leaderboardFetch(ctx, params, gql) {
285
+ async function leaderboardFetch(ctx, params, caps) {
279
286
  const deckId = requireDeckId(ctx, "leaderboard.fetch");
280
287
  const variables = {
281
288
  deckId,
282
289
  variable: asString(params.variable, "variable", "leaderboard.fetch"),
283
- type: asLeaderboardType(params.type),
290
+ type: asLeaderboardType(params.type, "leaderboard.fetch"),
284
291
  filter: "dedupUsers",
285
292
  includeFollowList: false,
286
293
  includeParties: false,
@@ -292,22 +299,147 @@ async function leaderboardFetch(ctx, params, gql) {
292
299
  // No score → plain read of the settled leaderboard.
293
300
  const score = asOptionalNumber(params.score);
294
301
  if (score !== null) {
295
- const data = await graphql(gql, LEADERBOARD_V2_MUTATION, { ...variables, score }, "leaderboard.fetch");
302
+ const data = await graphql(caps.graphqlFetch, LEADERBOARD_V2_MUTATION, { ...variables, score }, "leaderboard.fetch");
303
+ relayStreakCredit(caps, deckId, data.leaderboardV2?.streakCredit);
296
304
  return { leaderboard: data.leaderboardV2, currentUserId: ctx.userId };
297
305
  }
298
- const data = await graphql(gql, LEADERBOARD_QUERY, variables, "leaderboard.fetch");
306
+ const data = await graphql(caps.graphqlFetch, LEADERBOARD_QUERY, variables, "leaderboard.fetch");
299
307
  return { leaderboard: data.leaderboard, currentUserId: ctx.userId };
300
308
  }
301
- async function leaderboardSave(ctx, params, gql) {
309
+ async function leaderboardSave(ctx, params, caps) {
302
310
  const deckId = requireDeckId(ctx, "leaderboard.save");
303
- await graphql(gql, SAVE_LEADERBOARD_MUTATION, {
311
+ // saveVariableToLeaderboard returns a Json scalar shaped { streakCredit },
312
+ // so there is no selection set to add — read the key off the scalar.
313
+ const data = await graphql(caps.graphqlFetch, SAVE_LEADERBOARD_MUTATION, {
304
314
  deckId,
305
315
  variable: asString(params.variable, "variable", "leaderboard.save"),
306
316
  score: asNumber(params.score, "score", "leaderboard.save"),
307
317
  scope: asOptionalString(params.scope) ?? null,
308
318
  }, "leaderboard.save");
319
+ relayStreakCredit(caps, deckId, data.saveVariableToLeaderboard?.streakCredit);
309
320
  return { ok: true };
310
321
  }
322
+ // The host renders the board itself, so nothing here flows back to the deck
323
+ // beyond whether it was presented. With a score this is the same write-and-read
324
+ // the classic leaderboard performs when it opens.
325
+ async function leaderboardShow(ctx, params, caps) {
326
+ const show = caps.showLeaderboard;
327
+ if (!show)
328
+ return { status: "unavailable" };
329
+ if (caps.canShowLeaderboard && !caps.canShowLeaderboard()) {
330
+ return { status: "unavailable" };
331
+ }
332
+ const deckId = requireDeckId(ctx, "leaderboard.show");
333
+ const variable = asString(params.variable, "variable", "leaderboard.show");
334
+ const type = asLeaderboardType(params.type, "leaderboard.show");
335
+ const scope = asOptionalString(params.scope) ?? null;
336
+ const label = asOptionalString(params.label) ?? "Score";
337
+ const empty = {
338
+ v: LEADERBOARD_PAYLOAD_VERSION,
339
+ deckId,
340
+ variable,
341
+ scope,
342
+ type,
343
+ label,
344
+ isAnonymous: ctx.isAnonymous === true,
345
+ showParties: caps.leaderboardShowsParties === true,
346
+ yourScore: null,
347
+ list: [],
348
+ followList: [],
349
+ parties: [],
350
+ };
351
+ // Present, then fetch. The round trip is the slow part of an open, so the
352
+ // board goes up on its spinner the moment the deck asks for it and fills in
353
+ // when the rows land — the same order the engine's leaderboard uses.
354
+ show({ ...empty, loading: true });
355
+ let raw;
356
+ try {
357
+ raw = await fetchFullLeaderboard(caps, {
358
+ deckId,
359
+ variable,
360
+ type,
361
+ scope,
362
+ label,
363
+ url: ctx.deepLinkUri ?? null,
364
+ score: asOptionalNumber(params.score),
365
+ });
366
+ }
367
+ catch (error) {
368
+ // The board is already up: leave it on an empty list rather than a spinner
369
+ // that never stops. The deck still gets the error.
370
+ show(empty);
371
+ throw error;
372
+ }
373
+ relayStreakCredit(caps, deckId, raw?.streakCredit);
374
+ show({
375
+ ...empty,
376
+ yourScore: asNumberish(raw?.yourScore?.score),
377
+ list: leaderboardRows(raw?.list),
378
+ followList: leaderboardRows(raw?.followList),
379
+ parties: leaderboardParties(raw?.parties),
380
+ });
381
+ caps.logAnalyticsEvent?.("VIEW_LEADERBOARD", {
382
+ deckId,
383
+ variableName: variable,
384
+ scope: scope ?? "",
385
+ leaderboardType: type,
386
+ });
387
+ return { status: "shown" };
388
+ }
389
+ // A buffered score turns the read into the engine's write-and-read, so the
390
+ // player's own result is on the board the first time they see it.
391
+ function fetchFullLeaderboard(caps, request) {
392
+ const { score, ...rest } = request;
393
+ const variables = {
394
+ ...rest,
395
+ filter: "dedupUsers",
396
+ includeFollowList: true,
397
+ includeParties: true,
398
+ };
399
+ if (score === null) {
400
+ return graphql(caps.graphqlFetch, LEADERBOARD_FULL_QUERY, variables, "leaderboard.show").then((data) => data.leaderboard);
401
+ }
402
+ return graphql(caps.graphqlFetch, LEADERBOARD_V2_FULL_MUTATION, { ...variables, score }, "leaderboard.show").then((data) => data.leaderboardV2);
403
+ }
404
+ function relayStreakCredit(caps, deckId, credit) {
405
+ const kind = credit?.kind;
406
+ if (!caps.onStreakCredit || typeof kind !== "string" || kind.length === 0)
407
+ return;
408
+ caps.onStreakCredit({
409
+ deckId,
410
+ kind,
411
+ priorStreak: asNumberish(credit?.priorStreak) ?? 0,
412
+ newStreak: asNumberish(credit?.newStreak) ?? 0,
413
+ });
414
+ }
415
+ function leaderboardRows(entries) {
416
+ return (entries ?? []).map((entry) => ({
417
+ place: asNumberish(entry?.place),
418
+ score: entry?.score === null || entry?.score === undefined ? "" : String(entry.score),
419
+ isDeckCreator: entry?.isDeckCreator === true,
420
+ isLinkSharer: entry?.isLinkSharer === true,
421
+ isRequiredUser: entry?.isRequiredUser === true,
422
+ user: {
423
+ userId: asOptionalString(entry?.user?.userId),
424
+ username: asOptionalString(entry?.user?.username),
425
+ isAnonymous: entry?.user?.isAnonymous === true,
426
+ color: asOptionalString(entry?.user?.usernameStyle?.color),
427
+ avatarUrl: asOptionalString(entry?.user?.photo?.smallAvatarUrl),
428
+ badgeUrl: asOptionalString(entry?.user?.usernameBadge?.smallBadgeUrl),
429
+ frameUrl: asOptionalString(entry?.user?.photoFrame?.smallFrameUrl),
430
+ },
431
+ }));
432
+ }
433
+ function leaderboardParties(parties) {
434
+ return (parties ?? []).map((party) => ({
435
+ partyId: asOptionalString(party?.partyId),
436
+ name: asOptionalString(party?.name) ?? "",
437
+ avatarUrls: (party?.highlightUsers ?? [])
438
+ .map((user) => asOptionalString(user?.photo?.smallAvatarUrl))
439
+ .filter((url) => url !== null),
440
+ lastMessageTime: asOptionalString(party?.chatChannel?.lastChatMessageForDeck?.createdTime),
441
+ }));
442
+ }
311
443
  function userGetCurrent(ctx) {
312
444
  if (!ctx.userId || !ctx.username)
313
445
  return { user: null };
@@ -381,10 +513,10 @@ function asScope(value, command) {
381
513
  return value;
382
514
  throw new HostCommandError("CASTLE_STORAGE_INVALID_SCOPE", 'SharedStorage scope must be "deck" or "user".', command);
383
515
  }
384
- function asLeaderboardType(value) {
516
+ function asLeaderboardType(value, command) {
385
517
  if (value === "high" || value === "low")
386
518
  return value;
387
- throw new HostCommandError("INVALID_LEADERBOARD_TYPE", "Leaderboard type must be high or low.", "leaderboard.fetch");
519
+ throw new HostCommandError("INVALID_LEADERBOARD_TYPE", "Leaderboard type must be high or low.", command);
388
520
  }
389
521
  function asString(value, field, command) {
390
522
  if (typeof value === "string" && value.length > 0)
@@ -402,6 +534,16 @@ function asOptionalString(value) {
402
534
  function asOptionalNumber(value) {
403
535
  return typeof value === "number" && Number.isFinite(value) ? value : null;
404
536
  }
537
+ // GraphQL hands numeric fields back as numbers or as strings depending on the
538
+ // scalar, so leaderboard payload shaping accepts both.
539
+ function asNumberish(value) {
540
+ if (typeof value === "number")
541
+ return Number.isFinite(value) ? value : null;
542
+ if (typeof value !== "string")
543
+ return null;
544
+ const parsed = Number.parseFloat(value);
545
+ return Number.isFinite(parsed) ? parsed : null;
546
+ }
405
547
  const DECK_STORAGE_QUERY = `
406
548
  query CastleDeckStorage($deckId: ID!, $sessionId: ID) {
407
549
  deckStorage(deckId: $deckId, sessionId: $sessionId)
@@ -507,8 +649,104 @@ const LEADERBOARD_V2_MUTATION = `
507
649
  username
508
650
  }
509
651
  }
652
+ streakCredit { kind priorStreak newStreak }
653
+ }
654
+ }
655
+ `;
656
+ // The selection the host's own leaderboard UI renders from: the engine's full
657
+ // one (core/src/leaderboards.cpp getLeaderboard), which is a superset of the
658
+ // deck-facing one above. It is the only net-new artifact of `leaderboard.show`
659
+ // — the query text; every behavioural decision stays in the SDK's one copy.
660
+ const LEADERBOARD_FULL_FIELDS = `
661
+ parties {
662
+ partyId
663
+ status
664
+ userIds
665
+ name
666
+ users {
667
+ userId
668
+ username
669
+ usernameStyle { color }
670
+ usernameBadge { smallBadgeUrl }
671
+ photo { smallAvatarUrl }
672
+ }
673
+ highlightUsers {
674
+ userId
675
+ photo { smallAvatarUrl }
676
+ }
677
+ chatChannel {
678
+ chatChannelId
679
+ lastChatMessageForDeck {
680
+ createdTime
681
+ fromUser { userId }
682
+ }
510
683
  }
511
684
  }
685
+ yourScore { score }
686
+ list {
687
+ place
688
+ score
689
+ isDeckCreator
690
+ isLinkSharer
691
+ user {
692
+ userId
693
+ username
694
+ usernameStyle { color }
695
+ usernameBadge { smallBadgeUrl }
696
+ isAnonymous
697
+ photoFrame { smallFrameUrl }
698
+ photo { smallAvatarUrl }
699
+ }
700
+ }
701
+ followList {
702
+ place
703
+ score
704
+ isRequiredUser
705
+ isDeckCreator
706
+ isLinkSharer
707
+ user {
708
+ userId
709
+ username
710
+ usernameStyle { color }
711
+ usernameBadge { smallBadgeUrl }
712
+ isAnonymous
713
+ photoFrame { smallFrameUrl }
714
+ photo { smallAvatarUrl }
715
+ }
716
+ }
717
+ streakCredit { kind priorStreak newStreak }
718
+ `;
719
+ const LEADERBOARD_FULL_ARGS = `
720
+ deckId: $deckId
721
+ variable: $variable
722
+ type: $type
723
+ filter: $filter
724
+ label: $label
725
+ includeFollowList: $includeFollowList
726
+ includeParties: $includeParties
727
+ scope: $scope
728
+ url: $url
729
+ `;
730
+ const LEADERBOARD_FULL_VARS = `
731
+ $deckId: ID!
732
+ $variable: String!
733
+ $type: LeaderboardType!
734
+ $filter: LeaderboardFilter!
735
+ $label: String
736
+ $includeFollowList: Boolean
737
+ $includeParties: Boolean
738
+ $scope: String
739
+ $url: String
740
+ `;
741
+ const LEADERBOARD_FULL_QUERY = `
742
+ query CastleLeaderboardFull(${LEADERBOARD_FULL_VARS}) {
743
+ leaderboard(${LEADERBOARD_FULL_ARGS}) {${LEADERBOARD_FULL_FIELDS}}
744
+ }
745
+ `;
746
+ const LEADERBOARD_V2_FULL_MUTATION = `
747
+ mutation CastleLeaderboardV2Full(${LEADERBOARD_FULL_VARS}, $score: Float!) {
748
+ leaderboardV2(${LEADERBOARD_FULL_ARGS}, score: $score) {${LEADERBOARD_FULL_FIELDS}}
749
+ }
512
750
  `;
513
751
  const SAVE_LEADERBOARD_MUTATION = `
514
752
  mutation CastleSaveVariableToLeaderboard(
@@ -0,0 +1,73 @@
1
+ // Hand-written declaration for the vendored, self-contained leaderboardPanel.js
2
+ // (runtime copied here by castle-experimental-web/scripts/copy-host-module.mjs).
3
+ // Kept tiny and import-free on purpose. TypeScript pairs this with the sibling
4
+ // .js automatically. Mirror the public surface of
5
+ // castle-experimental-web/sdk/src/leaderboardPanel.ts; update if it changes.
6
+
7
+ export declare const CASTLE_LEADERBOARD_PAYLOAD_VERSION: number;
8
+
9
+ export type CastleLeaderboardTab = 'global' | 'forYou';
10
+
11
+ export interface CastleLeaderboardUser {
12
+ userId: string | null;
13
+ username: string | null;
14
+ isAnonymous: boolean;
15
+ color: string | null;
16
+ avatarUrl: string | null;
17
+ badgeUrl: string | null;
18
+ frameUrl: string | null;
19
+ }
20
+
21
+ export interface CastleLeaderboardRow {
22
+ place: number | null;
23
+ score: string;
24
+ isDeckCreator: boolean;
25
+ isLinkSharer: boolean;
26
+ isRequiredUser: boolean;
27
+ user: CastleLeaderboardUser;
28
+ }
29
+
30
+ export interface CastleLeaderboardParty {
31
+ partyId: string | null;
32
+ name: string;
33
+ avatarUrls: string[];
34
+ lastMessageTime: string | null;
35
+ }
36
+
37
+ export interface CastleLeaderboardPayload {
38
+ v: number;
39
+ deckId: string | null;
40
+ variable: string;
41
+ scope: string | null;
42
+ type: 'high' | 'low';
43
+ label: string;
44
+ isAnonymous: boolean;
45
+ showParties: boolean;
46
+ // Set while the host has put the board up but its rows haven't landed yet.
47
+ loading?: boolean;
48
+ yourScore: number | null;
49
+ list: CastleLeaderboardRow[];
50
+ followList: CastleLeaderboardRow[];
51
+ parties: CastleLeaderboardParty[];
52
+ }
53
+
54
+ export type CastleLeaderboardEvent =
55
+ | { type: 'ready' }
56
+ | { type: 'close' }
57
+ | { type: 'share' }
58
+ | { type: 'openProfile'; userId: string | null; username: string | null }
59
+ | { type: 'savePrompt'; userId: string | null }
60
+ | { type: 'switchTab'; tab: CastleLeaderboardTab };
61
+
62
+ export interface CastleLeaderboardPanel {
63
+ render(payload: CastleLeaderboardPayload): void;
64
+ clear(): void;
65
+ destroy(): void;
66
+ }
67
+
68
+ export declare function mountCastleLeaderboardPanel(
69
+ root: HTMLElement,
70
+ onEvent: (event: CastleLeaderboardEvent) => void
71
+ ): CastleLeaderboardPanel;
72
+
73
+ export declare function castleLeaderboardPanelPageBootstrap(): void;