@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
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type VerifiedLeaderboardService } from './verified-leaderboard';
|
|
2
|
+
export declare const verifiedLeaderboardConformanceScenarios: readonly ['first-selection-and-snapshot', 'best-selection-and-retry', 'deterministic-ties', 'identity-and-definition-conflicts', 'concurrent-idempotency', 'mutation-isolation', 'runtime-validation'];
|
|
3
|
+
export type VerifiedLeaderboardConformanceScenario = (typeof verifiedLeaderboardConformanceScenarios)[number];
|
|
4
|
+
export interface CreateVerifiedLeaderboardConformanceFixtureInput {
|
|
5
|
+
readonly scenario: VerifiedLeaderboardConformanceScenario;
|
|
6
|
+
readonly now: string;
|
|
7
|
+
}
|
|
8
|
+
export interface VerifiedLeaderboardConformanceFixture {
|
|
9
|
+
readonly service: VerifiedLeaderboardService;
|
|
10
|
+
readonly dispose?: () => Promise<void> | void;
|
|
11
|
+
}
|
|
12
|
+
export type CreateVerifiedLeaderboardConformanceFixture = (input: CreateVerifiedLeaderboardConformanceFixtureInput) => Promise<VerifiedLeaderboardConformanceFixture> | VerifiedLeaderboardConformanceFixture;
|
|
13
|
+
export interface RunVerifiedLeaderboardConformanceInput {
|
|
14
|
+
/**
|
|
15
|
+
* Return an isolated provider fixture for each scenario. Durable adapters may
|
|
16
|
+
* instead namespace every fixture in a shared test database.
|
|
17
|
+
*/
|
|
18
|
+
readonly createFixture: CreateVerifiedLeaderboardConformanceFixture;
|
|
19
|
+
readonly now?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface VerifiedLeaderboardConformanceReport {
|
|
22
|
+
readonly passedScenarios: readonly VerifiedLeaderboardConformanceScenario[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Runs the provider-neutral verified leaderboard contract against an adapter.
|
|
26
|
+
* The helper is test-framework independent and throws with the failed scenario
|
|
27
|
+
* name when a provider diverges from the public service semantics.
|
|
28
|
+
*/
|
|
29
|
+
export declare function runVerifiedLeaderboardConformance(input: RunVerifiedLeaderboardConformanceInput): Promise<VerifiedLeaderboardConformanceReport>;
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { assertRecordVerifiedLeaderboardAttemptResponse, assertVerifiedLeaderboardSnapshot, } from './verified-leaderboard';
|
|
2
|
+
export const verifiedLeaderboardConformanceScenarios = [
|
|
3
|
+
'first-selection-and-snapshot',
|
|
4
|
+
'best-selection-and-retry',
|
|
5
|
+
'deterministic-ties',
|
|
6
|
+
'identity-and-definition-conflicts',
|
|
7
|
+
'concurrent-idempotency',
|
|
8
|
+
'mutation-isolation',
|
|
9
|
+
'runtime-validation',
|
|
10
|
+
];
|
|
11
|
+
const scenarios = [
|
|
12
|
+
['first-selection-and-snapshot', runFirstSelectionAndSnapshotScenario],
|
|
13
|
+
['best-selection-and-retry', runBestSelectionAndRetryScenario],
|
|
14
|
+
['deterministic-ties', runDeterministicTiesScenario],
|
|
15
|
+
['identity-and-definition-conflicts', runIdentityAndDefinitionConflictsScenario],
|
|
16
|
+
['concurrent-idempotency', runConcurrentIdempotencyScenario],
|
|
17
|
+
['mutation-isolation', runMutationIsolationScenario],
|
|
18
|
+
['runtime-validation', runRuntimeValidationScenario],
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Runs the provider-neutral verified leaderboard contract against an adapter.
|
|
22
|
+
* The helper is test-framework independent and throws with the failed scenario
|
|
23
|
+
* name when a provider diverges from the public service semantics.
|
|
24
|
+
*/
|
|
25
|
+
export async function runVerifiedLeaderboardConformance(input) {
|
|
26
|
+
const now = input.now ?? '2030-01-02T03:04:05.000Z';
|
|
27
|
+
const passedScenarios = [];
|
|
28
|
+
for (const [scenario, runScenario] of scenarios) {
|
|
29
|
+
let fixture;
|
|
30
|
+
let scenarioError;
|
|
31
|
+
let scenarioFailed = false;
|
|
32
|
+
try {
|
|
33
|
+
fixture = await input.createFixture({ scenario, now });
|
|
34
|
+
await runScenario({ service: createOutputValidatingService(fixture.service), now });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
scenarioError = error;
|
|
38
|
+
scenarioFailed = true;
|
|
39
|
+
}
|
|
40
|
+
let cleanupError;
|
|
41
|
+
let cleanupFailed = false;
|
|
42
|
+
try {
|
|
43
|
+
await fixture?.dispose?.();
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
cleanupError = error;
|
|
47
|
+
cleanupFailed = true;
|
|
48
|
+
}
|
|
49
|
+
if (scenarioFailed) {
|
|
50
|
+
throw new Error(`Verified leaderboard conformance failed: ${scenario}.`, {
|
|
51
|
+
cause: cleanupFailed
|
|
52
|
+
? new AggregateError([scenarioError, cleanupError], `Scenario and fixture cleanup both failed: ${scenario}.`)
|
|
53
|
+
: scenarioError,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (cleanupFailed) {
|
|
57
|
+
throw new Error(`Verified leaderboard conformance cleanup failed: ${scenario}.`, {
|
|
58
|
+
cause: cleanupError,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
passedScenarios.push(scenario);
|
|
62
|
+
}
|
|
63
|
+
return { passedScenarios };
|
|
64
|
+
}
|
|
65
|
+
async function runFirstSelectionAndSnapshotScenario(context) {
|
|
66
|
+
const missing = await context.service.getSnapshot({ leaderboardId: 'first:missing' });
|
|
67
|
+
assertEqual(missing, undefined, 'unknown boards must not synthesize snapshots');
|
|
68
|
+
const laterRequest = createAttempt({
|
|
69
|
+
leaderboardId: 'first:board',
|
|
70
|
+
participantId: 'participant:first',
|
|
71
|
+
participantLabel: 'First Player',
|
|
72
|
+
attemptId: 'attempt:later',
|
|
73
|
+
score: 20,
|
|
74
|
+
completedAt: '2030-01-02T02:02:00.000Z',
|
|
75
|
+
});
|
|
76
|
+
const laterRecord = await context.service.recordVerifiedAttempt(laterRequest);
|
|
77
|
+
const earlierRecord = await context.service.recordVerifiedAttempt(createAttempt({
|
|
78
|
+
leaderboardId: 'first:board',
|
|
79
|
+
participantId: 'participant:first',
|
|
80
|
+
participantLabel: 'First Player',
|
|
81
|
+
attemptId: 'attempt:earlier',
|
|
82
|
+
score: 30,
|
|
83
|
+
completedAt: '2030-01-02T02:01:00.000Z',
|
|
84
|
+
}));
|
|
85
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
86
|
+
leaderboardId: 'first:board',
|
|
87
|
+
participantId: 'participant:leader',
|
|
88
|
+
attemptId: 'attempt:leader',
|
|
89
|
+
score: 10,
|
|
90
|
+
completedAt: '2030-01-02T02:03:00.000Z',
|
|
91
|
+
}));
|
|
92
|
+
assertEqual(laterRecord.retained, true, 'the first observed attempt must be retained');
|
|
93
|
+
assertEqual(earlierRecord.retained, true, 'an earlier verified completion must replace a later completion');
|
|
94
|
+
const latestRecord = await context.service.recordVerifiedAttempt(createAttempt({
|
|
95
|
+
leaderboardId: 'first:board',
|
|
96
|
+
participantId: 'participant:first',
|
|
97
|
+
participantLabel: 'First Player',
|
|
98
|
+
attemptId: 'attempt:latest',
|
|
99
|
+
score: 5,
|
|
100
|
+
completedAt: '2030-01-02T02:04:00.000Z',
|
|
101
|
+
}));
|
|
102
|
+
assertEqual(latestRecord.retained, false, 'later completions must not replace the earliest retained attempt');
|
|
103
|
+
assertEqual(latestRecord.entry.attemptId, 'attempt:earlier', 'rejected later attempts must report the retained earliest attempt');
|
|
104
|
+
const snapshot = await context.service.getSnapshot({
|
|
105
|
+
leaderboardId: 'first:board',
|
|
106
|
+
participantId: 'participant:first',
|
|
107
|
+
limit: 1,
|
|
108
|
+
});
|
|
109
|
+
assert(snapshot !== undefined, 'recorded boards must return snapshots');
|
|
110
|
+
assertEqual(snapshot.entries.length, 1, 'snapshot limits must be honored');
|
|
111
|
+
assertEqual(snapshot.entries[0]?.rank, 1, 'page entries must carry their global rank');
|
|
112
|
+
assertEqual(snapshot.entries[0]?.participantId, 'participant:leader', 'ascending snapshots must rank lower scores first');
|
|
113
|
+
assertEqual(snapshot.participantEntry?.attemptId, 'attempt:earlier', 'participant entries must reflect retained attempts outside the requested page');
|
|
114
|
+
assertEqual(snapshot.participantEntry?.rank, 2, 'participant entries must carry global rank');
|
|
115
|
+
assertEqual(snapshot.totalParticipants, 2, 'snapshots must count retained participants');
|
|
116
|
+
assertEqual(snapshot.generatedAt, context.now, 'snapshots must use the provider clock');
|
|
117
|
+
const firstBoardCursor = snapshot.nextCursor;
|
|
118
|
+
assert(firstBoardCursor !== undefined, 'limited snapshots must return a continuation cursor');
|
|
119
|
+
const nextSnapshot = await context.service.getSnapshot({
|
|
120
|
+
leaderboardId: 'first:board',
|
|
121
|
+
participantId: 'participant:first',
|
|
122
|
+
limit: 1,
|
|
123
|
+
cursor: firstBoardCursor,
|
|
124
|
+
});
|
|
125
|
+
assertEqual(nextSnapshot?.entries.length, 1, 'cursor reads must return the next page');
|
|
126
|
+
assertEqual(nextSnapshot?.entries[0]?.rank, 2, 'cursor pages must retain global ranks');
|
|
127
|
+
assertEqual(nextSnapshot?.entries[0]?.attemptId, 'attempt:earlier', 'ascending cursors must continue after the previous ordering tuple');
|
|
128
|
+
assertEqual(nextSnapshot?.participantEntry?.attemptId, 'attempt:earlier', 'participant entries must remain independent of the current page');
|
|
129
|
+
assertEqual(nextSnapshot?.nextCursor, undefined, 'final pages must omit nextCursor');
|
|
130
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
131
|
+
leaderboardId: 'first:other-board',
|
|
132
|
+
participantId: 'participant:other-board',
|
|
133
|
+
attemptId: 'attempt:other-board',
|
|
134
|
+
score: 1,
|
|
135
|
+
completedAt: '2030-01-02T02:05:00.000Z',
|
|
136
|
+
}));
|
|
137
|
+
await assertRejects(() => context.service.getSnapshot({
|
|
138
|
+
leaderboardId: 'first:other-board',
|
|
139
|
+
cursor: firstBoardCursor,
|
|
140
|
+
}), 'cursors must remain bound to their originating leaderboard definition');
|
|
141
|
+
const anonymousSnapshot = await context.service.getSnapshot({
|
|
142
|
+
leaderboardId: 'first:board',
|
|
143
|
+
limit: 1,
|
|
144
|
+
});
|
|
145
|
+
assertEqual(anonymousSnapshot?.participantEntry, undefined, 'anonymous snapshot reads must not synthesize participant entries');
|
|
146
|
+
const missingParticipantSnapshot = await context.service.getSnapshot({
|
|
147
|
+
leaderboardId: 'first:board',
|
|
148
|
+
participantId: 'participant:missing',
|
|
149
|
+
limit: 1,
|
|
150
|
+
});
|
|
151
|
+
assertEqual(missingParticipantSnapshot?.participantEntry, undefined, 'unknown participants must not receive another participant entry');
|
|
152
|
+
const laterRetry = await context.service.recordVerifiedAttempt(laterRequest);
|
|
153
|
+
assertEqual(laterRetry.alreadyProcessed, true, 'retries must be detected');
|
|
154
|
+
assertEqual(laterRetry.retained, true, 'retries must preserve the decision returned by the original write');
|
|
155
|
+
assertEqual(laterRetry.entry.attemptId, 'attempt:later', 'superseded retries must preserve their original response entry');
|
|
156
|
+
}
|
|
157
|
+
async function runBestSelectionAndRetryScenario(context) {
|
|
158
|
+
const initialRequest = createAttempt({
|
|
159
|
+
leaderboardId: 'best:board',
|
|
160
|
+
scoreOrder: 'descending',
|
|
161
|
+
attemptSelection: 'best',
|
|
162
|
+
participantId: 'participant:best',
|
|
163
|
+
attemptId: 'attempt:initial',
|
|
164
|
+
score: 100,
|
|
165
|
+
completedAt: '2030-01-02T02:10:00.000Z',
|
|
166
|
+
});
|
|
167
|
+
await context.service.recordVerifiedAttempt(initialRequest);
|
|
168
|
+
const worseRequest = createAttempt({
|
|
169
|
+
leaderboardId: 'best:board',
|
|
170
|
+
scoreOrder: 'descending',
|
|
171
|
+
attemptSelection: 'best',
|
|
172
|
+
participantId: 'participant:best',
|
|
173
|
+
attemptId: 'attempt:worse',
|
|
174
|
+
score: 90,
|
|
175
|
+
completedAt: '2030-01-02T02:11:00.000Z',
|
|
176
|
+
});
|
|
177
|
+
const worseRecord = await context.service.recordVerifiedAttempt(worseRequest);
|
|
178
|
+
const improvedRecord = await context.service.recordVerifiedAttempt(createAttempt({
|
|
179
|
+
leaderboardId: 'best:board',
|
|
180
|
+
scoreOrder: 'descending',
|
|
181
|
+
attemptSelection: 'best',
|
|
182
|
+
participantId: 'participant:best',
|
|
183
|
+
attemptId: 'attempt:improved',
|
|
184
|
+
score: 110,
|
|
185
|
+
completedAt: '2030-01-02T02:12:00.000Z',
|
|
186
|
+
}));
|
|
187
|
+
assertEqual(worseRecord.retained, false, 'best selection must reject worse attempts');
|
|
188
|
+
assertEqual(worseRecord.reason, 'ATTEMPT_NOT_RETAINED', 'non-retained attempts must explain their decision');
|
|
189
|
+
assertEqual(improvedRecord.retained, true, 'best selection must retain improved attempts');
|
|
190
|
+
assertEqual(improvedRecord.entry.attemptId, 'attempt:improved', 'improved attempts must replace the retained entry');
|
|
191
|
+
const worseRetry = await context.service.recordVerifiedAttempt(worseRequest);
|
|
192
|
+
assertEqual(worseRetry.alreadyProcessed, true, 'non-retained retries must be detected');
|
|
193
|
+
assertEqual(worseRetry.retained, false, 'non-retained attempt retries must preserve their original decision');
|
|
194
|
+
const initialRetry = await context.service.recordVerifiedAttempt(initialRequest);
|
|
195
|
+
assertEqual(initialRetry.alreadyProcessed, true, 'superseded best retries must be detected');
|
|
196
|
+
assertEqual(initialRetry.retained, true, 'superseded best retries must preserve their original decision');
|
|
197
|
+
assertEqual(initialRetry.entry.attemptId, 'attempt:initial', 'superseded best retries must preserve their original response entry');
|
|
198
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
199
|
+
leaderboardId: 'best:board',
|
|
200
|
+
scoreOrder: 'descending',
|
|
201
|
+
attemptSelection: 'best',
|
|
202
|
+
participantId: 'participant:runner-up',
|
|
203
|
+
attemptId: 'attempt:runner-up',
|
|
204
|
+
score: 105,
|
|
205
|
+
completedAt: '2030-01-02T02:13:00.000Z',
|
|
206
|
+
}));
|
|
207
|
+
const descendingSnapshot = await context.service.getSnapshot({
|
|
208
|
+
leaderboardId: 'best:board',
|
|
209
|
+
limit: 2,
|
|
210
|
+
});
|
|
211
|
+
assertEqual(descendingSnapshot?.entries[0]?.attemptId, 'attempt:improved', 'descending snapshots must rank higher scores first');
|
|
212
|
+
assertEqual(descendingSnapshot?.entries[1]?.attemptId, 'attempt:runner-up', 'descending snapshots must preserve lower-scoring followers');
|
|
213
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
214
|
+
leaderboardId: 'best:ascending',
|
|
215
|
+
scoreOrder: 'ascending',
|
|
216
|
+
attemptSelection: 'best',
|
|
217
|
+
participantId: 'participant:ascending',
|
|
218
|
+
attemptId: 'attempt:ascending-initial',
|
|
219
|
+
score: 100,
|
|
220
|
+
completedAt: '2030-01-02T02:14:00.000Z',
|
|
221
|
+
}));
|
|
222
|
+
const slowerAscendingRecord = await context.service.recordVerifiedAttempt(createAttempt({
|
|
223
|
+
leaderboardId: 'best:ascending',
|
|
224
|
+
scoreOrder: 'ascending',
|
|
225
|
+
attemptSelection: 'best',
|
|
226
|
+
participantId: 'participant:ascending',
|
|
227
|
+
attemptId: 'attempt:ascending-slower',
|
|
228
|
+
score: 110,
|
|
229
|
+
completedAt: '2030-01-02T02:15:00.000Z',
|
|
230
|
+
}));
|
|
231
|
+
const fasterAscendingRecord = await context.service.recordVerifiedAttempt(createAttempt({
|
|
232
|
+
leaderboardId: 'best:ascending',
|
|
233
|
+
scoreOrder: 'ascending',
|
|
234
|
+
attemptSelection: 'best',
|
|
235
|
+
participantId: 'participant:ascending',
|
|
236
|
+
attemptId: 'attempt:ascending-faster',
|
|
237
|
+
score: 90,
|
|
238
|
+
completedAt: '2030-01-02T02:16:00.000Z',
|
|
239
|
+
}));
|
|
240
|
+
assertEqual(slowerAscendingRecord.retained, false, 'ascending best selection must reject higher scores');
|
|
241
|
+
assertEqual(fasterAscendingRecord.retained, true, 'ascending best selection must retain lower scores');
|
|
242
|
+
}
|
|
243
|
+
async function runDeterministicTiesScenario(context) {
|
|
244
|
+
const bmpPrivateUseAttemptId = String.fromCodePoint(0xE000);
|
|
245
|
+
const supplementaryAttemptId = String.fromCodePoint(0x10000);
|
|
246
|
+
const attemptIds = ['ä', 'z', 'a', bmpPrivateUseAttemptId, supplementaryAttemptId];
|
|
247
|
+
for (const attemptId of attemptIds) {
|
|
248
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
249
|
+
leaderboardId: 'ties:ranking',
|
|
250
|
+
participantId: `participant:${attemptId}`,
|
|
251
|
+
attemptId,
|
|
252
|
+
score: 10,
|
|
253
|
+
completedAt: '2030-01-02T02:20:00.000Z',
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
const snapshot = await context.service.getSnapshot({
|
|
257
|
+
leaderboardId: 'ties:ranking',
|
|
258
|
+
limit: attemptIds.length,
|
|
259
|
+
});
|
|
260
|
+
assert(snapshot !== undefined, 'tie board must return a snapshot');
|
|
261
|
+
assertEqual(snapshot.entries[0]?.attemptId, 'a', 'ties must use ordinal attempt IDs');
|
|
262
|
+
assertEqual(snapshot.entries[1]?.attemptId, 'z', 'ties must not use locale collation');
|
|
263
|
+
assertEqual(snapshot.entries[2]?.attemptId, 'ä', 'non-ASCII IDs must sort ordinally');
|
|
264
|
+
assertEqual(snapshot.entries[3]?.attemptId, supplementaryAttemptId, 'supplementary IDs must follow JavaScript UTF-16 ordinal ordering');
|
|
265
|
+
assertEqual(snapshot.entries[4]?.attemptId, bmpPrivateUseAttemptId, 'BMP IDs must not be reordered by UTF-8 byte collation');
|
|
266
|
+
const firstTiePage = await context.service.getSnapshot({
|
|
267
|
+
leaderboardId: 'ties:ranking',
|
|
268
|
+
limit: 2,
|
|
269
|
+
});
|
|
270
|
+
assert(firstTiePage?.nextCursor !== undefined, 'the first tie page must be continuable');
|
|
271
|
+
const secondTiePage = await context.service.getSnapshot({
|
|
272
|
+
leaderboardId: 'ties:ranking',
|
|
273
|
+
limit: 2,
|
|
274
|
+
cursor: firstTiePage.nextCursor,
|
|
275
|
+
});
|
|
276
|
+
assert(secondTiePage?.nextCursor !== undefined, 'the second tie page must be continuable');
|
|
277
|
+
const thirdTiePage = await context.service.getSnapshot({
|
|
278
|
+
leaderboardId: 'ties:ranking',
|
|
279
|
+
limit: 2,
|
|
280
|
+
cursor: secondTiePage.nextCursor,
|
|
281
|
+
});
|
|
282
|
+
assertEqual([
|
|
283
|
+
...firstTiePage.entries,
|
|
284
|
+
...secondTiePage.entries,
|
|
285
|
+
...(thirdTiePage?.entries ?? []),
|
|
286
|
+
].map((entry) => entry.attemptId).join('|'), snapshot.entries.map((entry) => entry.attemptId).join('|'), 'cursor traversal must preserve UTF-16 ordinal ties without gaps or duplicates');
|
|
287
|
+
assertEqual(thirdTiePage?.nextCursor, undefined, 'the last tie page must terminate');
|
|
288
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
289
|
+
leaderboardId: 'ties:first',
|
|
290
|
+
participantId: 'participant:tie',
|
|
291
|
+
attemptId: 'ä',
|
|
292
|
+
score: 1,
|
|
293
|
+
completedAt: '2030-01-02T02:21:00.000Z',
|
|
294
|
+
}));
|
|
295
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
296
|
+
leaderboardId: 'ties:first',
|
|
297
|
+
participantId: 'participant:tie',
|
|
298
|
+
attemptId: 'z',
|
|
299
|
+
score: 2,
|
|
300
|
+
completedAt: '2030-01-02T02:21:00.000Z',
|
|
301
|
+
}));
|
|
302
|
+
const firstSnapshot = await context.service.getSnapshot({ leaderboardId: 'ties:first' });
|
|
303
|
+
assertEqual(firstSnapshot?.entries[0]?.attemptId, 'z', 'first selection must use ordinal IDs for equal completion instants');
|
|
304
|
+
}
|
|
305
|
+
async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
306
|
+
const request = createAttempt({
|
|
307
|
+
leaderboardId: 'identity:board',
|
|
308
|
+
participantId: 'participant:identity',
|
|
309
|
+
participantLabel: 'Original Label',
|
|
310
|
+
attemptId: 'attempt:identity',
|
|
311
|
+
score: 42,
|
|
312
|
+
completedAt: '2030-01-02T02:30:00.000Z',
|
|
313
|
+
});
|
|
314
|
+
await context.service.recordVerifiedAttempt(request);
|
|
315
|
+
const equivalentRetry = await context.service.recordVerifiedAttempt({
|
|
316
|
+
...request,
|
|
317
|
+
attempt: {
|
|
318
|
+
...request.attempt,
|
|
319
|
+
participantLabel: 'Changed Label',
|
|
320
|
+
completedAt: '2030-01-02T11:30:00.000+09:00',
|
|
321
|
+
verification: {
|
|
322
|
+
...request.attempt.verification,
|
|
323
|
+
verifiedAt: '2030-01-02T11:30:00.000+09:00',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
assertEqual(equivalentRetry.alreadyProcessed, true, 'equivalent retries must be idempotent');
|
|
328
|
+
assertEqual(equivalentRetry.entry.participantLabel, 'Original Label', 'retry labels must not rewrite stored presentation metadata');
|
|
329
|
+
assertEqual(equivalentRetry.entry.completedAt, request.attempt.completedAt, 'equivalent retries must preserve the original timestamp representation');
|
|
330
|
+
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
331
|
+
...request,
|
|
332
|
+
attempt: { ...request.attempt, score: 43 },
|
|
333
|
+
}), 'attempt IDs reused with a different score must fail closed');
|
|
334
|
+
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
335
|
+
...request,
|
|
336
|
+
attempt: {
|
|
337
|
+
...request.attempt,
|
|
338
|
+
completedAt: '2030-01-02T02:31:00.000Z',
|
|
339
|
+
},
|
|
340
|
+
}), 'attempt IDs reused with a different completion instant must fail closed');
|
|
341
|
+
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
342
|
+
...request,
|
|
343
|
+
attempt: {
|
|
344
|
+
...request.attempt,
|
|
345
|
+
verification: {
|
|
346
|
+
...request.attempt.verification,
|
|
347
|
+
evidenceId: 'evidence:different',
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
}), 'attempt IDs reused with different evidence must fail closed');
|
|
351
|
+
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
352
|
+
...request,
|
|
353
|
+
attempt: { ...request.attempt, participantId: 'participant:different' },
|
|
354
|
+
}), 'attempt IDs reused by another participant must fail closed');
|
|
355
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
356
|
+
leaderboardId: request.definition.leaderboardId,
|
|
357
|
+
scoreOrder: 'descending',
|
|
358
|
+
participantId: request.attempt.participantId,
|
|
359
|
+
attemptId: 'attempt:score-order-conflict',
|
|
360
|
+
score: request.attempt.score,
|
|
361
|
+
completedAt: request.attempt.completedAt,
|
|
362
|
+
})), 'leaderboard score order must be immutable');
|
|
363
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
364
|
+
leaderboardId: request.definition.leaderboardId,
|
|
365
|
+
attemptSelection: 'best',
|
|
366
|
+
participantId: request.attempt.participantId,
|
|
367
|
+
attemptId: 'attempt:selection-conflict',
|
|
368
|
+
score: request.attempt.score,
|
|
369
|
+
completedAt: request.attempt.completedAt,
|
|
370
|
+
})), 'leaderboard attempt selection must be immutable');
|
|
371
|
+
const stableRetry = await context.service.recordVerifiedAttempt(request);
|
|
372
|
+
assertEqual(stableRetry.alreadyProcessed, true, 'conflict failures must preserve the original attempt identity');
|
|
373
|
+
assertEqual(stableRetry.entry.score, request.attempt.score, 'conflict failures must not rewrite the original retained score');
|
|
374
|
+
const stableSnapshot = await context.service.getSnapshot({
|
|
375
|
+
leaderboardId: request.definition.leaderboardId,
|
|
376
|
+
});
|
|
377
|
+
assertEqual(stableSnapshot?.definition.attemptSelection, request.definition.attemptSelection, 'conflict failures must not rewrite the original selection policy');
|
|
378
|
+
assertEqual(stableSnapshot?.definition.scoreOrder, request.definition.scoreOrder, 'conflict failures must not rewrite the original score order');
|
|
379
|
+
assertEqual(stableSnapshot?.entries[0]?.attemptId, request.attempt.attemptId, 'conflict failures must leave the original attempt retained');
|
|
380
|
+
assertEqual(stableSnapshot?.entries[0]?.participantId, request.attempt.participantId, 'conflict failures must not retain a conflicting participant');
|
|
381
|
+
assertEqual(stableSnapshot?.totalParticipants, 1, 'conflict failures must not add retained participants');
|
|
382
|
+
const otherBoardRecord = await context.service.recordVerifiedAttempt(createAttempt({
|
|
383
|
+
leaderboardId: 'identity:other-board',
|
|
384
|
+
participantId: request.attempt.participantId,
|
|
385
|
+
...(request.attempt.participantLabel === undefined
|
|
386
|
+
? {}
|
|
387
|
+
: { participantLabel: request.attempt.participantLabel }),
|
|
388
|
+
attemptId: request.attempt.attemptId,
|
|
389
|
+
score: request.attempt.score,
|
|
390
|
+
completedAt: request.attempt.completedAt,
|
|
391
|
+
}));
|
|
392
|
+
assertEqual(otherBoardRecord.alreadyProcessed, false, 'attempt identity must be scoped to one leaderboard');
|
|
393
|
+
}
|
|
394
|
+
async function runConcurrentIdempotencyScenario(context) {
|
|
395
|
+
const request = createAttempt({
|
|
396
|
+
leaderboardId: 'concurrent:board',
|
|
397
|
+
participantId: 'participant:concurrent',
|
|
398
|
+
attemptId: 'attempt:concurrent',
|
|
399
|
+
score: 7,
|
|
400
|
+
completedAt: '2030-01-02T02:40:00.000Z',
|
|
401
|
+
});
|
|
402
|
+
const responses = await Promise.all([
|
|
403
|
+
context.service.recordVerifiedAttempt(request),
|
|
404
|
+
context.service.recordVerifiedAttempt(request),
|
|
405
|
+
]);
|
|
406
|
+
const newWrites = responses.filter((response) => !response.alreadyProcessed);
|
|
407
|
+
const retries = responses.filter((response) => response.alreadyProcessed);
|
|
408
|
+
assertEqual(newWrites.length, 1, 'concurrent duplicates must produce one new write');
|
|
409
|
+
assertEqual(retries.length, 1, 'concurrent duplicates must produce one idempotent retry');
|
|
410
|
+
assertEqual(responses[0]?.retained, responses[1]?.retained, 'duplicate decisions must agree');
|
|
411
|
+
assertEqual(responses[0]?.entry.attemptId, responses[1]?.entry.attemptId, 'duplicate responses must reference the same attempt');
|
|
412
|
+
}
|
|
413
|
+
async function runMutationIsolationScenario(context) {
|
|
414
|
+
const request = createMutableAttempt({
|
|
415
|
+
leaderboardId: 'mutation:board',
|
|
416
|
+
participantId: 'participant:mutation',
|
|
417
|
+
participantLabel: 'Stable Label',
|
|
418
|
+
attemptId: 'attempt:mutation',
|
|
419
|
+
score: 50,
|
|
420
|
+
completedAt: '2030-01-02T02:50:00.000Z',
|
|
421
|
+
});
|
|
422
|
+
const response = await context.service.recordVerifiedAttempt(request);
|
|
423
|
+
request.definition.scoreOrder = 'descending';
|
|
424
|
+
request.attempt.score = -1;
|
|
425
|
+
request.attempt.verification.evidenceId = 'evidence:mutated';
|
|
426
|
+
try {
|
|
427
|
+
Object.assign(response.entry, { participantLabel: 'Mutated Label', score: -2 });
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Frozen provider responses are already isolated from caller mutation.
|
|
431
|
+
}
|
|
432
|
+
const snapshot = await context.service.getSnapshot({
|
|
433
|
+
leaderboardId: 'mutation:board',
|
|
434
|
+
participantId: 'participant:mutation',
|
|
435
|
+
});
|
|
436
|
+
assert(snapshot !== undefined, 'mutation board must return a snapshot');
|
|
437
|
+
const snapshotEntry = snapshot.entries[0];
|
|
438
|
+
assert(snapshotEntry !== undefined, 'mutation board must retain its recorded entry');
|
|
439
|
+
const participantEntry = snapshot.participantEntry;
|
|
440
|
+
assert(participantEntry !== undefined, 'participant reads must return the retained entry');
|
|
441
|
+
assertEqual(snapshot.definition.scoreOrder, 'ascending', 'stored definitions must be cloned');
|
|
442
|
+
assertEqual(snapshotEntry.score, 50, 'stored attempts must resist caller mutation');
|
|
443
|
+
assertEqual(snapshotEntry.participantLabel, 'Stable Label', 'response mutation must not rewrite retained entries');
|
|
444
|
+
try {
|
|
445
|
+
Object.assign(snapshot.definition, { scoreOrder: 'descending' });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
// Frozen provider snapshot definitions are already isolated from mutation.
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
Object.assign(snapshotEntry, { participantLabel: 'Snapshot Mutation', score: -3 });
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Frozen provider snapshot entries are already isolated from mutation.
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
Object.assign(participantEntry, { participantLabel: 'Participant Mutation', score: -4 });
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// Frozen provider participant entries are already isolated from mutation.
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
snapshot.entries.pop();
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// Frozen provider snapshot arrays are already isolated from mutation.
|
|
467
|
+
}
|
|
468
|
+
const rereadSnapshot = await context.service.getSnapshot({
|
|
469
|
+
leaderboardId: 'mutation:board',
|
|
470
|
+
participantId: 'participant:mutation',
|
|
471
|
+
});
|
|
472
|
+
assertEqual(rereadSnapshot?.definition.scoreOrder, 'ascending', 'snapshot definition mutation must not rewrite stored policy');
|
|
473
|
+
assertEqual(rereadSnapshot?.entries[0]?.score, 50, 'snapshot entry mutation must not rewrite retained attempts');
|
|
474
|
+
assertEqual(rereadSnapshot?.entries[0]?.participantLabel, 'Stable Label', 'snapshot label mutation must not rewrite presentation metadata');
|
|
475
|
+
assertEqual(rereadSnapshot?.entries.length, 1, 'snapshot array mutation must not rewrite retained entries');
|
|
476
|
+
assertEqual(rereadSnapshot?.participantEntry?.score, 50, 'participant entry mutation must not rewrite retained attempts');
|
|
477
|
+
assertEqual(rereadSnapshot?.participantEntry?.participantLabel, 'Stable Label', 'participant entry mutation must not rewrite presentation metadata');
|
|
478
|
+
}
|
|
479
|
+
class ProviderOutputValidationError extends Error {
|
|
480
|
+
}
|
|
481
|
+
function createOutputValidatingService(service) {
|
|
482
|
+
return {
|
|
483
|
+
async recordVerifiedAttempt(input) {
|
|
484
|
+
const response = await service.recordVerifiedAttempt(input);
|
|
485
|
+
try {
|
|
486
|
+
assertRecordVerifiedLeaderboardAttemptResponse(response);
|
|
487
|
+
}
|
|
488
|
+
catch (cause) {
|
|
489
|
+
throw new ProviderOutputValidationError('Provider returned an invalid verified leaderboard record response.', { cause });
|
|
490
|
+
}
|
|
491
|
+
return response;
|
|
492
|
+
},
|
|
493
|
+
async getSnapshot(input) {
|
|
494
|
+
const snapshot = await service.getSnapshot(input);
|
|
495
|
+
if (snapshot !== undefined) {
|
|
496
|
+
try {
|
|
497
|
+
assertVerifiedLeaderboardSnapshot(snapshot);
|
|
498
|
+
}
|
|
499
|
+
catch (cause) {
|
|
500
|
+
throw new ProviderOutputValidationError('Provider returned an invalid verified leaderboard snapshot.', { cause });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return snapshot;
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
async function runRuntimeValidationScenario(context) {
|
|
508
|
+
const validVerificationTimestamp = '2030-01-02T02:00:00.000Z';
|
|
509
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
510
|
+
leaderboardId: 'validation:calendar',
|
|
511
|
+
participantId: 'participant:calendar',
|
|
512
|
+
attemptId: 'attempt:calendar',
|
|
513
|
+
score: 1,
|
|
514
|
+
completedAt: '2030-02-31T02:00:00.000Z',
|
|
515
|
+
verifiedAt: validVerificationTimestamp,
|
|
516
|
+
})), 'invalid calendar timestamps must fail closed');
|
|
517
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
518
|
+
leaderboardId: 'validation:timezone',
|
|
519
|
+
participantId: 'participant:timezone',
|
|
520
|
+
attemptId: 'attempt:timezone',
|
|
521
|
+
score: 1,
|
|
522
|
+
completedAt: '2030-01-02T02:00:00.000',
|
|
523
|
+
verifiedAt: validVerificationTimestamp,
|
|
524
|
+
})), 'offset-less timestamps must fail closed');
|
|
525
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
526
|
+
leaderboardId: 'validation:precision',
|
|
527
|
+
participantId: 'participant:precision',
|
|
528
|
+
attemptId: 'attempt:precision',
|
|
529
|
+
score: 1,
|
|
530
|
+
completedAt: '2030-01-02T02:00:00.0001Z',
|
|
531
|
+
verifiedAt: validVerificationTimestamp,
|
|
532
|
+
})), 'sub-millisecond timestamps must fail closed');
|
|
533
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
534
|
+
leaderboardId: 'validation:evidence-timezone',
|
|
535
|
+
participantId: 'participant:evidence-timezone',
|
|
536
|
+
attemptId: 'attempt:evidence-timezone',
|
|
537
|
+
score: 1,
|
|
538
|
+
completedAt: validVerificationTimestamp,
|
|
539
|
+
verifiedAt: '2030-01-02T02:00:00.000',
|
|
540
|
+
})), 'invalid verification timestamps must fail closed independently');
|
|
541
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
542
|
+
leaderboardId: 'validation:score-nan',
|
|
543
|
+
participantId: 'participant:score-nan',
|
|
544
|
+
attemptId: 'attempt:score-nan',
|
|
545
|
+
score: Number.NaN,
|
|
546
|
+
completedAt: validVerificationTimestamp,
|
|
547
|
+
})), 'NaN scores must fail closed');
|
|
548
|
+
await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
|
|
549
|
+
leaderboardId: 'validation:score-infinity',
|
|
550
|
+
participantId: 'participant:score-infinity',
|
|
551
|
+
attemptId: 'attempt:score-infinity',
|
|
552
|
+
score: Number.POSITIVE_INFINITY,
|
|
553
|
+
completedAt: validVerificationTimestamp,
|
|
554
|
+
})), 'infinite scores must fail closed');
|
|
555
|
+
const failedWriteChecks = await Promise.all([
|
|
556
|
+
'validation:calendar',
|
|
557
|
+
'validation:timezone',
|
|
558
|
+
'validation:precision',
|
|
559
|
+
'validation:evidence-timezone',
|
|
560
|
+
'validation:score-nan',
|
|
561
|
+
'validation:score-infinity',
|
|
562
|
+
].map(async (leaderboardId) => ({
|
|
563
|
+
leaderboardId,
|
|
564
|
+
snapshot: await context.service.getSnapshot({ leaderboardId }),
|
|
565
|
+
})));
|
|
566
|
+
for (const { leaderboardId, snapshot } of failedWriteChecks) {
|
|
567
|
+
assertEqual(snapshot, undefined, `rejected writes must not leave state for ${JSON.stringify(leaderboardId)}`);
|
|
568
|
+
}
|
|
569
|
+
await assertRejects(() => context.service.getSnapshot({ leaderboardId: 'validation:limit', limit: 0 }), 'non-positive snapshot limits must fail closed');
|
|
570
|
+
await assertRejects(() => context.service.getSnapshot({ leaderboardId: 'validation:limit', limit: 101 }), 'over-maximum snapshot limits must fail closed');
|
|
571
|
+
await context.service.recordVerifiedAttempt(createAttempt({
|
|
572
|
+
leaderboardId: 'validation:cursor',
|
|
573
|
+
participantId: 'participant:cursor',
|
|
574
|
+
attemptId: 'attempt:cursor',
|
|
575
|
+
score: 1,
|
|
576
|
+
completedAt: validVerificationTimestamp,
|
|
577
|
+
}));
|
|
578
|
+
await assertRejects(() => context.service.getSnapshot({
|
|
579
|
+
leaderboardId: 'validation:cursor',
|
|
580
|
+
cursor: 'not-a-valid-cursor',
|
|
581
|
+
}), 'malformed snapshot cursors must fail closed');
|
|
582
|
+
}
|
|
583
|
+
function createAttempt(input) {
|
|
584
|
+
return createMutableAttempt(input);
|
|
585
|
+
}
|
|
586
|
+
function createMutableAttempt(input) {
|
|
587
|
+
return {
|
|
588
|
+
definition: {
|
|
589
|
+
leaderboardId: input.leaderboardId,
|
|
590
|
+
scoreOrder: input.scoreOrder ?? 'ascending',
|
|
591
|
+
attemptSelection: input.attemptSelection ?? 'first',
|
|
592
|
+
},
|
|
593
|
+
attempt: {
|
|
594
|
+
participantId: input.participantId,
|
|
595
|
+
...(input.participantLabel === undefined
|
|
596
|
+
? {}
|
|
597
|
+
: { participantLabel: input.participantLabel }),
|
|
598
|
+
attemptId: input.attemptId,
|
|
599
|
+
score: input.score,
|
|
600
|
+
completedAt: input.completedAt,
|
|
601
|
+
verification: {
|
|
602
|
+
authorityId: 'verified-leaderboard-conformance',
|
|
603
|
+
evidenceId: `evidence:${input.leaderboardId}:${input.attemptId}`,
|
|
604
|
+
verifiedAt: input.verifiedAt ?? input.completedAt,
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
async function assertRejects(operation, message) {
|
|
610
|
+
try {
|
|
611
|
+
await operation();
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (error instanceof ProviderOutputValidationError) {
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
throw new Error(`${message}: expected the operation to reject.`);
|
|
620
|
+
}
|
|
621
|
+
function assert(condition, message) {
|
|
622
|
+
if (!condition) {
|
|
623
|
+
throw new Error(message);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function assertEqual(actual, expected, message) {
|
|
627
|
+
if (actual !== expected) {
|
|
628
|
+
throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}.`);
|
|
629
|
+
}
|
|
630
|
+
}
|