keeperboard 1.0.4 → 2.0.1
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 +198 -294
- package/dist/index.d.mts +344 -109
- package/dist/index.d.ts +344 -109
- package/dist/index.js +475 -47
- package/dist/index.mjs +470 -46
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -20,9 +20,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
Cache: () => Cache,
|
|
23
24
|
KeeperBoardClient: () => KeeperBoardClient,
|
|
24
25
|
KeeperBoardError: () => KeeperBoardError,
|
|
25
|
-
|
|
26
|
+
KeeperBoardSession: () => KeeperBoardSession,
|
|
27
|
+
PlayerIdentity: () => PlayerIdentity,
|
|
28
|
+
RetryQueue: () => RetryQueue,
|
|
29
|
+
validateName: () => validateName
|
|
26
30
|
});
|
|
27
31
|
module.exports = __toCommonJS(index_exports);
|
|
28
32
|
|
|
@@ -42,51 +46,87 @@ var _KeeperBoardClient = class _KeeperBoardClient {
|
|
|
42
46
|
const url = config.apiUrl ?? _KeeperBoardClient.DEFAULT_API_URL;
|
|
43
47
|
this.apiUrl = url.replace(/\/$/, "");
|
|
44
48
|
this.apiKey = config.apiKey;
|
|
49
|
+
this.defaultLeaderboard = config.defaultLeaderboard;
|
|
45
50
|
}
|
|
46
|
-
|
|
51
|
+
// ============================================
|
|
52
|
+
// SCORE SUBMISSION
|
|
53
|
+
// ============================================
|
|
54
|
+
/**
|
|
55
|
+
* Submit a score. Only updates if the new score is higher than the existing one.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const result = await client.submitScore({
|
|
59
|
+
* playerGuid: 'abc-123',
|
|
60
|
+
* playerName: 'ACE',
|
|
61
|
+
* score: 1500,
|
|
62
|
+
* });
|
|
63
|
+
* console.log(result.rank, result.isNewHighScore);
|
|
64
|
+
*/
|
|
65
|
+
async submitScore(options) {
|
|
66
|
+
const leaderboard = options.leaderboard ?? this.defaultLeaderboard;
|
|
47
67
|
const params = new URLSearchParams();
|
|
48
|
-
if (leaderboard)
|
|
49
|
-
params.set("leaderboard", leaderboard);
|
|
50
|
-
}
|
|
68
|
+
if (leaderboard) params.set("leaderboard", leaderboard);
|
|
51
69
|
const url = `${this.apiUrl}/api/v1/scores${params.toString() ? "?" + params.toString() : ""}`;
|
|
52
70
|
const body = {
|
|
53
|
-
player_guid: playerGuid,
|
|
54
|
-
player_name: playerName,
|
|
55
|
-
score,
|
|
56
|
-
...metadata && { metadata }
|
|
71
|
+
player_guid: options.playerGuid,
|
|
72
|
+
player_name: options.playerName,
|
|
73
|
+
score: options.score,
|
|
74
|
+
...options.metadata && { metadata: options.metadata }
|
|
57
75
|
};
|
|
58
|
-
|
|
76
|
+
const raw = await this.request(url, {
|
|
59
77
|
method: "POST",
|
|
60
78
|
body: JSON.stringify(body)
|
|
61
79
|
});
|
|
80
|
+
return this.mapScoreResponse(raw);
|
|
62
81
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
// ============================================
|
|
83
|
+
// LEADERBOARD
|
|
84
|
+
// ============================================
|
|
85
|
+
/**
|
|
86
|
+
* Get a leaderboard. Supports pagination and version-based lookups for
|
|
87
|
+
* time-based boards.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* // Top 10 on default board
|
|
91
|
+
* const lb = await client.getLeaderboard();
|
|
92
|
+
*
|
|
93
|
+
* // Top 25 on a specific board
|
|
94
|
+
* const lb = await client.getLeaderboard({ leaderboard: 'Weekly', limit: 25 });
|
|
95
|
+
*
|
|
96
|
+
* // Historical version
|
|
97
|
+
* const lb = await client.getLeaderboard({ leaderboard: 'Weekly', version: 3 });
|
|
98
|
+
*/
|
|
99
|
+
async getLeaderboard(options) {
|
|
100
|
+
const leaderboard = options?.leaderboard ?? this.defaultLeaderboard;
|
|
101
|
+
const limit = options?.limit ?? 10;
|
|
102
|
+
const offset = options?.offset ?? 0;
|
|
74
103
|
const params = new URLSearchParams();
|
|
75
|
-
params.set("leaderboard", name);
|
|
76
|
-
params.set("version", String(version));
|
|
77
104
|
params.set("limit", String(Math.min(limit, 100)));
|
|
78
105
|
params.set("offset", String(offset));
|
|
106
|
+
if (leaderboard) params.set("leaderboard", leaderboard);
|
|
107
|
+
if (options?.version !== void 0) params.set("version", String(options.version));
|
|
79
108
|
const url = `${this.apiUrl}/api/v1/leaderboard?${params.toString()}`;
|
|
80
|
-
|
|
109
|
+
const raw = await this.request(url, { method: "GET" });
|
|
110
|
+
return this.mapLeaderboardResponse(raw);
|
|
81
111
|
}
|
|
82
|
-
|
|
112
|
+
// ============================================
|
|
113
|
+
// PLAYER
|
|
114
|
+
// ============================================
|
|
115
|
+
/**
|
|
116
|
+
* Get a player's rank and score. Returns `null` if the player has no score.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* const player = await client.getPlayerRank({ playerGuid: 'abc-123' });
|
|
120
|
+
* if (player) console.log(`Rank #${player.rank}`);
|
|
121
|
+
*/
|
|
122
|
+
async getPlayerRank(options) {
|
|
123
|
+
const leaderboard = options.leaderboard ?? this.defaultLeaderboard;
|
|
83
124
|
const params = new URLSearchParams();
|
|
84
|
-
if (leaderboard)
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
const url = `${this.apiUrl}/api/v1/player/${encodeURIComponent(playerGuid)}${params.toString() ? "?" + params.toString() : ""}`;
|
|
125
|
+
if (leaderboard) params.set("leaderboard", leaderboard);
|
|
126
|
+
const url = `${this.apiUrl}/api/v1/player/${encodeURIComponent(options.playerGuid)}${params.toString() ? "?" + params.toString() : ""}`;
|
|
88
127
|
try {
|
|
89
|
-
|
|
128
|
+
const raw = await this.request(url, { method: "GET" });
|
|
129
|
+
return this.mapPlayerResponse(raw);
|
|
90
130
|
} catch (error) {
|
|
91
131
|
if (error instanceof KeeperBoardError && error.code === "NOT_FOUND") {
|
|
92
132
|
return null;
|
|
@@ -94,37 +134,52 @@ var _KeeperBoardClient = class _KeeperBoardClient {
|
|
|
94
134
|
throw error;
|
|
95
135
|
}
|
|
96
136
|
}
|
|
97
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Update a player's display name.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* const player = await client.updatePlayerName({
|
|
142
|
+
* playerGuid: 'abc-123',
|
|
143
|
+
* newName: 'MAVERICK',
|
|
144
|
+
* });
|
|
145
|
+
*/
|
|
146
|
+
async updatePlayerName(options) {
|
|
147
|
+
const leaderboard = options.leaderboard ?? this.defaultLeaderboard;
|
|
98
148
|
const params = new URLSearchParams();
|
|
99
|
-
if (leaderboard)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const url = `${this.apiUrl}/api/v1/player/${encodeURIComponent(playerGuid)}${params.toString() ? "?" + params.toString() : ""}`;
|
|
103
|
-
return this.request(url, {
|
|
149
|
+
if (leaderboard) params.set("leaderboard", leaderboard);
|
|
150
|
+
const url = `${this.apiUrl}/api/v1/player/${encodeURIComponent(options.playerGuid)}${params.toString() ? "?" + params.toString() : ""}`;
|
|
151
|
+
const raw = await this.request(url, {
|
|
104
152
|
method: "PUT",
|
|
105
|
-
body: JSON.stringify({ player_name: newName })
|
|
153
|
+
body: JSON.stringify({ player_name: options.newName })
|
|
106
154
|
});
|
|
155
|
+
return this.mapPlayerResponse(raw);
|
|
107
156
|
}
|
|
108
|
-
|
|
157
|
+
// ============================================
|
|
158
|
+
// CLAIM (for migrated scores)
|
|
159
|
+
// ============================================
|
|
160
|
+
/**
|
|
161
|
+
* Claim a migrated score by matching player name.
|
|
162
|
+
* Used when scores were imported without player GUIDs.
|
|
163
|
+
*/
|
|
164
|
+
async claimScore(options) {
|
|
165
|
+
const leaderboard = options.leaderboard ?? this.defaultLeaderboard;
|
|
109
166
|
const params = new URLSearchParams();
|
|
110
|
-
if (leaderboard)
|
|
111
|
-
params.set("leaderboard", leaderboard);
|
|
112
|
-
}
|
|
167
|
+
if (leaderboard) params.set("leaderboard", leaderboard);
|
|
113
168
|
const url = `${this.apiUrl}/api/v1/claim${params.toString() ? "?" + params.toString() : ""}`;
|
|
114
|
-
|
|
169
|
+
const raw = await this.request(url, {
|
|
115
170
|
method: "POST",
|
|
116
171
|
body: JSON.stringify({
|
|
117
|
-
player_guid: playerGuid,
|
|
118
|
-
player_name: playerName
|
|
172
|
+
player_guid: options.playerGuid,
|
|
173
|
+
player_name: options.playerName
|
|
119
174
|
})
|
|
120
175
|
});
|
|
176
|
+
return this.mapClaimResponse(raw);
|
|
121
177
|
}
|
|
122
178
|
// ============================================
|
|
123
179
|
// HEALTH CHECK
|
|
124
180
|
// ============================================
|
|
125
181
|
/**
|
|
126
|
-
* Check if the API is healthy.
|
|
127
|
-
* This endpoint does not require an API key.
|
|
182
|
+
* Check if the API is healthy. Does not require an API key.
|
|
128
183
|
*/
|
|
129
184
|
async healthCheck() {
|
|
130
185
|
const url = `${this.apiUrl}/api/v1/health`;
|
|
@@ -139,6 +194,51 @@ var _KeeperBoardClient = class _KeeperBoardClient {
|
|
|
139
194
|
return json.data;
|
|
140
195
|
}
|
|
141
196
|
// ============================================
|
|
197
|
+
// RESPONSE MAPPERS (snake_case → camelCase)
|
|
198
|
+
// ============================================
|
|
199
|
+
mapScoreResponse(raw) {
|
|
200
|
+
return {
|
|
201
|
+
id: raw.id,
|
|
202
|
+
playerGuid: raw.player_guid,
|
|
203
|
+
playerName: raw.player_name,
|
|
204
|
+
score: raw.score,
|
|
205
|
+
rank: raw.rank,
|
|
206
|
+
isNewHighScore: raw.is_new_high_score
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
mapLeaderboardResponse(raw) {
|
|
210
|
+
return {
|
|
211
|
+
entries: raw.entries.map((e) => ({
|
|
212
|
+
rank: e.rank,
|
|
213
|
+
playerGuid: e.player_guid,
|
|
214
|
+
playerName: e.player_name,
|
|
215
|
+
score: e.score
|
|
216
|
+
})),
|
|
217
|
+
totalCount: raw.total_count,
|
|
218
|
+
resetSchedule: raw.reset_schedule,
|
|
219
|
+
version: raw.version,
|
|
220
|
+
oldestVersion: raw.oldest_version,
|
|
221
|
+
nextReset: raw.next_reset
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
mapPlayerResponse(raw) {
|
|
225
|
+
return {
|
|
226
|
+
id: raw.id,
|
|
227
|
+
playerGuid: raw.player_guid,
|
|
228
|
+
playerName: raw.player_name,
|
|
229
|
+
score: raw.score,
|
|
230
|
+
rank: raw.rank
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
mapClaimResponse(raw) {
|
|
234
|
+
return {
|
|
235
|
+
claimed: raw.claimed,
|
|
236
|
+
score: raw.score,
|
|
237
|
+
rank: raw.rank,
|
|
238
|
+
playerName: raw.player_name
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// ============================================
|
|
142
242
|
// INTERNAL
|
|
143
243
|
// ============================================
|
|
144
244
|
async request(url, options) {
|
|
@@ -248,9 +348,337 @@ var PlayerIdentity = class {
|
|
|
248
348
|
});
|
|
249
349
|
}
|
|
250
350
|
};
|
|
351
|
+
|
|
352
|
+
// src/Cache.ts
|
|
353
|
+
var Cache = class {
|
|
354
|
+
constructor(ttlMs) {
|
|
355
|
+
this.fetchedAt = 0;
|
|
356
|
+
this.inflight = null;
|
|
357
|
+
this.pendingRefresh = null;
|
|
358
|
+
this.ttlMs = ttlMs;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Get cached value if fresh, otherwise fetch via the provided function.
|
|
362
|
+
* Deduplicates concurrent calls — only one fetch runs at a time.
|
|
363
|
+
*/
|
|
364
|
+
async getOrFetch(fetchFn) {
|
|
365
|
+
if (this.isFresh()) {
|
|
366
|
+
return this.data;
|
|
367
|
+
}
|
|
368
|
+
if (this.inflight) {
|
|
369
|
+
return this.inflight;
|
|
370
|
+
}
|
|
371
|
+
this.inflight = fetchFn().then((result) => {
|
|
372
|
+
this.data = result;
|
|
373
|
+
this.fetchedAt = Date.now();
|
|
374
|
+
this.inflight = null;
|
|
375
|
+
return result;
|
|
376
|
+
}).catch((err) => {
|
|
377
|
+
this.inflight = null;
|
|
378
|
+
throw err;
|
|
379
|
+
});
|
|
380
|
+
return this.inflight;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Trigger a background refresh without awaiting the result.
|
|
384
|
+
* Returns immediately. If a fetch is already in flight, schedules
|
|
385
|
+
* the refresh to run after the current one completes.
|
|
386
|
+
*/
|
|
387
|
+
refreshInBackground(fetchFn) {
|
|
388
|
+
if (this.inflight) {
|
|
389
|
+
this.pendingRefresh = fetchFn;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
this.startBackgroundFetch(fetchFn);
|
|
393
|
+
}
|
|
394
|
+
startBackgroundFetch(fetchFn) {
|
|
395
|
+
this.inflight = fetchFn().then((result) => {
|
|
396
|
+
this.data = result;
|
|
397
|
+
this.fetchedAt = Date.now();
|
|
398
|
+
this.inflight = null;
|
|
399
|
+
if (this.pendingRefresh) {
|
|
400
|
+
const pending = this.pendingRefresh;
|
|
401
|
+
this.pendingRefresh = null;
|
|
402
|
+
this.startBackgroundFetch(pending);
|
|
403
|
+
}
|
|
404
|
+
return result;
|
|
405
|
+
}).catch((err) => {
|
|
406
|
+
this.inflight = null;
|
|
407
|
+
this.pendingRefresh = null;
|
|
408
|
+
throw err;
|
|
409
|
+
});
|
|
410
|
+
this.inflight.catch(() => {
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/** Invalidate the cache, forcing the next getOrFetch to re-fetch. */
|
|
414
|
+
invalidate() {
|
|
415
|
+
this.fetchedAt = 0;
|
|
416
|
+
}
|
|
417
|
+
/** Get the cached value without fetching. Returns undefined if empty or stale. */
|
|
418
|
+
get() {
|
|
419
|
+
return this.isFresh() ? this.data : void 0;
|
|
420
|
+
}
|
|
421
|
+
/** Get the cached value even if stale. Returns undefined only if never fetched. */
|
|
422
|
+
getStale() {
|
|
423
|
+
return this.data;
|
|
424
|
+
}
|
|
425
|
+
/** Check if the cache has fresh (non-expired) data. */
|
|
426
|
+
isFresh() {
|
|
427
|
+
return this.data !== void 0 && Date.now() - this.fetchedAt < this.ttlMs;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// src/RetryQueue.ts
|
|
432
|
+
var DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
433
|
+
var RetryQueue = class {
|
|
434
|
+
constructor(storageKey, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
435
|
+
this.storageKey = storageKey;
|
|
436
|
+
this.maxAgeMs = maxAgeMs;
|
|
437
|
+
}
|
|
438
|
+
/** Save a failed score for later retry. */
|
|
439
|
+
save(score, metadata) {
|
|
440
|
+
try {
|
|
441
|
+
const pending = { score, metadata, timestamp: Date.now() };
|
|
442
|
+
localStorage.setItem(this.storageKey, JSON.stringify(pending));
|
|
443
|
+
} catch {
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Get the pending score, or null if none exists or it has expired.
|
|
448
|
+
* Automatically clears expired entries.
|
|
449
|
+
*/
|
|
450
|
+
get() {
|
|
451
|
+
try {
|
|
452
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
453
|
+
if (!raw) return null;
|
|
454
|
+
const pending = JSON.parse(raw);
|
|
455
|
+
if (Date.now() - pending.timestamp > this.maxAgeMs) {
|
|
456
|
+
this.clear();
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
return { score: pending.score, metadata: pending.metadata };
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** Check if there's a pending score. */
|
|
465
|
+
hasPending() {
|
|
466
|
+
return this.get() !== null;
|
|
467
|
+
}
|
|
468
|
+
/** Clear the pending score. */
|
|
469
|
+
clear() {
|
|
470
|
+
try {
|
|
471
|
+
localStorage.removeItem(this.storageKey);
|
|
472
|
+
} catch {
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// src/validation.ts
|
|
478
|
+
var DEFAULTS = {
|
|
479
|
+
minLength: 2,
|
|
480
|
+
maxLength: 12,
|
|
481
|
+
uppercase: true,
|
|
482
|
+
allowedPattern: /[^A-Z0-9_]/g
|
|
483
|
+
};
|
|
484
|
+
function validateName(input, options) {
|
|
485
|
+
const opts = { ...DEFAULTS, ...options };
|
|
486
|
+
let name = input.trim();
|
|
487
|
+
if (opts.uppercase) {
|
|
488
|
+
name = name.toUpperCase();
|
|
489
|
+
}
|
|
490
|
+
const pattern = options?.allowedPattern ?? (opts.uppercase ? /[^A-Z0-9_]/g : /[^A-Za-z0-9_]/g);
|
|
491
|
+
name = name.replace(pattern, "");
|
|
492
|
+
name = name.substring(0, opts.maxLength);
|
|
493
|
+
if (name.length < opts.minLength) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
return name;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/KeeperBoardSession.ts
|
|
500
|
+
var KeeperBoardSession = class {
|
|
501
|
+
constructor(config) {
|
|
502
|
+
this.cachedLimit = 0;
|
|
503
|
+
// Track the limit used for cached data
|
|
504
|
+
this.isSubmitting = false;
|
|
505
|
+
this.client = new KeeperBoardClient({
|
|
506
|
+
apiKey: config.apiKey,
|
|
507
|
+
defaultLeaderboard: config.leaderboard,
|
|
508
|
+
apiUrl: config.apiUrl
|
|
509
|
+
});
|
|
510
|
+
this.identity = new PlayerIdentity(config.identity);
|
|
511
|
+
this.leaderboard = config.leaderboard;
|
|
512
|
+
this.defaultPlayerName = config.defaultPlayerName ?? "ANON";
|
|
513
|
+
this.cache = config.cache ? new Cache(config.cache.ttlMs) : null;
|
|
514
|
+
this.retryQueue = config.retry ? new RetryQueue(
|
|
515
|
+
`keeperboard_retry_${config.leaderboard}`,
|
|
516
|
+
config.retry.maxAgeMs
|
|
517
|
+
) : null;
|
|
518
|
+
}
|
|
519
|
+
// ============================================
|
|
520
|
+
// IDENTITY
|
|
521
|
+
// ============================================
|
|
522
|
+
/** Get or create a persistent player GUID. */
|
|
523
|
+
getPlayerGuid() {
|
|
524
|
+
return this.identity.getOrCreatePlayerGuid();
|
|
525
|
+
}
|
|
526
|
+
/** Get the stored player name, falling back to defaultPlayerName. */
|
|
527
|
+
getPlayerName() {
|
|
528
|
+
return this.identity.getPlayerName() ?? this.defaultPlayerName;
|
|
529
|
+
}
|
|
530
|
+
/** Store a player name locally. Does NOT update the server — call updatePlayerName() for that. */
|
|
531
|
+
setPlayerName(name) {
|
|
532
|
+
this.identity.setPlayerName(name);
|
|
533
|
+
}
|
|
534
|
+
/** Validate a name using configurable rules. Returns sanitized string or null. */
|
|
535
|
+
validateName(input, options) {
|
|
536
|
+
return validateName(input, options);
|
|
537
|
+
}
|
|
538
|
+
// ============================================
|
|
539
|
+
// CORE API
|
|
540
|
+
// ============================================
|
|
541
|
+
/**
|
|
542
|
+
* Submit a score. Identity and leaderboard are auto-injected.
|
|
543
|
+
* Returns a discriminated union: `{ success: true, rank, isNewHighScore }` or `{ success: false, error }`.
|
|
544
|
+
*
|
|
545
|
+
* If retry is enabled, failed submissions are saved to localStorage for later retry.
|
|
546
|
+
* Prevents concurrent double-submissions.
|
|
547
|
+
*/
|
|
548
|
+
async submitScore(score, metadata) {
|
|
549
|
+
if (this.isSubmitting) {
|
|
550
|
+
return { success: false, error: "Submission in progress" };
|
|
551
|
+
}
|
|
552
|
+
this.isSubmitting = true;
|
|
553
|
+
try {
|
|
554
|
+
const result = await this.client.submitScore({
|
|
555
|
+
playerGuid: this.getPlayerGuid(),
|
|
556
|
+
playerName: this.getPlayerName(),
|
|
557
|
+
score,
|
|
558
|
+
metadata
|
|
559
|
+
});
|
|
560
|
+
this.retryQueue?.clear();
|
|
561
|
+
if (this.cache) {
|
|
562
|
+
this.cache.invalidate();
|
|
563
|
+
this.cachedLimit = 0;
|
|
564
|
+
this.cache.refreshInBackground(() => this.fetchSnapshot());
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
success: true,
|
|
568
|
+
rank: result.rank,
|
|
569
|
+
isNewHighScore: result.isNewHighScore
|
|
570
|
+
};
|
|
571
|
+
} catch (error) {
|
|
572
|
+
this.retryQueue?.save(score, metadata);
|
|
573
|
+
return {
|
|
574
|
+
success: false,
|
|
575
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
576
|
+
};
|
|
577
|
+
} finally {
|
|
578
|
+
this.isSubmitting = false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Get a combined snapshot: leaderboard entries (with `isCurrentPlayer` flag)
|
|
583
|
+
* plus the current player's rank if they're outside the top N.
|
|
584
|
+
*
|
|
585
|
+
* Uses cache if enabled and fresh. If a larger limit is requested than
|
|
586
|
+
* what's cached, the cache is invalidated and fresh data is fetched.
|
|
587
|
+
*/
|
|
588
|
+
async getSnapshot(options) {
|
|
589
|
+
const limit = options?.limit ?? 10;
|
|
590
|
+
if (this.cache) {
|
|
591
|
+
if (limit > this.cachedLimit) {
|
|
592
|
+
this.cache.invalidate();
|
|
593
|
+
}
|
|
594
|
+
const result = await this.cache.getOrFetch(() => this.fetchSnapshot(limit));
|
|
595
|
+
this.cachedLimit = limit;
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
return this.fetchSnapshot(limit);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Update the player's name on the server and locally.
|
|
602
|
+
* Returns true on success, false on failure.
|
|
603
|
+
*/
|
|
604
|
+
async updatePlayerName(newName) {
|
|
605
|
+
try {
|
|
606
|
+
await this.client.updatePlayerName({
|
|
607
|
+
playerGuid: this.getPlayerGuid(),
|
|
608
|
+
newName
|
|
609
|
+
});
|
|
610
|
+
this.identity.setPlayerName(newName);
|
|
611
|
+
if (this.cache) {
|
|
612
|
+
this.cache.invalidate();
|
|
613
|
+
this.cachedLimit = 0;
|
|
614
|
+
}
|
|
615
|
+
return true;
|
|
616
|
+
} catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Retry submitting a pending score (from a previous failed submission).
|
|
622
|
+
* Call this on app startup.
|
|
623
|
+
*/
|
|
624
|
+
async retryPendingScore() {
|
|
625
|
+
const pending = this.retryQueue?.get();
|
|
626
|
+
if (!pending) return null;
|
|
627
|
+
const result = await this.submitScore(pending.score, pending.metadata);
|
|
628
|
+
if (result.success) {
|
|
629
|
+
this.retryQueue?.clear();
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
/** Check if there's a pending score in the retry queue. */
|
|
634
|
+
hasPendingScore() {
|
|
635
|
+
return this.retryQueue?.hasPending() ?? false;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Pre-fetch snapshot data in the background for instant display later.
|
|
639
|
+
* No-op if cache is disabled or already fresh.
|
|
640
|
+
*/
|
|
641
|
+
prefetch() {
|
|
642
|
+
if (!this.cache) return;
|
|
643
|
+
if (this.cache.isFresh()) return;
|
|
644
|
+
this.cache.refreshInBackground(() => this.fetchSnapshot());
|
|
645
|
+
}
|
|
646
|
+
/** Escape hatch: access the underlying KeeperBoardClient. */
|
|
647
|
+
getClient() {
|
|
648
|
+
return this.client;
|
|
649
|
+
}
|
|
650
|
+
// ============================================
|
|
651
|
+
// INTERNAL
|
|
652
|
+
// ============================================
|
|
653
|
+
async fetchSnapshot(limit = 10) {
|
|
654
|
+
const playerGuid = this.getPlayerGuid();
|
|
655
|
+
const [leaderboard, playerRank] = await Promise.all([
|
|
656
|
+
this.client.getLeaderboard({ limit }),
|
|
657
|
+
this.client.getPlayerRank({ playerGuid })
|
|
658
|
+
]);
|
|
659
|
+
const entries = leaderboard.entries.map((e) => ({
|
|
660
|
+
rank: e.rank,
|
|
661
|
+
playerGuid: e.playerGuid,
|
|
662
|
+
playerName: e.playerName,
|
|
663
|
+
score: e.score,
|
|
664
|
+
isCurrentPlayer: e.playerGuid === playerGuid
|
|
665
|
+
}));
|
|
666
|
+
const playerInEntries = entries.some((e) => e.isCurrentPlayer);
|
|
667
|
+
const effectivePlayerRank = playerRank && !playerInEntries ? playerRank : null;
|
|
668
|
+
return {
|
|
669
|
+
entries,
|
|
670
|
+
totalCount: leaderboard.totalCount,
|
|
671
|
+
playerRank: effectivePlayerRank
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
};
|
|
251
675
|
// Annotate the CommonJS export names for ESM import in node:
|
|
252
676
|
0 && (module.exports = {
|
|
677
|
+
Cache,
|
|
253
678
|
KeeperBoardClient,
|
|
254
679
|
KeeperBoardError,
|
|
255
|
-
|
|
680
|
+
KeeperBoardSession,
|
|
681
|
+
PlayerIdentity,
|
|
682
|
+
RetryQueue,
|
|
683
|
+
validateName
|
|
256
684
|
});
|