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