Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .sampo/changesets/ait-production-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
npm/@mpgd/adapter-ait: minor (Added)
npm/@mpgd/target-config: minor (Changed)
npm/@mpgd/cli: minor (Added)
---

Add a reusable Apps in Toss production host bridge backed by the official game identity,
Storage, Ads 2.0, sharing, lifecycle, and Game Center APIs. Rewarded ads load before display and
forward callback evidence without issuing demo grants, while commerce stays fail-closed until a
game installs its own verified purchase flow.

Allow game targets to opt out of authoritative game services. Opted-out targets disable IAP and
ad features in their effective configuration and no longer require a production backend URL,
while targets that enable authoritative grants keep the public HTTPS backend release gate.

Scaffold each generated game with its own Apps in Toss wrapper, Granite configuration, and
`@ait-co/devtools` workflow. The wrapper reuses the adapter's exported bundle loader while
keeping app identity, console state, icons, and review metadata owned by the game repository.
Expose a shared AIT ad-placement extractor for wrapper builds, preserve configuration parse
causes, and publish package metadata that the official `.ait` dependency collector can resolve
from both installed packages and Kit workspaces.
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,10 @@ pnpm mpgd target smoke-all --targets-file ./mpgd.targets.json --targets web,micr

Generated Phaser starters own their Reddit Devvit app root in
`apps/target-devvit`; run `pnpm devvit:init` once after login before live
playtest/upload. Apps in Toss and Capacitor targets currently use kit reference
shells for smoke builds, but release artifacts and manifests are copied back
under the game app's `artifacts/` and `release-output/` directories. Production
app metadata should still move into game-owned wrappers before store or Toss
submission.
playtest/upload. They also own an Apps in Toss wrapper in `apps/target-ait`, so
app identity, console state, community devtools, icons, and review metadata stay
with the game. Capacitor targets continue to use kit reference shells for smoke
builds until a game creates production-owned Android and iOS shells.

Optional Microsoft Store support is modeled as a PWA/web target, not a separate native
SDK adapter. `pnpm build:microsoft-store` builds the Phaser game with the
Expand Down Expand Up @@ -423,10 +422,12 @@ for target overrides, native staging, and the Apps in Toss console URL gate.

## Apps in Toss

The Apps in Toss target currently uses SDK 2.x compatible `granite.config.ts`
and `ait build` scripts. Its wrapper resolves the game-specific player id with
`getUserKeyForGame`; Toss app 5.232.0 or newer and a game-category mini-app are
required outside the explicit plain-browser mock command. SDK 3.x keeps the feature interface compatible but
The Apps in Toss target uses SDK 2.x-compatible `granite.config.ts` and `ait
build` scripts. The reusable production host resolves a stable game-scoped
player id with the game-only `getUserKeyForGame`, persists gateway state with
native `Storage`, and delegates sharing, Game Center, and Ads 2.0 to the official SDK. Purchases
stay fail-closed, and rewarded-ad callbacks remain evidence until a game-owned
authority verifies them. SDK 3.x keeps the feature interface compatible but
renames the config file to `apps-in-toss.config.ts`, so that migration should be
handled as a dedicated follow-up.

Expand Down
24 changes: 23 additions & 1 deletion adapters/ait/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,20 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./host": {
"types": "./dist/host.d.ts",
"default": "./dist/host.js"
},
"./wrapper": {
"types": "./dist/wrapper.d.ts",
"default": "./dist/wrapper.js"
},
"./ad-config": {
"types": "./dist/ad-config.d.ts",
"default": "./dist/ad-config.js"
},
"./package.json": "./package.json"
},
"files": [
"dist"
Expand All @@ -44,7 +57,16 @@
"@mpgd/bridge": "workspace:*",
"@mpgd/platform": "workspace:*"
},
"peerDependencies": {
"@apps-in-toss/web-framework": ">=2.10.4 <3"
},
"peerDependenciesMeta": {
"@apps-in-toss/web-framework": {
"optional": true
}
},
"devDependencies": {
"@apps-in-toss/web-framework": "^2.10.6",
"ttsc": "0.18.4",
"typescript": "7.0.2",
"vitest": "^4.0.0"
Expand Down
54 changes: 54 additions & 0 deletions adapters/ait/src/ad-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';

import { extractAitAdBridgeConfig } from './ad-config';

describe('extractAitAdBridgeConfig', () => {
it('maps configured AIT placements and omits placements without an AIT group', () => {
expect(extractAitAdBridgeConfig({
version: '1',
placements: [
{
id: 'HINT_REWARDED',
type: 'rewarded',
reward: { type: 'currency', currency: 'coin', amount: 1 },
frequencyCap: { cooldownSeconds: 0 },
platformPlacementIds: { ait: ' rewarded-group ' },
},
{
id: 'RESULT_INTERSTITIAL',
type: 'interstitial',
frequencyCap: { cooldownSeconds: 60 },
platformPlacementIds: { android: 'android-only' },
},
],
})).toEqual({
adGroupIds: { HINT_REWARDED: 'rewarded-group' },
adPlacementTypes: { HINT_REWARDED: 'rewarded' },
});
});

it('rejects malformed placement entries with their source label', () => {
expect(() => extractAitAdBridgeConfig({
placements: [{ id: 'BROKEN', type: 'banner' }],
}, '/tmp/placements.json')).toThrow(
'AIT ad placement entry at index 0 is invalid: /tmp/placements.json',
);
});

it('rejects duplicate logical placement IDs before mapping AIT groups', () => {
const placement = {
id: 'HINT_REWARDED',
type: 'rewarded',
reward: { type: 'currency', currency: 'coin', amount: 1 },
frequencyCap: { cooldownSeconds: 0 },
platformPlacementIds: { ait: 'rewarded-group' },
} as const;

expect(() => extractAitAdBridgeConfig({
version: '1',
placements: [placement, placement],
}, 'placements.json')).toThrow(
'Duplicate AIT ad placement ID "HINT_REWARDED" at index 1: placements.json',
);
});
});
54 changes: 54 additions & 0 deletions adapters/ait/src/ad-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export interface AitAdBridgeConfig {
readonly adGroupIds: Readonly<Record<string, string>>;
readonly adPlacementTypes: Readonly<Record<string, 'rewarded' | 'interstitial'>>;
}

export function extractAitAdBridgeConfig(
input: unknown,
sourceLabel = 'ad placements',
): AitAdBridgeConfig {
if (!isRecord(input) || !Array.isArray(input.placements)) {
throw new Error(`AIT ad placements are invalid: ${sourceLabel}`);
}

const placements: readonly unknown[] = input.placements;
const adGroupIds: Record<string, string> = {};
const adPlacementTypes: Record<string, 'rewarded' | 'interstitial'> = {};
const placementIds = new Set<string>();

for (const [placementIndex, placement] of placements.entries()) {
if (
!isRecord(placement)
|| typeof placement.id !== 'string'
|| (placement.type !== 'rewarded' && placement.type !== 'interstitial')
) {
throw new Error(
`AIT ad placement entry at index ${placementIndex} is invalid: ${sourceLabel}`,
);
}

if (placementIds.has(placement.id)) {
throw new Error(
`Duplicate AIT ad placement ID "${placement.id}" at index `
+ `${placementIndex}: ${sourceLabel}`,
);
}
placementIds.add(placement.id);

const platformPlacementIds = placement.platformPlacementIds;
const adGroupId = isRecord(platformPlacementIds) && typeof platformPlacementIds.ait === 'string'
? platformPlacementIds.ait.trim()
: '';

if (adGroupId.length > 0) {
adGroupIds[placement.id] = adGroupId;
adPlacementTypes[placement.id] = placement.type;
}
}

return { adGroupIds, adPlacementTypes };
}

function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Loading