@mpgd/game-runtime 0.1.0 → 0.2.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
@@ -165,8 +165,8 @@ and rejects new registration, dispatch, emit, or setSnapshot calls. The final
165
165
  snapshot remains readable. Disposal, destruction, and unsubscribe are idempotent;
166
166
  late scoped commits still return `false` after bridge destruction.
167
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.
168
+ The UI subpath is part of the published package. Changes to its public contract
169
+ or runtime behavior require a Sampo changeset for `@mpgd/game-runtime`.
170
170
 
171
171
  ## Platform lifecycle binding
172
172
 
@@ -285,21 +285,75 @@ restarting the process is outside this guarantee. Do not recreate it per screen
285
285
  to bypass unresolved work, and do not generate a new key for each UI retry.
286
286
 
287
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.
288
+ `reconciliation-required`. Configure an optional `reconciliation` port to connect
289
+ existing provider/backend recovery to this coordinator. `getPendingOperation()`
290
+ returns its frozen invocation identity. `reconcile()` joins concurrent calls into
291
+ one recovery query; it never calls `purchase` or `claimRewardedAd` again.
292
+ The recovery adapter must not await `coordinator.reconcile()` itself: that joins
293
+ the same in-flight query and creates a wait cycle.
294
+
295
+ ```ts
296
+ const coordinator = createGameActionCoordinator({
297
+ execution,
298
+ client, // permanently bound to authenticatedPlayerId
299
+ reconciliation: {
300
+ playerId: authenticatedPlayerId,
301
+ async recover(operation) {
302
+ // Application port: query/recover the original operation using your existing
303
+ // authenticated backend and saved evidence. This is not a bundled HTTP API.
304
+ const transaction = await recoveryBackend.findCommittedGrant(operation);
305
+ if (!transaction || transaction.source === 'admin') return undefined;
306
+ return { operationId: operation.operationId, transaction };
307
+ },
308
+ },
309
+ });
310
+ const recovery = await coordinator.reconcile();
311
+ if (recovery.status === 'reconciled') {
312
+ // Refresh authoritative entitlements. Do not grant locally from this notification.
313
+ await refreshEntitlements();
314
+ }
315
+ ```
316
+
317
+ The transaction shape is a structural subset of game-services
318
+ `ProductGrantTransaction`: `playerId`, `source`, `grantId`, `idempotencyKey` and
319
+ `ledgerEntryId`. The port is a trusted application integration boundary, like the
320
+ operation client itself. It must return an authenticated, committed backend grant,
321
+ not a platform callback or a receipt supplied by the UI. The coordinator checks
322
+ player, local operation number, purchase/ad source, logical product/placement and
323
+ original idempotency key before unlocking. It does not authenticate a server or
324
+ verify receipt signatures itself. Keep the port and client bound to the same
325
+ player and recreate the application runtime on account change; do not change a
326
+ client's player behind an existing coordinator.
327
+
328
+ `undefined` means still pending, a query error preserves the operation, and
329
+ mismatched/malformed identity rejects with `invalid-reconciliation`. No port gives
330
+ `reconciliation-unavailable`. With no unconfirmed operation, the result is
331
+ `not-required`. Disposal or execution destruction prevents a late query result
332
+ from being applied. Reconciliation owns no gameplay block.
333
+
334
+ This first connection resolves **confirmed ledger grants only**. An empty lookup,
335
+ a negative verification response, a timeout, cancellation or retryable provider
336
+ hint cannot prove terminal non-grant and cannot unlock the coordinator. Such
337
+ cases retain the conservative recovery requirement. There is no reset or automatic
338
+ repurchase. This API does not add a recovery service or durable operation storage;
339
+ those remain with the existing application/backend recovery policy.
340
+
341
+ Successful reconciliation clears the unconfirmed operation but retains key
342
+ history and the original Promise. That Promise still carries its original
343
+ pending result/exception; controller snapshots and detached UI scopes are not
344
+ rewritten by recovery. Use the reconciliation result to refresh the current UI.
345
+ The same key never starts a second service call and `history-full` still applies.
346
+ Input/preflight scheduling rejections occur before the service call and do not
347
+ invent a business outcome.
294
348
 
295
349
  Packaging: `/actions` uses **type-only** imports from `@mpgd/game-services/operations`.
296
350
  The workspace dependency ensures declarations/build order; neither the basic
297
351
  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.
352
+ The headless consumer checks declarations with `skipLibCheck: false` and only
353
+ ES2022 globals. The published service dependency supplies the `/operations` port
354
+ and progress options without requiring DOM types. Changes to the public action
355
+ contract or runtime behavior require a Sampo changeset for `@mpgd/game-runtime`.
356
+ Generated games opt into this package explicitly.
303
357
 
304
358
  The owner that reserves an operation controls its pre-invocation startup permission.
305
359
  A reentrant same-key joiner cannot cancel that owner's startup by disposing itself.
@@ -29,7 +29,7 @@ export type GameActionSnapshot<K extends GameActionKind> = Readonly<{
29
29
  })>;
30
30
  export type PurchaseActionSnapshot = GameActionSnapshot<'purchase'>;
31
31
  export type RewardedAdActionSnapshot = GameActionSnapshot<'rewarded-ad'>;
32
- export type GameActionErrorCode = 'disposed' | 'busy' | 'key-conflict' | 'already-completed' | 'reconciliation-required' | 'history-full' | 'invalid-input';
32
+ export type GameActionErrorCode = 'disposed' | 'busy' | 'key-conflict' | 'already-completed' | 'reconciliation-required' | 'history-full' | 'invalid-input' | 'reconciliation-unavailable' | 'invalid-reconciliation';
33
33
  /** Scheduling/preflight rejection, distinct from a service result or external exception. */
34
34
  export declare class GameActionExecutionError extends Error {
35
35
  readonly code: GameActionErrorCode;
@@ -51,9 +51,50 @@ export interface GameActionController<K extends GameActionKind> extends GameActi
51
51
  event?(value: GameActionSnapshot<K>): E;
52
52
  }): GameActionView<K>;
53
53
  }
54
+ /** Immutable invocation identity; no receipt, token or platform payload is retained. */
55
+ type PendingOperation<K extends GameActionKind> = Readonly<{
56
+ kind: K;
57
+ operationId: number;
58
+ input: Inputs[K];
59
+ }>;
60
+ export type GameActionPendingOperation = PendingOperation<'purchase'> | PendingOperation<'rewarded-ad'>;
61
+ /** Structural subset of game-services ProductGrantTransaction, returned by a trusted recovery port. */
62
+ export interface GameActionRecoveredGrant {
63
+ readonly playerId: string;
64
+ readonly source: 'purchase' | 'ad_reward';
65
+ readonly grantId: string;
66
+ readonly idempotencyKey: string;
67
+ readonly ledgerEntryId: string;
68
+ }
69
+ export interface GameActionReconciliationPort {
70
+ /** Must identify the same fixed player as the operation client. Recreate on account change. */
71
+ readonly playerId: string;
72
+ /** Read/recover an authoritative committed ledger grant; never open purchase/ad UI here.
73
+ * Return undefined while no committed grant can be confirmed.
74
+ * Do not await coordinator.reconcile() here: it joins this query and would deadlock. */
75
+ recover(operation: GameActionPendingOperation & {
76
+ readonly playerId: string;
77
+ }): Promise<{
78
+ readonly operationId: number;
79
+ readonly transaction: GameActionRecoveredGrant;
80
+ } | undefined>;
81
+ }
82
+ export type GameActionReconciliationResult = Readonly<{
83
+ status: 'not-required';
84
+ }> | Readonly<{
85
+ status: 'pending';
86
+ operation: GameActionPendingOperation;
87
+ }> | Readonly<{
88
+ status: 'reconciled';
89
+ operation: GameActionPendingOperation;
90
+ ledgerEntryId: string;
91
+ }>;
54
92
  export interface GameActionCoordinator {
55
93
  createPurchaseController(): GameActionController<'purchase'>;
56
94
  createRewardedAdController(): GameActionController<'rewarded-ad'>;
95
+ getPendingOperation(): GameActionPendingOperation | undefined;
96
+ /** Concurrent callers join one recovery query; only a matching committed grant unlocks new keys. */
97
+ reconcile(): Promise<GameActionReconciliationResult>;
57
98
  getAvailability(): 'ready' | 'busy' | 'reconciliation-required' | 'history-full' | 'disposed';
58
99
  /** Terminal for new calls; pending external work still settles and releases its own block. */
59
100
  dispose(): void;
@@ -64,6 +105,7 @@ export declare function createGameActionCoordinator(options: {
64
105
  readonly client: Pick<GameServicesOperationClient, 'purchase' | 'claimRewardedAd'>;
65
106
  /** Never evicts keys: once full, new keys are rejected until application teardown. Default 1024. */
66
107
  readonly maxRememberedKeys?: number;
108
+ readonly reconciliation?: GameActionReconciliationPort;
67
109
  readonly onObserverError?: ObserverErrorHandler;
68
110
  }): GameActionCoordinator;
69
111
  export declare function createPurchaseActionController(input: {
@@ -19,15 +19,22 @@ const purchaseSources = {
19
19
  export function createGameActionCoordinator(options) {
20
20
  const { execution, client, onObserverError } = options;
21
21
  const capacity = options.maxRememberedKeys ?? 1024;
22
+ const recoveryPlayerId = options.reconciliation?.playerId;
23
+ const recoveryPort = options.reconciliation;
24
+ if (recoveryPort && (typeof recoveryPlayerId !== 'string' || recoveryPlayerId.trim() === '' || typeof recoveryPort.recover !== 'function')) {
25
+ throw new GameActionExecutionError('invalid-input');
26
+ }
22
27
  if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 10000) {
23
28
  throw new RangeError('maxRememberedKeys must be an integer from 1 to 10000.');
24
29
  }
30
+ const recover = recoveryPort?.recover.bind(recoveryPort);
25
31
  const history = new Map();
26
32
  let current;
27
33
  let last;
28
34
  let nextId = 0;
29
35
  let disposed = false;
30
- let needsReconciliation = false;
36
+ let unconfirmed;
37
+ let recoveryFlight;
31
38
  function isDisposed() {
32
39
  return disposed || execution.getSnapshot().status === 'destroyed';
33
40
  }
@@ -65,7 +72,7 @@ export function createGameActionCoordinator(options) {
65
72
  if (prior !== undefined) {
66
73
  throw new GameActionExecutionError('already-completed');
67
74
  }
68
- if (needsReconciliation) {
75
+ if (unconfirmed !== undefined) {
69
76
  throw new GameActionExecutionError('reconciliation-required');
70
77
  }
71
78
  if (current !== undefined) {
@@ -93,7 +100,7 @@ export function createGameActionCoordinator(options) {
93
100
  function finish(status) {
94
101
  flight.settled = true;
95
102
  if (invoked && (status === 'pending' || status === 'exception')) {
96
- needsReconciliation = true;
103
+ unconfirmed = Object.freeze({ kind, operationId: id, input });
97
104
  }
98
105
  current = undefined;
99
106
  if (invoked) {
@@ -178,6 +185,54 @@ export function createGameActionCoordinator(options) {
178
185
  current = flight;
179
186
  return flight;
180
187
  }
188
+ function reconcile() {
189
+ if (isDisposed()) {
190
+ return Promise.reject(new GameActionExecutionError('disposed'));
191
+ }
192
+ if (recoveryFlight) {
193
+ return recoveryFlight;
194
+ }
195
+ const operation = unconfirmed;
196
+ if (!operation) {
197
+ return Promise.resolve(Object.freeze({ status: 'not-required' }));
198
+ }
199
+ if (!recover || recoveryPlayerId === undefined) {
200
+ return Promise.reject(new GameActionExecutionError('reconciliation-unavailable'));
201
+ }
202
+ // Queue invocation until the shared promise is installed, including reentrant port calls.
203
+ const work = Promise.resolve().then(async () => {
204
+ if (isDisposed()) {
205
+ throw new GameActionExecutionError('disposed');
206
+ }
207
+ const recovered = await recover(Object.freeze({ ...operation, playerId: recoveryPlayerId }));
208
+ if (isDisposed()) {
209
+ throw new GameActionExecutionError('disposed');
210
+ }
211
+ if (recovered === undefined) {
212
+ return Object.freeze({ status: 'pending', operation });
213
+ }
214
+ const transaction = recovered?.transaction;
215
+ const grantId = operation.kind === 'purchase' ? operation.input.productId : operation.input.placementId;
216
+ const source = operation.kind === 'purchase' ? 'purchase' : 'ad_reward';
217
+ if (!recovered || unconfirmed !== operation || recovered.operationId !== operation.operationId
218
+ || !transaction || transaction.playerId !== recoveryPlayerId || transaction.source !== source
219
+ || transaction.grantId !== grantId || transaction.idempotencyKey !== operation.input.idempotencyKey
220
+ || typeof transaction.ledgerEntryId !== 'string' || transaction.ledgerEntryId.trim() === '') {
221
+ throw new GameActionExecutionError('invalid-reconciliation');
222
+ }
223
+ const result = Object.freeze({ status: 'reconciled', operation, ledgerEntryId: transaction.ledgerEntryId });
224
+ // History and the original promise are retained: recovering never makes the same key executable again.
225
+ unconfirmed = undefined;
226
+ return result;
227
+ });
228
+ const joined = work.finally(() => {
229
+ if (recoveryFlight === joined) {
230
+ recoveryFlight = undefined;
231
+ }
232
+ });
233
+ void (recoveryFlight = joined);
234
+ return joined;
235
+ }
181
236
  function makeController(kind) {
182
237
  if (isDisposed()) {
183
238
  throw new GameActionExecutionError('disposed');
@@ -295,6 +350,8 @@ export function createGameActionCoordinator(options) {
295
350
  }
296
351
  return Object.freeze({
297
352
  createPurchaseController: () => makeController('purchase'),
353
+ getPendingOperation: () => unconfirmed,
354
+ reconcile,
298
355
  createRewardedAdController: () => makeController('rewarded-ad'),
299
356
  getAvailability() {
300
357
  if (isDisposed()) {
@@ -303,7 +360,7 @@ export function createGameActionCoordinator(options) {
303
360
  if (current !== undefined) {
304
361
  return 'busy';
305
362
  }
306
- if (needsReconciliation) {
363
+ if (unconfirmed !== undefined) {
307
364
  return 'reconciliation-required';
308
365
  }
309
366
  return history.size >= capacity ? 'history-full' : 'ready';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Gameplay execution, scoped UI and action coordination with optional Phaser scene bindings.",
5
5
  "license": "MIT",
6
6
  "keywords": [