@mpgd/game-services 0.8.2 → 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/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
|
+
}
|
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
|
}
|