@mpgd/game-runtime 0.3.0 → 0.5.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,4 +1,4 @@
1
- import type { PlatformEvidenceEnvelope, PlatformGateway, PurchaseResult } from '@mpgd/platform';
1
+ import type { PlatformEvidenceEnvelope, PlatformGateway, PlatformPurchasePresentationEvent, PurchaseResult } from '@mpgd/platform';
2
2
  import type { GameServicesRewardedAdResult } from '@mpgd/game-services/operations';
3
3
  import { type AdPlacementInput, type AdProvider, type AdShowInput, type AdShowResult } from '@mpgd/platform/ads';
4
4
  import { type ObserverErrorHandler } from '../observers.js';
@@ -44,11 +44,7 @@ export declare function createCoordinatedAdProvider(input: {
44
44
  readonly result: AdShowResult;
45
45
  }) => void | Promise<void>;
46
46
  }): CoordinatedAdProvider;
47
- export interface PurchasePresentationEvent {
48
- readonly idempotencyKey: string;
49
- readonly sequence: number;
50
- readonly state: 'open' | 'closed' | 'not-started' | 'unknown';
51
- }
47
+ export type PurchasePresentationEvent = PlatformPurchasePresentationEvent;
52
48
  export interface CoordinatedPlatformGateway extends PlatformGateway {
53
49
  dispose(): void;
54
50
  }
package/dist/ads/index.js CHANGED
@@ -34,6 +34,7 @@ export function createCoordinatedAdProvider(input) {
34
34
  const protocol = provider.protocol;
35
35
  const protocolVersion = provider.protocolVersion;
36
36
  const rewardSignal = provider.rewardSignal;
37
+ const presentationAudio = provider.presentationAudio;
37
38
  const capacity = input.maxRememberedInvocations ?? 1024;
38
39
  if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 10000) {
39
40
  throw new RangeError('maxRememberedInvocations must be from 1 to 10000.');
@@ -372,7 +373,7 @@ export function createCoordinatedAdProvider(input) {
372
373
  throw new PresentationExecutionError('disposed');
373
374
  }
374
375
  try {
375
- flight.lease = input.presentation.acquire({ kind: request.format, invocationId: request.invocationId });
376
+ flight.lease = input.presentation.acquire({ kind: request.format, invocationId: request.invocationId, audioStart: presentationAudio ?? 'requested' });
376
377
  }
377
378
  catch (error) {
378
379
  if (error instanceof PresentationExecutionError && error.code === 'busy') {
@@ -419,6 +420,7 @@ export function createCoordinatedAdProvider(input) {
419
420
  }
420
421
  return Object.freeze({
421
422
  id: id, protocol: protocol, protocolVersion: protocolVersion, rewardSignal: rewardSignal,
423
+ ...(presentationAudio === undefined ? {} : { presentationAudio }),
422
424
  getAvailability,
423
425
  async preload(supplied) {
424
426
  const request = placement(supplied);
@@ -473,7 +475,8 @@ export function createCoordinatedPlatformGateway(input) {
473
475
  }
474
476
  maybeDetach();
475
477
  }
476
- const unsubscribe = input.purchasePresentation?.subscribe((event) => {
478
+ const purchasePresentation = input.purchasePresentation ?? input.gateway.commerce.presentation;
479
+ const unsubscribe = purchasePresentation?.subscribe((event) => {
477
480
  observe(() => {
478
481
  const flight = purchases.get(event.idempotencyKey);
479
482
  if (flight === undefined || !flight.called || flight.closed) {
@@ -15,12 +15,21 @@ export interface GameServicePorts {
15
15
  reconcile(): Promise<unknown>;
16
16
  };
17
17
  }
18
+ /** One scene's logical play state; execution blocks remain owned by the game runtime. */
19
+ export interface GameGameplayScope {
20
+ setActive(active: boolean): void;
21
+ dispose(): void;
22
+ }
18
23
  export interface GamePlatformRuntime<T extends PlatformGateway = PlatformGateway, S extends GameServicePorts = GameServicePorts> {
19
24
  readonly gateway: T;
20
25
  readonly execution: GameExecutionController;
21
26
  readonly presentation: FullScreenPresentationScope;
22
27
  readonly services: S;
23
28
  readonly actions?: GameActionCoordinator;
29
+ createGameplayScope(): GameGameplayScope;
30
+ setLoadingProgress(progress: number): void;
31
+ /** Complete host loading before opening a play scene. Rejected starts remain blocked. */
32
+ completeLoading(): Promise<void>;
24
33
  /** Reconcile existing journal entries only; this method never opens platform UI. */
25
34
  reconcile(): Promise<void>;
26
35
  /** Game teardown; native observers and application-owned late recovery may continue. */
@@ -38,4 +47,6 @@ export declare function createGamePlatformRuntime<T extends PlatformGateway, S e
38
47
  subscribe(listener: (event: PurchasePresentationEvent) => void): () => void;
39
48
  };
40
49
  readonly onObserverError?: ObserverErrorHandler;
50
+ /** Must complete synchronously; asynchronous uploads belong at earlier checkpoints. */
51
+ readonly onExit?: () => void;
41
52
  }): GamePlatformRuntime<T, S>;
@@ -1,4 +1,4 @@
1
- import { createGameExecutionController } from '../index.js';
1
+ import { createGameExecutionController, } from '../index.js';
2
2
  import { bindGameLifecycle } from '../platform/index.js';
3
3
  import { createFullScreenPresentationScope, } from '../presentation/index.js';
4
4
  import { createAdClaimEvidenceRecoveryObserver, createCoordinatedPlatformGateway, } from '../ads/index.js';
@@ -39,12 +39,123 @@ export function createGamePlatformRuntime(input) {
39
39
  let actions;
40
40
  let unsubscribeResume;
41
41
  let disposed = false;
42
+ let unsubscribeExit;
43
+ let startupBlock = input.gateway.gameLoading === undefined
44
+ ? undefined
45
+ : execution.acquireBlock({
46
+ reason: 'game-runtime:loading',
47
+ channels: ['simulation', 'gameplay-input', 'audio'],
48
+ });
49
+ let loadingCompletion;
50
+ const setLoadingProgress = (progress) => {
51
+ if (!disposed && loadingCompletion === undefined) {
52
+ observe(() => gateway.gameLoading?.setProgress(Math.max(0, Math.min(100, Number(progress) || 0))), input.onObserverError);
53
+ }
54
+ };
55
+ const completeLoading = () => {
56
+ if (disposed) {
57
+ return Promise.reject(new Error('Game runtime is disposed.'));
58
+ }
59
+ if (loadingCompletion !== undefined) {
60
+ return loadingCompletion;
61
+ }
62
+ const attempt = Promise.resolve().then(async () => {
63
+ if (disposed) {
64
+ throw new Error('Game runtime is disposed.');
65
+ }
66
+ gateway.gameLoading?.setProgress(100);
67
+ await gateway.gameLoading?.complete();
68
+ if (disposed) {
69
+ throw new Error('Game runtime was disposed during loading.');
70
+ }
71
+ const owned = startupBlock;
72
+ startupBlock = undefined;
73
+ owned?.release();
74
+ });
75
+ loadingCompletion = attempt;
76
+ void attempt.catch(() => {
77
+ if (loadingCompletion === attempt) {
78
+ loadingCompletion = undefined;
79
+ }
80
+ });
81
+ return attempt;
82
+ };
83
+ let hostAudio;
84
+ let unsubscribeHostAudio;
85
+ const gameplayScopes = new Map();
86
+ const lifecycleReason = 'game-runtime:lifecycle';
87
+ let gameplayActive = false;
88
+ let hasPlayed = false;
89
+ let loading = true;
90
+ let unsubscribeActivity;
91
+ const updateActivity = () => {
92
+ const snapshot = execution.getSnapshot();
93
+ const activity = gateway.gameActivity;
94
+ const playable = [...gameplayScopes.values()].some(Boolean);
95
+ const blocked = snapshot.blocks.some((block) => block.channels.includes('simulation')
96
+ && !(activity?.handlesFocusChanges === true && hasPlayed && block.reason === lifecycleReason));
97
+ const next = !disposed && snapshot.status === 'active' && playable && !blocked;
98
+ if (next && loading) {
99
+ observe(() => {
100
+ activity?.setLoading(false);
101
+ loading = false;
102
+ }, input.onObserverError);
103
+ if (loading) {
104
+ return;
105
+ }
106
+ }
107
+ if (next !== gameplayActive) {
108
+ observe(() => {
109
+ activity?.setGameplayActive(next);
110
+ gameplayActive = next;
111
+ if (next) {
112
+ hasPlayed = true;
113
+ }
114
+ }, input.onObserverError);
115
+ }
116
+ };
117
+ const createGameplayScope = () => {
118
+ const owner = {};
119
+ let ended = disposed;
120
+ if (!ended) {
121
+ gameplayScopes.set(owner, false);
122
+ }
123
+ return Object.freeze({
124
+ setActive(active) {
125
+ if (!ended && !disposed) {
126
+ gameplayScopes.set(owner, active);
127
+ updateActivity();
128
+ }
129
+ },
130
+ dispose() {
131
+ if (!ended) {
132
+ ended = true;
133
+ gameplayScopes.delete(owner);
134
+ updateActivity();
135
+ }
136
+ },
137
+ });
138
+ };
42
139
  const dispose = () => {
43
140
  if (disposed) {
44
141
  return;
45
142
  }
46
143
  disposed = true;
144
+ updateActivity();
145
+ if (loading) {
146
+ observe(() => {
147
+ gateway.gameActivity?.setLoading(false);
148
+ loading = false;
149
+ }, input.onObserverError);
150
+ }
151
+ unsubscribeActivity?.();
152
+ unsubscribeExit?.();
153
+ gameplayScopes.clear();
47
154
  execution.destroy();
155
+ if (unsubscribeHostAudio !== undefined) {
156
+ observe(unsubscribeHostAudio, input.onObserverError);
157
+ }
158
+ hostAudio?.release();
48
159
  actions?.dispose();
49
160
  coordinated?.dispose();
50
161
  presentation.dispose();
@@ -83,14 +194,46 @@ export function createGamePlatformRuntime(input) {
83
194
  });
84
195
  }
85
196
  lifecycle = bindGameLifecycle({ controller: execution, source: gateway.lifecycle, initialState: input.initialLifecycleState,
197
+ reason: lifecycleReason,
86
198
  ...(input.onObserverError === undefined ? {} : { onError: input.onObserverError }),
87
199
  });
88
200
  unsubscribeResume = gateway.lifecycle.onResume(() => { observe(reconcile, input.onObserverError); });
201
+ unsubscribeExit = gateway.lifecycle.onExit?.(() => {
202
+ observe(() => input.onExit?.(), input.onObserverError);
203
+ dispose();
204
+ });
205
+ if (disposed) {
206
+ unsubscribeExit?.();
207
+ throw new Error('Game exited during runtime initialization.');
208
+ }
209
+ const settings = gateway.gameSettings;
210
+ if (settings !== undefined) {
211
+ const updateMute = (muted) => {
212
+ if (disposed) {
213
+ return;
214
+ }
215
+ if (muted && hostAudio === undefined) {
216
+ hostAudio = execution.acquireBlock({ reason: 'game-runtime:host-audio', channels: ['audio'] });
217
+ }
218
+ else if (!muted && hostAudio !== undefined) {
219
+ const owned = hostAudio;
220
+ hostAudio = undefined;
221
+ owned.release();
222
+ }
223
+ };
224
+ unsubscribeHostAudio = settings.onAudioMuteChange(updateMute);
225
+ updateMute(settings.getAudioMuted());
226
+ }
227
+ observe(() => gateway.gameActivity?.setLoading(true), input.onObserverError);
228
+ unsubscribeActivity = execution.subscribe(updateActivity);
89
229
  return Object.freeze({
90
230
  gateway,
91
231
  execution,
92
232
  presentation,
93
233
  services,
234
+ createGameplayScope,
235
+ setLoadingProgress,
236
+ completeLoading,
94
237
  ...(actions === undefined ? {} : { actions }),
95
238
  reconcile,
96
239
  dispose,
@@ -1,4 +1,5 @@
1
1
  import type Phaser from 'phaser';
2
+ import type { GameGameplayScope } from '../game/index.js';
2
3
  import type { GameExecutionController, GameExecutionSnapshot } from '../index.js';
3
4
  export interface GameplayAudioSink {
4
5
  getMuted(): boolean;
@@ -21,6 +22,7 @@ export interface BindPhaserGameSceneInput {
21
22
  readonly uiScope?: {
22
23
  dispose(): void;
23
24
  };
25
+ readonly gameplayScope?: GameGameplayScope;
24
26
  /** Phaser pause also suspends input: simulation-only blocking cannot preserve active input. */
25
27
  readonly onUnsupportedState: (snapshot: GameExecutionSnapshot, reason: UnsupportedPhaserExecutionState) => void | Promise<void>;
26
28
  readonly onError?: (error: unknown) => void | Promise<void>;
@@ -23,6 +23,7 @@ export function bindPhaserGameScene(input) {
23
23
  let ownedVisibility = false;
24
24
  let ownedAudio = false;
25
25
  let inputBlocked = false;
26
+ let externallyInactive = scene.sys.isPaused();
26
27
  const ownedInput = new Set();
27
28
  let unsubscribe = () => { };
28
29
  function report(error) {
@@ -99,6 +100,7 @@ export function bindPhaserGameScene(input) {
99
100
  }
100
101
  disposed = true;
101
102
  attempt(unsubscribe);
103
+ attempt(() => input.gameplayScope?.dispose());
102
104
  // Keep shutdown observation installed while restoration calls into consumer/engine code.
103
105
  if (mode !== 'terminal') {
104
106
  attempt(() => {
@@ -141,11 +143,14 @@ export function bindPhaserGameScene(input) {
141
143
  function onPause() {
142
144
  if (!disposed && !applying) {
143
145
  ownedPause = false;
146
+ externallyInactive = true;
147
+ attempt(() => input.gameplayScope?.setActive(false));
144
148
  }
145
149
  }
146
150
  function onResume() {
147
151
  if (!disposed && !applying) {
148
152
  ownedPause = false;
153
+ externallyInactive = false;
149
154
  apply();
150
155
  }
151
156
  }
@@ -153,8 +158,11 @@ export function bindPhaserGameScene(input) {
153
158
  // External sleep owns activity/visibility until an external wake. Never wake it ourselves.
154
159
  ownedPause = false;
155
160
  ownedVisibility = false;
161
+ externallyInactive = true;
162
+ attempt(() => input.gameplayScope?.setActive(false));
156
163
  }
157
164
  function onWake() {
165
+ externallyInactive = false;
158
166
  if (controller.getSnapshot().blocked['gameplay-input']) {
159
167
  inputBlocked = false;
160
168
  }
@@ -175,6 +183,7 @@ export function bindPhaserGameScene(input) {
175
183
  dispose('terminal');
176
184
  break;
177
185
  }
186
+ attempt(() => input.gameplayScope?.setActive(!externallyInactive && (scene.sys.isActive() || scene.sys.isPaused())));
178
187
  const blocked = snapshot.blocked;
179
188
  const simulationUnsupported = blocked.simulation && !blocked['gameplay-input'];
180
189
  const audioUnsupported = blocked.audio && audio === undefined && input.audioOwner !== 'game';
@@ -23,6 +23,7 @@ export interface FullScreenPresentationScope {
23
23
  acquire(input: {
24
24
  readonly kind: FullScreenPresentationKind;
25
25
  readonly invocationId: string;
26
+ readonly audioStart?: 'requested' | 'started';
26
27
  }): FullScreenPresentationLease;
27
28
  getSnapshot(): FullScreenPresentationSnapshot;
28
29
  subscribe(listener: UiListener<FullScreenPresentationSnapshot>): () => void;
@@ -49,8 +49,10 @@ export function createFullScreenPresentationScope(input) {
49
49
  }
50
50
  const kind = request.kind;
51
51
  const invocationId = request.invocationId;
52
+ const audioStart = request.audioStart ?? 'requested';
52
53
  if (!['purchase', 'rewarded', 'interstitial'].includes(kind)
53
- || typeof invocationId !== 'string' || invocationId.trim() === '' || invocationId.length > 512) {
54
+ || typeof invocationId !== 'string' || invocationId.trim() === '' || invocationId.length > 512
55
+ || (audioStart !== 'requested' && audioStart !== 'started')) {
54
56
  throw new TypeError('Invalid presentation identity.');
55
57
  }
56
58
  if (isDisposed()) {
@@ -66,7 +68,7 @@ export function createFullScreenPresentationScope(input) {
66
68
  owner = owned;
67
69
  try {
68
70
  owned.block = input.execution.acquireBlock({
69
- reason: `presentation:${kind}`, channels: ['simulation', 'gameplay-input', 'audio'],
71
+ reason: `presentation:${kind}`, channels: audioStart === 'started' ? ['simulation', 'gameplay-input'] : ['simulation', 'gameplay-input', 'audio'],
70
72
  });
71
73
  }
72
74
  catch (error) {
@@ -75,16 +77,46 @@ export function createFullScreenPresentationScope(input) {
75
77
  }
76
78
  throw error;
77
79
  }
80
+ let acquiringAudio = false;
81
+ const ensureAudio = () => {
82
+ if (audioStart !== 'started' || acquiringAudio || owned.audioBlock !== undefined || input.execution.getSnapshot().status === 'destroyed') {
83
+ return;
84
+ }
85
+ acquiringAudio = true;
86
+ try {
87
+ const block = input.execution.acquireBlock({ reason: `presentation:${kind}:audio`, channels: ['audio'] });
88
+ if (owner === owned) {
89
+ owned.audioBlock = block;
90
+ }
91
+ else {
92
+ block.release();
93
+ }
94
+ }
95
+ finally {
96
+ acquiringAudio = false;
97
+ }
98
+ };
78
99
  publish();
79
100
  return Object.freeze({
80
101
  markStarted() {
81
102
  if (owner === owned && owned.info.state !== 'open') {
103
+ const previousInfo = owned.info;
104
+ ensureAudio();
105
+ if (owner !== owned || owned.info !== previousInfo) {
106
+ return;
107
+ }
82
108
  owned.info = Object.freeze({ ...owned.info, state: 'open' });
83
109
  publish();
84
110
  }
85
111
  },
86
112
  markUnknown() {
87
113
  if (owner === owned && owned.info.state !== 'unknown') {
114
+ const previousInfo = owned.info;
115
+ // Lost native observations quarantine audio as well as execution.
116
+ ensureAudio();
117
+ if (owner !== owned || owned.info !== previousInfo) {
118
+ return;
119
+ }
88
120
  owned.info = Object.freeze({ ...owned.info, state: 'unknown' });
89
121
  publish();
90
122
  }
@@ -95,6 +127,7 @@ export function createFullScreenPresentationScope(input) {
95
127
  }
96
128
  owner = undefined;
97
129
  publish();
130
+ owned.audioBlock?.release();
98
131
  owned.block?.release();
99
132
  },
100
133
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Gameplay execution, scoped UI and action coordination with optional Phaser scene bindings.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -64,8 +64,8 @@
64
64
  "@types/node": "^24.0.0"
65
65
  },
66
66
  "dependencies": {
67
- "@mpgd/game-services": "0.18.0",
68
- "@mpgd/platform": "0.15.0"
67
+ "@mpgd/game-services": "0.19.0",
68
+ "@mpgd/platform": "0.17.0"
69
69
  },
70
70
  "repository": {
71
71
  "type": "git",