castle-web-sdk 0.4.1 → 0.4.3

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/auth.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export interface CastleAuth {
2
+ token?: string | null;
3
+ userId?: string | null;
4
+ }
5
+ export declare function getAuth(): Promise<CastleAuth>;
6
+ export declare function getAuthToken(): Promise<string | null>;
7
+ export declare function getUserId(): Promise<string | null>;
8
+ export declare function requireAuthToken(operation?: string): Promise<string>;
package/dist/auth.js ADDED
@@ -0,0 +1,52 @@
1
+ import { getCastleEmbed } from "./context";
2
+ import { CastleError } from "./errors";
3
+ let cachedAuth = null;
4
+ let cachedAuthPromise = null;
5
+ export async function getAuth() {
6
+ return getCachedAuth();
7
+ }
8
+ export async function getAuthToken() {
9
+ const auth = await getCachedAuth();
10
+ return auth.token ?? null;
11
+ }
12
+ export async function getUserId() {
13
+ const auth = await getCachedAuth();
14
+ return auth.userId ?? null;
15
+ }
16
+ export async function requireAuthToken(operation = "Castle API request") {
17
+ const token = await getAuthToken();
18
+ if (!token) {
19
+ throw new CastleError({
20
+ code: "LOGIN_REQUIRED",
21
+ message: "Log in to Castle before using this API.",
22
+ operation,
23
+ });
24
+ }
25
+ return token;
26
+ }
27
+ function getCachedAuth() {
28
+ cachedAuthPromise ??= resolveAuth();
29
+ return cachedAuthPromise;
30
+ }
31
+ async function resolveAuth() {
32
+ if (cachedAuth)
33
+ return cachedAuth;
34
+ const embedded = getCastleEmbed()?.auth;
35
+ if (embedded && (embedded.token || embedded.userId)) {
36
+ cachedAuth = { token: embedded.token, userId: embedded.userId };
37
+ return cachedAuth;
38
+ }
39
+ try {
40
+ const res = await fetch("/__castle/auth");
41
+ if (res.ok) {
42
+ const json = (await res.json());
43
+ cachedAuth = { token: json.token, userId: json.userId };
44
+ return cachedAuth;
45
+ }
46
+ }
47
+ catch {
48
+ // endpoint missing or offline -- treat as unauthenticated
49
+ }
50
+ cachedAuth = {};
51
+ return cachedAuth;
52
+ }
@@ -0,0 +1,11 @@
1
+ export { isEdit } from "./context";
2
+ export { CastleError } from "./errors";
3
+ export { Leaderboard } from "./leaderboard";
4
+ export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardSort, } from "./leaderboard";
5
+ export { CARD_RATIO, initCard, setup, writeFile } from "./runtime";
6
+ export { SharedStorage, Storage } from "./storage";
7
+ export { Time } from "./time";
8
+ export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
9
+ export type { Json } from "./types";
10
+ export { User } from "./user";
11
+ export type { CastleUser, CastleUserApi } from "./user";
package/dist/castle.js ADDED
@@ -0,0 +1,8 @@
1
+ // Castle Web SDK
2
+ export { isEdit } from "./context";
3
+ export { CastleError } from "./errors";
4
+ export { Leaderboard } from "./leaderboard";
5
+ export { CARD_RATIO, initCard, setup, writeFile } from "./runtime";
6
+ export { SharedStorage, Storage } from "./storage";
7
+ export { Time } from "./time";
8
+ export { User } from "./user";
@@ -0,0 +1,30 @@
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
+ }
10
+ export interface CastleEmbed {
11
+ edit?: boolean;
12
+ feed?: boolean;
13
+ auth?: CastleEmbedAuth;
14
+ deck?: CastleDeckContext;
15
+ deckId?: string;
16
+ cardId?: string;
17
+ sessionId?: string | null;
18
+ graphqlEndpoint?: string;
19
+ }
20
+ declare global {
21
+ interface Window {
22
+ CastleEmbed?: CastleEmbed;
23
+ }
24
+ }
25
+ export declare function getCastleEmbed(): CastleEmbed | undefined;
26
+ 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 {};
@@ -0,0 +1,86 @@
1
+ import { CastleError } from "./errors";
2
+ let configuredDeckContext = {};
3
+ let cachedDeckContext = null;
4
+ let cachedDeckContextPromise = null;
5
+ export function getCastleEmbed() {
6
+ return typeof window === "undefined" ? undefined : window.CastleEmbed;
7
+ }
8
+ export function isEdit() {
9
+ try {
10
+ const params = new URLSearchParams(window.location.search);
11
+ const override = params.get("edit");
12
+ if (override === "0" || override === "false")
13
+ return false;
14
+ }
15
+ catch {
16
+ // ignore -- window.location may be unavailable
17
+ }
18
+ return !!getCastleEmbed()?.edit;
19
+ }
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
+ }
@@ -0,0 +1,22 @@
1
+ export interface GraphqlErrorPayload {
2
+ message?: string;
3
+ extensions?: Record<string, unknown>;
4
+ path?: Array<string | number>;
5
+ }
6
+ interface CastleErrorInput {
7
+ code: string;
8
+ message: string;
9
+ operation?: string;
10
+ status?: number;
11
+ extensions?: Record<string, unknown>;
12
+ errors?: GraphqlErrorPayload[];
13
+ }
14
+ export declare class CastleError extends Error {
15
+ code: string;
16
+ operation?: string;
17
+ status?: number;
18
+ extensions?: Record<string, unknown>;
19
+ errors?: GraphqlErrorPayload[];
20
+ constructor(input: CastleErrorInput);
21
+ }
22
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,16 @@
1
+ export class CastleError extends Error {
2
+ code;
3
+ operation;
4
+ status;
5
+ extensions;
6
+ errors;
7
+ constructor(input) {
8
+ super(input.message);
9
+ this.name = "CastleError";
10
+ this.code = input.code;
11
+ this.operation = input.operation;
12
+ this.status = input.status;
13
+ this.extensions = input.extensions;
14
+ this.errors = input.errors;
15
+ }
16
+ }
@@ -0,0 +1,15 @@
1
+ import type { Json } from "./types";
2
+ export declare const GRAPHQL_ENDPOINT = "https://api.castle.xyz/graphql";
3
+ export type GraphqlVariables = Record<string, Json | undefined>;
4
+ export type { GraphqlErrorPayload } from "./errors";
5
+ export interface GraphqlRequestOptions {
6
+ endpoint?: string;
7
+ headers?: Record<string, string>;
8
+ operation?: string;
9
+ operationName?: string;
10
+ requireAuth?: boolean;
11
+ signal?: AbortSignal;
12
+ timeoutMs?: number;
13
+ token?: string | null;
14
+ }
15
+ export declare function graphqlRequest<TData = unknown, TVariables extends GraphqlVariables = GraphqlVariables>(query: string, variables?: TVariables, options?: GraphqlRequestOptions): Promise<TData>;
@@ -0,0 +1,120 @@
1
+ import { getAuthToken } from "./auth";
2
+ import { getCastleEmbed } from "./context";
3
+ import { CastleError } from "./errors";
4
+ export const GRAPHQL_ENDPOINT = "https://api.castle.xyz/graphql";
5
+ export async function graphqlRequest(query, variables, options = {}) {
6
+ const operation = options.operation ?? guessGraphqlOperation(query);
7
+ const token = options.token ?? (await getAuthToken());
8
+ if (options.requireAuth && !token) {
9
+ throw new CastleError({
10
+ code: "LOGIN_REQUIRED",
11
+ message: "Log in to Castle before using this API.",
12
+ operation,
13
+ });
14
+ }
15
+ const response = await fetchGraphql(query, variables, {
16
+ ...options,
17
+ operation,
18
+ token,
19
+ });
20
+ if (response.errors?.length) {
21
+ throw graphqlError(response.errors, operation);
22
+ }
23
+ if (response.data === null || response.data === undefined) {
24
+ throw new CastleError({
25
+ code: "GRAPHQL_NO_DATA",
26
+ message: "Castle GraphQL response did not include data.",
27
+ operation,
28
+ });
29
+ }
30
+ return response.data;
31
+ }
32
+ async function fetchGraphql(query, variables, options) {
33
+ const headers = graphqlHeaders(options);
34
+ const abort = requestAbort(options);
35
+ try {
36
+ const response = await fetch(graphqlEndpoint(options), {
37
+ method: "POST",
38
+ headers,
39
+ body: JSON.stringify(graphqlBody(query, variables, options.operationName)),
40
+ signal: abort.signal,
41
+ });
42
+ const json = (await readGraphqlJson(response, options.operation));
43
+ if (!response.ok) {
44
+ throw new CastleError({
45
+ code: "GRAPHQL_HTTP_ERROR",
46
+ message: `Castle GraphQL request failed with HTTP ${response.status}.`,
47
+ operation: options.operation,
48
+ status: response.status,
49
+ errors: json?.errors,
50
+ });
51
+ }
52
+ return json ?? {};
53
+ }
54
+ finally {
55
+ abort.cleanup();
56
+ }
57
+ }
58
+ function requestAbort(options) {
59
+ if (options.signal)
60
+ return { signal: options.signal, cleanup: () => { } };
61
+ const controller = new AbortController();
62
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10000);
63
+ return { signal: controller.signal, cleanup: () => clearTimeout(timeout) };
64
+ }
65
+ function graphqlEndpoint(options) {
66
+ return (options.endpoint ?? getCastleEmbed()?.graphqlEndpoint ?? GRAPHQL_ENDPOINT);
67
+ }
68
+ function graphqlHeaders(options) {
69
+ const headers = {
70
+ Accept: "application/json",
71
+ "Content-Type": "application/json",
72
+ "X-OS": "web",
73
+ "X-TimeZone": Intl.DateTimeFormat().resolvedOptions().timeZone,
74
+ ...options.headers,
75
+ };
76
+ if (options.token)
77
+ headers["X-Auth-Token"] = options.token;
78
+ return headers;
79
+ }
80
+ function graphqlBody(query, variables, operationName) {
81
+ const body = { query };
82
+ if (variables)
83
+ body.variables = variables;
84
+ if (operationName)
85
+ body.operationName = operationName;
86
+ return body;
87
+ }
88
+ async function readGraphqlJson(response, operation) {
89
+ try {
90
+ return await response.json();
91
+ }
92
+ catch {
93
+ if (response.ok)
94
+ return {};
95
+ throw new CastleError({
96
+ code: "GRAPHQL_BAD_RESPONSE",
97
+ message: "Castle GraphQL response was not valid JSON.",
98
+ operation,
99
+ status: response.status,
100
+ });
101
+ }
102
+ }
103
+ function graphqlError(errors, operation) {
104
+ const first = errors[0];
105
+ return new CastleError({
106
+ code: graphqlErrorCode(first),
107
+ message: first?.message ?? "Castle GraphQL request failed.",
108
+ operation,
109
+ extensions: first?.extensions,
110
+ errors,
111
+ });
112
+ }
113
+ function graphqlErrorCode(error) {
114
+ const code = error?.extensions?.code;
115
+ return typeof code === "string" ? code : "GRAPHQL_ERROR";
116
+ }
117
+ function guessGraphqlOperation(query) {
118
+ const match = /\b(?:query|mutation|subscription)\s+([A-Za-z0-9_]+)/.exec(query);
119
+ return match?.[1] ?? "Castle GraphQL request";
120
+ }
@@ -0,0 +1,20 @@
1
+ export type LeaderboardSort = "high" | "low";
2
+ export type LeaderboardScope = string;
3
+ export interface LeaderboardOptions {
4
+ scope?: LeaderboardScope | null;
5
+ }
6
+ export interface LeaderboardEntry {
7
+ place: number;
8
+ value: number;
9
+ username: string;
10
+ userId?: string;
11
+ }
12
+ export interface LeaderboardData {
13
+ list: LeaderboardEntry[];
14
+ playerRank?: number;
15
+ playerValue?: number;
16
+ }
17
+ export declare const Leaderboard: {
18
+ readonly write: (variable: string, score: number, options?: LeaderboardOptions) => void;
19
+ readonly fetch: (variable: string, type: LeaderboardSort, options?: LeaderboardOptions) => Promise<LeaderboardData>;
20
+ };
@@ -0,0 +1,296 @@
1
+ import { getAuth, requireAuthToken } from "./auth";
2
+ import { isEdit, requireDeckId } from "./context";
3
+ import { CastleError } from "./errors";
4
+ import { graphqlRequest } from "./graphql";
5
+ const LEADERBOARD_FLUSH_INTERVAL_MS = 5000;
6
+ const leaderboardWrites = new Map();
7
+ let leaderboardFlushTimer = null;
8
+ let leaderboardFlushPromise = null;
9
+ let leaderboardUnloadHooked = false;
10
+ export const Leaderboard = {
11
+ write(variable, score, options = {}) {
12
+ writeLeaderboard(variable, score, options);
13
+ },
14
+ fetch(variable, type, options = {}) {
15
+ return fetchLeaderboardData(variable, type, options);
16
+ },
17
+ };
18
+ function writeLeaderboard(variable, score, options) {
19
+ if (isEdit())
20
+ return;
21
+ void bufferLeaderboardWrite(variable, score, options).catch(reportLeaderboardError);
22
+ }
23
+ async function fetchLeaderboardData(variable, type, options) {
24
+ const request = await leaderboardRequest("Leaderboard.fetch", variable, type, options);
25
+ return normalizeLeaderboard(await fetchLeaderboard(request), request.userId);
26
+ }
27
+ async function bufferLeaderboardWrite(variable, score, options) {
28
+ assertLeaderboardVariable(variable, "Leaderboard.write");
29
+ assertLeaderboardScore(score, "Leaderboard.write");
30
+ const deckId = await requireDeckId("Leaderboard.write");
31
+ await requireAuthToken("Leaderboard.write");
32
+ const scope = leaderboardScope(options);
33
+ const key = leaderboardWriteKey(deckId, variable, scope);
34
+ const record = leaderboardWrites.get(key);
35
+ if (record) {
36
+ updatePendingLeaderboardWrite(record, score);
37
+ }
38
+ else {
39
+ leaderboardWrites.set(key, newPendingLeaderboardWrite(deckId, variable, scope, score));
40
+ }
41
+ ensureLeaderboardUnloadFlush();
42
+ scheduleLeaderboardFlush();
43
+ }
44
+ function newPendingLeaderboardWrite(deckId, variable, scope, score) {
45
+ return {
46
+ deckId,
47
+ variable,
48
+ scope,
49
+ highScore: score,
50
+ lowScore: score,
51
+ isHighDirty: true,
52
+ isLowDirty: true,
53
+ };
54
+ }
55
+ function updatePendingLeaderboardWrite(record, score) {
56
+ if (score > record.highScore) {
57
+ record.highScore = score;
58
+ record.isHighDirty = true;
59
+ }
60
+ if (score < record.lowScore) {
61
+ record.lowScore = score;
62
+ record.isLowDirty = true;
63
+ }
64
+ }
65
+ function scheduleLeaderboardFlush() {
66
+ if (leaderboardFlushTimer)
67
+ return;
68
+ leaderboardFlushTimer = setTimeout(() => {
69
+ leaderboardFlushTimer = null;
70
+ void flushLeaderboardWrites().catch(reportLeaderboardError);
71
+ }, LEADERBOARD_FLUSH_INTERVAL_MS);
72
+ }
73
+ function ensureLeaderboardUnloadFlush() {
74
+ if (leaderboardUnloadHooked || typeof window === "undefined")
75
+ return;
76
+ leaderboardUnloadHooked = true;
77
+ window.addEventListener("pagehide", () => {
78
+ void flushLeaderboardWrites().catch(reportLeaderboardError);
79
+ });
80
+ }
81
+ async function flushLeaderboardWrites() {
82
+ if (leaderboardFlushPromise)
83
+ return leaderboardFlushPromise;
84
+ leaderboardFlushPromise = flushLeaderboardWritesOnce().finally(() => {
85
+ leaderboardFlushPromise = null;
86
+ if (hasDirtyLeaderboardWrites())
87
+ scheduleLeaderboardFlush();
88
+ });
89
+ return leaderboardFlushPromise;
90
+ }
91
+ async function flushLeaderboardWritesOnce() {
92
+ const jobs = leaderboardWriteJobs();
93
+ if (jobs.length === 0)
94
+ return;
95
+ const token = await requireAuthToken("Leaderboard.write");
96
+ for (const job of jobs) {
97
+ await saveLeaderboardScore(job.record, job.score, token);
98
+ markLeaderboardWriteClean(job);
99
+ }
100
+ }
101
+ function leaderboardWriteJobs() {
102
+ const jobs = [];
103
+ for (const record of leaderboardWrites.values()) {
104
+ if (record.isHighDirty &&
105
+ record.isLowDirty &&
106
+ record.highScore === record.lowScore) {
107
+ jobs.push({ record, type: "both", score: record.highScore });
108
+ }
109
+ else {
110
+ if (record.isHighDirty)
111
+ jobs.push({ record, type: "high", score: record.highScore });
112
+ if (record.isLowDirty)
113
+ jobs.push({ record, type: "low", score: record.lowScore });
114
+ }
115
+ }
116
+ return jobs;
117
+ }
118
+ function markLeaderboardWriteClean(job) {
119
+ const { record, score, type } = job;
120
+ if ((type === "high" || type === "both") && record.highScore === score) {
121
+ record.isHighDirty = false;
122
+ }
123
+ if ((type === "low" || type === "both") && record.lowScore === score) {
124
+ record.isLowDirty = false;
125
+ }
126
+ }
127
+ function hasDirtyLeaderboardWrites() {
128
+ for (const record of leaderboardWrites.values()) {
129
+ if (record.isHighDirty || record.isLowDirty)
130
+ return true;
131
+ }
132
+ return false;
133
+ }
134
+ async function leaderboardRequest(operation, variable, type, options) {
135
+ assertLeaderboardVariable(variable, operation);
136
+ assertLeaderboardType(type, operation);
137
+ const [deckId, token, auth] = await Promise.all([
138
+ requireDeckId(operation),
139
+ requireAuthToken(operation),
140
+ getAuth(),
141
+ ]);
142
+ return {
143
+ deckId,
144
+ variable,
145
+ type,
146
+ scope: leaderboardScope(options),
147
+ token,
148
+ userId: auth.userId ?? null,
149
+ };
150
+ }
151
+ async function fetchLeaderboard(request) {
152
+ const data = await graphqlRequest(`
153
+ query CastleLeaderboard(
154
+ $deckId: ID!
155
+ $variable: String!
156
+ $type: LeaderboardType!
157
+ $filter: LeaderboardFilter!
158
+ $includeFollowList: Boolean
159
+ $includeParties: Boolean
160
+ $scope: String
161
+ ) {
162
+ leaderboard(
163
+ deckId: $deckId
164
+ variable: $variable
165
+ type: $type
166
+ filter: $filter
167
+ includeFollowList: $includeFollowList
168
+ includeParties: $includeParties
169
+ scope: $scope
170
+ ) ${leaderboardFields()}
171
+ }
172
+ `, leaderboardVariables(request), {
173
+ operation: "CastleLeaderboard",
174
+ token: request.token,
175
+ requireAuth: true,
176
+ });
177
+ return data.leaderboard;
178
+ }
179
+ async function saveLeaderboardScore(record, score, token) {
180
+ await graphqlRequest(`
181
+ mutation CastleSaveVariableToLeaderboard(
182
+ $deckId: ID!
183
+ $variable: String!
184
+ $score: Float!
185
+ $scope: String
186
+ ) {
187
+ saveVariableToLeaderboard(
188
+ deckId: $deckId
189
+ variable: $variable
190
+ score: $score
191
+ scope: $scope
192
+ )
193
+ }
194
+ `, {
195
+ deckId: record.deckId,
196
+ variable: record.variable,
197
+ score,
198
+ scope: record.scope,
199
+ }, {
200
+ operation: "CastleSaveVariableToLeaderboard",
201
+ token,
202
+ requireAuth: true,
203
+ });
204
+ }
205
+ function leaderboardVariables(request) {
206
+ return {
207
+ deckId: request.deckId,
208
+ variable: request.variable,
209
+ type: request.type,
210
+ filter: "dedupUsers",
211
+ includeFollowList: false,
212
+ includeParties: false,
213
+ scope: request.scope,
214
+ };
215
+ }
216
+ function leaderboardFields() {
217
+ return `{
218
+ yourScore { score }
219
+ list {
220
+ place
221
+ score
222
+ user {
223
+ userId
224
+ username
225
+ }
226
+ }
227
+ }`;
228
+ }
229
+ function leaderboardScope(options) {
230
+ return options.scope ?? null;
231
+ }
232
+ function normalizeLeaderboard(leaderboard, userId) {
233
+ const list = (leaderboard.list ?? []).map(normalizeLeaderboardEntry);
234
+ const playerRank = playerRankFromList(list, userId);
235
+ const playerValue = scoreNumber(leaderboard.yourScore?.score);
236
+ return {
237
+ list,
238
+ ...(playerRank === undefined ? {} : { playerRank }),
239
+ ...(playerValue === undefined ? {} : { playerValue }),
240
+ };
241
+ }
242
+ function normalizeLeaderboardEntry(entry) {
243
+ const userId = entry.user?.userId ?? undefined;
244
+ return {
245
+ place: scoreNumber(entry.place) ?? 0,
246
+ value: scoreNumber(entry.score) ?? 0,
247
+ username: entry.user?.username ?? "",
248
+ ...(userId ? { userId } : {}),
249
+ };
250
+ }
251
+ function playerRankFromList(list, userId) {
252
+ return userId
253
+ ? list.find((entry) => entry.userId === userId)?.place
254
+ : undefined;
255
+ }
256
+ function scoreNumber(value) {
257
+ if (typeof value === "number")
258
+ return Number.isFinite(value) ? value : undefined;
259
+ if (typeof value !== "string")
260
+ return undefined;
261
+ const parsed = Number.parseFloat(value);
262
+ return Number.isFinite(parsed) ? parsed : undefined;
263
+ }
264
+ function leaderboardWriteKey(deckId, variable, scope) {
265
+ return `${deckId}::${variable}${scope ? `::${scope}` : ""}`;
266
+ }
267
+ function assertLeaderboardVariable(variable, operation) {
268
+ if (variable.trim().length > 0)
269
+ return;
270
+ throw new CastleError({
271
+ code: "INVALID_LEADERBOARD_VARIABLE",
272
+ message: "Leaderboard variable must be a non-empty string.",
273
+ operation,
274
+ });
275
+ }
276
+ function assertLeaderboardScore(score, operation) {
277
+ if (Number.isFinite(score))
278
+ return;
279
+ throw new CastleError({
280
+ code: "INVALID_LEADERBOARD_SCORE",
281
+ message: "Leaderboard score must be a finite number.",
282
+ operation,
283
+ });
284
+ }
285
+ function assertLeaderboardType(type, operation) {
286
+ if (type === "high" || type === "low")
287
+ return;
288
+ throw new CastleError({
289
+ code: "INVALID_LEADERBOARD_TYPE",
290
+ message: "Leaderboard type must be high or low.",
291
+ operation,
292
+ });
293
+ }
294
+ function reportLeaderboardError(error) {
295
+ console.warn("Castle leaderboard request failed", error);
296
+ }