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.
- package/dist/auth.d.ts +8 -0
- package/dist/auth.js +52 -0
- package/dist/castle.d.ts +11 -0
- package/dist/castle.js +8 -0
- package/dist/context.d.ts +30 -0
- package/dist/context.js +86 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +16 -0
- package/dist/graphql.d.ts +15 -0
- package/dist/graphql.js +120 -0
- package/dist/leaderboard.d.ts +20 -0
- package/dist/leaderboard.js +296 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +288 -0
- package/dist/storage.d.ts +17 -0
- package/dist/storage.js +405 -0
- package/dist/time.d.ts +17 -0
- package/dist/time.js +131 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.js +1 -0
- package/dist/user.d.ts +9 -0
- package/dist/user.js +58 -0
- package/package.json +30 -3
- package/src/auth.ts +64 -0
- package/src/castle.ts +19 -0
- package/src/context.ts +124 -0
- package/src/errors.ts +32 -0
- package/src/graphql.ts +182 -0
- package/src/leaderboard.ts +456 -0
- package/src/runtime.ts +345 -0
- package/src/storage.ts +636 -0
- package/src/time.ts +226 -0
- package/src/types.ts +7 -0
- package/src/user.ts +91 -0
- package/AGENTS.md +0 -27
- package/castle.js +0 -116
|
@@ -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
|
+
}
|