@mpgd/game-runtime 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 imjlk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # Game runtime
2
+
3
+ `@mpgd/game-runtime` coordinates gameplay execution with owned block tokens.
4
+ Its headless entrypoints have no Phaser, DOM, network, platform SDK, timer, or polling imports.
5
+ It does not change engine state, replace score-oriented `GameSession`, or alter
6
+ `PlatformGateway`. Engine and UI consumers apply the requested state separately.
7
+
8
+ ```ts
9
+ import { createGameExecutionController } from '@mpgd/game-runtime';
10
+
11
+ const runtime = createGameExecutionController({ onListenerError: reportError });
12
+ const settings = runtime.acquireBlock({
13
+ reason: 'settings', channels: ['simulation', 'gameplay-input'],
14
+ });
15
+ const background = runtime.acquireBlock({
16
+ reason: 'background', channels: ['simulation', 'gameplay-input', 'audio'],
17
+ });
18
+ background.release(); // Settings still blocks simulation and gameplay input.
19
+ settings.release(); // Those channels may now resume.
20
+ runtime.destroy(); // Terminal; this never requests gameplay resume.
21
+ ```
22
+
23
+ | Channel | Request |
24
+ | --- | --- |
25
+ | `simulation` | Stop gameplay simulation updates |
26
+ | `gameplay-input` | Block gameplay input, leaving pause/resume UI usable |
27
+ | `rendering` | Suppress gameplay rendering according to the engine binding policy |
28
+ | `audio` | Mute gameplay audio through an explicit audio sink |
29
+
30
+ Each channel is blocked while any token includes it. Equal reasons create
31
+ independent tokens. Only the returned token can release its block; diagnostic
32
+ IDs are local to a controller and cannot be used to release anything. Release
33
+ and unsubscribe are idempotent. Keep an additional token for user-confirmed
34
+ resume; the core supplies no countdown or automatic resume policy.
35
+
36
+ `getSnapshot()` returns the same reference until a successful acquisition,
37
+ first release, or first destruction. Each increments `version` once, including
38
+ changes to token diagnostics that leave the effective channel flags unchanged.
39
+ Snapshots, channel flags, token lists, token information, and channel arrays
40
+ are frozen. Caller arrays are copied; no consumer object is frozen.
41
+
42
+ `subscribe` does not emit initially: subscribe, then read `getSnapshot()`.
43
+ Listeners run in registration order. Each notification round captures its
44
+ snapshot and recipient list. Reentrant state changes are immediate but their
45
+ notifications queue behind the current round. Newly registered listeners only
46
+ receive future rounds; unsubscribed listeners are skipped even in a captured
47
+ round. Read the callback argument for that round's state, since `getSnapshot()`
48
+ may already reflect a reentrant change. Duplicate registrations are independent.
49
+
50
+ Synchronous exceptions and rejected listener promises go to optional
51
+ `onListenerError`. Promises are observed without awaiting them. Errors from
52
+ that hook are consumed; no global logging or transport is installed. Listeners
53
+ must not produce an unbounded cycle of state changes.
54
+
55
+ `destroy` is terminal and idempotent. It clears owned blocks, emits a terminal
56
+ snapshot with every channel blocked, and removes listeners. Destruction also
57
+ supersedes outstanding active notification rounds, preventing a stale resume
58
+ notification after shutdown. Late token releases are no-ops; new acquisitions
59
+ and subscriptions throw. Destroying this coordination object does not terminate
60
+ the game process. Consumers must check `status` before applying engine controls.
61
+
62
+ ## Installation and entrypoints
63
+
64
+ ```sh
65
+ pnpm add @mpgd/game-runtime
66
+ # Only when using the Phaser binding:
67
+ pnpm add phaser@^4.2.0
68
+ ```
69
+
70
+ | Import | Purpose |
71
+ | --- | --- |
72
+ | `@mpgd/game-runtime` | Reason-scoped execution controller |
73
+ | `@mpgd/game-runtime/ui` | Scoped snapshots, commands and events |
74
+ | `@mpgd/game-runtime/platform` | Injected lifecycle source binding |
75
+ | `@mpgd/game-runtime/actions` | Purchase and rewarded-ad coordination |
76
+ | `@mpgd/game-runtime/phaser` | Optional gameplay scene binding |
77
+
78
+ Phaser is an optional peer dependency. The root and headless subpaths do not
79
+ re-export the Phaser binding or reference its declarations, so headless consumers
80
+ can use them without installing Phaser or enabling DOM types. The action subpath
81
+ uses the published `@mpgd/game-services/operations` types; it does not load the
82
+ service implementation. Supply the existing game-services client explicitly.
83
+
84
+ The [Phaser binding guide](./docs/phaser.md) describes scene ownership and cleanup.
85
+ This package replaces the kit's two unpublished runtime previews with one public
86
+ package. Internal users of `@mpgd/phaser-game-runtime` should switch to
87
+ `@mpgd/game-runtime/phaser`. Existing generated games do not gain a dependency or
88
+ require migration automatically.
89
+
90
+ Releases use Sampo changesets and npm Trusted Publishing from
91
+ `imjlk/mpgd-kit`'s `.github/workflows/release.yml` after initial npm registration.
92
+
93
+ Contributor validation: `pnpm --dir packages/game-runtime test`,
94
+ `node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-runtime`,
95
+ and `node packages/game-runtime/test/dist-import.mjs`. The package test also
96
+ installs packed tarballs in an isolated consumer, checks headless declarations
97
+ without DOM types, and checks the optional binding with Phaser declarations.
98
+
99
+ ## Scoped UI bridge
100
+
101
+ `@mpgd/game-runtime/ui` is a separate, headless entrypoint. Importing the root
102
+ execution controller does not load the UI bridge. The bridge separates persistent
103
+ snapshots, user-intent commands, and one-time events. Multiple command handlers
104
+ are allowed and run in registration order; dispatch is not a success result or
105
+ proof that a purchase or reward was granted. Events have no replay or history.
106
+
107
+ ```ts
108
+ import { createGameUiBridge } from '@mpgd/game-runtime/ui';
109
+
110
+ const bridge = createGameUiBridge<
111
+ { count: number }, { type: 'refresh' }, { type: 'refreshed' }
112
+ >({ initialSnapshot: { count: 0 }, onListenerError: reportError });
113
+ const screen = bridge.createScope();
114
+ screen.subscribeSelector((state) => state.count, renderCount);
115
+ renderCount(bridge.getSnapshot().count); // Subscriptions do not emit initially.
116
+ screen.onCommand(async () => {
117
+ const count = await readCount();
118
+ screen.setSnapshot({ count });
119
+ screen.emit({ type: 'refreshed' });
120
+ });
121
+ screen.dispatch({ type: 'refresh' });
122
+ screen.dispose(); // A later readCount result cannot update this or a new screen.
123
+ ```
124
+
125
+ `getSnapshot()` remains referentially stable until `setSnapshot` receives a value
126
+ that differs under `Object.is`. Snapshots and command/event payloads belong to the
127
+ consumer: publish immutable values and replace changed state. The bridge neither
128
+ deep-clones nor freezes arbitrary consumer or engine objects. Only bridge and
129
+ scope API containers are frozen; listener lists and pending deliveries stay
130
+ private. There is no frame-based copying, timer, or global singleton.
131
+
132
+ `subscribeSelector(selector, listener, equality = Object.is)` evaluates its
133
+ initial selection without notifying. Unchanged selected values suppress delivery.
134
+ Selectors and equality functions must be pure. Initial selector failures reject
135
+ registration; subsequent selector, equality, or listener failures are isolated
136
+ and reported through `onListenerError`. A failed selection leaves the previous
137
+ selection intact. An invoked listener receives the new selection even if another
138
+ listener changed current state reentrantly.
139
+
140
+ All notifications share one FIFO delivery queue. Each dispatch captures its
141
+ value and recipients. Reentrant mutations update current state immediately, but
142
+ their notifications run after the current round. Unsubscribed recipients are
143
+ skipped and new registrations wait for future dispatches. Duplicate registrations
144
+ are independent. As with the execution controller, synchronous exceptions and
145
+ rejected promises are observed without awaiting; errors from the optional error
146
+ hook are consumed. Observation failure never converts a business operation into
147
+ failure. Avoid self-sustaining dispatch cycles.
148
+
149
+ Scopes own subscriptions and cleanup via `own(cleanup)`. Its returned function
150
+ releases that resource once, and removes it from the scope's retained cleanup
151
+ set. Disposal first revokes callback and commit permission, then runs registered
152
+ cleanups in registration order. Cleanup errors do not prevent remaining cleanup.
153
+ An asynchronously acquired resource passed to `own` after disposal is immediately
154
+ released. This is the one deliberately supported late registration; ordinary new
155
+ subscriptions on a disposed scope throw.
156
+
157
+ After disposal, scoped `setSnapshot`, `emit`, and `dispatch` return `false`.
158
+ They cannot affect another screen scope. Raw bridge methods are application-owner
159
+ APIs; passing those directly to a screen's asynchronous work bypasses this guard.
160
+ Scope disposal does not cancel a request, server verification, or reward claim.
161
+ Such business operations need an owner whose lifetime exceeds the screen.
162
+
163
+ Bridge destruction disposes all scopes, removes listeners and queued deliveries,
164
+ and rejects new registration, dispatch, emit, or setSnapshot calls. The final
165
+ snapshot remains readable. Disposal, destruction, and unsubscribe are idempotent;
166
+ late scoped commits still return `false` after bridge destruction.
167
+
168
+ The UI subpath shares the private package's publication prerequisites. No Sampo
169
+ changeset or generated-game dependency is added for this private-only extension.
170
+
171
+ ## Platform lifecycle binding
172
+
173
+ Import `bindGameLifecycle` from `@mpgd/game-runtime/platform`. Supply a controller,
174
+ a minimal `source` with `onPause`/`onResume` subscriptions (compatible with
175
+ `PlatformGateway.lifecycle`), and either an explicit `initialState` or a
176
+ `readState()` callback. States are `active`, `inactive`, and `unknown`; unknown
177
+ conservatively blocks. Default channels are all four execution channels.
178
+
179
+ Subscriptions install before reading current state. Events received during
180
+ installation override an explicit initial state; an event during `readState`
181
+ overrides that read's return value. A readable source should return its current
182
+ state synchronously. No DOM or SDK is imported and LifecycleAdapter is unchanged.
183
+
184
+ Each binding owns at most one token. Duplicate pause/resume events are idempotent;
185
+ a resume cannot release settings or another source's block. `dispose()` removes
186
+ subscriptions and releases only its own token. Source callbacks captured before
187
+ disposal become harmless, and controller destruction automatically detaches the
188
+ binding. Setup failures clean installed subscriptions; optional `onError` observes
189
+ cleanup errors without preventing remaining cleanup.
190
+
191
+ ```ts
192
+ const lifecycleBinding = bindGameLifecycle({
193
+ controller: runtime,
194
+ source: gateway.lifecycle,
195
+ initialState: 'unknown',
196
+ });
197
+ // A later source resume can release this binding's conservative startup block.
198
+ lifecycleBinding.dispose();
199
+ ```
200
+
201
+ ## Purchase and rewarded-ad actions
202
+
203
+ `@mpgd/game-runtime/actions` provides `createGameActionCoordinator`,
204
+ `createPurchaseActionController` and `createRewardedAdActionController`. Inject the
205
+ existing `GameServicesClient` (or its DOM-free `GameServicesOperationClient` port).
206
+ The controllers call only `purchase` and `claimRewardedAd`; they do not call an SDK,
207
+ verify a receipt, retry a transaction or grant local currency.
208
+
209
+ ```ts
210
+ import { createGameExecutionController } from '@mpgd/game-runtime';
211
+ import { createGameActionCoordinator } from '@mpgd/game-runtime/actions';
212
+ import { createGameUiBridge } from '@mpgd/game-runtime/ui';
213
+
214
+ // Application lifetime: one coordinator per runtime/client/player context.
215
+ const execution = createGameExecutionController();
216
+ const coordinator = createGameActionCoordinator({ execution, client });
217
+ const purchase = coordinator.createPurchaseController();
218
+ const ui = createGameUiBridge<string, never, string>({ initialSnapshot: 'idle' });
219
+ const screen = ui.createScope();
220
+ const view = purchase.bindScope(screen, {
221
+ snapshot: (value) => value.status,
222
+ event: (value) => `purchase:${value.status}`,
223
+ });
224
+ const result = view.execute({ productId: 'example', source: 'shop', idempotencyKey: suppliedKey });
225
+ screen.dispose(); // Detaches this screen; the service invocation and its block continue.
226
+ await result; // The owner also retains its safe completion snapshot through getSnapshot().
227
+ ```
228
+
229
+ ```mermaid
230
+ flowchart TD
231
+ App[Application / player context] --> Coordinator[Shared execution coordinator]
232
+ Coordinator --> Purchase[Purchase operation owner]
233
+ Coordinator --> Ad[Rewarded-ad operation owner]
234
+ Purchase --> A[Screen A scope]
235
+ Purchase --> B[Screen B scope]
236
+ Coordinator --> Client[Existing GameServicesClient]
237
+ Client --> Ledger[Existing platform and backend ledger flow]
238
+ ```
239
+
240
+ An owner outlives its views. `subscribe` observes safe owner snapshots without an
241
+ initial delivery. `bindScope` projects only operations explicitly executed or joined
242
+ through that view; merely opening screen B never subscribes B to screen A's old
243
+ completion. Scope disposal removes UI subscriptions and commit authority. It does
244
+ not cancel the service promise. Owner `dispose` rejects new executions and removes
245
+ owner UI listeners, but preserves the eventual snapshot of an already started
246
+ operation. Coordinator disposal prevents new work across its owners. Neither
247
+ kind of disposal claims to roll back an external purchase.
248
+
249
+ | State | Meaning and handling |
250
+ | --- | --- |
251
+ | `idle` | No operation observed by this owner. |
252
+ | `running` | Local service call in flight; optional `progress` is a real service observation. |
253
+ | `granted` | Existing service result reports a grant. Read authoritative economy state through existing APIs. |
254
+ | `cancelled` (purchase), `skipped` (ad) | Preserve the service outcome. No automatic retry. |
255
+ | `pending` (purchase) | Unresolved transaction; local gameplay block ends, reconciliation is still required. |
256
+ | `unavailable` (ad) | Service cannot provide this ad. |
257
+ | `rejected` / `failed` | Preserve these distinct service outcomes; no local grant and no automatic retry. |
258
+ | `exception` | Call threw; final transaction result is unknown. Original error rejects the returned Promise, not the UI snapshot. |
259
+
260
+ Snapshots contain only kind, status, coordinator-local operation ID and whitelisted
261
+ progress fields. They never contain receipt/evidence, raw server bodies, ledger
262
+ objects, player identity or provider error text. Progress does not prove grant or
263
+ native UI visibility/closure. A legacy client that ignores options stays `running`
264
+ until its result settles. Listener and projection errors, including rejected async
265
+ listeners, are isolated through `onObserverError`.
266
+
267
+ Each local invocation owns a simulation/gameplay-input token from before the service
268
+ call until settlement. Settings/background tokens remain independent. No timer,
269
+ SDK-visibility guess, polling or `Promise.race` releases a block early. A `pending`
270
+ result releases this local block but is not considered a cancelled transaction.
271
+
272
+ The coordinator serializes purchases and ads for its injected client. Identical
273
+ in-flight keys/inputs share the exact Promise across recreated owners. A key reused
274
+ with a different kind, product, source or placement rejects with `key-conflict`.
275
+ Different in-flight work rejects with `busy`. The most recent completed operation
276
+ can be explicitly observed again through the retained Promise; earlier completed
277
+ keys reject with `already-completed` and are never re-invoked.
278
+
279
+ Only input fingerprints are remembered, at most `maxRememberedKeys` (default 1024,
280
+ allowed 1–10000). Keys are never evicted silently: reaching the bound rejects new
281
+ keys with `history-full`. The coordinator retains at most one completed result
282
+ Promise, not an unbounded result/event log. It is scoped to the current process;
283
+ server ledger idempotency remains authoritative. Creating another coordinator or
284
+ restarting the process is outside this guarantee. Do not recreate it per screen or
285
+ to bypass unresolved work, and do not generate a new key for each UI retry.
286
+
287
+ After `pending` or an invoked operation exception, new keys reject with
288
+ `reconciliation-required`. The current client has no recovery/requery port, so this
289
+ version deliberately provides no reset/retry/polling API. Integrate the existing
290
+ provider/backend recovery policy outside these UI actions before starting a new
291
+ application coordination session. Do not treat a retryable hint as permission to
292
+ repurchase. Input/preflight scheduling rejections occur before the service call and
293
+ do not invent a business outcome.
294
+
295
+ Packaging: `/actions` uses **type-only** imports from `@mpgd/game-services/operations`.
296
+ The workspace dependency ensures declarations/build order; neither the basic
297
+ runtime import nor actions import loads the service implementation, Phaser or DOM.
298
+ Consumers use the repository-standard `skipLibCheck` for third-party typia
299
+ ambient declarations; the headless consumer smoke supplies only ES2022 globals.
300
+ This package remains private. Future publication requires initial npm registration,
301
+ OIDC, and the game-services release containing `/operations` and progress options
302
+ (planned 0.15.0). No generated game gains a dependency on this unpublished package.
303
+
304
+ The owner that reserves an operation controls its pre-invocation startup permission.
305
+ A reentrant same-key joiner cannot cancel that owner's startup by disposing itself.
306
+ If owner/runtime disposal prevents any client invocation, the flight rejects with
307
+ a scheduling error, resets its observed state to `idle`, and emits no business
308
+ completion/exception event. An invoked client failure remains `exception`.
@@ -0,0 +1,75 @@
1
+ import type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseProgress, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdProgress, GameServicesRewardedAdResult } from '@mpgd/game-services/operations';
2
+ import type { GameExecutionController } from '../index.js';
3
+ import { type ObserverErrorHandler } from '../observers.js';
4
+ import { type GameUiScope, type UiListener } from '../ui/index.js';
5
+ export type GameActionKind = 'purchase' | 'rewarded-ad';
6
+ interface Inputs {
7
+ purchase: GameServicesPurchaseInput;
8
+ 'rewarded-ad': GameServicesRewardedAdInput;
9
+ }
10
+ interface Results {
11
+ purchase: GameServicesPurchaseResult;
12
+ 'rewarded-ad': GameServicesRewardedAdResult;
13
+ }
14
+ interface Progress {
15
+ purchase: GameServicesPurchaseProgress;
16
+ 'rewarded-ad': GameServicesRewardedAdProgress;
17
+ }
18
+ export type GameActionSnapshot<K extends GameActionKind> = Readonly<{
19
+ kind: K;
20
+ } & ({
21
+ status: 'idle';
22
+ } | {
23
+ status: 'running';
24
+ operationId: number;
25
+ progress?: Progress[K];
26
+ } | {
27
+ status: Results[K]['status'] | 'exception';
28
+ operationId: number;
29
+ })>;
30
+ export type PurchaseActionSnapshot = GameActionSnapshot<'purchase'>;
31
+ export type RewardedAdActionSnapshot = GameActionSnapshot<'rewarded-ad'>;
32
+ export type GameActionErrorCode = 'disposed' | 'busy' | 'key-conflict' | 'already-completed' | 'reconciliation-required' | 'history-full' | 'invalid-input';
33
+ /** Scheduling/preflight rejection, distinct from a service result or external exception. */
34
+ export declare class GameActionExecutionError extends Error {
35
+ readonly code: GameActionErrorCode;
36
+ constructor(code: GameActionErrorCode);
37
+ }
38
+ export interface GameActionView<K extends GameActionKind> {
39
+ execute(input: Inputs[K]): Promise<Results[K]>;
40
+ /** Detaches this view; it cannot cancel an already started service operation. */
41
+ dispose(): void;
42
+ }
43
+ export interface GameActionController<K extends GameActionKind> extends GameActionView<K> {
44
+ getSnapshot(): GameActionSnapshot<K>;
45
+ /** No initial delivery. Observer failures cannot change the service result. */
46
+ subscribe(listener: UiListener<GameActionSnapshot<K>>): () => void;
47
+ isDisposed(): boolean;
48
+ /** Only actions explicitly executed/joined through this view may update its scope. */
49
+ bindScope<S, C, E>(scope: GameUiScope<S, C, E>, projection: {
50
+ snapshot(value: GameActionSnapshot<K>): S;
51
+ event?(value: GameActionSnapshot<K>): E;
52
+ }): GameActionView<K>;
53
+ }
54
+ export interface GameActionCoordinator {
55
+ createPurchaseController(): GameActionController<'purchase'>;
56
+ createRewardedAdController(): GameActionController<'rewarded-ad'>;
57
+ getAvailability(): 'ready' | 'busy' | 'reconciliation-required' | 'history-full' | 'disposed';
58
+ /** Terminal for new calls; pending external work still settles and releases its own block. */
59
+ dispose(): void;
60
+ }
61
+ /** Bind one coordinator to one runtime/client (including its player identity), above all screens. */
62
+ export declare function createGameActionCoordinator(options: {
63
+ readonly execution: GameExecutionController;
64
+ readonly client: Pick<GameServicesOperationClient, 'purchase' | 'claimRewardedAd'>;
65
+ /** Never evicts keys: once full, new keys are rejected until application teardown. Default 1024. */
66
+ readonly maxRememberedKeys?: number;
67
+ readonly onObserverError?: ObserverErrorHandler;
68
+ }): GameActionCoordinator;
69
+ export declare function createPurchaseActionController(input: {
70
+ coordinator: GameActionCoordinator;
71
+ }): GameActionController<'purchase'>;
72
+ export declare function createRewardedAdActionController(input: {
73
+ coordinator: GameActionCoordinator;
74
+ }): GameActionController<'rewarded-ad'>;
75
+ export {};