@mpgd/adapter-ait 0.12.6 → 0.13.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,18 @@
1
+ import { type AdProvider, type AdShowInput, type AdShowResult } from '@mpgd/platform/ads';
2
+ import type { AitHostDependencies } from './host.js';
3
+ import type { AitAdPlacementType } from './ad-config.js';
4
+ /** SDK event interpretation only. Game execution and backend grants are separate owners. */
5
+ export declare function createAitAdProvider(input: {
6
+ readonly dependencies: Pick<AitHostDependencies, 'showFullScreenAd'>;
7
+ readonly groups: ReadonlyMap<string, string>;
8
+ readonly types: ReadonlyMap<string, AitAdPlacementType>;
9
+ readonly isSupported: () => boolean;
10
+ readonly prepare: (group: string) => Promise<void>;
11
+ readonly consume: (group: string) => boolean;
12
+ readonly requestTimeoutMs: number;
13
+ readonly startTimeoutMs: number;
14
+ readonly displayTimeoutMs: number;
15
+ readonly onLegacyPhase?: (active: boolean) => void;
16
+ }): AdProvider & {
17
+ showLegacy(request: AdShowInput): Promise<AdShowResult>;
18
+ };
@@ -0,0 +1,295 @@
1
+ import { adProtocol, adProtocolVersion, assertAdPresentationEvent, assertAdShowInput, assertAdShowResult, } from '@mpgd/platform/ads';
2
+ const nativeOwners = new WeakMap();
3
+ /** SDK event interpretation only. Game execution and backend grants are separate owners. */
4
+ export function createAitAdProvider(input) {
5
+ const id = 'apps-in-toss-ads';
6
+ const sdk = input.dependencies.showFullScreenAd;
7
+ const listeners = new Set();
8
+ function availability(request) {
9
+ if (!input.isSupported()) {
10
+ return { state: 'unsupported', reason: 'unsupported' };
11
+ }
12
+ if (input.types.has(request.placementId) && input.types.get(request.placementId) !== request.format) {
13
+ return {
14
+ state: 'unsupported',
15
+ reason: 'unsupported',
16
+ };
17
+ }
18
+ if (!input.groups.has(request.placementId) || !input.types.has(request.placementId)) {
19
+ return {
20
+ state: 'configuration-required',
21
+ reason: 'configuration-required',
22
+ };
23
+ }
24
+ const owner = nativeOwners.get(sdk);
25
+ if (owner?.state === 'unknown') {
26
+ return {
27
+ state: 'action-required',
28
+ reason: 'action-required',
29
+ };
30
+ }
31
+ if (owner !== undefined) {
32
+ return { state: 'temporarily-unavailable', reason: 'busy' };
33
+ }
34
+ return { state: 'available' };
35
+ }
36
+ async function show(supplied, legacy) {
37
+ const request = assertAdShowInput(supplied);
38
+ if (request.idempotencyKey.length > 256 || /[\p{Cc}\p{Cf}]/u.test(request.idempotencyKey)) {
39
+ throw new TypeError('AIT ad correlation must contain 1 to 256 visible characters.');
40
+ }
41
+ const makeResult = (state) => assertAdShowResult({ providerId: id, invocationId: request.invocationId, format: request.format, ...state });
42
+ const noStart = (outcome, reason) => makeResult({
43
+ outcome,
44
+ presentation: 'not-started',
45
+ eligibility: request.format === 'rewarded' ? 'not-earned' : 'not-applicable',
46
+ ...(reason === undefined ? {} : { reason }),
47
+ });
48
+ const available = availability(request);
49
+ if (available.state !== 'available') {
50
+ return noStart('unavailable', available.reason);
51
+ }
52
+ const group = input.groups.get(request.placementId);
53
+ if (group === undefined) {
54
+ return noStart('unavailable', 'configuration-required');
55
+ }
56
+ const owned = { state: 'requested' };
57
+ nativeOwners.set(sdk, owned);
58
+ const legacyPhase = (active) => {
59
+ if (!legacy) {
60
+ return;
61
+ }
62
+ try {
63
+ input.onLegacyPhase?.(active);
64
+ }
65
+ catch { /* Isolate lifecycle observation. */ }
66
+ };
67
+ const release = () => {
68
+ if (nativeOwners.get(sdk) === owned) {
69
+ nativeOwners.delete(sdk);
70
+ }
71
+ };
72
+ try {
73
+ await input.prepare(group);
74
+ }
75
+ catch {
76
+ release();
77
+ return noStart('failed', 'transient-failure');
78
+ }
79
+ if (!input.consume(group)) {
80
+ release();
81
+ return noStart('failed', 'transient-failure');
82
+ }
83
+ let started = false;
84
+ let closed = false;
85
+ let earned;
86
+ let sequence = 0;
87
+ let cleanup;
88
+ let cleaned = false;
89
+ let timer;
90
+ let displayTimerArmed = false;
91
+ let delivered = false;
92
+ let resolve;
93
+ const result = new Promise((done) => {
94
+ resolve = done;
95
+ });
96
+ const startedAt = Date.now();
97
+ const emit = (event) => {
98
+ const safe = assertAdPresentationEvent({
99
+ ...event,
100
+ providerId: id,
101
+ invocationId: request.invocationId,
102
+ sequence: ++sequence,
103
+ });
104
+ for (const listener of [...listeners]) {
105
+ try {
106
+ void Promise.resolve(listener(safe)).catch(() => undefined);
107
+ }
108
+ catch { /* Keep native terminal observation alive. */ }
109
+ }
110
+ };
111
+ const clean = () => {
112
+ if (cleaned || cleanup === undefined || !closed || request.format === 'rewarded' && earned === undefined) {
113
+ return;
114
+ }
115
+ cleaned = true;
116
+ try {
117
+ cleanup();
118
+ }
119
+ catch { /* Cleanup does not change native closure. */ }
120
+ };
121
+ const finish = (value) => {
122
+ if (!delivered) {
123
+ delivered = true;
124
+ resolve(value);
125
+ }
126
+ };
127
+ const eligibility = () => {
128
+ if (request.format === 'interstitial') {
129
+ return 'not-applicable';
130
+ }
131
+ return earned === undefined ? 'unknown' : 'eligible';
132
+ };
133
+ const uncertain = () => {
134
+ if (closed || owned.state === 'unknown') {
135
+ return;
136
+ }
137
+ owned.state = 'unknown';
138
+ emit({ type: 'unknown' });
139
+ finish(makeResult({
140
+ outcome: 'pending',
141
+ presentation: 'unknown',
142
+ eligibility: eligibility(),
143
+ reason: 'outcome-unknown',
144
+ ...(earned === undefined ? {} : { evidence: earned }),
145
+ }));
146
+ };
147
+ const close = (failed) => {
148
+ if (closed) {
149
+ return;
150
+ }
151
+ closed = true;
152
+ if (timer !== undefined) {
153
+ clearTimeout(timer);
154
+ }
155
+ release();
156
+ legacyPhase(false);
157
+ emit(failed ? { type: 'failed', reason: 'transient-failure' } : { type: 'closed' });
158
+ if (failed && !started && earned === undefined) {
159
+ finish(noStart('failed', 'transient-failure'));
160
+ }
161
+ else {
162
+ finish(makeResult({
163
+ outcome: failed ? 'failed' : 'shown',
164
+ presentation: 'closed',
165
+ eligibility: eligibility(),
166
+ ...(failed ? { reason: 'transient-failure' } : {}),
167
+ ...(earned === undefined ? {} : { evidence: earned }),
168
+ }));
169
+ }
170
+ // Failure before presentation proves no earning is forthcoming. After a
171
+ // started presentation, retain the scoped callback for a delayed reward.
172
+ if (failed && !started && earned === undefined) {
173
+ cleaned = true;
174
+ if (cleanup !== undefined) {
175
+ try {
176
+ cleanup();
177
+ }
178
+ catch { /* Isolate cleanup. */ }
179
+ }
180
+ }
181
+ else {
182
+ clean();
183
+ }
184
+ };
185
+ const startedEvent = () => {
186
+ if (closed) {
187
+ return;
188
+ }
189
+ if (!started) {
190
+ started = true;
191
+ owned.state = 'open';
192
+ emit({ type: 'started' });
193
+ }
194
+ if (!displayTimerArmed) {
195
+ displayTimerArmed = true;
196
+ if (timer !== undefined) {
197
+ clearTimeout(timer);
198
+ }
199
+ timer = setTimeout(uncertain, input.displayTimeoutMs);
200
+ }
201
+ };
202
+ legacyPhase(true);
203
+ emit({ type: 'requested' });
204
+ timer = setTimeout(uncertain, input.requestTimeoutMs);
205
+ try {
206
+ cleanup = sdk({
207
+ options: { adGroupId: group },
208
+ onEvent(event) {
209
+ switch (event.type) {
210
+ case 'requested':
211
+ if (!closed && !started) {
212
+ if (timer !== undefined) {
213
+ clearTimeout(timer);
214
+ }
215
+ timer = setTimeout(uncertain, Math.min(input.startTimeoutMs, Math.max(0, input.requestTimeoutMs - (Date.now() - startedAt))));
216
+ }
217
+ break;
218
+ case 'show':
219
+ case 'impression':
220
+ case 'clicked':
221
+ startedEvent();
222
+ break;
223
+ case 'userEarnedReward':
224
+ if (request.format !== 'rewarded' || earned !== undefined || cleaned) {
225
+ break;
226
+ }
227
+ if (!closed) {
228
+ startedEvent();
229
+ }
230
+ earned = { schema: 'apps-in-toss.rewarded-ad.callback.v1', payload: { event: 'user-earned-reward', correlationId: request.idempotencyKey, placementId: group } };
231
+ emit({ type: 'reward-earned', evidence: earned });
232
+ clean();
233
+ break;
234
+ case 'dismissed':
235
+ close(false);
236
+ break;
237
+ case 'failedToShow':
238
+ close(true);
239
+ break;
240
+ }
241
+ },
242
+ // A bridge error does not prove the native UI never opened.
243
+ onError() { uncertain(); },
244
+ });
245
+ if (cleaned) {
246
+ try {
247
+ cleanup();
248
+ }
249
+ catch { /* Isolate synchronous terminal cleanup. */ }
250
+ }
251
+ else {
252
+ clean();
253
+ }
254
+ }
255
+ catch {
256
+ uncertain();
257
+ }
258
+ return result;
259
+ }
260
+ return {
261
+ id,
262
+ protocol: adProtocol,
263
+ protocolVersion: adProtocolVersion,
264
+ rewardSignal: 'delayed',
265
+ getAvailability: async (request) => availability(request),
266
+ async preload(request) {
267
+ const available = availability(request);
268
+ if (available.state !== 'available') {
269
+ return {
270
+ status: 'unavailable',
271
+ ...(available.reason === undefined ? {} : { reason: available.reason }),
272
+ };
273
+ }
274
+ const group = input.groups.get(request.placementId);
275
+ if (group === undefined) {
276
+ return { status: 'unavailable', reason: 'configuration-required' };
277
+ }
278
+ try {
279
+ await input.prepare(group);
280
+ return { status: 'ready' };
281
+ }
282
+ catch {
283
+ return { status: 'failed', reason: 'transient-failure' };
284
+ }
285
+ },
286
+ show: (request) => show(request, false),
287
+ showLegacy: (request) => show(request, true),
288
+ subscribe(listener) {
289
+ listeners.add(listener);
290
+ return () => {
291
+ listeners.delete(listener);
292
+ };
293
+ },
294
+ };
295
+ }
@@ -8,6 +8,19 @@
8
8
  * response validation in its authority client. This helper only owns the
9
9
  * WebView transport invocation, so all game-owned authority endpoints can use
10
10
  * the same safe call shape.
11
+ *
12
+ * Authority calls carry grant evidence and idempotency headers, so the
13
+ * resource must resolve to an `https:` origin. Plain `http:` is accepted only
14
+ * for a loopback development authority (`localhost` or `127.0.0.1`).
15
+ *
16
+ * The transport receives exactly the representation that passed the policy
17
+ * check. A string, or a URL-like object (a string `href` and no `url`, which
18
+ * also covers a `URL` created in another realm such as an iframe), is
19
+ * forwarded as its validated absolute `href`. A same-realm `Request` is
20
+ * forwarded unchanged after its immutable `url` validated. Anything else is
21
+ * rejected: a Request-like object that is not a same-realm `Request` would be
22
+ * stringified by native fetch through `toString`, not `url`, so validating its
23
+ * `url` would not cover what is actually fetched.
11
24
  */
12
25
  export declare function fetchAitAuthority(input: {
13
26
  readonly resource: RequestInfo | URL;
@@ -8,11 +8,91 @@
8
8
  * response validation in its authority client. This helper only owns the
9
9
  * WebView transport invocation, so all game-owned authority endpoints can use
10
10
  * the same safe call shape.
11
+ *
12
+ * Authority calls carry grant evidence and idempotency headers, so the
13
+ * resource must resolve to an `https:` origin. Plain `http:` is accepted only
14
+ * for a loopback development authority (`localhost` or `127.0.0.1`).
15
+ *
16
+ * The transport receives exactly the representation that passed the policy
17
+ * check. A string, or a URL-like object (a string `href` and no `url`, which
18
+ * also covers a `URL` created in another realm such as an iframe), is
19
+ * forwarded as its validated absolute `href`. A same-realm `Request` is
20
+ * forwarded unchanged after its immutable `url` validated. Anything else is
21
+ * rejected: a Request-like object that is not a same-realm `Request` would be
22
+ * stringified by native fetch through `toString`, not `url`, so validating its
23
+ * `url` would not cover what is actually fetched.
11
24
  */
12
25
  export function fetchAitAuthority(input) {
13
26
  const fetchImplementation = input.fetch ?? globalThis.fetch;
14
27
  if (typeof fetchImplementation !== 'function') {
15
28
  return Promise.reject(new TypeError('AIT authority fetch is unavailable.'));
16
29
  }
17
- return fetchImplementation(input.resource, input.init);
30
+ const resolution = resolveAitAuthorityResource(input.resource);
31
+ if (!resolution.ok) {
32
+ return Promise.reject(resolution.error);
33
+ }
34
+ return fetchImplementation(resolution.resource, input.init);
35
+ }
36
+ const loopbackHostnames = new Set(['localhost', '127.0.0.1']);
37
+ function resolveAitAuthorityResource(resource) {
38
+ const candidate = readAitAuthorityResourceCandidate(resource);
39
+ if (candidate === undefined) {
40
+ return {
41
+ ok: false,
42
+ error: new TypeError('AIT authority resource must be a string, URL, or same-realm Request.'),
43
+ };
44
+ }
45
+ let url;
46
+ try {
47
+ url = new URL(candidate.href, globalThis.location?.href);
48
+ }
49
+ catch {
50
+ return {
51
+ ok: false,
52
+ error: new TypeError('AIT authority resource must be an absolute https: URL.'),
53
+ };
54
+ }
55
+ const policyError = checkAitAuthorityUrl(url);
56
+ if (policyError !== undefined) {
57
+ return { ok: false, error: policyError };
58
+ }
59
+ return {
60
+ ok: true,
61
+ resource: candidate.kind === 'request' ? candidate.request : url.href,
62
+ };
63
+ }
64
+ function checkAitAuthorityUrl(url) {
65
+ if (url.protocol === 'https:') {
66
+ return undefined;
67
+ }
68
+ if (url.protocol === 'http:' && loopbackHostnames.has(url.hostname)) {
69
+ return undefined;
70
+ }
71
+ return new TypeError(`AIT authority resource must use https: (http: is allowed only for localhost); received ${url.protocol}//${url.host}.`);
72
+ }
73
+ /**
74
+ * Reads the URL text once so the same value is both validated and forwarded.
75
+ *
76
+ * URL-like objects are recognized by shape (`instanceof URL` is false for a
77
+ * URL from another realm) and forwarded as their href string, so the object
78
+ * itself never reaches the transport. Requests are only accepted via a
79
+ * same-realm `instanceof Request`: the instance must be forwarded as-is, and
80
+ * native fetch stringifies any non-Request object through `toString` rather
81
+ * than reading `url`, so a duck-typed `{ url }` cannot be trusted.
82
+ */
83
+ function readAitAuthorityResourceCandidate(resource) {
84
+ if (typeof resource === 'string') {
85
+ return { kind: 'href', href: resource };
86
+ }
87
+ if (typeof resource !== 'object' || resource === null) {
88
+ return undefined;
89
+ }
90
+ if (typeof Request === 'function' && resource instanceof Request) {
91
+ return { kind: 'request', href: resource.url, request: resource };
92
+ }
93
+ const { href, url } = resource;
94
+ if (typeof href === 'string' && url === undefined) {
95
+ return { kind: 'href', href };
96
+ }
97
+ return undefined;
18
98
  }
package/dist/host.d.ts CHANGED
@@ -149,7 +149,7 @@ export interface InstallAitHostBridgeOptions {
149
149
  readonly adLoadQueueTimeoutMs?: number;
150
150
  /** Upper bound for the requested-to-display portion of the total show timeout. */
151
151
  readonly adDisplayStartTimeoutMs?: number;
152
- /** Last-resort cleanup when the native SDK omits its terminal display callback. */
152
+ /** Caller wait limit; a missing native terminal callback keeps presentation unknown. */
153
153
  readonly adMaximumDisplayMs?: number;
154
154
  /** Maximum wait for an inline banner to render or report no-fill/failure. */
155
155
  readonly bannerRenderTimeoutMs?: number;
package/dist/host.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { getTossShareLink, getUserKeyForGame, grantPromotionRewardForGame as grantPromotionReward, IAP, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, requestNotificationAgreement, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore, TossAds, } from '@apps-in-toss/web-framework';
2
2
  import { assertBridgeRequest, bridgeStorageLoadProtocol, } from '@mpgd/bridge';
3
+ import { assertAdShowInput, toAdAdapter } from '@mpgd/platform/ads';
4
+ import { createAitAdProvider } from './ad-provider.js';
3
5
  import { dispatchAitLifecycleEvent } from './lifecycle.js';
4
6
  const defaultAdTimeoutMs = 60_000;
5
7
  const defaultAdLoadQueueTimeoutMs = 5_000;
@@ -13,9 +15,15 @@ const defaultIapPurchaseSessionTimeoutMs = 30 * 60_000;
13
15
  /** A provider response is untrusted input; keep restore work bounded per launch. */
14
16
  const maximumPendingIapOrders = 20;
15
17
  const invalidBridgeRequestId = 'ait-invalid-request';
16
- const rewardedAdEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
17
18
  const iapEvidenceSchema = 'apps-in-toss.iap.callback.v1';
18
19
  const minimumPromotionTossAppVersion = '5.232.0';
20
+ /**
21
+ * Every adapter-owned durable marker (purchase attempts, promotion grants,
22
+ * recovery cursors) lives under this prefix. Game code shares the same native
23
+ * storage, so `storage.save`/`storage.load` must never touch these keys: a
24
+ * forged `completed` marker would otherwise short-circuit purchase verification.
25
+ */
26
+ const reservedStorageKeyPrefix = 'mpgd:ait:';
19
27
  const promotionGrantStoragePrefix = 'mpgd:ait:promotion-grant:v1:';
20
28
  /**
21
29
  * A client idempotency key must survive a bridge reload. The authoritative
@@ -108,7 +116,6 @@ export function createAitHostBridge(options = {}) {
108
116
  const notificationSubscriptions = new Set();
109
117
  const loadedAdGroupIds = new Set();
110
118
  const loadingAdGroups = new Map();
111
- const activeAdGroupIds = new Set();
112
119
  const activeBannerAttachments = new Map();
113
120
  const bannerMountOwners = new Map();
114
121
  const pendingBannerMountSettlements = new Map();
@@ -124,10 +131,20 @@ export function createAitHostBridge(options = {}) {
124
131
  tone: options.bannerAppearance?.tone ?? 'blackAndWhite',
125
132
  variant: options.bannerAppearance?.variant ?? 'expanded',
126
133
  };
127
- const adLoadCoordinator = {
128
- active: undefined,
129
- waitTimeoutMs: adLoadQueueTimeoutMs,
130
- };
134
+ const adLoadCoordinator = createAdLoadCoordinator(dependencies.loadFullScreenAd, adLoadQueueTimeoutMs);
135
+ const nativeAds = createAitAdProvider({
136
+ dependencies,
137
+ groups: adGroupIds,
138
+ types: adPlacementTypes,
139
+ isSupported: () => areFullScreenAdsSupported(dependencies),
140
+ prepare: (group) => preloadAdGroup(dependencies, group, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, adTimeoutMs),
141
+ consume: (group) => consumeLoadedAd(group, loadedAdGroupIds),
142
+ requestTimeoutMs: adTimeoutMs,
143
+ startTimeoutMs: adDisplayStartTimeoutMs,
144
+ displayTimeoutMs: adMaximumDisplayMs,
145
+ onLegacyPhase: (active) => dispatchAitLifecycleEvent(active ? 'pause' : 'resume'),
146
+ });
147
+ const legacyAds = toAdAdapter({ ...nativeAds, show: nativeAds.showLegacy });
131
148
  const enqueueCompletedIapAttemptRetention = (storage, storageKey) => {
132
149
  const scheduled = completedIapAttemptRetention.then(async () => {
133
150
  await retainCompletedAitIapPurchaseAttempt(storage, storageKey, iapProductGrantTimeoutMs);
@@ -138,6 +155,13 @@ export function createAitHostBridge(options = {}) {
138
155
  return next;
139
156
  };
140
157
  return {
158
+ advertising: {
159
+ id: nativeAds.id,
160
+ protocol: nativeAds.protocol,
161
+ protocolVersion: nativeAds.protocolVersion,
162
+ rewardSignal: nativeAds.rewardSignal,
163
+ subscribe: (listener) => nativeAds.subscribe(listener),
164
+ },
141
165
  async request(input) {
142
166
  try {
143
167
  return await handleRequest(parseBridgeRequest(input));
@@ -370,51 +394,24 @@ export function createAitHostBridge(options = {}) {
370
394
  await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, adTimeoutMs, placementType);
371
395
  return ok(request, {});
372
396
  }
373
- case 'ads.showRewarded': {
374
- const placementId = readPlacementId(request.payload);
375
- const adGroupId = adGroupIds.get(placementId);
376
- if (adGroupId === undefined
377
- || adPlacementTypes.get(placementId) !== 'rewarded'
378
- || !areFullScreenAdsSupported(dependencies)) {
379
- return ok(request, { status: 'unavailable', rewardGranted: false });
380
- }
381
- const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'rewarded', async () => {
382
- const correlationId = readIdempotencyKey(request.payload, request.id);
383
- const result = await showRewardedAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
384
- return result.rewardGranted
385
- ? {
386
- ...result,
387
- // game-services forwards this as platformImpressionId and compares it
388
- // with the native callback correlationId during authority verification.
389
- ledgerEntryId: correlationId,
390
- evidence: {
391
- schema: rewardedAdEvidenceSchema,
392
- payload: {
393
- event: 'user-earned-reward',
394
- correlationId,
395
- placementId: adGroupId,
396
- },
397
- },
398
- }
399
- : result;
397
+ case 'ads.getAvailability':
398
+ case 'ads.prepare': {
399
+ const payload = readPayloadRecord(request.payload);
400
+ const query = assertAdShowInput({
401
+ ...payload,
402
+ invocationId: 'host-query',
403
+ idempotencyKey: 'host-query',
400
404
  });
401
- if (!shown.acquired) {
402
- return ok(request, { status: 'unavailable', rewardGranted: false });
403
- }
404
- return ok(request, shown.value);
405
- }
406
- case 'ads.showInterstitial': {
407
- const placementId = readPlacementId(request.payload);
408
- const adGroupId = adGroupIds.get(placementId);
409
- if (adGroupId === undefined
410
- || adPlacementTypes.get(placementId) !== 'interstitial'
411
- || !areFullScreenAdsSupported(dependencies)) {
412
- return ok(request, { status: 'unavailable' });
413
- }
414
- const showInterstitial = () => showInterstitialAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
415
- const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'interstitial', showInterstitial);
416
- return ok(request, shown.acquired ? shown.value : { status: 'unavailable' });
405
+ return ok(request, request.method === 'ads.prepare'
406
+ ? await nativeAds.preload(query)
407
+ : await nativeAds.getAvailability(query));
417
408
  }
409
+ case 'ads.show':
410
+ return ok(request, await nativeAds.show(assertAdShowInput(request.payload)));
411
+ case 'ads.showRewarded':
412
+ return ok(request, await legacyAds.showRewarded({ placementId: readPlacementId(request.payload), idempotencyKey: readRewardedAdCorrelationId(request.payload, request.id) }));
413
+ case 'ads.showInterstitial':
414
+ return ok(request, await legacyAds.showInterstitial?.({ placementId: readPlacementId(request.payload) }));
418
415
  case 'ads.mountBanner': {
419
416
  const placementId = readPlacementId(request.payload);
420
417
  const surfaceId = readBannerSurfaceId(request.payload);
@@ -828,6 +825,24 @@ async function preloadAdGroup(dependencies, adGroupId, loaded, loading, coordina
828
825
  }
829
826
  }
830
827
  }
828
+ const nativeAdLoadScopes = new WeakMap();
829
+ function createAdLoadCoordinator(sdk, waitTimeoutMs) {
830
+ let shared = nativeAdLoadScopes.get(sdk);
831
+ if (shared === undefined) {
832
+ shared = { active: undefined };
833
+ nativeAdLoadScopes.set(sdk, shared);
834
+ }
835
+ const owned = shared;
836
+ return {
837
+ get active() {
838
+ return owned.active;
839
+ },
840
+ set active(value) {
841
+ owned.active = value;
842
+ },
843
+ waitTimeoutMs,
844
+ };
845
+ }
831
846
  async function runSerializedAdLoad(coordinator, adGroupId, startNativeLoad) {
832
847
  const waitDeadline = Date.now() + coordinator.waitTimeoutMs;
833
848
  while (coordinator.active !== undefined) {
@@ -871,22 +886,6 @@ function waitForAdLoadSettlement(active, timeoutMs, adGroupId) {
871
886
  void active.then(() => finish(), () => finish());
872
887
  });
873
888
  }
874
- async function acquireLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType) {
875
- if (active.has(adGroupId)) {
876
- return false;
877
- }
878
- const loadedSuccessfully = await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType);
879
- if (!loadedSuccessfully) {
880
- return false;
881
- }
882
- // Re-check after the asynchronous load so concurrent callers cannot consume
883
- // or display the same native ad slot twice.
884
- if (active.has(adGroupId) || !consumeLoadedAd(adGroupId, loaded)) {
885
- return false;
886
- }
887
- active.add(adGroupId);
888
- return true;
889
- }
890
889
  async function preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType) {
891
890
  // The first caller enters preloadAdGroup synchronously and publishes its promise
892
891
  // before yielding. Remember that ownership so concurrent waiters do not emit the
@@ -905,136 +904,19 @@ async function preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, lo
905
904
  return false;
906
905
  }
907
906
  }
908
- async function withLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType, display) {
909
- const acquired = await acquireLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType);
910
- if (!acquired) {
911
- return { acquired: false };
912
- }
913
- try {
914
- return { acquired: true, value: await display() };
915
- }
916
- finally {
917
- active.delete(adGroupId);
918
- }
919
- }
920
- async function showRewardedAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
921
- let rewardGranted = false;
922
- const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, (eventType) => {
923
- if (eventType === 'userEarnedReward') {
924
- rewardGranted = true;
925
- }
926
- });
927
- let resultStatus;
928
- if (status === 'shown' && rewardGranted) {
929
- resultStatus = 'completed';
930
- }
931
- else if (status === 'failed') {
932
- resultStatus = 'failed';
933
- }
934
- else {
935
- resultStatus = 'skipped';
936
- }
937
- return {
938
- status: resultStatus,
939
- rewardGranted,
940
- };
941
- }
942
- async function showInterstitialAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
943
- const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs);
944
- return { status: status === 'failed' ? 'skipped' : status };
945
- }
946
- function showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, observe) {
947
- dispatchAitLifecycleEvent('pause');
948
- return new Promise((resolve) => {
949
- const startedAt = Date.now();
950
- let cleanup = () => { };
951
- let settled = false;
952
- let maximumDisplayTimeoutArmed = false;
953
- let adTimer;
954
- const clearAdTimer = () => {
955
- if (adTimer !== undefined) {
956
- globalThis.clearTimeout(adTimer);
957
- adTimer = undefined;
958
- }
959
- };
960
- const finish = (status) => {
961
- if (settled) {
962
- return;
963
- }
964
- settled = true;
965
- clearAdTimer();
966
- cleanup();
967
- dispatchAitLifecycleEvent('resume');
968
- resolve(status);
969
- };
970
- const armDisplayStartTimeout = () => {
971
- clearAdTimer();
972
- const elapsedMs = Math.max(0, Date.now() - startedAt);
973
- const remainingMs = Math.max(0, timeoutMs - elapsedMs);
974
- adTimer = globalThis.setTimeout(() => finish('failed'), Math.min(displayStartTimeoutMs, remainingMs));
975
- };
976
- const armMaximumDisplayTimeout = () => {
977
- if (maximumDisplayTimeoutArmed) {
978
- return;
979
- }
980
- maximumDisplayTimeoutArmed = true;
981
- clearAdTimer();
982
- adTimer = globalThis.setTimeout(() => {
983
- console.warn('AIT full-screen ad omitted its terminal callback; recovering the game lifecycle.', adGroupId);
984
- finish('shown');
985
- }, maximumDisplayMs);
986
- };
987
- const failShow = (error) => {
988
- if (settled) {
989
- return;
990
- }
991
- console.warn('Failed to show AIT full-screen ad.', adGroupId, error ?? 'unknown native error');
992
- finish('failed');
993
- };
994
- adTimer = globalThis.setTimeout(() => finish('failed'), timeoutMs);
995
- try {
996
- const unregister = dependencies.showFullScreenAd({
997
- options: { adGroupId },
998
- onEvent: (event) => {
999
- observe?.(event.type);
1000
- switch (event.type) {
1001
- case 'requested':
1002
- armDisplayStartTimeout();
1003
- break;
1004
- case 'show':
1005
- case 'impression':
1006
- case 'clicked':
1007
- case 'userEarnedReward':
1008
- // Native terminal callbacks remain authoritative. A long, one-shot
1009
- // recovery timeout prevents a broken SDK callback from deadlocking
1010
- // the game forever without treating ordinary end-card dwell as failure.
1011
- armMaximumDisplayTimeout();
1012
- break;
1013
- case 'dismissed':
1014
- finish('shown');
1015
- break;
1016
- case 'failedToShow':
1017
- finish('failed');
1018
- break;
1019
- }
1020
- },
1021
- onError: failShow,
1022
- });
1023
- cleanup = unregister;
1024
- if (settled) {
1025
- cleanup();
1026
- }
1027
- }
1028
- catch (error) {
1029
- failShow(error);
1030
- }
1031
- });
1032
- }
907
+ /**
908
+ * Launch and inbound-share parameters arrive from untrusted deep links. Bound
909
+ * each forwarded value and the nested `queryParams` JSON so a hostile link
910
+ * cannot push oversized or unbounded data into the game.
911
+ */
912
+ const maximumLaunchParamLength = 256;
913
+ const maximumNestedQueryParamsJsonLength = 4_096;
914
+ const maximumNestedQueryParamCount = 32;
1033
915
  function getLaunchIntent() {
1034
916
  const params = inboundSearchParams();
1035
- const challengeToken = nonEmptyParam(params.get('challengeToken'));
1036
- const puzzleId = nonEmptyParam(params.get('puzzleId'));
1037
- const requestedEntry = nonEmptyParam(params.get('entry'));
917
+ const challengeToken = boundedParam(params.get('challengeToken'));
918
+ const puzzleId = boundedParam(params.get('puzzleId'));
919
+ const requestedEntry = boundedParam(params.get('entry'));
1038
920
  let entry;
1039
921
  if (requestedEntry !== undefined && launchEntries.has(requestedEntry)) {
1040
922
  entry = requestedEntry;
@@ -1053,8 +935,8 @@ function getLaunchIntent() {
1053
935
  }
1054
936
  function readInboundShare() {
1055
937
  const params = inboundSearchParams();
1056
- const puzzleId = nonEmptyParam(params.get('puzzleId'));
1057
- const challengeToken = nonEmptyParam(params.get('challengeToken'));
938
+ const puzzleId = boundedParam(params.get('puzzleId'));
939
+ const challengeToken = boundedParam(params.get('challengeToken'));
1058
940
  return puzzleId === undefined && challengeToken === undefined
1059
941
  ? null
1060
942
  : {
@@ -1065,12 +947,24 @@ function readInboundShare() {
1065
947
  function inboundSearchParams() {
1066
948
  const params = new URLSearchParams(globalThis.location?.search ?? '');
1067
949
  const nested = params.get('queryParams');
1068
- if (nested !== null) {
950
+ if (nested !== null && nested.length <= maximumNestedQueryParamsJsonLength) {
1069
951
  try {
1070
952
  const parsed = JSON.parse(nested);
1071
953
  if (isRecord(parsed)) {
954
+ let merged = 0;
1072
955
  for (const [key, value] of Object.entries(parsed)) {
1073
- if (typeof value === 'string' && !params.has(key)) {
956
+ if (merged >= maximumNestedQueryParamCount) {
957
+ break;
958
+ }
959
+ // Direct search params win; nested values only fill missing keys and
960
+ // must be bounded strings. Keys are counted whether or not they merge
961
+ // so an attacker cannot pad past the cap with ignored entries.
962
+ merged += 1;
963
+ if (typeof value === 'string'
964
+ && key.length > 0
965
+ && key.length <= maximumLaunchParamLength
966
+ && value.length <= maximumLaunchParamLength
967
+ && !params.has(key)) {
1074
968
  params.set(key, value);
1075
969
  }
1076
970
  }
@@ -2170,6 +2064,9 @@ function readStorageKey(payload) {
2170
2064
  if (typeof key !== 'string' || key.length === 0) {
2171
2065
  throw new TypeError('AIT storage key must be a non-empty string.');
2172
2066
  }
2067
+ if (key.startsWith(reservedStorageKeyPrefix)) {
2068
+ throw new TypeError(`AIT storage keys starting with "${reservedStorageKeyPrefix}" are reserved for adapter-owned markers.`);
2069
+ }
2173
2070
  return key;
2174
2071
  }
2175
2072
  function readPlacementId(payload) {
@@ -2186,9 +2083,24 @@ function readBannerSurfaceId(payload) {
2186
2083
  }
2187
2084
  return surfaceId;
2188
2085
  }
2189
- function readIdempotencyKey(payload, fallback) {
2086
+ /**
2087
+ * A rewarded-ad idempotencyKey becomes the client-supplied evidence
2088
+ * correlationId. It is optional, but when present it must obey the same
2089
+ * bounded visible-character rule as purchase keys so a game cannot push
2090
+ * arbitrary blobs into reward evidence.
2091
+ */
2092
+ function readRewardedAdCorrelationId(payload, fallback) {
2190
2093
  const value = readPayloadRecord(payload).idempotencyKey;
2191
- return typeof value === 'string' && value.length > 0 ? value : fallback;
2094
+ if (value === undefined) {
2095
+ return fallback;
2096
+ }
2097
+ if (typeof value !== 'string'
2098
+ || value.length === 0
2099
+ || value.length > 256
2100
+ || /[\p{Cc}\p{Cf}]/u.test(value)) {
2101
+ throw new TypeError('AIT rewarded ad idempotencyKey must contain 1 to 256 visible characters.');
2102
+ }
2103
+ return value;
2192
2104
  }
2193
2105
  function readCommercePurchase(payload) {
2194
2106
  const value = readPayloadRecord(payload);
@@ -2256,8 +2168,10 @@ function ok(request, data) {
2256
2168
  function createBridgeError(id, code, message, retryable = false) {
2257
2169
  return { id, ok: false, error: { code, message, retryable } };
2258
2170
  }
2259
- function nonEmptyParam(value) {
2260
- return value === null || value.length === 0 ? undefined : value;
2171
+ function boundedParam(value) {
2172
+ return value === null || value.length === 0 || value.length > maximumLaunchParamLength
2173
+ ? undefined
2174
+ : value;
2261
2175
  }
2262
2176
  function isAbortError(error) {
2263
2177
  return isRecord(error) && error.name === 'AbortError';
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type PlatformGateway } from '@mpgd/platform';
3
+ import { type AdProvider } from '@mpgd/platform/ads';
3
4
  export interface GamePlatformBridge {
4
5
  request(input: BridgeRequest): Promise<BridgeResponse>;
6
+ /** Native observations are scoped to this host; SDK commands still use request envelopes. */
7
+ readonly advertising?: Pick<AdProvider, 'id' | 'protocol' | 'protocolVersion' | 'rewardSignal' | 'subscribe'>;
5
8
  }
6
9
  export interface AitSandboxStorage {
7
10
  readonly getItem: (key: string) => string | null;
package/dist/index.js CHANGED
@@ -1,7 +1,48 @@
1
- import { bridgeStorageLoadProtocol, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
1
+ import { assertBridgeResponse, bridgeStorageLoadProtocol, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
2
2
  import { PlatformOperationError, } from '@mpgd/platform';
3
+ import { assertAdAvailability, assertAdPreparationResult, assertAdPresentationEvent, assertAdShowInput, assertAdShowResult, toAdAdapter, } from '@mpgd/platform/ads';
3
4
  import { createAitLifecycleAdapter } from './lifecycle.js';
4
5
  export function createAitPlatformGateway(input) {
6
+ const advertisingBridge = input.bridge ?? getBridge() ?? input.fallbackBridge;
7
+ const advertising = advertisingBridge?.advertising;
8
+ async function requestAdvertising(method, payload) {
9
+ if (advertisingBridge === undefined) {
10
+ throw new PlatformOperationError({
11
+ code: 'AIT_BRIDGE_NOT_INSTALLED',
12
+ });
13
+ }
14
+ const id = crypto.randomUUID();
15
+ const response = assertBridgeResponse(await advertisingBridge.request({ id, method, payload, meta: { target: 'ait', appVersion: input.appVersion, buildId: input.buildId, sentAt: new Date().toISOString() } }));
16
+ if (response.id !== id) {
17
+ throw new TypeError('Advertising response belongs to another bridge request.');
18
+ }
19
+ if (!response.ok) {
20
+ throw new PlatformOperationError(response.error);
21
+ }
22
+ return response.data;
23
+ }
24
+ const provider = advertising === undefined
25
+ ? undefined
26
+ : {
27
+ id: advertising.id,
28
+ protocol: advertising.protocol,
29
+ protocolVersion: advertising.protocolVersion,
30
+ rewardSignal: advertising.rewardSignal,
31
+ getAvailability: async (payload) => assertAdAvailability(await requestAdvertising('ads.getAvailability', payload)),
32
+ preload: async (payload) => assertAdPreparationResult(await requestAdvertising('ads.prepare', payload)),
33
+ show: async (supplied) => {
34
+ const request = assertAdShowInput(supplied);
35
+ const result = assertAdShowResult(await requestAdvertising('ads.show', request));
36
+ if (result.providerId !== advertising.id || result.invocationId !== request.invocationId || result.format !== request.format) {
37
+ throw new TypeError('Advertising result belongs to another invocation.');
38
+ }
39
+ return result;
40
+ },
41
+ subscribe: (listener) => advertising.subscribe((event) => listener(assertAdPresentationEvent(event))),
42
+ };
43
+ if (provider !== undefined) {
44
+ toAdAdapter(provider);
45
+ }
5
46
  async function request(method, payload) {
6
47
  const bridge = input.bridge ?? getBridge() ?? input.fallbackBridge;
7
48
  if (bridge === undefined) {
@@ -60,6 +101,7 @@ export function createAitPlatformGateway(input) {
60
101
  getEntitlements: () => request('commerce.getEntitlements', {}),
61
102
  },
62
103
  ads: {
104
+ ...(provider === undefined ? {} : { provider }),
63
105
  preload: (payload) => request('ads.preload', payload),
64
106
  showRewarded: (payload) => request('ads.showRewarded', payload),
65
107
  showInterstitial: (payload) => request('ads.showInterstitial', payload),
@@ -1,5 +1,6 @@
1
1
  import type { LifecycleAdapter } from '@mpgd/platform';
2
2
  export declare const aitLifecyclePauseEvent = "mpgd:ait:pause";
3
3
  export declare const aitLifecycleResumeEvent = "mpgd:ait:resume";
4
+ /** Legacy SDK presentation, page lifetime, and visibility are independent owners. */
4
5
  export declare function createAitLifecycleAdapter(): LifecycleAdapter;
5
6
  export declare function dispatchAitLifecycleEvent(type: 'pause' | 'resume'): void;
package/dist/lifecycle.js CHANGED
@@ -1,45 +1,92 @@
1
1
  export const aitLifecyclePauseEvent = 'mpgd:ait:pause';
2
2
  export const aitLifecycleResumeEvent = 'mpgd:ait:resume';
3
- const lifecycleEventDedupeWindowMs = 500;
3
+ /** Legacy SDK presentation, page lifetime, and visibility are independent owners. */
4
4
  export function createAitLifecycleAdapter() {
5
- return {
6
- onPause(callback) {
7
- return subscribeToAitLifecycle('pause', callback);
8
- },
9
- onResume(callback) {
10
- return subscribeToAitLifecycle('resume', callback);
11
- },
12
- };
13
- }
14
- export function dispatchAitLifecycleEvent(type) {
15
- globalThis.dispatchEvent?.(new Event(type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent));
16
- }
17
- function subscribeToAitLifecycle(type, callback) {
18
5
  const target = globalThis;
19
6
  const documentTarget = target.document;
20
- const eventName = type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent;
21
- let lastFiredAt = Number.NEGATIVE_INFINITY;
22
- const fire = () => {
23
- const now = Date.now();
24
- if (now - lastFiredAt < lifecycleEventDedupeWindowMs) {
25
- return;
7
+ const pauses = new Set();
8
+ const resumes = new Set();
9
+ let nativePaused = false;
10
+ let pageActive = true;
11
+ let attached = false;
12
+ let active = documentTarget?.visibilityState !== 'hidden';
13
+ const notify = (callback) => {
14
+ try {
15
+ void Promise.resolve(callback()).catch(() => undefined);
26
16
  }
27
- lastFiredAt = now;
28
- callback();
17
+ catch { /* Keep lifecycle observation alive. */ }
29
18
  };
30
- const handleCustomEvent = () => fire();
31
- const handleVisibilityChange = () => {
32
- if (documentTarget?.visibilityState === (type === 'pause' ? 'hidden' : 'visible')) {
33
- fire();
19
+ function reconcile() {
20
+ const next = !nativePaused && pageActive && documentTarget?.visibilityState !== 'hidden';
21
+ if (next === active) {
22
+ return;
23
+ }
24
+ active = next;
25
+ for (const callback of [...(active ? resumes : pauses)]) {
26
+ notify(callback);
34
27
  }
28
+ }
29
+ const nativePause = () => {
30
+ nativePaused = true;
31
+ reconcile();
32
+ };
33
+ const nativeResume = () => {
34
+ nativePaused = false;
35
+ reconcile();
35
36
  };
36
- const handlePageTransition = () => fire();
37
- target.addEventListener?.(eventName, handleCustomEvent);
38
- documentTarget?.addEventListener('visibilitychange', handleVisibilityChange);
39
- target.addEventListener?.(type === 'pause' ? 'pagehide' : 'pageshow', handlePageTransition);
40
- return () => {
41
- target.removeEventListener?.(eventName, handleCustomEvent);
42
- documentTarget?.removeEventListener('visibilitychange', handleVisibilityChange);
43
- target.removeEventListener?.(type === 'pause' ? 'pagehide' : 'pageshow', handlePageTransition);
37
+ const pageHide = () => {
38
+ pageActive = false;
39
+ reconcile();
44
40
  };
41
+ const pageShow = () => {
42
+ pageActive = true;
43
+ reconcile();
44
+ };
45
+ function attach() {
46
+ if (attached) {
47
+ return;
48
+ }
49
+ attached = true;
50
+ target.addEventListener?.(aitLifecyclePauseEvent, nativePause);
51
+ target.addEventListener?.(aitLifecycleResumeEvent, nativeResume);
52
+ target.addEventListener?.('pagehide', pageHide);
53
+ target.addEventListener?.('pageshow', pageShow);
54
+ documentTarget?.addEventListener('visibilitychange', reconcile);
55
+ }
56
+ function detach() {
57
+ if (!attached) {
58
+ return;
59
+ }
60
+ attached = false;
61
+ target.removeEventListener?.(aitLifecyclePauseEvent, nativePause);
62
+ target.removeEventListener?.(aitLifecycleResumeEvent, nativeResume);
63
+ target.removeEventListener?.('pagehide', pageHide);
64
+ target.removeEventListener?.('pageshow', pageShow);
65
+ documentTarget?.removeEventListener('visibilitychange', reconcile);
66
+ }
67
+ function subscribe(callbacks, callback) {
68
+ callbacks.add(callback);
69
+ attach();
70
+ if (callbacks === pauses && !active) {
71
+ notify(callback);
72
+ }
73
+ return () => {
74
+ callbacks.delete(callback);
75
+ if (pauses.size + resumes.size === 0) {
76
+ detach();
77
+ }
78
+ };
79
+ }
80
+ return {
81
+ onPause: (callback) => subscribe(pauses, callback),
82
+ onResume: (callback) => subscribe(resumes, callback),
83
+ async dispose() {
84
+ pauses.clear();
85
+ resumes.clear();
86
+ detach();
87
+ },
88
+ };
89
+ }
90
+ export function dispatchAitLifecycleEvent(type) {
91
+ globalThis.dispatchEvent?.(new Event(type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent));
45
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-ait",
3
- "version": "0.12.6",
3
+ "version": "0.13.0",
4
4
  "description": "Apps in Toss WebView platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,8 +59,8 @@
59
59
  "dist"
60
60
  ],
61
61
  "dependencies": {
62
- "@mpgd/bridge": "0.10.0",
63
- "@mpgd/platform": "0.14.0"
62
+ "@mpgd/platform": "0.15.0",
63
+ "@mpgd/bridge": "0.11.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@apps-in-toss/web-framework": ">=3.0.0 <4"
@@ -71,10 +71,12 @@
71
71
  }
72
72
  },
73
73
  "devDependencies": {
74
+ "@types/node": "^24.0.0",
74
75
  "@apps-in-toss/web-framework": "3.5.0",
75
76
  "ttsc": "0.30.4",
76
77
  "typescript": "7.0.2",
77
- "vitest": "^4.0.0"
78
+ "vitest": "^4.0.0",
79
+ "@mpgd/game-runtime": "0.3.0"
78
80
  },
79
81
  "main": "./dist/index.js",
80
82
  "types": "./dist/index.d.ts",