@mpgd/game-services 0.16.0 → 0.17.1
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/dist/admob-client-reward.d.ts +27 -0
- package/dist/admob-client-reward.js +84 -0
- package/dist/admob-ssv.d.ts +2 -0
- package/dist/admob-ssv.js +5 -0
- package/dist/app-store-recovery.d.ts +38 -0
- package/dist/app-store-recovery.js +271 -0
- package/dist/app-store-verifier.d.ts +7 -0
- package/dist/app-store-verifier.js +41 -3
- package/dist/authoritative-purchase.d.ts +9 -0
- package/dist/authoritative-purchase.js +10 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +17 -18
- package/dist/google-play-publisher-client.d.ts +11 -0
- package/dist/google-play-publisher-client.js +113 -0
- package/dist/google-play-purchase.d.ts +2 -0
- package/dist/google-play-purchase.js +21 -7
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/monetization-recovery.d.ts +63 -0
- package/dist/monetization-recovery.js +632 -0
- package/dist/operation-progress.d.ts +7 -1
- package/dist/operation-progress.js +23 -0
- package/dist/operation-results.d.ts +4 -0
- package/dist/operations.d.ts +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +29 -14
- package/dist/server.d.ts +20 -2
- package/dist/server.js +246 -86
- package/dist/types.d.ts +12 -0
- package/dist/types.js +81 -27
- package/dist/validation.d.ts +18 -0
- package/dist/validation.js +26 -0
- package/package.json +19 -6
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
|
|
2
|
+
import { createGameServicesClient } from './client.js';
|
|
3
|
+
import { isClientRewardEvidence, resolveRewardPlatformImpressionId, } from './admob-client-reward.js';
|
|
4
|
+
import { forwardGameServicesProgress, observeGameServicesOperation, } from './operation-progress.js';
|
|
5
|
+
/**
|
|
6
|
+
* Client-side recovery coordinates UI callbacks and authoritative backend calls.
|
|
7
|
+
* It never grants a wallet locally. Journal evidence can contain receipts/tokens,
|
|
8
|
+
* so the game's durable store must encrypt and access-control its records.
|
|
9
|
+
*/
|
|
10
|
+
export function createRecoverableMonetizationClient(input) {
|
|
11
|
+
const store = input.operationStore;
|
|
12
|
+
const observedAt = () => input.now?.() ?? new Date().toISOString();
|
|
13
|
+
const inFlight = new Map();
|
|
14
|
+
const unjournaledPurchases = new Map();
|
|
15
|
+
const unjournaledRewards = new Map();
|
|
16
|
+
const unjournaledPurchaseResponses = new Map();
|
|
17
|
+
const unjournaledRewardResponses = new Map();
|
|
18
|
+
const unjournaledPurchaseResults = new Map();
|
|
19
|
+
const unjournaledRewardResults = new Map();
|
|
20
|
+
async function serializeOperation(key, task) {
|
|
21
|
+
const previous = inFlight.get(key) ?? Promise.resolve();
|
|
22
|
+
const running = previous.catch(() => undefined).then(task);
|
|
23
|
+
inFlight.set(key, running);
|
|
24
|
+
try {
|
|
25
|
+
return await running;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (inFlight.get(key) === running) {
|
|
29
|
+
inFlight.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function save(record, patch) {
|
|
34
|
+
const next = { ...record, ...patch, revision: record.revision + 1 };
|
|
35
|
+
await store.replace(record.revision, next);
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
function assertOwner(record, kind, idempotencyKey, subjectId) {
|
|
39
|
+
if (record.key !== operationKey(input.target, kind, idempotencyKey)
|
|
40
|
+
|| record.kind !== kind || record.playerId !== input.playerId
|
|
41
|
+
|| record.target !== input.target
|
|
42
|
+
|| record.deploymentTarget !== (input.deploymentTarget ?? input.target)
|
|
43
|
+
|| record.input.idempotencyKey !== idempotencyKey
|
|
44
|
+
|| (record.kind === 'purchase' ? record.input.productId : record.input.placementId)
|
|
45
|
+
!== subjectId) {
|
|
46
|
+
throw new Error('Monetization operation key is bound to another player or subject.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function purchase(operation, options) {
|
|
50
|
+
const key = operationKey(input.target, 'purchase', operation.idempotencyKey);
|
|
51
|
+
return serializeOperation(key, async () => {
|
|
52
|
+
const reserved = await store.reserve({
|
|
53
|
+
key,
|
|
54
|
+
kind: 'purchase',
|
|
55
|
+
playerId: input.playerId,
|
|
56
|
+
target: input.target,
|
|
57
|
+
deploymentTarget: input.deploymentTarget ?? input.target,
|
|
58
|
+
revision: 0,
|
|
59
|
+
input: operation,
|
|
60
|
+
});
|
|
61
|
+
if (reserved.record.kind !== 'purchase') {
|
|
62
|
+
throw new Error('Monetization operation kind conflicts with its journal key.');
|
|
63
|
+
}
|
|
64
|
+
assertOwner(reserved.record, 'purchase', operation.idempotencyKey, operation.productId);
|
|
65
|
+
if (reserved.record.input.source !== operation.source) {
|
|
66
|
+
throw new Error('Purchase idempotency key was reused for another source.');
|
|
67
|
+
}
|
|
68
|
+
return resumePurchase(reserved.record, reserved.created, options);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function claimRewardedAd(operation, options) {
|
|
72
|
+
const key = operationKey(input.target, 'rewarded-ad', operation.idempotencyKey);
|
|
73
|
+
return serializeOperation(key, async () => {
|
|
74
|
+
const reserved = await store.reserve({
|
|
75
|
+
key,
|
|
76
|
+
kind: 'rewarded-ad',
|
|
77
|
+
playerId: input.playerId,
|
|
78
|
+
target: input.target,
|
|
79
|
+
deploymentTarget: input.deploymentTarget ?? input.target,
|
|
80
|
+
revision: 0,
|
|
81
|
+
input: operation,
|
|
82
|
+
});
|
|
83
|
+
if (reserved.record.kind !== 'rewarded-ad') {
|
|
84
|
+
throw new Error('Monetization operation kind conflicts with its journal key.');
|
|
85
|
+
}
|
|
86
|
+
assertOwner(reserved.record, 'rewarded-ad', operation.idempotencyKey, operation.placementId);
|
|
87
|
+
return resumeReward(reserved.record, reserved.created, options);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function resumePurchase(initial, mayOpenPlatform = false, options) {
|
|
91
|
+
// Observe recovery once, so a journal failure resolved into a result still completes.
|
|
92
|
+
return observeGameServicesOperation('purchase', options, (progress) => {
|
|
93
|
+
return settlePurchase(initial, mayOpenPlatform, progress);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function settlePurchase(initial, mayOpenPlatform, progress) {
|
|
97
|
+
let record = initial;
|
|
98
|
+
const unsavedResponse = unjournaledPurchaseResponses.get(record.key);
|
|
99
|
+
if (unsavedResponse !== undefined && record.response?.verified !== true) {
|
|
100
|
+
// A result beside the non-grant response is stale, such as another runtime's rejection.
|
|
101
|
+
// Drop it in the same write so the client path derives this grant again.
|
|
102
|
+
const { result: staleResult, ...current } = record;
|
|
103
|
+
try {
|
|
104
|
+
record = await save(current, { response: unsavedResponse });
|
|
105
|
+
unjournaledPurchaseResponses.delete(record.key);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return recordedPurchaseGrant({ ...current, response: unsavedResponse })
|
|
109
|
+
?? pendingPurchase(record.platform);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (record.response?.verified === true) {
|
|
113
|
+
unjournaledPurchaseResponses.delete(record.key);
|
|
114
|
+
}
|
|
115
|
+
const unsavedResult = unjournaledPurchaseResults.get(record.key);
|
|
116
|
+
if (unsavedResult !== undefined && record.result?.status !== 'granted') {
|
|
117
|
+
try {
|
|
118
|
+
record = await save(record, { result: unsavedResult });
|
|
119
|
+
unjournaledPurchaseResults.delete(record.key);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return unsavedResult;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (record.result?.status === 'granted') {
|
|
126
|
+
unjournaledPurchaseResults.delete(record.key);
|
|
127
|
+
}
|
|
128
|
+
const unjournaled = unjournaledPurchases.get(record.key);
|
|
129
|
+
if (unjournaled !== undefined && (record.platform === undefined
|
|
130
|
+
|| record.platform.status === 'pending' && unjournaled.platform.status !== 'pending')) {
|
|
131
|
+
try {
|
|
132
|
+
record = await save(record, {
|
|
133
|
+
platform: unjournaled.platform,
|
|
134
|
+
platformCompletedAt: unjournaled.completedAt,
|
|
135
|
+
});
|
|
136
|
+
unjournaledPurchases.delete(record.key);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return recordedPurchaseGrant({ ...record, platform: unjournaled.platform })
|
|
140
|
+
?? pendingPurchase(unjournaled.platform);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (record.platform !== undefined) {
|
|
144
|
+
if (unjournaled !== undefined && !samePlatformResult(record.platform, unjournaled.platform)) {
|
|
145
|
+
throw new Error('A platform purchase callback conflicts with the recorded operation.');
|
|
146
|
+
}
|
|
147
|
+
unjournaledPurchases.delete(record.key);
|
|
148
|
+
}
|
|
149
|
+
const completed = record.result;
|
|
150
|
+
if (completed !== undefined && !purchaseNeedsRetry(record)) {
|
|
151
|
+
return completed;
|
|
152
|
+
}
|
|
153
|
+
if (record.request !== undefined) {
|
|
154
|
+
assertPurchaseRequest(record, input);
|
|
155
|
+
}
|
|
156
|
+
if (record.platform === undefined && !mayOpenPlatform) {
|
|
157
|
+
return pendingPurchase();
|
|
158
|
+
}
|
|
159
|
+
const client = createGameServicesClient({
|
|
160
|
+
...input,
|
|
161
|
+
requestNow: () => record.platformCompletedAt ?? observedAt(),
|
|
162
|
+
gateway: {
|
|
163
|
+
...input.gateway,
|
|
164
|
+
commerce: {
|
|
165
|
+
...input.gateway.commerce,
|
|
166
|
+
async purchase(request) {
|
|
167
|
+
if (record.platform !== undefined) {
|
|
168
|
+
return record.platform;
|
|
169
|
+
}
|
|
170
|
+
const platform = await input.gateway.commerce.purchase(request);
|
|
171
|
+
const completedAt = observedAt();
|
|
172
|
+
const prior = record;
|
|
173
|
+
record = { ...record, platform, platformCompletedAt: completedAt };
|
|
174
|
+
try {
|
|
175
|
+
record = await save(prior, { platform, platformCompletedAt: completedAt });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
unjournaledPurchases.set(record.key, { platform, completedAt });
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
return platform;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
backend: {
|
|
186
|
+
...input.backend,
|
|
187
|
+
purchases: {
|
|
188
|
+
async verifyPurchase(request) {
|
|
189
|
+
if (record.request === undefined) {
|
|
190
|
+
record = await save(record, { request });
|
|
191
|
+
}
|
|
192
|
+
const recordedRequest = record.request;
|
|
193
|
+
if (recordedRequest === undefined) {
|
|
194
|
+
throw new Error('Purchase request was not journaled before backend dispatch.');
|
|
195
|
+
}
|
|
196
|
+
let response;
|
|
197
|
+
try {
|
|
198
|
+
response = await input.backend.purchases.verifyPurchase(recordedRequest);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
if (record.response?.verified === true) {
|
|
202
|
+
return record.response;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
verified: false,
|
|
206
|
+
alreadyProcessed: false,
|
|
207
|
+
disposition: 'pending',
|
|
208
|
+
reason: 'BACKEND_UNAVAILABLE',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (record.response?.verified === true && !response.verified) {
|
|
212
|
+
return record.response;
|
|
213
|
+
}
|
|
214
|
+
const prior = record;
|
|
215
|
+
record = { ...record, response };
|
|
216
|
+
try {
|
|
217
|
+
record = await save(prior, { response });
|
|
218
|
+
unjournaledPurchaseResponses.delete(record.key);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (response.verified) {
|
|
222
|
+
unjournaledPurchaseResponses.set(record.key, response);
|
|
223
|
+
}
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
return response;
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
try {
|
|
232
|
+
const result = await client.purchase(record.input, forwardGameServicesProgress(progress));
|
|
233
|
+
if (result.status === 'granted') {
|
|
234
|
+
unjournaledPurchaseResults.set(record.key, result);
|
|
235
|
+
}
|
|
236
|
+
record = await save(record, { result });
|
|
237
|
+
unjournaledPurchaseResults.delete(record.key);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// No UI retry is safe after a possibly completed platform callback.
|
|
242
|
+
return recordedPurchaseGrant(record)
|
|
243
|
+
?? unjournaledPurchaseResults.get(record.key)
|
|
244
|
+
?? pendingPurchase(record.platform);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function resumeReward(initial, mayOpenPlatform = false, options) {
|
|
248
|
+
return observeGameServicesOperation('rewarded-ad', options, (progress) => {
|
|
249
|
+
return settleReward(initial, mayOpenPlatform, progress);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async function settleReward(initial, mayOpenPlatform, progress) {
|
|
253
|
+
let record = initial;
|
|
254
|
+
const unsavedResponse = unjournaledRewardResponses.get(record.key);
|
|
255
|
+
if (unsavedResponse !== undefined && record.response?.granted !== true) {
|
|
256
|
+
// A result beside the non-grant claim is stale, such as another runtime's rejection.
|
|
257
|
+
// Drop it in the same write so the client path derives this grant again.
|
|
258
|
+
const { result: staleResult, ...current } = record;
|
|
259
|
+
try {
|
|
260
|
+
record = await save(current, { response: unsavedResponse });
|
|
261
|
+
unjournaledRewardResponses.delete(record.key);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return recordedRewardGrant({ ...current, response: unsavedResponse })
|
|
265
|
+
?? pendingReward(record.platform);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (record.response?.granted === true) {
|
|
269
|
+
unjournaledRewardResponses.delete(record.key);
|
|
270
|
+
}
|
|
271
|
+
const unsavedResult = unjournaledRewardResults.get(record.key);
|
|
272
|
+
if (unsavedResult !== undefined && record.result?.status !== 'granted') {
|
|
273
|
+
try {
|
|
274
|
+
record = await save(record, { result: unsavedResult });
|
|
275
|
+
unjournaledRewardResults.delete(record.key);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return unsavedResult;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else if (record.result?.status === 'granted') {
|
|
282
|
+
unjournaledRewardResults.delete(record.key);
|
|
283
|
+
}
|
|
284
|
+
const unjournaled = unjournaledRewards.get(record.key);
|
|
285
|
+
if (unjournaled !== undefined && (record.platform === undefined
|
|
286
|
+
|| record.platform.status === 'pending' && unjournaled.platform.status !== 'pending')) {
|
|
287
|
+
try {
|
|
288
|
+
record = await save(record, {
|
|
289
|
+
platform: unjournaled.platform,
|
|
290
|
+
platformCompletedAt: unjournaled.completedAt,
|
|
291
|
+
});
|
|
292
|
+
unjournaledRewards.delete(record.key);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return pendingReward(unjournaled.platform);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else if (record.platform !== undefined) {
|
|
299
|
+
if (unjournaled !== undefined && !samePlatformResult(record.platform, unjournaled.platform)) {
|
|
300
|
+
throw new Error('A platform ad callback conflicts with the recorded operation.');
|
|
301
|
+
}
|
|
302
|
+
unjournaledRewards.delete(record.key);
|
|
303
|
+
}
|
|
304
|
+
const completed = record.result;
|
|
305
|
+
if (completed !== undefined && completed.status !== 'pending') {
|
|
306
|
+
return completed;
|
|
307
|
+
}
|
|
308
|
+
if (record.request !== undefined) {
|
|
309
|
+
assertRewardRequest(record, input);
|
|
310
|
+
}
|
|
311
|
+
if (record.platform === undefined && !mayOpenPlatform) {
|
|
312
|
+
return pendingReward();
|
|
313
|
+
}
|
|
314
|
+
const client = createGameServicesClient({
|
|
315
|
+
...input,
|
|
316
|
+
requestNow: () => record.platformCompletedAt ?? observedAt(),
|
|
317
|
+
gateway: {
|
|
318
|
+
...input.gateway,
|
|
319
|
+
ads: {
|
|
320
|
+
...input.gateway.ads,
|
|
321
|
+
async showRewarded(request) {
|
|
322
|
+
if (record.platform !== undefined) {
|
|
323
|
+
return record.platform;
|
|
324
|
+
}
|
|
325
|
+
const platform = await input.gateway.ads.showRewarded(request);
|
|
326
|
+
const completedAt = observedAt();
|
|
327
|
+
const prior = record;
|
|
328
|
+
record = { ...record, platform, platformCompletedAt: completedAt };
|
|
329
|
+
try {
|
|
330
|
+
record = await save(prior, { platform, platformCompletedAt: completedAt });
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
unjournaledRewards.set(record.key, { platform, completedAt });
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
return platform;
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
backend: {
|
|
341
|
+
...input.backend,
|
|
342
|
+
adRewards: {
|
|
343
|
+
async claimAdReward(request) {
|
|
344
|
+
if (record.request === undefined) {
|
|
345
|
+
record = await save(record, { request });
|
|
346
|
+
}
|
|
347
|
+
const recordedRequest = record.request;
|
|
348
|
+
if (recordedRequest === undefined) {
|
|
349
|
+
throw new Error('Reward claim was not journaled before backend dispatch.');
|
|
350
|
+
}
|
|
351
|
+
let response;
|
|
352
|
+
try {
|
|
353
|
+
response = await input.backend.adRewards.claimAdReward(recordedRequest);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
if (record.response?.granted === true) {
|
|
357
|
+
return record.response;
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
granted: false,
|
|
361
|
+
alreadyProcessed: false,
|
|
362
|
+
disposition: 'pending',
|
|
363
|
+
reason: 'BACKEND_UNAVAILABLE',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (record.response?.granted === true && !response.granted) {
|
|
367
|
+
return record.response;
|
|
368
|
+
}
|
|
369
|
+
const prior = record;
|
|
370
|
+
record = { ...record, response };
|
|
371
|
+
try {
|
|
372
|
+
record = await save(prior, { response });
|
|
373
|
+
unjournaledRewardResponses.delete(record.key);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (response.granted) {
|
|
377
|
+
unjournaledRewardResponses.set(record.key, response);
|
|
378
|
+
}
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
return response;
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
try {
|
|
387
|
+
const result = await client.claimRewardedAd(record.input, forwardGameServicesProgress(progress));
|
|
388
|
+
if (result.status === 'granted') {
|
|
389
|
+
unjournaledRewardResults.set(record.key, result);
|
|
390
|
+
}
|
|
391
|
+
record = await save(record, { result });
|
|
392
|
+
unjournaledRewardResults.delete(record.key);
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return recordedRewardGrant(record)
|
|
397
|
+
?? unjournaledRewardResults.get(record.key)
|
|
398
|
+
?? pendingReward(record.platform);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function recoverPurchaseResult(idempotencyKey, platform) {
|
|
402
|
+
const key = operationKey(input.target, 'purchase', idempotencyKey);
|
|
403
|
+
return serializeOperation(key, async () => {
|
|
404
|
+
const found = await store.read(key);
|
|
405
|
+
if (found?.kind !== 'purchase') {
|
|
406
|
+
throw new Error('No reserved purchase operation matches this platform callback.');
|
|
407
|
+
}
|
|
408
|
+
assertOwner(found, 'purchase', idempotencyKey, found.input.productId);
|
|
409
|
+
const transient = unjournaledPurchases.get(key);
|
|
410
|
+
if (transient !== undefined && !samePlatformResult(transient.platform, platform)
|
|
411
|
+
&& !(transient.platform.status === 'pending' && platform.status !== 'pending')) {
|
|
412
|
+
throw new Error('A platform purchase callback conflicts with the unjournaled result.');
|
|
413
|
+
}
|
|
414
|
+
const canAdvance = found.platform?.status === 'pending'
|
|
415
|
+
&& platform.status !== 'pending' && found.request === undefined;
|
|
416
|
+
if (found.platform !== undefined && !samePlatformResult(found.platform, platform)
|
|
417
|
+
&& !canAdvance) {
|
|
418
|
+
throw new Error('A platform purchase callback conflicts with the recorded operation.');
|
|
419
|
+
}
|
|
420
|
+
let record = found;
|
|
421
|
+
if (found.platform === undefined || canAdvance) {
|
|
422
|
+
const completedAt = observedAt();
|
|
423
|
+
try {
|
|
424
|
+
record = await save(found, { platform, platformCompletedAt: completedAt });
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
unjournaledPurchases.set(key, { platform, completedAt });
|
|
428
|
+
return recordedPurchaseGrant({ ...found, platform }) ?? pendingPurchase(platform);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
unjournaledPurchases.delete(key);
|
|
432
|
+
return resumePurchase(record);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
async function recoverRewardResult(idempotencyKey, platform) {
|
|
436
|
+
const key = operationKey(input.target, 'rewarded-ad', idempotencyKey);
|
|
437
|
+
return serializeOperation(key, async () => {
|
|
438
|
+
const found = await store.read(key);
|
|
439
|
+
if (found?.kind !== 'rewarded-ad') {
|
|
440
|
+
throw new Error('No reserved rewarded-ad operation matches this platform callback.');
|
|
441
|
+
}
|
|
442
|
+
assertOwner(found, 'rewarded-ad', idempotencyKey, found.input.placementId);
|
|
443
|
+
const transient = unjournaledRewards.get(key);
|
|
444
|
+
if (transient !== undefined && !samePlatformResult(transient.platform, platform)
|
|
445
|
+
&& !(transient.platform.status === 'pending' && platform.status !== 'pending')) {
|
|
446
|
+
throw new Error('A platform ad callback conflicts with the unjournaled result.');
|
|
447
|
+
}
|
|
448
|
+
const canAdvance = found.platform?.status === 'pending'
|
|
449
|
+
&& platform.status !== 'pending' && found.request === undefined;
|
|
450
|
+
if (found.platform !== undefined && !samePlatformResult(found.platform, platform)
|
|
451
|
+
&& !canAdvance) {
|
|
452
|
+
throw new Error('A platform ad callback conflicts with the recorded operation.');
|
|
453
|
+
}
|
|
454
|
+
let record = found;
|
|
455
|
+
if (found.platform === undefined || canAdvance) {
|
|
456
|
+
const completedAt = observedAt();
|
|
457
|
+
try {
|
|
458
|
+
record = await save(found, { platform, platformCompletedAt: completedAt });
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
unjournaledRewards.set(key, { platform, completedAt });
|
|
462
|
+
return pendingReward(platform);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
unjournaledRewards.delete(key);
|
|
466
|
+
return resumeReward(record);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async function reconcile() {
|
|
470
|
+
const pending = await store.listRecoverable(input.playerId);
|
|
471
|
+
const summaries = [];
|
|
472
|
+
for (const record of pending) {
|
|
473
|
+
if (record?.playerId !== input.playerId || record.target !== input.target
|
|
474
|
+
|| record.deploymentTarget !== (input.deploymentTarget ?? input.target)) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (record.kind !== 'purchase' && record.kind !== 'rewarded-ad') {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const idempotencyKey = typeof record.input?.idempotencyKey === 'string'
|
|
481
|
+
? record.input.idempotencyKey
|
|
482
|
+
: 'unreadable-operation';
|
|
483
|
+
if (inFlight.has(record.key)) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const summary = await serializeOperation(record.key, async () => {
|
|
488
|
+
let latest;
|
|
489
|
+
try {
|
|
490
|
+
latest = await store.read(record.key) ?? record;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
throw new OperationReadError();
|
|
494
|
+
}
|
|
495
|
+
assertOwner(latest, latest.kind, latest.input.idempotencyKey, latest.kind === 'purchase' ? latest.input.productId : latest.input.placementId);
|
|
496
|
+
if (latest.kind === 'purchase') {
|
|
497
|
+
const result = await resumePurchase(latest);
|
|
498
|
+
return {
|
|
499
|
+
kind: latest.kind,
|
|
500
|
+
idempotencyKey: latest.input.idempotencyKey,
|
|
501
|
+
status: result.status,
|
|
502
|
+
finalizationPending: result.verification?.finalization?.status === 'pending',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const result = await resumeReward(latest);
|
|
506
|
+
return {
|
|
507
|
+
kind: latest.kind,
|
|
508
|
+
idempotencyKey: latest.input.idempotencyKey,
|
|
509
|
+
status: result.status,
|
|
510
|
+
finalizationPending: false,
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
summaries.push(summary);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
summaries.push({
|
|
517
|
+
kind: record.kind,
|
|
518
|
+
idempotencyKey,
|
|
519
|
+
status: error instanceof OperationReadError ? 'pending' : 'action-required',
|
|
520
|
+
finalizationPending: false,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return summaries;
|
|
525
|
+
}
|
|
526
|
+
return { purchase, claimRewardedAd, recoverPurchaseResult, recoverRewardResult, reconcile };
|
|
527
|
+
}
|
|
528
|
+
class OperationReadError extends Error {
|
|
529
|
+
}
|
|
530
|
+
function operationKey(target, kind, idempotencyKey) {
|
|
531
|
+
if (idempotencyKey.trim() === '') {
|
|
532
|
+
throw new Error('Monetization operations require a nonempty idempotency key.');
|
|
533
|
+
}
|
|
534
|
+
return JSON.stringify([target, kind, idempotencyKey]);
|
|
535
|
+
}
|
|
536
|
+
function samePlatformResult(left, right) {
|
|
537
|
+
return JSON.stringify(canonicalJson(left)) === JSON.stringify(canonicalJson(right));
|
|
538
|
+
}
|
|
539
|
+
function canonicalJson(value) {
|
|
540
|
+
if (Array.isArray(value)) {
|
|
541
|
+
return value.map(canonicalJson);
|
|
542
|
+
}
|
|
543
|
+
if (value !== null && typeof value === 'object') {
|
|
544
|
+
const entries = Object.entries(value)
|
|
545
|
+
.filter(([, entry]) => entry !== undefined)
|
|
546
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
547
|
+
.map(([key, entry]) => [key, canonicalJson(entry)]);
|
|
548
|
+
return Object.fromEntries(entries);
|
|
549
|
+
}
|
|
550
|
+
return value;
|
|
551
|
+
}
|
|
552
|
+
function pendingPurchase(platform) {
|
|
553
|
+
return {
|
|
554
|
+
status: 'pending',
|
|
555
|
+
purchase: platform ?? { status: 'pending', entitlementIds: [] },
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function pendingReward(platform) {
|
|
559
|
+
return {
|
|
560
|
+
status: 'pending',
|
|
561
|
+
reward: platform ?? { status: 'pending', rewardGranted: false },
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function recordedPurchaseGrant(record) {
|
|
565
|
+
if (record.result?.status === 'granted') {
|
|
566
|
+
return record.result;
|
|
567
|
+
}
|
|
568
|
+
if (record.platform !== undefined
|
|
569
|
+
&& isAuthoritativeMicrosoftStoreCompletion(record.target, record.platform)) {
|
|
570
|
+
// The adapter completed this ledger grant before returning, as the client path reports it.
|
|
571
|
+
return {
|
|
572
|
+
status: 'granted',
|
|
573
|
+
purchase: record.platform,
|
|
574
|
+
ledgerEntryId: record.platform.transactionId,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
if (record.response?.verified !== true || record.platform === undefined) {
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
status: 'granted',
|
|
582
|
+
purchase: record.platform,
|
|
583
|
+
verification: record.response,
|
|
584
|
+
...(record.response.ledgerEntryId === undefined
|
|
585
|
+
? {} : { ledgerEntryId: record.response.ledgerEntryId }),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function recordedRewardGrant(record) {
|
|
589
|
+
if (record.result?.status === 'granted') {
|
|
590
|
+
return record.result;
|
|
591
|
+
}
|
|
592
|
+
if (record.response?.granted !== true || record.platform === undefined) {
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
status: 'granted',
|
|
597
|
+
reward: record.platform,
|
|
598
|
+
claim: record.response,
|
|
599
|
+
...(record.response.ledgerEntryId === undefined
|
|
600
|
+
? {} : { ledgerEntryId: record.response.ledgerEntryId }),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function purchaseNeedsRetry(record) {
|
|
604
|
+
return record.result?.status === 'pending'
|
|
605
|
+
|| record.response?.finalization?.status === 'pending';
|
|
606
|
+
}
|
|
607
|
+
function assertPurchaseRequest(record, input) {
|
|
608
|
+
const request = record.request;
|
|
609
|
+
if (request === undefined || record.platform?.status !== 'completed'
|
|
610
|
+
|| request.playerId !== record.playerId || request.playerId !== input.playerId
|
|
611
|
+
|| request.target !== record.target || request.productId !== record.input.productId
|
|
612
|
+
|| request.idempotencyKey !== record.input.idempotencyKey
|
|
613
|
+
|| request.platformTransactionId !== record.platform.transactionId
|
|
614
|
+
|| request.deploymentTarget !== (input.deploymentTarget === input.target
|
|
615
|
+
? undefined : input.deploymentTarget)) {
|
|
616
|
+
throw new Error('Journaled purchase verification is not bound to its platform result.');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function assertRewardRequest(record, input) {
|
|
620
|
+
const request = record.request;
|
|
621
|
+
if (request === undefined || record.platform === undefined
|
|
622
|
+
|| ((record.platform.status !== 'completed' || !record.platform.rewardGranted)
|
|
623
|
+
&& !isClientRewardEvidence(record.platform))
|
|
624
|
+
|| request.playerId !== record.playerId || request.playerId !== input.playerId
|
|
625
|
+
|| request.target !== record.target || request.placementId !== record.input.placementId
|
|
626
|
+
|| request.idempotencyKey !== record.input.idempotencyKey
|
|
627
|
+
|| request.platformImpressionId !== resolveRewardPlatformImpressionId(record.platform)
|
|
628
|
+
|| request.deploymentTarget !== (input.deploymentTarget === input.target
|
|
629
|
+
? undefined : input.deploymentTarget)) {
|
|
630
|
+
throw new Error('Journaled ad claim is not bound to its platform result.');
|
|
631
|
+
}
|
|
632
|
+
}
|
|
@@ -36,12 +36,18 @@ export interface GameServicesOperationOptions<TProgress = GameServicesOperationP
|
|
|
36
36
|
readonly onObserverError?: (error: unknown) => void | Promise<void>;
|
|
37
37
|
readonly correlationId?: string;
|
|
38
38
|
}
|
|
39
|
-
interface OperationReporter<K extends GameServicesOperationKind> {
|
|
39
|
+
export interface OperationReporter<K extends GameServicesOperationKind> {
|
|
40
40
|
platformRequested(): void;
|
|
41
41
|
platformResult(status: PlatformStatus<K>): void;
|
|
42
42
|
serverRequested(): void;
|
|
43
43
|
serverResult(accepted: boolean): void;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Internal: relay a nested operation's platform and server phases to an outer observation.
|
|
47
|
+
* Its `completed` and `exception` stay internal because the outer observation reports the
|
|
48
|
+
* outcome it actually returns.
|
|
49
|
+
*/
|
|
50
|
+
export declare function forwardGameServicesProgress<K extends GameServicesOperationKind>(reporter: OperationReporter<K>): GameServicesOperationOptions<OperationProgress<K>>;
|
|
45
51
|
/** Internal observation wrapper; it does not retry, cancel, verify, or grant anything. */
|
|
46
52
|
export declare function observeGameServicesOperation<K extends GameServicesOperationKind>(kind: K, options: GameServicesOperationOptions<OperationProgress<K>> | undefined, operation: (reporter: OperationReporter<K>) => Promise<OperationResult<K>>): Promise<OperationResult<K>>;
|
|
47
53
|
export {};
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal: relay a nested operation's platform and server phases to an outer observation.
|
|
3
|
+
* Its `completed` and `exception` stay internal because the outer observation reports the
|
|
4
|
+
* outcome it actually returns.
|
|
5
|
+
*/
|
|
6
|
+
export function forwardGameServicesProgress(reporter) {
|
|
7
|
+
return {
|
|
8
|
+
onProgress(progress) {
|
|
9
|
+
if (progress.phase === 'platform-requested') {
|
|
10
|
+
reporter.platformRequested();
|
|
11
|
+
}
|
|
12
|
+
else if (progress.phase === 'platform-result') {
|
|
13
|
+
reporter.platformResult(progress.status);
|
|
14
|
+
}
|
|
15
|
+
else if (progress.phase === 'server-requested') {
|
|
16
|
+
reporter.serverRequested();
|
|
17
|
+
}
|
|
18
|
+
else if (progress.phase === 'server-result') {
|
|
19
|
+
reporter.serverResult(progress.accepted);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
1
24
|
/** Internal observation wrapper; it does not retry, cancel, verify, or grant anything. */
|
|
2
25
|
export async function observeGameServicesOperation(kind, options, operation) {
|
|
3
26
|
let onProgress;
|