@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.
- package/dist/capacitor-shell-starter.d.ts +30 -0
- package/dist/capacitor-shell-starter.js +243 -11
- package/dist/index.js +170 -11
- package/dist/legal-site-output.d.ts +18 -0
- package/dist/legal-site-output.js +46 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +3935 -1308
- package/dist/native-deploy-run.js +4 -0
- package/dist/native-shell-store-policy.d.ts +123 -0
- package/dist/native-shell-store-policy.js +816 -0
- package/dist/notifications-starter.js +26 -3
- package/dist/release-state.d.ts +2 -0
- package/dist/release-state.js +4 -2
- package/dist/testflight-submission.d.ts +9 -0
- package/dist/testflight-submission.js +69 -0
- package/package.json +16 -16
- package/templates/lite-overlay/agent/acceptance.md +1 -1
- package/templates/lite-overlay/agent/brief.md +1 -1
- package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
- package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
- package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +28 -6
- package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
- package/templates/notifications-overlay/common/notifications/topics.json +3 -0
- package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +111 -2
- package/templates/notifications-overlay/common/src/platform/notifications.ts +36 -4
- package/templates/phaser-game/README.md +72 -0
- package/templates/phaser-game/agent/acceptance.md +4 -1
- package/templates/phaser-game/agent/brief.md +14 -4
- package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +37 -0
- package/templates/phaser-game/gitignore +11 -0
- package/templates/phaser-game/legal/delete-account.html +133 -0
- package/templates/phaser-game/legal/privacy.html +111 -12
- package/templates/phaser-game/legal/support.html +1 -0
- package/templates/phaser-game/legal/terms.html +1 -0
- package/templates/phaser-game/mpgd.game.json +3 -0
- package/templates/phaser-game/src/env.d.ts +6 -0
- package/templates/phaser-game/src/i18n/messages.ts +86 -1
- package/templates/phaser-game/src/main.ts +69 -8
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
- package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
- package/templates/phaser-game/src/platform/gameServices.ts +55 -10
- package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
- package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
- package/templates/phaser-game/src/platform/serverSession.ts +8 -0
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
- package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
- 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
|
-
'
|
|
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:
|
|
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():
|
|
256
|
+
"function createNotificationService(): ReturnType<typeof createDevvitNotificationService>['service'] | undefined {",
|
|
234
257
|
' try {',
|
|
235
258
|
' return createDevvitNotificationService({',
|
|
236
259
|
' redis,',
|
package/dist/release-state.d.ts
CHANGED
|
@@ -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;
|
package/dist/release-state.js
CHANGED
|
@@ -524,7 +524,8 @@ export async function releaseNativeSubmissionLease(input) {
|
|
|
524
524
|
return checkpoint;
|
|
525
525
|
});
|
|
526
526
|
}
|
|
527
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
76
|
-
"@mpgd/platform": "0.
|
|
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.
|
|
85
|
-
"@mpgd/adapter-
|
|
86
|
-
"@mpgd/adapter-
|
|
87
|
-
"@mpgd/adapter-
|
|
88
|
-
"@mpgd/adapter-
|
|
89
|
-
"@mpgd/analytics": "0.5.
|
|
90
|
-
"@mpgd/bridge": "0.
|
|
91
|
-
"@mpgd/capacitor-play-billing": "0.3.
|
|
92
|
-
"@mpgd/capacitor-storekit": "0.3.
|
|
93
|
-
"@mpgd/game-runtime": "0.8.
|
|
94
|
-
"@mpgd/
|
|
95
|
-
"@mpgd/
|
|
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
|
|
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:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|