@mpgd/game-services 0.16.0 → 0.17.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.
@@ -1,6 +1,8 @@
1
1
  /** Shared response shapes with no schema, DOM or transport dependencies. */
2
2
  export interface VerifyPurchaseResponse {
3
3
  readonly verified: boolean;
4
+ /** Explicit non-grant decision; older servers may omit this field. */
5
+ readonly disposition?: 'pending' | 'rejected';
4
6
  readonly ledgerEntryId?: string;
5
7
  readonly alreadyProcessed: boolean;
6
8
  readonly reason?: string;
@@ -15,6 +17,8 @@ export interface PurchaseGrantFinalization {
15
17
  }
16
18
  export interface ClaimAdRewardResponse {
17
19
  readonly granted: boolean;
20
+ /** Explicit non-grant decision; older servers may omit this field. */
21
+ readonly disposition?: 'pending' | 'rejected';
18
22
  readonly ledgerEntryId?: string;
19
23
  readonly alreadyProcessed: boolean;
20
24
  readonly reason?: string;
@@ -23,7 +23,7 @@ export interface GameServicesRewardedAdInput {
23
23
  readonly idempotencyKey: string;
24
24
  }
25
25
  export interface GameServicesRewardedAdResult {
26
- readonly status: 'granted' | 'skipped' | 'unavailable' | 'failed' | 'rejected';
26
+ readonly status: 'granted' | 'pending' | 'skipped' | 'unavailable' | 'failed' | 'rejected';
27
27
  readonly reward: RewardedAdResult;
28
28
  readonly claim?: ClaimAdRewardResponse;
29
29
  readonly ledgerEntryId?: string;
package/dist/runtime.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { AnalyticsSink } from '@mpgd/analytics';
2
2
  import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
3
3
  import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
4
4
  import type { GameServicesLedgerTarget } from './types.js';
5
+ import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
5
6
  export type GameServicesAuthorityMode = 'production' | 'non-production';
6
7
  export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
7
8
  export type GameServicesDisabledReason = 'unsupported_target' | 'missing_authoritative_backend' | 'invalid_authoritative_backend' | 'local_backend_not_allowed' | 'local_backend_unavailable';
@@ -11,6 +12,7 @@ export interface GameServicesRuntime {
11
12
  readonly baseUrl?: string;
12
13
  readonly target?: GameServicesLedgerTarget;
13
14
  readonly client?: GameServicesClient;
15
+ readonly monetizationRecovery?: RecoverableMonetizationClient;
14
16
  }
15
17
  interface CreateGameServicesRuntimeCommonInput {
16
18
  readonly gateway: PlatformGateway;
@@ -28,6 +30,8 @@ interface CreateGameServicesRuntimeCommonInput {
28
30
  readonly analytics?: AnalyticsSink;
29
31
  readonly analyticsSessionId?: string;
30
32
  readonly now?: () => string;
33
+ /** Durable, compare-and-swap journal for purchase and rewarded-ad recovery. */
34
+ readonly operationStore?: MonetizationOperationStore;
31
35
  }
32
36
  export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
33
37
  readonly transport?: 'http';
package/dist/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, resolveGameServicesRequestHeaders, } from './client.js';
2
+ import { createRecoverableMonetizationClient, } from './monetization-recovery.js';
2
3
  export function createGameServicesRuntime(input) {
3
4
  assertAuthorityMode(input.authorityMode);
4
5
  assertTransport(input.transport);
@@ -47,24 +48,38 @@ export function createGameServicesRuntime(input) {
47
48
  mode = 'local';
48
49
  backend = input.localBackend;
49
50
  }
51
+ const clientInput = {
52
+ gateway: input.gateway,
53
+ backend,
54
+ playerId: input.playerId,
55
+ target,
56
+ ...(input.deploymentTarget === undefined
57
+ ? {}
58
+ : { deploymentTarget: input.deploymentTarget }),
59
+ ...(input.analytics === undefined ? {} : { analytics: input.analytics }),
60
+ ...(input.analyticsSessionId === undefined
61
+ ? {}
62
+ : { analyticsSessionId: input.analyticsSessionId }),
63
+ ...(input.now === undefined ? {} : { now: input.now }),
64
+ };
65
+ const client = createGameServicesClient(clientInput);
66
+ const monetizationRecovery = input.operationStore === undefined
67
+ ? undefined
68
+ : createRecoverableMonetizationClient({ ...clientInput, operationStore: input.operationStore });
69
+ let routedClient = client;
70
+ if (monetizationRecovery !== undefined) {
71
+ routedClient = {
72
+ ...client,
73
+ purchase: monetizationRecovery.purchase,
74
+ claimRewardedAd: monetizationRecovery.claimRewardedAd,
75
+ };
76
+ }
50
77
  return {
51
78
  mode,
52
79
  ...(baseUrl === undefined ? {} : { baseUrl }),
53
80
  target,
54
- client: createGameServicesClient({
55
- gateway: input.gateway,
56
- backend,
57
- playerId: input.playerId,
58
- target,
59
- ...(input.deploymentTarget === undefined
60
- ? {}
61
- : { deploymentTarget: input.deploymentTarget }),
62
- ...(input.analytics === undefined ? {} : { analytics: input.analytics }),
63
- ...(input.analyticsSessionId === undefined
64
- ? {}
65
- : { analyticsSessionId: input.analyticsSessionId }),
66
- ...(input.now === undefined ? {} : { now: input.now }),
67
- }),
81
+ client: routedClient,
82
+ ...(monetizationRecovery === undefined ? {} : { monetizationRecovery }),
68
83
  };
69
84
  }
70
85
  function withRuntimeHeaders(transport, input) {
package/dist/server.js CHANGED
@@ -142,7 +142,7 @@ export function createGameServicesBackend(input) {
142
142
  version,
143
143
  purchases: {
144
144
  async verifyPurchase(request) {
145
- const verification = await verifyPurchaseWithStore(request, {
145
+ return verifyPurchaseWithStore(request, {
146
146
  catalog: input.catalog,
147
147
  store,
148
148
  now,
@@ -153,47 +153,49 @@ export function createGameServicesBackend(input) {
153
153
  ...(input.purchaseGrantFinalizer === undefined
154
154
  ? {}
155
155
  : { purchaseGrantFinalizer: input.purchaseGrantFinalizer }),
156
+ }, async (verification) => {
157
+ await analytics.track({
158
+ name: verification.verified ? 'purchase_granted'
159
+ : verification.disposition === 'pending' ? 'purchase_pending' : 'purchase_rejected',
160
+ properties: {
161
+ target: request.target,
162
+ playerId: request.playerId,
163
+ productId: request.productId,
164
+ ledgerEntryId: verification.ledgerEntryId,
165
+ alreadyProcessed: verification.alreadyProcessed,
166
+ reason: verification.reason,
167
+ finalizationStatus: verification.finalization?.status,
168
+ finalizationAction: verification.finalization?.action,
169
+ finalizationAlreadyCompleted: verification.finalization?.alreadyCompleted,
170
+ finalizationReason: verification.finalization?.reason,
171
+ },
172
+ });
156
173
  });
157
- await analytics.track({
158
- name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
159
- properties: {
160
- target: request.target,
161
- playerId: request.playerId,
162
- productId: request.productId,
163
- ledgerEntryId: verification.ledgerEntryId,
164
- alreadyProcessed: verification.alreadyProcessed,
165
- reason: verification.reason,
166
- finalizationStatus: verification.finalization?.status,
167
- finalizationAction: verification.finalization?.action,
168
- finalizationAlreadyCompleted: verification.finalization?.alreadyCompleted,
169
- finalizationReason: verification.finalization?.reason,
170
- },
171
- });
172
- return verification;
173
174
  },
174
175
  },
175
176
  adRewards: {
176
177
  async claimAdReward(request) {
177
- const claim = await claimAdRewardWithStore(request, {
178
+ return claimAdRewardWithStore(request, {
178
179
  placements: input.placements,
179
180
  store,
180
181
  now,
181
182
  evidenceVerifier,
182
183
  evidenceVerificationTimeoutMs,
183
184
  deploymentTargetBindings,
185
+ }, async (claim) => {
186
+ await analytics.track({
187
+ name: claim.granted ? 'rewarded_ad_granted'
188
+ : claim.disposition === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
189
+ properties: {
190
+ target: request.target,
191
+ playerId: request.playerId,
192
+ placementId: request.placementId,
193
+ ledgerEntryId: claim.ledgerEntryId,
194
+ alreadyProcessed: claim.alreadyProcessed,
195
+ reason: claim.reason,
196
+ },
197
+ });
184
198
  });
185
- await analytics.track({
186
- name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
187
- properties: {
188
- target: request.target,
189
- playerId: request.playerId,
190
- placementId: request.placementId,
191
- ledgerEntryId: claim.ledgerEntryId,
192
- alreadyProcessed: claim.alreadyProcessed,
193
- reason: claim.reason,
194
- },
195
- });
196
- return claim;
197
199
  },
198
200
  },
199
201
  leaderboard: {
@@ -345,7 +347,7 @@ function bindRequestDeploymentTarget(request, bindings) {
345
347
  ? request
346
348
  : { ...request, deploymentTarget };
347
349
  }
348
- async function verifyPurchaseWithStore(input, context) {
350
+ async function verifyPurchaseWithStore(input, context, onDecision) {
349
351
  const request = bindRequestDeploymentTarget(assertVerifyPurchaseRequest(input), context.deploymentTargetBindings);
350
352
  const retryIdentity = {
351
353
  source: 'purchase',
@@ -357,6 +359,13 @@ async function verifyPurchaseWithStore(input, context) {
357
359
  ? {}
358
360
  : { deploymentTarget: request.deploymentTarget }),
359
361
  };
362
+ return withEntitlementRetryLock(retryIdentity, async () => {
363
+ const verification = await verifyPurchaseForRetry(request, retryIdentity, context);
364
+ await onDecision(verification);
365
+ return verification;
366
+ });
367
+ }
368
+ async function verifyPurchaseForRetry(request, retryIdentity, context) {
360
369
  const completeExistingRetry = async (transaction) => {
361
370
  if (!matchesEntitlementRetry(transaction, retryIdentity)) {
362
371
  return assertVerifyPurchaseResponse({
@@ -412,6 +421,7 @@ async function verifyPurchaseWithStore(input, context) {
412
421
  return assertVerifyPurchaseResponse({
413
422
  verified: false,
414
423
  alreadyProcessed: false,
424
+ disposition: verification.status === 'pending' ? 'pending' : 'rejected',
415
425
  reason: verification.status === 'pending'
416
426
  ? (verification.reason ?? 'EVIDENCE_PENDING')
417
427
  : verification.reason,
@@ -633,7 +643,7 @@ function createStoredPurchaseRequest(request, transaction) {
633
643
  : {}),
634
644
  };
635
645
  }
636
- async function claimAdRewardWithStore(input, context) {
646
+ async function claimAdRewardWithStore(input, context, onDecision) {
637
647
  const request = bindRequestDeploymentTarget(assertClaimAdRewardRequest(input), context.deploymentTargetBindings);
638
648
  const retryIdentity = {
639
649
  source: 'ad_reward',
@@ -645,9 +655,15 @@ async function claimAdRewardWithStore(input, context) {
645
655
  ? {}
646
656
  : { deploymentTarget: request.deploymentTarget }),
647
657
  };
648
- const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
649
- if (existing !== undefined) {
650
- if (!matchesEntitlementRetry(existing, retryIdentity)) {
658
+ return withEntitlementRetryLock(retryIdentity, async () => {
659
+ const claim = await claimAdRewardForRetry(request, retryIdentity, context);
660
+ await onDecision(claim);
661
+ return claim;
662
+ });
663
+ }
664
+ async function claimAdRewardForRetry(request, retryIdentity, context) {
665
+ const completeExistingRetry = (transaction) => {
666
+ if (!matchesEntitlementRetry(transaction, retryIdentity)) {
651
667
  return assertClaimAdRewardResponse({
652
668
  granted: false,
653
669
  alreadyProcessed: false,
@@ -656,9 +672,13 @@ async function claimAdRewardWithStore(input, context) {
656
672
  }
657
673
  return assertClaimAdRewardResponse({
658
674
  granted: true,
659
- ledgerEntryId: existing.ledgerEntryId,
675
+ ledgerEntryId: transaction.ledgerEntryId,
660
676
  alreadyProcessed: true,
661
677
  });
678
+ };
679
+ const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
680
+ if (existing !== undefined) {
681
+ return completeExistingRetry(existing);
662
682
  }
663
683
  const placement = context.placements.placements.find((entry) => entry.id === request.placementId);
664
684
  if (placement === undefined || placement.type !== 'rewarded' || placement.reward === undefined) {
@@ -679,9 +699,16 @@ async function claimAdRewardWithStore(input, context) {
679
699
  });
680
700
  }, context.evidenceVerificationTimeoutMs);
681
701
  if (verification.status !== 'verified') {
702
+ // Another backend process can record a matching grant while verification is in flight.
703
+ // Recheck before returning a stale pending or rejected state.
704
+ const racedExisting = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
705
+ if (racedExisting !== undefined) {
706
+ return completeExistingRetry(racedExisting);
707
+ }
682
708
  return assertClaimAdRewardResponse({
683
709
  granted: false,
684
710
  alreadyProcessed: false,
711
+ disposition: verification.status === 'pending' ? 'pending' : 'rejected',
685
712
  reason: verification.status === 'pending'
686
713
  ? (verification.reason ?? 'EVIDENCE_PENDING')
687
714
  : verification.reason,
@@ -739,24 +766,16 @@ async function claimAdRewardWithStore(input, context) {
739
766
  async function verifyEvidence(verify, timeoutMs) {
740
767
  const controller = new AbortController();
741
768
  const timeoutDecision = {
742
- status: 'rejected',
769
+ status: 'pending',
743
770
  reason: 'EVIDENCE_VERIFIER_TIMEOUT',
744
771
  };
745
- let timedOut = false;
746
772
  let timeout;
773
+ let decision;
747
774
  try {
748
- return await Promise.race([
749
- verify(controller.signal)
750
- .then(assertEvidenceVerificationDecision)
751
- .catch((error) => {
752
- if (timedOut) {
753
- return timeoutDecision;
754
- }
755
- throw error;
756
- }),
775
+ decision = await Promise.race([
776
+ verify(controller.signal),
757
777
  new Promise((resolve) => {
758
778
  timeout = setTimeout(() => {
759
- timedOut = true;
760
779
  resolve(timeoutDecision);
761
780
  controller.abort();
762
781
  }, timeoutMs);
@@ -765,7 +784,7 @@ async function verifyEvidence(verify, timeoutMs) {
765
784
  }
766
785
  catch {
767
786
  return {
768
- status: 'rejected',
787
+ status: 'pending',
769
788
  reason: 'EVIDENCE_VERIFIER_ERROR',
770
789
  };
771
790
  }
@@ -774,6 +793,12 @@ async function verifyEvidence(verify, timeoutMs) {
774
793
  clearTimeout(timeout);
775
794
  }
776
795
  }
796
+ try {
797
+ return assertEvidenceVerificationDecision(decision);
798
+ }
799
+ catch {
800
+ return { status: 'rejected', reason: 'EVIDENCE_VERIFIER_ERROR' };
801
+ }
777
802
  }
778
803
  function createPurchaseVerificationPayload(verification) {
779
804
  if (verification.platformEvidenceId !== null || verification.payload === undefined) {
@@ -875,34 +900,43 @@ async function recordEntitlementGrantUnchecked(store, grant) {
875
900
  throw error;
876
901
  }
877
902
  }
878
- const entitlementEvidenceLocks = new WeakMap();
903
+ const entitlementLocks = new WeakMap();
904
+ /** Hosts such as the Worker create a store per request, so retries lock per process instead. */
905
+ const entitlementRetryLockScope = {};
879
906
  async function withEntitlementEvidenceLocks(store, lockKeys, task) {
880
907
  const orderedLockKeys = [...new Set(lockKeys)].sort((left, right) => left.localeCompare(right));
881
908
  async function runWithLock(index) {
882
909
  const lockKey = orderedLockKeys[index];
883
910
  return lockKey === undefined
884
911
  ? task()
885
- : withEntitlementEvidenceLockKey(store, lockKey, () => runWithLock(index + 1));
912
+ : withEntitlementLockKey(store, lockKey, () => runWithLock(index + 1));
886
913
  }
887
914
  return runWithLock(0);
888
915
  }
889
- async function withEntitlementEvidenceLockKey(store, lockKey, task) {
890
- const storeLocks = entitlementEvidenceLocks.get(store) ?? new Map();
891
- entitlementEvidenceLocks.set(store, storeLocks);
892
- const previous = storeLocks.get(lockKey) ?? Promise.resolve();
916
+ /**
917
+ * Serialize one retry identity in this backend process, from its ledger lookup to its grant
918
+ * or non-grant decision, so a verifier decision cannot race a same-key grant here.
919
+ */
920
+ async function withEntitlementRetryLock(identity, task) {
921
+ return withEntitlementLockKey(entitlementRetryLockScope, createEntitlementIdempotencyKey(identity), task);
922
+ }
923
+ async function withEntitlementLockKey(scope, lockKey, task) {
924
+ const scopeLocks = entitlementLocks.get(scope) ?? new Map();
925
+ entitlementLocks.set(scope, scopeLocks);
926
+ const previous = scopeLocks.get(lockKey) ?? Promise.resolve();
893
927
  const gate = createPromiseGate();
894
928
  const current = previous.then(() => gate.promise);
895
- storeLocks.set(lockKey, current);
929
+ scopeLocks.set(lockKey, current);
896
930
  await previous;
897
931
  try {
898
932
  return await task();
899
933
  }
900
934
  finally {
901
935
  gate.release();
902
- if (storeLocks.get(lockKey) === current) {
903
- storeLocks.delete(lockKey);
904
- if (storeLocks.size === 0) {
905
- entitlementEvidenceLocks.delete(store);
936
+ if (scopeLocks.get(lockKey) === current) {
937
+ scopeLocks.delete(lockKey);
938
+ if (scopeLocks.size === 0) {
939
+ entitlementLocks.delete(scope);
906
940
  }
907
941
  }
908
942
  }
package/dist/types.js CHANGED
@@ -13,6 +13,7 @@ export function assertVerifyPurchaseRequest(input) {
13
13
  export function assertVerifyPurchaseResponse(input) {
14
14
  assertRecord(input, 'VerifyPurchaseResponse');
15
15
  assertBoolean(input.verified, 'verified');
16
+ assertOptionalNonGrantDisposition(input.disposition, input.verified);
16
17
  assertOptionalNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
17
18
  assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
18
19
  assertOptionalNonEmptyString(input.reason, 'reason');
@@ -57,6 +58,7 @@ export function assertClaimAdRewardRequest(input) {
57
58
  export function assertClaimAdRewardResponse(input) {
58
59
  assertRecord(input, 'ClaimAdRewardResponse');
59
60
  assertBoolean(input.granted, 'granted');
61
+ assertOptionalNonGrantDisposition(input.disposition, input.granted);
60
62
  assertOptionalNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
61
63
  assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
62
64
  assertOptionalNonEmptyString(input.reason, 'reason');
@@ -198,6 +200,17 @@ function assertBoolean(input, label) {
198
200
  throw new Error(`${label} must be a boolean.`);
199
201
  }
200
202
  }
203
+ function assertOptionalNonGrantDisposition(input, granted) {
204
+ if (input === undefined) {
205
+ return;
206
+ }
207
+ if (input !== 'pending' && input !== 'rejected') {
208
+ throw new Error('disposition must be pending or rejected.');
209
+ }
210
+ if (granted) {
211
+ throw new Error('A granted result cannot have a non-grant disposition.');
212
+ }
213
+ }
201
214
  function assertPurchaseGrantFinalizationAction(input) {
202
215
  if (input !== 'acknowledge'
203
216
  && input !== 'consume'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,6 +29,10 @@
29
29
  "types": "./dist/index.d.ts",
30
30
  "default": "./dist/index.js"
31
31
  },
32
+ "./admob-client-reward": {
33
+ "types": "./dist/admob-client-reward.d.ts",
34
+ "default": "./dist/admob-client-reward.js"
35
+ },
32
36
  "./admob-ssv": {
33
37
  "types": "./dist/admob-ssv.d.ts",
34
38
  "default": "./dist/admob-ssv.js"
@@ -41,6 +45,10 @@
41
45
  "types": "./dist/app-store-verifier.d.ts",
42
46
  "default": "./dist/app-store-verifier.js"
43
47
  },
48
+ "./app-store-recovery": {
49
+ "types": "./dist/app-store-recovery.d.ts",
50
+ "default": "./dist/app-store-recovery.js"
51
+ },
44
52
  "./app-store-verifier-conformance": {
45
53
  "types": "./dist/app-store-verifier-conformance.d.ts",
46
54
  "default": "./dist/app-store-verifier-conformance.js"
@@ -73,6 +81,10 @@
73
81
  "types": "./dist/google-play-purchase.d.ts",
74
82
  "default": "./dist/google-play-purchase.js"
75
83
  },
84
+ "./google-play-publisher-client": {
85
+ "types": "./dist/google-play-publisher-client.d.ts",
86
+ "default": "./dist/google-play-publisher-client.js"
87
+ },
76
88
  "./guest-session": {
77
89
  "types": "./dist/guest-session.d.ts",
78
90
  "default": "./dist/guest-session.js"
@@ -142,9 +154,9 @@
142
154
  "@orpc/client": "2.0.0-beta.14",
143
155
  "@orpc/contract": "2.0.0-beta.14",
144
156
  "@orpc/server": "2.0.0-beta.14",
145
- "@mpgd/analytics": "0.3.15",
146
- "@mpgd/platform": "0.13.0",
147
- "@mpgd/catalog": "0.7.5"
157
+ "@mpgd/analytics": "0.4.0",
158
+ "@mpgd/catalog": "0.7.6",
159
+ "@mpgd/platform": "0.14.0"
148
160
  },
149
161
  "devDependencies": {
150
162
  "@opentelemetry/api": "1.9.0",
@@ -163,7 +175,8 @@
163
175
  "fix": "ttsc fix",
164
176
  "build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
165
177
  "test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs",
166
- "test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.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/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.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/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.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 && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts",
167
- "test": "pnpm test:unit && pnpm test:packed-transport"
178
+ "test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.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/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.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/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.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 && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts",
179
+ "test:recovery": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/monetization-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-recovery.test.ts",
180
+ "test": "pnpm test:unit && pnpm test:recovery && pnpm test:packed-transport"
168
181
  }
169
182
  }