@mpgd/game-services 0.17.2 → 0.18.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/README.md CHANGED
@@ -4,6 +4,52 @@ Client, contract, backend, and store helpers for authoritative game services.
4
4
  See [backend integration](../../docs/GAME_SERVICES_BACKEND.md) for the existing
5
5
  verification and ledger boundaries.
6
6
 
7
+ ## Registered rewarded-ad evidence
8
+
9
+ `createGameServicesClient`, `createRecoverableMonetizationClient`, and
10
+ `createGameServicesRuntime` accept `rewardEvidenceRegistry`. Create one with
11
+ `createClientRewardEvidenceRegistry` from
12
+ `@mpgd/game-services/client-reward-evidence`, or extend the default AdMob,
13
+ Verse8, and Apps in Toss decoders with
14
+ `createDefaultClientRewardEvidenceRegistry` from
15
+ `@mpgd/game-services/default-client-reward-evidence`.
16
+
17
+ Each explicit registration has a unique `schema` and a `normalize(reward)`
18
+ function returning `{ platformImpressionId? }` or `undefined`. The registry
19
+ passes a bounded, frozen copy of the evidence to the decoder. Decoder errors,
20
+ unknown schemas, and malformed candidates produce no claim. A decoder only
21
+ recognizes a claim candidate: the backend must independently verify proof
22
+ before a ledger grant. An SDK earned event may have `rewardGranted: false`
23
+ and still produce a pending or verified backend claim.
24
+
25
+ ```ts
26
+ import { createDefaultClientRewardEvidenceRegistry } from
27
+ '@mpgd/game-services/default-client-reward-evidence';
28
+
29
+ const rewardEvidenceRegistry = createDefaultClientRewardEvidenceRegistry([{
30
+ schema: 'game-provider.reward.v1',
31
+ normalize(reward) {
32
+ const id = reward.evidence?.payload.impressionId;
33
+ return typeof id === 'string' && id.trim() !== ''
34
+ ? { platformImpressionId: id } : undefined;
35
+ },
36
+ }]);
37
+ // Pass this registry to the client/runtime, and configure server verification separately.
38
+ ```
39
+
40
+ Recovery uses the same registered decoder and checks the journaled claim
41
+ against the original evidence and impression identity. Missing registrations
42
+ or altered evidence leave reconciliation `action-required`; they cannot
43
+ reopen the SDK display. Supply the same registrations after a restart.
44
+
45
+ For existing v1 consumers that supply neither a registry nor `ads.provider`,
46
+ the completed SDK reward flag still forwards a legacy candidate to the
47
+ backend. A registered schema that fails decoding cannot take this fallback.
48
+ Explicit registries and versioned providers reject unknown schemas even if
49
+ an SDK reports a reward. Existing `admob-client-reward` exports remain
50
+ available for compatibility. Target routing and server verifier registration
51
+ are separate integration boundaries.
52
+
7
53
  ## Optional operation progress
8
54
 
9
55
  `purchase(input, options?)` and `claimRewardedAd(input, options?)` accept optional
@@ -0,0 +1,23 @@
1
+ import type { EvidenceVerificationDecision, VerifyAdRewardEvidenceInput } from './evidence-verification.js';
2
+ import { type GameServicesAdRewardTarget } from './types.js';
3
+ export interface AdRewardVerifierBinding {
4
+ readonly target: GameServicesAdRewardTarget;
5
+ /** Omit only for the deployment whose key equals its platform target. */
6
+ readonly deploymentTarget?: string;
7
+ }
8
+ export interface AdRewardVerifierDescriptor {
9
+ readonly providerId: string;
10
+ readonly schema: string;
11
+ readonly bindings: readonly AdRewardVerifierBinding[];
12
+ }
13
+ export interface AdRewardVerifierRegistration extends AdRewardVerifierDescriptor {
14
+ /** Explicit server proof lookup without a client envelope (for example signed SSV recovery). */
15
+ readonly acceptsMissingEvidence?: boolean;
16
+ /** Authenticate proof and all request bindings; registration itself proves no reward. */
17
+ verify(input: VerifyAdRewardEvidenceInput): Promise<EvidenceVerificationDecision>;
18
+ }
19
+ export interface AdRewardEvidenceVerifierRegistry {
20
+ verifyAdReward(input: VerifyAdRewardEvidenceInput): Promise<EvidenceVerificationDecision>;
21
+ }
22
+ /** A deployment-owned allow-list. Client decoder registration never reaches this registry. */
23
+ export declare function createAdRewardEvidenceVerifierRegistry(entries: readonly AdRewardVerifierRegistration[]): AdRewardEvidenceVerifierRegistry;
@@ -0,0 +1,83 @@
1
+ import { assertGameServicesDeploymentTarget } from './types.js';
2
+ /** A deployment-owned allow-list. Client decoder registration never reaches this registry. */
3
+ export function createAdRewardEvidenceVerifierRegistry(entries) {
4
+ const registered = new Map();
5
+ const providerSchemas = new Map();
6
+ for (const entry of entries) {
7
+ assertIdentity(entry.providerId);
8
+ assertIdentity(entry.schema);
9
+ if (!Array.isArray(entry.bindings) || entry.bindings.length === 0 || typeof entry.verify !== 'function'
10
+ || entry.acceptsMissingEvidence !== undefined && typeof entry.acceptsMissingEvidence !== 'boolean') {
11
+ throw new TypeError('Invalid advertising verifier registration.');
12
+ }
13
+ const owner = providerSchemas.get(entry.schema);
14
+ if (owner !== undefined && owner !== entry.providerId) {
15
+ throw new TypeError('Advertising evidence schema belongs to another provider.');
16
+ }
17
+ providerSchemas.set(entry.schema, entry.providerId);
18
+ for (const binding of entry.bindings) {
19
+ if (!rewardTargets.has(binding.target)) {
20
+ throw new TypeError('Invalid advertising verifier target.');
21
+ }
22
+ const deploymentTarget = binding.deploymentTarget ?? binding.target;
23
+ assertGameServicesDeploymentTarget(deploymentTarget);
24
+ const key = bindingKey(entry.schema, binding.target, deploymentTarget);
25
+ if (registered.has(key)) {
26
+ throw new TypeError('Duplicate advertising verifier binding.');
27
+ }
28
+ const verifier = Object.freeze({ providerId: entry.providerId, verify: entry.verify });
29
+ registered.set(key, verifier);
30
+ if (entry.acceptsMissingEvidence === true) {
31
+ const missingKey = bindingKey(null, binding.target, deploymentTarget);
32
+ if (registered.has(missingKey)) {
33
+ throw new TypeError('Duplicate missing-evidence advertising verifier binding.');
34
+ }
35
+ registered.set(missingKey, verifier);
36
+ }
37
+ }
38
+ }
39
+ return Object.freeze({
40
+ async verifyAdReward(input) {
41
+ const request = input.request;
42
+ const schema = request.evidence === undefined ? null : request.evidence?.schema;
43
+ const selected = schema === undefined || schema === '' ? undefined : registered.get(bindingKey(schema, request.target, request.deploymentTarget ?? request.target));
44
+ if (selected === undefined) {
45
+ return { status: 'rejected', reason: 'AD_REWARD_VERIFIER_UNREGISTERED' };
46
+ }
47
+ const { providerId, verify } = selected;
48
+ if (request.providerId !== undefined && request.providerId !== providerId) {
49
+ return { status: 'rejected', reason: 'AD_REWARD_PROVIDER_MISMATCH' };
50
+ }
51
+ // Proof verification stays abortable and server-owned. Preserve its authoritative
52
+ // identity so ledger replay checks remain stable across registry migration.
53
+ const decision = await verify(input);
54
+ if (decision?.status !== 'verified') {
55
+ return decision;
56
+ }
57
+ const payload = decision.payload;
58
+ if (payload !== undefined && (typeof payload !== 'object' || payload === null
59
+ || Array.isArray(payload) || Object.values(payload).some((value) => typeof value !== 'string' && typeof value !== 'boolean'
60
+ && (typeof value !== 'number' || !Number.isFinite(value))))) {
61
+ return { status: 'rejected', reason: 'EVIDENCE_VERIFIER_ERROR' };
62
+ }
63
+ return { ...decision, payload: { ...payload, adProviderId: providerId } };
64
+ },
65
+ });
66
+ }
67
+ const rewardTargets = new Set([
68
+ 'browser',
69
+ 'microsoft-store',
70
+ 'android',
71
+ 'ios',
72
+ 'ait',
73
+ 'reddit',
74
+ 'verse8',
75
+ ]);
76
+ function bindingKey(schema, target, deploymentTarget) {
77
+ return JSON.stringify([schema, target, deploymentTarget]);
78
+ }
79
+ function assertIdentity(value) {
80
+ if (typeof value !== 'string' || value.trim() === '' || value.length > 512) {
81
+ throw new TypeError('Invalid advertising verifier identity.');
82
+ }
83
+ }
@@ -16,7 +16,7 @@ export declare const verse8ClientRewardEvidenceSchema = "verse8.ads.reward.v1";
16
16
  */
17
17
  export declare const clientRewardEvidenceSchemas: readonly ["mpgd.admob.client-reward.v1", "verse8.ads.reward.v1"];
18
18
  export type ClientRewardEvidenceSchema = (typeof clientRewardEvidenceSchemas)[number];
19
- /** True when a rewarded-ad result carries ungranted evidence from an allow-listed schema. */
19
+ /** @deprecated Built-in v1 compatibility helper. Claim/recovery flows use the injected reward evidence registry. */
20
20
  export declare function isClientRewardEvidence(value: unknown): boolean;
21
21
  export declare function isVerse8ClientRewardEvidence(value: unknown): boolean;
22
22
  /**
@@ -35,7 +35,7 @@ export const clientRewardEvidenceSchemas = Object.freeze([
35
35
  admobClientRewardEvidenceSchema,
36
36
  verse8ClientRewardEvidenceSchema,
37
37
  ]);
38
- /** True when a rewarded-ad result carries ungranted evidence from an allow-listed schema. */
38
+ /** @deprecated Built-in v1 compatibility helper. Claim/recovery flows use the injected reward evidence registry. */
39
39
  export function isClientRewardEvidence(value) {
40
40
  return isAdMobClientRewardEvidence(value) || isVerse8ClientRewardEvidence(value);
41
41
  }
@@ -0,0 +1,22 @@
1
+ import type { PlatformEvidenceEnvelope, RewardedAdResult } from '@mpgd/platform';
2
+ export interface ClientRewardClaimCandidate {
3
+ readonly evidence?: PlatformEvidenceEnvelope;
4
+ readonly platformImpressionId?: string;
5
+ }
6
+ export interface ClientRewardEvidenceNormalizer {
7
+ readonly schema: string;
8
+ /** Decode a claim candidate only. This cannot authorize a server grant. */
9
+ normalize(reward: RewardedAdResult): {
10
+ readonly platformImpressionId?: string;
11
+ } | undefined;
12
+ }
13
+ export interface ClientRewardEvidenceRegistry {
14
+ resolve(reward: RewardedAdResult): ClientRewardClaimCandidate | undefined;
15
+ recognizes(schema: string): boolean;
16
+ }
17
+ /** An immutable, explicitly registered client decoder set, independent of server verification. */
18
+ export declare function createClientRewardEvidenceRegistry(entries: readonly ClientRewardEvidenceNormalizer[]): ClientRewardEvidenceRegistry;
19
+ /** Preserve the old SDK-flag claim path only for consumers without a versioned/registered provider. */
20
+ export declare function resolveClientRewardClaim(reward: RewardedAdResult, registry: ClientRewardEvidenceRegistry, options: {
21
+ readonly allowLegacyCompletion: boolean;
22
+ }): ClientRewardClaimCandidate | undefined;
@@ -0,0 +1,93 @@
1
+ /** An immutable, explicitly registered client decoder set, independent of server verification. */
2
+ export function createClientRewardEvidenceRegistry(entries) {
3
+ const normalizers = new Map();
4
+ for (const entry of entries) {
5
+ if (typeof entry.schema !== 'string' || entry.schema.trim() === '' || entry.schema.length > 512
6
+ || typeof entry.normalize !== 'function' || normalizers.has(entry.schema)) {
7
+ throw new TypeError('Invalid or duplicate client reward evidence registration.');
8
+ }
9
+ normalizers.set(entry.schema, entry.normalize);
10
+ }
11
+ return Object.freeze({
12
+ recognizes: (schema) => normalizers.has(schema),
13
+ resolve(reward) {
14
+ if ((reward.status !== 'completed' && reward.status !== 'pending')
15
+ || typeof reward.rewardGranted !== 'boolean'
16
+ || (reward.ledgerEntryId !== undefined && (typeof reward.ledgerEntryId !== 'string' || reward.ledgerEntryId.trim() === '' || reward.ledgerEntryId.length > 512))) {
17
+ return undefined;
18
+ }
19
+ const evidence = cloneEvidence(reward.evidence);
20
+ if (evidence === undefined) {
21
+ return undefined;
22
+ }
23
+ const normalize = normalizers.get(evidence.schema);
24
+ if (normalize === undefined) {
25
+ return undefined;
26
+ }
27
+ try {
28
+ const normalized = normalize(Object.freeze({
29
+ status: reward.status, rewardGranted: reward.rewardGranted, evidence,
30
+ ...(reward.ledgerEntryId === undefined ? {} : { ledgerEntryId: reward.ledgerEntryId }),
31
+ }));
32
+ if (typeof normalized !== 'object' || normalized === null || Array.isArray(normalized)) {
33
+ return undefined;
34
+ }
35
+ const impressionId = normalized.platformImpressionId;
36
+ if (impressionId !== undefined && (typeof impressionId !== 'string' || impressionId.trim() === '' || impressionId.length > 512)) {
37
+ return undefined;
38
+ }
39
+ return Object.freeze({
40
+ evidence,
41
+ ...(impressionId === undefined ? {} : { platformImpressionId: impressionId }),
42
+ });
43
+ }
44
+ catch {
45
+ // Decoder failures cannot turn untrusted evidence into a claim or leak its contents.
46
+ return undefined;
47
+ }
48
+ },
49
+ });
50
+ }
51
+ /** Preserve the old SDK-flag claim path only for consumers without a versioned/registered provider. */
52
+ export function resolveClientRewardClaim(reward, registry, options) {
53
+ const candidate = registry.resolve(reward);
54
+ if (candidate !== undefined) {
55
+ return candidate;
56
+ }
57
+ if (!options.allowLegacyCompletion || reward.status !== 'completed' || reward.rewardGranted !== true) {
58
+ return undefined;
59
+ }
60
+ const evidence = reward.evidence === undefined ? undefined : cloneEvidence(reward.evidence);
61
+ if ((reward.evidence !== undefined && evidence === undefined)
62
+ || (evidence !== undefined && registry.recognizes(evidence.schema))
63
+ || (reward.ledgerEntryId !== undefined && (typeof reward.ledgerEntryId !== 'string'
64
+ || reward.ledgerEntryId.trim() === '' || reward.ledgerEntryId.length > 512))) {
65
+ return undefined;
66
+ }
67
+ return Object.freeze({
68
+ ...(evidence === undefined ? {} : { evidence }),
69
+ ...(reward.ledgerEntryId === undefined ? {} : { platformImpressionId: reward.ledgerEntryId }),
70
+ });
71
+ }
72
+ function cloneEvidence(value) {
73
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
74
+ return undefined;
75
+ }
76
+ const envelope = value;
77
+ const payload = envelope.payload;
78
+ if (typeof envelope.schema !== 'string' || envelope.schema.trim() === '' || envelope.schema.length > 512
79
+ || typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
80
+ return undefined;
81
+ }
82
+ const entries = Object.entries(payload);
83
+ if (entries.length > 64 || entries.some(([key, field]) => key.length > 128
84
+ || (typeof field !== 'string' && typeof field !== 'number' && typeof field !== 'boolean')
85
+ || (typeof field === 'string' && field.length > 8192)
86
+ || (typeof field === 'number' && !Number.isFinite(field)))) {
87
+ return undefined;
88
+ }
89
+ return Object.freeze({
90
+ schema: envelope.schema,
91
+ payload: Object.freeze(Object.fromEntries(entries)),
92
+ });
93
+ }
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type AnalyticsSink } from '@mpgd/analytics';
2
2
  import type { LeaderboardScoreInput, PlatformGateway, PlatformTarget } from '@mpgd/platform';
3
+ import { type ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
3
4
  import type { GameServicesOperationClient } from './operations.js';
4
5
  export type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult, } from './operations.js';
5
6
  import type { GameServicesContractClient } from './contract.js';
@@ -100,6 +101,8 @@ export interface CreateGameServicesClientInput {
100
101
  readonly now?: () => string;
101
102
  /** Override backend evidence timestamps without backdating analytics events. */
102
103
  readonly requestNow?: () => string;
104
+ /** Explicit client decoders only; server verification is configured independently. */
105
+ readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
103
106
  }
104
107
  export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
105
108
  }
package/dist/client.js CHANGED
@@ -2,7 +2,8 @@ import { createORPCClient } from '@orpc/client';
2
2
  import { RPCLink } from '@orpc/client/fetch';
3
3
  import { createAnalyticsReporter } from '@mpgd/analytics';
4
4
  import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
5
- import { isClientRewardEvidence, resolveRewardPlatformImpressionId, } from './admob-client-reward.js';
5
+ import { resolveClientRewardClaim, } from './client-reward-evidence.js';
6
+ import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
6
7
  import { observeGameServicesOperation } from './operation-progress.js';
7
8
  export const gameServicesBackendEndpoints = {
8
9
  verifyPurchase: '/game-services/purchases/verify',
@@ -57,6 +58,8 @@ export async function resolveGameServicesRequestHeaders(input) {
57
58
  export function createGameServicesClient(input) {
58
59
  const now = input.now ?? (() => new Date().toISOString());
59
60
  const requestNow = input.requestNow ?? now;
61
+ const rewardEvidenceRegistry = input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry();
62
+ const allowLegacyRewardCompletion = input.rewardEvidenceRegistry === undefined && input.gateway.ads?.provider === undefined;
60
63
  const analytics = createAnalyticsReporter({
61
64
  target: input.target,
62
65
  sessionId: input.analyticsSessionId ?? input.playerId,
@@ -177,30 +180,13 @@ export function createGameServicesClient(input) {
177
180
  async claimRewardedAd(rewardInput, options) {
178
181
  return observeGameServicesOperation('rewarded-ad', options, async (progress) => {
179
182
  const target = input.target;
180
- if (!isGameServicesAdRewardTarget(target)) {
181
- const reward = {
182
- status: 'unavailable',
183
- rewardGranted: false,
184
- };
185
- await analytics.track({
186
- name: 'rewarded_ad_rejected',
187
- properties: {
188
- placementId: rewardInput.placementId,
189
- status: reward.status,
190
- rewardGranted: reward.rewardGranted,
191
- reason: 'unsupported_target',
192
- },
193
- });
194
- return {
195
- status: 'rejected',
196
- reward,
197
- };
198
- }
199
183
  progress.platformRequested();
200
184
  const reward = await input.gateway.ads.showRewarded(rewardInput);
201
185
  progress.platformResult(reward.status);
202
- if ((reward.status !== 'completed' || !reward.rewardGranted)
203
- && !isClientRewardEvidence(reward)) {
186
+ const candidate = resolveClientRewardClaim(reward, rewardEvidenceRegistry, {
187
+ allowLegacyCompletion: allowLegacyRewardCompletion,
188
+ });
189
+ if (candidate === undefined) {
204
190
  await analytics.track({
205
191
  name: reward.status === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
206
192
  properties: {
@@ -214,9 +200,10 @@ export function createGameServicesClient(input) {
214
200
  reward,
215
201
  };
216
202
  }
217
- const platformImpressionId = resolveRewardPlatformImpressionId(reward);
203
+ const platformImpressionId = candidate.platformImpressionId;
218
204
  const claimRequest = {
219
205
  target,
206
+ ...(input.gateway.ads.provider === undefined ? {} : { providerId: input.gateway.ads.provider.id }),
220
207
  ...(input.deploymentTarget === undefined || input.deploymentTarget === target
221
208
  ? {}
222
209
  : { deploymentTarget: input.deploymentTarget }),
@@ -227,7 +214,7 @@ export function createGameServicesClient(input) {
227
214
  : { platformImpressionId }),
228
215
  idempotencyKey: rewardInput.idempotencyKey,
229
216
  completedAt: requestNow(),
230
- ...(reward.evidence === undefined ? {} : { evidence: reward.evidence }),
217
+ ...(candidate.evidence === undefined ? {} : { evidence: candidate.evidence }),
231
218
  };
232
219
  progress.serverRequested();
233
220
  const claim = await input.backend.adRewards.claimAdReward(claimRequest);
@@ -414,9 +401,6 @@ function isGameServicesCommerceTarget(target) {
414
401
  || target === 'ait'
415
402
  || target === 'verse8';
416
403
  }
417
- function isGameServicesAdRewardTarget(target) {
418
- return target === 'android' || target === 'ios' || target === 'ait' || target === 'verse8';
419
- }
420
404
  function isGameServicesLeaderboardTarget(target) {
421
405
  return target === 'browser'
422
406
  || target === 'microsoft-store'
@@ -0,0 +1,4 @@
1
+ import { type ClientRewardEvidenceNormalizer, type ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
2
+ /** Registered decoder data. Common claim and recovery code never inspects these provider payloads. */
3
+ export declare const defaultClientRewardEvidenceNormalizers: readonly ClientRewardEvidenceNormalizer[];
4
+ export declare function createDefaultClientRewardEvidenceRegistry(additional?: readonly ClientRewardEvidenceNormalizer[]): ClientRewardEvidenceRegistry;
@@ -0,0 +1,38 @@
1
+ import { admobClientRewardEvidenceSchema, isAdMobClientRewardEvidence, isVerse8ClientRewardEvidence, verse8ClientRewardEvidenceSchema, } from './admob-client-reward.js';
2
+ import { createClientRewardEvidenceRegistry, } from './client-reward-evidence.js';
3
+ /** Registered decoder data. Common claim and recovery code never inspects these provider payloads. */
4
+ export const defaultClientRewardEvidenceNormalizers = Object.freeze([
5
+ Object.freeze({
6
+ schema: admobClientRewardEvidenceSchema,
7
+ normalize: (reward) => isAdMobClientRewardEvidence(reward) ? {} : undefined,
8
+ }),
9
+ Object.freeze({
10
+ schema: verse8ClientRewardEvidenceSchema,
11
+ normalize: (reward) => {
12
+ if (!isVerse8ClientRewardEvidence(reward)) {
13
+ return undefined;
14
+ }
15
+ const requestId = reward.evidence?.payload.requestId;
16
+ return typeof requestId === 'string' ? { platformImpressionId: requestId } : undefined;
17
+ },
18
+ }),
19
+ Object.freeze({
20
+ schema: 'apps-in-toss.rewarded-ad.callback.v1',
21
+ normalize: (reward) => {
22
+ const payload = reward.evidence?.payload;
23
+ const correlationId = payload?.correlationId;
24
+ if (payload?.event !== 'user-earned-reward' || typeof correlationId !== 'string' || correlationId.trim() === ''
25
+ || typeof payload.placementId !== 'string' || payload.placementId.trim() === ''
26
+ || (reward.ledgerEntryId !== undefined && reward.ledgerEntryId !== correlationId)) {
27
+ return undefined;
28
+ }
29
+ return { platformImpressionId: correlationId };
30
+ },
31
+ }),
32
+ ]);
33
+ export function createDefaultClientRewardEvidenceRegistry(additional = []) {
34
+ return createClientRewardEvidenceRegistry([
35
+ ...defaultClientRewardEvidenceNormalizers,
36
+ ...additional,
37
+ ]);
38
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  export * from './admob-ssv.js';
2
+ export * from './ad-reward-verifier-registry.js';
2
3
  export * from './app-store-verifier.js';
3
4
  export * from './app-store-recovery.js';
4
5
  export * from './apps-in-toss-evidence-verification.js';
5
6
  export * from './apps-in-toss-partner-api.js';
6
7
  export * from './client.js';
8
+ export * from './client-reward-evidence.js';
9
+ export * from './default-client-reward-evidence.js';
7
10
  export * from './admob-client-reward.js';
8
11
  export * from './contract.js';
9
12
  export * from './evidence-verification.js';
package/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  export * from './admob-ssv.js';
2
+ export * from './ad-reward-verifier-registry.js';
2
3
  export * from './app-store-verifier.js';
3
4
  export * from './app-store-recovery.js';
4
5
  export * from './apps-in-toss-evidence-verification.js';
5
6
  export * from './apps-in-toss-partner-api.js';
6
7
  export * from './client.js';
8
+ export * from './client-reward-evidence.js';
9
+ export * from './default-client-reward-evidence.js';
7
10
  export * from './admob-client-reward.js';
8
11
  export * from './contract.js';
9
12
  export * from './evidence-verification.js';
@@ -58,8 +58,33 @@ export interface CreateMicrosoftStorePurchaseBoundaryInput {
58
58
  * Production implementations must share this state across every game-service instance.
59
59
  */
60
60
  readonly recoveryOwnershipStore: MicrosoftStoreRecoveryOwnershipStore;
61
+ /**
62
+ * Persist validated consume/order attribution before releasing recovery ownership.
63
+ * Must be durable and idempotent by evidenceVerificationId/trackingId. Throw to keep
64
+ * finalization pending. On developer-managed retries, orderTransactions can be empty;
65
+ * resolve only if an exact previously persisted receipt is available in that case.
66
+ * This hook does not receive credentials and does not process Clawback events.
67
+ */
68
+ readonly persistConsumptionReceipt?: (receipt: MicrosoftStoreConsumptionReceipt, signal: AbortSignal) => Promise<void>;
61
69
  readonly now?: () => string;
62
70
  }
71
+ export interface MicrosoftStoreConsumeOrderTransaction {
72
+ readonly orderId: string;
73
+ readonly orderLineItemId: string;
74
+ readonly quantityConsumed: number;
75
+ }
76
+ /** Server-only attribution for subsequent provider refund reconciliation. */
77
+ export interface MicrosoftStoreConsumptionReceipt {
78
+ readonly evidenceVerificationId: string;
79
+ readonly playerId: string;
80
+ /** Stable logical product, distinct from the provider Store ID. */
81
+ readonly productId: string;
82
+ readonly storeId: string;
83
+ readonly collectionItemId: string;
84
+ readonly trackingId: string;
85
+ /** Empty when the provider omitted order IDs on a developer-managed retry. */
86
+ readonly orderTransactions: readonly MicrosoftStoreConsumeOrderTransaction[];
87
+ }
63
88
  export interface MicrosoftStoreHistoricalProductMapping {
64
89
  readonly inAppOfferToken: string;
65
90
  readonly storeId: string;
@@ -1,3 +1,4 @@
1
+ import { gameServicesRequestLimits } from './validation.js';
1
2
  export const microsoftStoreDigitalGoodsEvidenceSchema = 'mpgd.microsoft-store.digital-goods.v1';
2
3
  export const microsoftStoreCollectionsQueryUrl = 'https://collections.mp.microsoft.com/v9.0/collections/publisherQuery';
3
4
  export const microsoftStoreCollectionsConsumeUrl = 'https://collections.mp.microsoft.com/v8.0/collections/consume';
@@ -112,7 +113,9 @@ export function createMicrosoftStorePurchaseBoundary(input) {
112
113
  const clientEvidence = readRecoveryOwnershipEvidence(ownershipInput);
113
114
  if (playerId === undefined
114
115
  || productId === undefined
115
- || clientEvidence === undefined) {
116
+ || clientEvidence === undefined
117
+ || (ownershipInput.idempotencyKey !== undefined
118
+ && (generation === undefined || generation.length > gameServicesRequestLimits.maxStringLength))) {
116
119
  return { status: 'denied' };
117
120
  }
118
121
  const currentInAppOfferToken = inAppOfferTokens.get(productId);
@@ -239,7 +242,7 @@ export function createMicrosoftStorePurchaseBoundary(input) {
239
242
  }
240
243
  const effective = await input.recoveryOwnershipStore.claim({
241
244
  ...ownership.record,
242
- generation: ownership.providerPurchaseId,
245
+ generation: await createRecoveryGeneration(ownership.providerPurchaseId),
243
246
  providerPurchaseId: ownership.providerPurchaseId,
244
247
  });
245
248
  return effective?.playerId === ownership.record.playerId
@@ -437,6 +440,34 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
437
440
  || raw.newQuantity !== 0) {
438
441
  return finalizationPending('MICROSOFT_STORE_CONSUME_RESPONSE_MISMATCH');
439
442
  }
443
+ if (input.persistConsumptionReceipt !== undefined) {
444
+ const orderTransactions = readConsumeOrderTransactions(raw.orderTransactions);
445
+ // Some consume responses omit the product echo. Attribution comes from the
446
+ // verified context and the item/tracking checks above; reject a conflicting echo.
447
+ if ((raw.productId !== undefined && raw.productId !== context.storeId)
448
+ || orderTransactions === undefined) {
449
+ return finalizationPending('MICROSOFT_STORE_CONSUMPTION_RECEIPT_INVALID');
450
+ }
451
+ finalizationInput.signal.throwIfAborted();
452
+ try {
453
+ await input.persistConsumptionReceipt(Object.freeze({
454
+ evidenceVerificationId: finalizationInput.evidenceVerificationId,
455
+ playerId: finalizationInput.request.playerId,
456
+ productId: finalizationInput.request.productId,
457
+ storeId: context.storeId,
458
+ collectionItemId: context.collectionItemId,
459
+ trackingId,
460
+ orderTransactions,
461
+ }), finalizationInput.signal);
462
+ }
463
+ catch (error) {
464
+ if (finalizationInput.signal.aborted) {
465
+ throw error;
466
+ }
467
+ return finalizationPending('MICROSOFT_STORE_CONSUMPTION_RECEIPT_UNAVAILABLE');
468
+ }
469
+ finalizationInput.signal.throwIfAborted();
470
+ }
440
471
  try {
441
472
  await input.recoveryOwnershipStore.release({
442
473
  accountBindingHash,
@@ -462,6 +493,32 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
462
493
  return finalizationPending('MICROSOFT_STORE_CONSUME_UNAVAILABLE');
463
494
  }
464
495
  }
496
+ function readConsumeOrderTransactions(value) {
497
+ // Microsoft documents omitted order IDs for developer-managed consume retries.
498
+ // Never invent them from the collection item, client token, or tracking ID.
499
+ if (value === undefined) {
500
+ return Object.freeze([]);
501
+ }
502
+ if (!Array.isArray(value) || value.length > 1) {
503
+ return undefined;
504
+ }
505
+ const transactions = [];
506
+ const guid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/iu;
507
+ for (const item of value) {
508
+ // This boundary only grants developer-managed consumables with quantity one.
509
+ if (!isRecord(item) || typeof item.orderId !== 'string' || !guid.test(item.orderId)
510
+ || typeof item.orderLineItemId !== 'string' || !guid.test(item.orderLineItemId)
511
+ || item.quantityConsumed !== 1) {
512
+ return undefined;
513
+ }
514
+ transactions.push(Object.freeze({
515
+ orderId: item.orderId.toLowerCase(),
516
+ orderLineItemId: item.orderLineItemId.toLowerCase(),
517
+ quantityConsumed: 1,
518
+ }));
519
+ }
520
+ return Object.freeze(transactions);
521
+ }
465
522
  function inspectPublisherQuery(raw, expectedStoreId) {
466
523
  if (!isRecord(raw)) {
467
524
  return rejected('MICROSOFT_STORE_COLLECTIONS_RESPONSE_INVALID');
@@ -592,6 +649,15 @@ function readFinalizationContext(input) {
592
649
  function createVerificationId(item) {
593
650
  return `microsoft-store:${item.productId}:${item.id}:${item.modifiedDate}`;
594
651
  }
652
+ /** Keep existing short generations stable while bounding new provider-derived request keys. */
653
+ async function createRecoveryGeneration(providerPurchaseId) {
654
+ if (providerPurchaseId.length <= gameServicesRequestLimits.maxStringLength) {
655
+ return providerPurchaseId;
656
+ }
657
+ const encoded = new TextEncoder().encode(providerPurchaseId);
658
+ const digest = new Uint8Array(await readGlobalSubtleCrypto().digest('SHA-256', encoded));
659
+ return `microsoft-store-recovery:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
660
+ }
595
661
  async function deterministicTrackingId(value) {
596
662
  const subtle = readGlobalSubtleCrypto();
597
663
  const encoded = new TextEncoder().encode(value);
@@ -1,6 +1,7 @@
1
1
  import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
2
2
  import { createGameServicesClient } from './client.js';
3
- import { isClientRewardEvidence, resolveRewardPlatformImpressionId, } from './admob-client-reward.js';
3
+ import { resolveClientRewardClaim } from './client-reward-evidence.js';
4
+ import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
4
5
  import { forwardGameServicesProgress, observeGameServicesOperation, } from './operation-progress.js';
5
6
  /**
6
7
  * Client-side recovery coordinates UI callbacks and authoritative backend calls.
@@ -9,6 +10,8 @@ import { forwardGameServicesProgress, observeGameServicesOperation, } from './op
9
10
  */
10
11
  export function createRecoverableMonetizationClient(input) {
11
12
  const store = input.operationStore;
13
+ const rewardEvidenceRegistry = input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry();
14
+ const allowLegacyRewardCompletion = input.rewardEvidenceRegistry === undefined && input.gateway.ads.provider === undefined;
12
15
  const observedAt = () => input.now?.() ?? new Date().toISOString();
13
16
  const inFlight = new Map();
14
17
  const unjournaledPurchases = new Map();
@@ -442,18 +445,19 @@ export function createRecoverableMonetizationClient(input) {
442
445
  assertOwner(found, 'rewarded-ad', idempotencyKey, found.input.placementId);
443
446
  const transient = unjournaledRewards.get(key);
444
447
  if (transient !== undefined && !samePlatformResult(transient.platform, platform)
445
- && !(transient.platform.status === 'pending' && platform.status !== 'pending')) {
448
+ && !canAdvanceReward(transient.platform, platform, false)) {
446
449
  throw new Error('A platform ad callback conflicts with the unjournaled result.');
447
450
  }
448
- const canAdvance = found.platform?.status === 'pending'
449
- && platform.status !== 'pending' && found.request === undefined;
451
+ const canAdvance = found.platform !== undefined
452
+ && !samePlatformResult(found.platform, platform)
453
+ && canAdvanceReward(found.platform, platform, found.request !== undefined);
450
454
  if (found.platform !== undefined && !samePlatformResult(found.platform, platform)
451
455
  && !canAdvance) {
452
456
  throw new Error('A platform ad callback conflicts with the recorded operation.');
453
457
  }
454
458
  let record = found;
455
459
  if (found.platform === undefined || canAdvance) {
456
- const completedAt = observedAt();
460
+ const completedAt = found.platformCompletedAt ?? observedAt();
457
461
  try {
458
462
  record = await save(found, { platform, platformCompletedAt: completedAt });
459
463
  }
@@ -466,6 +470,37 @@ export function createRecoverableMonetizationClient(input) {
466
470
  return resumeReward(record);
467
471
  });
468
472
  }
473
+ function canAdvanceReward(previous, next, hasRequest) {
474
+ if (previous.status !== 'pending') {
475
+ return false;
476
+ }
477
+ const legacyUpgrade = !hasRequest && allowLegacyRewardCompletion
478
+ && previous.rewardGranted === false && next.rewardGranted === true && next.status === 'completed';
479
+ // Once a candidate is recorded, its correlation cannot be replaced even
480
+ // when dispatch has not yet reached the server or its journal write failed.
481
+ if (previous.evidence !== undefined && (!sameRewardEvidence(previous.evidence, next.evidence)
482
+ || !legacyUpgrade && (previous.rewardGranted !== next.rewardGranted || previous.ledgerEntryId !== next.ledgerEntryId))) {
483
+ return false;
484
+ }
485
+ if (previous.ledgerEntryId !== undefined && previous.ledgerEntryId !== next.ledgerEntryId) {
486
+ return false;
487
+ }
488
+ const candidate = resolveClientRewardClaim(next, rewardEvidenceRegistry, {
489
+ allowLegacyCompletion: allowLegacyRewardCompletion,
490
+ });
491
+ if (candidate === undefined) {
492
+ return !hasRequest && previous.evidence === undefined && next.evidence === undefined
493
+ && (next.status === 'skipped' || next.status === 'unavailable' || next.status === 'failed');
494
+ }
495
+ const prior = resolveClientRewardClaim(previous, rewardEvidenceRegistry, {
496
+ allowLegacyCompletion: allowLegacyRewardCompletion,
497
+ });
498
+ if (prior !== undefined && (prior.platformImpressionId !== candidate.platformImpressionId
499
+ || !sameRewardEvidence(prior.evidence, candidate.evidence))) {
500
+ return false;
501
+ }
502
+ return !hasRequest || prior !== undefined;
503
+ }
469
504
  async function reconcile() {
470
505
  const pending = await store.listRecoverable(input.playerId);
471
506
  const summaries = [];
@@ -618,15 +653,25 @@ function assertPurchaseRequest(record, input) {
618
653
  }
619
654
  function assertRewardRequest(record, input) {
620
655
  const request = record.request;
656
+ const candidate = record.platform === undefined ? undefined : resolveClientRewardClaim(record.platform, input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry(), { allowLegacyCompletion: input.rewardEvidenceRegistry === undefined && input.gateway.ads.provider === undefined });
621
657
  if (request === undefined || record.platform === undefined
622
- || ((record.platform.status !== 'completed' || !record.platform.rewardGranted)
623
- && !isClientRewardEvidence(record.platform))
658
+ || candidate === undefined
624
659
  || request.playerId !== record.playerId || request.playerId !== input.playerId
625
660
  || request.target !== record.target || request.placementId !== record.input.placementId
626
661
  || request.idempotencyKey !== record.input.idempotencyKey
627
- || request.platformImpressionId !== resolveRewardPlatformImpressionId(record.platform)
662
+ || request.platformImpressionId !== candidate.platformImpressionId
663
+ || request.providerId !== undefined && request.providerId !== input.gateway.ads.provider?.id
664
+ || !sameRewardEvidence(request.evidence, candidate.evidence)
628
665
  || request.deploymentTarget !== (input.deploymentTarget === input.target
629
666
  ? undefined : input.deploymentTarget)) {
630
667
  throw new Error('Journaled ad claim is not bound to its platform result.');
631
668
  }
632
669
  }
670
+ function sameRewardEvidence(request, candidate) {
671
+ if (request === undefined || candidate === undefined) {
672
+ return request === candidate;
673
+ }
674
+ return request.schema === candidate.schema
675
+ && Object.keys(request.payload).length === Object.keys(candidate.payload).length
676
+ && Object.entries(request.payload).every(([key, value]) => Object.hasOwn(candidate.payload, key) && candidate.payload[key] === value);
677
+ }
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 { ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
5
6
  import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
6
7
  export type GameServicesAuthorityMode = 'production' | 'non-production';
7
8
  export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
@@ -32,6 +33,7 @@ interface CreateGameServicesRuntimeCommonInput {
32
33
  readonly now?: () => string;
33
34
  /** Durable, compare-and-swap journal for purchase and rewarded-ad recovery. */
34
35
  readonly operationStore?: MonetizationOperationStore;
36
+ readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
35
37
  }
36
38
  export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
37
39
  readonly transport?: 'http';
package/dist/runtime.js CHANGED
@@ -61,6 +61,7 @@ export function createGameServicesRuntime(input) {
61
61
  ? {}
62
62
  : { analyticsSessionId: input.analyticsSessionId }),
63
63
  ...(input.now === undefined ? {} : { now: input.now }),
64
+ ...(input.rewardEvidenceRegistry === undefined ? {} : { rewardEvidenceRegistry: input.rewardEvidenceRegistry }),
64
65
  };
65
66
  const client = createGameServicesClient(clientInput);
66
67
  const monetizationRecovery = input.operationStore === undefined
package/dist/server.js CHANGED
@@ -394,11 +394,13 @@ function resolveMaxBodyBytes(maxBodyBytes) {
394
394
  return resolved;
395
395
  }
396
396
  const gameServicesDeploymentTargetPlatforms = new Set([
397
+ 'browser',
397
398
  'microsoft-store',
398
399
  'android',
399
400
  'ios',
400
401
  'ait',
401
402
  'verse8',
403
+ 'reddit',
402
404
  ]);
403
405
  function resolveDeploymentTargetBindings(input) {
404
406
  const bindings = {};
package/dist/types.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { ClaimAdRewardResponse, PurchaseGrantFinalization, VerifyPurchaseRe
4
4
  export type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseGrantFinalizationAction, VerifyPurchaseResponse, } from './operation-results.js';
5
5
  export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
6
6
  export type GameServicesStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait'>;
7
- export type GameServicesAdRewardTarget = Extract<PlatformTarget, 'android' | 'ios' | 'ait' | 'verse8'>;
7
+ export type GameServicesAdRewardTarget = GameServicesLedgerTarget;
8
8
  export type GameServicesLeaderboardTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit'>;
9
9
  export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit' | 'verse8'>;
10
10
  export type PurchaseIdempotencyKey = string;
@@ -25,6 +25,8 @@ export interface VerifyPurchaseRequest {
25
25
  }
26
26
  export interface ClaimAdRewardRequest {
27
27
  readonly target: GameServicesAdRewardTarget;
28
+ /** Versioned provider identity, independent of build target; checked by server registration. */
29
+ readonly providerId?: string;
28
30
  /** Deployment config key used to resolve game-owned platform placement identifiers. */
29
31
  readonly deploymentTarget?: string;
30
32
  readonly playerId: string;
package/dist/types.js CHANGED
@@ -58,6 +58,7 @@ export function assertClaimAdRewardRequest(input) {
58
58
  return validateRequest(() => {
59
59
  assertRecord(input, 'ClaimAdRewardRequest');
60
60
  assertAdRewardTarget(input.target);
61
+ assertOptionalRequestString(input.providerId, 'providerId');
61
62
  assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
62
63
  assertRequestString(input.playerId, 'playerId');
63
64
  assertRequestString(input.placementId, 'placementId');
@@ -158,8 +159,9 @@ function assertStoreTarget(input) {
158
159
  }
159
160
  }
160
161
  function assertAdRewardTarget(input) {
161
- if (input !== 'android' && input !== 'ios' && input !== 'ait' && input !== 'verse8') {
162
- throw new Error('target must be android, ios, ait, or verse8.');
162
+ if (input !== 'browser' && input !== 'microsoft-store' && input !== 'android'
163
+ && input !== 'ios' && input !== 'ait' && input !== 'reddit' && input !== 'verse8') {
164
+ throw new Error('target must be a supported platform target.');
163
165
  }
164
166
  }
165
167
  export function assertGameServicesDeploymentTarget(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,14 @@
25
25
  "type": "module",
26
26
  "sideEffects": false,
27
27
  "exports": {
28
+ "./client-reward-evidence": {
29
+ "types": "./dist/client-reward-evidence.d.ts",
30
+ "default": "./dist/client-reward-evidence.js"
31
+ },
32
+ "./default-client-reward-evidence": {
33
+ "types": "./dist/default-client-reward-evidence.d.ts",
34
+ "default": "./dist/default-client-reward-evidence.js"
35
+ },
28
36
  ".": {
29
37
  "types": "./dist/index.d.ts",
30
38
  "default": "./dist/index.js"
@@ -144,6 +152,10 @@
144
152
  "./operations": {
145
153
  "types": "./dist/operations.d.ts",
146
154
  "default": "./dist/operations.js"
155
+ },
156
+ "./ad-reward-verifier-registry": {
157
+ "types": "./dist/ad-reward-verifier-registry.d.ts",
158
+ "default": "./dist/ad-reward-verifier-registry.js"
147
159
  }
148
160
  },
149
161
  "files": [
@@ -154,9 +166,9 @@
154
166
  "@orpc/client": "2.0.0-beta.14",
155
167
  "@orpc/contract": "2.0.0-beta.14",
156
168
  "@orpc/server": "2.0.0-beta.14",
157
- "@mpgd/analytics": "0.4.0",
158
- "@mpgd/catalog": "0.7.6",
159
- "@mpgd/platform": "0.14.0"
169
+ "@mpgd/catalog": "0.7.7",
170
+ "@mpgd/analytics": "0.4.1",
171
+ "@mpgd/platform": "0.15.0"
160
172
  },
161
173
  "devDependencies": {
162
174
  "@opentelemetry/api": "1.9.0",
@@ -174,8 +186,8 @@
174
186
  "format": "ttsc format",
175
187
  "fix": "ttsc fix",
176
188
  "build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
177
- "test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs",
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",
189
+ "test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
190
+ "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 && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts",
179
191
  "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
192
  "test": "pnpm test:unit && pnpm test:recovery && pnpm test:packed-transport"
181
193
  }