@mpgd/game-services 0.4.0 → 0.6.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.
@@ -0,0 +1,539 @@
1
+ export const verifiedLeaderboardIdentifierMaximumLength = 512;
2
+ const timestampPattern = new RegExp('^(\\d{4})-(\\d{2})-(\\d{2})'
3
+ + 'T(\\d{2}):(\\d{2}):(\\d{2})'
4
+ + '(?:\\.(\\d{1,3}))?(Z|([+-])(\\d{2}):(\\d{2}))$');
5
+ export class VerifiedLeaderboardCursorError extends Error {
6
+ name = 'VerifiedLeaderboardCursorError';
7
+ }
8
+ export function createInMemoryVerifiedLeaderboardService(input = {}) {
9
+ return new InMemoryVerifiedLeaderboardService(input.now ?? (() => new Date().toISOString()));
10
+ }
11
+ /**
12
+ * Process-local reference adapter for tests and development. It keeps every
13
+ * definition and processed attempt for its full lifetime and fully sorts the
14
+ * retained attempts for each write and read. Use a durable adapter with
15
+ * eviction and an indexed ranking strategy for long-lived or large boards.
16
+ */
17
+ export class InMemoryVerifiedLeaderboardService {
18
+ now;
19
+ definitions = new Map();
20
+ attemptsById = new Map();
21
+ retainedByLeaderboard = new Map();
22
+ constructor(now = () => new Date().toISOString()) {
23
+ this.now = now;
24
+ }
25
+ async recordVerifiedAttempt(input) {
26
+ assertRecordVerifiedLeaderboardAttemptRequest(input);
27
+ const definition = this.ensureDefinition(input.definition);
28
+ const attempt = cloneVerifiedLeaderboardAttempt(input.attempt);
29
+ const attemptKey = createCompositeKey([definition.leaderboardId, attempt.attemptId]);
30
+ const existingAttempt = this.attemptsById.get(attemptKey);
31
+ if (existingAttempt !== undefined) {
32
+ assertSameAttempt(existingAttempt.attempt, attempt);
33
+ return cloneRecordResponse(existingAttempt.response, true);
34
+ }
35
+ const retainedByParticipant = this.getRetainedAttempts(definition.leaderboardId);
36
+ const retainedAttempt = retainedByParticipant.get(attempt.participantId);
37
+ if (retainedAttempt === undefined
38
+ || shouldReplaceRetainedAttempt(definition, retainedAttempt, attempt)) {
39
+ retainedByParticipant.set(attempt.participantId, attempt);
40
+ }
41
+ const response = this.createRecordResponse(definition, attempt, false);
42
+ this.attemptsById.set(attemptKey, {
43
+ attempt,
44
+ response: cloneRecordResponse(response, false),
45
+ });
46
+ return response;
47
+ }
48
+ async getSnapshot(input) {
49
+ assertGetVerifiedLeaderboardSnapshotRequest(input);
50
+ const definition = this.definitions.get(input.leaderboardId);
51
+ if (definition === undefined) {
52
+ return undefined;
53
+ }
54
+ const rankedEntries = this.createRankedEntries(definition);
55
+ const cursorPosition = input.cursor === undefined
56
+ ? undefined
57
+ : parseVerifiedLeaderboardCursor(input.cursor, definition);
58
+ const firstPageIndex = cursorPosition === undefined
59
+ ? 0
60
+ : rankedEntries.findIndex((entry) => compareRankedEntryToCursor(definition, entry, cursorPosition) > 0);
61
+ const pageStart = firstPageIndex < 0 ? rankedEntries.length : firstPageIndex;
62
+ const pageEnd = pageStart + (input.limit ?? 10);
63
+ const entries = rankedEntries.slice(pageStart, pageEnd);
64
+ const participantEntry = input.participantId === undefined
65
+ ? undefined
66
+ : rankedEntries.find((entry) => entry.participantId === input.participantId);
67
+ const lastEntry = entries.at(-1);
68
+ const nextCursor = lastEntry !== undefined && pageEnd < rankedEntries.length
69
+ ? createVerifiedLeaderboardCursor(definition, lastEntry)
70
+ : undefined;
71
+ const snapshot = {
72
+ definition: cloneVerifiedLeaderboardDefinition(definition),
73
+ entries,
74
+ ...(participantEntry === undefined ? {} : { participantEntry }),
75
+ totalParticipants: rankedEntries.length,
76
+ generatedAt: this.now(),
77
+ ...(nextCursor === undefined ? {} : { nextCursor }),
78
+ };
79
+ assertVerifiedLeaderboardSnapshot(snapshot);
80
+ return snapshot;
81
+ }
82
+ createRecordResponse(definition, attempted, alreadyProcessed) {
83
+ const rankedEntries = this.createRankedEntries(definition);
84
+ const entry = rankedEntries.find((candidate) => candidate.participantId === attempted.participantId);
85
+ if (entry === undefined) {
86
+ throw new Error('Retained leaderboard entry was not found after recording an attempt.');
87
+ }
88
+ const retained = entry.attemptId === attempted.attemptId;
89
+ const response = {
90
+ recorded: true,
91
+ alreadyProcessed,
92
+ retained,
93
+ entry,
94
+ ...(retained ? {} : { reason: 'ATTEMPT_NOT_RETAINED' }),
95
+ };
96
+ assertRecordVerifiedLeaderboardAttemptResponse(response);
97
+ return response;
98
+ }
99
+ ensureDefinition(input) {
100
+ assertVerifiedLeaderboardDefinition(input);
101
+ const definition = cloneVerifiedLeaderboardDefinition(input);
102
+ const existing = this.definitions.get(definition.leaderboardId);
103
+ if (existing === undefined) {
104
+ this.definitions.set(definition.leaderboardId, definition);
105
+ return definition;
106
+ }
107
+ if (existing.scoreOrder !== definition.scoreOrder
108
+ || existing.attemptSelection !== definition.attemptSelection) {
109
+ throw new Error(`Leaderboard definition conflict for ${JSON.stringify(definition.leaderboardId)}.`);
110
+ }
111
+ return existing;
112
+ }
113
+ getRetainedAttempts(leaderboardId) {
114
+ const existing = this.retainedByLeaderboard.get(leaderboardId);
115
+ if (existing !== undefined) {
116
+ return existing;
117
+ }
118
+ const created = new Map();
119
+ this.retainedByLeaderboard.set(leaderboardId, created);
120
+ return created;
121
+ }
122
+ createRankedEntries(definition) {
123
+ return [...this.getRetainedAttempts(definition.leaderboardId).values()]
124
+ .sort((left, right) => compareAttempts(definition.scoreOrder, left, right))
125
+ .map((attempt, index) => toRankedEntry(attempt, index + 1));
126
+ }
127
+ }
128
+ export function assertVerifiedLeaderboardDefinition(input) {
129
+ assertRecord(input, 'VerifiedLeaderboardDefinition');
130
+ assertVerifiedLeaderboardIdentifier(input.leaderboardId, 'leaderboardId');
131
+ assertScoreOrder(input.scoreOrder);
132
+ assertAttemptSelection(input.attemptSelection);
133
+ }
134
+ export function assertLeaderboardVerificationEvidence(input) {
135
+ assertRecord(input, 'LeaderboardVerificationEvidence');
136
+ assertNonEmptyString(input.authorityId, 'authorityId');
137
+ assertNonEmptyString(input.evidenceId, 'evidenceId');
138
+ assertTimestamp(input.verifiedAt, 'verifiedAt');
139
+ }
140
+ export function assertVerifiedLeaderboardAttempt(input) {
141
+ assertRecord(input, 'VerifiedLeaderboardAttempt');
142
+ assertNonEmptyString(input.participantId, 'participantId');
143
+ assertOptionalNonEmptyString(input.participantLabel, 'participantLabel');
144
+ assertVerifiedLeaderboardIdentifier(input.attemptId, 'attemptId');
145
+ assertFiniteNumber(input.score, 'score');
146
+ assertTimestamp(input.completedAt, 'completedAt');
147
+ assertLeaderboardVerificationEvidence(input.verification);
148
+ }
149
+ export function assertRecordVerifiedLeaderboardAttemptRequest(input) {
150
+ assertRecord(input, 'RecordVerifiedLeaderboardAttemptRequest');
151
+ assertVerifiedLeaderboardDefinition(input.definition);
152
+ assertVerifiedLeaderboardAttempt(input.attempt);
153
+ }
154
+ export function assertRecordVerifiedLeaderboardAttemptResponse(input) {
155
+ assertRecord(input, 'RecordVerifiedLeaderboardAttemptResponse');
156
+ if (input.recorded !== true) {
157
+ throw new Error('recorded must be true.');
158
+ }
159
+ assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
160
+ assertBoolean(input.retained, 'retained');
161
+ assertLeaderboardRankedEntry(input.entry);
162
+ if (input.reason !== undefined && input.reason !== 'ATTEMPT_NOT_RETAINED') {
163
+ throw new Error('reason must be ATTEMPT_NOT_RETAINED when provided.');
164
+ }
165
+ if (input.retained === (input.reason !== undefined)) {
166
+ throw new Error('reason must be present exactly when the attempt is not retained.');
167
+ }
168
+ }
169
+ export function assertGetVerifiedLeaderboardSnapshotRequest(input) {
170
+ assertRecord(input, 'GetVerifiedLeaderboardSnapshotRequest');
171
+ assertVerifiedLeaderboardIdentifier(input.leaderboardId, 'leaderboardId');
172
+ assertOptionalNonEmptyString(input.participantId, 'participantId');
173
+ assertOptionalCursor(input.cursor);
174
+ if (input.limit !== undefined
175
+ && (typeof input.limit !== 'number'
176
+ || !Number.isInteger(input.limit)
177
+ || input.limit < 1
178
+ || input.limit > 100)) {
179
+ throw new Error('limit must be an integer from 1 through 100.');
180
+ }
181
+ }
182
+ export function assertLeaderboardRankedEntry(input) {
183
+ assertRecord(input, 'LeaderboardRankedEntry');
184
+ if (typeof input.rank !== 'number' || !Number.isInteger(input.rank) || input.rank < 1) {
185
+ throw new Error('rank must be a positive integer.');
186
+ }
187
+ assertNonEmptyString(input.participantId, 'participantId');
188
+ assertOptionalNonEmptyString(input.participantLabel, 'participantLabel');
189
+ assertVerifiedLeaderboardIdentifier(input.attemptId, 'attemptId');
190
+ assertFiniteNumber(input.score, 'score');
191
+ assertTimestamp(input.completedAt, 'completedAt');
192
+ }
193
+ export function assertVerifiedLeaderboardSnapshot(input) {
194
+ assertRecord(input, 'VerifiedLeaderboardSnapshot');
195
+ assertVerifiedLeaderboardDefinition(input.definition);
196
+ if (!Array.isArray(input.entries)) {
197
+ throw new Error('entries must be an array.');
198
+ }
199
+ for (const entry of input.entries) {
200
+ assertLeaderboardRankedEntry(entry);
201
+ }
202
+ if (input.participantEntry !== undefined) {
203
+ assertLeaderboardRankedEntry(input.participantEntry);
204
+ }
205
+ if (typeof input.totalParticipants !== 'number'
206
+ || !Number.isInteger(input.totalParticipants)
207
+ || input.totalParticipants < 0) {
208
+ throw new Error('totalParticipants must be a non-negative integer.');
209
+ }
210
+ assertTimestamp(input.generatedAt, 'generatedAt');
211
+ assertOptionalCursor(input.nextCursor);
212
+ }
213
+ export function createVerifiedLeaderboardCursor(definition, entry) {
214
+ assertVerifiedLeaderboardDefinition(definition);
215
+ assertLeaderboardRankedEntry(entry);
216
+ const cursor = encodeBase64Url(JSON.stringify({
217
+ version: 1,
218
+ leaderboardId: definition.leaderboardId,
219
+ scoreOrder: definition.scoreOrder,
220
+ attemptSelection: definition.attemptSelection,
221
+ score: entry.score,
222
+ completedAt: entry.completedAt,
223
+ attemptId: entry.attemptId,
224
+ }));
225
+ assertOptionalCursor(cursor);
226
+ return cursor;
227
+ }
228
+ export function parseVerifiedLeaderboardCursor(cursor, definition) {
229
+ assertVerifiedLeaderboardDefinition(definition);
230
+ try {
231
+ assertOptionalCursor(cursor);
232
+ const payload = JSON.parse(decodeBase64Url(cursor));
233
+ assertRecord(payload, 'VerifiedLeaderboardCursor');
234
+ if (payload.version !== 1) {
235
+ throw new Error('cursor version must be 1.');
236
+ }
237
+ assertVerifiedLeaderboardIdentifier(payload.leaderboardId, 'cursor leaderboardId');
238
+ assertScoreOrder(payload.scoreOrder);
239
+ assertAttemptSelection(payload.attemptSelection);
240
+ assertFiniteNumber(payload.score, 'cursor score');
241
+ assertTimestamp(payload.completedAt, 'cursor completedAt');
242
+ assertVerifiedLeaderboardIdentifier(payload.attemptId, 'cursor attemptId');
243
+ if (payload.leaderboardId !== definition.leaderboardId
244
+ || payload.scoreOrder !== definition.scoreOrder
245
+ || payload.attemptSelection !== definition.attemptSelection) {
246
+ throw new Error('cursor does not match the leaderboard definition.');
247
+ }
248
+ return {
249
+ score: payload.score,
250
+ completedAtMs: parseTimestamp(payload.completedAt),
251
+ attemptId: payload.attemptId,
252
+ };
253
+ }
254
+ catch (error) {
255
+ if (error instanceof VerifiedLeaderboardCursorError) {
256
+ throw error;
257
+ }
258
+ throw new VerifiedLeaderboardCursorError('Invalid verified leaderboard cursor.', {
259
+ cause: error,
260
+ });
261
+ }
262
+ }
263
+ function shouldReplaceRetainedAttempt(definition, retained, candidate) {
264
+ if (definition.attemptSelection === 'first') {
265
+ return compareAttemptChronology(candidate, retained) < 0;
266
+ }
267
+ return compareAttempts(definition.scoreOrder, candidate, retained) < 0;
268
+ }
269
+ function compareAttemptChronology(left, right) {
270
+ const completedAtComparison = parseTimestamp(left.completedAt) - parseTimestamp(right.completedAt);
271
+ if (completedAtComparison !== 0) {
272
+ return completedAtComparison;
273
+ }
274
+ return compareOrdinal(left.attemptId, right.attemptId);
275
+ }
276
+ function compareAttempts(scoreOrder, left, right) {
277
+ if (left.score !== right.score) {
278
+ return scoreOrder === 'ascending' ? left.score - right.score : right.score - left.score;
279
+ }
280
+ const completedAtComparison = parseTimestamp(left.completedAt) - parseTimestamp(right.completedAt);
281
+ if (completedAtComparison !== 0) {
282
+ return completedAtComparison;
283
+ }
284
+ return compareOrdinal(left.attemptId, right.attemptId);
285
+ }
286
+ function toRankedEntry(attempt, rank) {
287
+ const entry = {
288
+ rank,
289
+ participantId: attempt.participantId,
290
+ ...(attempt.participantLabel === undefined
291
+ ? {}
292
+ : { participantLabel: attempt.participantLabel }),
293
+ attemptId: attempt.attemptId,
294
+ score: attempt.score,
295
+ completedAt: attempt.completedAt,
296
+ };
297
+ assertLeaderboardRankedEntry(entry);
298
+ return entry;
299
+ }
300
+ function assertSameAttempt(existing, candidate) {
301
+ if (existing.participantId !== candidate.participantId
302
+ || existing.attemptId !== candidate.attemptId
303
+ || existing.score !== candidate.score
304
+ || parseTimestamp(existing.completedAt) !== parseTimestamp(candidate.completedAt)
305
+ || existing.verification.authorityId !== candidate.verification.authorityId
306
+ || existing.verification.evidenceId !== candidate.verification.evidenceId
307
+ || parseTimestamp(existing.verification.verifiedAt)
308
+ !== parseTimestamp(candidate.verification.verifiedAt)) {
309
+ throw new Error(`Attempt id conflict for ${JSON.stringify(candidate.attemptId)}.`);
310
+ }
311
+ }
312
+ function cloneVerifiedLeaderboardDefinition(input) {
313
+ return {
314
+ leaderboardId: input.leaderboardId,
315
+ scoreOrder: input.scoreOrder,
316
+ attemptSelection: input.attemptSelection,
317
+ };
318
+ }
319
+ function cloneVerifiedLeaderboardAttempt(input) {
320
+ return {
321
+ participantId: input.participantId,
322
+ ...(input.participantLabel === undefined
323
+ ? {}
324
+ : { participantLabel: input.participantLabel }),
325
+ attemptId: input.attemptId,
326
+ score: input.score,
327
+ completedAt: input.completedAt,
328
+ verification: {
329
+ authorityId: input.verification.authorityId,
330
+ evidenceId: input.verification.evidenceId,
331
+ verifiedAt: input.verification.verifiedAt,
332
+ },
333
+ };
334
+ }
335
+ function cloneRecordResponse(input, alreadyProcessed) {
336
+ const response = {
337
+ recorded: true,
338
+ alreadyProcessed,
339
+ retained: input.retained,
340
+ entry: {
341
+ rank: input.entry.rank,
342
+ participantId: input.entry.participantId,
343
+ ...(input.entry.participantLabel === undefined
344
+ ? {}
345
+ : { participantLabel: input.entry.participantLabel }),
346
+ attemptId: input.entry.attemptId,
347
+ score: input.entry.score,
348
+ completedAt: input.entry.completedAt,
349
+ },
350
+ ...(input.reason === undefined ? {} : { reason: input.reason }),
351
+ };
352
+ assertRecordVerifiedLeaderboardAttemptResponse(response);
353
+ return response;
354
+ }
355
+ function createCompositeKey(parts) {
356
+ return JSON.stringify(parts);
357
+ }
358
+ function assertRecord(input, label) {
359
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
360
+ throw new Error(`${label} must be an object.`);
361
+ }
362
+ }
363
+ function assertNonEmptyString(input, label) {
364
+ if (typeof input !== 'string' || input.length === 0) {
365
+ throw new Error(`${label} must be a non-empty string.`);
366
+ }
367
+ }
368
+ function assertVerifiedLeaderboardIdentifier(input, label) {
369
+ assertNonEmptyString(input, label);
370
+ if (input.length > verifiedLeaderboardIdentifierMaximumLength) {
371
+ throw new Error(`${label} must contain at most ${String(verifiedLeaderboardIdentifierMaximumLength)} characters.`);
372
+ }
373
+ if (!isWellFormedUnicode(input)) {
374
+ throw new Error(`${label} must contain only well-formed Unicode.`);
375
+ }
376
+ }
377
+ function isWellFormedUnicode(input) {
378
+ for (let index = 0; index < input.length; index += 1) {
379
+ const codeUnit = input.charCodeAt(index);
380
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
381
+ const nextCodeUnit = input.charCodeAt(index + 1);
382
+ if (index + 1 >= input.length
383
+ || nextCodeUnit < 0xdc00
384
+ || nextCodeUnit > 0xdfff) {
385
+ return false;
386
+ }
387
+ index += 1;
388
+ }
389
+ else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
390
+ return false;
391
+ }
392
+ }
393
+ return true;
394
+ }
395
+ function assertOptionalNonEmptyString(input, label) {
396
+ if (input !== undefined) {
397
+ assertNonEmptyString(input, label);
398
+ }
399
+ }
400
+ function assertFiniteNumber(input, label) {
401
+ if (typeof input !== 'number' || !Number.isFinite(input)) {
402
+ throw new Error(`${label} must be a finite number.`);
403
+ }
404
+ }
405
+ function assertBoolean(input, label) {
406
+ if (typeof input !== 'boolean') {
407
+ throw new Error(`${label} must be a boolean.`);
408
+ }
409
+ }
410
+ function assertTimestamp(input, label) {
411
+ assertNonEmptyString(input, label);
412
+ if (!Number.isFinite(parseTimestamp(input))) {
413
+ throw new Error(`${label} must be a valid timezone-qualified timestamp.`);
414
+ }
415
+ }
416
+ function parseTimestamp(input) {
417
+ const match = timestampPattern.exec(input);
418
+ if (match === null) {
419
+ return Number.NaN;
420
+ }
421
+ const year = Number(match[1]);
422
+ const month = Number(match[2]);
423
+ const day = Number(match[3]);
424
+ const hour = Number(match[4]);
425
+ const minute = Number(match[5]);
426
+ const second = Number(match[6]);
427
+ const millisecond = Number((match[7] ?? '').padEnd(3, '0'));
428
+ const offsetHour = Number(match[10] ?? '0');
429
+ const offsetMinute = Number(match[11] ?? '0');
430
+ if (offsetHour > 23 || offsetMinute > 59) {
431
+ return Number.NaN;
432
+ }
433
+ const localTime = new Date(0);
434
+ localTime.setUTCFullYear(year, month - 1, day);
435
+ localTime.setUTCHours(hour, minute, second, millisecond);
436
+ if (localTime.getUTCFullYear() !== year
437
+ || localTime.getUTCMonth() !== month - 1
438
+ || localTime.getUTCDate() !== day
439
+ || localTime.getUTCHours() !== hour
440
+ || localTime.getUTCMinutes() !== minute
441
+ || localTime.getUTCSeconds() !== second
442
+ || localTime.getUTCMilliseconds() !== millisecond) {
443
+ return Number.NaN;
444
+ }
445
+ const offsetDirection = match[9] === '-' ? -1 : 1;
446
+ const offsetMilliseconds = offsetDirection
447
+ * (offsetHour * 60 + offsetMinute)
448
+ * 60000;
449
+ return localTime.getTime() - offsetMilliseconds;
450
+ }
451
+ function compareOrdinal(left, right) {
452
+ if (left < right) {
453
+ return -1;
454
+ }
455
+ return left > right ? 1 : 0;
456
+ }
457
+ function compareRankedEntryToCursor(definition, entry, cursor) {
458
+ if (entry.score !== cursor.score) {
459
+ const comparison = entry.score < cursor.score ? -1 : 1;
460
+ return definition.scoreOrder === 'ascending' ? comparison : -comparison;
461
+ }
462
+ const completedAtMs = parseTimestamp(entry.completedAt);
463
+ if (completedAtMs !== cursor.completedAtMs) {
464
+ return completedAtMs < cursor.completedAtMs ? -1 : 1;
465
+ }
466
+ return compareOrdinal(entry.attemptId, cursor.attemptId);
467
+ }
468
+ function assertOptionalCursor(input) {
469
+ if (input === undefined) {
470
+ return;
471
+ }
472
+ if (typeof input !== 'string'
473
+ || input.length === 0
474
+ || input.length > 65536
475
+ || !/^[A-Za-z0-9_-]+$/u.test(input)) {
476
+ throw new VerifiedLeaderboardCursorError('cursor must be a base64url string no longer than 65536 characters.');
477
+ }
478
+ }
479
+ const base64UrlAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
480
+ function encodeBase64Url(input) {
481
+ const bytes = new TextEncoder().encode(input);
482
+ let encoded = '';
483
+ for (let index = 0; index < bytes.length; index += 3) {
484
+ const first = bytes[index] ?? 0;
485
+ const second = bytes[index + 1];
486
+ const third = bytes[index + 2];
487
+ const bits = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
488
+ encoded += base64UrlAlphabet.charAt((bits >>> 18) & 63);
489
+ encoded += base64UrlAlphabet.charAt((bits >>> 12) & 63);
490
+ if (second !== undefined) {
491
+ encoded += base64UrlAlphabet.charAt((bits >>> 6) & 63);
492
+ }
493
+ if (third !== undefined) {
494
+ encoded += base64UrlAlphabet.charAt(bits & 63);
495
+ }
496
+ }
497
+ return encoded;
498
+ }
499
+ function decodeBase64Url(input) {
500
+ if (input.length % 4 === 1) {
501
+ throw new Error('cursor has invalid base64url length.');
502
+ }
503
+ const bytes = [];
504
+ for (let index = 0; index < input.length; index += 4) {
505
+ const first = decodeBase64UrlCharacter(input[index]);
506
+ const second = decodeBase64UrlCharacter(input[index + 1]);
507
+ const third = decodeBase64UrlCharacter(input[index + 2]);
508
+ const fourth = decodeBase64UrlCharacter(input[index + 3]);
509
+ const bits = (first << 18) | (second << 12) | (third << 6) | fourth;
510
+ bytes.push((bits >>> 16) & 255);
511
+ if (input[index + 2] !== undefined) {
512
+ bytes.push((bits >>> 8) & 255);
513
+ }
514
+ if (input[index + 3] !== undefined) {
515
+ bytes.push(bits & 255);
516
+ }
517
+ }
518
+ return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes));
519
+ }
520
+ function decodeBase64UrlCharacter(input) {
521
+ if (input === undefined) {
522
+ return 0;
523
+ }
524
+ const value = base64UrlAlphabet.indexOf(input);
525
+ if (value < 0) {
526
+ throw new Error('cursor contains invalid base64url characters.');
527
+ }
528
+ return value;
529
+ }
530
+ function assertScoreOrder(input) {
531
+ if (input !== 'ascending' && input !== 'descending') {
532
+ throw new Error('scoreOrder must be ascending or descending.');
533
+ }
534
+ }
535
+ function assertAttemptSelection(input) {
536
+ if (input !== 'first' && input !== 'best') {
537
+ throw new Error('attemptSelection must be first or best.');
538
+ }
539
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,6 +45,10 @@
45
45
  "types": "./dist/progress-link.d.ts",
46
46
  "default": "./dist/progress-link.js"
47
47
  },
48
+ "./runtime": {
49
+ "types": "./dist/runtime.d.ts",
50
+ "default": "./dist/runtime.js"
51
+ },
48
52
  "./server": {
49
53
  "types": "./dist/server.d.ts",
50
54
  "default": "./dist/server.js"
@@ -52,6 +56,18 @@
52
56
  "./types": {
53
57
  "types": "./dist/types.d.ts",
54
58
  "default": "./dist/types.js"
59
+ },
60
+ "./verified-leaderboard": {
61
+ "types": "./dist/verified-leaderboard.d.ts",
62
+ "default": "./dist/verified-leaderboard.js"
63
+ },
64
+ "./verified-leaderboard-transport": {
65
+ "types": "./dist/verified-leaderboard-transport.d.ts",
66
+ "default": "./dist/verified-leaderboard-transport.js"
67
+ },
68
+ "./verified-leaderboard-conformance": {
69
+ "types": "./dist/verified-leaderboard-conformance.d.ts",
70
+ "default": "./dist/verified-leaderboard-conformance.js"
55
71
  }
56
72
  },
57
73
  "files": [
@@ -61,13 +77,13 @@
61
77
  "@orpc/client": "2.0.0-beta.14",
62
78
  "@orpc/contract": "2.0.0-beta.14",
63
79
  "@orpc/server": "2.0.0-beta.14",
64
- "@mpgd/analytics": "0.3.3",
65
- "@mpgd/catalog": "0.3.3",
66
- "@mpgd/platform": "0.4.0"
80
+ "@mpgd/catalog": "0.3.5",
81
+ "@mpgd/platform": "0.5.1",
82
+ "@mpgd/analytics": "0.3.5"
67
83
  },
68
84
  "devDependencies": {
69
- "ttsc": "0.16.9",
70
- "typescript": "7.0.1-rc"
85
+ "ttsc": "0.18.4",
86
+ "typescript": "7.0.2"
71
87
  },
72
88
  "main": "./dist/index.js",
73
89
  "types": "./dist/index.d.ts",
@@ -79,6 +95,6 @@
79
95
  "lint": "ttsc --noEmit",
80
96
  "format": "ttsc format",
81
97
  "fix": "ttsc fix",
82
- "test": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts"
98
+ "test": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts"
83
99
  }
84
100
  }