keeperboard 1.0.3 → 2.0.0

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/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
- PlayerIdentity: () => PlayerIdentity
26
+ KeeperBoardSession: () => KeeperBoardSession,
27
+ PlayerIdentity: () => PlayerIdentity,
28
+ RetryQueue: () => RetryQueue,
29
+ validateName: () => validateName
26
30
  });
27
31
  module.exports = __toCommonJS(index_exports);
28
32
 
@@ -37,55 +41,92 @@ var KeeperBoardError = class extends Error {
37
41
  };
38
42
 
39
43
  // src/KeeperBoardClient.ts
40
- var KeeperBoardClient = class {
44
+ var _KeeperBoardClient = class _KeeperBoardClient {
41
45
  constructor(config) {
42
- this.apiUrl = config.apiUrl.replace(/\/$/, "");
46
+ const url = config.apiUrl ?? _KeeperBoardClient.DEFAULT_API_URL;
47
+ this.apiUrl = url.replace(/\/$/, "");
43
48
  this.apiKey = config.apiKey;
49
+ this.defaultLeaderboard = config.defaultLeaderboard;
44
50
  }
45
- async submitScore(playerGuid, playerName, score, leaderboard, metadata) {
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;
46
67
  const params = new URLSearchParams();
47
- if (leaderboard) {
48
- params.set("leaderboard", leaderboard);
49
- }
68
+ if (leaderboard) params.set("leaderboard", leaderboard);
50
69
  const url = `${this.apiUrl}/api/v1/scores${params.toString() ? "?" + params.toString() : ""}`;
51
70
  const body = {
52
- player_guid: playerGuid,
53
- player_name: playerName,
54
- score,
55
- ...metadata && { metadata }
71
+ player_guid: options.playerGuid,
72
+ player_name: options.playerName,
73
+ score: options.score,
74
+ ...options.metadata && { metadata: options.metadata }
56
75
  };
57
- return this.request(url, {
76
+ const raw = await this.request(url, {
58
77
  method: "POST",
59
78
  body: JSON.stringify(body)
60
79
  });
80
+ return this.mapScoreResponse(raw);
61
81
  }
62
- async getLeaderboard(name, limit = 10, offset = 0) {
63
- const params = new URLSearchParams();
64
- params.set("limit", String(Math.min(limit, 100)));
65
- params.set("offset", String(offset));
66
- if (name) {
67
- params.set("leaderboard", name);
68
- }
69
- const url = `${this.apiUrl}/api/v1/leaderboard?${params.toString()}`;
70
- return this.request(url, { method: "GET" });
71
- }
72
- async getLeaderboardVersion(name, version, limit = 10, offset = 0) {
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;
73
103
  const params = new URLSearchParams();
74
- params.set("leaderboard", name);
75
- params.set("version", String(version));
76
104
  params.set("limit", String(Math.min(limit, 100)));
77
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));
78
108
  const url = `${this.apiUrl}/api/v1/leaderboard?${params.toString()}`;
79
- return this.request(url, { method: "GET" });
109
+ const raw = await this.request(url, { method: "GET" });
110
+ return this.mapLeaderboardResponse(raw);
80
111
  }
81
- async getPlayerRank(playerGuid, leaderboard) {
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;
82
124
  const params = new URLSearchParams();
83
- if (leaderboard) {
84
- params.set("leaderboard", leaderboard);
85
- }
86
- 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() : ""}`;
87
127
  try {
88
- return await this.request(url, { method: "GET" });
128
+ const raw = await this.request(url, { method: "GET" });
129
+ return this.mapPlayerResponse(raw);
89
130
  } catch (error) {
90
131
  if (error instanceof KeeperBoardError && error.code === "NOT_FOUND") {
91
132
  return null;
@@ -93,37 +134,52 @@ var KeeperBoardClient = class {
93
134
  throw error;
94
135
  }
95
136
  }
96
- async updatePlayerName(playerGuid, newName, leaderboard) {
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;
97
148
  const params = new URLSearchParams();
98
- if (leaderboard) {
99
- params.set("leaderboard", leaderboard);
100
- }
101
- const url = `${this.apiUrl}/api/v1/player/${encodeURIComponent(playerGuid)}${params.toString() ? "?" + params.toString() : ""}`;
102
- 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, {
103
152
  method: "PUT",
104
- body: JSON.stringify({ player_name: newName })
153
+ body: JSON.stringify({ player_name: options.newName })
105
154
  });
155
+ return this.mapPlayerResponse(raw);
106
156
  }
107
- async claimScore(playerGuid, playerName, leaderboard) {
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;
108
166
  const params = new URLSearchParams();
109
- if (leaderboard) {
110
- params.set("leaderboard", leaderboard);
111
- }
167
+ if (leaderboard) params.set("leaderboard", leaderboard);
112
168
  const url = `${this.apiUrl}/api/v1/claim${params.toString() ? "?" + params.toString() : ""}`;
113
- return this.request(url, {
169
+ const raw = await this.request(url, {
114
170
  method: "POST",
115
171
  body: JSON.stringify({
116
- player_guid: playerGuid,
117
- player_name: playerName
172
+ player_guid: options.playerGuid,
173
+ player_name: options.playerName
118
174
  })
119
175
  });
176
+ return this.mapClaimResponse(raw);
120
177
  }
121
178
  // ============================================
122
179
  // HEALTH CHECK
123
180
  // ============================================
124
181
  /**
125
- * Check if the API is healthy.
126
- * This endpoint does not require an API key.
182
+ * Check if the API is healthy. Does not require an API key.
127
183
  */
128
184
  async healthCheck() {
129
185
  const url = `${this.apiUrl}/api/v1/health`;
@@ -138,6 +194,51 @@ var KeeperBoardClient = class {
138
194
  return json.data;
139
195
  }
140
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
+ // ============================================
141
242
  // INTERNAL
142
243
  // ============================================
143
244
  async request(url, options) {
@@ -157,6 +258,8 @@ var KeeperBoardClient = class {
157
258
  return json.data;
158
259
  }
159
260
  };
261
+ _KeeperBoardClient.DEFAULT_API_URL = "https://keeperboard.vercel.app";
262
+ var KeeperBoardClient = _KeeperBoardClient;
160
263
 
161
264
  // src/PlayerIdentity.ts
162
265
  var DEFAULT_KEY_PREFIX = "keeperboard_";
@@ -245,9 +348,310 @@ var PlayerIdentity = class {
245
348
  });
246
349
  }
247
350
  };
351
+
352
+ // src/Cache.ts
353
+ var Cache = class {
354
+ constructor(ttlMs) {
355
+ this.fetchedAt = 0;
356
+ this.inflight = null;
357
+ this.ttlMs = ttlMs;
358
+ }
359
+ /**
360
+ * Get cached value if fresh, otherwise fetch via the provided function.
361
+ * Deduplicates concurrent calls — only one fetch runs at a time.
362
+ */
363
+ async getOrFetch(fetchFn) {
364
+ if (this.isFresh()) {
365
+ return this.data;
366
+ }
367
+ if (this.inflight) {
368
+ return this.inflight;
369
+ }
370
+ this.inflight = fetchFn().then((result) => {
371
+ this.data = result;
372
+ this.fetchedAt = Date.now();
373
+ this.inflight = null;
374
+ return result;
375
+ }).catch((err) => {
376
+ this.inflight = null;
377
+ throw err;
378
+ });
379
+ return this.inflight;
380
+ }
381
+ /**
382
+ * Trigger a background refresh without awaiting the result.
383
+ * Returns immediately. If a fetch is already in flight, does nothing.
384
+ */
385
+ refreshInBackground(fetchFn) {
386
+ if (this.inflight) return;
387
+ this.inflight = fetchFn().then((result) => {
388
+ this.data = result;
389
+ this.fetchedAt = Date.now();
390
+ this.inflight = null;
391
+ return result;
392
+ }).catch((err) => {
393
+ this.inflight = null;
394
+ throw err;
395
+ });
396
+ this.inflight.catch(() => {
397
+ });
398
+ }
399
+ /** Invalidate the cache, forcing the next getOrFetch to re-fetch. */
400
+ invalidate() {
401
+ this.fetchedAt = 0;
402
+ }
403
+ /** Get the cached value without fetching. Returns undefined if empty or stale. */
404
+ get() {
405
+ return this.isFresh() ? this.data : void 0;
406
+ }
407
+ /** Get the cached value even if stale. Returns undefined only if never fetched. */
408
+ getStale() {
409
+ return this.data;
410
+ }
411
+ /** Check if the cache has fresh (non-expired) data. */
412
+ isFresh() {
413
+ return this.data !== void 0 && Date.now() - this.fetchedAt < this.ttlMs;
414
+ }
415
+ };
416
+
417
+ // src/RetryQueue.ts
418
+ var DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
419
+ var RetryQueue = class {
420
+ constructor(storageKey, maxAgeMs = DEFAULT_MAX_AGE_MS) {
421
+ this.storageKey = storageKey;
422
+ this.maxAgeMs = maxAgeMs;
423
+ }
424
+ /** Save a failed score for later retry. */
425
+ save(score, metadata) {
426
+ try {
427
+ const pending = { score, metadata, timestamp: Date.now() };
428
+ localStorage.setItem(this.storageKey, JSON.stringify(pending));
429
+ } catch {
430
+ }
431
+ }
432
+ /**
433
+ * Get the pending score, or null if none exists or it has expired.
434
+ * Automatically clears expired entries.
435
+ */
436
+ get() {
437
+ try {
438
+ const raw = localStorage.getItem(this.storageKey);
439
+ if (!raw) return null;
440
+ const pending = JSON.parse(raw);
441
+ if (Date.now() - pending.timestamp > this.maxAgeMs) {
442
+ this.clear();
443
+ return null;
444
+ }
445
+ return { score: pending.score, metadata: pending.metadata };
446
+ } catch {
447
+ return null;
448
+ }
449
+ }
450
+ /** Check if there's a pending score. */
451
+ hasPending() {
452
+ return this.get() !== null;
453
+ }
454
+ /** Clear the pending score. */
455
+ clear() {
456
+ try {
457
+ localStorage.removeItem(this.storageKey);
458
+ } catch {
459
+ }
460
+ }
461
+ };
462
+
463
+ // src/validation.ts
464
+ var DEFAULTS = {
465
+ minLength: 2,
466
+ maxLength: 12,
467
+ uppercase: true,
468
+ allowedPattern: /[^A-Z0-9_]/g
469
+ };
470
+ function validateName(input, options) {
471
+ const opts = { ...DEFAULTS, ...options };
472
+ let name = input.trim();
473
+ if (opts.uppercase) {
474
+ name = name.toUpperCase();
475
+ }
476
+ name = name.replace(opts.allowedPattern, "");
477
+ name = name.substring(0, opts.maxLength);
478
+ if (name.length < opts.minLength) {
479
+ return null;
480
+ }
481
+ return name;
482
+ }
483
+
484
+ // src/KeeperBoardSession.ts
485
+ var KeeperBoardSession = class {
486
+ constructor(config) {
487
+ this.isSubmitting = false;
488
+ this.client = new KeeperBoardClient({
489
+ apiKey: config.apiKey,
490
+ defaultLeaderboard: config.leaderboard,
491
+ apiUrl: config.apiUrl
492
+ });
493
+ this.identity = new PlayerIdentity(config.identity);
494
+ this.leaderboard = config.leaderboard;
495
+ this.defaultPlayerName = config.defaultPlayerName ?? "ANON";
496
+ this.cache = config.cache ? new Cache(config.cache.ttlMs) : null;
497
+ this.retryQueue = config.retry ? new RetryQueue(
498
+ `keeperboard_retry_${config.leaderboard}`,
499
+ config.retry.maxAgeMs
500
+ ) : null;
501
+ }
502
+ // ============================================
503
+ // IDENTITY
504
+ // ============================================
505
+ /** Get or create a persistent player GUID. */
506
+ getPlayerGuid() {
507
+ return this.identity.getOrCreatePlayerGuid();
508
+ }
509
+ /** Get the stored player name, falling back to defaultPlayerName. */
510
+ getPlayerName() {
511
+ return this.identity.getPlayerName() ?? this.defaultPlayerName;
512
+ }
513
+ /** Store a player name locally. Does NOT update the server — call updatePlayerName() for that. */
514
+ setPlayerName(name) {
515
+ this.identity.setPlayerName(name);
516
+ }
517
+ /** Validate a name using configurable rules. Returns sanitized string or null. */
518
+ validateName(input, options) {
519
+ return validateName(input, options);
520
+ }
521
+ // ============================================
522
+ // CORE API
523
+ // ============================================
524
+ /**
525
+ * Submit a score. Identity and leaderboard are auto-injected.
526
+ * Returns a discriminated union: `{ success: true, rank, isNewHighScore }` or `{ success: false, error }`.
527
+ *
528
+ * If retry is enabled, failed submissions are saved to localStorage for later retry.
529
+ * Prevents concurrent double-submissions.
530
+ */
531
+ async submitScore(score, metadata) {
532
+ if (this.isSubmitting) {
533
+ return { success: false, error: "Submission in progress" };
534
+ }
535
+ this.isSubmitting = true;
536
+ try {
537
+ const result = await this.client.submitScore({
538
+ playerGuid: this.getPlayerGuid(),
539
+ playerName: this.getPlayerName(),
540
+ score,
541
+ metadata
542
+ });
543
+ this.retryQueue?.clear();
544
+ if (this.cache) {
545
+ this.cache.invalidate();
546
+ this.cache.refreshInBackground(() => this.fetchSnapshot());
547
+ }
548
+ return {
549
+ success: true,
550
+ rank: result.rank,
551
+ isNewHighScore: result.isNewHighScore
552
+ };
553
+ } catch (error) {
554
+ this.retryQueue?.save(score, metadata);
555
+ return {
556
+ success: false,
557
+ error: error instanceof Error ? error.message : "Unknown error"
558
+ };
559
+ } finally {
560
+ this.isSubmitting = false;
561
+ }
562
+ }
563
+ /**
564
+ * Get a combined snapshot: leaderboard entries (with `isCurrentPlayer` flag)
565
+ * plus the current player's rank if they're outside the top N.
566
+ *
567
+ * Uses cache if enabled and fresh.
568
+ */
569
+ async getSnapshot(options) {
570
+ const limit = options?.limit ?? 10;
571
+ if (this.cache) {
572
+ return this.cache.getOrFetch(() => this.fetchSnapshot(limit));
573
+ }
574
+ return this.fetchSnapshot(limit);
575
+ }
576
+ /**
577
+ * Update the player's name on the server and locally.
578
+ * Returns true on success, false on failure.
579
+ */
580
+ async updatePlayerName(newName) {
581
+ try {
582
+ await this.client.updatePlayerName({
583
+ playerGuid: this.getPlayerGuid(),
584
+ newName
585
+ });
586
+ this.identity.setPlayerName(newName);
587
+ this.cache?.invalidate();
588
+ return true;
589
+ } catch {
590
+ return false;
591
+ }
592
+ }
593
+ /**
594
+ * Retry submitting a pending score (from a previous failed submission).
595
+ * Call this on app startup.
596
+ */
597
+ async retryPendingScore() {
598
+ const pending = this.retryQueue?.get();
599
+ if (!pending) return null;
600
+ const result = await this.submitScore(pending.score, pending.metadata);
601
+ if (result.success) {
602
+ this.retryQueue?.clear();
603
+ }
604
+ return result;
605
+ }
606
+ /** Check if there's a pending score in the retry queue. */
607
+ hasPendingScore() {
608
+ return this.retryQueue?.hasPending() ?? false;
609
+ }
610
+ /**
611
+ * Pre-fetch snapshot data in the background for instant display later.
612
+ * No-op if cache is disabled or already fresh.
613
+ */
614
+ prefetch() {
615
+ if (!this.cache) return;
616
+ if (this.cache.isFresh()) return;
617
+ this.cache.refreshInBackground(() => this.fetchSnapshot());
618
+ }
619
+ /** Escape hatch: access the underlying KeeperBoardClient. */
620
+ getClient() {
621
+ return this.client;
622
+ }
623
+ // ============================================
624
+ // INTERNAL
625
+ // ============================================
626
+ async fetchSnapshot(limit = 10) {
627
+ const playerGuid = this.getPlayerGuid();
628
+ const [leaderboard, playerRank] = await Promise.all([
629
+ this.client.getLeaderboard({ limit }),
630
+ this.client.getPlayerRank({ playerGuid })
631
+ ]);
632
+ const entries = leaderboard.entries.map((e) => ({
633
+ rank: e.rank,
634
+ playerGuid: e.playerGuid,
635
+ playerName: e.playerName,
636
+ score: e.score,
637
+ isCurrentPlayer: e.playerGuid === playerGuid
638
+ }));
639
+ const playerInEntries = entries.some((e) => e.isCurrentPlayer);
640
+ const effectivePlayerRank = playerRank && !playerInEntries ? playerRank : null;
641
+ return {
642
+ entries,
643
+ totalCount: leaderboard.totalCount,
644
+ playerRank: effectivePlayerRank
645
+ };
646
+ }
647
+ };
248
648
  // Annotate the CommonJS export names for ESM import in node:
249
649
  0 && (module.exports = {
650
+ Cache,
250
651
  KeeperBoardClient,
251
652
  KeeperBoardError,
252
- PlayerIdentity
653
+ KeeperBoardSession,
654
+ PlayerIdentity,
655
+ RetryQueue,
656
+ validateName
253
657
  });