lightleaderboard 1.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/README.md +258 -0
- package/dist/index.cjs +260 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +271 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +237 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# lightleaderboard
|
|
2
|
+
|
|
3
|
+
Official JavaScript / TypeScript SDK for [LightLeaderboard](https://leaderboard.goproso.com) — leaderboard-as-a-service for game developers.
|
|
4
|
+
|
|
5
|
+
Works in **Node.js 18+**, **browsers**, and any runtime with the Web Crypto API and native `fetch` (Deno, Bun, Cloudflare Workers, etc.).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install lightleaderboard
|
|
13
|
+
# or
|
|
14
|
+
yarn add lightleaderboard
|
|
15
|
+
# or
|
|
16
|
+
pnpm add lightleaderboard
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { LightLeaderboard } from 'lightleaderboard';
|
|
25
|
+
|
|
26
|
+
const lb = new LightLeaderboard({
|
|
27
|
+
apiKey: 'YOUR_API_KEY', // from the LightLeaderboard dashboard
|
|
28
|
+
gameId: 'YOUR_GAME_ID', // your game's reference ID
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Submit a score and get back the player's rank immediately
|
|
32
|
+
const result = await lb.submitScore({
|
|
33
|
+
score: 9500,
|
|
34
|
+
playerRefId: 'player-123',
|
|
35
|
+
playerName: 'Alice',
|
|
36
|
+
});
|
|
37
|
+
console.log(`Rank #${result.rank} of ${result.totalPlayers}!`);
|
|
38
|
+
// → "Rank #4 of 1024!"
|
|
39
|
+
|
|
40
|
+
// Fetch the top 10
|
|
41
|
+
const { entries } = await lb.getLeaderboard({ limit: 10 });
|
|
42
|
+
entries.forEach(e => {
|
|
43
|
+
console.log(`#${e.rank} ${e.playerName} ${e.score}`);
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## API reference
|
|
50
|
+
|
|
51
|
+
### `new LightLeaderboard(config)`
|
|
52
|
+
|
|
53
|
+
| Option | Type | Required | Description |
|
|
54
|
+
|--------|------|----------|-------------|
|
|
55
|
+
| `apiKey` | `string` | ✓ | Your game's API key from the dashboard |
|
|
56
|
+
| `gameId` | `string` | ✓ | Your game's reference ID |
|
|
57
|
+
| `baseUrl` | `string` | | Override the API base URL |
|
|
58
|
+
| `scoreSecret` | `string` | | Auto-sign scores with HMAC-SHA256 (see [Score signing](#score-signing)) |
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### `submitScore(options)`
|
|
63
|
+
|
|
64
|
+
Submit a score. Returns rank, personal-best flag, and total player count in a single call.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const result = await lb.submitScore({
|
|
68
|
+
score: 9500,
|
|
69
|
+
playerRefId: 'player-123', // your internal player ID
|
|
70
|
+
playerName: 'Alice', // display name
|
|
71
|
+
playTimeMs: 120_000, // run duration
|
|
72
|
+
seasonId: 'season-3', // optional season bucket
|
|
73
|
+
teamId: 'team-red', // optional team bucket
|
|
74
|
+
submissionId: crypto.randomUUID(), // idempotency key
|
|
75
|
+
metadata: { level: 5, combo: 12 }, // arbitrary JSON
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
console.log(result.rank); // 4 (player's current rank)
|
|
79
|
+
console.log(result.isPersonalBest); // true
|
|
80
|
+
console.log(result.totalPlayers); // 1024
|
|
81
|
+
console.log(result.deduped); // true if submissionId was already seen
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Response fields**
|
|
85
|
+
|
|
86
|
+
| Field | Type | Description |
|
|
87
|
+
|-------|------|-------------|
|
|
88
|
+
| `id` | `number` | Database ID of the created entry |
|
|
89
|
+
| `rank` | `number \| null` | Player's rank right after submission (`null` if no `playerRefId`) |
|
|
90
|
+
| `isPersonalBest` | `boolean` | Whether this beats the player's previous best |
|
|
91
|
+
| `totalPlayers` | `number \| null` | Total unique players on this leaderboard |
|
|
92
|
+
| `deduped` | `boolean?` | `true` when `submissionId` was already seen |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### `getLeaderboard(options?)`
|
|
97
|
+
|
|
98
|
+
Fetch the leaderboard. Returns **one entry per player** (their best score) by default.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const { entries } = await lb.getLeaderboard({
|
|
102
|
+
limit: 20, // 1–100, default 20
|
|
103
|
+
offset: 0, // pagination
|
|
104
|
+
period: 'weekly', // 'all' | 'weekly' | 'monthly'
|
|
105
|
+
season: 'season-3',
|
|
106
|
+
team: 'team-red',
|
|
107
|
+
allEntries: false, // true → return every raw submission
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Each entry has a `rank` field (1-based, offset-aware)
|
|
111
|
+
entries.forEach(e => console.log(e.rank, e.playerName, e.score));
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Pagination example**
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// Page 2 of a weekly board
|
|
118
|
+
const page2 = await lb.getLeaderboard({ limit: 20, offset: 20, period: 'weekly' });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### `getPlayerRank(playerRefId, options?)`
|
|
124
|
+
|
|
125
|
+
Get a player's rank, score, and **percentile** in a single call.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const rank = await lb.getPlayerRank('player-123', { period: 'weekly' });
|
|
129
|
+
|
|
130
|
+
console.log(rank.rank); // 4
|
|
131
|
+
console.log(rank.score); // 9500
|
|
132
|
+
console.log(rank.totalPlayers); // 1024
|
|
133
|
+
console.log(rank.percentile); // 99.7 → top 0.3%
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`percentile` is 0–100, higher is better. `rank 1 of 100` → `percentile 100`.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### `getCentricLeaderboard(playerRefId, options?)`
|
|
141
|
+
|
|
142
|
+
Fetch the leaderboard centered on a specific player — the entries immediately above and below them. Great for in-game "you vs. your neighbors" screens.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const { entries, playerRank } = await lb.getCentricLeaderboard('player-123', {
|
|
146
|
+
limit: 11, // 5 above + player + 5 below
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### `getPlayer(playerRefId)`
|
|
153
|
+
|
|
154
|
+
Fetch a player's profile.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const profile = await lb.getPlayer('player-123');
|
|
158
|
+
console.log(profile.playerName, profile.avatarUrl, profile.country);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
### `updatePlayer(playerRefId, options)`
|
|
164
|
+
|
|
165
|
+
Create or update a player's profile. Fields are merged — omitted fields keep their current value.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
await lb.updatePlayer('player-123', {
|
|
169
|
+
playerName: 'Alice',
|
|
170
|
+
avatarUrl: 'https://example.com/avatar.png',
|
|
171
|
+
country: 'US',
|
|
172
|
+
level: 42,
|
|
173
|
+
device: 'mobile',
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### `getPlayerScores(playerRefId, options?)`
|
|
180
|
+
|
|
181
|
+
Fetch all submissions a player has made, ordered **newest first**. Useful for progression graphs and run-history screens.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const { entries, bestScore, total } = await lb.getPlayerScores('player-123', {
|
|
185
|
+
limit: 50, // 1–200, default 50
|
|
186
|
+
offset: 0,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
console.log(`${total} total runs, best: ${bestScore}`);
|
|
190
|
+
entries.forEach(e => console.log(e.score, e.createdAt));
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Score signing
|
|
196
|
+
|
|
197
|
+
If you enable "Require signed scores" on your game in the dashboard, every submission must include a valid HMAC-SHA256 signature. Pass `scoreSecret` to the constructor and the SDK handles signing automatically:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const lb = new LightLeaderboard({
|
|
201
|
+
apiKey: 'YOUR_API_KEY',
|
|
202
|
+
gameId: 'YOUR_GAME_ID',
|
|
203
|
+
scoreSecret: 'YOUR_SCORE_SECRET', // from the dashboard → Signature Secret
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Scores are now automatically signed — no extra code needed
|
|
207
|
+
await lb.submitScore({ score: 9500, playerRefId: 'p1' });
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Signing uses the **Web Crypto API** (`crypto.subtle`), available natively in Node 18+, modern browsers, Bun, Deno, and Cloudflare Workers.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Error handling
|
|
215
|
+
|
|
216
|
+
All methods throw `LightLeaderboardError` on failure.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { LightLeaderboard, LightLeaderboardError } from 'lightleaderboard';
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await lb.submitScore({ score: 9500 });
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err instanceof LightLeaderboardError) {
|
|
225
|
+
console.error(err.message); // human-readable message from the API
|
|
226
|
+
console.error(err.status); // HTTP status code
|
|
227
|
+
console.error(err.response); // raw response body
|
|
228
|
+
|
|
229
|
+
if (err.isAuthError) console.error('Check your API key');
|
|
230
|
+
if (err.isRateLimitError) console.error('Slow down score submissions');
|
|
231
|
+
if (err.isBillingError) console.error('Free tier limit reached');
|
|
232
|
+
if (err.isValidationError) console.error('Invalid score data');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## TypeScript
|
|
240
|
+
|
|
241
|
+
The SDK is written in TypeScript and ships full type declarations. All options and return types are exported:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import type {
|
|
245
|
+
SubmitScoreOptions,
|
|
246
|
+
SubmitScoreResult,
|
|
247
|
+
GetLeaderboardOptions,
|
|
248
|
+
GetLeaderboardResult,
|
|
249
|
+
PlayerRankResult,
|
|
250
|
+
LeaderboardEntry,
|
|
251
|
+
} from 'lightleaderboard';
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
LightLeaderboard: () => LightLeaderboard,
|
|
24
|
+
LightLeaderboardError: () => LightLeaderboardError
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/error.ts
|
|
29
|
+
var LightLeaderboardError = class extends Error {
|
|
30
|
+
constructor(message, status, response) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "LightLeaderboardError";
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.response = response;
|
|
35
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
36
|
+
}
|
|
37
|
+
get isAuthError() {
|
|
38
|
+
return this.status === 401 || this.status === 403;
|
|
39
|
+
}
|
|
40
|
+
get isRateLimitError() {
|
|
41
|
+
return this.status === 429;
|
|
42
|
+
}
|
|
43
|
+
get isBillingError() {
|
|
44
|
+
return this.status === 402;
|
|
45
|
+
}
|
|
46
|
+
get isValidationError() {
|
|
47
|
+
return this.status === 400;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/client.ts
|
|
52
|
+
var DEFAULT_BASE_URL = "https://leaderboard.goproso.com";
|
|
53
|
+
async function hmacSha256(key, data) {
|
|
54
|
+
const enc = new TextEncoder();
|
|
55
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey(
|
|
56
|
+
"raw",
|
|
57
|
+
enc.encode(key),
|
|
58
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
59
|
+
false,
|
|
60
|
+
["sign"]
|
|
61
|
+
);
|
|
62
|
+
const sig = await globalThis.crypto.subtle.sign("HMAC", cryptoKey, enc.encode(data));
|
|
63
|
+
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
64
|
+
}
|
|
65
|
+
function buildUrl(base, path, params) {
|
|
66
|
+
const url = `${base}${path}`;
|
|
67
|
+
if (!params) return url;
|
|
68
|
+
const sp = new URLSearchParams();
|
|
69
|
+
for (const [k, v] of Object.entries(params)) {
|
|
70
|
+
if (v !== void 0 && v !== null) sp.set(k, String(v));
|
|
71
|
+
}
|
|
72
|
+
const qs = sp.toString();
|
|
73
|
+
return qs ? `${url}?${qs}` : url;
|
|
74
|
+
}
|
|
75
|
+
var LightLeaderboard = class {
|
|
76
|
+
constructor(config) {
|
|
77
|
+
if (!config.apiKey) throw new Error("LightLeaderboard: apiKey is required");
|
|
78
|
+
if (!config.gameId) throw new Error("LightLeaderboard: gameId is required");
|
|
79
|
+
this.apiKey = config.apiKey;
|
|
80
|
+
this.gameId = config.gameId;
|
|
81
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
82
|
+
this.scoreSecret = config.scoreSecret;
|
|
83
|
+
}
|
|
84
|
+
gameUrl(path, params) {
|
|
85
|
+
return buildUrl(this.baseUrl, `/api/v1/games/${encodeURIComponent(this.gameId)}${path}`, params);
|
|
86
|
+
}
|
|
87
|
+
async fetch(method, url, body, extraHeaders) {
|
|
88
|
+
const headers = {
|
|
89
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
...extraHeaders
|
|
92
|
+
};
|
|
93
|
+
const res = await globalThis.fetch(url, {
|
|
94
|
+
method,
|
|
95
|
+
headers,
|
|
96
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
97
|
+
});
|
|
98
|
+
let data;
|
|
99
|
+
try {
|
|
100
|
+
data = await res.json();
|
|
101
|
+
} catch {
|
|
102
|
+
throw new LightLeaderboardError(`HTTP ${res.status}: failed to parse response`, res.status, null);
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok || data?.ok === false) {
|
|
105
|
+
throw new LightLeaderboardError(
|
|
106
|
+
data?.error ?? `HTTP ${res.status}`,
|
|
107
|
+
res.status,
|
|
108
|
+
data
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
113
|
+
// ─── Score submission ───────────────────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* Submit a score to the leaderboard.
|
|
116
|
+
*
|
|
117
|
+
* Returns the player's new rank immediately — no second API call needed.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* const result = await lb.submitScore({ score: 9500, playerRefId: 'p1', playerName: 'Alice' });
|
|
121
|
+
* console.log(`Rank #${result.rank} of ${result.totalPlayers}`);
|
|
122
|
+
*/
|
|
123
|
+
async submitScore(options) {
|
|
124
|
+
const url = this.gameUrl("/scores");
|
|
125
|
+
const bodyStr = JSON.stringify(options);
|
|
126
|
+
const extraHeaders = {};
|
|
127
|
+
if (this.scoreSecret) {
|
|
128
|
+
const sig = await hmacSha256(this.scoreSecret, bodyStr);
|
|
129
|
+
extraHeaders["x-score-signature"] = `sha256=${sig}`;
|
|
130
|
+
}
|
|
131
|
+
const res = await globalThis.fetch(url, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
...extraHeaders
|
|
137
|
+
},
|
|
138
|
+
body: bodyStr
|
|
139
|
+
});
|
|
140
|
+
let data;
|
|
141
|
+
try {
|
|
142
|
+
data = await res.json();
|
|
143
|
+
} catch {
|
|
144
|
+
throw new LightLeaderboardError(`HTTP ${res.status}: failed to parse response`, res.status, null);
|
|
145
|
+
}
|
|
146
|
+
if (!res.ok || data?.ok === false) {
|
|
147
|
+
throw new LightLeaderboardError(data?.error ?? `HTTP ${res.status}`, res.status, data);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
id: data.id,
|
|
151
|
+
rank: data.rank ?? null,
|
|
152
|
+
isPersonalBest: data.isPersonalBest ?? false,
|
|
153
|
+
totalPlayers: data.totalPlayers ?? null,
|
|
154
|
+
deduped: data.deduped
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// ─── Leaderboard ───────────────────────────────────────────────────────────
|
|
158
|
+
/**
|
|
159
|
+
* Fetch the leaderboard. Returns one entry per player (their best score) by default.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* const { entries } = await lb.getLeaderboard({ limit: 10, period: 'weekly' });
|
|
163
|
+
* entries.forEach(e => console.log(`#${e.rank} ${e.playerName}: ${e.score}`));
|
|
164
|
+
*/
|
|
165
|
+
async getLeaderboard(options) {
|
|
166
|
+
const { allEntries, ...rest } = options ?? {};
|
|
167
|
+
const data = await this.fetch(
|
|
168
|
+
"GET",
|
|
169
|
+
this.gameUrl("/leaderboard", {
|
|
170
|
+
...rest,
|
|
171
|
+
allEntries: allEntries ? "true" : void 0
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
entries: data.entries,
|
|
176
|
+
scoreOrder: data.scoreOrder,
|
|
177
|
+
period: data.period,
|
|
178
|
+
season: data.season ?? null,
|
|
179
|
+
team: data.team ?? null,
|
|
180
|
+
limit: data.limit,
|
|
181
|
+
offset: data.offset
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// ─── Rank ───────────────────────────────────────────────────────────────────
|
|
185
|
+
/**
|
|
186
|
+
* Get a player's current rank, score, and percentile.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* const { rank, percentile } = await lb.getPlayerRank('player-123');
|
|
190
|
+
* console.log(`Rank #${rank} — top ${(100 - percentile).toFixed(1)}%`);
|
|
191
|
+
*/
|
|
192
|
+
async getPlayerRank(playerRefId, options) {
|
|
193
|
+
return this.fetch(
|
|
194
|
+
"GET",
|
|
195
|
+
this.gameUrl(`/players/${encodeURIComponent(playerRefId)}/rank`, options)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// ─── Centric leaderboard ───────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Fetch the leaderboard centered on a specific player, showing the players
|
|
201
|
+
* immediately above and below them.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* const { entries, playerRank } = await lb.getCentricLeaderboard('player-123', { limit: 11 });
|
|
205
|
+
*/
|
|
206
|
+
async getCentricLeaderboard(playerRefId, options) {
|
|
207
|
+
const data = await this.fetch(
|
|
208
|
+
"GET",
|
|
209
|
+
this.gameUrl(`/players/${encodeURIComponent(playerRefId)}/centric`, options)
|
|
210
|
+
);
|
|
211
|
+
return {
|
|
212
|
+
entries: data.entries,
|
|
213
|
+
playerRank: data.playerRank ?? null,
|
|
214
|
+
period: data.period,
|
|
215
|
+
season: data.season ?? null,
|
|
216
|
+
team: data.team ?? null,
|
|
217
|
+
scoreOrder: data.scoreOrder
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// ─── Player profile ─────────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Fetch a player's profile (name, avatar, country, level, etc.).
|
|
223
|
+
*/
|
|
224
|
+
async getPlayer(playerRefId) {
|
|
225
|
+
const data = await this.fetch(
|
|
226
|
+
"GET",
|
|
227
|
+
this.gameUrl(`/players/${encodeURIComponent(playerRefId)}`)
|
|
228
|
+
);
|
|
229
|
+
return data.player;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Create or update a player's profile. Fields are merged — omitted fields
|
|
233
|
+
* keep their existing values.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* await lb.updatePlayer('player-123', { playerName: 'Alice', country: 'US', level: 5 });
|
|
237
|
+
*/
|
|
238
|
+
async updatePlayer(playerRefId, options) {
|
|
239
|
+
await this.fetch(
|
|
240
|
+
"PUT",
|
|
241
|
+
this.gameUrl(`/players/${encodeURIComponent(playerRefId)}`),
|
|
242
|
+
options
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
// ─── Score history ──────────────────────────────────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* Fetch all submissions a player has ever made, ordered newest first.
|
|
248
|
+
* Useful for progression charts and run history screens.
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* const { entries, bestScore, total } = await lb.getPlayerScores('player-123');
|
|
252
|
+
*/
|
|
253
|
+
async getPlayerScores(playerRefId, options) {
|
|
254
|
+
return this.fetch(
|
|
255
|
+
"GET",
|
|
256
|
+
this.gameUrl(`/players/${encodeURIComponent(playerRefId)}/scores`, options)
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/error.ts","../src/client.ts"],"sourcesContent":["export { LightLeaderboard } from './client.js';\nexport { LightLeaderboardError } from './error.js';\nexport type {\n LightLeaderboardConfig,\n LeaderboardEntry,\n GetLeaderboardOptions,\n GetLeaderboardResult,\n SubmitScoreOptions,\n SubmitScoreResult,\n GetRankOptions,\n PlayerRankResult,\n GetCentricOptions,\n CentricLeaderboardResult,\n PlayerProfile,\n UpdatePlayerOptions,\n PlayerScoreEntry,\n GetPlayerScoresOptions,\n GetPlayerScoresResult,\n} from './types.js';\n","export class LightLeaderboardError extends Error {\n /** HTTP status code returned by the API */\n readonly status: number;\n /** Raw response body from the API */\n readonly response: unknown;\n\n constructor(message: string, status: number, response: unknown) {\n super(message);\n this.name = 'LightLeaderboardError';\n this.status = status;\n this.response = response;\n // Restore prototype chain in environments that need it\n Object.setPrototypeOf(this, new.target.prototype);\n }\n\n get isAuthError() {\n return this.status === 401 || this.status === 403;\n }\n\n get isRateLimitError() {\n return this.status === 429;\n }\n\n get isBillingError() {\n return this.status === 402;\n }\n\n get isValidationError() {\n return this.status === 400;\n }\n}\n","import { LightLeaderboardError } from './error.js';\nimport type {\n LightLeaderboardConfig,\n SubmitScoreOptions,\n SubmitScoreResult,\n GetLeaderboardOptions,\n GetLeaderboardResult,\n GetRankOptions,\n PlayerRankResult,\n GetCentricOptions,\n CentricLeaderboardResult,\n PlayerProfile,\n UpdatePlayerOptions,\n GetPlayerScoresOptions,\n GetPlayerScoresResult,\n} from './types.js';\n\nconst DEFAULT_BASE_URL = 'https://leaderboard.goproso.com';\n\nasync function hmacSha256(key: string, data: string): Promise<string> {\n const enc = new TextEncoder();\n const cryptoKey = await globalThis.crypto.subtle.importKey(\n 'raw',\n enc.encode(key),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const sig = await globalThis.crypto.subtle.sign('HMAC', cryptoKey, enc.encode(data));\n return Array.from(new Uint8Array(sig))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\nfunction buildUrl(base: string, path: string, params?: object): string {\n const url = `${base}${path}`;\n if (!params) return url;\n const sp = new URLSearchParams();\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== null) sp.set(k, String(v));\n }\n const qs = sp.toString();\n return qs ? `${url}?${qs}` : url;\n}\n\nexport class LightLeaderboard {\n private readonly apiKey: string;\n private readonly gameId: string;\n private readonly baseUrl: string;\n private readonly scoreSecret?: string;\n\n constructor(config: LightLeaderboardConfig) {\n if (!config.apiKey) throw new Error('LightLeaderboard: apiKey is required');\n if (!config.gameId) throw new Error('LightLeaderboard: gameId is required');\n this.apiKey = config.apiKey;\n this.gameId = config.gameId;\n this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/$/, '');\n this.scoreSecret = config.scoreSecret;\n }\n\n private gameUrl(path: string, params?: object): string {\n return buildUrl(this.baseUrl, `/api/v1/games/${encodeURIComponent(this.gameId)}${path}`, params);\n }\n\n private async fetch<T>(\n method: string,\n url: string,\n body?: unknown,\n extraHeaders?: Record<string, string>\n ): Promise<T> {\n const headers: Record<string, string> = {\n Authorization: `Bearer ${this.apiKey}`,\n 'Content-Type': 'application/json',\n ...extraHeaders,\n };\n\n const res = await globalThis.fetch(url, {\n method,\n headers,\n body: body !== undefined ? JSON.stringify(body) : undefined,\n });\n\n let data: any;\n try {\n data = await res.json();\n } catch {\n throw new LightLeaderboardError(`HTTP ${res.status}: failed to parse response`, res.status, null);\n }\n\n if (!res.ok || data?.ok === false) {\n throw new LightLeaderboardError(\n data?.error ?? `HTTP ${res.status}`,\n res.status,\n data\n );\n }\n\n return data as T;\n }\n\n // ─── Score submission ───────────────────────────────────────────────────────\n\n /**\n * Submit a score to the leaderboard.\n *\n * Returns the player's new rank immediately — no second API call needed.\n *\n * @example\n * const result = await lb.submitScore({ score: 9500, playerRefId: 'p1', playerName: 'Alice' });\n * console.log(`Rank #${result.rank} of ${result.totalPlayers}`);\n */\n async submitScore(options: SubmitScoreOptions): Promise<SubmitScoreResult> {\n const url = this.gameUrl('/scores');\n const bodyStr = JSON.stringify(options);\n const extraHeaders: Record<string, string> = {};\n\n if (this.scoreSecret) {\n const sig = await hmacSha256(this.scoreSecret, bodyStr);\n extraHeaders['x-score-signature'] = `sha256=${sig}`;\n }\n\n const res = await globalThis.fetch(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.apiKey}`,\n 'Content-Type': 'application/json',\n ...extraHeaders,\n },\n body: bodyStr,\n });\n\n let data: any;\n try {\n data = await res.json();\n } catch {\n throw new LightLeaderboardError(`HTTP ${res.status}: failed to parse response`, res.status, null);\n }\n\n if (!res.ok || data?.ok === false) {\n throw new LightLeaderboardError(data?.error ?? `HTTP ${res.status}`, res.status, data);\n }\n\n return {\n id: data.id,\n rank: data.rank ?? null,\n isPersonalBest: data.isPersonalBest ?? false,\n totalPlayers: data.totalPlayers ?? null,\n deduped: data.deduped,\n };\n }\n\n // ─── Leaderboard ───────────────────────────────────────────────────────────\n\n /**\n * Fetch the leaderboard. Returns one entry per player (their best score) by default.\n *\n * @example\n * const { entries } = await lb.getLeaderboard({ limit: 10, period: 'weekly' });\n * entries.forEach(e => console.log(`#${e.rank} ${e.playerName}: ${e.score}`));\n */\n async getLeaderboard(options?: GetLeaderboardOptions): Promise<GetLeaderboardResult> {\n const { allEntries, ...rest } = options ?? {};\n const data = await this.fetch<any>(\n 'GET',\n this.gameUrl('/leaderboard', {\n ...rest,\n allEntries: allEntries ? 'true' : undefined,\n })\n );\n return {\n entries: data.entries,\n scoreOrder: data.scoreOrder,\n period: data.period,\n season: data.season ?? null,\n team: data.team ?? null,\n limit: data.limit,\n offset: data.offset,\n };\n }\n\n // ─── Rank ───────────────────────────────────────────────────────────────────\n\n /**\n * Get a player's current rank, score, and percentile.\n *\n * @example\n * const { rank, percentile } = await lb.getPlayerRank('player-123');\n * console.log(`Rank #${rank} — top ${(100 - percentile).toFixed(1)}%`);\n */\n async getPlayerRank(playerRefId: string, options?: GetRankOptions): Promise<PlayerRankResult> {\n return this.fetch<PlayerRankResult>(\n 'GET',\n this.gameUrl(`/players/${encodeURIComponent(playerRefId)}/rank`, options)\n );\n }\n\n // ─── Centric leaderboard ───────────────────────────────────────────────────\n\n /**\n * Fetch the leaderboard centered on a specific player, showing the players\n * immediately above and below them.\n *\n * @example\n * const { entries, playerRank } = await lb.getCentricLeaderboard('player-123', { limit: 11 });\n */\n async getCentricLeaderboard(\n playerRefId: string,\n options?: GetCentricOptions\n ): Promise<CentricLeaderboardResult> {\n const data = await this.fetch<any>(\n 'GET',\n this.gameUrl(`/players/${encodeURIComponent(playerRefId)}/centric`, options)\n );\n return {\n entries: data.entries,\n playerRank: data.playerRank ?? null,\n period: data.period,\n season: data.season ?? null,\n team: data.team ?? null,\n scoreOrder: data.scoreOrder,\n };\n }\n\n // ─── Player profile ─────────────────────────────────────────────────────────\n\n /**\n * Fetch a player's profile (name, avatar, country, level, etc.).\n */\n async getPlayer(playerRefId: string): Promise<PlayerProfile> {\n const data = await this.fetch<any>(\n 'GET',\n this.gameUrl(`/players/${encodeURIComponent(playerRefId)}`)\n );\n return data.player as PlayerProfile;\n }\n\n /**\n * Create or update a player's profile. Fields are merged — omitted fields\n * keep their existing values.\n *\n * @example\n * await lb.updatePlayer('player-123', { playerName: 'Alice', country: 'US', level: 5 });\n */\n async updatePlayer(playerRefId: string, options: UpdatePlayerOptions): Promise<void> {\n await this.fetch<any>(\n 'PUT',\n this.gameUrl(`/players/${encodeURIComponent(playerRefId)}`),\n options\n );\n }\n\n // ─── Score history ──────────────────────────────────────────────────────────\n\n /**\n * Fetch all submissions a player has ever made, ordered newest first.\n * Useful for progression charts and run history screens.\n *\n * @example\n * const { entries, bestScore, total } = await lb.getPlayerScores('player-123');\n */\n async getPlayerScores(\n playerRefId: string,\n options?: GetPlayerScoresOptions\n ): Promise<GetPlayerScoresResult> {\n return this.fetch<GetPlayerScoresResult>(\n 'GET',\n this.gameUrl(`/players/${encodeURIComponent(playerRefId)}/scores`, options)\n );\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAM/C,YAAY,SAAiB,QAAgB,UAAmB;AAC9D,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,WAAW;AAEhB,WAAO,eAAe,MAAM,WAAW,SAAS;AAAA,EAClD;AAAA,EAEA,IAAI,cAAc;AAChB,WAAO,KAAK,WAAW,OAAO,KAAK,WAAW;AAAA,EAChD;AAAA,EAEA,IAAI,mBAAmB;AACrB,WAAO,KAAK,WAAW;AAAA,EACzB;AAAA,EAEA,IAAI,iBAAiB;AACnB,WAAO,KAAK,WAAW;AAAA,EACzB;AAAA,EAEA,IAAI,oBAAoB;AACtB,WAAO,KAAK,WAAW;AAAA,EACzB;AACF;;;ACbA,IAAM,mBAAmB;AAEzB,eAAe,WAAW,KAAa,MAA+B;AACpE,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,YAAY,MAAM,WAAW,OAAO,OAAO;AAAA,IAC/C;AAAA,IACA,IAAI,OAAO,GAAG;AAAA,IACd,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,QAAM,MAAM,MAAM,WAAW,OAAO,OAAO,KAAK,QAAQ,WAAW,IAAI,OAAO,IAAI,CAAC;AACnF,SAAO,MAAM,KAAK,IAAI,WAAW,GAAG,CAAC,EAClC,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAC1C,KAAK,EAAE;AACZ;AAEA,SAAS,SAAS,MAAc,MAAc,QAAyB;AACrE,QAAM,MAAM,GAAG,IAAI,GAAG,IAAI;AAC1B,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,KAAK,IAAI,gBAAgB;AAC/B,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,MAAM,GAAG;AAC3C,QAAI,MAAM,UAAa,MAAM,KAAM,IAAG,IAAI,GAAG,OAAO,CAAC,CAAC;AAAA,EACxD;AACA,QAAM,KAAK,GAAG,SAAS;AACvB,SAAO,KAAK,GAAG,GAAG,IAAI,EAAE,KAAK;AAC/B;AAEO,IAAM,mBAAN,MAAuB;AAAA,EAM5B,YAAY,QAAgC;AAC1C,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,MAAM,sCAAsC;AAC1E,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,MAAM,sCAAsC;AAC1E,SAAK,SAAS,OAAO;AACrB,SAAK,SAAS,OAAO;AACrB,SAAK,WAAW,OAAO,WAAW,kBAAkB,QAAQ,OAAO,EAAE;AACrE,SAAK,cAAc,OAAO;AAAA,EAC5B;AAAA,EAEQ,QAAQ,MAAc,QAAyB;AACrD,WAAO,SAAS,KAAK,SAAS,iBAAiB,mBAAmB,KAAK,MAAM,CAAC,GAAG,IAAI,IAAI,MAAM;AAAA,EACjG;AAAA,EAEA,MAAc,MACZ,QACA,KACA,MACA,cACY;AACZ,UAAM,UAAkC;AAAA,MACtC,eAAe,UAAU,KAAK,MAAM;AAAA,MACpC,gBAAgB;AAAA,MAChB,GAAG;AAAA,IACL;AAEA,UAAM,MAAM,MAAM,WAAW,MAAM,KAAK;AAAA,MACtC;AAAA,MACA;AAAA,MACA,MAAM,SAAS,SAAY,KAAK,UAAU,IAAI,IAAI;AAAA,IACpD,CAAC;AAED,QAAI;AACJ,QAAI;AACF,aAAO,MAAM,IAAI,KAAK;AAAA,IACxB,QAAQ;AACN,YAAM,IAAI,sBAAsB,QAAQ,IAAI,MAAM,8BAA8B,IAAI,QAAQ,IAAI;AAAA,IAClG;AAEA,QAAI,CAAC,IAAI,MAAM,MAAM,OAAO,OAAO;AACjC,YAAM,IAAI;AAAA,QACR,MAAM,SAAS,QAAQ,IAAI,MAAM;AAAA,QACjC,IAAI;AAAA,QACJ;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,MAAM,YAAY,SAAyD;AACzE,UAAM,MAAM,KAAK,QAAQ,SAAS;AAClC,UAAM,UAAU,KAAK,UAAU,OAAO;AACtC,UAAM,eAAuC,CAAC;AAE9C,QAAI,KAAK,aAAa;AACpB,YAAM,MAAM,MAAM,WAAW,KAAK,aAAa,OAAO;AACtD,mBAAa,mBAAmB,IAAI,UAAU,GAAG;AAAA,IACnD;AAEA,UAAM,MAAM,MAAM,WAAW,MAAM,KAAK;AAAA,MACtC,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,eAAe,UAAU,KAAK,MAAM;AAAA,QACpC,gBAAgB;AAAA,QAChB,GAAG;AAAA,MACL;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAED,QAAI;AACJ,QAAI;AACF,aAAO,MAAM,IAAI,KAAK;AAAA,IACxB,QAAQ;AACN,YAAM,IAAI,sBAAsB,QAAQ,IAAI,MAAM,8BAA8B,IAAI,QAAQ,IAAI;AAAA,IAClG;AAEA,QAAI,CAAC,IAAI,MAAM,MAAM,OAAO,OAAO;AACjC,YAAM,IAAI,sBAAsB,MAAM,SAAS,QAAQ,IAAI,MAAM,IAAI,IAAI,QAAQ,IAAI;AAAA,IACvF;AAEA,WAAO;AAAA,MACL,IAAI,KAAK;AAAA,MACT,MAAM,KAAK,QAAQ;AAAA,MACnB,gBAAgB,KAAK,kBAAkB;AAAA,MACvC,cAAc,KAAK,gBAAgB;AAAA,MACnC,SAAS,KAAK;AAAA,IAChB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,eAAe,SAAgE;AACnF,UAAM,EAAE,YAAY,GAAG,KAAK,IAAI,WAAW,CAAC;AAC5C,UAAM,OAAO,MAAM,KAAK;AAAA,MACtB;AAAA,MACA,KAAK,QAAQ,gBAAgB;AAAA,QAC3B,GAAG;AAAA,QACH,YAAY,aAAa,SAAS;AAAA,MACpC,CAAC;AAAA,IACH;AACA,WAAO;AAAA,MACL,SAAS,KAAK;AAAA,MACd,YAAY,KAAK;AAAA,MACjB,QAAQ,KAAK;AAAA,MACb,QAAQ,KAAK,UAAU;AAAA,MACvB,MAAM,KAAK,QAAQ;AAAA,MACnB,OAAO,KAAK;AAAA,MACZ,QAAQ,KAAK;AAAA,IACf;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,cAAc,aAAqB,SAAqD;AAC5F,WAAO,KAAK;AAAA,MACV;AAAA,MACA,KAAK,QAAQ,YAAY,mBAAmB,WAAW,CAAC,SAAS,OAAO;AAAA,IAC1E;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,sBACJ,aACA,SACmC;AACnC,UAAM,OAAO,MAAM,KAAK;AAAA,MACtB;AAAA,MACA,KAAK,QAAQ,YAAY,mBAAmB,WAAW,CAAC,YAAY,OAAO;AAAA,IAC7E;AACA,WAAO;AAAA,MACL,SAAS,KAAK;AAAA,MACd,YAAY,KAAK,cAAc;AAAA,MAC/B,QAAQ,KAAK;AAAA,MACb,QAAQ,KAAK,UAAU;AAAA,MACvB,MAAM,KAAK,QAAQ;AAAA,MACnB,YAAY,KAAK;AAAA,IACnB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,UAAU,aAA6C;AAC3D,UAAM,OAAO,MAAM,KAAK;AAAA,MACtB;AAAA,MACA,KAAK,QAAQ,YAAY,mBAAmB,WAAW,CAAC,EAAE;AAAA,IAC5D;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,aAAa,aAAqB,SAA6C;AACnF,UAAM,KAAK;AAAA,MACT;AAAA,MACA,KAAK,QAAQ,YAAY,mBAAmB,WAAW,CAAC,EAAE;AAAA,MAC1D;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,gBACJ,aACA,SACgC;AAChC,WAAO,KAAK;AAAA,MACV;AAAA,MACA,KAAK,QAAQ,YAAY,mBAAmB,WAAW,CAAC,WAAW,OAAO;AAAA,IAC5E;AAAA,EACF;AACF;","names":[]}
|