@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 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 (!isGameServicesStoreTarget(target)) {
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 (!isGameServicesStoreTarget(target)) {
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: input.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 isGameServicesStoreTarget(target) {
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({
@@ -5,6 +5,7 @@ const platformTargets = new Set([
5
5
  'ios',
6
6
  'ait',
7
7
  'reddit',
8
+ 'verse8',
8
9
  'telegram',
9
10
  'tauri',
10
11
  ]);
package/dist/runtime.js CHANGED
@@ -59,7 +59,8 @@ export function resolveGameServicesLedgerTarget(target) {
59
59
  || target === 'android'
60
60
  || target === 'ios'
61
61
  || target === 'ait'
62
- || target === 'reddit') {
62
+ || target === 'reddit'
63
+ || target === 'verse8') {
63
64
  return target;
64
65
  }
65
66
  return null;
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 GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'android' | 'ios' | 'ait' | 'reddit'>;
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: GameServicesStoreTarget;
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: GameServicesLedgerTarget;
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
- assertStoreTarget(input.target);
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
- throw new Error('grant.type must be currency or entitlement.');
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) => compareAttempts(definition.scoreOrder, 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
- assertOptionalCursor(input.nextCursor);
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 compareAttempts(definition.scoreOrder, candidate, retained) < 0;
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 compareAttempts(scoreOrder, left, right) {
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.8.1",
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.6",
89
- "@mpgd/catalog": "0.4.0",
90
- "@mpgd/platform": "0.6.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
  }