@mpgd/game-services 0.8.1 → 0.9.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/client.js +51 -5
- package/dist/notification-delivery.js +1 -0
- package/dist/runtime.js +2 -1
- package/dist/types.d.ts +5 -3
- package/dist/types.js +15 -2
- package/dist/verified-leaderboard-durability-conformance.d.ts +39 -0
- package/dist/verified-leaderboard-durability-conformance.js +193 -0
- package/dist/verified-leaderboard.js +85 -4
- package/package.json +9 -5
package/dist/client.js
CHANGED
|
@@ -29,7 +29,7 @@ export function createGameServicesClient(input) {
|
|
|
29
29
|
return {
|
|
30
30
|
async purchase(purchaseInput) {
|
|
31
31
|
const target = input.target;
|
|
32
|
-
if (!
|
|
32
|
+
if (!isGameServicesCommerceTarget(target)) {
|
|
33
33
|
const purchase = {
|
|
34
34
|
status: 'failed',
|
|
35
35
|
entitlementIds: [],
|
|
@@ -48,6 +48,25 @@ export function createGameServicesClient(input) {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
const purchase = await input.gateway.commerce.purchase(purchaseInput);
|
|
51
|
+
if (target === 'verse8') {
|
|
52
|
+
const status = purchase.status === 'completed' ? 'rejected' : purchase.status;
|
|
53
|
+
await analytics.track({
|
|
54
|
+
name: 'purchase_rejected',
|
|
55
|
+
properties: {
|
|
56
|
+
productId: purchaseInput.productId,
|
|
57
|
+
status,
|
|
58
|
+
reason: purchase.status === 'completed'
|
|
59
|
+
? 'verse8_grants_require_agent8_purchase_event'
|
|
60
|
+
: purchase.status === 'pending'
|
|
61
|
+
? 'agent8_purchase_event_pending'
|
|
62
|
+
: undefined,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
status,
|
|
67
|
+
purchase,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
51
70
|
if (purchase.status !== 'completed' || purchase.transactionId === undefined) {
|
|
52
71
|
await analytics.track({
|
|
53
72
|
name: 'purchase_rejected',
|
|
@@ -93,7 +112,7 @@ export function createGameServicesClient(input) {
|
|
|
93
112
|
},
|
|
94
113
|
async claimRewardedAd(rewardInput) {
|
|
95
114
|
const target = input.target;
|
|
96
|
-
if (!
|
|
115
|
+
if (!isGameServicesAdRewardTarget(target)) {
|
|
97
116
|
const reward = {
|
|
98
117
|
status: 'unavailable',
|
|
99
118
|
rewardGranted: false,
|
|
@@ -157,6 +176,23 @@ export function createGameServicesClient(input) {
|
|
|
157
176
|
return result;
|
|
158
177
|
},
|
|
159
178
|
async submitLeaderboardScore(scoreInput) {
|
|
179
|
+
const target = input.target;
|
|
180
|
+
if (!isGameServicesLeaderboardTarget(target)) {
|
|
181
|
+
await analytics.track({
|
|
182
|
+
name: 'leaderboard_submitted',
|
|
183
|
+
properties: {
|
|
184
|
+
leaderboardId: scoreInput.leaderboardId,
|
|
185
|
+
score: scoreInput.score,
|
|
186
|
+
submitted: false,
|
|
187
|
+
reason: 'unsupported_target',
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
submitted: false,
|
|
192
|
+
platformSubmitted: false,
|
|
193
|
+
alreadyProcessed: false,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
160
196
|
const platformResult = await input.gateway.leaderboard.submitScore(scoreInput);
|
|
161
197
|
if (!platformResult.submitted) {
|
|
162
198
|
await analytics.track({
|
|
@@ -174,7 +210,7 @@ export function createGameServicesClient(input) {
|
|
|
174
210
|
};
|
|
175
211
|
}
|
|
176
212
|
const record = await input.backend.leaderboard.recordScore({
|
|
177
|
-
target
|
|
213
|
+
target,
|
|
178
214
|
playerId: input.playerId,
|
|
179
215
|
...scoreInput,
|
|
180
216
|
});
|
|
@@ -281,8 +317,18 @@ export function createGameServicesIdempotencyKey(input) {
|
|
|
281
317
|
function normalizeSegment(value) {
|
|
282
318
|
return value.replaceAll(/[^a-zA-Z0-9_-]+/g, '-').replaceAll(/^-|-$/g, '').slice(0, 64);
|
|
283
319
|
}
|
|
284
|
-
function
|
|
285
|
-
return target === 'android' || target === 'ios' || target === 'ait';
|
|
320
|
+
function isGameServicesCommerceTarget(target) {
|
|
321
|
+
return target === 'android' || target === 'ios' || target === 'ait' || target === 'verse8';
|
|
322
|
+
}
|
|
323
|
+
function isGameServicesAdRewardTarget(target) {
|
|
324
|
+
return target === 'android' || target === 'ios' || target === 'ait' || target === 'verse8';
|
|
325
|
+
}
|
|
326
|
+
function isGameServicesLeaderboardTarget(target) {
|
|
327
|
+
return target === 'browser'
|
|
328
|
+
|| target === 'android'
|
|
329
|
+
|| target === 'ios'
|
|
330
|
+
|| target === 'ait'
|
|
331
|
+
|| target === 'reddit';
|
|
286
332
|
}
|
|
287
333
|
async function sendGameServicesBackendRequest(transport, endpoint, body) {
|
|
288
334
|
const response = await transport.send({
|
package/dist/runtime.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -2,7 +2,9 @@ import type { ProductGrant } from '@mpgd/catalog';
|
|
|
2
2
|
import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformEvidenceEnvelope, PlatformTarget } from '@mpgd/platform';
|
|
3
3
|
export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
4
4
|
export type GameServicesStoreTarget = Extract<PlatformTarget, 'android' | 'ios' | 'ait'>;
|
|
5
|
-
export type
|
|
5
|
+
export type GameServicesAdRewardTarget = Extract<PlatformTarget, 'android' | 'ios' | 'ait' | 'verse8'>;
|
|
6
|
+
export type GameServicesLeaderboardTarget = Extract<PlatformTarget, 'browser' | 'android' | 'ios' | 'ait' | 'reddit'>;
|
|
7
|
+
export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'android' | 'ios' | 'ait' | 'reddit' | 'verse8'>;
|
|
6
8
|
export type PurchaseIdempotencyKey = string;
|
|
7
9
|
export type AdRewardIdempotencyKey = string;
|
|
8
10
|
export type EntitlementIdempotencyKey = PurchaseIdempotencyKey | AdRewardIdempotencyKey;
|
|
@@ -24,7 +26,7 @@ export interface VerifyPurchaseResponse {
|
|
|
24
26
|
readonly reason?: string;
|
|
25
27
|
}
|
|
26
28
|
export interface ClaimAdRewardRequest {
|
|
27
|
-
readonly target:
|
|
29
|
+
readonly target: GameServicesAdRewardTarget;
|
|
28
30
|
readonly playerId: string;
|
|
29
31
|
readonly placementId: LogicalAdPlacementId;
|
|
30
32
|
readonly platformImpressionId?: string;
|
|
@@ -39,7 +41,7 @@ export interface ClaimAdRewardResponse {
|
|
|
39
41
|
readonly reason?: string;
|
|
40
42
|
}
|
|
41
43
|
export interface RecordLeaderboardScoreRequest extends LeaderboardScoreInput {
|
|
42
|
-
readonly target:
|
|
44
|
+
readonly target: GameServicesLeaderboardTarget;
|
|
43
45
|
readonly playerId: string;
|
|
44
46
|
readonly platformSubmissionId?: string;
|
|
45
47
|
}
|
package/dist/types.js
CHANGED
|
@@ -19,7 +19,7 @@ export function assertVerifyPurchaseResponse(input) {
|
|
|
19
19
|
}
|
|
20
20
|
export function assertClaimAdRewardRequest(input) {
|
|
21
21
|
assertRecord(input, 'ClaimAdRewardRequest');
|
|
22
|
-
|
|
22
|
+
assertAdRewardTarget(input.target);
|
|
23
23
|
assertNonEmptyString(input.playerId, 'playerId');
|
|
24
24
|
assertNonEmptyString(input.placementId, 'placementId');
|
|
25
25
|
assertOptionalNonEmptyString(input.platformImpressionId, 'platformImpressionId');
|
|
@@ -106,6 +106,11 @@ function assertStoreTarget(input) {
|
|
|
106
106
|
throw new Error('target must be android, ios, or ait.');
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
function assertAdRewardTarget(input) {
|
|
110
|
+
if (input !== 'android' && input !== 'ios' && input !== 'ait' && input !== 'verse8') {
|
|
111
|
+
throw new Error('target must be android, ios, ait, or verse8.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
109
114
|
function assertLeaderboardTarget(input) {
|
|
110
115
|
if (input !== 'browser'
|
|
111
116
|
&& input !== 'android'
|
|
@@ -153,7 +158,15 @@ function assertProductGrant(input) {
|
|
|
153
158
|
assertNonEmptyString(input.entitlement, 'grant.entitlement');
|
|
154
159
|
return;
|
|
155
160
|
}
|
|
156
|
-
|
|
161
|
+
if (input.type === 'resource') {
|
|
162
|
+
assertNonEmptyString(input.resource, 'grant.resource');
|
|
163
|
+
assertFiniteNumber(input.amount, 'grant.amount');
|
|
164
|
+
if (input.amount <= 0) {
|
|
165
|
+
throw new Error('grant.amount must be greater than zero for a resource grant.');
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
throw new Error('grant.type must be currency, entitlement, or resource.');
|
|
157
170
|
}
|
|
158
171
|
function assertPayload(input) {
|
|
159
172
|
assertRecord(input, 'payload');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type VerifiedLeaderboardService } from './verified-leaderboard';
|
|
2
|
+
export declare const verifiedLeaderboardDurabilityConformanceScenarios: readonly ['replacement-after-interrupted-retained-write', 'concurrent-retry-after-interrupted-retained-write', 'snapshot-after-interrupted-retained-write'];
|
|
3
|
+
export type VerifiedLeaderboardDurabilityConformanceScenario = (typeof verifiedLeaderboardDurabilityConformanceScenarios)[number];
|
|
4
|
+
export interface CreateVerifiedLeaderboardDurabilityConformanceFixtureInput {
|
|
5
|
+
readonly scenario: VerifiedLeaderboardDurabilityConformanceScenario;
|
|
6
|
+
readonly now: string;
|
|
7
|
+
}
|
|
8
|
+
export interface VerifiedLeaderboardDurabilityConformanceFixture {
|
|
9
|
+
readonly service: VerifiedLeaderboardService;
|
|
10
|
+
/**
|
|
11
|
+
* Arms one retained record so its caller observes a failure after the
|
|
12
|
+
* provider has crossed the recovery boundary. Transactional providers may
|
|
13
|
+
* simulate a committed write with a lost response. Multi-write providers
|
|
14
|
+
* should interrupt at their recoverable handoff boundary.
|
|
15
|
+
*/
|
|
16
|
+
readonly interruptNextRetainedRecord: () => void;
|
|
17
|
+
readonly dispose?: () => Promise<void> | void;
|
|
18
|
+
}
|
|
19
|
+
export type CreateVerifiedLeaderboardDurabilityConformanceFixture = (input: CreateVerifiedLeaderboardDurabilityConformanceFixtureInput) => Promise<VerifiedLeaderboardDurabilityConformanceFixture> | VerifiedLeaderboardDurabilityConformanceFixture;
|
|
20
|
+
export interface RunVerifiedLeaderboardDurabilityConformanceInput {
|
|
21
|
+
/** Return an isolated fixture, or a uniquely namespaced fixture, per scenario. */
|
|
22
|
+
readonly createFixture: CreateVerifiedLeaderboardDurabilityConformanceFixture;
|
|
23
|
+
readonly now?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface VerifiedLeaderboardDurabilityConformanceReport {
|
|
26
|
+
readonly passedScenarios: readonly VerifiedLeaderboardDurabilityConformanceScenario[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Runs provider-neutral recovery checks that require a provider-specific fault
|
|
30
|
+
* boundary. It complements the semantic conformance suite and deliberately
|
|
31
|
+
* does not prescribe a shared persistence implementation.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runVerifiedLeaderboardDurabilityConformance(input: RunVerifiedLeaderboardDurabilityConformanceInput): Promise<VerifiedLeaderboardDurabilityConformanceReport>;
|
|
34
|
+
/**
|
|
35
|
+
* Wraps an atomic provider so the next armed record commits successfully but
|
|
36
|
+
* its caller observes an interruption. Use a provider-specific fixture when
|
|
37
|
+
* the implementation has a recoverable boundary inside a multi-write flow.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createAmbiguousCommitVerifiedLeaderboardDurabilityFixture(provider: VerifiedLeaderboardService, dispose?: () => Promise<void> | void): VerifiedLeaderboardDurabilityConformanceFixture;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { assertRecordVerifiedLeaderboardAttemptResponse, assertVerifiedLeaderboardSnapshot, } from './verified-leaderboard';
|
|
2
|
+
export const verifiedLeaderboardDurabilityConformanceScenarios = [
|
|
3
|
+
'replacement-after-interrupted-retained-write',
|
|
4
|
+
'concurrent-retry-after-interrupted-retained-write',
|
|
5
|
+
'snapshot-after-interrupted-retained-write',
|
|
6
|
+
];
|
|
7
|
+
const durabilityScenarioRunners = {
|
|
8
|
+
'replacement-after-interrupted-retained-write': runReplacementAfterInterruptedRetainedWriteScenario,
|
|
9
|
+
'concurrent-retry-after-interrupted-retained-write': runConcurrentRetryAfterInterruptedRetainedWriteScenario,
|
|
10
|
+
'snapshot-after-interrupted-retained-write': runSnapshotAfterInterruptedRetainedWriteScenario,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Runs provider-neutral recovery checks that require a provider-specific fault
|
|
14
|
+
* boundary. It complements the semantic conformance suite and deliberately
|
|
15
|
+
* does not prescribe a shared persistence implementation.
|
|
16
|
+
*/
|
|
17
|
+
export async function runVerifiedLeaderboardDurabilityConformance(input) {
|
|
18
|
+
const now = input.now ?? '2030-01-02T03:04:05.000Z';
|
|
19
|
+
const passedScenarios = [];
|
|
20
|
+
for (const scenario of verifiedLeaderboardDurabilityConformanceScenarios) {
|
|
21
|
+
const runScenario = durabilityScenarioRunners[scenario];
|
|
22
|
+
let fixture;
|
|
23
|
+
let scenarioError;
|
|
24
|
+
let scenarioFailed = false;
|
|
25
|
+
try {
|
|
26
|
+
fixture = await input.createFixture({ scenario, now });
|
|
27
|
+
await runScenario(fixture);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
scenarioError = error;
|
|
31
|
+
scenarioFailed = true;
|
|
32
|
+
}
|
|
33
|
+
let cleanupError;
|
|
34
|
+
let cleanupFailed = false;
|
|
35
|
+
try {
|
|
36
|
+
await fixture?.dispose?.();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
cleanupError = error;
|
|
40
|
+
cleanupFailed = true;
|
|
41
|
+
}
|
|
42
|
+
if (scenarioFailed) {
|
|
43
|
+
throw new Error(`Verified leaderboard durability conformance failed: ${scenario}.`, {
|
|
44
|
+
cause: cleanupFailed
|
|
45
|
+
? new AggregateError([scenarioError, cleanupError], `Scenario and fixture cleanup both failed: ${scenario}.`)
|
|
46
|
+
: scenarioError,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (cleanupFailed) {
|
|
50
|
+
const message = `Verified leaderboard durability conformance cleanup failed: ${scenario}.`;
|
|
51
|
+
throw new Error(message, { cause: cleanupError });
|
|
52
|
+
}
|
|
53
|
+
passedScenarios.push(scenario);
|
|
54
|
+
}
|
|
55
|
+
return { passedScenarios };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Wraps an atomic provider so the next armed record commits successfully but
|
|
59
|
+
* its caller observes an interruption. Use a provider-specific fixture when
|
|
60
|
+
* the implementation has a recoverable boundary inside a multi-write flow.
|
|
61
|
+
*/
|
|
62
|
+
export function createAmbiguousCommitVerifiedLeaderboardDurabilityFixture(provider, dispose) {
|
|
63
|
+
let interruptNextRecord = false;
|
|
64
|
+
return {
|
|
65
|
+
service: {
|
|
66
|
+
async recordVerifiedAttempt(input) {
|
|
67
|
+
const response = await provider.recordVerifiedAttempt(input);
|
|
68
|
+
if (interruptNextRecord) {
|
|
69
|
+
interruptNextRecord = false;
|
|
70
|
+
throw new Error('Simulated verified leaderboard response interruption.');
|
|
71
|
+
}
|
|
72
|
+
return response;
|
|
73
|
+
},
|
|
74
|
+
getSnapshot: (input) => provider.getSnapshot(input),
|
|
75
|
+
},
|
|
76
|
+
interruptNextRetainedRecord() {
|
|
77
|
+
if (interruptNextRecord) {
|
|
78
|
+
throw new Error('A verified leaderboard interruption is already armed.');
|
|
79
|
+
}
|
|
80
|
+
interruptNextRecord = true;
|
|
81
|
+
},
|
|
82
|
+
...(dispose === undefined ? {} : { dispose }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function runReplacementAfterInterruptedRetainedWriteScenario(fixture) {
|
|
86
|
+
const interrupted = createAttempt('replacement-after-interrupted-retained-write', 'interrupted', 10, '2030-01-02T03:00:00.000Z');
|
|
87
|
+
const later = createAttempt('replacement-after-interrupted-retained-write', 'later-better', 20, '2030-01-02T03:01:00.000Z');
|
|
88
|
+
fixture.interruptNextRetainedRecord();
|
|
89
|
+
await assertRejects(() => fixture.service.recordVerifiedAttempt(interrupted), 'the armed retained record must surface an interruption');
|
|
90
|
+
const laterResponse = await fixture.service.recordVerifiedAttempt(later);
|
|
91
|
+
assertRecordVerifiedLeaderboardAttemptResponse(laterResponse);
|
|
92
|
+
assertEqual(laterResponse.retained, true, 'the later better attempt must be retained');
|
|
93
|
+
assertEqual(laterResponse.entry.attemptId, later.attempt.attemptId, 'the later better attempt must replace the interrupted retained entry');
|
|
94
|
+
const retry = await fixture.service.recordVerifiedAttempt(interrupted);
|
|
95
|
+
assertOriginalInterruptedDecision(retry, interrupted);
|
|
96
|
+
const snapshot = await fixture.service.getSnapshot({
|
|
97
|
+
leaderboardId: interrupted.definition.leaderboardId,
|
|
98
|
+
participantId: interrupted.attempt.participantId,
|
|
99
|
+
});
|
|
100
|
+
assert(snapshot !== undefined, 'the recovered board must return a snapshot');
|
|
101
|
+
assertVerifiedLeaderboardSnapshot(snapshot);
|
|
102
|
+
assertEqual(snapshot.totalParticipants, 1, 'the replacement must keep one participant');
|
|
103
|
+
assertEqual(snapshot.entries.length, 1, 'the replacement must keep one ranked entry');
|
|
104
|
+
assertEqual(snapshot.entries[0]?.participantId, later.attempt.participantId, 'the ranked snapshot must contain the replacement participant');
|
|
105
|
+
assertEqual(snapshot.entries[0]?.attemptId, later.attempt.attemptId, 'the ranked snapshot must contain the later better attempt');
|
|
106
|
+
assertEqual(snapshot.entries[0]?.score, later.attempt.score, 'the ranked snapshot must contain the later better score');
|
|
107
|
+
assertEqual(snapshot.participantEntry?.attemptId, later.attempt.attemptId, 'the final snapshot must retain the later better attempt');
|
|
108
|
+
}
|
|
109
|
+
async function runConcurrentRetryAfterInterruptedRetainedWriteScenario(fixture) {
|
|
110
|
+
const interrupted = createAttempt('concurrent-retry-after-interrupted-retained-write', 'interrupted', 10, '2030-01-02T03:00:00.000Z');
|
|
111
|
+
fixture.interruptNextRetainedRecord();
|
|
112
|
+
await assertRejects(() => fixture.service.recordVerifiedAttempt(interrupted), 'the armed retained record must surface an interruption');
|
|
113
|
+
const retries = await Promise.all([
|
|
114
|
+
fixture.service.recordVerifiedAttempt(interrupted),
|
|
115
|
+
fixture.service.recordVerifiedAttempt(interrupted),
|
|
116
|
+
]);
|
|
117
|
+
for (const retry of retries) {
|
|
118
|
+
assertOriginalInterruptedDecision(retry, interrupted);
|
|
119
|
+
}
|
|
120
|
+
const snapshot = await fixture.service.getSnapshot({
|
|
121
|
+
leaderboardId: interrupted.definition.leaderboardId,
|
|
122
|
+
});
|
|
123
|
+
assert(snapshot !== undefined, 'the concurrently recovered board must return a snapshot');
|
|
124
|
+
assertVerifiedLeaderboardSnapshot(snapshot);
|
|
125
|
+
assertEqual(snapshot.totalParticipants, 1, 'concurrent retries must not duplicate participants');
|
|
126
|
+
assertEqual(snapshot.entries.length, 1, 'concurrent retries must not duplicate retained rows');
|
|
127
|
+
}
|
|
128
|
+
async function runSnapshotAfterInterruptedRetainedWriteScenario(fixture) {
|
|
129
|
+
const interrupted = createAttempt('snapshot-after-interrupted-retained-write', 'interrupted', 10, '2030-01-02T03:00:00.000Z');
|
|
130
|
+
fixture.interruptNextRetainedRecord();
|
|
131
|
+
await assertRejects(() => fixture.service.recordVerifiedAttempt(interrupted), 'the armed retained record must surface an interruption');
|
|
132
|
+
const snapshot = await fixture.service.getSnapshot({
|
|
133
|
+
leaderboardId: interrupted.definition.leaderboardId,
|
|
134
|
+
participantId: interrupted.attempt.participantId,
|
|
135
|
+
});
|
|
136
|
+
assert(snapshot !== undefined, 'reads must recover an interrupted retained record');
|
|
137
|
+
assertVerifiedLeaderboardSnapshot(snapshot);
|
|
138
|
+
assertEqual(snapshot.totalParticipants, 1, 'recovery must restore one participant');
|
|
139
|
+
assertEqual(snapshot.entries.length, 1, 'recovery must restore one ranked entry');
|
|
140
|
+
assertEqual(snapshot.entries[0]?.participantId, interrupted.attempt.participantId, 'reads must expose the recovered participant in ranked entries');
|
|
141
|
+
assertEqual(snapshot.entries[0]?.attemptId, interrupted.attempt.attemptId, 'reads must expose the recovered attempt in ranked entries');
|
|
142
|
+
assertEqual(snapshot.entries[0]?.score, interrupted.attempt.score, 'reads must expose the recovered score in ranked entries');
|
|
143
|
+
assertEqual(snapshot.participantEntry?.attemptId, interrupted.attempt.attemptId, 'reads must expose the recovered retained entry');
|
|
144
|
+
const retry = await fixture.service.recordVerifiedAttempt(interrupted);
|
|
145
|
+
assertOriginalInterruptedDecision(retry, interrupted);
|
|
146
|
+
}
|
|
147
|
+
function assertOriginalInterruptedDecision(response, interrupted) {
|
|
148
|
+
assertRecordVerifiedLeaderboardAttemptResponse(response);
|
|
149
|
+
assertEqual(response.alreadyProcessed, true, 'retries must detect the interrupted write');
|
|
150
|
+
assertEqual(response.retained, true, 'retries must preserve the original retained decision');
|
|
151
|
+
assertEqual(response.entry.attemptId, interrupted.attempt.attemptId, 'retries must preserve the original response entry');
|
|
152
|
+
assertEqual(response.entry.score, interrupted.attempt.score, 'retries must preserve the original response score');
|
|
153
|
+
}
|
|
154
|
+
function createAttempt(scenario, suffix, score, completedAt) {
|
|
155
|
+
return {
|
|
156
|
+
definition: {
|
|
157
|
+
leaderboardId: `durability:${scenario}`,
|
|
158
|
+
scoreOrder: 'descending',
|
|
159
|
+
attemptSelection: 'best',
|
|
160
|
+
},
|
|
161
|
+
attempt: {
|
|
162
|
+
participantId: `participant:${scenario}`,
|
|
163
|
+
attemptId: `attempt:${scenario}:${suffix}`,
|
|
164
|
+
score,
|
|
165
|
+
completedAt,
|
|
166
|
+
verification: {
|
|
167
|
+
authorityId: 'verified-leaderboard-durability-conformance',
|
|
168
|
+
evidenceId: `evidence:${scenario}:${suffix}`,
|
|
169
|
+
verifiedAt: completedAt,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function assertRejects(callback, message) {
|
|
175
|
+
let rejected = false;
|
|
176
|
+
try {
|
|
177
|
+
await callback();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
rejected = true;
|
|
181
|
+
}
|
|
182
|
+
assert(rejected, message);
|
|
183
|
+
}
|
|
184
|
+
function assert(condition, message) {
|
|
185
|
+
if (!condition) {
|
|
186
|
+
throw new Error(message);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function assertEqual(actual, expected, message) {
|
|
190
|
+
if (actual !== expected) {
|
|
191
|
+
throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -124,7 +124,7 @@ export class InMemoryVerifiedLeaderboardService {
|
|
|
124
124
|
}
|
|
125
125
|
createRankedEntries(definition) {
|
|
126
126
|
return [...this.getRetainedAttempts(definition.leaderboardId).values()]
|
|
127
|
-
.sort((left, right) =>
|
|
127
|
+
.sort((left, right) => compareLeaderboardOrder(definition.scoreOrder, left, right))
|
|
128
128
|
.map((attempt, index) => toRankedEntry(attempt, index + 1));
|
|
129
129
|
}
|
|
130
130
|
}
|
|
@@ -238,8 +238,10 @@ export function assertVerifiedLeaderboardSnapshot(input) {
|
|
|
238
238
|
if (!Array.isArray(input.entries)) {
|
|
239
239
|
throw new Error('entries must be an array.');
|
|
240
240
|
}
|
|
241
|
+
const entries = [];
|
|
241
242
|
for (const entry of input.entries) {
|
|
242
243
|
assertLeaderboardRankedEntry(entry);
|
|
244
|
+
entries.push(entry);
|
|
243
245
|
}
|
|
244
246
|
if (input.participantEntry !== undefined) {
|
|
245
247
|
assertLeaderboardRankedEntry(input.participantEntry);
|
|
@@ -249,8 +251,87 @@ export function assertVerifiedLeaderboardSnapshot(input) {
|
|
|
249
251
|
|| input.totalParticipants < 0) {
|
|
250
252
|
throw new Error('totalParticipants must be a non-negative integer.');
|
|
251
253
|
}
|
|
254
|
+
assertVerifiedLeaderboardSnapshotEntries(input.definition, entries, input.participantEntry, input.totalParticipants);
|
|
252
255
|
assertTimestamp(input.generatedAt, 'generatedAt');
|
|
253
|
-
|
|
256
|
+
assertVerifiedLeaderboardSnapshotCursor(input.definition, entries, input.totalParticipants, input.nextCursor);
|
|
257
|
+
}
|
|
258
|
+
function assertVerifiedLeaderboardSnapshotEntries(definition, entries, participantEntry, totalParticipants) {
|
|
259
|
+
const participantIds = new Set();
|
|
260
|
+
const attemptIds = new Set();
|
|
261
|
+
for (const [index, entry] of entries.entries()) {
|
|
262
|
+
if (entry.rank > totalParticipants) {
|
|
263
|
+
throw new Error('entry ranks must not exceed totalParticipants.');
|
|
264
|
+
}
|
|
265
|
+
if (participantIds.has(entry.participantId)) {
|
|
266
|
+
throw new Error('entries must not repeat a participantId.');
|
|
267
|
+
}
|
|
268
|
+
if (attemptIds.has(entry.attemptId)) {
|
|
269
|
+
throw new Error('entries must not repeat an attemptId.');
|
|
270
|
+
}
|
|
271
|
+
participantIds.add(entry.participantId);
|
|
272
|
+
attemptIds.add(entry.attemptId);
|
|
273
|
+
const previous = entries[index - 1];
|
|
274
|
+
if (previous === undefined) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (entry.rank !== previous.rank + 1) {
|
|
278
|
+
throw new Error('entry ranks must be contiguous within a snapshot page.');
|
|
279
|
+
}
|
|
280
|
+
if (compareLeaderboardOrder(definition.scoreOrder, previous, entry) >= 0) {
|
|
281
|
+
throw new Error('entries must follow the leaderboard ranking order.');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (participantEntry !== undefined) {
|
|
285
|
+
if (participantEntry.rank > totalParticipants) {
|
|
286
|
+
throw new Error('participantEntry rank must not exceed totalParticipants.');
|
|
287
|
+
}
|
|
288
|
+
const overlappingEntry = entries.find((entry) => entry.rank === participantEntry.rank
|
|
289
|
+
|| entry.participantId === participantEntry.participantId
|
|
290
|
+
|| entry.attemptId === participantEntry.attemptId);
|
|
291
|
+
if (overlappingEntry !== undefined
|
|
292
|
+
&& !areLeaderboardRankedEntriesEqual(overlappingEntry, participantEntry)) {
|
|
293
|
+
throw new Error('participantEntry must match overlapping snapshot entries.');
|
|
294
|
+
}
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
if (entry.rank === participantEntry.rank) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const order = compareLeaderboardOrder(definition.scoreOrder, participantEntry, entry);
|
|
300
|
+
const participantRanksBeforeEntry = participantEntry.rank < entry.rank;
|
|
301
|
+
if ((participantRanksBeforeEntry && order >= 0)
|
|
302
|
+
|| (!participantRanksBeforeEntry && order <= 0)) {
|
|
303
|
+
throw new Error('participantEntry must follow the leaderboard ranking order.');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function areLeaderboardRankedEntriesEqual(left, right) {
|
|
309
|
+
return left.rank === right.rank
|
|
310
|
+
&& left.participantId === right.participantId
|
|
311
|
+
&& left.participantLabel === right.participantLabel
|
|
312
|
+
&& left.attemptId === right.attemptId
|
|
313
|
+
&& left.score === right.score
|
|
314
|
+
&& parseTimestamp(left.completedAt) === parseTimestamp(right.completedAt)
|
|
315
|
+
&& areVerifiedLeaderboardMetricsEqual(left.metrics, right.metrics);
|
|
316
|
+
}
|
|
317
|
+
function assertVerifiedLeaderboardSnapshotCursor(definition, entries, totalParticipants, nextCursor) {
|
|
318
|
+
assertOptionalCursor(nextCursor);
|
|
319
|
+
const lastEntry = entries.at(-1);
|
|
320
|
+
if (nextCursor === undefined) {
|
|
321
|
+
if (lastEntry !== undefined && lastEntry.rank < totalParticipants) {
|
|
322
|
+
throw new Error('nextCursor must be present when snapshot entries remain.');
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (lastEntry === undefined) {
|
|
327
|
+
throw new Error('nextCursor requires at least one snapshot entry.');
|
|
328
|
+
}
|
|
329
|
+
if (lastEntry.rank >= totalParticipants) {
|
|
330
|
+
throw new Error('nextCursor must be omitted after the final snapshot entry.');
|
|
331
|
+
}
|
|
332
|
+
if (nextCursor !== createVerifiedLeaderboardCursor(definition, lastEntry)) {
|
|
333
|
+
throw new Error('nextCursor must continue after the final snapshot entry.');
|
|
334
|
+
}
|
|
254
335
|
}
|
|
255
336
|
export function createVerifiedLeaderboardCursor(definition, entry) {
|
|
256
337
|
assertVerifiedLeaderboardDefinition(definition);
|
|
@@ -306,7 +387,7 @@ function shouldReplaceRetainedAttempt(definition, retained, candidate) {
|
|
|
306
387
|
if (definition.attemptSelection === 'first') {
|
|
307
388
|
return compareAttemptChronology(candidate, retained) < 0;
|
|
308
389
|
}
|
|
309
|
-
return
|
|
390
|
+
return compareLeaderboardOrder(definition.scoreOrder, candidate, retained) < 0;
|
|
310
391
|
}
|
|
311
392
|
function compareAttemptChronology(left, right) {
|
|
312
393
|
const completedAtComparison = parseTimestamp(left.completedAt) - parseTimestamp(right.completedAt);
|
|
@@ -315,7 +396,7 @@ function compareAttemptChronology(left, right) {
|
|
|
315
396
|
}
|
|
316
397
|
return compareOrdinal(left.attemptId, right.attemptId);
|
|
317
398
|
}
|
|
318
|
-
function
|
|
399
|
+
function compareLeaderboardOrder(scoreOrder, left, right) {
|
|
319
400
|
if (left.score !== right.score) {
|
|
320
401
|
return scoreOrder === 'ascending' ? left.score - right.score : right.score - left.score;
|
|
321
402
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
"./verified-leaderboard-conformance": {
|
|
77
77
|
"types": "./dist/verified-leaderboard-conformance.d.ts",
|
|
78
78
|
"default": "./dist/verified-leaderboard-conformance.js"
|
|
79
|
+
},
|
|
80
|
+
"./verified-leaderboard-durability-conformance": {
|
|
81
|
+
"types": "./dist/verified-leaderboard-durability-conformance.d.ts",
|
|
82
|
+
"default": "./dist/verified-leaderboard-durability-conformance.js"
|
|
79
83
|
}
|
|
80
84
|
},
|
|
81
85
|
"files": [
|
|
@@ -85,9 +89,9 @@
|
|
|
85
89
|
"@orpc/client": "2.0.0-beta.14",
|
|
86
90
|
"@orpc/contract": "2.0.0-beta.14",
|
|
87
91
|
"@orpc/server": "2.0.0-beta.14",
|
|
88
|
-
"@mpgd/analytics": "0.3.
|
|
89
|
-
"@mpgd/catalog": "0.
|
|
90
|
-
"@mpgd/platform": "0.
|
|
92
|
+
"@mpgd/analytics": "0.3.7",
|
|
93
|
+
"@mpgd/catalog": "0.5.0",
|
|
94
|
+
"@mpgd/platform": "0.7.0"
|
|
91
95
|
},
|
|
92
96
|
"devDependencies": {
|
|
93
97
|
"ttsc": "0.18.4",
|
|
@@ -103,6 +107,6 @@
|
|
|
103
107
|
"lint": "ttsc --noEmit",
|
|
104
108
|
"format": "ttsc format",
|
|
105
109
|
"fix": "ttsc fix",
|
|
106
|
-
"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/platform-order.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"
|
|
110
|
+
"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/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.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-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts"
|
|
107
111
|
}
|
|
108
112
|
}
|