@mpgd/game-runtime 0.2.5 → 0.3.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 +625 -0
- package/dist/audio/index.d.ts +14 -0
- package/dist/audio/index.js +35 -0
- package/dist/game/index.d.ts +41 -0
- package/dist/game/index.js +103 -0
- package/dist/phaser/index.d.ts +2 -0
- package/dist/phaser/index.js +4 -1
- package/dist/presentation/index.d.ts +40 -0
- package/dist/presentation/index.js +120 -0
- package/docs/phaser.md +11 -0
- package/package.json +19 -2
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { assertAdAvailability, assertAdPreparationResult, assertAdPresentationEvent, assertAdShowInput, assertAdShowResult, createAdSession, reduceAdSession, toAdAdapter, } from '@mpgd/platform/ads';
|
|
2
|
+
import { observe } from '../observers.js';
|
|
3
|
+
import { PresentationExecutionError, } from '../presentation/index.js';
|
|
4
|
+
/** Connect late SDK candidates to the reserved journal operation, without reopening UI. */
|
|
5
|
+
export function createAdClaimEvidenceRecoveryObserver(input) {
|
|
6
|
+
const recover = input.recovery.recoverRewardResult.bind(input.recovery);
|
|
7
|
+
return async (observation) => {
|
|
8
|
+
const request = assertAdShowInput(observation.input);
|
|
9
|
+
if (request.format !== 'rewarded' || (observation.eligibility !== 'eligible' && observation.eligibility !== 'unknown')) {
|
|
10
|
+
throw new TypeError('Late claim observation must belong to a rewarded invocation.');
|
|
11
|
+
}
|
|
12
|
+
const result = assertAdShowResult({
|
|
13
|
+
providerId: 'claim-recovery',
|
|
14
|
+
invocationId: request.invocationId,
|
|
15
|
+
format: 'rewarded',
|
|
16
|
+
outcome: 'pending',
|
|
17
|
+
presentation: 'unknown',
|
|
18
|
+
eligibility: 'unknown',
|
|
19
|
+
reason: 'outcome-unknown',
|
|
20
|
+
claimEvidence: observation.evidence,
|
|
21
|
+
});
|
|
22
|
+
if (result.claimEvidence === undefined) {
|
|
23
|
+
throw new TypeError('Late claim observation lacks evidence.');
|
|
24
|
+
}
|
|
25
|
+
const settled = await recover(request.idempotencyKey, Object.freeze({ status: 'pending', rewardGranted: false, evidence: result.claimEvidence }));
|
|
26
|
+
await input.onResult?.(request, settled);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function createCoordinatedAdProvider(input) {
|
|
30
|
+
const provider = input.provider;
|
|
31
|
+
// Validate protocol metadata before installing any external observer.
|
|
32
|
+
toAdAdapter(provider);
|
|
33
|
+
const id = provider.id;
|
|
34
|
+
const protocol = provider.protocol;
|
|
35
|
+
const protocolVersion = provider.protocolVersion;
|
|
36
|
+
const rewardSignal = provider.rewardSignal;
|
|
37
|
+
const capacity = input.maxRememberedInvocations ?? 1024;
|
|
38
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 10000) {
|
|
39
|
+
throw new RangeError('maxRememberedInvocations must be from 1 to 10000.');
|
|
40
|
+
}
|
|
41
|
+
const deadline = input.deadline;
|
|
42
|
+
if (deadline !== undefined && (!Number.isSafeInteger(deadline.milliseconds) || deadline.milliseconds < 1
|
|
43
|
+
|| typeof deadline.schedule !== 'function')) {
|
|
44
|
+
throw new TypeError('Invalid advertising wait deadline.');
|
|
45
|
+
}
|
|
46
|
+
const flights = new Map();
|
|
47
|
+
const listeners = new Set();
|
|
48
|
+
const outputSequences = new Map();
|
|
49
|
+
let disposed = false;
|
|
50
|
+
let detached = false;
|
|
51
|
+
function emit(flight, event) {
|
|
52
|
+
const sequence = (outputSequences.get(flight.input.invocationId) ?? 0) + 1;
|
|
53
|
+
outputSequences.set(flight.input.invocationId, sequence);
|
|
54
|
+
const published = assertAdPresentationEvent({
|
|
55
|
+
...event,
|
|
56
|
+
providerId: id,
|
|
57
|
+
invocationId: flight.input.invocationId,
|
|
58
|
+
sequence,
|
|
59
|
+
});
|
|
60
|
+
for (const listener of [...listeners]) {
|
|
61
|
+
observe(() => listener(published), input.onObserverError);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function evidence(flight, candidate, eligibility) {
|
|
65
|
+
if (!flight.delivered) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const fingerprint = JSON.stringify([
|
|
69
|
+
eligibility,
|
|
70
|
+
candidate.schema,
|
|
71
|
+
Object.entries(candidate.payload).sort(([a], [b]) => a.localeCompare(b)),
|
|
72
|
+
]);
|
|
73
|
+
if (fingerprint === flight.lastClaimFingerprint) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
flight.lastClaimFingerprint = fingerprint;
|
|
77
|
+
observe(() => input.onClaimEvidence?.(Object.freeze({ input: flight.input, evidence: candidate, eligibility })), input.onObserverError);
|
|
78
|
+
}
|
|
79
|
+
function lateResult(flight, result) {
|
|
80
|
+
if (!flight.delivered || flight.hasObservedCandidate && (result.eligibility === 'not-earned' || result.presentation === 'not-started')) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const fingerprint = JSON.stringify(result);
|
|
84
|
+
if (flight.lastLateResultFingerprint === fingerprint) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
flight.lastLateResultFingerprint = fingerprint;
|
|
88
|
+
observe(() => input.onLateResult?.(Object.freeze({ input: flight.input, result })), input.onObserverError);
|
|
89
|
+
}
|
|
90
|
+
function maybeDetach() {
|
|
91
|
+
if (!disposed || detached || [...flights.values()].some((flight) => !flight.delivered
|
|
92
|
+
|| flight.session.presentation === 'unknown' || flight.session.presentation === 'open'
|
|
93
|
+
|| flight.session.presentation === 'closed' && flight.session.eligibility === 'unknown' && rewardSignal === 'delayed')) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
detached = true;
|
|
97
|
+
if (unsubscribe !== undefined) {
|
|
98
|
+
observe(unsubscribe, input.onObserverError);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function updateLease(flight) {
|
|
102
|
+
if (flight.session.presentation === 'closed' || flight.session.terminal && flight.session.presentation === 'not-started') {
|
|
103
|
+
flight.lease?.confirmClosed();
|
|
104
|
+
}
|
|
105
|
+
else if (flight.session.presentation === 'unknown') {
|
|
106
|
+
flight.lease?.markUnknown();
|
|
107
|
+
}
|
|
108
|
+
else if (flight.session.presentation === 'open') {
|
|
109
|
+
flight.lease?.markStarted();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const unsubscribe = provider.subscribe((event) => {
|
|
113
|
+
observe(() => {
|
|
114
|
+
const flight = flights.get(event.invocationId);
|
|
115
|
+
if (flight === undefined || !flight.called || event.providerId !== id) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const previous = flight.session;
|
|
119
|
+
const next = reduceAdSession(previous, event);
|
|
120
|
+
if (next === previous) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
flight.session = next;
|
|
124
|
+
flight.claimEvidence = next.claimEvidence;
|
|
125
|
+
flight.hasObservedCandidate ||= next.evidence !== undefined || next.claimEvidence !== undefined;
|
|
126
|
+
updateLease(flight);
|
|
127
|
+
if (event.type === 'failed' && next.presentation === 'not-started' && !next.started
|
|
128
|
+
&& previous.evidence === undefined && previous.claimEvidence === undefined) {
|
|
129
|
+
lateResult(flight, assertAdShowResult({ providerId: id, invocationId: flight.input.invocationId, format: flight.input.format,
|
|
130
|
+
outcome: 'failed', presentation: 'not-started', eligibility: flight.input.format === 'rewarded' ? 'not-earned' : 'not-applicable', reason: event.reason }));
|
|
131
|
+
}
|
|
132
|
+
if (!previous.terminal || event.type === 'reward-earned' && next.evidence !== previous.evidence) {
|
|
133
|
+
if (event.type !== 'reward-earned' || next.evidence !== previous.evidence) {
|
|
134
|
+
emit(flight, event);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (event.type === 'reward-earned' && next.evidence !== previous.evidence && next.evidence !== undefined) {
|
|
138
|
+
evidence(flight, flight.claimEvidence ?? next.evidence, 'eligible');
|
|
139
|
+
}
|
|
140
|
+
if (event.type === 'requested' && next.claimEvidence !== undefined && next.claimEvidence !== previous.claimEvidence) {
|
|
141
|
+
evidence(flight, next.claimEvidence, next.eligibility === 'eligible' ? 'eligible' : 'unknown');
|
|
142
|
+
}
|
|
143
|
+
maybeDetach();
|
|
144
|
+
}, input.onObserverError);
|
|
145
|
+
});
|
|
146
|
+
function placement(supplied) {
|
|
147
|
+
const request = assertAdShowInput({
|
|
148
|
+
...supplied,
|
|
149
|
+
invocationId: 'availability',
|
|
150
|
+
idempotencyKey: 'availability',
|
|
151
|
+
});
|
|
152
|
+
return Object.freeze({ placementId: request.placementId, format: request.format });
|
|
153
|
+
}
|
|
154
|
+
async function getAvailability(supplied) {
|
|
155
|
+
const request = placement(supplied);
|
|
156
|
+
if (disposed || input.presentation.getSnapshot().status === 'disposed') {
|
|
157
|
+
return { state: 'action-required', reason: 'action-required' };
|
|
158
|
+
}
|
|
159
|
+
if (input.canShow?.(request) === false) {
|
|
160
|
+
return {
|
|
161
|
+
state: 'temporarily-unavailable',
|
|
162
|
+
reason: 'policy-disabled',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (input.presentation.getSnapshot().owner !== undefined) {
|
|
166
|
+
return {
|
|
167
|
+
state: 'temporarily-unavailable',
|
|
168
|
+
reason: 'busy',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return assertAdAvailability(await provider.getAvailability(request));
|
|
172
|
+
}
|
|
173
|
+
function unavailable(request, reason) {
|
|
174
|
+
return assertAdShowResult({
|
|
175
|
+
providerId: id,
|
|
176
|
+
invocationId: request.invocationId,
|
|
177
|
+
format: request.format,
|
|
178
|
+
outcome: 'unavailable',
|
|
179
|
+
presentation: 'not-started',
|
|
180
|
+
eligibility: request.format === 'rewarded' ? 'not-earned' : 'not-applicable',
|
|
181
|
+
reason,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function availabilityReason(value) {
|
|
185
|
+
if (value.reason !== undefined) {
|
|
186
|
+
return value.reason;
|
|
187
|
+
}
|
|
188
|
+
if (value.state === 'configuration-required' || value.state === 'action-required') {
|
|
189
|
+
return value.state;
|
|
190
|
+
}
|
|
191
|
+
return value.state === 'temporarily-unavailable' ? 'transient-failure' : 'unsupported';
|
|
192
|
+
}
|
|
193
|
+
function observation(flight) {
|
|
194
|
+
const session = flight.session;
|
|
195
|
+
if (session.terminal && session.presentation === 'not-started') {
|
|
196
|
+
return assertAdShowResult({
|
|
197
|
+
providerId: id,
|
|
198
|
+
invocationId: flight.input.invocationId,
|
|
199
|
+
format: flight.input.format,
|
|
200
|
+
outcome: 'failed',
|
|
201
|
+
presentation: 'not-started',
|
|
202
|
+
eligibility: session.eligibility,
|
|
203
|
+
reason: session.reason ?? 'transient-failure',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const closed = session.presentation === 'closed';
|
|
207
|
+
const shown = session.started || session.eligibility === 'eligible';
|
|
208
|
+
let outcome = 'pending';
|
|
209
|
+
if (closed) {
|
|
210
|
+
outcome = shown ? 'shown' : 'skipped';
|
|
211
|
+
}
|
|
212
|
+
return assertAdShowResult({
|
|
213
|
+
providerId: id, invocationId: flight.input.invocationId, format: flight.input.format,
|
|
214
|
+
outcome, presentation: closed ? 'closed' : 'unknown', eligibility: session.eligibility,
|
|
215
|
+
...(!closed ? { reason: 'outcome-unknown' } : {}),
|
|
216
|
+
...(session.evidence === undefined ? {} : { evidence: session.evidence }),
|
|
217
|
+
...(flight.claimEvidence === undefined || session.eligibility === 'not-earned' || session.eligibility === 'not-applicable'
|
|
218
|
+
? {} : { claimEvidence: flight.claimEvidence }),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function deliver(flight, result) {
|
|
222
|
+
if (flight.delivered) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
flight.delivered = true;
|
|
226
|
+
observe(flight.cancelWait, input.onObserverError);
|
|
227
|
+
flight.resolve(result);
|
|
228
|
+
if (!flight.called) {
|
|
229
|
+
flights.delete(flight.input.invocationId);
|
|
230
|
+
}
|
|
231
|
+
maybeDetach();
|
|
232
|
+
}
|
|
233
|
+
function unknown(flight) {
|
|
234
|
+
if (!flight.session.terminal && flight.session.presentation !== 'closed') {
|
|
235
|
+
flight.session = Object.freeze({ ...flight.session, presentation: 'unknown', reason: 'outcome-unknown' });
|
|
236
|
+
updateLease(flight);
|
|
237
|
+
emit(flight, { type: 'unknown' });
|
|
238
|
+
}
|
|
239
|
+
deliver(flight, observation(flight));
|
|
240
|
+
}
|
|
241
|
+
function finish(flight, supplied) {
|
|
242
|
+
const result = assertAdShowResult(supplied);
|
|
243
|
+
if (result.providerId !== id || result.invocationId !== flight.input.invocationId || result.format !== flight.input.format) {
|
|
244
|
+
throw new TypeError('Advertising result belongs to another invocation.');
|
|
245
|
+
}
|
|
246
|
+
if (result.presentation === 'not-started' && (flight.session.started || flight.session.eligibility === 'eligible')) {
|
|
247
|
+
throw new TypeError('Advertising result contradicts native presentation observations.');
|
|
248
|
+
}
|
|
249
|
+
const previous = flight.session;
|
|
250
|
+
flight.hasObservedCandidate ||= previous.evidence !== undefined || previous.claimEvidence !== undefined || result.evidence !== undefined || result.claimEvidence !== undefined;
|
|
251
|
+
if (previous.terminal && previous.presentation === 'not-started' && result.presentation !== 'not-started') {
|
|
252
|
+
// A less specific Promise cannot reopen a definitively failed native request.
|
|
253
|
+
const terminal = observation(flight);
|
|
254
|
+
lateResult(flight, terminal);
|
|
255
|
+
deliver(flight, terminal);
|
|
256
|
+
maybeDetach();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const closed = previous.presentation === 'closed' || result.presentation === 'closed';
|
|
260
|
+
let eligibility = result.eligibility;
|
|
261
|
+
if (previous.eligibility === 'eligible') {
|
|
262
|
+
eligibility = 'eligible';
|
|
263
|
+
}
|
|
264
|
+
else if (previous.terminal && rewardSignal === 'immediate') {
|
|
265
|
+
eligibility = previous.eligibility;
|
|
266
|
+
}
|
|
267
|
+
const rewardEvidence = eligibility === 'eligible'
|
|
268
|
+
? (previous.evidence ?? result.evidence)
|
|
269
|
+
: undefined;
|
|
270
|
+
const { evidence: previousEvidence, reason: previousReason, claimEvidence: previousClaimEvidence, ...base } = previous;
|
|
271
|
+
void previousEvidence;
|
|
272
|
+
void previousReason;
|
|
273
|
+
void previousClaimEvidence;
|
|
274
|
+
if (eligibility !== 'eligible' && eligibility !== 'unknown' || result.presentation === 'not-started') {
|
|
275
|
+
flight.claimEvidence = undefined;
|
|
276
|
+
}
|
|
277
|
+
else if (result.claimEvidence !== undefined) {
|
|
278
|
+
flight.claimEvidence ??= result.claimEvidence;
|
|
279
|
+
}
|
|
280
|
+
// Native sequence watermarks belong to the native event stream, never synthetic result/deadline observations.
|
|
281
|
+
flight.session = Object.freeze({
|
|
282
|
+
...base, presentation: closed ? 'closed' : result.presentation,
|
|
283
|
+
terminal: closed || result.presentation === 'not-started',
|
|
284
|
+
started: previous.started || result.outcome === 'shown',
|
|
285
|
+
eligibility,
|
|
286
|
+
...(rewardEvidence === undefined ? {} : { evidence: rewardEvidence }),
|
|
287
|
+
...(flight.claimEvidence === undefined ? {} : { claimEvidence: flight.claimEvidence }),
|
|
288
|
+
});
|
|
289
|
+
if (result.presentation === 'not-started') {
|
|
290
|
+
flight.lease?.confirmClosed();
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
updateLease(flight);
|
|
294
|
+
}
|
|
295
|
+
if (closed && previous.presentation !== 'closed') {
|
|
296
|
+
emit(flight, { type: 'closed' });
|
|
297
|
+
}
|
|
298
|
+
const candidate = flight.claimEvidence ?? rewardEvidence;
|
|
299
|
+
if (candidate !== undefined && (eligibility === 'eligible' || eligibility === 'unknown')) {
|
|
300
|
+
evidence(flight, candidate, eligibility);
|
|
301
|
+
}
|
|
302
|
+
const { evidence: suppliedEvidence, claimEvidence: suppliedClaimEvidence, ...resultBase } = result;
|
|
303
|
+
void suppliedEvidence;
|
|
304
|
+
void suppliedClaimEvidence;
|
|
305
|
+
let outcome = result.outcome;
|
|
306
|
+
if (closed && eligibility === 'eligible') {
|
|
307
|
+
outcome = 'shown';
|
|
308
|
+
}
|
|
309
|
+
if (closed && outcome === 'pending') {
|
|
310
|
+
outcome = flight.session.started ? 'shown' : 'skipped';
|
|
311
|
+
}
|
|
312
|
+
const settled = assertAdShowResult({
|
|
313
|
+
...resultBase,
|
|
314
|
+
outcome,
|
|
315
|
+
presentation: flight.session.presentation,
|
|
316
|
+
eligibility,
|
|
317
|
+
...(rewardEvidence === undefined ? {} : { evidence: rewardEvidence }),
|
|
318
|
+
...(flight.claimEvidence === undefined ? {} : { claimEvidence: flight.claimEvidence }),
|
|
319
|
+
});
|
|
320
|
+
lateResult(flight, settled);
|
|
321
|
+
deliver(flight, settled);
|
|
322
|
+
maybeDetach();
|
|
323
|
+
}
|
|
324
|
+
function show(supplied) {
|
|
325
|
+
const request = assertAdShowInput(supplied);
|
|
326
|
+
if (disposed || input.presentation.getSnapshot().status === 'disposed') {
|
|
327
|
+
return Promise.reject(new PresentationExecutionError('disposed'));
|
|
328
|
+
}
|
|
329
|
+
const fingerprint = JSON.stringify([
|
|
330
|
+
request.placementId,
|
|
331
|
+
request.format,
|
|
332
|
+
request.idempotencyKey,
|
|
333
|
+
]);
|
|
334
|
+
const prior = flights.get(request.invocationId);
|
|
335
|
+
if (prior !== undefined) {
|
|
336
|
+
return prior.fingerprint === fingerprint
|
|
337
|
+
? prior.promise
|
|
338
|
+
: Promise.reject(new PresentationExecutionError('key-conflict'));
|
|
339
|
+
}
|
|
340
|
+
if (flights.size >= capacity) {
|
|
341
|
+
return Promise.reject(new PresentationExecutionError('history-full'));
|
|
342
|
+
}
|
|
343
|
+
let resolve;
|
|
344
|
+
const promise = new Promise((done) => {
|
|
345
|
+
resolve = done;
|
|
346
|
+
});
|
|
347
|
+
const flight = {
|
|
348
|
+
input: request,
|
|
349
|
+
fingerprint,
|
|
350
|
+
promise,
|
|
351
|
+
resolve,
|
|
352
|
+
called: false,
|
|
353
|
+
delivered: false,
|
|
354
|
+
cancelWait: () => { },
|
|
355
|
+
session: createAdSession({
|
|
356
|
+
providerId: id,
|
|
357
|
+
invocationId: request.invocationId,
|
|
358
|
+
format: request.format,
|
|
359
|
+
rewardSignal: rewardSignal,
|
|
360
|
+
}),
|
|
361
|
+
};
|
|
362
|
+
flights.set(request.invocationId, flight);
|
|
363
|
+
void (async () => {
|
|
364
|
+
try {
|
|
365
|
+
const available = await getAvailability(request);
|
|
366
|
+
if (available.state !== 'available') {
|
|
367
|
+
deliver(flight, unavailable(request, availabilityReason(available)));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Availability may await an SDK check; arbitrate again immediately before display.
|
|
371
|
+
if (disposed || input.presentation.getSnapshot().status === 'disposed') {
|
|
372
|
+
throw new PresentationExecutionError('disposed');
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
flight.lease = input.presentation.acquire({ kind: request.format, invocationId: request.invocationId });
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
if (error instanceof PresentationExecutionError && error.code === 'busy') {
|
|
379
|
+
deliver(flight, unavailable(request, 'busy'));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
if (disposed || input.presentation.getSnapshot().status === 'disposed') {
|
|
385
|
+
throw new PresentationExecutionError('disposed');
|
|
386
|
+
}
|
|
387
|
+
flight.called = true;
|
|
388
|
+
const native = Promise.resolve(provider.show(request));
|
|
389
|
+
void native.then((result) => {
|
|
390
|
+
try {
|
|
391
|
+
finish(flight, result);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
unknown(flight);
|
|
395
|
+
}
|
|
396
|
+
}, () => unknown(flight));
|
|
397
|
+
if (deadline !== undefined && !flight.delivered) {
|
|
398
|
+
const cancel = deadline.schedule(() => unknown(flight), deadline.milliseconds);
|
|
399
|
+
if (typeof cancel !== 'function') {
|
|
400
|
+
throw new TypeError('Invalid advertising deadline cleanup.');
|
|
401
|
+
}
|
|
402
|
+
flight.cancelWait = cancel;
|
|
403
|
+
if (flight.delivered) {
|
|
404
|
+
observe(cancel, input.onObserverError);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
if (flight.called) {
|
|
410
|
+
unknown(flight);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
flight.lease?.confirmClosed();
|
|
414
|
+
deliver(flight, assertAdShowResult({ ...unavailable(request, 'transient-failure'), outcome: 'failed' }));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
})();
|
|
418
|
+
return promise;
|
|
419
|
+
}
|
|
420
|
+
return Object.freeze({
|
|
421
|
+
id: id, protocol: protocol, protocolVersion: protocolVersion, rewardSignal: rewardSignal,
|
|
422
|
+
getAvailability,
|
|
423
|
+
async preload(supplied) {
|
|
424
|
+
const request = placement(supplied);
|
|
425
|
+
try {
|
|
426
|
+
const available = await getAvailability(request);
|
|
427
|
+
if (available.state !== 'available') {
|
|
428
|
+
return { status: 'unavailable', reason: availabilityReason(available) };
|
|
429
|
+
}
|
|
430
|
+
if (input.presentation.getSnapshot().owner !== undefined) {
|
|
431
|
+
return { status: 'unavailable', reason: 'busy' };
|
|
432
|
+
}
|
|
433
|
+
return assertAdPreparationResult(await provider.preload(request));
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return { status: 'failed', reason: 'transient-failure' };
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
show,
|
|
440
|
+
subscribe(listener) {
|
|
441
|
+
if (disposed) {
|
|
442
|
+
throw new PresentationExecutionError('disposed');
|
|
443
|
+
}
|
|
444
|
+
listeners.add(listener);
|
|
445
|
+
return () => { listeners.delete(listener); };
|
|
446
|
+
},
|
|
447
|
+
dispose() { disposed = true; listeners.clear(); maybeDetach(); },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
/** Games receive this gateway so direct gateway calls use the same native surface. */
|
|
451
|
+
export function createCoordinatedPlatformGateway(input) {
|
|
452
|
+
const ads = createCoordinatedAdProvider(input);
|
|
453
|
+
const source = input.gateway.commerce;
|
|
454
|
+
const history = new Map();
|
|
455
|
+
const purchases = new Map();
|
|
456
|
+
let last;
|
|
457
|
+
let disposed = false;
|
|
458
|
+
let detached = false;
|
|
459
|
+
function maybeDetach() {
|
|
460
|
+
if (!disposed || detached || [...purchases.values()].some((flight) => flight.called && !flight.closed)) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
detached = true;
|
|
464
|
+
if (unsubscribe !== undefined) {
|
|
465
|
+
observe(unsubscribe, input.onObserverError);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function close(flight) {
|
|
469
|
+
flight.closed = true;
|
|
470
|
+
flight.lease?.confirmClosed();
|
|
471
|
+
if (flight.settled) {
|
|
472
|
+
purchases.delete(flight.key);
|
|
473
|
+
}
|
|
474
|
+
maybeDetach();
|
|
475
|
+
}
|
|
476
|
+
const unsubscribe = input.purchasePresentation?.subscribe((event) => {
|
|
477
|
+
observe(() => {
|
|
478
|
+
const flight = purchases.get(event.idempotencyKey);
|
|
479
|
+
if (flight === undefined || !flight.called || flight.closed) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence <= flight.sequence) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (!['open', 'closed', 'not-started', 'unknown'].includes(event.state)) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
flight.sequence = event.sequence;
|
|
489
|
+
if (event.state === 'not-started' && flight.started) {
|
|
490
|
+
flight.lease?.markUnknown();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (event.state === 'closed' || event.state === 'not-started') {
|
|
494
|
+
close(flight);
|
|
495
|
+
}
|
|
496
|
+
else if (event.state === 'open') {
|
|
497
|
+
flight.started = true;
|
|
498
|
+
flight.lease?.markStarted();
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
flight.lease?.markUnknown();
|
|
502
|
+
}
|
|
503
|
+
}, input.onObserverError);
|
|
504
|
+
});
|
|
505
|
+
const commerce = forward(source, {
|
|
506
|
+
purchase(supplied) {
|
|
507
|
+
if (disposed) {
|
|
508
|
+
return Promise.reject(new PresentationExecutionError('disposed'));
|
|
509
|
+
}
|
|
510
|
+
const request = Object.freeze({ productId: supplied.productId, source: supplied.source, idempotencyKey: supplied.idempotencyKey });
|
|
511
|
+
if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.trim() === '' || request.idempotencyKey.length > 512
|
|
512
|
+
|| typeof request.productId !== 'string' || request.productId.trim() === '' || request.productId.length > 512
|
|
513
|
+
|| !['shop', 'stage_fail', 'result', 'event'].includes(request.source)) {
|
|
514
|
+
return Promise.reject(new TypeError('Invalid purchase presentation input.'));
|
|
515
|
+
}
|
|
516
|
+
const key = request.idempotencyKey;
|
|
517
|
+
const fingerprint = JSON.stringify([request.productId, request.source]);
|
|
518
|
+
const prior = history.get(key);
|
|
519
|
+
if (prior !== undefined) {
|
|
520
|
+
if (prior !== fingerprint) {
|
|
521
|
+
return Promise.reject(new PresentationExecutionError('key-conflict'));
|
|
522
|
+
}
|
|
523
|
+
const flight = purchases.get(key) ?? (last?.key === key ? last : undefined);
|
|
524
|
+
return flight?.promise ?? Promise.reject(new PresentationExecutionError('already-completed'));
|
|
525
|
+
}
|
|
526
|
+
if (history.size >= (input.maxRememberedInvocations ?? 1024)) {
|
|
527
|
+
return Promise.reject(new PresentationExecutionError('history-full'));
|
|
528
|
+
}
|
|
529
|
+
const flight = {
|
|
530
|
+
key, called: false, settled: false, closed: false, started: false, sequence: 0,
|
|
531
|
+
promise: Promise.resolve().then(async () => {
|
|
532
|
+
try {
|
|
533
|
+
if (disposed) {
|
|
534
|
+
throw new PresentationExecutionError('disposed');
|
|
535
|
+
}
|
|
536
|
+
flight.lease = input.presentation.acquire({ kind: 'purchase', invocationId: key });
|
|
537
|
+
if (disposed) {
|
|
538
|
+
close(flight);
|
|
539
|
+
throw new PresentationExecutionError('disposed');
|
|
540
|
+
}
|
|
541
|
+
flight.called = true;
|
|
542
|
+
const result = await source.purchase(request);
|
|
543
|
+
const state = input.classifyPurchasePresentation?.(result) ?? 'unknown';
|
|
544
|
+
if (state !== 'closed' && state !== 'not-started' && state !== 'unknown') {
|
|
545
|
+
throw new TypeError('Invalid purchase presentation observation.');
|
|
546
|
+
}
|
|
547
|
+
if (state === 'not-started' && flight.started && !flight.closed) {
|
|
548
|
+
throw new TypeError('Purchase observation contradicts native presentation.');
|
|
549
|
+
}
|
|
550
|
+
if (state === 'unknown') {
|
|
551
|
+
if (!flight.closed) {
|
|
552
|
+
flight.lease.markUnknown();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
close(flight);
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if (flight.called && !flight.closed) {
|
|
562
|
+
flight.lease?.markUnknown();
|
|
563
|
+
}
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
flight.settled = true;
|
|
568
|
+
if (!flight.called) {
|
|
569
|
+
history.delete(key);
|
|
570
|
+
purchases.delete(key);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
last = flight;
|
|
574
|
+
if (flight.closed) {
|
|
575
|
+
purchases.delete(key);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
maybeDetach();
|
|
579
|
+
}
|
|
580
|
+
}),
|
|
581
|
+
};
|
|
582
|
+
history.set(key, fingerprint);
|
|
583
|
+
purchases.set(key, flight);
|
|
584
|
+
void flight.promise.catch(() => { });
|
|
585
|
+
return flight.promise;
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
return forward(input.gateway, {
|
|
589
|
+
ads: forward(input.gateway.ads, toAdAdapter(ads)),
|
|
590
|
+
commerce,
|
|
591
|
+
dispose() {
|
|
592
|
+
disposed = true;
|
|
593
|
+
ads.dispose();
|
|
594
|
+
maybeDetach();
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
/** Preserve prototype methods and their receivers, including optional port methods. */
|
|
599
|
+
function forward(source, overrides) {
|
|
600
|
+
const methods = new Map();
|
|
601
|
+
return new Proxy({}, {
|
|
602
|
+
get(_target, key) {
|
|
603
|
+
if (Object.hasOwn(overrides, key)) {
|
|
604
|
+
return Reflect.get(overrides, key);
|
|
605
|
+
}
|
|
606
|
+
const value = Reflect.get(source, key, source);
|
|
607
|
+
if (typeof value !== 'function') {
|
|
608
|
+
return value;
|
|
609
|
+
}
|
|
610
|
+
const prior = methods.get(key);
|
|
611
|
+
if (prior?.original === value) {
|
|
612
|
+
return prior.bound;
|
|
613
|
+
}
|
|
614
|
+
const bound = value.bind(source);
|
|
615
|
+
methods.set(key, { original: value, bound });
|
|
616
|
+
return bound;
|
|
617
|
+
},
|
|
618
|
+
has: (_target, key) => Object.hasOwn(overrides, key) || key in source,
|
|
619
|
+
ownKeys: () => [...new Set([...Reflect.ownKeys(source), ...Reflect.ownKeys(overrides)])],
|
|
620
|
+
getOwnPropertyDescriptor: (_target, key) => key in source || Object.hasOwn(overrides, key)
|
|
621
|
+
? { configurable: true, enumerable: true, writable: false, value: undefined }
|
|
622
|
+
: undefined,
|
|
623
|
+
getPrototypeOf: () => Reflect.getPrototypeOf(source),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GameExecutionController } from '../index.js';
|
|
2
|
+
import { type ObserverErrorHandler } from '../observers.js';
|
|
3
|
+
export interface GameAudioSink {
|
|
4
|
+
getMuted(): boolean;
|
|
5
|
+
setMuted(muted: boolean): void;
|
|
6
|
+
}
|
|
7
|
+
/** One game owns audio projection; scene teardown cannot release a live native lease. */
|
|
8
|
+
export declare function bindGameAudio(input: {
|
|
9
|
+
readonly execution: GameExecutionController;
|
|
10
|
+
readonly sink: GameAudioSink;
|
|
11
|
+
readonly onError?: ObserverErrorHandler;
|
|
12
|
+
}): {
|
|
13
|
+
dispose(): void;
|
|
14
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { observe } from '../observers.js';
|
|
2
|
+
/** One game owns audio projection; scene teardown cannot release a live native lease. */
|
|
3
|
+
export function bindGameAudio(input) {
|
|
4
|
+
let owned = false;
|
|
5
|
+
let disposed = false;
|
|
6
|
+
const apply = () => {
|
|
7
|
+
if (disposed) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const snapshot = input.execution.getSnapshot();
|
|
11
|
+
observe(() => {
|
|
12
|
+
if (snapshot.blocked.audio && !input.sink.getMuted()) {
|
|
13
|
+
owned = true;
|
|
14
|
+
input.sink.setMuted(true);
|
|
15
|
+
}
|
|
16
|
+
else if (!snapshot.blocked.audio && owned) {
|
|
17
|
+
input.sink.setMuted(false);
|
|
18
|
+
owned = false;
|
|
19
|
+
}
|
|
20
|
+
}, input.onError);
|
|
21
|
+
};
|
|
22
|
+
const unsubscribe = input.execution.subscribe(apply);
|
|
23
|
+
apply();
|
|
24
|
+
return { dispose() {
|
|
25
|
+
if (disposed) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
disposed = true;
|
|
29
|
+
unsubscribe();
|
|
30
|
+
// Detaching an observer does not close UI or remove another pause owner.
|
|
31
|
+
if (owned && !input.execution.getSnapshot().blocked.audio) {
|
|
32
|
+
observe(() => { input.sink.setMuted(false); owned = false; }, input.onError);
|
|
33
|
+
}
|
|
34
|
+
} };
|
|
35
|
+
}
|