@mpgd/game-services 0.5.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.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/verified-leaderboard-conformance.d.ts +29 -0
- package/dist/verified-leaderboard-conformance.js +630 -0
- package/dist/verified-leaderboard-transport.d.ts +24 -0
- package/dist/verified-leaderboard-transport.js +169 -0
- package/dist/verified-leaderboard.d.ts +112 -0
- package/dist/verified-leaderboard.js +539 -0
- package/package.json +19 -7
|
@@ -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.
|
|
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": {
|
|
@@ -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.
|
|
69
|
-
"@mpgd/
|
|
70
|
-
"@mpgd/
|
|
80
|
+
"@mpgd/catalog": "0.3.5",
|
|
81
|
+
"@mpgd/platform": "0.5.1",
|
|
82
|
+
"@mpgd/analytics": "0.3.5"
|
|
71
83
|
},
|
|
72
84
|
"devDependencies": {
|
|
73
|
-
"ttsc": "0.
|
|
74
|
-
"typescript": "7.0.
|
|
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
|
}
|