@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 +67 -13
- package/dist/actions/index.d.ts +43 -1
- package/dist/actions/index.js +61 -4
- package/package.json +1 -1
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
|
|
169
|
-
|
|
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`.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
package/dist/actions/index.d.ts
CHANGED
|
@@ -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: {
|
package/dist/actions/index.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
363
|
+
if (unconfirmed !== undefined) {
|
|
307
364
|
return 'reconciliation-required';
|
|
308
365
|
}
|
|
309
366
|
return history.size >= capacity ? 'history-full' : 'ready';
|