@mpgd/cli 0.57.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 (46) 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 +1663 -899
  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/release-state.d.ts +2 -0
  12. package/dist/release-state.js +4 -2
  13. package/dist/testflight-submission.d.ts +9 -0
  14. package/dist/testflight-submission.js +69 -0
  15. package/package.json +17 -17
  16. package/templates/lite-overlay/agent/acceptance.md +1 -1
  17. package/templates/lite-overlay/agent/brief.md +1 -1
  18. package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
  19. package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
  20. package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
  21. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +12 -6
  22. package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
  23. package/templates/notifications-overlay/common/notifications/topics.json +3 -0
  24. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
  25. package/templates/notifications-overlay/common/src/platform/notifications.ts +17 -3
  26. package/templates/phaser-game/README.md +72 -0
  27. package/templates/phaser-game/agent/acceptance.md +4 -1
  28. package/templates/phaser-game/agent/brief.md +14 -4
  29. package/templates/phaser-game/gitignore +11 -0
  30. package/templates/phaser-game/legal/delete-account.html +133 -0
  31. package/templates/phaser-game/legal/privacy.html +111 -12
  32. package/templates/phaser-game/legal/support.html +1 -0
  33. package/templates/phaser-game/legal/terms.html +1 -0
  34. package/templates/phaser-game/mpgd.game.json +3 -0
  35. package/templates/phaser-game/src/env.d.ts +6 -0
  36. package/templates/phaser-game/src/i18n/messages.ts +86 -1
  37. package/templates/phaser-game/src/main.ts +69 -8
  38. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
  39. package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
  40. package/templates/phaser-game/src/platform/gameServices.ts +55 -10
  41. package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
  42. package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
  43. package/templates/phaser-game/src/platform/serverSession.ts +8 -0
  44. package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
  45. package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
  46. package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
@@ -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.57.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.8",
74
- "@mpgd/platform": "0.28.0",
75
73
  "@mpgd/phaser-assets": "0.9.1",
76
- "@mpgd/target-config": "0.23.8"
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,19 +81,19 @@
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.5",
85
- "@mpgd/adapter-browser": "0.11.8",
86
- "@mpgd/adapter-capacitor": "0.12.5",
87
- "@mpgd/adapter-devvit": "0.14.0",
88
- "@mpgd/adapter-verse8": "0.7.9",
89
- "@mpgd/capacitor-play-billing": "0.3.9",
90
- "@mpgd/game-runtime": "0.8.2",
91
- "@mpgd/bridge": "0.16.0",
92
- "@mpgd/capacitor-storekit": "0.3.9",
93
- "@mpgd/analytics": "0.5.1",
94
- "@mpgd/game-services": "0.28.1",
95
- "@mpgd/lite-shell": "0.1.0",
96
- "@mpgd/i18n": "0.6.22"
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
+ "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
99
99
  "types": "./dist/index.d.ts",
@@ -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
@@ -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
  },
@@ -3,6 +3,7 @@ import {
3
3
  createCapacitorNotificationServiceProvider,
4
4
  loadCapacitorNotificationSdks,
5
5
  openCapacitorNotificationSettings,
6
+ readCapacitorAdvertisingPushRenderer,
6
7
  } from '@mpgd/adapter-capacitor/notifications';
7
8
 
8
9
  import { starterNotificationTopics } from './notifications';
@@ -38,9 +39,8 @@ export async function createStarterCapacitorNotifications(
38
39
  ...sdks,
39
40
  // Android channel names the player sees in system settings, in the
40
41
  // device language. The provider creates a channel only for the topics
41
- // mapped to Android; `mpgd-advertising` is named for the day an
42
- // advertising topic is mapped there, in a channel of its own the
43
- // player can turn off.
42
+ // mapped to Android; advertising has `mpgd-advertising`, a channel of
43
+ // its own the player can turn off.
44
44
  channels: korean
45
45
  ? {
46
46
  'mpgd-service': { name: '알림' },
@@ -54,6 +54,11 @@ export async function createStarterCapacitorNotifications(
54
54
  },
55
55
  advertisingOptOutTitle: korean ? '수신거부' : 'Unsubscribe',
56
56
  openSettings: openCapacitorNotificationSettings,
57
+ // Android: the shell renders advertising pushes with the opt-out
58
+ // action when its messaging service is the kit's renderer, which
59
+ // `mpgd target init capacitor --providers notifications` declares; the
60
+ // backend sends advertising only to such an installation.
61
+ advertisingRenderer: readCapacitorAdvertisingPushRenderer,
57
62
  }),
58
63
  ];
59
64
  }
@@ -9,6 +9,7 @@ import {
9
9
  resolveStarterHostedNotificationBackend,
10
10
  type StarterNotificationBackend,
11
11
  } from './notificationBackend';
12
+ import { onStarterAccountDeleted } from './deleteAccount';
12
13
  import { resolveStarterServerSession } from './serverSession';
13
14
  import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
14
15
  import {
@@ -25,9 +26,13 @@ import {
25
26
  * The starter topics' purposes follow the notifications RFC (open question 1):
26
27
  * `daily-ready` and `streak-at-risk` are `requested` reminders the player
27
28
  * turns on, `friend-challenge` is `service`, and `events` is the sample
28
- * `advertising` topic, mapped to iOS only: an Android FCM push cannot carry
29
- * the opt-out action the Korean rules require yet. A topic whose copy offers a reward, a benefit, or a
30
- * promotion is advertising: declare it so, never `requested`.
29
+ * `advertising` topic. On Android an FCM notification cannot carry the
30
+ * opt-out action the Korean rules require, so the shell renders advertising
31
+ * pushes itself (`mpgd target init capacitor --providers notifications`
32
+ * declares the kit's renderer, and `notifications/deployment.json` sets
33
+ * `shellRendersAdvertising`); the backend sends one only to an installation
34
+ * whose shell has the renderer. A topic whose copy offers a reward, a
35
+ * benefit, or a promotion is advertising: declare it so, never `requested`.
31
36
  */
32
37
  export const starterNotificationCatalog = notificationTopicsJson as NotificationTopicCatalog;
33
38
 
@@ -129,6 +134,15 @@ function starterNotificationBackend(platform: TargetConfiguredGateway): StarterN
129
134
  backend = createStarterNotificationBackend(platform);
130
135
  if (backend !== undefined) {
131
136
  backends.set(platform, backend);
137
+ const { runtime } = backend;
138
+ // After an account deletion the device stops registering for the
139
+ // deleted player (the server already removed its endpoints), and the
140
+ // next session gets a fresh runtime.
141
+ const stop = onStarterAccountDeleted(async () => {
142
+ stop();
143
+ backends.delete(platform);
144
+ await runtime.unregister();
145
+ });
132
146
  }
133
147
  }
134
148
  return backend;