@mpgd/cli 0.56.0 → 0.58.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.
Files changed (49) hide show
  1. package/dist/capacitor-shell-starter.d.ts +30 -0
  2. package/dist/capacitor-shell-starter.js +243 -11
  3. package/dist/index.js +170 -11
  4. package/dist/legal-site-output.d.ts +18 -0
  5. package/dist/legal-site-output.js +46 -1
  6. package/dist/native-build-info.json +2 -2
  7. package/dist/native-build-target.js +3935 -1308
  8. package/dist/native-deploy-run.js +4 -0
  9. package/dist/native-shell-store-policy.d.ts +123 -0
  10. package/dist/native-shell-store-policy.js +816 -0
  11. package/dist/notifications-starter.js +26 -3
  12. package/dist/release-state.d.ts +2 -0
  13. package/dist/release-state.js +4 -2
  14. package/dist/testflight-submission.d.ts +9 -0
  15. package/dist/testflight-submission.js +69 -0
  16. package/package.json +16 -16
  17. package/templates/lite-overlay/agent/acceptance.md +1 -1
  18. package/templates/lite-overlay/agent/brief.md +1 -1
  19. package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
  20. package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
  21. package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
  22. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +28 -6
  23. package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
  24. package/templates/notifications-overlay/common/notifications/topics.json +3 -0
  25. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
  26. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +111 -2
  27. package/templates/notifications-overlay/common/src/platform/notifications.ts +36 -4
  28. package/templates/phaser-game/README.md +72 -0
  29. package/templates/phaser-game/agent/acceptance.md +4 -1
  30. package/templates/phaser-game/agent/brief.md +14 -4
  31. package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +37 -0
  32. package/templates/phaser-game/gitignore +11 -0
  33. package/templates/phaser-game/legal/delete-account.html +133 -0
  34. package/templates/phaser-game/legal/privacy.html +111 -12
  35. package/templates/phaser-game/legal/support.html +1 -0
  36. package/templates/phaser-game/legal/terms.html +1 -0
  37. package/templates/phaser-game/mpgd.game.json +3 -0
  38. package/templates/phaser-game/src/env.d.ts +6 -0
  39. package/templates/phaser-game/src/i18n/messages.ts +86 -1
  40. package/templates/phaser-game/src/main.ts +69 -8
  41. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
  42. package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
  43. package/templates/phaser-game/src/platform/gameServices.ts +55 -10
  44. package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
  45. package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
  46. package/templates/phaser-game/src/platform/serverSession.ts +8 -0
  47. package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
  48. package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
  49. package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
@@ -64,6 +64,15 @@ const commonEdits = {
64
64
  'src/platform/buildGateways/capacitorAndroid.ts': [gatewayImport, gatewayProviders('android')],
65
65
  'src/platform/buildGateways/capacitorIos.ts': [gatewayImport, gatewayProviders('ios')],
66
66
  'src/platform/buildGateways/reddit.ts': [
67
+ {
68
+ anchor: ' createDevvitCommerceProducts,\n createDevvitPlatformGateway,\n',
69
+ replacement: [
70
+ ' createDevvitCommerceProducts,',
71
+ ' createDevvitNotificationsBackend,',
72
+ ' createDevvitPlatformGateway,',
73
+ '',
74
+ ].join('\n'),
75
+ },
67
76
  {
68
77
  anchor: "import { createDevvitWebPaymentsClient } from '@mpgd/adapter-devvit/web';\n",
69
78
  replacement: [
@@ -77,6 +86,7 @@ const commonEdits = {
77
86
  {
78
87
  anchor: "import type { RuntimeConfig } from '../runtimeDetector';\n",
79
88
  replacement: [
89
+ "import { setStarterHostedNotificationBackend } from '../notificationBackend';",
80
90
  "import { starterNotificationTopics } from '../notifications';",
81
91
  "import type { RuntimeConfig } from '../runtimeDetector';",
82
92
  '',
@@ -102,7 +112,14 @@ const commonEdits = {
102
112
  ' runtime: RuntimeConfig,',
103
113
  "): Pick<Parameters<typeof createDevvitPlatformGateway>[0], 'notifications'> {",
104
114
  ' const topics = starterNotificationTopics(runtime.configTarget).map((topic) => topic.id);',
105
- ' return topics.length === 0 ? {} : { notifications: { topics } };',
115
+ ' if (topics.length === 0) {',
116
+ ' return {};',
117
+ ' }',
118
+ " // The runtime's backend is the Devvit server (consent records, reminders, notices for the",
119
+ ' // logged-in Reddit user), over the same server bridge as the gateway; created on first use.',
120
+ ' setStarterHostedNotificationBackend(runtime.configTarget, () =>',
121
+ ' createDevvitNotificationsBackend({ appVersion: runtime.appVersion, buildId: runtime.buildId }));',
122
+ ' return { notifications: { topics } };',
106
123
  '}',
107
124
  '',
108
125
  'function createCommerceOptions(): DevvitGatewayCommerceOptions | undefined {',
@@ -139,6 +156,7 @@ const commonEdits = {
139
156
  ' createDevvitCommerceBridgeProducts,',
140
157
  ' createDevvitCommerceV2BridgeHandler,',
141
158
  ' createDevvitNotificationService,',
159
+ ' createDevvitNotificationsBackendBridgeHandler,',
142
160
  ' createDevvitNotificationsV2BridgeHandler,',
143
161
  "} from '@mpgd/adapter-devvit/server';",
144
162
  '',
@@ -182,7 +200,7 @@ const commonEdits = {
182
200
  ' */',
183
201
  'const notificationTopicCatalog = notificationTopicsJson as NotificationTopicCatalog;',
184
202
  'const redditNotificationTopics = readRedditNotificationTopics();',
185
- "const notificationService: Parameters<typeof handleNotificationTask>[0]['notifications'] =",
203
+ "const notificationService: ReturnType<typeof createDevvitNotificationService>['service'] | undefined =",
186
204
  ' redditNotificationTopics.length === 0 ? undefined : createNotificationService();',
187
205
  '// The opt-in is served only with the service that sends; a refused catalog turns both off.',
188
206
  'const notificationsBridge = notificationService === undefined',
@@ -192,6 +210,10 @@ const commonEdits = {
192
210
  ' currentUserId: currentPlayerId,',
193
211
  ' topics: redditNotificationTopics,',
194
212
  ' });',
213
+ "// The webview runtime's backend: consent records, reminders, and notices for the request's user.",
214
+ 'const notificationsBackend = notificationService === undefined',
215
+ ' ? undefined',
216
+ ' : createDevvitNotificationsBackendBridgeHandler({ service: notificationService, context: () => context });',
195
217
  'const handleBridgeRequest = createDevvitBridgeHandler({',
196
218
  ' redis,',
197
219
  ' currentPlayerId,',
@@ -199,6 +221,7 @@ const commonEdits = {
199
221
  ' storageKeyNamespace: gameName,',
200
222
  ' ...(commerce === undefined ? {} : { commerce }),',
201
223
  ' ...(notificationsBridge === undefined ? {} : { notifications: notificationsBridge }),',
224
+ ' ...(notificationsBackend === undefined ? {} : { notificationsBackend }),',
202
225
  '});',
203
226
  '',
204
227
  ].join('\n'),
@@ -230,7 +253,7 @@ const commonEdits = {
230
253
  ' * deployment the service refuses turns notifications off, never the game',
231
254
  ' * server.',
232
255
  ' */',
233
- "function createNotificationService(): Parameters<typeof handleNotificationTask>[0]['notifications'] {",
256
+ "function createNotificationService(): ReturnType<typeof createDevvitNotificationService>['service'] | undefined {",
234
257
  ' try {',
235
258
  ' return createDevvitNotificationService({',
236
259
  ' redis,',
@@ -136,5 +136,7 @@ export declare function releaseNativeSubmissionLease(input: {
136
136
  readonly environment?: NodeJS.ProcessEnv;
137
137
  readonly signal?: AbortSignal;
138
138
  }): Promise<NativeSubmissionCheckpoint>;
139
+ /** Exported for tests: the checkpoint status moves a store submission may make. */
140
+ export declare function allowedSubmissionTransition(from: NativeSubmissionStatus, to: NativeSubmissionStatus): boolean;
139
141
  /** Validate persisted checkpoint shape before any remote mutation or recovery. */
140
142
  export declare function assertNativeSubmissionCheckpoint(value: unknown): asserts value is NativeSubmissionCheckpoint;
@@ -524,7 +524,8 @@ export async function releaseNativeSubmissionLease(input) {
524
524
  return checkpoint;
525
525
  });
526
526
  }
527
- function allowedSubmissionTransition(from, to) {
527
+ /** Exported for tests: the checkpoint status moves a store submission may make. */
528
+ export function allowedSubmissionTransition(from, to) {
528
529
  if (from === to) {
529
530
  return true;
530
531
  }
@@ -550,7 +551,8 @@ function allowedSubmissionTransition(from, to) {
550
551
  'failed',
551
552
  'action-required',
552
553
  ],
553
- 'action-required': ['processing', 'testflight-ready', 'unknown'],
554
+ // A build waiting on an operator answer can still expire or fail processing.
555
+ 'action-required': ['processing', 'testflight-ready', 'unknown', 'failed'],
554
556
  };
555
557
  return allowed[from]?.includes(to) ?? false;
556
558
  }
@@ -44,6 +44,15 @@ export type AscJsonRunner = (args: readonly string[], timeoutMs: number) => Prom
44
44
  export declare function submitVerifiedIosBuild(input: IosTestFlightSubmissionInput): Promise<IosTestFlightSubmissionResult>;
45
45
  /** Internal mock boundary used by credential-free submission tests. */
46
46
  export declare function submitVerifiedIosBuildWithRunner(input: IosTestFlightSubmissionInput, run: AscJsonRunner): Promise<IosTestFlightSubmissionResult>;
47
+ /**
48
+ * App Store Connect `BuildBetaDetail.internalBuildState`: only
49
+ * READY_FOR_BETA_TESTING and IN_BETA_TESTING let the build reach internal
50
+ * testers. Returns undefined for those, else the status to report.
51
+ */
52
+ export declare function classifyInternalBuildState(state: string): {
53
+ readonly status: Extract<IosTestFlightStatus, 'processing' | 'action-required' | 'failed' | 'unknown'>;
54
+ readonly detail?: string;
55
+ } | undefined;
47
56
  export declare function isolatedAscEnvironment(input: IosTestFlightSubmissionInput, directory: string, source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
48
57
  /** The package consumes one reviewed asc pin; it does not trust PATH or version text. */
49
58
  export declare function verifyPinnedAscBinary(binary: string, pinFile?: string): Promise<void>;
@@ -228,6 +228,32 @@ async function submitVerifiedIosBuildResolved(input, run, { marketingVersion, bu
228
228
  detail: 'App Store Connect returned an unknown processing state.',
229
229
  };
230
230
  }
231
+ // processingState VALID does not mean testers can install the build: its
232
+ // TestFlight internal state can still be processing or waiting for the
233
+ // export-compliance answer (asc `builds build-beta-detail view`).
234
+ let internalState;
235
+ try {
236
+ internalState = readInternalBuildState(await run(['builds', 'build-beta-detail', 'view', '--build-id', build.id], lookupTimeoutMs));
237
+ }
238
+ catch (error) {
239
+ return {
240
+ ...base,
241
+ status: 'unknown',
242
+ ...uploadReference,
243
+ buildId: build.id,
244
+ detail: `Build is processed, but its TestFlight state could not be read${failureReason(error)}.`,
245
+ };
246
+ }
247
+ const blocked = classifyInternalBuildState(internalState);
248
+ if (blocked !== undefined) {
249
+ return {
250
+ ...base,
251
+ status: blocked.status,
252
+ ...uploadReference,
253
+ buildId: build.id,
254
+ ...(blocked.detail === undefined ? {} : { detail: blocked.detail }),
255
+ };
256
+ }
231
257
  try {
232
258
  const added = await run(['builds', 'add-groups', '--build-id', build.id, '--group', input.internalGroupId], lookupTimeoutMs);
233
259
  assertGroupAssignment(added, build.id, input.internalGroupId);
@@ -331,6 +357,49 @@ async function lookupBuild(input, marketingVersion, buildNumber, run) {
331
357
  }
332
358
  return { id: item.id, processingState: item.attributes.processingState };
333
359
  }
360
+ function readInternalBuildState(response) {
361
+ if (!isRecord(response) || !isRecord(response.data)
362
+ || response.data.type !== 'buildBetaDetails'
363
+ || !isRecord(response.data.attributes)
364
+ || typeof response.data.attributes.internalBuildState !== 'string'
365
+ || response.data.attributes.internalBuildState === '') {
366
+ throw new Error('asc builds build-beta-detail view returned an invalid JSON schema.');
367
+ }
368
+ return response.data.attributes.internalBuildState;
369
+ }
370
+ /**
371
+ * App Store Connect `BuildBetaDetail.internalBuildState`: only
372
+ * READY_FOR_BETA_TESTING and IN_BETA_TESTING let the build reach internal
373
+ * testers. Returns undefined for those, else the status to report.
374
+ */
375
+ export function classifyInternalBuildState(state) {
376
+ switch (state) {
377
+ case 'READY_FOR_BETA_TESTING':
378
+ case 'IN_BETA_TESTING':
379
+ return undefined;
380
+ case 'PROCESSING':
381
+ return { status: 'processing' };
382
+ case 'MISSING_EXPORT_COMPLIANCE':
383
+ return {
384
+ status: 'action-required',
385
+ detail: 'TestFlight is waiting for the export-compliance answer: answer it in App Store '
386
+ + 'Connect, or ship ITSAppUsesNonExemptEncryption in Info.plist for later builds.',
387
+ };
388
+ case 'IN_EXPORT_COMPLIANCE_REVIEW':
389
+ return {
390
+ status: 'action-required',
391
+ detail: 'Apple is reviewing the export-compliance documentation.',
392
+ };
393
+ case 'PROCESSING_EXCEPTION':
394
+ case 'EXPIRED':
395
+ return { status: 'failed', detail: `TestFlight reported ${state}.` };
396
+ default:
397
+ return {
398
+ status: 'unknown',
399
+ detail: `TestFlight reported an unknown internal state ${state}.`,
400
+ };
401
+ }
402
+ }
334
403
  function findInternalGroup(response, groupId) {
335
404
  return readDataArray(response, 'TestFlight groups list').some((value) => isRecord(value)
336
405
  && value.type === 'betaGroups' && value.id === groupId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,10 +70,10 @@
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/catalog": "0.10.7",
74
73
  "@mpgd/phaser-assets": "0.9.1",
75
- "@mpgd/target-config": "0.23.7",
76
- "@mpgd/platform": "0.27.2"
74
+ "@mpgd/catalog": "0.11.0",
75
+ "@mpgd/platform": "0.29.0",
76
+ "@mpgd/target-config": "0.23.9"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,18 +81,18 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.20.4",
85
- "@mpgd/adapter-browser": "0.11.7",
86
- "@mpgd/adapter-capacitor": "0.12.4",
87
- "@mpgd/adapter-devvit": "0.13.6",
88
- "@mpgd/adapter-verse8": "0.7.8",
89
- "@mpgd/analytics": "0.5.0",
90
- "@mpgd/bridge": "0.15.0",
91
- "@mpgd/capacitor-play-billing": "0.3.8",
92
- "@mpgd/capacitor-storekit": "0.3.8",
93
- "@mpgd/game-runtime": "0.8.1",
94
- "@mpgd/i18n": "0.6.21",
95
- "@mpgd/game-services": "0.28.0",
84
+ "@mpgd/adapter-ait": "0.20.6",
85
+ "@mpgd/adapter-devvit": "0.14.1",
86
+ "@mpgd/adapter-browser": "0.11.9",
87
+ "@mpgd/adapter-verse8": "0.7.10",
88
+ "@mpgd/adapter-capacitor": "0.13.0",
89
+ "@mpgd/analytics": "0.5.2",
90
+ "@mpgd/bridge": "0.16.1",
91
+ "@mpgd/capacitor-play-billing": "0.3.10",
92
+ "@mpgd/capacitor-storekit": "0.3.10",
93
+ "@mpgd/game-runtime": "0.8.3",
94
+ "@mpgd/game-services": "0.29.0",
95
+ "@mpgd/i18n": "0.6.23",
96
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
@@ -11,7 +11,7 @@ Preserve the generated JSON and Markdown in `artifacts/acceptance` or the target
11
11
  - Verify renderer teardown removes its own listeners and never updates a replacement view from an old promise.
12
12
  - Purchases and rewarded-ad callbacks never grant locally; verify authoritative ledger grants and durable reconciliation before release.
13
13
  - Keep unknown native outcomes and late recovery observers; closing one lease must not release another owner's presentation or audio.
14
- - Verify the game sets its real server session with `setStarterServerSession` and does not rely on the development fallback (read only with `--providers notifications`): `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only (its `mpgd-dev` header needs the Vite dev server, so a `pnpm build` never signs in with it), and deployed acceptance uses the identity service's sessions.
14
+ - Verify the game sets its real server session with `setStarterServerSession` (the starter's `guestIdentity.ts` with `VITE_MPGD_IDENTITY_URL`), that store purchases bind to the server player rather than the native shell's local one, and that it does not rely on the development fallback (read only with `--providers notifications`): `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only (its `mpgd-dev` header needs the Vite dev server, so a `pnpm build` never signs in with it), and deployed acceptance uses the identity service's sessions.
15
15
 
16
16
  ## Reddit Devvit
17
17
 
@@ -11,7 +11,7 @@ Read `AGENTS.md`, the agent manifest, and the `use-mpgd-kit` router before editi
11
11
  - Keep recovery above renderer lifetimes; reconcile existing operations on startup and resume without opening SDK UI.
12
12
  - Rewarded v2 needs an application-owned durable encrypted journal. An in-memory store is not recovery evidence.
13
13
  - Reserve safe-area geometry once and preserve feed scrolling and browser touch gestures.
14
- - Set the server session with `setStarterServerSession` in `src/main.ts` before `bootstrap()` (a guest backend through `createGuestServerSession`). In a game created with `--providers notifications`, `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite dev server in the `development` mode is a local-only development session for notifications (`src/platform/developmentServerSession.ts`); it never reaches a build, commerce, or a deployed Worker.
14
+ - Set `VITE_MPGD_IDENTITY_URL` to the guest identity Worker: the shared bootstrap (`src/main.ts` and `src/platform/guestIdentity.ts`, the same files as the Phaser template) signs in a server-issued guest during bootstrap and calls `setStarterServerSession` with `createGuestServerSession`, and store purchases bind to that server player. A game with its own identity service calls `setStarterServerSession` before the game services are created instead. In a game created with `--providers notifications`, `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite dev server in the `development` mode is a local-only development session for notifications (`src/platform/developmentServerSession.ts`); it never reaches a build, commerce, or a deployed Worker.
15
15
 
16
16
  Run `pnpm dev`, `pnpm check`, `pnpm build`, and `pnpm accept`.
17
17
  Devvit inline and expanded entries both use the lite renderer. Keep optional engines behind explicit dynamic import boundaries and verify the inline budget.
@@ -0,0 +1,173 @@
1
+ import { t } from '../i18n/messages';
2
+ import {
3
+ createStarterAccountDeletion,
4
+ deleteAccountConfirmText,
5
+ deleteAccountOutcomeText,
6
+ starterAccountDeletionConfirmation,
7
+ type StarterDeleteAccountOutcome,
8
+ } from '../platform/deleteAccount';
9
+ import { resolveStarterServerSession } from '../platform/serverSession';
10
+ import type { StarterContext } from '../runtime/gameContext';
11
+
12
+ /**
13
+ * Delete account for the lite template: a button over the game, shown where
14
+ * the game has a game-services backend (App Store guideline 5.1.1(v) and
15
+ * Google Play require deletion in the app). The first tap opens a panel that
16
+ * says what is deleted and kept; only "Delete permanently" deletes
17
+ * (`deleteAccount.ts`). Returns the disposer.
18
+ */
19
+ export function mountDeleteAccountButton(mount: HTMLElement, context: StarterContext): () => void {
20
+ // No backend yet: read no session, which `once` would pin. A run whose guest sign-in
21
+ // failed at boot gets the backend after a later sign-in (`onReady`); mount then.
22
+ if (context.gameServices.accountDeletion === undefined) {
23
+ const onReady = (context.gameServices as { readonly onReady?: (listener: () => void) => () => void })
24
+ .onReady;
25
+ if (onReady === undefined) {
26
+ return () => undefined;
27
+ }
28
+ let dispose = (): void => undefined;
29
+ const stop = onReady(() => {
30
+ stop();
31
+ dispose = mountDeleteAccountButton(mount, context);
32
+ });
33
+ return () => {
34
+ stop();
35
+ dispose();
36
+ };
37
+ }
38
+ // The commerce client's session (`once`), so the button and the request agree.
39
+ const session = resolveStarterServerSession({ once: true });
40
+ const confirmation = starterAccountDeletionConfirmation(session);
41
+ const deletion = createStarterAccountDeletion({
42
+ ...(context.gameServices.accountDeletion === undefined
43
+ ? {}
44
+ : { client: context.gameServices.accountDeletion }),
45
+ storage: context.platform.storage,
46
+ ...(context.platform.secureCredentials === undefined
47
+ ? {}
48
+ : { secureCredentials: context.platform.secureCredentials }),
49
+ signedIn: () => session?.current() !== undefined,
50
+ // The identity service's confirmation, so an access token alone cannot delete the account.
51
+ ...(confirmation === undefined ? {} : { requestConfirmation: confirmation }),
52
+ });
53
+ if (!deletion.available) {
54
+ if (context.gameServices.accountDeletion === undefined || session === undefined) {
55
+ return () => undefined;
56
+ }
57
+ // Signed in later (a guest session starts in the background): mount then.
58
+ let dispose = (): void => undefined;
59
+ const stop = session.onAuthenticated(() => {
60
+ stop();
61
+ dispose = mountDeleteAccountButton(mount, context);
62
+ });
63
+ return () => {
64
+ stop();
65
+ dispose();
66
+ };
67
+ }
68
+ const locale = context.locale;
69
+ const previousPosition = mount.style.position;
70
+ if (getComputedStyle(mount).position === 'static') {
71
+ mount.style.position = 'relative';
72
+ }
73
+ const open = document.createElement('button');
74
+ open.type = 'button';
75
+ open.textContent = t(locale, 'deleteAccount');
76
+ open.style.cssText = 'position:absolute;top:8px;right:8px;padding:6px 10px;';
77
+ const panel = document.createElement('div');
78
+ panel.setAttribute('role', 'dialog');
79
+ panel.setAttribute('aria-modal', 'true');
80
+ panel.setAttribute('aria-labelledby', 'mpgd-delete-account-message');
81
+ // Shown by display, not `hidden`: an inline display would override it.
82
+ panel.style.cssText = 'position:absolute;inset:0;display:none;flex-direction:column;'
83
+ + 'align-items:center;justify-content:center;gap:16px;padding:24px;box-sizing:border-box;'
84
+ + 'background:#020617eb;color:#fff;font:15px system-ui,sans-serif;text-align:center;';
85
+ const message = document.createElement('p');
86
+ message.id = 'mpgd-delete-account-message';
87
+ message.style.cssText = 'margin:0;max-width:640px;white-space:pre-line;';
88
+ const status = document.createElement('p');
89
+ status.setAttribute('role', 'status');
90
+ status.setAttribute('aria-live', 'polite');
91
+ status.style.cssText = 'margin:0;min-height:1.4em;';
92
+ const confirm = document.createElement('button');
93
+ confirm.type = 'button';
94
+ confirm.textContent = t(locale, 'deleteAccountConfirmButton');
95
+ confirm.style.cssText = 'padding:8px 14px;background:#b91c1c;color:#fff;border:0;';
96
+ const cancel = document.createElement('button');
97
+ cancel.type = 'button';
98
+ cancel.textContent = t(locale, 'deleteAccountCancel');
99
+ cancel.style.cssText = 'padding:8px 14px;';
100
+ const actions = document.createElement('div');
101
+ actions.style.cssText = 'display:flex;gap:12px;';
102
+ actions.append(confirm, cancel);
103
+ panel.append(message, actions, status);
104
+ // Taps on the controls stay out of the game's own tap handling.
105
+ for (const element of [open, panel]) {
106
+ element.addEventListener('pointerdown', (event) => event.stopPropagation());
107
+ }
108
+ let deleting = false;
109
+ let signedOut = false;
110
+ const close = (): void => {
111
+ if (deleting) {
112
+ return;
113
+ }
114
+ if (signedOut) {
115
+ // A fresh start drops every in-memory trace of the deleted player.
116
+ globalThis.location.reload();
117
+ return;
118
+ }
119
+ panel.style.display = 'none';
120
+ open.focus();
121
+ };
122
+ panel.addEventListener('keydown', (event) => {
123
+ if (event.key === 'Escape') {
124
+ close();
125
+ }
126
+ });
127
+ open.addEventListener('click', () => {
128
+ if (deleting) {
129
+ return;
130
+ }
131
+ status.textContent = '';
132
+ confirm.hidden = false;
133
+ cancel.textContent = t(locale, 'deleteAccountCancel');
134
+ message.textContent = deleteAccountConfirmText(locale, false);
135
+ panel.style.display = 'flex';
136
+ cancel.focus();
137
+ void deletion.hasPendingPurchase().then((pending) => {
138
+ if (pending) {
139
+ message.textContent = deleteAccountConfirmText(locale, true);
140
+ }
141
+ });
142
+ });
143
+ cancel.addEventListener('click', close);
144
+ confirm.addEventListener('click', () => {
145
+ if (deleting) {
146
+ return;
147
+ }
148
+ deleting = true;
149
+ confirm.setAttribute('aria-disabled', 'true');
150
+ status.textContent = t(locale, 'deleteAccountWorking');
151
+ void deletion.deleteAccount()
152
+ .catch((): StarterDeleteAccountOutcome => ({ outcome: 'failed', retryable: true }))
153
+ .then((result) => {
154
+ deleting = false;
155
+ confirm.removeAttribute('aria-disabled');
156
+ status.textContent = deleteAccountOutcomeText(locale, result);
157
+ if (result.outcome === 'deleted' || result.outcome === 'session-ended') {
158
+ // Signed out: Close restarts the game.
159
+ signedOut = true;
160
+ confirm.hidden = true;
161
+ open.hidden = true;
162
+ cancel.textContent = t(locale, 'deleteAccountClose');
163
+ cancel.focus();
164
+ }
165
+ });
166
+ });
167
+ mount.append(open, panel);
168
+ return () => {
169
+ open.remove();
170
+ panel.remove();
171
+ mount.style.position = previousPosition;
172
+ };
173
+ }
@@ -0,0 +1,63 @@
1
+ import { t } from '../i18n/messages';
2
+ import {
3
+ createStarterRestorePurchases,
4
+ restoreOutcomeText,
5
+ type StarterRestoreOutcome,
6
+ } from '../platform/restorePurchases';
7
+ import type { StarterContext } from '../runtime/gameContext';
8
+
9
+ /**
10
+ * Restore Purchases for the lite template: a button over the game, shown
11
+ * where the target sells through a store (App Review expects it while the
12
+ * catalog sells a non-consumable such as REMOVE_ADS). The grant still comes
13
+ * only from the backend ledger (`restorePurchases.ts`). Returns the disposer.
14
+ */
15
+ export function mountRestorePurchasesButton(mount: HTMLElement, context: StarterContext): () => void {
16
+ const services = context.gameServices;
17
+ const restore = createStarterRestorePurchases({
18
+ gateway: context.platform,
19
+ // Read at tap time: a run that signs in late gets its recovery client then.
20
+ monetizationRecovery: () => services.monetizationRecovery,
21
+ ...(services.ensureSession === undefined ? {} : { ensureSession: services.ensureSession }),
22
+ });
23
+ if (!restore.available) {
24
+ return () => undefined;
25
+ }
26
+ const previousPosition = mount.style.position;
27
+ if (getComputedStyle(mount).position === 'static') {
28
+ mount.style.position = 'relative';
29
+ }
30
+ const button = document.createElement('button');
31
+ button.type = 'button';
32
+ button.textContent = t(context.locale, 'restorePurchases');
33
+ button.style.cssText = 'position:absolute;bottom:8px;left:8px;padding:8px 12px;';
34
+ const status = document.createElement('p');
35
+ status.setAttribute('role', 'status');
36
+ status.style.cssText = 'position:absolute;bottom:44px;left:8px;margin:0;padding:4px 8px;'
37
+ + 'color:#d6dee8;font:13px system-ui,sans-serif;border-radius:6px;pointer-events:none;';
38
+ status.setAttribute('aria-live', 'polite');
39
+ // In the accessibility tree from the start (empty), so its updates are announced.
40
+ const show = (text: string): void => {
41
+ status.textContent = text;
42
+ status.style.background = '#0b1726cc';
43
+ };
44
+ // Taps on the button stay out of the game's own tap handling.
45
+ button.addEventListener('pointerdown', (event) => event.stopPropagation());
46
+ button.addEventListener('click', () => {
47
+ // aria-disabled keeps keyboard focus; a second tap joins the running restore.
48
+ button.setAttribute('aria-disabled', 'true');
49
+ show(t(context.locale, 'restoreWorking'));
50
+ void restore.restore()
51
+ .catch((): StarterRestoreOutcome => ({ outcome: 'failed' }))
52
+ .then((result) => {
53
+ button.removeAttribute('aria-disabled');
54
+ show(restoreOutcomeText(context.locale, result));
55
+ });
56
+ });
57
+ mount.append(button, status);
58
+ return () => {
59
+ button.remove();
60
+ status.remove();
61
+ mount.style.position = previousPosition;
62
+ };
63
+ }
@@ -1,6 +1,8 @@
1
1
  import { bindGameAudio } from '@mpgd/game-runtime/audio';
2
2
  import type { CanvasSurface, LiteShell } from '@mpgd/lite-shell';
3
3
 
4
+ import { mountDeleteAccountButton } from '../lite/deleteAccountButton';
5
+ import { mountRestorePurchasesButton } from '../lite/restorePurchasesButton';
4
6
  import { createTapGame } from '../lite/tapGame';
5
7
  import type { StarterContext } from './gameContext';
6
8
 
@@ -29,6 +31,9 @@ export function createStarterGame(input: {
29
31
  console.error('[game-audio]', error);
30
32
  },
31
33
  });
34
+ // Mounted before the game so later overlays (such as a settings dialog) stack above it.
35
+ const disposeRestorePurchases = mountRestorePurchasesButton(mount, input.context);
36
+ const disposeDeleteAccount = mountDeleteAccountButton(mount, input.context);
32
37
  const shell = createTapGame({ mount, title: '__GAME_TITLE__' });
33
38
  let disposed = false;
34
39
  const dispose = (): void => {
@@ -39,6 +44,9 @@ export function createStarterGame(input: {
39
44
  globalThis.removeEventListener('pagehide', onPageHide);
40
45
  audio.dispose();
41
46
  input.context.gameRuntime.dispose();
47
+ // In reverse order of mounting: each restores the mount's position it saw.
48
+ disposeDeleteAccount();
49
+ disposeRestorePurchases();
42
50
  shell.dispose();
43
51
  };
44
52
  const onPageHide = (event: PageTransitionEvent): void => {
@@ -40,12 +40,18 @@ left for you.
40
40
  `mpgd target init capacitor --providers notifications` to install the two
41
41
  plugins into the game-owned shell. Never install
42
42
  `@capacitor/push-notifications` next to them.
43
- 6. Advertising on Android: the sample `events` topic is mapped to iOS only.
44
- An Android FCM notification cannot carry the opt-out action that the
45
- Korean rules require on every advertising push (a settings path in the
46
- text does not replace it), and the kit does not render Android
47
- advertising natively yet. Do not add an `android` mapping to an
48
- advertising topic until it does.
43
+ 6. Advertising on Android: an Android FCM notification cannot carry the
44
+ opt-out action that the Korean rules require on every advertising push
45
+ (a settings path in the text does not replace it), so the shell renders
46
+ advertising pushes itself. `mpgd target init capacitor --providers
47
+ notifications` declares the kit's renderer
48
+ (`dev.mpgd.capacitor.AdvertisingMessagingService`) in a newly generated
49
+ Android project; for an existing one it prints the manifest lines to add.
50
+ `notifications/deployment.json` sets `shellRendersAdvertising: true`,
51
+ which lets the sample `events` topic map to `android`. The backend sends
52
+ advertising only to an Android installation whose shell registered the
53
+ renderer, so an older build of your app receives none. Remove the
54
+ `android` mapping (and the setting) if you ship a shell without it.
49
55
  7. Apps in Toss: register each functional-message template in the console and
50
56
  add its code to the topic's `targets.ait` mapping
51
57
  (`{ "templateSetCode": "...", "agreement": "required" }`). Apps in Toss
@@ -104,6 +110,22 @@ game's notifications backend is its Devvit server, which keeps the opt-in,
104
110
  the records, and the sends, and the Worker serves no `reddit` target. The
105
111
  Worker serves the Android, iOS, and Apps in Toss providers.
106
112
 
113
+ On Reddit the runtime runs over the Devvit server instead:
114
+ `src/platform/buildGateways/reddit.ts` sets
115
+ `createDevvitNotificationsBackend` as the hosted backend
116
+ (`setStarterHostedNotificationBackend`), and the generated Devvit server
117
+ serves it with `createDevvitNotificationsBackendBridgeHandler`. No session
118
+ is needed: the server answers for the logged-in Reddit user, and a
119
+ logged-out player is asked to log in first. Once Reddit approves the app
120
+ and `integrations.notifications` is `available`, the settings screen shows
121
+ the reminder switches there; advertising and night consent stay off,
122
+ because Devvit delivers `service` and `requested` topics only. A reminder
123
+ the game schedules on Reddit (`runtime.reminders.set`) needs a post or
124
+ comment to open: pass `link` (the post id, `context.postId` from
125
+ `@devvit/web/client`), or give the topic's `reddit` mapping a default
126
+ `link`; the `mpgd-notifications-dispatch` task sends it through Devvit when
127
+ it is due.
128
+
107
129
  The settings screen shows each
108
130
  switch from the latest record, read every time it opens and after every
109
131
  change, so a choice made on another device shows. A tap reads the records
@@ -4,5 +4,6 @@
4
4
  "name": "__GAME_TITLE__",
5
5
  "contact": "REPLACE-WITH-SENDER-PHONE-OR-ADDRESS"
6
6
  },
7
- "timeZone": "Asia/Seoul"
7
+ "timeZone": "Asia/Seoul",
8
+ "shellRendersAdvertising": true
8
9
  }
@@ -201,6 +201,9 @@
201
201
  "maxPerDay": 1
202
202
  },
203
203
  "targets": {
204
+ "android": {
205
+ "channelId": "mpgd-advertising"
206
+ },
204
207
  "ios": {}
205
208
  }
206
209
  },