@mpgd/platform 0.14.0 → 0.15.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.
@@ -0,0 +1,44 @@
1
+ import { adProtocol, adProtocolVersion } from './ads.js';
2
+ export type AdConformanceValue = string | number | boolean | null | readonly AdConformanceValue[] | {
3
+ readonly [key: string]: AdConformanceValue;
4
+ };
5
+ export type AdConformanceActor = 'game' | 'sdk' | 'backend' | 'clock' | 'runtime';
6
+ export interface AdConformanceStep {
7
+ readonly actor: AdConformanceActor;
8
+ readonly action: string;
9
+ readonly [key: string]: AdConformanceValue;
10
+ }
11
+ export interface AdConformanceVector {
12
+ readonly vector: string;
13
+ readonly protocol: typeof adProtocol;
14
+ readonly version: typeof adProtocolVersion;
15
+ readonly profile: Readonly<Record<string, AdConformanceValue>>;
16
+ readonly steps: readonly AdConformanceStep[];
17
+ readonly expect: Readonly<Record<string, AdConformanceValue>>;
18
+ }
19
+ export interface AdConformanceDriver {
20
+ /** Complete immediate scheduled work, without waiting for an open native surface. */
21
+ step(input: AdConformanceStep): Promise<void>;
22
+ read(): Promise<Readonly<Record<string, AdConformanceValue>>>;
23
+ dispose(): Promise<void>;
24
+ }
25
+ export interface AdConformanceFixture {
26
+ readonly name: string;
27
+ /** A fresh driver for every vector. Expectations are never supplied to the driver. */
28
+ create(input: {
29
+ readonly vector: string;
30
+ readonly profile: Readonly<Record<string, AdConformanceValue>>;
31
+ }): Promise<AdConformanceDriver>;
32
+ }
33
+ /** Deterministic orchestration only: adapters translate each step into their real SDK input. */
34
+ export declare function runAdConformance(input: {
35
+ readonly vectors: readonly unknown[];
36
+ readonly fixtures: readonly AdConformanceFixture[];
37
+ }): Promise<{
38
+ readonly passed: readonly {
39
+ readonly fixture: string;
40
+ readonly vector: string;
41
+ }[];
42
+ }>;
43
+ /** Clone and freeze the script so fixtures cannot mutate later assertions. */
44
+ export declare function assertAdConformanceVector(value: unknown): AdConformanceVector;
@@ -0,0 +1,216 @@
1
+ import { adProtocol, adProtocolVersion } from './ads.js';
2
+ const actions = {
3
+ game: ['prepare', 'start', 'disposeView'],
4
+ sdk: ['started', 'rewardEarned', 'closed', 'failed'],
5
+ backend: ['grant', 'reject'],
6
+ clock: ['advance'],
7
+ runtime: ['block', 'release', 'recover', 'restart'],
8
+ };
9
+ const observationKeys = new Set([
10
+ 'sdkShows',
11
+ 'claimRequests',
12
+ 'ledgerGrants',
13
+ 'purchaseUiOpened',
14
+ 'occupied',
15
+ 'outcomes',
16
+ 'claims',
17
+ 'reason',
18
+ 'presentation',
19
+ 'eligibility',
20
+ 'claimState',
21
+ 'preparation',
22
+ 'lateCloseObserved',
23
+ 'updatesAfterViewDisposal',
24
+ 'activeInvocationId',
25
+ 'executionOwners',
26
+ 'reasons',
27
+ 'callerResults',
28
+ 'normalization',
29
+ 'ledgerEntryId',
30
+ ]);
31
+ /** Deterministic orchestration only: adapters translate each step into their real SDK input. */
32
+ export async function runAdConformance(input) {
33
+ if (input.vectors.length === 0 || input.fixtures.length === 0) {
34
+ throw new TypeError('Ad conformance requires vectors and fixtures.');
35
+ }
36
+ const vectors = input.vectors.map(assertAdConformanceVector);
37
+ assertUnique(vectors.map((vector) => vector.vector), 'vector');
38
+ assertUnique(input.fixtures.map((fixture) => fixture.name), 'fixture');
39
+ const passed = [];
40
+ for (const fixture of input.fixtures) {
41
+ for (const vector of vectors) {
42
+ let driver;
43
+ let failure;
44
+ let failed = false;
45
+ try {
46
+ driver = await fixture.create({ vector: vector.vector, profile: vector.profile });
47
+ for (const step of vector.steps) {
48
+ const { expect: checkpoint, ...action } = step;
49
+ await driver.step(action);
50
+ if (checkpoint !== undefined) {
51
+ assertMatches(cloneValue(await driver.read(), 0), checkpoint, 'checkpoint');
52
+ }
53
+ }
54
+ const observed = cloneValue(await driver.read(), 0);
55
+ assertMatches(observed, vector.expect, 'expect');
56
+ }
57
+ catch (error) {
58
+ failed = true;
59
+ failure = error;
60
+ }
61
+ finally {
62
+ try {
63
+ await driver?.dispose();
64
+ }
65
+ catch (error) {
66
+ if (!failed) {
67
+ failed = true;
68
+ failure = error;
69
+ }
70
+ }
71
+ }
72
+ if (failed) {
73
+ throw new Error(`Ad conformance failed: ${fixture.name}/${vector.vector}.`, {
74
+ cause: failure,
75
+ });
76
+ }
77
+ passed.push(Object.freeze({ fixture: fixture.name, vector: vector.vector }));
78
+ }
79
+ }
80
+ return Object.freeze({ passed: Object.freeze(passed) });
81
+ }
82
+ /** Clone and freeze the script so fixtures cannot mutate later assertions. */
83
+ export function assertAdConformanceVector(value) {
84
+ const record = asRecord(cloneValue(value, 0));
85
+ if (typeof record.vector !== 'string' || !/^[a-z][a-z0-9-]{0,127}$/.test(record.vector)
86
+ || record.protocol !== adProtocol || record.version !== adProtocolVersion) {
87
+ throw new TypeError('Invalid advertising vector identity or protocol.');
88
+ }
89
+ const profile = asRecord(record.profile);
90
+ if (!Array.isArray(profile.formats) || profile.formats.some((format) => format !== 'rewarded' && format !== 'interstitial')
91
+ || typeof profile.configured !== 'boolean'
92
+ || (profile.rewardSignal !== 'immediate' && profile.rewardSignal !== 'delayed')) {
93
+ throw new TypeError('Invalid advertising vector profile.');
94
+ }
95
+ if (!Array.isArray(record.steps) || record.steps.length === 0 || record.steps.length > 500) {
96
+ throw new TypeError('Invalid advertising vector steps.');
97
+ }
98
+ if ((profile.policyEnabled !== undefined && typeof profile.policyEnabled !== 'boolean')
99
+ || (profile.actionRequired !== undefined && typeof profile.actionRequired !== 'boolean')
100
+ || (profile.preparation !== undefined && !['ready', 'deferred', 'unavailable', 'failed'].includes(profile.preparation))) {
101
+ throw new TypeError('Invalid advertising vector policy or preparation.');
102
+ }
103
+ for (const supplied of record.steps) {
104
+ const step = asRecord(supplied);
105
+ if (typeof step.actor !== 'string' || !Object.hasOwn(actions, step.actor)
106
+ || typeof step.action !== 'string' || !actions[step.actor].includes(step.action)) {
107
+ throw new TypeError('Invalid advertising vector actor/action.');
108
+ }
109
+ if (['start', 'disposeView', 'started', 'rewardEarned', 'closed', 'failed', 'grant', 'reject', 'recover'].includes(step.action)
110
+ && (typeof step.invocationId !== 'string' || step.invocationId.trim() === '' || step.invocationId.length > 512)) {
111
+ throw new TypeError('Invalid advertising step invocation identity.');
112
+ }
113
+ if (step.action === 'start' || step.action === 'prepare') {
114
+ if ((step.format !== 'rewarded' && step.format !== 'interstitial'
115
+ && !(step.action === 'start' && step.format === 'purchase'))
116
+ || typeof step.placementId !== 'string' || step.placementId.trim() === '') {
117
+ throw new TypeError('Invalid advertising step placement.');
118
+ }
119
+ }
120
+ if (step.actor === 'backend' && step.action === 'grant') {
121
+ for (const field of ['verifiedImpressionId', 'ledgerEntryId']) {
122
+ const id = step[field];
123
+ if (typeof id !== 'string' || id.trim() === '' || id.length > 512) {
124
+ throw new TypeError('Invalid advertising backend grant identity.');
125
+ }
126
+ }
127
+ }
128
+ if (step.actor === 'clock' && (!Number.isSafeInteger(step.milliseconds) || step.milliseconds < 0)) {
129
+ throw new TypeError('Invalid advertising clock advance.');
130
+ }
131
+ if ((step.action === 'block' || step.action === 'release')
132
+ && (typeof step.owner !== 'string' || step.owner.trim() === '')) {
133
+ throw new TypeError('Invalid advertising execution owner.');
134
+ }
135
+ if (step.expect !== undefined) {
136
+ assertExpectation(step.expect);
137
+ }
138
+ }
139
+ assertExpectation(record.expect);
140
+ return record;
141
+ }
142
+ function assertExpectation(value) {
143
+ const expected = asRecord(value);
144
+ if (Object.keys(expected).length === 0 || Object.keys(expected).some((key) => !observationKeys.has(key))) {
145
+ throw new TypeError('Invalid advertising vector expectations.');
146
+ }
147
+ }
148
+ function assertUnique(names, kind) {
149
+ const unique = new Set();
150
+ for (const name of names) {
151
+ if (typeof name !== 'string' || name.trim() === '' || unique.has(name)) {
152
+ throw new TypeError(`Invalid or duplicate ad conformance ${kind} name.`);
153
+ }
154
+ unique.add(name);
155
+ }
156
+ }
157
+ function asRecord(value) {
158
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
159
+ throw new TypeError('Expected an advertising conformance object.');
160
+ }
161
+ return value;
162
+ }
163
+ function cloneValue(value, depth) {
164
+ if (depth > 12) {
165
+ throw new TypeError('Advertising conformance value is too deeply nested.');
166
+ }
167
+ if (value === null || typeof value === 'boolean') {
168
+ return value;
169
+ }
170
+ if (typeof value === 'string' && value.length <= 8192) {
171
+ return value;
172
+ }
173
+ if (typeof value === 'number' && Number.isFinite(value)) {
174
+ return value;
175
+ }
176
+ if (Array.isArray(value) && value.length <= 500) {
177
+ return Object.freeze(value.map((entry) => cloneValue(entry, depth + 1)));
178
+ }
179
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
180
+ const entries = Object.entries(value);
181
+ if (entries.length <= 128 && entries.every(([key]) => key.length <= 128)) {
182
+ return Object.freeze(Object.fromEntries(entries.map(([key, entry]) => [key, cloneValue(entry, depth + 1)])));
183
+ }
184
+ }
185
+ throw new TypeError('Invalid advertising conformance value.');
186
+ }
187
+ function assertMatches(actual, expected, path) {
188
+ if (Array.isArray(expected)) {
189
+ if (!Array.isArray(actual) || actual.length !== expected.length) {
190
+ throw new Error(`Advertising observation differs at ${path}.`);
191
+ }
192
+ for (let i = 0; i < expected.length; i += 1) {
193
+ const wanted = expected[i];
194
+ const observed = actual[i];
195
+ if (wanted === undefined || observed === undefined) {
196
+ throw new Error(`Advertising observation missing at ${path}[${i}].`);
197
+ }
198
+ assertMatches(observed, wanted, `${path}[${i}]`);
199
+ }
200
+ return;
201
+ }
202
+ if (expected !== null && typeof expected === 'object') {
203
+ const record = asRecord(actual);
204
+ for (const [key, wanted] of Object.entries(expected)) {
205
+ const observed = record[key];
206
+ if (!Object.hasOwn(record, key) || observed === undefined) {
207
+ throw new Error(`Advertising observation missing at ${path}.${key}.`);
208
+ }
209
+ assertMatches(observed, wanted, `${path}.${key}`);
210
+ }
211
+ return;
212
+ }
213
+ if (actual !== expected) {
214
+ throw new Error(`Advertising observation differs at ${path}.`);
215
+ }
216
+ }
package/dist/ads.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { AdAdapter, PlatformEvidenceEnvelope } from './index.js';
2
+ export declare const adProtocolVersion: '2.0.0-draft.1';
3
+ export declare const adProtocol: 'mpgd.ads.v2';
4
+ export type AdFormat = 'rewarded' | 'interstitial';
5
+ export type AdProviderId = string;
6
+ export type AdReason = 'unsupported' | 'configuration-required' | 'policy-disabled' | 'action-required' | 'no-fill' | 'busy' | 'transient-failure' | 'outcome-unknown';
7
+ export type AdPresentationState = 'not-started' | 'open' | 'closed' | 'unknown';
8
+ export type AdRewardEligibility = 'eligible' | 'not-earned' | 'unknown' | 'not-applicable';
9
+ export interface AdPlacementInput {
10
+ readonly placementId: string;
11
+ readonly format: AdFormat;
12
+ }
13
+ export interface AdShowInput extends AdPlacementInput {
14
+ readonly invocationId: string;
15
+ readonly idempotencyKey: string;
16
+ }
17
+ export interface AdAvailability {
18
+ readonly state: 'available' | 'unsupported' | 'configuration-required' | 'action-required' | 'temporarily-unavailable';
19
+ readonly reason?: AdReason;
20
+ }
21
+ export interface AdPreparationResult {
22
+ readonly status: 'ready' | 'deferred' | 'unavailable' | 'failed';
23
+ readonly reason?: AdReason;
24
+ }
25
+ export interface AdShowResult {
26
+ readonly providerId: AdProviderId;
27
+ readonly invocationId: string;
28
+ readonly format: AdFormat;
29
+ readonly outcome: 'shown' | 'skipped' | 'unavailable' | 'failed' | 'pending';
30
+ readonly presentation: Exclude<AdPresentationState, 'open'>;
31
+ readonly eligibility: AdRewardEligibility;
32
+ readonly reason?: AdReason;
33
+ /** A callback is a claim candidate only; no ledger grant belongs here. */
34
+ readonly evidence?: PlatformEvidenceEnvelope;
35
+ /** Server proof-lookup correlation; it does not establish local reward eligibility. */
36
+ readonly claimEvidence?: PlatformEvidenceEnvelope;
37
+ }
38
+ export type AdPresentationEvent = Readonly<{
39
+ providerId: AdProviderId;
40
+ invocationId: string;
41
+ sequence: number;
42
+ } & ({
43
+ type: 'requested';
44
+ claimEvidence?: PlatformEvidenceEnvelope;
45
+ } | {
46
+ type: 'started' | 'closed' | 'unknown';
47
+ } | {
48
+ type: 'reward-earned';
49
+ evidence: PlatformEvidenceEnvelope;
50
+ } | {
51
+ type: 'failed';
52
+ reason: AdReason;
53
+ })>;
54
+ /** Provider-facing surface. Runtime arbitration and backend grants are separate owners. */
55
+ export interface AdProvider {
56
+ readonly id: AdProviderId;
57
+ readonly protocol: typeof adProtocol;
58
+ readonly protocolVersion: typeof adProtocolVersion;
59
+ readonly rewardSignal: 'immediate' | 'delayed';
60
+ getAvailability(input: AdPlacementInput): Promise<AdAvailability>;
61
+ preload(input: AdPlacementInput): Promise<AdPreparationResult>;
62
+ show(input: AdShowInput): Promise<AdShowResult>;
63
+ /** Install before show; unknown presentation must retain a later close observer. */
64
+ subscribe(listener: (event: AdPresentationEvent) => void): () => void;
65
+ }
66
+ export interface AdSessionSnapshot {
67
+ readonly providerId: AdProviderId;
68
+ readonly invocationId: string;
69
+ readonly format: AdFormat;
70
+ readonly rewardSignal: 'immediate' | 'delayed';
71
+ readonly sequence: number;
72
+ readonly presentation: AdPresentationState;
73
+ readonly eligibility: AdRewardEligibility;
74
+ readonly started: boolean;
75
+ readonly terminal: boolean;
76
+ readonly reason?: AdReason;
77
+ readonly evidence?: PlatformEvidenceEnvelope;
78
+ readonly claimEvidence?: PlatformEvidenceEnvelope;
79
+ }
80
+ export declare function createAdSession(input: {
81
+ readonly providerId: AdProviderId;
82
+ readonly invocationId: string;
83
+ readonly format: AdFormat;
84
+ readonly rewardSignal?: 'immediate' | 'delayed';
85
+ }): AdSessionSnapshot;
86
+ /** Pure transition: stale identities, duplicate events, and late reopen signals are ignored. */
87
+ export declare function reduceAdSession(snapshot: AdSessionSnapshot, supplied: AdPresentationEvent): AdSessionSnapshot;
88
+ export declare function assertAdShowInput(value: unknown): AdShowInput;
89
+ export declare function assertAdAvailability(value: unknown): AdAvailability;
90
+ export declare function assertAdPreparationResult(value: unknown): AdPreparationResult;
91
+ export declare function assertAdPresentationEvent(value: unknown): AdPresentationEvent;
92
+ export declare function assertAdShowResult(value: unknown): AdShowResult;
93
+ export declare function toAdAdapter(provider: AdProvider): AdAdapter;
package/dist/ads.js ADDED
@@ -0,0 +1,306 @@
1
+ export const adProtocolVersion = '2.0.0-draft.1';
2
+ export const adProtocol = 'mpgd.ads.v2';
3
+ export function createAdSession(input) {
4
+ assertId(input.providerId, 'providerId');
5
+ assertId(input.invocationId, 'invocationId');
6
+ assertFormat(input.format);
7
+ if (input.rewardSignal !== undefined && input.rewardSignal !== 'immediate' && input.rewardSignal !== 'delayed') {
8
+ throw new TypeError('Invalid ad reward signal.');
9
+ }
10
+ return Object.freeze({
11
+ providerId: input.providerId,
12
+ invocationId: input.invocationId,
13
+ format: input.format,
14
+ rewardSignal: input.rewardSignal ?? 'immediate',
15
+ sequence: 0,
16
+ presentation: 'not-started',
17
+ eligibility: input.format === 'interstitial' ? 'not-applicable' : 'unknown',
18
+ started: false,
19
+ terminal: false,
20
+ });
21
+ }
22
+ /** Pure transition: stale identities, duplicate events, and late reopen signals are ignored. */
23
+ export function reduceAdSession(snapshot, supplied) {
24
+ const header = assertRecord(supplied);
25
+ assertId(header.providerId, 'providerId');
26
+ assertId(header.invocationId, 'invocationId');
27
+ if (header.providerId !== snapshot.providerId || header.invocationId !== snapshot.invocationId) {
28
+ return snapshot;
29
+ }
30
+ if (!Number.isSafeInteger(header.sequence) || header.sequence < 1) {
31
+ throw new TypeError('Invalid ad event sequence.');
32
+ }
33
+ if (header.sequence <= snapshot.sequence) {
34
+ return snapshot;
35
+ }
36
+ const event = assertAdPresentationEvent(supplied);
37
+ const next = { ...snapshot, sequence: event.sequence };
38
+ // A closed invocation may receive delayed reward evidence, but cannot reopen.
39
+ if (snapshot.terminal && (event.type !== 'reward-earned'
40
+ || snapshot.presentation !== 'closed' || snapshot.rewardSignal !== 'delayed')) {
41
+ return Object.freeze(next);
42
+ }
43
+ switch (event.type) {
44
+ case 'requested':
45
+ return Object.freeze({ ...next, ...(snapshot.format !== 'rewarded' || event.claimEvidence === undefined
46
+ ? {} : { claimEvidence: snapshot.claimEvidence ?? event.claimEvidence }) });
47
+ case 'started':
48
+ return Object.freeze({ ...next, presentation: 'open', started: true });
49
+ case 'reward-earned':
50
+ if (snapshot.format !== 'rewarded' || snapshot.eligibility === 'eligible') {
51
+ return Object.freeze(next);
52
+ }
53
+ return Object.freeze({ ...next, eligibility: 'eligible', evidence: event.evidence });
54
+ case 'unknown':
55
+ return Object.freeze({ ...next, presentation: 'unknown', reason: 'outcome-unknown' });
56
+ case 'closed': {
57
+ const { reason: previousReason, claimEvidence, ...closed } = next;
58
+ let eligibility = snapshot.eligibility;
59
+ if (snapshot.format === 'interstitial') {
60
+ eligibility = 'not-applicable';
61
+ }
62
+ else if (eligibility !== 'eligible') {
63
+ eligibility = snapshot.rewardSignal === 'delayed' ? 'unknown' : 'not-earned';
64
+ }
65
+ return Object.freeze({
66
+ ...closed,
67
+ presentation: 'closed',
68
+ terminal: true,
69
+ ...(previousReason === undefined || previousReason === 'outcome-unknown' ? {} : { reason: previousReason }),
70
+ eligibility,
71
+ ...(claimEvidence === undefined || eligibility === 'not-earned' || eligibility === 'not-applicable'
72
+ ? {} : { claimEvidence }),
73
+ });
74
+ }
75
+ case 'failed': {
76
+ const { claimEvidence, ...failed } = next;
77
+ return Object.freeze({
78
+ ...failed,
79
+ presentation: snapshot.started ? 'closed' : 'not-started',
80
+ terminal: true,
81
+ reason: event.reason,
82
+ ...(snapshot.started && claimEvidence !== undefined ? { claimEvidence } : {}),
83
+ });
84
+ }
85
+ }
86
+ }
87
+ export function assertAdShowInput(value) {
88
+ const record = assertRecord(value);
89
+ assertId(record.placementId, 'placementId');
90
+ assertId(record.invocationId, 'invocationId');
91
+ assertId(record.idempotencyKey, 'idempotencyKey');
92
+ assertFormat(record.format);
93
+ return Object.freeze({
94
+ placementId: record.placementId,
95
+ invocationId: record.invocationId,
96
+ idempotencyKey: record.idempotencyKey,
97
+ format: record.format,
98
+ });
99
+ }
100
+ export function assertAdAvailability(value) {
101
+ const record = assertRecord(value);
102
+ if (!['available', 'unsupported', 'configuration-required', 'action-required', 'temporarily-unavailable'].includes(record.state)) {
103
+ throw new TypeError('Invalid ad availability.');
104
+ }
105
+ if (record.reason !== undefined) {
106
+ assertReason(record.reason);
107
+ if (record.state === 'available') {
108
+ throw new TypeError('Available advertising cannot carry an unavailable reason.');
109
+ }
110
+ }
111
+ return Object.freeze({
112
+ state: record.state,
113
+ ...(record.reason === undefined ? {} : { reason: record.reason }),
114
+ });
115
+ }
116
+ export function assertAdPreparationResult(value) {
117
+ const record = assertRecord(value);
118
+ if (!['ready', 'deferred', 'unavailable', 'failed'].includes(record.status)) {
119
+ throw new TypeError('Invalid ad preparation result.');
120
+ }
121
+ if (record.reason !== undefined) {
122
+ assertReason(record.reason);
123
+ if (record.status === 'ready' || record.status === 'deferred') {
124
+ throw new TypeError('Successful advertising preparation cannot carry a failure reason.');
125
+ }
126
+ }
127
+ return Object.freeze({
128
+ status: record.status,
129
+ ...(record.reason === undefined ? {} : { reason: record.reason }),
130
+ });
131
+ }
132
+ export function assertAdPresentationEvent(value) {
133
+ const record = assertRecord(value);
134
+ assertId(record.providerId, 'providerId');
135
+ assertId(record.invocationId, 'invocationId');
136
+ if (!Number.isSafeInteger(record.sequence) || record.sequence < 1) {
137
+ throw new TypeError('Invalid ad event sequence.');
138
+ }
139
+ const base = {
140
+ providerId: record.providerId,
141
+ invocationId: record.invocationId,
142
+ sequence: record.sequence,
143
+ };
144
+ switch (record.type) {
145
+ case 'requested':
146
+ return Object.freeze({ ...base, type: 'requested', ...(record.claimEvidence === undefined
147
+ ? {} : { claimEvidence: assertEvidence(record.claimEvidence) }) });
148
+ case 'started':
149
+ case 'closed':
150
+ case 'unknown':
151
+ return Object.freeze({ ...base, type: record.type });
152
+ case 'failed':
153
+ assertReason(record.reason);
154
+ return Object.freeze({ ...base, type: 'failed', reason: record.reason });
155
+ case 'reward-earned':
156
+ return Object.freeze({
157
+ ...base,
158
+ type: 'reward-earned',
159
+ evidence: assertEvidence(record.evidence),
160
+ });
161
+ default:
162
+ throw new TypeError('Invalid ad event type.');
163
+ }
164
+ }
165
+ export function assertAdShowResult(value) {
166
+ const record = assertRecord(value);
167
+ assertId(record.providerId, 'providerId');
168
+ assertId(record.invocationId, 'invocationId');
169
+ assertFormat(record.format);
170
+ if (!['shown', 'skipped', 'unavailable', 'failed', 'pending'].includes(record.outcome)
171
+ || !['not-started', 'closed', 'unknown'].includes(record.presentation)
172
+ || !['eligible', 'not-earned', 'unknown', 'not-applicable'].includes(record.eligibility)) {
173
+ throw new TypeError('Invalid ad result state.');
174
+ }
175
+ if (record.reason !== undefined) {
176
+ assertReason(record.reason);
177
+ }
178
+ if ((record.format === 'interstitial') !== (record.eligibility === 'not-applicable')
179
+ || ((record.outcome === 'shown' || record.outcome === 'skipped') && record.presentation !== 'closed')
180
+ || (record.presentation === 'unknown' && record.outcome !== 'pending')
181
+ || (record.outcome === 'pending' && record.presentation !== 'unknown')
182
+ || (record.outcome === 'unavailable' && record.presentation !== 'not-started')
183
+ || (record.eligibility === 'eligible' && record.presentation === 'not-started')
184
+ || (record.outcome === 'skipped' && record.eligibility === 'eligible')
185
+ || (record.presentation === 'unknown' && record.reason !== 'outcome-unknown')
186
+ || (record.eligibility === 'eligible' && record.evidence === undefined)
187
+ || (record.evidence !== undefined && record.eligibility !== 'eligible')) {
188
+ throw new TypeError('Inconsistent ad result state.');
189
+ }
190
+ if (record.claimEvidence !== undefined && (record.format !== 'rewarded'
191
+ || record.presentation === 'not-started'
192
+ || record.eligibility === 'not-earned' || record.eligibility === 'not-applicable')) {
193
+ throw new TypeError('Inconsistent ad claim candidate state.');
194
+ }
195
+ return Object.freeze({
196
+ providerId: record.providerId,
197
+ invocationId: record.invocationId,
198
+ format: record.format,
199
+ outcome: record.outcome,
200
+ presentation: record.presentation,
201
+ eligibility: record.eligibility,
202
+ ...(record.reason === undefined ? {} : { reason: record.reason }),
203
+ ...(record.evidence === undefined ? {} : { evidence: assertEvidence(record.evidence) }),
204
+ ...(record.claimEvidence === undefined ? {} : { claimEvidence: assertEvidence(record.claimEvidence) }),
205
+ });
206
+ }
207
+ /** Additive v1 facade. It never reports an SDK eligibility event as a grant. */
208
+ let legacyInterstitialSequence = 0;
209
+ export function toAdAdapter(provider) {
210
+ // These ids identify impressions, not authorization. A per-facade nonce survives
211
+ // resettable module counters without requiring DOM/SDK/crypto globals.
212
+ const interstitialSessionId = [
213
+ Date.now().toString(36), Math.random().toString(36).slice(2), Math.random().toString(36).slice(2),
214
+ ].join('-');
215
+ assertId(provider.id, 'providerId');
216
+ if (provider.protocol !== adProtocol || provider.protocolVersion !== adProtocolVersion) {
217
+ throw new TypeError('Unsupported advertising provider protocol.');
218
+ }
219
+ if (provider.rewardSignal !== 'immediate' && provider.rewardSignal !== 'delayed') {
220
+ throw new TypeError('Invalid advertising provider reward signal.');
221
+ }
222
+ return {
223
+ provider,
224
+ async preload(input) {
225
+ if (input.format === 'banner') {
226
+ return;
227
+ }
228
+ assertAdPreparationResult(await provider.preload({ placementId: input.placementId, format: input.format ?? 'rewarded' }));
229
+ },
230
+ async showRewarded(input) {
231
+ const result = assertAdShowResult(await provider.show({
232
+ ...input, format: 'rewarded', invocationId: input.idempotencyKey,
233
+ }));
234
+ if (result.format !== 'rewarded' || result.providerId !== provider.id || result.invocationId !== input.idempotencyKey) {
235
+ throw new TypeError('Ad result does not match its invocation.');
236
+ }
237
+ let status;
238
+ if (result.eligibility === 'eligible') {
239
+ status = result.presentation === 'unknown' ? 'pending' : 'completed';
240
+ }
241
+ else if (result.outcome === 'shown') {
242
+ status = result.eligibility === 'unknown' ? 'pending' : 'skipped';
243
+ }
244
+ else {
245
+ status = result.outcome;
246
+ }
247
+ const evidence = result.claimEvidence ?? result.evidence;
248
+ return { status, rewardGranted: false, ...(evidence === undefined ? {} : { evidence }) };
249
+ },
250
+ async showInterstitial(input) {
251
+ const invocationId = `legacy-interstitial:${interstitialSessionId}:${++legacyInterstitialSequence}`;
252
+ const result = assertAdShowResult(await provider.show({
253
+ ...input, format: 'interstitial', invocationId, idempotencyKey: invocationId,
254
+ }));
255
+ if (result.format !== 'interstitial' || result.providerId !== provider.id || result.invocationId !== invocationId) {
256
+ throw new TypeError('Ad result does not match its invocation.');
257
+ }
258
+ let status = 'skipped';
259
+ if (result.outcome === 'shown') {
260
+ status = 'shown';
261
+ }
262
+ else if (result.outcome === 'unavailable') {
263
+ status = 'unavailable';
264
+ }
265
+ return { status };
266
+ },
267
+ };
268
+ }
269
+ function assertRecord(value) {
270
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
271
+ throw new TypeError('Expected an ad contract object.');
272
+ }
273
+ return value;
274
+ }
275
+ function assertId(value, name) {
276
+ if (typeof value !== 'string' || value.trim() === '' || value.length > 512) {
277
+ throw new TypeError(`Invalid ad ${name}.`);
278
+ }
279
+ }
280
+ function assertFormat(value) {
281
+ if (value !== 'rewarded' && value !== 'interstitial') {
282
+ throw new TypeError('Invalid ad format.');
283
+ }
284
+ }
285
+ function assertReason(value) {
286
+ if (!['unsupported', 'configuration-required', 'policy-disabled', 'action-required',
287
+ 'no-fill', 'busy', 'transient-failure', 'outcome-unknown'].includes(value)) {
288
+ throw new TypeError('Invalid ad reason.');
289
+ }
290
+ }
291
+ function assertEvidence(value) {
292
+ const record = assertRecord(value);
293
+ assertId(record.schema, 'evidence schema');
294
+ const payload = assertRecord(record.payload);
295
+ const entries = Object.entries(payload);
296
+ if (entries.length > 32 || entries.some(([key, item]) => key.length > 128
297
+ || (typeof item !== 'string' && typeof item !== 'number' && typeof item !== 'boolean')
298
+ || (typeof item === 'string' && item.length > 4096)
299
+ || (typeof item === 'number' && !Number.isFinite(item)))) {
300
+ throw new TypeError('Invalid ad evidence payload.');
301
+ }
302
+ return Object.freeze({
303
+ schema: record.schema,
304
+ payload: Object.freeze(Object.fromEntries(entries)),
305
+ });
306
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AdProvider } from './ads.js';
1
2
  export type PlatformTarget = 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit' | 'verse8' | 'telegram' | 'tauri' | 'wechat' | 'tiktok';
2
3
  export type StarterLogicalProductId = 'COINS_100' | 'COINS_500' | 'REMOVE_ADS';
3
4
  export type LogicalProductId = StarterLogicalProductId | (string & Record<never, never>);
@@ -112,6 +113,8 @@ export interface CommerceAdapter {
112
113
  getEntitlements(): Promise<readonly Entitlement[]>;
113
114
  }
114
115
  export interface AdAdapter {
116
+ /** Optional versioned provider surface; v1 methods remain compatible. */
117
+ readonly provider?: AdProvider;
115
118
  preload(input: {
116
119
  readonly placementId: LogicalAdPlacementId;
117
120
  /** Allows a shared provider preload method to enforce format readiness. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/platform",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Shared platform gateway, monetization, ads, leaderboard, identity, lifecycle, and storage contracts for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,14 @@
25
25
  "type": "module",
26
26
  "sideEffects": false,
27
27
  "exports": {
28
+ "./ads": {
29
+ "types": "./dist/ads.d.ts",
30
+ "default": "./dist/ads.js"
31
+ },
32
+ "./ads-conformance": {
33
+ "types": "./dist/ads-conformance.d.ts",
34
+ "default": "./dist/ads-conformance.js"
35
+ },
28
36
  ".": {
29
37
  "types": "./dist/index.d.ts",
30
38
  "default": "./dist/index.js"
@@ -56,6 +64,6 @@
56
64
  "lint": "ttsc --noEmit",
57
65
  "format": "ttsc format",
58
66
  "fix": "ttsc fix",
59
- "test": "vitest run && node test/capability-conformance-dist-import.mjs && node test/storage-conformance-dist-import.mjs"
67
+ "test": "vitest run && node test/capability-conformance-dist-import.mjs && node test/storage-conformance-dist-import.mjs && node test/ads-dist-import.mjs"
60
68
  }
61
69
  }