castle-web-sdk 0.4.0 → 0.4.2

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.
@@ -0,0 +1,456 @@
1
+ import { getAuth, requireAuthToken } from "./auth";
2
+ import { isEdit, requireDeckId } from "./context";
3
+ import { CastleError } from "./errors";
4
+ import { graphqlRequest, type GraphqlVariables } from "./graphql";
5
+
6
+ export type LeaderboardSort = "high" | "low";
7
+ export type LeaderboardScope = string;
8
+
9
+ export interface LeaderboardOptions {
10
+ scope?: LeaderboardScope | null;
11
+ }
12
+
13
+ export interface LeaderboardEntry {
14
+ place: number;
15
+ value: number;
16
+ username: string;
17
+ userId?: string;
18
+ }
19
+
20
+ export interface LeaderboardData {
21
+ list: LeaderboardEntry[];
22
+ playerRank?: number;
23
+ playerValue?: number;
24
+ }
25
+
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
+ interface PendingLeaderboardWrite {
36
+ deckId: string;
37
+ variable: string;
38
+ scope: string | null;
39
+ highScore: number;
40
+ lowScore: number;
41
+ isHighDirty: boolean;
42
+ isLowDirty: boolean;
43
+ }
44
+
45
+ interface LeaderboardWriteJob {
46
+ record: PendingLeaderboardWrite;
47
+ type: "high" | "low" | "both";
48
+ score: number;
49
+ }
50
+
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
+ const LEADERBOARD_FLUSH_INTERVAL_MS = 5000;
76
+
77
+ const leaderboardWrites = new Map<string, PendingLeaderboardWrite>();
78
+ let leaderboardFlushTimer: ReturnType<typeof setTimeout> | null = null;
79
+ let leaderboardFlushPromise: Promise<void> | null = null;
80
+ let leaderboardUnloadHooked = false;
81
+
82
+ export const Leaderboard = {
83
+ write(
84
+ variable: string,
85
+ score: number,
86
+ options: LeaderboardOptions = {},
87
+ ): void {
88
+ writeLeaderboard(variable, score, options);
89
+ },
90
+
91
+ fetch(
92
+ variable: string,
93
+ type: LeaderboardSort,
94
+ options: LeaderboardOptions = {},
95
+ ): Promise<LeaderboardData> {
96
+ return fetchLeaderboardData(variable, type, options);
97
+ },
98
+ } as const;
99
+
100
+ function writeLeaderboard(
101
+ variable: string,
102
+ score: number,
103
+ options: LeaderboardOptions,
104
+ ): void {
105
+ if (isEdit()) return;
106
+ void bufferLeaderboardWrite(variable, score, options).catch(
107
+ reportLeaderboardError,
108
+ );
109
+ }
110
+
111
+ async function fetchLeaderboardData(
112
+ variable: string,
113
+ type: LeaderboardSort,
114
+ options: LeaderboardOptions,
115
+ ): Promise<LeaderboardData> {
116
+ const request = await leaderboardRequest(
117
+ "Leaderboard.fetch",
118
+ variable,
119
+ type,
120
+ options,
121
+ );
122
+ return normalizeLeaderboard(await fetchLeaderboard(request), request.userId);
123
+ }
124
+
125
+ async function bufferLeaderboardWrite(
126
+ variable: string,
127
+ score: number,
128
+ options: LeaderboardOptions,
129
+ ): Promise<void> {
130
+ assertLeaderboardVariable(variable, "Leaderboard.write");
131
+ assertLeaderboardScore(score, "Leaderboard.write");
132
+ const deckId = await requireDeckId("Leaderboard.write");
133
+ await requireAuthToken("Leaderboard.write");
134
+ const scope = leaderboardScope(options);
135
+ const key = leaderboardWriteKey(deckId, variable, scope);
136
+ const record = leaderboardWrites.get(key);
137
+ if (record) {
138
+ updatePendingLeaderboardWrite(record, score);
139
+ } else {
140
+ leaderboardWrites.set(
141
+ key,
142
+ newPendingLeaderboardWrite(deckId, variable, scope, score),
143
+ );
144
+ }
145
+ ensureLeaderboardUnloadFlush();
146
+ scheduleLeaderboardFlush();
147
+ }
148
+
149
+ function newPendingLeaderboardWrite(
150
+ deckId: string,
151
+ variable: string,
152
+ scope: string | null,
153
+ score: number,
154
+ ): PendingLeaderboardWrite {
155
+ return {
156
+ deckId,
157
+ variable,
158
+ scope,
159
+ highScore: score,
160
+ lowScore: score,
161
+ isHighDirty: true,
162
+ isLowDirty: true,
163
+ };
164
+ }
165
+
166
+ function updatePendingLeaderboardWrite(
167
+ record: PendingLeaderboardWrite,
168
+ score: number,
169
+ ): void {
170
+ if (score > record.highScore) {
171
+ record.highScore = score;
172
+ record.isHighDirty = true;
173
+ }
174
+ if (score < record.lowScore) {
175
+ record.lowScore = score;
176
+ record.isLowDirty = true;
177
+ }
178
+ }
179
+
180
+ function scheduleLeaderboardFlush(): void {
181
+ if (leaderboardFlushTimer) return;
182
+ leaderboardFlushTimer = setTimeout(() => {
183
+ leaderboardFlushTimer = null;
184
+ void flushLeaderboardWrites().catch(reportLeaderboardError);
185
+ }, LEADERBOARD_FLUSH_INTERVAL_MS);
186
+ }
187
+
188
+ function ensureLeaderboardUnloadFlush(): void {
189
+ if (leaderboardUnloadHooked || typeof window === "undefined") return;
190
+ leaderboardUnloadHooked = true;
191
+ window.addEventListener("pagehide", () => {
192
+ void flushLeaderboardWrites().catch(reportLeaderboardError);
193
+ });
194
+ }
195
+
196
+ async function flushLeaderboardWrites(): Promise<void> {
197
+ if (leaderboardFlushPromise) return leaderboardFlushPromise;
198
+ leaderboardFlushPromise = flushLeaderboardWritesOnce().finally(() => {
199
+ leaderboardFlushPromise = null;
200
+ if (hasDirtyLeaderboardWrites()) scheduleLeaderboardFlush();
201
+ });
202
+ return leaderboardFlushPromise;
203
+ }
204
+
205
+ 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);
211
+ markLeaderboardWriteClean(job);
212
+ }
213
+ }
214
+
215
+ function leaderboardWriteJobs(): LeaderboardWriteJob[] {
216
+ const jobs: LeaderboardWriteJob[] = [];
217
+ for (const record of leaderboardWrites.values()) {
218
+ if (
219
+ record.isHighDirty &&
220
+ record.isLowDirty &&
221
+ record.highScore === record.lowScore
222
+ ) {
223
+ jobs.push({ record, type: "both", score: record.highScore });
224
+ } else {
225
+ if (record.isHighDirty)
226
+ jobs.push({ record, type: "high", score: record.highScore });
227
+ if (record.isLowDirty)
228
+ jobs.push({ record, type: "low", score: record.lowScore });
229
+ }
230
+ }
231
+ return jobs;
232
+ }
233
+
234
+ function markLeaderboardWriteClean(job: LeaderboardWriteJob): void {
235
+ const { record, score, type } = job;
236
+ if ((type === "high" || type === "both") && record.highScore === score) {
237
+ record.isHighDirty = false;
238
+ }
239
+ if ((type === "low" || type === "both") && record.lowScore === score) {
240
+ record.isLowDirty = false;
241
+ }
242
+ }
243
+
244
+ function hasDirtyLeaderboardWrites(): boolean {
245
+ for (const record of leaderboardWrites.values()) {
246
+ if (record.isHighDirty || record.isLowDirty) return true;
247
+ }
248
+ return false;
249
+ }
250
+
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
+ async function saveLeaderboardScore(
310
+ record: PendingLeaderboardWrite,
311
+ score: number,
312
+ token: string,
313
+ ): 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;
372
+ }
373
+
374
+ function normalizeLeaderboard(
375
+ leaderboard: GraphqlLeaderboard,
376
+ userId: string | null,
377
+ ): LeaderboardData {
378
+ const list = (leaderboard.list ?? []).map(normalizeLeaderboardEntry);
379
+ const playerRank = playerRankFromList(list, userId);
380
+ const playerValue = scoreNumber(leaderboard.yourScore?.score);
381
+ return {
382
+ list,
383
+ ...(playerRank === undefined ? {} : { playerRank }),
384
+ ...(playerValue === undefined ? {} : { playerValue }),
385
+ };
386
+ }
387
+
388
+ function normalizeLeaderboardEntry(
389
+ entry: GraphqlLeaderboardEntry,
390
+ ): LeaderboardEntry {
391
+ const userId = entry.user?.userId ?? undefined;
392
+ return {
393
+ place: scoreNumber(entry.place) ?? 0,
394
+ value: scoreNumber(entry.score) ?? 0,
395
+ username: entry.user?.username ?? "",
396
+ ...(userId ? { userId } : {}),
397
+ };
398
+ }
399
+
400
+ function playerRankFromList(
401
+ list: LeaderboardEntry[],
402
+ userId: string | null,
403
+ ): number | undefined {
404
+ return userId
405
+ ? list.find((entry) => entry.userId === userId)?.place
406
+ : undefined;
407
+ }
408
+
409
+ function scoreNumber(
410
+ value: string | number | null | undefined,
411
+ ): number | undefined {
412
+ if (typeof value === "number")
413
+ return Number.isFinite(value) ? value : undefined;
414
+ if (typeof value !== "string") return undefined;
415
+ const parsed = Number.parseFloat(value);
416
+ return Number.isFinite(parsed) ? parsed : undefined;
417
+ }
418
+
419
+ function leaderboardWriteKey(
420
+ deckId: string,
421
+ variable: string,
422
+ scope: string | null,
423
+ ): string {
424
+ return `${deckId}::${variable}${scope ? `::${scope}` : ""}`;
425
+ }
426
+
427
+ function assertLeaderboardVariable(variable: string, operation: string): void {
428
+ if (variable.trim().length > 0) return;
429
+ throw new CastleError({
430
+ code: "INVALID_LEADERBOARD_VARIABLE",
431
+ message: "Leaderboard variable must be a non-empty string.",
432
+ operation,
433
+ });
434
+ }
435
+
436
+ function assertLeaderboardScore(score: number, operation: string): void {
437
+ if (Number.isFinite(score)) return;
438
+ throw new CastleError({
439
+ code: "INVALID_LEADERBOARD_SCORE",
440
+ message: "Leaderboard score must be a finite number.",
441
+ operation,
442
+ });
443
+ }
444
+
445
+ function assertLeaderboardType(type: LeaderboardSort, operation: string): void {
446
+ if (type === "high" || type === "low") return;
447
+ throw new CastleError({
448
+ code: "INVALID_LEADERBOARD_TYPE",
449
+ message: "Leaderboard type must be high or low.",
450
+ operation,
451
+ });
452
+ }
453
+
454
+ function reportLeaderboardError(error: unknown): void {
455
+ console.warn("Castle leaderboard request failed", error);
456
+ }