@mpgd/game-services 0.6.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.
|
@@ -71,6 +71,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
|
|
|
71
71
|
participantLabel: 'First Player',
|
|
72
72
|
attemptId: 'attempt:later',
|
|
73
73
|
score: 20,
|
|
74
|
+
metrics: { elapsedMs: 20000, hints: 1, mistakes: 2 },
|
|
74
75
|
completedAt: '2030-01-02T02:02:00.000Z',
|
|
75
76
|
});
|
|
76
77
|
const laterRecord = await context.service.recordVerifiedAttempt(laterRequest);
|
|
@@ -80,6 +81,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
|
|
|
80
81
|
participantLabel: 'First Player',
|
|
81
82
|
attemptId: 'attempt:earlier',
|
|
82
83
|
score: 30,
|
|
84
|
+
metrics: { elapsedMs: 30000, hints: 0, mistakes: 1 },
|
|
83
85
|
completedAt: '2030-01-02T02:01:00.000Z',
|
|
84
86
|
}));
|
|
85
87
|
await context.service.recordVerifiedAttempt(createAttempt({
|
|
@@ -97,10 +99,12 @@ async function runFirstSelectionAndSnapshotScenario(context) {
|
|
|
97
99
|
participantLabel: 'First Player',
|
|
98
100
|
attemptId: 'attempt:latest',
|
|
99
101
|
score: 5,
|
|
102
|
+
metrics: { elapsedMs: 5000, hints: 2, mistakes: 0 },
|
|
100
103
|
completedAt: '2030-01-02T02:04:00.000Z',
|
|
101
104
|
}));
|
|
102
105
|
assertEqual(latestRecord.retained, false, 'later completions must not replace the earliest retained attempt');
|
|
103
106
|
assertEqual(latestRecord.entry.attemptId, 'attempt:earlier', 'rejected later attempts must report the retained earliest attempt');
|
|
107
|
+
assertEqual(latestRecord.entry.metrics?.elapsedMs, 30000, 'rejected attempts must report the retained entry metrics');
|
|
104
108
|
const snapshot = await context.service.getSnapshot({
|
|
105
109
|
leaderboardId: 'first:board',
|
|
106
110
|
participantId: 'participant:first',
|
|
@@ -111,6 +115,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
|
|
|
111
115
|
assertEqual(snapshot.entries[0]?.rank, 1, 'page entries must carry their global rank');
|
|
112
116
|
assertEqual(snapshot.entries[0]?.participantId, 'participant:leader', 'ascending snapshots must rank lower scores first');
|
|
113
117
|
assertEqual(snapshot.participantEntry?.attemptId, 'attempt:earlier', 'participant entries must reflect retained attempts outside the requested page');
|
|
118
|
+
assertEqual(snapshot.participantEntry?.metrics?.mistakes, 1, 'participant entries must preserve generic attempt metrics');
|
|
114
119
|
assertEqual(snapshot.participantEntry?.rank, 2, 'participant entries must carry global rank');
|
|
115
120
|
assertEqual(snapshot.totalParticipants, 2, 'snapshots must count retained participants');
|
|
116
121
|
assertEqual(snapshot.generatedAt, context.now, 'snapshots must use the provider clock');
|
|
@@ -309,6 +314,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
|
309
314
|
participantLabel: 'Original Label',
|
|
310
315
|
attemptId: 'attempt:identity',
|
|
311
316
|
score: 42,
|
|
317
|
+
metrics: { elapsedMs: 42000, hints: 1, mistakes: 2 },
|
|
312
318
|
completedAt: '2030-01-02T02:30:00.000Z',
|
|
313
319
|
});
|
|
314
320
|
await context.service.recordVerifiedAttempt(request);
|
|
@@ -317,6 +323,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
|
317
323
|
attempt: {
|
|
318
324
|
...request.attempt,
|
|
319
325
|
participantLabel: 'Changed Label',
|
|
326
|
+
metrics: { mistakes: 2, elapsedMs: 42000, hints: 1 },
|
|
320
327
|
completedAt: '2030-01-02T11:30:00.000+09:00',
|
|
321
328
|
verification: {
|
|
322
329
|
...request.attempt.verification,
|
|
@@ -327,10 +334,18 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
|
327
334
|
assertEqual(equivalentRetry.alreadyProcessed, true, 'equivalent retries must be idempotent');
|
|
328
335
|
assertEqual(equivalentRetry.entry.participantLabel, 'Original Label', 'retry labels must not rewrite stored presentation metadata');
|
|
329
336
|
assertEqual(equivalentRetry.entry.completedAt, request.attempt.completedAt, 'equivalent retries must preserve the original timestamp representation');
|
|
337
|
+
assertEqual(equivalentRetry.entry.metrics?.elapsedMs, 42000, 'equivalent retries must preserve the original metrics');
|
|
330
338
|
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
331
339
|
...request,
|
|
332
340
|
attempt: { ...request.attempt, score: 43 },
|
|
333
341
|
}), 'attempt IDs reused with a different score must fail closed');
|
|
342
|
+
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
343
|
+
...request,
|
|
344
|
+
attempt: {
|
|
345
|
+
...request.attempt,
|
|
346
|
+
metrics: { elapsedMs: 42000, hints: 0, mistakes: 2 },
|
|
347
|
+
},
|
|
348
|
+
}), 'attempt IDs reused with different metrics must fail closed');
|
|
334
349
|
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
335
350
|
...request,
|
|
336
351
|
attempt: {
|
|
@@ -417,11 +432,15 @@ async function runMutationIsolationScenario(context) {
|
|
|
417
432
|
participantLabel: 'Stable Label',
|
|
418
433
|
attemptId: 'attempt:mutation',
|
|
419
434
|
score: 50,
|
|
435
|
+
metrics: { elapsedMs: 50000, hints: 1, mistakes: 2 },
|
|
420
436
|
completedAt: '2030-01-02T02:50:00.000Z',
|
|
421
437
|
});
|
|
422
438
|
const response = await context.service.recordVerifiedAttempt(request);
|
|
423
439
|
request.definition.scoreOrder = 'descending';
|
|
424
440
|
request.attempt.score = -1;
|
|
441
|
+
if (request.attempt.metrics !== undefined) {
|
|
442
|
+
request.attempt.metrics.elapsedMs = 1;
|
|
443
|
+
}
|
|
425
444
|
request.attempt.verification.evidenceId = 'evidence:mutated';
|
|
426
445
|
try {
|
|
427
446
|
Object.assign(response.entry, { participantLabel: 'Mutated Label', score: -2 });
|
|
@@ -429,6 +448,12 @@ async function runMutationIsolationScenario(context) {
|
|
|
429
448
|
catch {
|
|
430
449
|
// Frozen provider responses are already isolated from caller mutation.
|
|
431
450
|
}
|
|
451
|
+
try {
|
|
452
|
+
Object.assign(response.entry.metrics ?? {}, { elapsedMs: 2 });
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// Frozen provider metric copies are already isolated from caller mutation.
|
|
456
|
+
}
|
|
432
457
|
const snapshot = await context.service.getSnapshot({
|
|
433
458
|
leaderboardId: 'mutation:board',
|
|
434
459
|
participantId: 'participant:mutation',
|
|
@@ -440,6 +465,7 @@ async function runMutationIsolationScenario(context) {
|
|
|
440
465
|
assert(participantEntry !== undefined, 'participant reads must return the retained entry');
|
|
441
466
|
assertEqual(snapshot.definition.scoreOrder, 'ascending', 'stored definitions must be cloned');
|
|
442
467
|
assertEqual(snapshotEntry.score, 50, 'stored attempts must resist caller mutation');
|
|
468
|
+
assertEqual(snapshotEntry.metrics?.elapsedMs, 50000, 'stored metrics must be cloned');
|
|
443
469
|
assertEqual(snapshotEntry.participantLabel, 'Stable Label', 'response mutation must not rewrite retained entries');
|
|
444
470
|
try {
|
|
445
471
|
Object.assign(snapshot.definition, { scoreOrder: 'descending' });
|
|
@@ -453,12 +479,24 @@ async function runMutationIsolationScenario(context) {
|
|
|
453
479
|
catch {
|
|
454
480
|
// Frozen provider snapshot entries are already isolated from mutation.
|
|
455
481
|
}
|
|
482
|
+
try {
|
|
483
|
+
Object.assign(snapshotEntry.metrics ?? {}, { elapsedMs: 3 });
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// Frozen provider snapshot metrics are already isolated from mutation.
|
|
487
|
+
}
|
|
456
488
|
try {
|
|
457
489
|
Object.assign(participantEntry, { participantLabel: 'Participant Mutation', score: -4 });
|
|
458
490
|
}
|
|
459
491
|
catch {
|
|
460
492
|
// Frozen provider participant entries are already isolated from mutation.
|
|
461
493
|
}
|
|
494
|
+
try {
|
|
495
|
+
Object.assign(participantEntry.metrics ?? {}, { mistakes: 4 });
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// Frozen provider participant metrics are already isolated from mutation.
|
|
499
|
+
}
|
|
462
500
|
try {
|
|
463
501
|
snapshot.entries.pop();
|
|
464
502
|
}
|
|
@@ -472,9 +510,11 @@ async function runMutationIsolationScenario(context) {
|
|
|
472
510
|
assertEqual(rereadSnapshot?.definition.scoreOrder, 'ascending', 'snapshot definition mutation must not rewrite stored policy');
|
|
473
511
|
assertEqual(rereadSnapshot?.entries[0]?.score, 50, 'snapshot entry mutation must not rewrite retained attempts');
|
|
474
512
|
assertEqual(rereadSnapshot?.entries[0]?.participantLabel, 'Stable Label', 'snapshot label mutation must not rewrite presentation metadata');
|
|
513
|
+
assertEqual(rereadSnapshot?.entries[0]?.metrics?.elapsedMs, 50000, 'snapshot metric mutation must not rewrite retained metrics');
|
|
475
514
|
assertEqual(rereadSnapshot?.entries.length, 1, 'snapshot array mutation must not rewrite retained entries');
|
|
476
515
|
assertEqual(rereadSnapshot?.participantEntry?.score, 50, 'participant entry mutation must not rewrite retained attempts');
|
|
477
516
|
assertEqual(rereadSnapshot?.participantEntry?.participantLabel, 'Stable Label', 'participant entry mutation must not rewrite presentation metadata');
|
|
517
|
+
assertEqual(rereadSnapshot?.participantEntry?.metrics?.mistakes, 2, 'participant metric mutation must not rewrite retained metrics');
|
|
478
518
|
}
|
|
479
519
|
class ProviderOutputValidationError extends Error {
|
|
480
520
|
}
|
|
@@ -552,6 +592,38 @@ async function runRuntimeValidationScenario(context) {
|
|
|
552
592
|
score: Number.POSITIVE_INFINITY,
|
|
553
593
|
completedAt: validVerificationTimestamp,
|
|
554
594
|
})), 'infinite scores must fail closed');
|
|
595
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
596
|
+
leaderboardId: 'validation:metric-key',
|
|
597
|
+
participantId: 'participant:metric-key',
|
|
598
|
+
attemptId: 'attempt:metric-key',
|
|
599
|
+
score: 1,
|
|
600
|
+
metrics: { '1elapsedMs': 1 },
|
|
601
|
+
completedAt: validVerificationTimestamp,
|
|
602
|
+
})), 'invalid metric keys must fail closed');
|
|
603
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
604
|
+
leaderboardId: 'validation:metric-value',
|
|
605
|
+
participantId: 'participant:metric-value',
|
|
606
|
+
attemptId: 'attempt:metric-value',
|
|
607
|
+
score: 1,
|
|
608
|
+
metrics: { mistakes: -1 },
|
|
609
|
+
completedAt: validVerificationTimestamp,
|
|
610
|
+
})), 'negative metric values must fail closed');
|
|
611
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
612
|
+
leaderboardId: 'validation:metric-safe-integer',
|
|
613
|
+
participantId: 'participant:metric-safe-integer',
|
|
614
|
+
attemptId: 'attempt:metric-safe-integer',
|
|
615
|
+
score: 1,
|
|
616
|
+
metrics: { elapsedMs: Number.MAX_SAFE_INTEGER + 1 },
|
|
617
|
+
completedAt: validVerificationTimestamp,
|
|
618
|
+
})), 'unsafe integer metric values must fail closed');
|
|
619
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
620
|
+
leaderboardId: 'validation:metric-count',
|
|
621
|
+
participantId: 'participant:metric-count',
|
|
622
|
+
attemptId: 'attempt:metric-count',
|
|
623
|
+
score: 1,
|
|
624
|
+
metrics: Object.fromEntries(Array.from({ length: 17 }, (_, index) => [`metric${String(index)}`, index])),
|
|
625
|
+
completedAt: validVerificationTimestamp,
|
|
626
|
+
})), 'oversized metric maps must fail closed');
|
|
555
627
|
const failedWriteChecks = await Promise.all([
|
|
556
628
|
'validation:calendar',
|
|
557
629
|
'validation:timezone',
|
|
@@ -559,6 +631,10 @@ async function runRuntimeValidationScenario(context) {
|
|
|
559
631
|
'validation:evidence-timezone',
|
|
560
632
|
'validation:score-nan',
|
|
561
633
|
'validation:score-infinity',
|
|
634
|
+
'validation:metric-key',
|
|
635
|
+
'validation:metric-value',
|
|
636
|
+
'validation:metric-safe-integer',
|
|
637
|
+
'validation:metric-count',
|
|
562
638
|
].map(async (leaderboardId) => ({
|
|
563
639
|
leaderboardId,
|
|
564
640
|
snapshot: await context.service.getSnapshot({ leaderboardId }),
|
|
@@ -597,6 +673,7 @@ function createMutableAttempt(input) {
|
|
|
597
673
|
: { participantLabel: input.participantLabel }),
|
|
598
674
|
attemptId: input.attemptId,
|
|
599
675
|
score: input.score,
|
|
676
|
+
...(input.metrics === undefined ? {} : { metrics: { ...input.metrics } }),
|
|
600
677
|
completedAt: input.completedAt,
|
|
601
678
|
verification: {
|
|
602
679
|
authorityId: 'verified-leaderboard-conformance',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertGetVerifiedLeaderboardSnapshotRequest, assertVerifiedLeaderboardSnapshot, VerifiedLeaderboardCursorError, } from './verified-leaderboard.js';
|
|
1
|
+
import { assertGetVerifiedLeaderboardSnapshotRequest, assertVerifiedLeaderboardSnapshot, normalizeVerifiedLeaderboardMetrics, VerifiedLeaderboardCursorError, } from './verified-leaderboard.js';
|
|
2
2
|
export const verifiedLeaderboardSnapshotPath = '/game-services/verified-leaderboard/snapshot';
|
|
3
3
|
export function createVerifiedLeaderboardSnapshotFetchHandler(input) {
|
|
4
4
|
const path = input.path ?? verifiedLeaderboardSnapshotPath;
|
|
@@ -99,10 +99,35 @@ export function createVerifiedLeaderboardSnapshotFetchClient(input) {
|
|
|
99
99
|
throw new Error(`Verified leaderboard snapshot request failed: ${error}.`);
|
|
100
100
|
}
|
|
101
101
|
assertVerifiedLeaderboardSnapshot(body);
|
|
102
|
-
return body;
|
|
102
|
+
return normalizeSnapshotMetrics(body);
|
|
103
103
|
},
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
|
+
function normalizeSnapshotMetrics(input) {
|
|
107
|
+
const normalizeEntry = (entry) => ({
|
|
108
|
+
rank: entry.rank,
|
|
109
|
+
participantId: entry.participantId,
|
|
110
|
+
...(entry.participantLabel === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: { participantLabel: entry.participantLabel }),
|
|
113
|
+
attemptId: entry.attemptId,
|
|
114
|
+
score: entry.score,
|
|
115
|
+
...(entry.metrics === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { metrics: normalizeVerifiedLeaderboardMetrics(entry.metrics) }),
|
|
118
|
+
completedAt: entry.completedAt,
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
definition: input.definition,
|
|
122
|
+
entries: input.entries.map(normalizeEntry),
|
|
123
|
+
...(input.participantEntry === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { participantEntry: normalizeEntry(input.participantEntry) }),
|
|
126
|
+
totalParticipants: input.totalParticipants,
|
|
127
|
+
generatedAt: input.generatedAt,
|
|
128
|
+
...(input.nextCursor === undefined ? {} : { nextCursor: input.nextCursor }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
106
131
|
function readSnapshotRequest(url) {
|
|
107
132
|
const allowedParameters = new Set(['leaderboardId', 'limit', 'cursor']);
|
|
108
133
|
for (const parameter of url.searchParams.keys()) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export type VerifiedLeaderboardScoreOrder = 'ascending' | 'descending';
|
|
2
2
|
export type VerifiedLeaderboardAttemptSelection = 'first' | 'best';
|
|
3
|
+
export type VerifiedLeaderboardMetrics = Readonly<Record<string, number>>;
|
|
3
4
|
export declare const verifiedLeaderboardIdentifierMaximumLength = 512;
|
|
5
|
+
export declare const verifiedLeaderboardMetricsMaximumCount = 16;
|
|
6
|
+
export declare const verifiedLeaderboardMetricKeyMaximumLength = 64;
|
|
4
7
|
export interface VerifiedLeaderboardDefinition {
|
|
5
8
|
readonly leaderboardId: string;
|
|
6
9
|
readonly scoreOrder: VerifiedLeaderboardScoreOrder;
|
|
@@ -16,6 +19,7 @@ export interface VerifiedLeaderboardAttempt {
|
|
|
16
19
|
readonly participantLabel?: string;
|
|
17
20
|
readonly attemptId: string;
|
|
18
21
|
readonly score: number;
|
|
22
|
+
readonly metrics?: VerifiedLeaderboardMetrics;
|
|
19
23
|
readonly completedAt: string;
|
|
20
24
|
readonly verification: LeaderboardVerificationEvidence;
|
|
21
25
|
}
|
|
@@ -29,6 +33,7 @@ export interface LeaderboardRankedEntry {
|
|
|
29
33
|
readonly participantLabel?: string;
|
|
30
34
|
readonly attemptId: string;
|
|
31
35
|
readonly score: number;
|
|
36
|
+
readonly metrics?: VerifiedLeaderboardMetrics;
|
|
32
37
|
readonly completedAt: string;
|
|
33
38
|
}
|
|
34
39
|
export interface RecordVerifiedLeaderboardAttemptResponse {
|
|
@@ -107,6 +112,10 @@ export declare function assertRecordVerifiedLeaderboardAttemptRequest(input: unk
|
|
|
107
112
|
export declare function assertRecordVerifiedLeaderboardAttemptResponse(input: unknown): asserts input is RecordVerifiedLeaderboardAttemptResponse;
|
|
108
113
|
export declare function assertGetVerifiedLeaderboardSnapshotRequest(input: unknown): asserts input is GetVerifiedLeaderboardSnapshotRequest;
|
|
109
114
|
export declare function assertLeaderboardRankedEntry(input: unknown): asserts input is LeaderboardRankedEntry;
|
|
115
|
+
export declare function assertVerifiedLeaderboardMetrics(input: unknown): asserts input is VerifiedLeaderboardMetrics | undefined;
|
|
116
|
+
export declare function normalizeVerifiedLeaderboardMetrics(input: undefined): undefined;
|
|
117
|
+
export declare function normalizeVerifiedLeaderboardMetrics(input: VerifiedLeaderboardMetrics): VerifiedLeaderboardMetrics;
|
|
118
|
+
export declare function areVerifiedLeaderboardMetricsEqual(left: VerifiedLeaderboardMetrics | undefined, right: VerifiedLeaderboardMetrics | undefined): boolean;
|
|
110
119
|
export declare function assertVerifiedLeaderboardSnapshot(input: unknown): asserts input is VerifiedLeaderboardSnapshot;
|
|
111
120
|
export declare function createVerifiedLeaderboardCursor(definition: VerifiedLeaderboardDefinition, entry: LeaderboardRankedEntry): string;
|
|
112
121
|
export declare function parseVerifiedLeaderboardCursor(cursor: string, definition: VerifiedLeaderboardDefinition): VerifiedLeaderboardCursorPosition;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
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}$/;
|
|
2
5
|
const timestampPattern = new RegExp('^(\\d{4})-(\\d{2})-(\\d{2})'
|
|
3
6
|
+ 'T(\\d{2}):(\\d{2}):(\\d{2})'
|
|
4
7
|
+ '(?:\\.(\\d{1,3}))?(Z|([+-])(\\d{2}):(\\d{2}))$');
|
|
@@ -143,6 +146,7 @@ export function assertVerifiedLeaderboardAttempt(input) {
|
|
|
143
146
|
assertOptionalNonEmptyString(input.participantLabel, 'participantLabel');
|
|
144
147
|
assertVerifiedLeaderboardIdentifier(input.attemptId, 'attemptId');
|
|
145
148
|
assertFiniteNumber(input.score, 'score');
|
|
149
|
+
assertVerifiedLeaderboardMetrics(input.metrics);
|
|
146
150
|
assertTimestamp(input.completedAt, 'completedAt');
|
|
147
151
|
assertLeaderboardVerificationEvidence(input.verification);
|
|
148
152
|
}
|
|
@@ -188,8 +192,46 @@ export function assertLeaderboardRankedEntry(input) {
|
|
|
188
192
|
assertOptionalNonEmptyString(input.participantLabel, 'participantLabel');
|
|
189
193
|
assertVerifiedLeaderboardIdentifier(input.attemptId, 'attemptId');
|
|
190
194
|
assertFiniteNumber(input.score, 'score');
|
|
195
|
+
assertVerifiedLeaderboardMetrics(input.metrics);
|
|
191
196
|
assertTimestamp(input.completedAt, 'completedAt');
|
|
192
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
|
+
}
|
|
193
235
|
export function assertVerifiedLeaderboardSnapshot(input) {
|
|
194
236
|
assertRecord(input, 'VerifiedLeaderboardSnapshot');
|
|
195
237
|
assertVerifiedLeaderboardDefinition(input.definition);
|
|
@@ -292,6 +334,7 @@ function toRankedEntry(attempt, rank) {
|
|
|
292
334
|
: { participantLabel: attempt.participantLabel }),
|
|
293
335
|
attemptId: attempt.attemptId,
|
|
294
336
|
score: attempt.score,
|
|
337
|
+
...verifiedLeaderboardMetricsProperty(attempt.metrics),
|
|
295
338
|
completedAt: attempt.completedAt,
|
|
296
339
|
};
|
|
297
340
|
assertLeaderboardRankedEntry(entry);
|
|
@@ -301,6 +344,7 @@ function assertSameAttempt(existing, candidate) {
|
|
|
301
344
|
if (existing.participantId !== candidate.participantId
|
|
302
345
|
|| existing.attemptId !== candidate.attemptId
|
|
303
346
|
|| existing.score !== candidate.score
|
|
347
|
+
|| !areVerifiedLeaderboardMetricsEqual(existing.metrics, candidate.metrics)
|
|
304
348
|
|| parseTimestamp(existing.completedAt) !== parseTimestamp(candidate.completedAt)
|
|
305
349
|
|| existing.verification.authorityId !== candidate.verification.authorityId
|
|
306
350
|
|| existing.verification.evidenceId !== candidate.verification.evidenceId
|
|
@@ -324,6 +368,7 @@ function cloneVerifiedLeaderboardAttempt(input) {
|
|
|
324
368
|
: { participantLabel: input.participantLabel }),
|
|
325
369
|
attemptId: input.attemptId,
|
|
326
370
|
score: input.score,
|
|
371
|
+
...verifiedLeaderboardMetricsProperty(input.metrics),
|
|
327
372
|
completedAt: input.completedAt,
|
|
328
373
|
verification: {
|
|
329
374
|
authorityId: input.verification.authorityId,
|
|
@@ -345,6 +390,7 @@ function cloneRecordResponse(input, alreadyProcessed) {
|
|
|
345
390
|
: { participantLabel: input.entry.participantLabel }),
|
|
346
391
|
attemptId: input.entry.attemptId,
|
|
347
392
|
score: input.entry.score,
|
|
393
|
+
...verifiedLeaderboardMetricsProperty(input.entry.metrics),
|
|
348
394
|
completedAt: input.entry.completedAt,
|
|
349
395
|
},
|
|
350
396
|
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
@@ -454,6 +500,9 @@ function compareOrdinal(left, right) {
|
|
|
454
500
|
}
|
|
455
501
|
return left > right ? 1 : 0;
|
|
456
502
|
}
|
|
503
|
+
function verifiedLeaderboardMetricsProperty(metrics) {
|
|
504
|
+
return metrics === undefined ? {} : { metrics: normalizeVerifiedLeaderboardMetrics(metrics) };
|
|
505
|
+
}
|
|
457
506
|
function compareRankedEntryToCursor(definition, entry, cursor) {
|
|
458
507
|
if (entry.score !== cursor.score) {
|
|
459
508
|
const comparison = entry.score < cursor.score ? -1 : 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "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": {
|
|
@@ -77,9 +77,9 @@
|
|
|
77
77
|
"@orpc/client": "2.0.0-beta.14",
|
|
78
78
|
"@orpc/contract": "2.0.0-beta.14",
|
|
79
79
|
"@orpc/server": "2.0.0-beta.14",
|
|
80
|
+
"@mpgd/analytics": "0.3.5",
|
|
80
81
|
"@mpgd/catalog": "0.3.5",
|
|
81
|
-
"@mpgd/platform": "0.5.1"
|
|
82
|
-
"@mpgd/analytics": "0.3.5"
|
|
82
|
+
"@mpgd/platform": "0.5.1"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"ttsc": "0.18.4",
|