@mpgd/game-services 0.7.0 → 0.8.1

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
@@ -69,6 +69,7 @@ export function createGameServicesClient(input) {
69
69
  platformTransactionId: purchase.transactionId,
70
70
  idempotencyKey: purchaseInput.idempotencyKey,
71
71
  purchasedAt: now(),
72
+ ...(purchase.evidence === undefined ? {} : { evidence: purchase.evidence }),
72
73
  });
73
74
  const result = {
74
75
  status: verification.verified ? 'granted' : 'rejected',
@@ -135,6 +136,7 @@ export function createGameServicesClient(input) {
135
136
  : { platformImpressionId: reward.ledgerEntryId }),
136
137
  idempotencyKey: rewardInput.idempotencyKey,
137
138
  completedAt: now(),
139
+ ...(reward.evidence === undefined ? {} : { evidence: reward.evidence }),
138
140
  });
139
141
  const result = {
140
142
  status: claim.granted ? 'granted' : 'rejected',
@@ -0,0 +1,34 @@
1
+ import type { AdPlacements, ProductCatalog } from '@mpgd/catalog';
2
+ import type { ClaimAdRewardRequest, EntitlementLedgerPayload, VerifyPurchaseRequest } from './types';
3
+ export type EvidenceVerificationDecision = {
4
+ readonly status: 'verified';
5
+ readonly verificationId: string;
6
+ readonly verifiedAt: string;
7
+ readonly payload?: EntitlementLedgerPayload;
8
+ } | {
9
+ readonly status: 'pending';
10
+ readonly reason?: string;
11
+ } | {
12
+ readonly status: 'rejected';
13
+ readonly reason: string;
14
+ };
15
+ export interface VerifyPurchaseEvidenceInput {
16
+ readonly request: VerifyPurchaseRequest;
17
+ readonly product: ProductCatalog['products'][number];
18
+ readonly platformProductId: string;
19
+ readonly signal: AbortSignal;
20
+ readonly timeoutMs: number;
21
+ }
22
+ export interface VerifyAdRewardEvidenceInput {
23
+ readonly request: ClaimAdRewardRequest;
24
+ readonly placement: AdPlacements['placements'][number];
25
+ readonly platformPlacementId?: string;
26
+ readonly signal: AbortSignal;
27
+ readonly timeoutMs: number;
28
+ }
29
+ export interface GameServicesEvidenceVerifier {
30
+ verifyPurchase(input: VerifyPurchaseEvidenceInput): Promise<EvidenceVerificationDecision>;
31
+ verifyAdReward(input: VerifyAdRewardEvidenceInput): Promise<EvidenceVerificationDecision>;
32
+ }
33
+ export declare function createDevelopmentGameServicesEvidenceVerifier(now?: () => string): GameServicesEvidenceVerifier;
34
+ export declare function createRejectingGameServicesEvidenceVerifier(): GameServicesEvidenceVerifier;
@@ -0,0 +1,34 @@
1
+ export function createDevelopmentGameServicesEvidenceVerifier(now = () => new Date().toISOString()) {
2
+ return {
3
+ async verifyPurchase({ request }) {
4
+ return {
5
+ status: 'verified',
6
+ verificationId: `development:purchase:${request.target}:${request.platformTransactionId}`,
7
+ verifiedAt: now(),
8
+ };
9
+ },
10
+ async verifyAdReward({ request }) {
11
+ return {
12
+ status: 'verified',
13
+ verificationId: `development:ad-reward:${request.target}:${request.platformImpressionId ?? request.idempotencyKey}`,
14
+ verifiedAt: now(),
15
+ };
16
+ },
17
+ };
18
+ }
19
+ export function createRejectingGameServicesEvidenceVerifier() {
20
+ return {
21
+ async verifyPurchase() {
22
+ return {
23
+ status: 'rejected',
24
+ reason: 'EVIDENCE_VERIFIER_UNAVAILABLE',
25
+ };
26
+ },
27
+ async verifyAdReward() {
28
+ return {
29
+ status: 'rejected',
30
+ reason: 'EVIDENCE_VERIFIER_UNAVAILABLE',
31
+ };
32
+ },
33
+ };
34
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './client';
2
2
  export * from './contract';
3
+ export * from './evidence-verification';
3
4
  export * from './notification-delivery';
5
+ export * from './platform-order';
4
6
  export * from './progress-link';
5
7
  export * from './runtime.js';
6
8
  export * from './server';
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './client';
2
2
  export * from './contract';
3
+ export * from './evidence-verification';
3
4
  export * from './notification-delivery';
5
+ export * from './platform-order';
4
6
  export * from './progress-link';
5
7
  export * from './runtime.js';
6
8
  export * from './server';
@@ -0,0 +1,22 @@
1
+ import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
2
+ export type PlatformOrderTarget = 'reddit';
3
+ export interface FulfillPlatformOrderInput {
4
+ readonly target: PlatformOrderTarget;
5
+ readonly orderId: string;
6
+ readonly playerId: string;
7
+ readonly platformSku: string;
8
+ readonly paidAt: string;
9
+ readonly evidence: PlatformEvidenceEnvelope;
10
+ }
11
+ export interface RefundPlatformOrderInput {
12
+ readonly target: PlatformOrderTarget;
13
+ readonly orderId: string;
14
+ readonly playerId: string;
15
+ readonly platformSku: string;
16
+ readonly refundedAt: string;
17
+ readonly evidence: PlatformEvidenceEnvelope;
18
+ }
19
+ export type PlatformOrderIdempotencyKey = `${PlatformOrderTarget}:${string}`;
20
+ export declare function createPlatformOrderIdempotencyKey(input: Pick<FulfillPlatformOrderInput, 'target' | 'orderId'>): PlatformOrderIdempotencyKey;
21
+ export declare function assertFulfillPlatformOrderInput(input: FulfillPlatformOrderInput): FulfillPlatformOrderInput;
22
+ export declare function assertRefundPlatformOrderInput(input: RefundPlatformOrderInput): RefundPlatformOrderInput;
@@ -0,0 +1,69 @@
1
+ export function createPlatformOrderIdempotencyKey(input) {
2
+ assertPlatformOrderTarget(input.target);
3
+ assertIdentifier(input.orderId, 'orderId');
4
+ return `${input.target}:${input.orderId}`;
5
+ }
6
+ export function assertFulfillPlatformOrderInput(input) {
7
+ assertPlatformOrderBase(input, 'FulfillPlatformOrderInput');
8
+ assertIsoTimestamp(input.paidAt, 'paidAt');
9
+ return input;
10
+ }
11
+ export function assertRefundPlatformOrderInput(input) {
12
+ assertPlatformOrderBase(input, 'RefundPlatformOrderInput');
13
+ assertIsoTimestamp(input.refundedAt, 'refundedAt');
14
+ return input;
15
+ }
16
+ function assertPlatformOrderBase(input, label) {
17
+ assertRecord(input, label);
18
+ assertPlatformOrderTarget(input.target);
19
+ assertIdentifier(input.orderId, 'orderId');
20
+ assertIdentifier(input.playerId, 'playerId');
21
+ assertIdentifier(input.platformSku, 'platformSku');
22
+ assertEvidenceEnvelope(input.evidence);
23
+ }
24
+ function assertPlatformOrderTarget(input) {
25
+ if (input !== 'reddit') {
26
+ throw new TypeError('target must be reddit.');
27
+ }
28
+ }
29
+ function assertIdentifier(input, label) {
30
+ if (typeof input !== 'string'
31
+ || input.length === 0
32
+ || input.length > 256
33
+ || input.trim() !== input
34
+ || /[\u0000-\u001f\u007f]/u.test(input)) {
35
+ throw new TypeError(`${label} must be a non-empty identifier.`);
36
+ }
37
+ }
38
+ function assertIsoTimestamp(input, label) {
39
+ if (typeof input !== 'string'
40
+ || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u.test(input)) {
41
+ throw new TypeError(`${label} must be an ISO 8601 UTC timestamp.`);
42
+ }
43
+ const parsed = new Date(input);
44
+ const canonicalInput = input.includes('.') ? input : `${input.slice(0, -1)}.000Z`;
45
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== canonicalInput) {
46
+ throw new TypeError(`${label} must be an ISO 8601 UTC timestamp.`);
47
+ }
48
+ }
49
+ function assertEvidenceEnvelope(input) {
50
+ assertRecord(input, 'evidence');
51
+ assertIdentifier(input.schema, 'evidence.schema');
52
+ assertRecord(input.payload, 'evidence.payload');
53
+ for (const [key, value] of Object.entries(input.payload)) {
54
+ assertIdentifier(key, 'evidence.payload key');
55
+ if (typeof value !== 'string'
56
+ && typeof value !== 'number'
57
+ && typeof value !== 'boolean') {
58
+ throw new TypeError('evidence.payload values must be strings, numbers, or booleans.');
59
+ }
60
+ if (typeof value === 'number' && !Number.isFinite(value)) {
61
+ throw new TypeError('evidence.payload numbers must be finite.');
62
+ }
63
+ }
64
+ }
65
+ function assertRecord(input, label) {
66
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
67
+ throw new TypeError(`${label} must be an object.`);
68
+ }
69
+ }
package/dist/server.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type AnalyticsSink } from '@mpgd/analytics';
2
2
  import type { AdPlacements, ProductCatalog } from '@mpgd/catalog';
3
3
  import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesBackendTransportRequest, type GameServicesBackendTransportResponse } from './client';
4
4
  import { type GameServicesHealthResponse } from './contract';
5
+ import { type GameServicesEvidenceVerifier } from './evidence-verification';
5
6
  import { type ClaimAdRewardRequest, type ClaimAdRewardResponse, type EntitlementLedgerGrant, type EntitlementLedgerResult, type LeaderboardScoreTransaction, type ProductGrantTransaction, type RecordLeaderboardScoreRequest, type RecordLeaderboardScoreResponse, type VerifyPurchaseRequest, type VerifyPurchaseResponse } from './types';
6
7
  type CorsHeaders = Record<string, string>;
7
8
  export interface CreateGameServicesBackendInput {
@@ -12,6 +13,8 @@ export interface CreateGameServicesBackendInput {
12
13
  readonly analyticsSessionId?: string;
13
14
  readonly now?: () => string;
14
15
  readonly version?: string;
16
+ readonly evidenceVerifier?: GameServicesEvidenceVerifier;
17
+ readonly evidenceVerificationTimeoutMs?: number;
15
18
  }
16
19
  export interface GameServicesBackendApiHandler {
17
20
  readonly version?: string;
@@ -22,6 +25,16 @@ export interface GameServicesBackendErrorResponse {
22
25
  }
23
26
  export interface GameServicesStore {
24
27
  recordEntitlementGrant(input: EntitlementLedgerGrant): Promise<EntitlementLedgerResult>;
28
+ findEntitlementTransactionByIdempotency?(input: {
29
+ readonly source: EntitlementLedgerGrant['source'];
30
+ readonly playerId: string;
31
+ readonly idempotencyKey: string;
32
+ }): Promise<ProductGrantTransaction | undefined>;
33
+ findEntitlementTransactionByEvidenceVerificationId?(input: {
34
+ readonly source: EntitlementLedgerGrant['source'];
35
+ readonly evidenceVerificationId: string;
36
+ }): Promise<ProductGrantTransaction | undefined>;
37
+ findEntitlementTransactionByPlatformEvidence?(input: EntitlementPlatformEvidenceIdentity): Promise<ProductGrantTransaction | undefined>;
25
38
  getEntitlementTransaction(ledgerEntryId: string): Promise<ProductGrantTransaction | undefined>;
26
39
  listEntitlementTransactions(): Promise<readonly ProductGrantTransaction[]>;
27
40
  recordLeaderboardScore(input: RecordLeaderboardScoreRequest, options?: {
@@ -30,6 +43,11 @@ export interface GameServicesStore {
30
43
  getLeaderboardTransaction(ledgerEntryId: string): Promise<LeaderboardScoreTransaction | undefined>;
31
44
  listLeaderboardTransactions(): Promise<readonly LeaderboardScoreTransaction[]>;
32
45
  }
46
+ export interface EntitlementPlatformEvidenceIdentity {
47
+ readonly source: Extract<EntitlementLedgerGrant['source'], 'purchase' | 'ad_reward'>;
48
+ readonly target: string;
49
+ readonly platformEvidenceId: string;
50
+ }
33
51
  export interface CreateGameServicesFetchHandlerOptions {
34
52
  readonly corsHeaders?: CorsHeaders;
35
53
  readonly healthPath?: string;
@@ -43,10 +61,22 @@ export interface GameServicesOrpcContext {
43
61
  }
44
62
  export declare class InMemoryGameServicesStore implements GameServicesStore {
45
63
  private readonly entitlementTransactionsByKey;
64
+ private readonly entitlementTransactionsByEvidence;
65
+ private readonly entitlementTransactionsByPlatformEvidence;
46
66
  private readonly entitlementTransactionsById;
47
67
  private readonly leaderboardTransactionsByRun;
48
68
  private readonly leaderboardTransactionsById;
49
69
  recordEntitlementGrant(input: EntitlementLedgerGrant): Promise<EntitlementLedgerResult>;
70
+ findEntitlementTransactionByIdempotency(input: {
71
+ readonly source: EntitlementLedgerGrant['source'];
72
+ readonly playerId: string;
73
+ readonly idempotencyKey: string;
74
+ }): Promise<ProductGrantTransaction | undefined>;
75
+ findEntitlementTransactionByEvidenceVerificationId(input: {
76
+ readonly source: EntitlementLedgerGrant['source'];
77
+ readonly evidenceVerificationId: string;
78
+ }): Promise<ProductGrantTransaction | undefined>;
79
+ findEntitlementTransactionByPlatformEvidence(input: EntitlementPlatformEvidenceIdentity): Promise<ProductGrantTransaction | undefined>;
50
80
  getEntitlementTransaction(ledgerEntryId: string): Promise<ProductGrantTransaction | undefined>;
51
81
  listEntitlementTransactions(): Promise<readonly ProductGrantTransaction[]>;
52
82
  recordLeaderboardScore(input: RecordLeaderboardScoreRequest, options?: {
@@ -57,6 +87,9 @@ export declare class InMemoryGameServicesStore implements GameServicesStore {
57
87
  private rankFor;
58
88
  private sortedLeaderboardTransactions;
59
89
  }
90
+ export declare class EvidenceAlreadyProcessedError extends Error {
91
+ constructor();
92
+ }
60
93
  export declare function createInMemoryGameServicesStore(): InMemoryGameServicesStore;
61
94
  export declare function createGameServicesBackend(input: CreateGameServicesBackendInput): GameServicesBackendApi;
62
95
  export declare function createGameServicesBackendApiHandler(input: CreateGameServicesBackendInput): GameServicesBackendApiHandler;
package/dist/server.js CHANGED
@@ -3,9 +3,12 @@ import { RPCHandler } from '@orpc/server/fetch';
3
3
  import { createAnalyticsReporter } from '@mpgd/analytics';
4
4
  import { gameServicesBackendEndpoints, } from './client';
5
5
  import { gameServicesContract } from './contract';
6
+ import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification';
6
7
  import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types';
7
8
  export class InMemoryGameServicesStore {
8
9
  entitlementTransactionsByKey = new Map();
10
+ entitlementTransactionsByEvidence = new Map();
11
+ entitlementTransactionsByPlatformEvidence = new Map();
9
12
  entitlementTransactionsById = new Map();
10
13
  leaderboardTransactionsByRun = new Map();
11
14
  leaderboardTransactionsById = new Map();
@@ -19,14 +22,46 @@ export class InMemoryGameServicesStore {
19
22
  alreadyProcessed: true,
20
23
  });
21
24
  }
25
+ if (grant.evidenceVerificationId !== undefined) {
26
+ const evidenceKey = createEntitlementEvidenceKey({
27
+ source: grant.source,
28
+ evidenceVerificationId: grant.evidenceVerificationId,
29
+ });
30
+ if (this.entitlementTransactionsByEvidence.has(evidenceKey)) {
31
+ throw new EvidenceAlreadyProcessedError();
32
+ }
33
+ }
34
+ const platformEvidenceIdentity = getEntitlementPlatformEvidenceIdentity(grant);
35
+ if (platformEvidenceIdentity !== undefined
36
+ && this.entitlementTransactionsByPlatformEvidence.has(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity))) {
37
+ throw new EvidenceAlreadyProcessedError();
38
+ }
22
39
  const transaction = createEntitlementTransaction(grant);
23
40
  this.entitlementTransactionsByKey.set(key, transaction);
24
41
  this.entitlementTransactionsById.set(transaction.ledgerEntryId, transaction);
42
+ if (grant.evidenceVerificationId !== undefined) {
43
+ this.entitlementTransactionsByEvidence.set(createEntitlementEvidenceKey({
44
+ source: grant.source,
45
+ evidenceVerificationId: grant.evidenceVerificationId,
46
+ }), transaction);
47
+ }
48
+ if (platformEvidenceIdentity !== undefined) {
49
+ this.entitlementTransactionsByPlatformEvidence.set(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity), transaction);
50
+ }
25
51
  return assertEntitlementLedgerResult({
26
52
  ledgerEntryId: transaction.ledgerEntryId,
27
53
  alreadyProcessed: false,
28
54
  });
29
55
  }
56
+ async findEntitlementTransactionByIdempotency(input) {
57
+ return this.entitlementTransactionsByKey.get(createEntitlementIdempotencyKey(input));
58
+ }
59
+ async findEntitlementTransactionByEvidenceVerificationId(input) {
60
+ return this.entitlementTransactionsByEvidence.get(createEntitlementEvidenceKey(input));
61
+ }
62
+ async findEntitlementTransactionByPlatformEvidence(input) {
63
+ return this.entitlementTransactionsByPlatformEvidence.get(createEntitlementPlatformEvidenceKey(input));
64
+ }
30
65
  async getEntitlementTransaction(ledgerEntryId) {
31
66
  return this.entitlementTransactionsById.get(ledgerEntryId);
32
67
  }
@@ -79,6 +114,12 @@ export class InMemoryGameServicesStore {
79
114
  });
80
115
  }
81
116
  }
117
+ export class EvidenceAlreadyProcessedError extends Error {
118
+ constructor() {
119
+ super('Evidence verification identity has already been processed.');
120
+ this.name = 'EvidenceAlreadyProcessedError';
121
+ }
122
+ }
82
123
  export function createInMemoryGameServicesStore() {
83
124
  return new InMemoryGameServicesStore();
84
125
  }
@@ -86,6 +127,8 @@ export function createGameServicesBackend(input) {
86
127
  const store = input.store ?? createInMemoryGameServicesStore();
87
128
  const now = input.now ?? (() => new Date().toISOString());
88
129
  const version = input.version ?? '0.0.0';
130
+ const evidenceVerifier = input.evidenceVerifier ?? createRejectingGameServicesEvidenceVerifier();
131
+ const evidenceVerificationTimeoutMs = resolveEvidenceVerificationTimeout(input.evidenceVerificationTimeoutMs);
89
132
  const analytics = createAnalyticsReporter({
90
133
  target: 'server',
91
134
  sessionId: input.analyticsSessionId ?? 'game-services',
@@ -100,6 +143,8 @@ export function createGameServicesBackend(input) {
100
143
  catalog: input.catalog,
101
144
  store,
102
145
  now,
146
+ evidenceVerifier,
147
+ evidenceVerificationTimeoutMs,
103
148
  });
104
149
  await analytics.track({
105
150
  name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
@@ -121,6 +166,8 @@ export function createGameServicesBackend(input) {
121
166
  placements: input.placements,
122
167
  store,
123
168
  now,
169
+ evidenceVerifier,
170
+ evidenceVerificationTimeoutMs,
124
171
  });
125
172
  await analytics.track({
126
173
  name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
@@ -259,6 +306,28 @@ export function createGameServicesHttpFetchHandler(handler, options = {}) {
259
306
  }
260
307
  async function verifyPurchaseWithStore(input, context) {
261
308
  const request = assertVerifyPurchaseRequest(input);
309
+ const retryIdentity = {
310
+ source: 'purchase',
311
+ playerId: request.playerId,
312
+ idempotencyKey: request.idempotencyKey,
313
+ grantId: request.productId,
314
+ target: request.target,
315
+ };
316
+ const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
317
+ if (existing !== undefined) {
318
+ if (!matchesEntitlementRetry(existing, retryIdentity)) {
319
+ return assertVerifyPurchaseResponse({
320
+ verified: false,
321
+ alreadyProcessed: false,
322
+ reason: 'IDEMPOTENCY_KEY_CONFLICT',
323
+ });
324
+ }
325
+ return assertVerifyPurchaseResponse({
326
+ verified: true,
327
+ ledgerEntryId: existing.ledgerEntryId,
328
+ alreadyProcessed: true,
329
+ });
330
+ }
262
331
  const product = context.catalog.products.find((entry) => entry.id === request.productId);
263
332
  if (product === undefined) {
264
333
  return assertVerifyPurchaseResponse({
@@ -275,29 +344,88 @@ async function verifyPurchaseWithStore(input, context) {
275
344
  reason: 'PRODUCT_NOT_AVAILABLE_ON_TARGET',
276
345
  });
277
346
  }
278
- const grant = await context.store.recordEntitlementGrant({
347
+ const verification = await verifyEvidence((signal) => {
348
+ return context.evidenceVerifier.verifyPurchase({
349
+ request,
350
+ product,
351
+ platformProductId,
352
+ signal,
353
+ timeoutMs: context.evidenceVerificationTimeoutMs,
354
+ });
355
+ }, context.evidenceVerificationTimeoutMs);
356
+ if (verification.status !== 'verified') {
357
+ return assertVerifyPurchaseResponse({
358
+ verified: false,
359
+ alreadyProcessed: false,
360
+ reason: verification.status === 'pending'
361
+ ? (verification.reason ?? 'EVIDENCE_PENDING')
362
+ : verification.reason,
363
+ });
364
+ }
365
+ const grant = await recordEntitlementGrantWithEvidence(context.store, {
279
366
  playerId: request.playerId,
280
367
  grantId: product.id,
281
368
  source: 'purchase',
282
369
  idempotencyKey: request.idempotencyKey,
283
370
  grantedAt: context.now(),
284
371
  grant: product.grant,
372
+ evidenceVerificationId: verification.verificationId,
285
373
  payload: {
374
+ ...verification.payload,
286
375
  target: request.target,
287
376
  productId: request.productId,
288
377
  platformProductId,
289
378
  platformTransactionId: request.platformTransactionId,
290
379
  purchasedAt: request.purchasedAt,
380
+ evidenceVerificationId: verification.verificationId,
381
+ evidenceVerifiedAt: verification.verifiedAt,
291
382
  },
292
383
  });
384
+ if (grant.status === 'evidence_already_processed') {
385
+ return assertVerifyPurchaseResponse({
386
+ verified: false,
387
+ alreadyProcessed: false,
388
+ reason: 'EVIDENCE_ALREADY_PROCESSED',
389
+ });
390
+ }
391
+ if (grant.result.alreadyProcessed
392
+ && !await recordedEntitlementMatchesRetry(context.store, grant.result, retryIdentity)) {
393
+ return assertVerifyPurchaseResponse({
394
+ verified: false,
395
+ alreadyProcessed: false,
396
+ reason: 'IDEMPOTENCY_KEY_CONFLICT',
397
+ });
398
+ }
293
399
  return assertVerifyPurchaseResponse({
294
400
  verified: true,
295
- ledgerEntryId: grant.ledgerEntryId,
296
- alreadyProcessed: grant.alreadyProcessed,
401
+ ledgerEntryId: grant.result.ledgerEntryId,
402
+ alreadyProcessed: grant.result.alreadyProcessed,
297
403
  });
298
404
  }
299
405
  async function claimAdRewardWithStore(input, context) {
300
406
  const request = assertClaimAdRewardRequest(input);
407
+ const retryIdentity = {
408
+ source: 'ad_reward',
409
+ playerId: request.playerId,
410
+ idempotencyKey: request.idempotencyKey,
411
+ grantId: request.placementId,
412
+ target: request.target,
413
+ };
414
+ const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
415
+ if (existing !== undefined) {
416
+ if (!matchesEntitlementRetry(existing, retryIdentity)) {
417
+ return assertClaimAdRewardResponse({
418
+ granted: false,
419
+ alreadyProcessed: false,
420
+ reason: 'IDEMPOTENCY_KEY_CONFLICT',
421
+ });
422
+ }
423
+ return assertClaimAdRewardResponse({
424
+ granted: true,
425
+ ledgerEntryId: existing.ledgerEntryId,
426
+ alreadyProcessed: true,
427
+ });
428
+ }
301
429
  const placement = context.placements.placements.find((entry) => entry.id === request.placementId);
302
430
  if (placement === undefined || placement.type !== 'rewarded' || placement.reward === undefined) {
303
431
  return assertClaimAdRewardResponse({
@@ -306,12 +434,34 @@ async function claimAdRewardWithStore(input, context) {
306
434
  reason: placement === undefined ? 'UNKNOWN_PLACEMENT' : 'NOT_REWARDED_PLACEMENT',
307
435
  });
308
436
  }
437
+ const platformPlacementId = placement.platformPlacementIds[request.target];
438
+ const verification = await verifyEvidence((signal) => {
439
+ return context.evidenceVerifier.verifyAdReward({
440
+ request,
441
+ placement,
442
+ ...(platformPlacementId === undefined ? {} : { platformPlacementId }),
443
+ signal,
444
+ timeoutMs: context.evidenceVerificationTimeoutMs,
445
+ });
446
+ }, context.evidenceVerificationTimeoutMs);
447
+ if (verification.status !== 'verified') {
448
+ return assertClaimAdRewardResponse({
449
+ granted: false,
450
+ alreadyProcessed: false,
451
+ reason: verification.status === 'pending'
452
+ ? (verification.reason ?? 'EVIDENCE_PENDING')
453
+ : verification.reason,
454
+ });
455
+ }
309
456
  const payload = {
457
+ ...verification.payload,
310
458
  target: request.target,
311
459
  placementId: request.placementId,
312
460
  rewardType: placement.reward.type,
313
461
  amount: placement.reward.amount,
314
462
  completedAt: request.completedAt,
463
+ evidenceVerificationId: verification.verificationId,
464
+ evidenceVerifiedAt: verification.verifiedAt,
315
465
  };
316
466
  if (placement.reward.type === 'currency') {
317
467
  payload.currency = placement.reward.currency;
@@ -319,20 +469,281 @@ async function claimAdRewardWithStore(input, context) {
319
469
  if (request.platformImpressionId !== undefined) {
320
470
  payload.platformImpressionId = request.platformImpressionId;
321
471
  }
322
- const result = await context.store.recordEntitlementGrant({
472
+ const result = await recordEntitlementGrantWithEvidence(context.store, {
323
473
  playerId: request.playerId,
324
474
  grantId: request.placementId,
325
475
  source: 'ad_reward',
326
476
  idempotencyKey: request.idempotencyKey,
327
477
  grantedAt: context.now(),
478
+ evidenceVerificationId: verification.verificationId,
328
479
  payload,
329
480
  });
481
+ if (result.status === 'evidence_already_processed') {
482
+ return assertClaimAdRewardResponse({
483
+ granted: false,
484
+ alreadyProcessed: false,
485
+ reason: 'EVIDENCE_ALREADY_PROCESSED',
486
+ });
487
+ }
488
+ if (result.result.alreadyProcessed
489
+ && !await recordedEntitlementMatchesRetry(context.store, result.result, retryIdentity)) {
490
+ return assertClaimAdRewardResponse({
491
+ granted: false,
492
+ alreadyProcessed: false,
493
+ reason: 'IDEMPOTENCY_KEY_CONFLICT',
494
+ });
495
+ }
330
496
  return assertClaimAdRewardResponse({
331
497
  granted: true,
332
- ledgerEntryId: result.ledgerEntryId,
333
- alreadyProcessed: result.alreadyProcessed,
498
+ ledgerEntryId: result.result.ledgerEntryId,
499
+ alreadyProcessed: result.result.alreadyProcessed,
334
500
  });
335
501
  }
502
+ async function verifyEvidence(verify, timeoutMs) {
503
+ const controller = new AbortController();
504
+ const timeoutDecision = {
505
+ status: 'rejected',
506
+ reason: 'EVIDENCE_VERIFIER_TIMEOUT',
507
+ };
508
+ let timedOut = false;
509
+ let timeout;
510
+ try {
511
+ return await Promise.race([
512
+ verify(controller.signal)
513
+ .then(assertEvidenceVerificationDecision)
514
+ .catch((error) => {
515
+ if (timedOut) {
516
+ return timeoutDecision;
517
+ }
518
+ throw error;
519
+ }),
520
+ new Promise((resolve) => {
521
+ timeout = setTimeout(() => {
522
+ timedOut = true;
523
+ resolve(timeoutDecision);
524
+ controller.abort();
525
+ }, timeoutMs);
526
+ }),
527
+ ]);
528
+ }
529
+ catch {
530
+ return {
531
+ status: 'rejected',
532
+ reason: 'EVIDENCE_VERIFIER_ERROR',
533
+ };
534
+ }
535
+ finally {
536
+ if (timeout !== undefined) {
537
+ clearTimeout(timeout);
538
+ }
539
+ }
540
+ }
541
+ function assertEvidenceVerificationDecision(decision) {
542
+ if (decision.status === 'verified') {
543
+ assertNonEmptyDecisionString(decision.verificationId, 'verificationId');
544
+ assertNonEmptyDecisionString(decision.verifiedAt, 'verifiedAt');
545
+ if (!Number.isFinite(Date.parse(decision.verifiedAt))) {
546
+ throw new Error('Evidence verification verifiedAt must be a valid timestamp.');
547
+ }
548
+ if (decision.payload !== undefined) {
549
+ if (typeof decision.payload !== 'object'
550
+ || decision.payload === null
551
+ || Array.isArray(decision.payload)) {
552
+ throw new Error('Evidence verification payload must be a record.');
553
+ }
554
+ for (const [key, value] of Object.entries(decision.payload)) {
555
+ if (typeof value !== 'string'
556
+ && (typeof value !== 'number' || !Number.isFinite(value))
557
+ && typeof value !== 'boolean') {
558
+ throw new Error(`Evidence verification payload.${key} must be primitive.`);
559
+ }
560
+ }
561
+ }
562
+ return decision;
563
+ }
564
+ if (decision.status === 'pending') {
565
+ if (decision.reason !== undefined) {
566
+ assertNonEmptyDecisionString(decision.reason, 'reason');
567
+ }
568
+ return decision;
569
+ }
570
+ if (decision.status === 'rejected') {
571
+ assertNonEmptyDecisionString(decision.reason, 'reason');
572
+ return decision;
573
+ }
574
+ throw new Error('Unknown evidence verification status.');
575
+ }
576
+ async function recordEntitlementGrantWithEvidence(store, grant) {
577
+ const evidenceVerificationId = grant.evidenceVerificationId;
578
+ if (evidenceVerificationId !== undefined) {
579
+ const evidenceIdentity = {
580
+ source: grant.source,
581
+ evidenceVerificationId,
582
+ };
583
+ const platformEvidenceIdentity = getEntitlementPlatformEvidenceIdentity(grant);
584
+ const lockKeys = [createEntitlementEvidenceKey(evidenceIdentity)];
585
+ if (platformEvidenceIdentity !== undefined) {
586
+ lockKeys.push(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity));
587
+ }
588
+ return withEntitlementEvidenceLocks(store, lockKeys, async () => {
589
+ const authorityMatch = await findEntitlementTransactionByEvidenceVerificationId(store, {
590
+ source: grant.source,
591
+ evidenceVerificationId,
592
+ });
593
+ const existing = authorityMatch ?? (platformEvidenceIdentity === undefined
594
+ ? undefined
595
+ : await findEntitlementTransactionByPlatformEvidence(store, platformEvidenceIdentity));
596
+ if (existing !== undefined) {
597
+ if (existing.source === grant.source
598
+ && existing.playerId === grant.playerId
599
+ && existing.idempotencyKey === grant.idempotencyKey) {
600
+ return {
601
+ status: 'recorded',
602
+ result: assertEntitlementLedgerResult({
603
+ ledgerEntryId: existing.ledgerEntryId,
604
+ alreadyProcessed: true,
605
+ }),
606
+ };
607
+ }
608
+ return { status: 'evidence_already_processed' };
609
+ }
610
+ return recordEntitlementGrantUnchecked(store, grant);
611
+ });
612
+ }
613
+ return recordEntitlementGrantUnchecked(store, grant);
614
+ }
615
+ async function recordEntitlementGrantUnchecked(store, grant) {
616
+ try {
617
+ return {
618
+ status: 'recorded',
619
+ result: await store.recordEntitlementGrant(grant),
620
+ };
621
+ }
622
+ catch (error) {
623
+ if (error instanceof EvidenceAlreadyProcessedError) {
624
+ return { status: 'evidence_already_processed' };
625
+ }
626
+ throw error;
627
+ }
628
+ }
629
+ const entitlementEvidenceLocks = new WeakMap();
630
+ async function withEntitlementEvidenceLocks(store, lockKeys, task) {
631
+ const orderedLockKeys = [...new Set(lockKeys)].sort((left, right) => left.localeCompare(right));
632
+ async function runWithLock(index) {
633
+ const lockKey = orderedLockKeys[index];
634
+ return lockKey === undefined
635
+ ? task()
636
+ : withEntitlementEvidenceLockKey(store, lockKey, () => runWithLock(index + 1));
637
+ }
638
+ return runWithLock(0);
639
+ }
640
+ async function withEntitlementEvidenceLockKey(store, lockKey, task) {
641
+ const storeLocks = entitlementEvidenceLocks.get(store) ?? new Map();
642
+ entitlementEvidenceLocks.set(store, storeLocks);
643
+ const previous = storeLocks.get(lockKey) ?? Promise.resolve();
644
+ const gate = createPromiseGate();
645
+ const current = previous.then(() => gate.promise);
646
+ storeLocks.set(lockKey, current);
647
+ await previous;
648
+ try {
649
+ return await task();
650
+ }
651
+ finally {
652
+ gate.release();
653
+ if (storeLocks.get(lockKey) === current) {
654
+ storeLocks.delete(lockKey);
655
+ if (storeLocks.size === 0) {
656
+ entitlementEvidenceLocks.delete(store);
657
+ }
658
+ }
659
+ }
660
+ }
661
+ function createPromiseGate() {
662
+ let release = () => { };
663
+ const promise = new Promise((resolve) => {
664
+ release = resolve;
665
+ });
666
+ return { promise, release };
667
+ }
668
+ async function findEntitlementTransactionByIdempotency(store, identity) {
669
+ if (store.findEntitlementTransactionByIdempotency !== undefined) {
670
+ return store.findEntitlementTransactionByIdempotency(identity);
671
+ }
672
+ const transactions = await store.listEntitlementTransactions();
673
+ return transactions.find((transaction) => {
674
+ return transaction.source === identity.source
675
+ && transaction.playerId === identity.playerId
676
+ && transaction.idempotencyKey === identity.idempotencyKey;
677
+ });
678
+ }
679
+ async function findEntitlementTransactionByEvidenceVerificationId(store, input) {
680
+ if (store.findEntitlementTransactionByEvidenceVerificationId !== undefined) {
681
+ return store.findEntitlementTransactionByEvidenceVerificationId(input);
682
+ }
683
+ const transactions = await store.listEntitlementTransactions();
684
+ return transactions.find((transaction) => {
685
+ const storedEvidenceVerificationId = transaction.evidenceVerificationId
686
+ ?? transaction.payload.evidenceVerificationId;
687
+ return transaction.source === input.source
688
+ && storedEvidenceVerificationId === input.evidenceVerificationId;
689
+ });
690
+ }
691
+ async function findEntitlementTransactionByPlatformEvidence(store, input) {
692
+ if (store.findEntitlementTransactionByPlatformEvidence !== undefined) {
693
+ return store.findEntitlementTransactionByPlatformEvidence(input);
694
+ }
695
+ const transactions = await store.listEntitlementTransactions();
696
+ return transactions.find((transaction) => {
697
+ const identity = getEntitlementPlatformEvidenceIdentity(transaction);
698
+ return identity !== undefined
699
+ && identity.source === input.source
700
+ && identity.target === input.target
701
+ && identity.platformEvidenceId === input.platformEvidenceId;
702
+ });
703
+ }
704
+ function getEntitlementPlatformEvidenceIdentity(transaction) {
705
+ if (transaction.source !== 'purchase' && transaction.source !== 'ad_reward') {
706
+ return undefined;
707
+ }
708
+ const target = transaction.payload.target;
709
+ const platformEvidenceId = transaction.source === 'purchase'
710
+ ? transaction.payload.platformTransactionId
711
+ : transaction.payload.platformImpressionId;
712
+ if (typeof target !== 'string'
713
+ || target.length === 0
714
+ || typeof platformEvidenceId !== 'string'
715
+ || platformEvidenceId.length === 0) {
716
+ return undefined;
717
+ }
718
+ return {
719
+ source: transaction.source,
720
+ target,
721
+ platformEvidenceId,
722
+ };
723
+ }
724
+ async function recordedEntitlementMatchesRetry(store, result, identity) {
725
+ const transaction = await store.getEntitlementTransaction(result.ledgerEntryId);
726
+ return transaction !== undefined && matchesEntitlementRetry(transaction, identity);
727
+ }
728
+ function matchesEntitlementRetry(transaction, identity) {
729
+ return transaction.source === identity.source
730
+ && transaction.playerId === identity.playerId
731
+ && transaction.idempotencyKey === identity.idempotencyKey
732
+ && transaction.grantId === identity.grantId
733
+ && transaction.payload.target === identity.target;
734
+ }
735
+ function resolveEvidenceVerificationTimeout(timeoutMs) {
736
+ const resolved = timeoutMs ?? 10000;
737
+ if (!Number.isFinite(resolved) || resolved <= 0) {
738
+ throw new Error('evidenceVerificationTimeoutMs must be a positive finite number.');
739
+ }
740
+ return resolved;
741
+ }
742
+ function assertNonEmptyDecisionString(value, label) {
743
+ if (typeof value !== 'string' || value.length === 0) {
744
+ throw new Error(`Evidence verification ${label} must be a non-empty string.`);
745
+ }
746
+ }
336
747
  async function recordLeaderboardScoreWithStore(input, context) {
337
748
  return context.store.recordLeaderboardScore(assertRecordLeaderboardScoreRequest(input), {
338
749
  recordedAt: context.now(),
@@ -379,12 +790,21 @@ function createEntitlementTransaction(grant) {
379
790
  idempotencyKey: grant.idempotencyKey,
380
791
  grantedAt: grant.grantedAt,
381
792
  payload: grant.payload,
793
+ ...(grant.evidenceVerificationId === undefined
794
+ ? {}
795
+ : { evidenceVerificationId: grant.evidenceVerificationId }),
382
796
  };
383
797
  return assertProductGrantTransaction(grant.grant === undefined ? baseTransaction : { ...baseTransaction, grant: grant.grant });
384
798
  }
385
799
  function createEntitlementIdempotencyKey(grant) {
386
800
  return createCompositeKey([grant.source, grant.playerId, grant.idempotencyKey]);
387
801
  }
802
+ function createEntitlementEvidenceKey(grant) {
803
+ return createCompositeKey([grant.source, grant.evidenceVerificationId]);
804
+ }
805
+ function createEntitlementPlatformEvidenceKey(identity) {
806
+ return createCompositeKey([identity.source, identity.target, identity.platformEvidenceId]);
807
+ }
388
808
  function createEntitlementLedgerEntryId(grant) {
389
809
  return [
390
810
  'ledger',
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ProductGrant } from '@mpgd/catalog';
2
- import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformTarget } from '@mpgd/platform';
2
+ import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformEvidenceEnvelope, PlatformTarget } from '@mpgd/platform';
3
+ export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
3
4
  export type GameServicesStoreTarget = Extract<PlatformTarget, 'android' | 'ios' | 'ait'>;
4
5
  export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'android' | 'ios' | 'ait' | 'reddit'>;
5
6
  export type PurchaseIdempotencyKey = string;
@@ -14,6 +15,7 @@ export interface VerifyPurchaseRequest {
14
15
  readonly platformTransactionId: string;
15
16
  readonly idempotencyKey: PurchaseIdempotencyKey;
16
17
  readonly purchasedAt: string;
18
+ readonly evidence?: PlatformEvidenceEnvelope;
17
19
  }
18
20
  export interface VerifyPurchaseResponse {
19
21
  readonly verified: boolean;
@@ -28,12 +30,13 @@ export interface ClaimAdRewardRequest {
28
30
  readonly platformImpressionId?: string;
29
31
  readonly idempotencyKey: AdRewardIdempotencyKey;
30
32
  readonly completedAt: string;
33
+ readonly evidence?: PlatformEvidenceEnvelope;
31
34
  }
32
35
  export interface ClaimAdRewardResponse {
33
36
  readonly granted: boolean;
34
37
  readonly ledgerEntryId?: string;
35
38
  readonly alreadyProcessed: boolean;
36
- readonly reason?: 'UNKNOWN_PLACEMENT' | 'NOT_REWARDED_PLACEMENT';
39
+ readonly reason?: string;
37
40
  }
38
41
  export interface RecordLeaderboardScoreRequest extends LeaderboardScoreInput {
39
42
  readonly target: GameServicesLedgerTarget;
@@ -54,6 +57,7 @@ export interface EntitlementLedgerGrant {
54
57
  readonly grantedAt: string;
55
58
  readonly grant?: ProductGrant;
56
59
  readonly payload: EntitlementLedgerPayload;
60
+ readonly evidenceVerificationId?: string;
57
61
  }
58
62
  export interface EntitlementLedgerResult {
59
63
  readonly ledgerEntryId: string;
@@ -68,6 +72,7 @@ export interface ProductGrantTransaction {
68
72
  readonly grantedAt: string;
69
73
  readonly grant?: ProductGrant;
70
74
  readonly payload: EntitlementLedgerPayload;
75
+ readonly evidenceVerificationId?: string;
71
76
  }
72
77
  export interface LeaderboardScoreTransaction extends RecordLeaderboardScoreRequest {
73
78
  readonly ledgerEntryId: string;
package/dist/types.js CHANGED
@@ -6,6 +6,7 @@ export function assertVerifyPurchaseRequest(input) {
6
6
  assertNonEmptyString(input.platformTransactionId, 'platformTransactionId');
7
7
  assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
8
8
  assertNonEmptyString(input.purchasedAt, 'purchasedAt');
9
+ assertOptionalEvidenceEnvelope(input.evidence);
9
10
  return input;
10
11
  }
11
12
  export function assertVerifyPurchaseResponse(input) {
@@ -24,6 +25,7 @@ export function assertClaimAdRewardRequest(input) {
24
25
  assertOptionalNonEmptyString(input.platformImpressionId, 'platformImpressionId');
25
26
  assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
26
27
  assertNonEmptyString(input.completedAt, 'completedAt');
28
+ assertOptionalEvidenceEnvelope(input.evidence);
27
29
  return input;
28
30
  }
29
31
  export function assertClaimAdRewardResponse(input) {
@@ -31,7 +33,7 @@ export function assertClaimAdRewardResponse(input) {
31
33
  assertBoolean(input.granted, 'granted');
32
34
  assertOptionalNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
33
35
  assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
34
- assertOptionalAdRewardReason(input.reason, 'reason');
36
+ assertOptionalNonEmptyString(input.reason, 'reason');
35
37
  return input;
36
38
  }
37
39
  export function assertRecordLeaderboardScoreRequest(input) {
@@ -64,6 +66,7 @@ export function assertEntitlementLedgerGrant(input) {
64
66
  assertProductGrant(input.grant);
65
67
  }
66
68
  assertPayload(input.payload);
69
+ assertOptionalNonEmptyString(input.evidenceVerificationId, 'evidenceVerificationId');
67
70
  return input;
68
71
  }
69
72
  export function assertEntitlementLedgerResult(input) {
@@ -84,6 +87,7 @@ export function assertProductGrantTransaction(input) {
84
87
  assertProductGrant(input.grant);
85
88
  }
86
89
  assertPayload(input.payload);
90
+ assertOptionalNonEmptyString(input.evidenceVerificationId, 'evidenceVerificationId');
87
91
  return input;
88
92
  }
89
93
  export function assertLeaderboardScoreTransaction(input) {
@@ -151,13 +155,6 @@ function assertProductGrant(input) {
151
155
  }
152
156
  throw new Error('grant.type must be currency or entitlement.');
153
157
  }
154
- function assertOptionalAdRewardReason(input, label) {
155
- if (input !== undefined
156
- && input !== 'UNKNOWN_PLACEMENT'
157
- && input !== 'NOT_REWARDED_PLACEMENT') {
158
- throw new Error(`${label} must be UNKNOWN_PLACEMENT or NOT_REWARDED_PLACEMENT.`);
159
- }
160
- }
161
158
  function assertPayload(input) {
162
159
  assertRecord(input, 'payload');
163
160
  for (const [key, value] of Object.entries(input)) {
@@ -167,3 +164,11 @@ function assertPayload(input) {
167
164
  }
168
165
  }
169
166
  }
167
+ function assertOptionalEvidenceEnvelope(input) {
168
+ if (input === undefined) {
169
+ return;
170
+ }
171
+ assertRecord(input, 'evidence');
172
+ assertNonEmptyString(input.schema, 'evidence.schema');
173
+ assertPayload(input.payload);
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,10 +37,18 @@
37
37
  "types": "./dist/contract.d.ts",
38
38
  "default": "./dist/contract.js"
39
39
  },
40
+ "./evidence-verification": {
41
+ "types": "./dist/evidence-verification.d.ts",
42
+ "default": "./dist/evidence-verification.js"
43
+ },
40
44
  "./notification-delivery": {
41
45
  "types": "./dist/notification-delivery.d.ts",
42
46
  "default": "./dist/notification-delivery.js"
43
47
  },
48
+ "./platform-order": {
49
+ "types": "./dist/platform-order.d.ts",
50
+ "default": "./dist/platform-order.js"
51
+ },
44
52
  "./progress-link": {
45
53
  "types": "./dist/progress-link.d.ts",
46
54
  "default": "./dist/progress-link.js"
@@ -77,9 +85,9 @@
77
85
  "@orpc/client": "2.0.0-beta.14",
78
86
  "@orpc/contract": "2.0.0-beta.14",
79
87
  "@orpc/server": "2.0.0-beta.14",
80
- "@mpgd/analytics": "0.3.5",
81
- "@mpgd/catalog": "0.3.5",
82
- "@mpgd/platform": "0.5.1"
88
+ "@mpgd/analytics": "0.3.6",
89
+ "@mpgd/catalog": "0.4.0",
90
+ "@mpgd/platform": "0.6.0"
83
91
  },
84
92
  "devDependencies": {
85
93
  "ttsc": "0.18.4",
@@ -95,6 +103,6 @@
95
103
  "lint": "ttsc --noEmit",
96
104
  "format": "ttsc format",
97
105
  "fix": "ttsc fix",
98
- "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/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"
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"
99
107
  }
100
108
  }