@mpgd/platform 0.12.1 → 0.13.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,6 @@
1
1
  import type { PlatformCapabilities, PlatformGateway, PlatformTarget } from './index.js';
2
2
  export declare const platformCapabilityKeys: readonly ["nativeIap", "nativeAds", "rewardedAds", "interstitialAds", "nativeLeaderboard", "remoteLeaderboard", "achievements", "cloudSave", "socialShare", "haptics", "localizedContent"];
3
- export declare const optionalPlatformCapabilityKeys: readonly ["bannerAds"];
3
+ export declare const optionalPlatformCapabilityKeys: readonly ["bannerAds", "subscriptionIap"];
4
4
  export interface PlatformGatewayCapabilityConformanceTransition {
5
5
  readonly update: () => Promise<void> | void;
6
6
  readonly expectedCapabilities: PlatformCapabilities;
@@ -26,5 +26,11 @@ export interface PlatformGatewayCapabilityConformanceReport {
26
26
  * Runs provider-neutral capability checks against real adapter gateways or
27
27
  * target-configured wrappers. Every read must return a complete boolean
28
28
  * snapshot, a fresh object, and the provider's latest state.
29
+ * @evidence docs/specs/platform-capability-snapshots.md#snapshot-shape Checks each gateway read against the required boolean keys and expected provider state.
30
+ * @evidenceReview docs/specs/platform-capability-snapshots.md#snapshot-shape #a1834db Reviewed required booleans, optional banner/subscription defaults, ordered readiness comparison, and fresh nested rereads.
31
+ * @evidence docs/specs/platform-capability-snapshots.md#provider-transitions Checks a fresh provider read after the optional transition update.
32
+ * @evidenceReview docs/specs/platform-capability-snapshots.md#provider-transitions #839c0c8 Reviewed the changed-state guard and post-update snapshot assertion.
33
+ * @evidence docs/specs/platform-capability-snapshots.md#fixture-validation Rejects invalid fixture sets and returns the names of passing fixtures.
34
+ * @evidenceReview docs/specs/platform-capability-snapshots.md#fixture-validation #d566052 Checked the nonempty and unique name guards and target assertion.
29
35
  */
30
36
  export declare function runPlatformGatewayCapabilityConformance(input: RunPlatformGatewayCapabilityConformanceInput): Promise<PlatformGatewayCapabilityConformanceReport>;
@@ -13,17 +13,44 @@ export const platformCapabilityKeys = Object.freeze([
13
13
  ]);
14
14
  export const optionalPlatformCapabilityKeys = Object.freeze([
15
15
  'bannerAds',
16
+ 'subscriptionIap',
16
17
  ]);
17
18
  const allowedPlatformCapabilityKeys = Object.freeze([
18
19
  ...platformCapabilityKeys,
19
20
  ...optionalPlatformCapabilityKeys,
20
21
  ]);
21
- const allowedPlatformCapabilityKeySet = new Set(allowedPlatformCapabilityKeys);
22
+ const allowedPlatformCapabilityKeySet = new Set([
23
+ ...allowedPlatformCapabilityKeys,
24
+ 'providerAvailability',
25
+ ]);
22
26
  const optionalPlatformCapabilityKeySet = new Set(optionalPlatformCapabilityKeys);
27
+ const providerFeatureSet = new Set([
28
+ 'nativeIap',
29
+ 'subscriptionIap',
30
+ 'rewardedAds',
31
+ 'interstitialAds',
32
+ 'bannerAds',
33
+ 'nativeLeaderboard',
34
+ 'identityUpgrade',
35
+ 'pushNotifications',
36
+ ]);
37
+ const providerAvailabilitySet = new Set([
38
+ 'unsupported',
39
+ 'configuration-required',
40
+ 'action-required',
41
+ 'temporarily-unavailable',
42
+ 'available',
43
+ ]);
23
44
  /**
24
45
  * Runs provider-neutral capability checks against real adapter gateways or
25
46
  * target-configured wrappers. Every read must return a complete boolean
26
47
  * snapshot, a fresh object, and the provider's latest state.
48
+ * @evidence docs/specs/platform-capability-snapshots.md#snapshot-shape Checks each gateway read against the required boolean keys and expected provider state.
49
+ * @evidenceReview docs/specs/platform-capability-snapshots.md#snapshot-shape #a1834db Reviewed required booleans, optional banner/subscription defaults, ordered readiness comparison, and fresh nested rereads.
50
+ * @evidence docs/specs/platform-capability-snapshots.md#provider-transitions Checks a fresh provider read after the optional transition update.
51
+ * @evidenceReview docs/specs/platform-capability-snapshots.md#provider-transitions #839c0c8 Reviewed the changed-state guard and post-update snapshot assertion.
52
+ * @evidence docs/specs/platform-capability-snapshots.md#fixture-validation Rejects invalid fixture sets and returns the names of passing fixtures.
53
+ * @evidenceReview docs/specs/platform-capability-snapshots.md#fixture-validation #d566052 Checked the nonempty and unique name guards and target assertion.
27
54
  */
28
55
  export async function runPlatformGatewayCapabilityConformance(input) {
29
56
  if (input.fixtures.length === 0) {
@@ -52,21 +79,28 @@ export async function runPlatformGatewayCapabilityConformance(input) {
52
79
  return { passedFixtures };
53
80
  }
54
81
  async function runFixture(fixture) {
82
+ // A fixture may accidentally share its expected object with the provider.
83
+ // Keep the oracle independent before tryMutateSnapshot probes isolation.
84
+ const initialExpected = cloneExpectedCapabilities(fixture.expectedCapabilities);
85
+ const transitionExpected = fixture.transition === undefined
86
+ ? undefined
87
+ : cloneExpectedCapabilities(fixture.transition.expectedCapabilities);
55
88
  assertEqual(fixture.gateway.target, fixture.expectedTarget, 'gateway target must match the fixture target');
56
89
  const first = await fixture.gateway.getCapabilities();
57
- assertCapabilitySnapshot(first, fixture.expectedCapabilities);
90
+ assertCapabilitySnapshot(first, initialExpected);
58
91
  tryMutateSnapshot(first);
59
92
  const second = await fixture.gateway.getCapabilities();
60
93
  assert(second !== first, 'getCapabilities must return a fresh snapshot object for every read');
61
- assertCapabilitySnapshot(second, fixture.expectedCapabilities);
62
- if (fixture.transition === undefined) {
94
+ assertCapabilitySnapshot(second, initialExpected);
95
+ if (fixture.transition === undefined || transitionExpected === undefined) {
63
96
  return;
64
97
  }
65
- assert(hasCapabilityDifference(fixture.expectedCapabilities, fixture.transition.expectedCapabilities), 'a transition fixture must change at least one capability');
98
+ assert(hasCapabilityDifference(initialExpected, transitionExpected), 'a transition fixture must change at least one capability');
66
99
  await fixture.transition.update();
67
100
  const updated = await fixture.gateway.getCapabilities();
68
101
  assert(updated !== second, 'getCapabilities must return a fresh snapshot after a provider transition');
69
- assertCapabilitySnapshot(updated, fixture.transition.expectedCapabilities);
102
+ assert(updated !== first, 'getCapabilities must not reuse an earlier snapshot after a provider transition');
103
+ assertCapabilitySnapshot(updated, transitionExpected);
70
104
  }
71
105
  function assertCapabilitySnapshot(actual, expected) {
72
106
  const actualKeys = Object.keys(actual).sort();
@@ -80,6 +114,29 @@ function assertCapabilitySnapshot(actual, expected) {
80
114
  }
81
115
  assertEqual(optional ? (actual[key] ?? false) : actual[key], optional ? (expected[key] ?? false) : expected[key], `capability ${key} must match the expected provider state`);
82
116
  }
117
+ assertProviderAvailabilityShape(actual.providerAvailability);
118
+ assertEqual(normalizedAvailability(actual.providerAvailability), normalizedAvailability(expected.providerAvailability), 'provider availability must match the expected provider state');
119
+ }
120
+ function assertProviderAvailabilityShape(value) {
121
+ if (value === undefined) {
122
+ return;
123
+ }
124
+ assert(typeof value === 'object' && value !== null && !Array.isArray(value), 'provider availability must be a non-array record');
125
+ for (const [feature, state] of Object.entries(value)) {
126
+ assert(providerFeatureSet.has(feature)
127
+ && providerAvailabilitySet.has(state), 'provider availability must contain only known features and readiness states');
128
+ }
129
+ }
130
+ function cloneExpectedCapabilities(value) {
131
+ return {
132
+ ...value,
133
+ ...(value.providerAvailability === undefined
134
+ ? {}
135
+ : { providerAvailability: { ...value.providerAvailability } }),
136
+ };
137
+ }
138
+ function normalizedAvailability(value) {
139
+ return JSON.stringify(Object.entries(value ?? {}).sort(([a], [b]) => a.localeCompare(b)));
83
140
  }
84
141
  function tryMutateSnapshot(snapshot) {
85
142
  try {
@@ -88,10 +145,23 @@ function tryMutateSnapshot(snapshot) {
88
145
  catch {
89
146
  // Frozen snapshots already satisfy the isolation requirement.
90
147
  }
148
+ if (snapshot.providerAvailability !== undefined) {
149
+ try {
150
+ const next = snapshot.providerAvailability.nativeIap === 'available'
151
+ ? 'unsupported'
152
+ : 'available';
153
+ Reflect.set(snapshot.providerAvailability, 'nativeIap', next);
154
+ }
155
+ catch {
156
+ // A frozen nested readiness record already satisfies isolation.
157
+ }
158
+ }
91
159
  }
92
160
  function hasCapabilityDifference(first, second) {
93
161
  return allowedPlatformCapabilityKeys
94
- .some((key) => (first[key] ?? false) !== (second[key] ?? false));
162
+ .some((key) => (first[key] ?? false) !== (second[key] ?? false))
163
+ || normalizedAvailability(first.providerAvailability)
164
+ !== normalizedAvailability(second.providerAvailability);
95
165
  }
96
166
  function assert(condition, message) {
97
167
  if (!condition) {
package/dist/index.d.ts CHANGED
@@ -59,7 +59,29 @@ export interface PurchaseResult {
59
59
  }
60
60
  export interface PurchaseRestoreResult {
61
61
  readonly restoredEntitlements: readonly Entitlement[];
62
+ /** Server-confirmed consumable outcomes; native order visibility alone is not a grant. */
63
+ readonly settledPurchases?: readonly PurchaseSettlement[];
62
64
  }
65
+ export interface PurchaseSettlement {
66
+ readonly transactionId: string;
67
+ readonly productId: LogicalProductId;
68
+ readonly status: 'granted' | 'refunded';
69
+ readonly ledgerEntryId?: string;
70
+ readonly alreadyProcessed?: boolean;
71
+ }
72
+ /**
73
+ * Resolve a specific purchase from authoritative checkout or restore evidence.
74
+ * A completed native checkout without authoritativeGrant is still unconfirmed.
75
+ * This is UI status evidence, never permission to credit a wallet locally.
76
+ * The caller must pair `purchase` with its requested `productId` because
77
+ * PurchaseResult itself does not carry a product identifier.
78
+ */
79
+ export declare function findAuthoritativePurchaseSettlement(input: {
80
+ readonly productId: LogicalProductId;
81
+ readonly transactionId?: string;
82
+ readonly purchase?: PurchaseResult;
83
+ readonly restore?: PurchaseRestoreResult;
84
+ }): PurchaseSettlement | null;
63
85
  export interface RewardedAdResult {
64
86
  readonly status: 'completed' | 'skipped' | 'unavailable' | 'failed';
65
87
  readonly rewardGranted: boolean;
@@ -92,6 +114,8 @@ export interface CommerceAdapter {
92
114
  export interface AdAdapter {
93
115
  preload(input: {
94
116
  readonly placementId: LogicalAdPlacementId;
117
+ /** Allows a shared provider preload method to enforce format readiness. */
118
+ readonly format?: 'rewarded' | 'interstitial' | 'banner';
95
119
  }): Promise<void>;
96
120
  showRewarded(input: {
97
121
  readonly placementId: LogicalAdPlacementId;
@@ -139,6 +163,8 @@ export interface LeaderboardScoreInput {
139
163
  readonly score: number;
140
164
  readonly runId: string;
141
165
  readonly submittedAt: string;
166
+ /** Optional target-selected route when native and remote backends coexist. */
167
+ readonly route?: 'native' | 'remote';
142
168
  }
143
169
  export interface LeaderboardSubmitResult {
144
170
  readonly submitted: boolean;
@@ -148,10 +174,17 @@ export interface LeaderboardAdapter {
148
174
  submitScore(input: LeaderboardScoreInput): Promise<LeaderboardSubmitResult>;
149
175
  open(input?: {
150
176
  readonly leaderboardId?: string;
177
+ readonly route?: 'native' | 'remote';
151
178
  }): Promise<void>;
152
179
  }
180
+ /** Readiness of an installed platform provider, independent of target policy. */
181
+ export type PlatformProviderAvailability = 'unsupported' | 'configuration-required' | 'action-required' | 'temporarily-unavailable' | 'available';
182
+ /** Existing capabilities and integrations annotated by optional providers. */
183
+ export type PlatformProviderFeature = 'nativeIap' | 'subscriptionIap' | 'rewardedAds' | 'interstitialAds' | 'bannerAds' | 'nativeLeaderboard' | 'identityUpgrade' | 'pushNotifications';
153
184
  export interface PlatformCapabilities {
154
185
  readonly nativeIap: boolean;
186
+ /** Optional for adapters published before subscription-specific support. */
187
+ readonly subscriptionIap?: boolean;
155
188
  readonly nativeAds: boolean;
156
189
  readonly rewardedAds: boolean;
157
190
  readonly interstitialAds: boolean;
@@ -165,6 +198,8 @@ export interface PlatformCapabilities {
165
198
  readonly socialShare: boolean;
166
199
  readonly haptics: boolean;
167
200
  readonly localizedContent: boolean;
201
+ /** Explains a false capability without claiming an unavailable SDK works. */
202
+ readonly providerAvailability?: Readonly<Partial<Record<PlatformProviderFeature, PlatformProviderAvailability>>>;
168
203
  }
169
204
  export interface PlayerIdentity {
170
205
  readonly playerId: string;
@@ -245,9 +280,77 @@ export interface NotificationSubscriptionAdapter {
245
280
  getStatus(topic: NotificationTopic): Promise<NotificationSubscriptionStatus>;
246
281
  requestSubscription(topic: NotificationTopic): Promise<NotificationSubscriptionResult>;
247
282
  }
283
+ /** The source of an app URL, not a claim that the URL is trusted. */
284
+ export interface NativeOpenUrlEvent {
285
+ readonly url: string;
286
+ readonly source: 'cold' | 'warm';
287
+ }
288
+ export interface NativeBackButtonEvent {
289
+ /** Whether the WebView history can navigate back, not the game's own scene stack. */
290
+ readonly canGoBack: boolean;
291
+ }
292
+ /** Full WebView viewport geometry in CSS pixels, before applying game-owned padding. */
293
+ export interface PlatformViewportInsets {
294
+ readonly top: number;
295
+ readonly right: number;
296
+ readonly bottom: number;
297
+ readonly left: number;
298
+ }
299
+ export interface PlatformViewportBounds {
300
+ readonly x: number;
301
+ readonly y: number;
302
+ readonly width: number;
303
+ readonly height: number;
304
+ }
305
+ /** An occupied game surface with stable identity, not an extra inset to sum. */
306
+ export interface PlatformViewportOccupiedSurface {
307
+ readonly surfaceId: string;
308
+ readonly edge: 'top' | 'right' | 'bottom' | 'left';
309
+ readonly bounds: PlatformViewportBounds;
310
+ }
311
+ /**
312
+ * Structurally matches `TargetViewportUsableAreaInput` in @mpgd/target-config
313
+ * (field names, CSS-pixel units, and edge union), so consumers can pass the
314
+ * same state to `resolveTargetViewportUsableArea` without conversion.
315
+ */
316
+ export interface PlatformViewportState {
317
+ readonly width: number;
318
+ readonly height: number;
319
+ readonly safeAreaInsets: PlatformViewportInsets;
320
+ readonly systemBarInsets: PlatformViewportInsets;
321
+ readonly keyboardInsets: PlatformViewportInsets;
322
+ readonly occupiedSurfaces: readonly PlatformViewportOccupiedSurface[];
323
+ }
324
+ /** Optional native viewport source; consumers use one owner for canvas and DOM layout. */
325
+ export interface ViewportAdapter {
326
+ getState(): PlatformViewportState;
327
+ onChange(callback: (state: PlatformViewportState) => void): () => void;
328
+ dispose?(): void;
329
+ }
248
330
  export interface LifecycleAdapter {
249
331
  onPause(callback: () => void): () => void;
250
332
  onResume(callback: () => void): () => void;
333
+ /**
334
+ * Android handlers run last-registered-first until one returns true. If none
335
+ * consumes the event, Capacitor navigates WebView history when possible or
336
+ * exits the app. The method's presence does not imply support on iOS: check
337
+ * the gateway target. Persist progress at checkpoints; back/exit callbacks
338
+ * are not guaranteed to run before process termination.
339
+ */
340
+ onBackButton?(handler: (event: NativeBackButtonEvent) => boolean | Promise<boolean>): () => void;
341
+ /** Game links and OAuth redirects use separate callbacks. */
342
+ onGameUrlOpen?(callback: (event: NativeOpenUrlEvent) => void): () => void;
343
+ onOAuthRedirect?(callback: (event: NativeOpenUrlEvent) => void): () => void;
344
+ getInitialGameUrl?(): Promise<NativeOpenUrlEvent | null>;
345
+ getInitialOAuthRedirect?(): Promise<NativeOpenUrlEvent | null>;
346
+ /**
347
+ * Keep execution paused while external UI is open. Call the returned
348
+ * idempotent release function when it closes, including failure paths;
349
+ * otherwise execution remains paused.
350
+ */
351
+ beginExternalActivity?(): () => void;
352
+ /** Remove only the native listeners owned by this adapter instance. */
353
+ dispose?(): Promise<void>;
251
354
  }
252
355
  export interface StorageLoadResult {
253
356
  readonly value: unknown;
@@ -262,6 +365,19 @@ export interface StorageAdapter {
262
365
  readonly value: unknown;
263
366
  }): Promise<void>;
264
367
  }
368
+ /** Device-protected opaque credentials; never backed by ordinary game storage. */
369
+ export interface SecureCredentialStore {
370
+ load(input: {
371
+ readonly key: string;
372
+ }): Promise<string | null>;
373
+ save(input: {
374
+ readonly key: string;
375
+ readonly value: string;
376
+ }): Promise<void>;
377
+ remove(input: {
378
+ readonly key: string;
379
+ }): Promise<void>;
380
+ }
265
381
  export interface PlatformGateway {
266
382
  readonly target: PlatformTarget;
267
383
  getCapabilities(): Promise<PlatformCapabilities>;
@@ -270,7 +386,10 @@ export interface PlatformGateway {
270
386
  readonly ads: AdAdapter;
271
387
  readonly leaderboard: LeaderboardAdapter;
272
388
  readonly lifecycle: LifecycleAdapter;
389
+ readonly viewport?: ViewportAdapter;
273
390
  readonly storage: StorageAdapter;
391
+ /** Optional: missing native support must not fall back to plaintext storage. */
392
+ readonly secureCredentials?: SecureCredentialStore;
274
393
  readonly presentation?: PresentationAdapter;
275
394
  readonly sharing?: ShareAdapter;
276
395
  readonly notifications?: NotificationSubscriptionAdapter;
package/dist/index.js CHANGED
@@ -41,6 +41,41 @@ function isPlatformOperationCode(code) {
41
41
  && code.length <= 128
42
42
  && /^[A-Z][A-Z0-9_:-]*$/u.test(code);
43
43
  }
44
+ /**
45
+ * Resolve a specific purchase from authoritative checkout or restore evidence.
46
+ * A completed native checkout without authoritativeGrant is still unconfirmed.
47
+ * This is UI status evidence, never permission to credit a wallet locally.
48
+ * The caller must pair `purchase` with its requested `productId` because
49
+ * PurchaseResult itself does not carry a product identifier.
50
+ */
51
+ export function findAuthoritativePurchaseSettlement(input) {
52
+ const effectiveTransactionId = input.transactionId ?? input.purchase?.transactionId;
53
+ const matchingRestored = input.restore?.settledPurchases?.filter((settlement) => settlement.productId === input.productId
54
+ && (effectiveTransactionId === undefined || settlement.transactionId === effectiveTransactionId));
55
+ // Without an order ID, multiple historical outcomes are ambiguous.
56
+ if (matchingRestored !== undefined && matchingRestored.length > 1) {
57
+ return null;
58
+ }
59
+ if (matchingRestored?.[0] !== undefined) {
60
+ return matchingRestored[0];
61
+ }
62
+ const purchase = input.purchase;
63
+ if (purchase?.status !== 'completed'
64
+ || purchase.transactionId === undefined
65
+ || purchase.authoritativeGrant === undefined
66
+ || (effectiveTransactionId !== undefined && effectiveTransactionId !== purchase.transactionId)) {
67
+ return null;
68
+ }
69
+ return {
70
+ transactionId: purchase.transactionId,
71
+ productId: input.productId,
72
+ status: 'granted',
73
+ ledgerEntryId: purchase.authoritativeGrant.ledgerEntryId,
74
+ ...(purchase.authoritativeGrant.alreadyProcessed === undefined
75
+ ? {}
76
+ : { alreadyProcessed: purchase.authoritativeGrant.alreadyProcessed }),
77
+ };
78
+ }
44
79
  export function resolveShareCompletion(result) {
45
80
  if (result.status !== 'shared') {
46
81
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/platform",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Shared platform gateway, monetization, ads, leaderboard, identity, lifecycle, and storage contracts for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {