@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 +111 -0
- package/dist/actions/index.d.ts +5 -0
- package/dist/actions/index.js +37 -3
- package/dist/ads/index.d.ts +63 -0
- package/dist/ads/index.js +627 -0
- package/dist/audio/index.d.ts +14 -0
- package/dist/audio/index.js +35 -0
- package/dist/game/index.d.ts +47 -0
- package/dist/game/index.js +194 -0
- package/dist/phaser/index.d.ts +4 -0
- package/dist/phaser/index.js +13 -1
- package/dist/presentation/index.d.ts +41 -0
- package/dist/presentation/index.js +153 -0
- package/docs/phaser.md +11 -0
- package/package.json +19 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { GameServicesOperationClient, GameServicesRewardedAdResult } from '@mpgd/game-services/operations';
|
|
2
|
+
import type { PlatformGateway, RewardedAdResult } from '@mpgd/platform';
|
|
3
|
+
import type { AdPlacementInput } from '@mpgd/platform/ads';
|
|
4
|
+
import { type GameExecutionController } from '../index.js';
|
|
5
|
+
import { type GameLifecycleState } from '../platform/index.js';
|
|
6
|
+
import { type FullScreenPresentationScope } from '../presentation/index.js';
|
|
7
|
+
import { type AdWaitDeadline, type PurchasePresentationEvent } from '../ads/index.js';
|
|
8
|
+
import { type GameActionCoordinator, type GameActionReconciliationPort } from '../actions/index.js';
|
|
9
|
+
import { type ObserverErrorHandler } from '../observers.js';
|
|
10
|
+
/** Structural service ports keep the core usable without DOM, HTTP, or schema libraries. */
|
|
11
|
+
export interface GameServicePorts {
|
|
12
|
+
readonly client?: GameServicesOperationClient;
|
|
13
|
+
readonly monetizationRecovery?: {
|
|
14
|
+
recoverRewardResult(idempotencyKey: string, platform: RewardedAdResult): Promise<GameServicesRewardedAdResult>;
|
|
15
|
+
reconcile(): Promise<unknown>;
|
|
16
|
+
};
|
|
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
|
+
}
|
|
23
|
+
export interface GamePlatformRuntime<T extends PlatformGateway = PlatformGateway, S extends GameServicePorts = GameServicePorts> {
|
|
24
|
+
readonly gateway: T;
|
|
25
|
+
readonly execution: GameExecutionController;
|
|
26
|
+
readonly presentation: FullScreenPresentationScope;
|
|
27
|
+
readonly services: S;
|
|
28
|
+
readonly actions?: GameActionCoordinator;
|
|
29
|
+
createGameplayScope(): GameGameplayScope;
|
|
30
|
+
/** Reconcile existing journal entries only; this method never opens platform UI. */
|
|
31
|
+
reconcile(): Promise<void>;
|
|
32
|
+
/** Game teardown; native observers and application-owned late recovery may continue. */
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
/** Construct once above every scene, preserving target gateway extensions and receiver binding. */
|
|
36
|
+
export declare function createGamePlatformRuntime<T extends PlatformGateway, S extends GameServicePorts>(input: {
|
|
37
|
+
readonly gateway: T;
|
|
38
|
+
readonly createServices: (gateway: T) => S;
|
|
39
|
+
readonly initialLifecycleState: GameLifecycleState;
|
|
40
|
+
readonly canShow?: (placement: AdPlacementInput) => boolean;
|
|
41
|
+
readonly deadline?: AdWaitDeadline;
|
|
42
|
+
readonly reconciliation?: GameActionReconciliationPort;
|
|
43
|
+
readonly purchasePresentation?: {
|
|
44
|
+
subscribe(listener: (event: PurchasePresentationEvent) => void): () => void;
|
|
45
|
+
};
|
|
46
|
+
readonly onObserverError?: ObserverErrorHandler;
|
|
47
|
+
}): GamePlatformRuntime<T, S>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createGameExecutionController, } from '../index.js';
|
|
2
|
+
import { bindGameLifecycle } from '../platform/index.js';
|
|
3
|
+
import { createFullScreenPresentationScope, } from '../presentation/index.js';
|
|
4
|
+
import { createAdClaimEvidenceRecoveryObserver, createCoordinatedPlatformGateway, } from '../ads/index.js';
|
|
5
|
+
import { createGameActionCoordinator, } from '../actions/index.js';
|
|
6
|
+
import { observe } from '../observers.js';
|
|
7
|
+
/** Construct once above every scene, preserving target gateway extensions and receiver binding. */
|
|
8
|
+
export function createGamePlatformRuntime(input) {
|
|
9
|
+
const execution = createGameExecutionController({
|
|
10
|
+
...(input.onObserverError === undefined ? {} : { onListenerError: input.onObserverError }),
|
|
11
|
+
});
|
|
12
|
+
const presentation = createFullScreenPresentationScope({ execution });
|
|
13
|
+
let services;
|
|
14
|
+
let lateEvidence;
|
|
15
|
+
const source = input.gateway.ads.provider;
|
|
16
|
+
const coordinated = source === undefined ? undefined : createCoordinatedPlatformGateway({
|
|
17
|
+
gateway: input.gateway, provider: source, presentation,
|
|
18
|
+
canShow: (placement) => input.canShow?.(placement) !== false && (placement.format !== 'rewarded' || services?.monetizationRecovery !== undefined),
|
|
19
|
+
...(input.deadline === undefined ? {} : { deadline: input.deadline }),
|
|
20
|
+
...(input.purchasePresentation === undefined ? {} : { purchasePresentation: input.purchasePresentation }),
|
|
21
|
+
...(input.onObserverError === undefined ? {} : { onObserverError: input.onObserverError }),
|
|
22
|
+
onClaimEvidence: (observation) => lateEvidence?.(observation),
|
|
23
|
+
onLateResult: async ({ input: request, result }) => {
|
|
24
|
+
if (request.format !== 'rewarded' || result.eligibility === 'eligible' || result.presentation !== 'not-started'
|
|
25
|
+
|| result.evidence !== undefined || result.claimEvidence !== undefined || services?.monetizationRecovery === undefined) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (result.outcome !== 'failed' && result.outcome !== 'unavailable' && result.outcome !== 'skipped') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const recovered = await services.monetizationRecovery.recoverRewardResult(request.idempotencyKey, { status: result.outcome, rewardGranted: false });
|
|
32
|
+
if (!disposed) {
|
|
33
|
+
actions?.confirmRecoveredRewardResult(request.idempotencyKey, recovered);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const gateway = (coordinated ?? input.gateway);
|
|
38
|
+
let lifecycle;
|
|
39
|
+
let actions;
|
|
40
|
+
let unsubscribeResume;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
let hostAudio;
|
|
43
|
+
let unsubscribeHostAudio;
|
|
44
|
+
const gameplayScopes = new Map();
|
|
45
|
+
const lifecycleReason = 'game-runtime:lifecycle';
|
|
46
|
+
let gameplayActive = false;
|
|
47
|
+
let hasPlayed = false;
|
|
48
|
+
let loading = true;
|
|
49
|
+
let unsubscribeActivity;
|
|
50
|
+
const updateActivity = () => {
|
|
51
|
+
const snapshot = execution.getSnapshot();
|
|
52
|
+
const activity = gateway.gameActivity;
|
|
53
|
+
const playable = [...gameplayScopes.values()].some(Boolean);
|
|
54
|
+
const blocked = snapshot.blocks.some((block) => block.channels.includes('simulation')
|
|
55
|
+
&& !(activity?.handlesFocusChanges === true && hasPlayed && block.reason === lifecycleReason));
|
|
56
|
+
const next = !disposed && snapshot.status === 'active' && playable && !blocked;
|
|
57
|
+
if (next && loading) {
|
|
58
|
+
observe(() => {
|
|
59
|
+
activity?.setLoading(false);
|
|
60
|
+
loading = false;
|
|
61
|
+
}, input.onObserverError);
|
|
62
|
+
if (loading) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (next !== gameplayActive) {
|
|
67
|
+
observe(() => {
|
|
68
|
+
activity?.setGameplayActive(next);
|
|
69
|
+
gameplayActive = next;
|
|
70
|
+
if (next) {
|
|
71
|
+
hasPlayed = true;
|
|
72
|
+
}
|
|
73
|
+
}, input.onObserverError);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const createGameplayScope = () => {
|
|
77
|
+
const owner = {};
|
|
78
|
+
let ended = disposed;
|
|
79
|
+
if (!ended) {
|
|
80
|
+
gameplayScopes.set(owner, false);
|
|
81
|
+
}
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
setActive(active) {
|
|
84
|
+
if (!ended && !disposed) {
|
|
85
|
+
gameplayScopes.set(owner, active);
|
|
86
|
+
updateActivity();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
dispose() {
|
|
90
|
+
if (!ended) {
|
|
91
|
+
ended = true;
|
|
92
|
+
gameplayScopes.delete(owner);
|
|
93
|
+
updateActivity();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
const dispose = () => {
|
|
99
|
+
if (disposed) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
disposed = true;
|
|
103
|
+
updateActivity();
|
|
104
|
+
if (loading) {
|
|
105
|
+
observe(() => {
|
|
106
|
+
gateway.gameActivity?.setLoading(false);
|
|
107
|
+
loading = false;
|
|
108
|
+
}, input.onObserverError);
|
|
109
|
+
}
|
|
110
|
+
unsubscribeActivity?.();
|
|
111
|
+
gameplayScopes.clear();
|
|
112
|
+
execution.destroy();
|
|
113
|
+
if (unsubscribeHostAudio !== undefined) {
|
|
114
|
+
observe(unsubscribeHostAudio, input.onObserverError);
|
|
115
|
+
}
|
|
116
|
+
hostAudio?.release();
|
|
117
|
+
actions?.dispose();
|
|
118
|
+
coordinated?.dispose();
|
|
119
|
+
presentation.dispose();
|
|
120
|
+
lifecycle?.dispose();
|
|
121
|
+
if (unsubscribeResume !== undefined) {
|
|
122
|
+
observe(unsubscribeResume, input.onObserverError);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const reconcile = async () => {
|
|
126
|
+
if (disposed) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await services?.monetizationRecovery?.reconcile();
|
|
130
|
+
if (!disposed && input.reconciliation !== undefined) {
|
|
131
|
+
await actions?.reconcile();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
services = input.createServices(gateway);
|
|
136
|
+
const recovery = services.monetizationRecovery;
|
|
137
|
+
if (recovery !== undefined) {
|
|
138
|
+
lateEvidence = createAdClaimEvidenceRecoveryObserver({ recovery, onResult: async (request, result) => {
|
|
139
|
+
if (!disposed && result.status === 'rejected') {
|
|
140
|
+
actions?.confirmRecoveredRewardResult(request.idempotencyKey, result);
|
|
141
|
+
}
|
|
142
|
+
if (!disposed && input.reconciliation !== undefined) {
|
|
143
|
+
await actions?.reconcile();
|
|
144
|
+
}
|
|
145
|
+
} });
|
|
146
|
+
}
|
|
147
|
+
if (services.client !== undefined) {
|
|
148
|
+
actions = createGameActionCoordinator({ execution, client: services.client,
|
|
149
|
+
...(coordinated === undefined ? {} : { presentation }),
|
|
150
|
+
...(input.reconciliation === undefined ? {} : { reconciliation: input.reconciliation }),
|
|
151
|
+
...(input.onObserverError === undefined ? {} : { onObserverError: input.onObserverError }),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
lifecycle = bindGameLifecycle({ controller: execution, source: gateway.lifecycle, initialState: input.initialLifecycleState,
|
|
155
|
+
reason: lifecycleReason,
|
|
156
|
+
...(input.onObserverError === undefined ? {} : { onError: input.onObserverError }),
|
|
157
|
+
});
|
|
158
|
+
unsubscribeResume = gateway.lifecycle.onResume(() => { observe(reconcile, input.onObserverError); });
|
|
159
|
+
const settings = gateway.gameSettings;
|
|
160
|
+
if (settings !== undefined) {
|
|
161
|
+
const updateMute = (muted) => {
|
|
162
|
+
if (disposed) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (muted && hostAudio === undefined) {
|
|
166
|
+
hostAudio = execution.acquireBlock({ reason: 'game-runtime:host-audio', channels: ['audio'] });
|
|
167
|
+
}
|
|
168
|
+
else if (!muted && hostAudio !== undefined) {
|
|
169
|
+
const owned = hostAudio;
|
|
170
|
+
hostAudio = undefined;
|
|
171
|
+
owned.release();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
unsubscribeHostAudio = settings.onAudioMuteChange(updateMute);
|
|
175
|
+
updateMute(settings.getAudioMuted());
|
|
176
|
+
}
|
|
177
|
+
observe(() => gateway.gameActivity?.setLoading(true), input.onObserverError);
|
|
178
|
+
unsubscribeActivity = execution.subscribe(updateActivity);
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
gateway,
|
|
181
|
+
execution,
|
|
182
|
+
presentation,
|
|
183
|
+
services,
|
|
184
|
+
createGameplayScope,
|
|
185
|
+
...(actions === undefined ? {} : { actions }),
|
|
186
|
+
reconcile,
|
|
187
|
+
dispose,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
dispose();
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
package/dist/phaser/index.d.ts
CHANGED
|
@@ -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;
|
|
@@ -16,9 +17,12 @@ export interface BindPhaserGameSceneInput {
|
|
|
16
17
|
readonly resetInput: () => void;
|
|
17
18
|
readonly renderingPolicy: 'visibility';
|
|
18
19
|
readonly audio?: GameplayAudioSink;
|
|
20
|
+
/** A game-owned bindGameAudio projection survives this scene's disposal. */
|
|
21
|
+
readonly audioOwner?: 'scene' | 'game';
|
|
19
22
|
readonly uiScope?: {
|
|
20
23
|
dispose(): void;
|
|
21
24
|
};
|
|
25
|
+
readonly gameplayScope?: GameGameplayScope;
|
|
22
26
|
/** Phaser pause also suspends input: simulation-only blocking cannot preserve active input. */
|
|
23
27
|
readonly onUnsupportedState: (snapshot: GameExecutionSnapshot, reason: UnsupportedPhaserExecutionState) => void | Promise<void>;
|
|
24
28
|
readonly onError?: (error: unknown) => void | Promise<void>;
|
package/dist/phaser/index.js
CHANGED
|
@@ -9,6 +9,9 @@ export function bindPhaserGameScene(input) {
|
|
|
9
9
|
if (controller.getSnapshot().status === 'destroyed') {
|
|
10
10
|
throw new Error('Cannot bind a destroyed game execution controller.');
|
|
11
11
|
}
|
|
12
|
+
if (input.audioOwner === 'game' && audio !== undefined) {
|
|
13
|
+
throw new TypeError('Game-owned audio cannot also be controlled by a scene sink.');
|
|
14
|
+
}
|
|
12
15
|
const events = scene.sys.events;
|
|
13
16
|
let disposed = false;
|
|
14
17
|
let sceneEnded = false;
|
|
@@ -20,6 +23,7 @@ export function bindPhaserGameScene(input) {
|
|
|
20
23
|
let ownedVisibility = false;
|
|
21
24
|
let ownedAudio = false;
|
|
22
25
|
let inputBlocked = false;
|
|
26
|
+
let externallyInactive = scene.sys.isPaused();
|
|
23
27
|
const ownedInput = new Set();
|
|
24
28
|
let unsubscribe = () => { };
|
|
25
29
|
function report(error) {
|
|
@@ -96,6 +100,7 @@ export function bindPhaserGameScene(input) {
|
|
|
96
100
|
}
|
|
97
101
|
disposed = true;
|
|
98
102
|
attempt(unsubscribe);
|
|
103
|
+
attempt(() => input.gameplayScope?.dispose());
|
|
99
104
|
// Keep shutdown observation installed while restoration calls into consumer/engine code.
|
|
100
105
|
if (mode !== 'terminal') {
|
|
101
106
|
attempt(() => {
|
|
@@ -138,11 +143,14 @@ export function bindPhaserGameScene(input) {
|
|
|
138
143
|
function onPause() {
|
|
139
144
|
if (!disposed && !applying) {
|
|
140
145
|
ownedPause = false;
|
|
146
|
+
externallyInactive = true;
|
|
147
|
+
attempt(() => input.gameplayScope?.setActive(false));
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
function onResume() {
|
|
144
151
|
if (!disposed && !applying) {
|
|
145
152
|
ownedPause = false;
|
|
153
|
+
externallyInactive = false;
|
|
146
154
|
apply();
|
|
147
155
|
}
|
|
148
156
|
}
|
|
@@ -150,8 +158,11 @@ export function bindPhaserGameScene(input) {
|
|
|
150
158
|
// External sleep owns activity/visibility until an external wake. Never wake it ourselves.
|
|
151
159
|
ownedPause = false;
|
|
152
160
|
ownedVisibility = false;
|
|
161
|
+
externallyInactive = true;
|
|
162
|
+
attempt(() => input.gameplayScope?.setActive(false));
|
|
153
163
|
}
|
|
154
164
|
function onWake() {
|
|
165
|
+
externallyInactive = false;
|
|
155
166
|
if (controller.getSnapshot().blocked['gameplay-input']) {
|
|
156
167
|
inputBlocked = false;
|
|
157
168
|
}
|
|
@@ -172,9 +183,10 @@ export function bindPhaserGameScene(input) {
|
|
|
172
183
|
dispose('terminal');
|
|
173
184
|
break;
|
|
174
185
|
}
|
|
186
|
+
attempt(() => input.gameplayScope?.setActive(!externallyInactive && (scene.sys.isActive() || scene.sys.isPaused())));
|
|
175
187
|
const blocked = snapshot.blocked;
|
|
176
188
|
const simulationUnsupported = blocked.simulation && !blocked['gameplay-input'];
|
|
177
|
-
const audioUnsupported = blocked.audio && audio === undefined;
|
|
189
|
+
const audioUnsupported = blocked.audio && audio === undefined && input.audioOwner !== 'game';
|
|
178
190
|
const notifySimulation = simulationUnsupported && !reportedSimulation;
|
|
179
191
|
const notifyAudio = audioUnsupported && !reportedAudio;
|
|
180
192
|
reportedSimulation = simulationUnsupported;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GameExecutionController } from '../index.js';
|
|
2
|
+
import { type UiListener } from '../ui/index.js';
|
|
3
|
+
import type { ObserverErrorHandler } from '../observers.js';
|
|
4
|
+
export type FullScreenPresentationKind = 'purchase' | 'rewarded' | 'interstitial';
|
|
5
|
+
export interface FullScreenPresentationOwner {
|
|
6
|
+
readonly kind: FullScreenPresentationKind;
|
|
7
|
+
readonly invocationId: string;
|
|
8
|
+
readonly state: 'requested' | 'open' | 'unknown';
|
|
9
|
+
}
|
|
10
|
+
export interface FullScreenPresentationSnapshot {
|
|
11
|
+
readonly version: number;
|
|
12
|
+
readonly status: 'ready' | 'busy' | 'unknown' | 'disposed';
|
|
13
|
+
readonly owner?: FullScreenPresentationOwner;
|
|
14
|
+
}
|
|
15
|
+
export interface FullScreenPresentationLease {
|
|
16
|
+
/** Native facts only. A caller deadline or a backend grant cannot confirm closure. */
|
|
17
|
+
markStarted(): void;
|
|
18
|
+
markUnknown(): void;
|
|
19
|
+
confirmClosed(): void;
|
|
20
|
+
}
|
|
21
|
+
export interface FullScreenPresentationScope {
|
|
22
|
+
readonly execution: GameExecutionController;
|
|
23
|
+
acquire(input: {
|
|
24
|
+
readonly kind: FullScreenPresentationKind;
|
|
25
|
+
readonly invocationId: string;
|
|
26
|
+
readonly audioStart?: 'requested' | 'started';
|
|
27
|
+
}): FullScreenPresentationLease;
|
|
28
|
+
getSnapshot(): FullScreenPresentationSnapshot;
|
|
29
|
+
subscribe(listener: UiListener<FullScreenPresentationSnapshot>): () => void;
|
|
30
|
+
/** Stop new presentations and detach projections; retain any live native ownership. */
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare class PresentationExecutionError extends Error {
|
|
34
|
+
readonly code: 'busy' | 'disposed' | 'key-conflict' | 'history-full' | 'already-completed';
|
|
35
|
+
constructor(code: 'busy' | 'disposed' | 'key-conflict' | 'history-full' | 'already-completed');
|
|
36
|
+
}
|
|
37
|
+
/** Share one scope above all screens, providers, and purchase/ad entrypoints. */
|
|
38
|
+
export declare function createFullScreenPresentationScope(input: {
|
|
39
|
+
readonly execution: GameExecutionController;
|
|
40
|
+
readonly onObserverError?: ObserverErrorHandler;
|
|
41
|
+
}): FullScreenPresentationScope;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createGameUiBridge } from '../ui/index.js';
|
|
2
|
+
export class PresentationExecutionError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(`Presentation cannot start: ${code}`);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = 'PresentationExecutionError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Share one scope above all screens, providers, and purchase/ad entrypoints. */
|
|
11
|
+
export function createFullScreenPresentationScope(input) {
|
|
12
|
+
let disposed = false;
|
|
13
|
+
let version = 0;
|
|
14
|
+
let owner;
|
|
15
|
+
let snapshot = Object.freeze({ version, status: 'ready' });
|
|
16
|
+
const bridge = createGameUiBridge({
|
|
17
|
+
initialSnapshot: snapshot,
|
|
18
|
+
...(input.onObserverError === undefined ? {} : { onListenerError: input.onObserverError }),
|
|
19
|
+
});
|
|
20
|
+
const isDisposed = () => disposed || input.execution.getSnapshot().status === 'destroyed';
|
|
21
|
+
function publish() {
|
|
22
|
+
let status = 'ready';
|
|
23
|
+
if (isDisposed()) {
|
|
24
|
+
status = 'disposed';
|
|
25
|
+
}
|
|
26
|
+
else if (owner?.info.state === 'unknown') {
|
|
27
|
+
status = 'unknown';
|
|
28
|
+
}
|
|
29
|
+
else if (owner !== undefined) {
|
|
30
|
+
status = 'busy';
|
|
31
|
+
}
|
|
32
|
+
snapshot = Object.freeze({
|
|
33
|
+
version: ++version,
|
|
34
|
+
status,
|
|
35
|
+
...(owner === undefined ? {} : { owner: owner.info }),
|
|
36
|
+
});
|
|
37
|
+
if (!disposed) {
|
|
38
|
+
bridge.setSnapshot(snapshot);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
execution: input.execution,
|
|
43
|
+
acquire(request) {
|
|
44
|
+
if (isDisposed()) {
|
|
45
|
+
throw new PresentationExecutionError('disposed');
|
|
46
|
+
}
|
|
47
|
+
if (owner !== undefined) {
|
|
48
|
+
throw new PresentationExecutionError('busy');
|
|
49
|
+
}
|
|
50
|
+
const kind = request.kind;
|
|
51
|
+
const invocationId = request.invocationId;
|
|
52
|
+
const audioStart = request.audioStart ?? 'requested';
|
|
53
|
+
if (!['purchase', 'rewarded', 'interstitial'].includes(kind)
|
|
54
|
+
|| typeof invocationId !== 'string' || invocationId.trim() === '' || invocationId.length > 512
|
|
55
|
+
|| (audioStart !== 'requested' && audioStart !== 'started')) {
|
|
56
|
+
throw new TypeError('Invalid presentation identity.');
|
|
57
|
+
}
|
|
58
|
+
if (isDisposed()) {
|
|
59
|
+
throw new PresentationExecutionError('disposed');
|
|
60
|
+
}
|
|
61
|
+
if (owner !== undefined) {
|
|
62
|
+
throw new PresentationExecutionError('busy');
|
|
63
|
+
}
|
|
64
|
+
const owned = {
|
|
65
|
+
info: Object.freeze({ kind, invocationId, state: 'requested' }),
|
|
66
|
+
};
|
|
67
|
+
// Install ownership before execution listeners can reenter during acquisition.
|
|
68
|
+
owner = owned;
|
|
69
|
+
try {
|
|
70
|
+
owned.block = input.execution.acquireBlock({
|
|
71
|
+
reason: `presentation:${kind}`, channels: audioStart === 'started' ? ['simulation', 'gameplay-input'] : ['simulation', 'gameplay-input', 'audio'],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (owner === owned) {
|
|
76
|
+
owner = undefined;
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
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
|
+
};
|
|
99
|
+
publish();
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
markStarted() {
|
|
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
|
+
}
|
|
108
|
+
owned.info = Object.freeze({ ...owned.info, state: 'open' });
|
|
109
|
+
publish();
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
markUnknown() {
|
|
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
|
+
}
|
|
120
|
+
owned.info = Object.freeze({ ...owned.info, state: 'unknown' });
|
|
121
|
+
publish();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
confirmClosed() {
|
|
125
|
+
if (owner !== owned) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
owner = undefined;
|
|
129
|
+
publish();
|
|
130
|
+
owned.audioBlock?.release();
|
|
131
|
+
owned.block?.release();
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
getSnapshot() {
|
|
136
|
+
if (isDisposed() && snapshot.status !== 'disposed') {
|
|
137
|
+
publish();
|
|
138
|
+
}
|
|
139
|
+
return snapshot;
|
|
140
|
+
},
|
|
141
|
+
subscribe: (listener) => bridge.subscribeSnapshot(listener),
|
|
142
|
+
dispose() {
|
|
143
|
+
if (disposed) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Publish disposal before detaching view projections, without proving native closure.
|
|
147
|
+
snapshot = Object.freeze({ version: ++version, status: 'disposed', ...(owner === undefined ? {} : { owner: owner.info }) });
|
|
148
|
+
disposed = true;
|
|
149
|
+
bridge.setSnapshot(snapshot);
|
|
150
|
+
bridge.destroy();
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
package/docs/phaser.md
CHANGED
|
@@ -104,3 +104,14 @@ subsequent explicit `dispose()` retries it while the runtime is active. Successf
|
|
|
104
104
|
cleanup stays idempotent; terminal runtime destruction never retries an unmute.
|
|
105
105
|
Complete this explicit cleanup before reusing the same sink for another binding,
|
|
106
106
|
as required by the single-writer ownership contract. No retry timer is installed.
|
|
107
|
+
|
|
108
|
+
## Game-owned sound manager
|
|
109
|
+
|
|
110
|
+
Use `bindGameAudio` from `@mpgd/game-runtime/audio` above every scene and select
|
|
111
|
+
`audioOwner: 'game'` in scene bindings. That option prevents scene-level audio
|
|
112
|
+
projection and suppresses the missing-scene-audio diagnostic; providing both
|
|
113
|
+
it and a scene audio sink is rejected. Connect the game sound manager's mute
|
|
114
|
+
property to the sink. Scene shutdown only detaches its input/render binding.
|
|
115
|
+
Destroy the shared runtime on the Phaser game's destroy event, then detach the
|
|
116
|
+
audio projection. Unknown native presentation and application-owned claim
|
|
117
|
+
recovery survive scene disposal and must not be canceled by SDK cleanup.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Gameplay execution, scoped UI and action coordination with optional Phaser scene bindings.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -34,6 +34,22 @@
|
|
|
34
34
|
"./phaser": {
|
|
35
35
|
"types": "./dist/phaser/index.d.ts",
|
|
36
36
|
"default": "./dist/phaser/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./presentation": {
|
|
39
|
+
"types": "./dist/presentation/index.d.ts",
|
|
40
|
+
"default": "./dist/presentation/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./ads": {
|
|
43
|
+
"types": "./dist/ads/index.d.ts",
|
|
44
|
+
"default": "./dist/ads/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./audio": {
|
|
47
|
+
"types": "./dist/audio/index.d.ts",
|
|
48
|
+
"default": "./dist/audio/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./game": {
|
|
51
|
+
"types": "./dist/game/index.d.ts",
|
|
52
|
+
"default": "./dist/game/index.js"
|
|
37
53
|
}
|
|
38
54
|
},
|
|
39
55
|
"files": [
|
|
@@ -48,7 +64,8 @@
|
|
|
48
64
|
"@types/node": "^24.0.0"
|
|
49
65
|
},
|
|
50
66
|
"dependencies": {
|
|
51
|
-
"@mpgd/game-services": "0.
|
|
67
|
+
"@mpgd/game-services": "0.18.1",
|
|
68
|
+
"@mpgd/platform": "0.16.0"
|
|
52
69
|
},
|
|
53
70
|
"repository": {
|
|
54
71
|
"type": "git",
|