@mpgd/game-runtime 0.2.6 → 0.4.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
@@ -73,6 +73,10 @@ pnpm add phaser@^4.2.0
73
73
  | `@mpgd/game-runtime/ui` | Scoped snapshots, commands and events |
74
74
  | `@mpgd/game-runtime/platform` | Injected lifecycle source binding |
75
75
  | `@mpgd/game-runtime/actions` | Purchase and rewarded-ad coordination |
76
+ | `@mpgd/game-runtime/presentation` | Shared native full-screen ownership |
77
+ | `@mpgd/game-runtime/game` | Game-owned gateway, service, lifecycle and action assembly |
78
+ | `@mpgd/game-runtime/audio` | Game-owned audio projection |
79
+ | `@mpgd/game-runtime/ads` | Coordinated provider and gateway, injected deadlines and policy |
76
80
  | `@mpgd/game-runtime/phaser` | Optional gameplay scene binding |
77
81
 
78
82
  Phaser is an optional peer dependency. The root and headless subpaths do not
@@ -96,6 +100,56 @@ and `node packages/game-runtime/test/dist-import.mjs`. The package test also
96
100
  installs packed tarballs in an isolated consumer, checks headless declarations
97
101
  without DOM types, and checks the optional binding with Phaser declarations.
98
102
 
103
+ ## Native presentation and financial settlement
104
+
105
+ Create one `createFullScreenPresentationScope({ execution })` above all screens.
106
+ Pass it to `createCoordinatedPlatformGateway` with the original gateway and its
107
+ v2 ad provider. Use the returned gateway for both direct platform calls and the
108
+ game-services client; pass the same scope and execution controller to
109
+ `createGameActionCoordinator`. The coordinator rejects a mismatched controller.
110
+
111
+ The scope owns simulation, gameplay input, and audio blocks only while native UI
112
+ is open or uncertain. Native closure releases that invocation's blocks even
113
+ while the game-services client is waiting for a backend claim. Settings and
114
+ background blocks remain independently owned. Financial operations retain their
115
+ existing duplicate, pending, and reconciliation guards.
116
+
117
+ An injected advertising deadline ends caller waiting and returns an uncertain
118
+ result. It never cancels the native SDK or proves closure. Native close and late
119
+ reward observers survive view disposal while needed. `onClaimEvidence` reports
120
+ late candidates under the original invocation and idempotency key for an
121
+ application-owned claim journal; it cannot grant rewards. Use a durable
122
+ game-services operation store when recovery must survive process restart.
123
+ `claimEvidence` carries a server proof-lookup correlation without asserting local
124
+ SDK eligibility. Registered server verification and its ledger remain authoritative.
125
+
126
+ Use `createAdClaimEvidenceRecoveryObserver({ recovery, onResult })` to connect
127
+ `onClaimEvidence` to an application-owned recoverable monetization client. Bind
128
+ the handler during bootstrap before enabling SDK entrypoints. The observer
129
+ always forwards an ungranted pending candidate under the original idempotency
130
+ key, which may differ from the display invocation ID. Recovery serializes with
131
+ the original operation, attaches a newly registered lookup envelope to a pending
132
+ journal, and retries its recorded verification request without another SDK call.
133
+ Previously recorded evidence and the first journal timestamp remain fixed.
134
+ An eligibility change can prompt another lookup under the same identity.
135
+ `onResult` observes backend settlement; refresh authoritative state or reconcile
136
+ the action coordinator rather than adding rewards directly from SDK callbacks.
137
+
138
+ Purchase business results do not prove that native UI closed. Without a trusted
139
+ `classifyPurchasePresentation` function or a native `purchasePresentation`
140
+ event source, the wrapper keeps presentation unknown. Wire actual native close
141
+ facts before enabling the managed purchase path. Events are scoped to the
142
+ original idempotency key and a monotonic sequence; late or foreign events cannot
143
+ release another invocation. Disposal rejects new calls and retains uncertain
144
+ native close observation. Lightweight key history prevents retired purchase
145
+ keys from reopening the SDK; only active/uncertain results and the last settled
146
+ result are retained.
147
+
148
+ `canShow` is an injected placement policy. No target-specific branch belongs in
149
+ the game flow. Availability and preparation gate SDK calls; a shared busy surface
150
+ blocks purchases, rewarded ads, and interstitials together. The adapter-level
151
+ SDK conformance suites certify each provider separately from these runtime tests.
152
+
99
153
  ## Scoped UI bridge
100
154
 
101
155
  `@mpgd/game-runtime/ui` is a separate, headless entrypoint. Importing the root
@@ -360,3 +414,60 @@ A reentrant same-key joiner cannot cancel that owner's startup by disposing itse
360
414
  If owner/runtime disposal prevents any client invocation, the flight rejects with
361
415
  a scheduling error, resets its observed state to `idle`, and emits no business
362
416
  completion/exception event. An invoked client failure remains `exception`.
417
+
418
+ ## Game-owned platform assembly
419
+
420
+ Create one `createGamePlatformRuntime` from `@mpgd/game-runtime/game` during
421
+ bootstrap. Pass the original gateway, an explicit initial lifecycle state,
422
+ a game policy, optional injected wait deadline, and `createServices(gateway)`.
423
+ The services factory must use the supplied coordinated gateway. Its structural
424
+ ports keep this subpath independent of DOM, Phaser, network and schema imports,
425
+ while preserving target-specific gateway extensions and concrete service types.
426
+
427
+ A versioned provider installs one shared scope for purchases, rewarded ads and
428
+ interstitials. Legacy gateways retain their existing surface and coarse action
429
+ blocking. V2 rewarded display is disabled until the services expose
430
+ `monetizationRecovery`: the application must supply a durable, atomic,
431
+ encrypted operation store rather than a memory fallback. Startup and resume
432
+ reconcile existing records without opening SDK UI. Account changes require a
433
+ new client/runtime bound to that account.
434
+
435
+ Keep the runtime above scenes. Create scene-owned action controllers from
436
+ `runtime.actions`; dispose those controllers and their input listeners on
437
+ scene shutdown. After an await, check the captured controller's disposal state
438
+ before touching scene objects. A trusted `reconciliation` port can unlock new
439
+ financial keys only after reading the matching authoritative ledger grant.
440
+ The original key/history remains retired and never becomes an SDK retry.
441
+
442
+ Late registered candidates reach `recoverRewardResult` under the original
443
+ operation idempotency key. `onLateResult` also exposes provider settlement
444
+ that follows a caller deadline. The game assembly journals a definitive
445
+ not-started/non-earned result only when no prior candidate was observed;
446
+ previous proof/request identity is never replaced by a negative callback.
447
+ Native closure and business settlement remain independent, including during
448
+ runtime teardown. Purchase results still require native `purchasePresentation`
449
+ facts to prove physical closure; a business success alone remains unknown.
450
+
451
+ Bind `bindGameAudio({ execution, sink })` once to the game's sound manager.
452
+ Scenes use `bindPhaserGameScene({ audioOwner: 'game', ... })` and never supply
453
+ a competing scene audio sink. Scene shutdown therefore cannot unmute a live
454
+ native presentation or release background/settings ownership. Disposing an
455
+ audio projection while blocked leaves mute in place. Destroy execution on game
456
+ teardown before detaching the audio projection. Express additional user mute
457
+ ownership with an audio execution block.
458
+
459
+ The AIT SDK/host/proxy integration test combines this assembly with the real
460
+ client recovery, registered independent verifier and replay-safe ledger. It
461
+ proves late evidence after view disposal, pending authority with zero grants,
462
+ authoritative settlement, immutable request/timestamp reuse, proof replay
463
+ rejection and reconstructed-client recovery with one SDK show. Its memory
464
+ journal/ledger are test fixtures, not production persistence or live Toss
465
+ certification.
466
+
467
+ `actions.confirmRecoveredRewardResult(key, result)` accepts only a trusted
468
+ journal recovery's confirmed non-grant result for a matching rewarded action.
469
+ It can retire a late SDK preparation failure or a definitive server rejection,
470
+ while preserving original key history and any live native lease. Foreign keys,
471
+ purchase actions, pending results and grant-shaped results cannot be unlocked
472
+ through this path. Positive grants continue through the authoritative ledger
473
+ reconciliation port. This method never updates a disposed view or opens UI.
@@ -1,6 +1,7 @@
1
1
  import type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseProgress, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdProgress, GameServicesRewardedAdResult } from '@mpgd/game-services/operations';
2
2
  import type { GameExecutionController } from '../index.js';
3
3
  import { type ObserverErrorHandler } from '../observers.js';
4
+ import type { FullScreenPresentationScope } from '../presentation/index.js';
4
5
  import { type GameUiScope, type UiListener } from '../ui/index.js';
5
6
  export type GameActionKind = 'purchase' | 'rewarded-ad';
6
7
  interface Inputs {
@@ -93,6 +94,8 @@ export interface GameActionCoordinator {
93
94
  createPurchaseController(): GameActionController<'purchase'>;
94
95
  createRewardedAdController(): GameActionController<'rewarded-ad'>;
95
96
  getPendingOperation(): GameActionPendingOperation | undefined;
97
+ /** Trusted journal recovery only. Retire a matching non-grant reward without reopening SDK UI. */
98
+ confirmRecoveredRewardResult(idempotencyKey: string, result: GameServicesRewardedAdResult): boolean;
96
99
  /** Concurrent callers join one recovery query; only a matching committed grant unlocks new keys. */
97
100
  reconcile(): Promise<GameActionReconciliationResult>;
98
101
  getAvailability(): 'ready' | 'busy' | 'reconciliation-required' | 'history-full' | 'disposed';
@@ -103,6 +106,8 @@ export interface GameActionCoordinator {
103
106
  export declare function createGameActionCoordinator(options: {
104
107
  readonly execution: GameExecutionController;
105
108
  readonly client: Pick<GameServicesOperationClient, 'purchase' | 'claimRewardedAd'>;
109
+ /** Use only with a client backed by createCoordinatedPlatformGateway sharing this scope/execution. */
110
+ readonly presentation?: FullScreenPresentationScope;
106
111
  /** Never evicts keys: once full, new keys are rejected until application teardown. Default 1024. */
107
112
  readonly maxRememberedKeys?: number;
108
113
  readonly reconciliation?: GameActionReconciliationPort;
@@ -18,6 +18,9 @@ const purchaseSources = {
18
18
  /** Bind one coordinator to one runtime/client (including its player identity), above all screens. */
19
19
  export function createGameActionCoordinator(options) {
20
20
  const { execution, client, onObserverError } = options;
21
+ if (options.presentation !== undefined && options.presentation.execution !== execution) {
22
+ throw new TypeError('Action and presentation coordination must share one execution controller.');
23
+ }
21
24
  const capacity = options.maxRememberedKeys ?? 1024;
22
25
  const recoveryPlayerId = options.reconciliation?.playerId;
23
26
  const recoveryPort = options.reconciliation;
@@ -34,9 +37,11 @@ export function createGameActionCoordinator(options) {
34
37
  let nextId = 0;
35
38
  let disposed = false;
36
39
  let unconfirmed;
40
+ const recoveredNonGrants = new Set();
37
41
  let recoveryFlight;
38
42
  function isDisposed() {
39
- return disposed || execution.getSnapshot().status === 'destroyed';
43
+ return disposed || execution.getSnapshot().status === 'destroyed'
44
+ || options.presentation?.getSnapshot().status === 'disposed';
40
45
  }
41
46
  function reserve(kind, supplied, canStart) {
42
47
  if (isDisposed()) {
@@ -78,6 +83,9 @@ export function createGameActionCoordinator(options) {
78
83
  if (current !== undefined) {
79
84
  throw new GameActionExecutionError('busy');
80
85
  }
86
+ if (options.presentation?.getSnapshot().owner !== undefined) {
87
+ throw new GameActionExecutionError('busy');
88
+ }
81
89
  if (history.size >= capacity) {
82
90
  throw new GameActionExecutionError('history-full');
83
91
  }
@@ -99,9 +107,10 @@ export function createGameActionCoordinator(options) {
99
107
  let invoked = false;
100
108
  function finish(status) {
101
109
  flight.settled = true;
102
- if (invoked && (status === 'pending' || status === 'exception')) {
110
+ if (invoked && (status === 'pending' || status === 'exception') && !(kind === 'rewarded-ad' && recoveredNonGrants.has(key))) {
103
111
  unconfirmed = Object.freeze({ kind, operationId: id, input });
104
112
  }
113
+ recoveredNonGrants.delete(key);
105
114
  current = undefined;
106
115
  if (invoked) {
107
116
  last = flight;
@@ -163,7 +172,9 @@ export function createGameActionCoordinator(options) {
163
172
  if (isDisposed() || !canStart()) {
164
173
  throw new GameActionExecutionError('disposed');
165
174
  }
166
- block = execution.acquireBlock({ reason: `action:${kind}`, channels: ['simulation', 'gameplay-input'] });
175
+ if (options.presentation === undefined) {
176
+ block = execution.acquireBlock({ reason: `action:${kind}`, channels: ['simulation', 'gameplay-input'] });
177
+ }
167
178
  if (isDisposed() || !canStart()) {
168
179
  throw new GameActionExecutionError('disposed');
169
180
  }
@@ -351,6 +362,26 @@ export function createGameActionCoordinator(options) {
351
362
  return Object.freeze({
352
363
  createPurchaseController: () => makeController('purchase'),
353
364
  getPendingOperation: () => unconfirmed,
365
+ confirmRecoveredRewardResult(idempotencyKey, result) {
366
+ if (isDisposed()) {
367
+ throw new GameActionExecutionError('disposed');
368
+ }
369
+ const rejected = result.status === 'rejected' && result.claim?.granted === false && result.claim.disposition === 'rejected';
370
+ const noCandidate = ['failed', 'skipped', 'unavailable'].includes(result.status) && result.reward.status === result.status
371
+ && result.reward.rewardGranted === false && result.reward.evidence === undefined && result.reward.ledgerEntryId === undefined && result.claim === undefined;
372
+ if ((!rejected && !noCandidate) || result.ledgerEntryId !== undefined || result.claim?.ledgerEntryId !== undefined || result.reward.ledgerEntryId !== undefined || result.reward.rewardGranted !== false) {
373
+ throw new GameActionExecutionError('invalid-reconciliation');
374
+ }
375
+ if (unconfirmed?.kind === 'rewarded-ad' && unconfirmed.input.idempotencyKey === idempotencyKey) {
376
+ unconfirmed = undefined;
377
+ return true;
378
+ }
379
+ if (current !== undefined && current.key === idempotencyKey && current.bridge.getSnapshot().kind === 'rewarded-ad' && !current.settled) {
380
+ recoveredNonGrants.add(idempotencyKey);
381
+ return true;
382
+ }
383
+ return false;
384
+ },
354
385
  reconcile,
355
386
  createRewardedAdController: () => makeController('rewarded-ad'),
356
387
  getAvailability() {
@@ -360,6 +391,9 @@ export function createGameActionCoordinator(options) {
360
391
  if (current !== undefined) {
361
392
  return 'busy';
362
393
  }
394
+ if (options.presentation?.getSnapshot().owner !== undefined) {
395
+ return 'busy';
396
+ }
363
397
  if (unconfirmed !== undefined) {
364
398
  return 'reconciliation-required';
365
399
  }
@@ -0,0 +1,63 @@
1
+ import type { PlatformEvidenceEnvelope, PlatformGateway, PurchaseResult } from '@mpgd/platform';
2
+ import type { GameServicesRewardedAdResult } from '@mpgd/game-services/operations';
3
+ import { type AdPlacementInput, type AdProvider, type AdShowInput, type AdShowResult } from '@mpgd/platform/ads';
4
+ import { type ObserverErrorHandler } from '../observers.js';
5
+ import { type FullScreenPresentationScope } from '../presentation/index.js';
6
+ /** Scheduling is injected so the runtime remains usable without DOM or Node globals. */
7
+ export interface AdWaitDeadline {
8
+ readonly milliseconds: number;
9
+ schedule(callback: () => void, milliseconds: number): () => void;
10
+ }
11
+ export interface AdClaimEvidenceObservation {
12
+ readonly input: AdShowInput;
13
+ readonly evidence: PlatformEvidenceEnvelope;
14
+ readonly eligibility: 'eligible' | 'unknown';
15
+ }
16
+ /** Connect late SDK candidates to the reserved journal operation, without reopening UI. */
17
+ export declare function createAdClaimEvidenceRecoveryObserver(input: {
18
+ readonly recovery: {
19
+ recoverRewardResult(idempotencyKey: string, reward: {
20
+ readonly status: 'pending';
21
+ readonly rewardGranted: false;
22
+ readonly evidence: PlatformEvidenceEnvelope;
23
+ }): Promise<GameServicesRewardedAdResult>;
24
+ };
25
+ /** Application-owned settlement observation, never a view-scoped SDK grant callback. */
26
+ readonly onResult?: (request: AdShowInput, result: GameServicesRewardedAdResult) => void | Promise<void>;
27
+ }): (observation: AdClaimEvidenceObservation) => Promise<void>;
28
+ export interface CoordinatedAdProvider extends AdProvider {
29
+ /** Detach projections and reject new calls; native terminal/reward observers remain while needed. */
30
+ dispose(): void;
31
+ }
32
+ export declare function createCoordinatedAdProvider(input: {
33
+ readonly provider: AdProvider;
34
+ readonly presentation: FullScreenPresentationScope;
35
+ readonly canShow?: (placement: AdPlacementInput) => boolean;
36
+ readonly deadline?: AdWaitDeadline;
37
+ readonly maxRememberedInvocations?: number;
38
+ readonly onObserverError?: ObserverErrorHandler;
39
+ /** Application-owned claim/journal observer, never a grant callback. Survives view disposal. */
40
+ readonly onClaimEvidence?: (observation: AdClaimEvidenceObservation) => void | Promise<void>;
41
+ /** Application-owned provider settlement after a caller deadline; never a UI callback. */
42
+ readonly onLateResult?: (observation: {
43
+ readonly input: AdShowInput;
44
+ readonly result: AdShowResult;
45
+ }) => void | Promise<void>;
46
+ }): CoordinatedAdProvider;
47
+ export interface PurchasePresentationEvent {
48
+ readonly idempotencyKey: string;
49
+ readonly sequence: number;
50
+ readonly state: 'open' | 'closed' | 'not-started' | 'unknown';
51
+ }
52
+ export interface CoordinatedPlatformGateway extends PlatformGateway {
53
+ dispose(): void;
54
+ }
55
+ /** Games receive this gateway so direct gateway calls use the same native surface. */
56
+ export declare function createCoordinatedPlatformGateway(input: Parameters<typeof createCoordinatedAdProvider>[0] & {
57
+ readonly gateway: PlatformGateway;
58
+ /** Trusted native facts only. A purchase business result alone defaults to unknown. */
59
+ readonly classifyPurchasePresentation?: (result: PurchaseResult) => 'closed' | 'not-started' | 'unknown';
60
+ readonly purchasePresentation?: {
61
+ subscribe(listener: (event: PurchasePresentationEvent) => void): () => void;
62
+ };
63
+ }): CoordinatedPlatformGateway;