@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
@@ -17,7 +17,13 @@ import { t } from './i18n/messages';
17
17
  import { createClientId } from './runtime/id';
18
18
  import { createStarterGame } from './runtime/createGame';
19
19
  import { detectRuntime } from './platform/runtimeDetector';
20
- import { createStarterGameServices, type StarterMonetizationPorts } from './platform/gameServices';
20
+ import {
21
+ createStarterGameServices,
22
+ createStarterSessionGatedGameServices,
23
+ type StarterMonetizationPorts,
24
+ } from './platform/gameServices';
25
+ import { createStarterGuestSignIn } from './platform/guestIdentity';
26
+ import { setStarterServerSession } from './platform/serverSession';
21
27
  import { installPlatform } from './platform/installPlatform';
22
28
  import { installMicrosoftStorePwa } from './platform/microsoftStorePwa';
23
29
 
@@ -28,6 +34,7 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
28
34
  let gameRuntime: GamePlatformRuntime | undefined;
29
35
  let locale: Locale = 'en';
30
36
  let disposeMicrosoftStorePwa: (() => void) | undefined;
37
+ let disposeGuestSignIn: (() => void) | undefined;
31
38
 
32
39
  try {
33
40
  if (__APP_TARGET__ === 'ait') {
@@ -53,15 +60,45 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
53
60
  runtime: runtime.config.runtime,
54
61
  orientationPolicy,
55
62
  });
56
- const player =
63
+ const platformPlayer =
57
64
  (await platform.identity.getPlayer()) ?? {
58
65
  playerId: 'local-player',
59
66
  displayName: 'Local Player',
60
67
  };
61
- const [identitySession, launchIntent] = await Promise.all([
62
- resolveIdentitySession(platform, player.playerId),
68
+ // The server-issued guest (VITE_MPGD_IDENTITY_URL): tried at boot, and when that fails,
69
+ // again in the background and before any purchase or restore (`guestIdentity.ts`).
70
+ const guestSignIn = createStarterGuestSignIn({
71
+ identityUrl: import.meta.env.VITE_MPGD_IDENTITY_URL,
72
+ credentials: platform.secureCredentials,
73
+ });
74
+ disposeGuestSignIn = () => guestSignIn.dispose();
75
+ const [bootSignIn, platformSession, launchIntent] = await Promise.all([
76
+ guestSignIn.ensure(),
77
+ resolveIdentitySession(platform, platformPlayer.playerId),
63
78
  resolveLaunchIntent(platform),
64
79
  ]);
80
+ // A background retry may have won while the other boot reads were pending.
81
+ const guestIdentity = guestSignIn.current() ?? bootSignIn;
82
+ // Before the game services read the session: at once, or when a later attempt succeeds
83
+ // (notifications then come up on their next open; the session-gated services read it).
84
+ if (guestIdentity !== undefined) {
85
+ setStarterServerSession(guestIdentity.serverSession);
86
+ } else {
87
+ guestSignIn.onSignedIn((later) => setStarterServerSession(later.serverSession));
88
+ }
89
+ // Signed in at boot, the server player replaces the platform's (on the native shell a local
90
+ // placeholder) everywhere. Signed in later, the scenes keep this run's boot player (see
91
+ // README, "Guest identity"); only the purchase and notification services use the server player.
92
+ const player = guestIdentity !== undefined
93
+ ? { ...platformPlayer, playerId: guestIdentity.playerId }
94
+ : platformPlayer;
95
+ const identitySession: IdentitySession = guestIdentity !== undefined
96
+ ? {
97
+ identityLevel: guestIdentity.identityLevel,
98
+ playerId: guestIdentity.playerId,
99
+ trustLevel: 'server-verified',
100
+ }
101
+ : platformSession;
65
102
  locale = resolveTargetMpgdLocale({
66
103
  capabilities: runtime.capabilities,
67
104
  ...(platform.gameSettings?.getLocale === undefined ? {} : { preferredLocales: [platform.gameSettings.getLocale()] }),
@@ -76,10 +113,16 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
76
113
  sessionId: analyticsSessionId,
77
114
  sink: analyticsSink,
78
115
  });
116
+ const sessionGated = guestIdentity === undefined && guestSignIn.configured;
79
117
  const ownedRuntime = createGamePlatformRuntime({
80
118
  gateway: platform,
81
119
  initialLifecycleState: document.visibilityState === 'hidden' ? 'inactive' : 'active',
82
120
  canShow: (placement) => {
121
+ // A run that booted before its sign-in shows no rewarded ad: the runtime's late
122
+ // reward evidence recovery is bound at boot, which had no recovery client.
123
+ if (placement.format === 'rewarded' && sessionGated) {
124
+ return false;
125
+ }
83
126
  const enabled = placement.format === 'rewarded'
84
127
  ? runtime.config.features.rewardedAds
85
128
  : runtime.config.features.interstitialAds;
@@ -100,15 +143,32 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
100
143
  onObserverError: (error) => {
101
144
  console.error('[game-runtime]', error);
102
145
  },
103
- createServices: (gateway) =>
104
- createStarterGameServices({
146
+ createServices: (gateway) => {
147
+ const servicesInput = {
105
148
  gateway,
106
- playerId: identitySession.playerId ?? player.playerId,
107
149
  configTarget: runtime.configTarget,
108
150
  ...(options.monetization === undefined ? {} : { operationStore: options.monetization.operationStore }),
109
151
  analytics: analyticsSink,
110
152
  analyticsSessionId,
111
- }),
153
+ };
154
+ // Configured but not signed in yet: the services wait for the server player, never
155
+ // binding purchases or the journal to the platform's local placeholder.
156
+ return sessionGated
157
+ ? createStarterSessionGatedGameServices({
158
+ ...servicesInput,
159
+ signIn: guestSignIn,
160
+ // The startup recovery the boot ran without a player, with the action coordinator's.
161
+ onActivated: () => {
162
+ void gameRuntime?.reconcile().catch((error: unknown) => {
163
+ console.error('[game-services] recovery after sign-in failed', error);
164
+ });
165
+ },
166
+ })
167
+ : createStarterGameServices({
168
+ ...servicesInput,
169
+ playerId: identitySession.playerId ?? player.playerId,
170
+ });
171
+ },
112
172
  });
113
173
  gameRuntime = ownedRuntime;
114
174
  const gameServices = ownedRuntime.services;
@@ -149,6 +209,7 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
149
209
  });
150
210
  } catch (error) {
151
211
  gameRuntime?.dispose();
212
+ disposeGuestSignIn?.();
152
213
  disposeMicrosoftStorePwa?.();
153
214
  renderBootstrapError(error, locale);
154
215
  console.error('[bootstrap]', error);
@@ -8,23 +8,44 @@ import type { StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
8
8
  * (`resolveObfuscatedAccountId` of the Play verifier,
9
9
  * `resolveAppAccountToken` of the App Store verifier), which is how the
10
10
  * backend ties a store purchase to the player before it grants. This module
11
- * derives both from a SHA-256 digest of the authenticated player id; a game
12
- * with its own account scheme replaces these two functions and the backend
11
+ * derives both from a SHA-256 digest of the server player id (the reference
12
+ * identity Worker, apps/game-identity-worker, derives the same); a game with
13
+ * its own account scheme replaces these two functions and the backend
13
14
  * resolvers together. Nothing here grants anything.
14
15
  */
15
16
  export const starterAccountBindingDigestPrefix = 'mpgd-commerce-account:';
16
17
 
18
+ /** The game's server player source; see `setStarterCommerceServerPlayer`. */
19
+ let serverPlayer: (() => string | undefined) | undefined;
20
+
21
+ /**
22
+ * Binds store purchases to the player of the game's server session (the
23
+ * identity service's player, a server-issued guest included) instead of the
24
+ * platform session. A generated game's `guestIdentity.ts` sets it when
25
+ * `VITE_MPGD_IDENTITY_URL` is configured; `undefined` restores the platform
26
+ * session. While it is set, no server player (not signed in yet, or the
27
+ * sign-in failed) binds nothing: the platform's local player never stands in.
28
+ */
29
+ export function setStarterCommerceServerPlayer(source: (() => string | undefined) | undefined): void {
30
+ serverPlayer = source;
31
+ }
32
+
17
33
  /**
18
- * The authenticated player id a purchase is bound to. Only an
19
- * `authenticated` session whose trust is not `local` binds: a guest, a
20
- * platform-anonymous session (the native shell's default
21
- * `android-local-player` / `ios-local-player`), or a locally asserted id
22
- * would give every install the same binding, and the backend binds the
23
- * purchase to its own authenticated player id anyway.
34
+ * The player id a purchase is bound to: the server session's player when
35
+ * the game set one (`setStarterCommerceServerPlayer`), else an
36
+ * `authenticated` platform session whose trust is not `local`. A
37
+ * platform-anonymous session (the native shell's `android-local-player` /
38
+ * `ios-local-player`) or a locally asserted id would give every install the
39
+ * same binding, so it binds nothing; the backend binds the purchase to the
40
+ * player its ingress authentication resolved anyway.
24
41
  */
25
42
  export async function resolveStarterCommercePlayerId(
26
43
  gateway: Pick<PlatformGateway, 'identity'>,
27
44
  ): Promise<string | undefined> {
45
+ if (serverPlayer !== undefined) {
46
+ const playerId = serverPlayer()?.trim();
47
+ return playerId === undefined || playerId.length === 0 ? undefined : playerId;
48
+ }
28
49
  let session: IdentitySession | undefined;
29
50
  try {
30
51
  session = await gateway.identity.getSession?.();
@@ -38,7 +59,11 @@ export async function resolveStarterCommercePlayerId(
38
59
  return playerId === undefined || playerId.length === 0 ? undefined : playerId;
39
60
  }
40
61
 
41
- /** Google Play `obfuscatedAccountId`: 64 hex characters, within Play's 64-character limit. */
62
+ /**
63
+ * Google Play `obfuscatedAccountId`: 64 hex characters, within Play's
64
+ * 64-character limit. `undefined` without a player, which the Play
65
+ * provider refuses with `ACCOUNT_BINDING_REQUIRED`.
66
+ */
42
67
  export async function resolvePlayBillingAccountBinding(
43
68
  gateway: Pick<PlatformGateway, 'identity'>,
44
69
  ): Promise<StorePurchaseAccountBinding | undefined> {
@@ -55,7 +80,11 @@ export async function resolveStoreKitAppAccountToken(
55
80
  ): Promise<string> {
56
81
  const playerId = await resolveStarterCommercePlayerId(gateway);
57
82
  if (playerId === undefined) {
58
- throw new Error('A StoreKit purchase requires an authenticated player session.');
83
+ throw new Error(
84
+ 'A StoreKit purchase is bound to the player of a server session, and there is none: '
85
+ + 'sign in to an identity service first (a generated game: VITE_MPGD_IDENTITY_URL and '
86
+ + "src/platform/guestIdentity.ts). The native shell's local player cannot bind a purchase.",
87
+ );
59
88
  }
60
89
  return toUuid(await digestPlayerId(playerId));
61
90
  }
@@ -0,0 +1,334 @@
1
+ import {
2
+ AccountDeletionError,
3
+ GuestSessionCoordinatorError,
4
+ isMonetizationOperationRecoverable,
5
+ type AccountDeletionClient,
6
+ type MonetizationOperationRecord,
7
+ } from '@mpgd/game-services';
8
+ import type { Locale } from '@mpgd/i18n';
9
+ import type { SecureCredentialStore, StorageAdapter } from '@mpgd/platform';
10
+
11
+ import { t } from '../i18n/messages';
12
+ import { defaultMonetizationJournalKey } from './monetizationJournal';
13
+
14
+ /**
15
+ * The secure credential keys of the guest session
16
+ * (`createGuestSessionCoordinator`'s default `credentialKey` and its family
17
+ * entry). A game that passes another `credentialKey` registers its own
18
+ * sign-out with `onStarterAccountDeleted`.
19
+ */
20
+ export const starterGuestCredentialKeys = [
21
+ 'mpgd.guest.refresh',
22
+ 'mpgd.guest.refresh.family',
23
+ ] as const;
24
+
25
+ /**
26
+ * The `requestConfirmation` of `createStarterAccountDeletion` for a server
27
+ * session that can ask the identity service for one (the guest session's
28
+ * `requestAccountDeletionConfirmation`), or `undefined` for one that cannot
29
+ * (a development session, whose server needs none). A session the identity
30
+ * service refused (`GUEST_SESSION_REJECTED`, now or in an earlier refresh)
31
+ * is `AccountDeletionError('UNAUTHORIZED')`; a backend that cannot issue one
32
+ * (`GUEST_SESSION_UNSUPPORTED`) is `ACCOUNT_DELETION_NOT_CONFIGURED`. Any
33
+ * other failure (offline, a failed credential save) passes through, a
34
+ * retryable failure that says nothing about an earlier request.
35
+ */
36
+ export function starterAccountDeletionConfirmation(
37
+ session: { requestAccountDeletionConfirmation?(): Promise<string> } | undefined,
38
+ ): (() => Promise<string>) | undefined {
39
+ const request = session?.requestAccountDeletionConfirmation;
40
+ if (session === undefined || request === undefined) {
41
+ return undefined;
42
+ }
43
+ return async () => {
44
+ try {
45
+ return await request.call(session);
46
+ } catch (error) {
47
+ if (error instanceof GuestSessionCoordinatorError && error.code === 'GUEST_SESSION_REJECTED') {
48
+ throw new AccountDeletionError('UNAUTHORIZED', { cause: error });
49
+ }
50
+ if (error instanceof GuestSessionCoordinatorError && error.code === 'GUEST_SESSION_UNSUPPORTED') {
51
+ throw new AccountDeletionError('ACCOUNT_DELETION_NOT_CONFIGURED', { cause: error });
52
+ }
53
+ throw error;
54
+ }
55
+ };
56
+ }
57
+
58
+ export type StarterDeleteAccountOutcome =
59
+ /**
60
+ * The server deleted the account. `signedOut` is `false` when this device
61
+ * could not forget the session's credentials, the purchase journal, or
62
+ * the run checkpoint.
63
+ */
64
+ | { readonly outcome: 'deleted'; readonly retainedPurchaseRecords: number; readonly signedOut: boolean }
65
+ /**
66
+ * The server refused the session after an earlier attempt whose request
67
+ * was sent but whose answer was lost: the account was most likely
68
+ * deleted. `signedOut` reports whether this device forgot the session.
69
+ */
70
+ | { readonly outcome: 'session-ended'; readonly signedOut: boolean }
71
+ /** No signed-in session to delete. */
72
+ | { readonly outcome: 'needs-session' }
73
+ /** Not this time; `retryable` says whether trying again can help. */
74
+ | { readonly outcome: 'failed'; readonly retryable: boolean }
75
+ /** This build or server has no account deletion; point the player at the web page. */
76
+ | { readonly outcome: 'unavailable' };
77
+
78
+ export interface StarterAccountDeletion {
79
+ /** Whether the game should show the Delete account entry: a deletion client and a signed-in session. */
80
+ readonly available: boolean;
81
+ /** Whether a purchase on this device is still being processed, to warn before deleting. */
82
+ hasPendingPurchase(): Promise<boolean>;
83
+ /** Deletes the account on the server, then signs this device out. A second call joins the first. */
84
+ deleteAccount(): Promise<StarterDeleteAccountOutcome>;
85
+ }
86
+
87
+ type SignOutStep = () => void | Promise<void>;
88
+
89
+ const signOutSteps = new Set<SignOutStep>();
90
+ /** Steps that close a session holder, run before the credentials are removed. */
91
+ const sessionSteps = new Set<SignOutStep>();
92
+
93
+ /**
94
+ * An earlier attempt's request was sent but its answer was lost or was a
95
+ * retryable server failure, so the deletion may have completed. Kept for
96
+ * the page's life, across scenes.
97
+ */
98
+ let answerLost = false;
99
+
100
+ /** The PlayScene run checkpoint's storage key; the scene saves under it. */
101
+ export const starterRunKey = 'starter-run';
102
+
103
+ /** A local sign-out step that hangs is given up on after this long. */
104
+ const signOutStepTimeoutMs = 5_000;
105
+
106
+ /**
107
+ * Registers a local sign-out step run after the account is deleted (for
108
+ * example the guest session's `logout()`, the notifications runtime's
109
+ * `unregister()`, a game-owned journal or save). Returns the unregister.
110
+ */
111
+ export function onStarterAccountDeleted(
112
+ step: SignOutStep,
113
+ options: {
114
+ /**
115
+ * `session`: the step closes something that may still write the
116
+ * session's credentials (the guest session coordinator's `logout()`,
117
+ * which closes it at once and waits for a refresh in flight). It runs
118
+ * before the credentials are removed, so a refresh that answers late
119
+ * cannot write the deleted account's token back.
120
+ */
121
+ readonly phase?: 'session' | 'after';
122
+ } = {},
123
+ ): () => void {
124
+ const steps = options.phase === 'session' ? sessionSteps : signOutSteps;
125
+ steps.add(step);
126
+ return () => {
127
+ steps.delete(step);
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Delete account for the starter's settings: App Store guideline 5.1.1(v)
133
+ * and Google Play require deletion in the app. The scene asks twice (see
134
+ * `deleteAccountConfirmText`) before calling `deleteAccount()`; the server
135
+ * (`POST /game-services/account/delete`) erases the player's data, keeps
136
+ * the purchase records the law requires without the player id, and ends
137
+ * every session. This device then forgets the session's credentials, the
138
+ * purchase journal, and whatever `onStarterAccountDeleted` registered.
139
+ */
140
+ export function createStarterAccountDeletion(input: {
141
+ /** `context.gameServices.accountDeletion`; absent without a game-services URL. */
142
+ readonly client?: Pick<AccountDeletionClient, 'deleteAccount'>;
143
+ readonly storage: Pick<StorageAdapter, 'load' | 'save'>;
144
+ readonly secureCredentials?: Pick<SecureCredentialStore, 'remove'>;
145
+ readonly journalKey?: string;
146
+ /** Whether a server-backed session is signed in (`resolveStarterServerSession()?.current()`). */
147
+ readonly signedIn?: () => boolean;
148
+ /**
149
+ * Asks the identity service for a deletion confirmation (it takes the
150
+ * session's refresh token, which a stolen access token does not give),
151
+ * right after the player's second tap. Without it the request carries
152
+ * none, which only a development server accepts. It throws
153
+ * `AccountDeletionError('UNAUTHORIZED')` when the identity service refused
154
+ * the session (which is what a completed deletion looks like after a lost
155
+ * answer); anything else it throws is a retryable failure.
156
+ */
157
+ readonly requestConfirmation?: () => Promise<string>;
158
+ }): StarterAccountDeletion {
159
+ const client = input.client;
160
+ const journalKey = input.journalKey ?? defaultMonetizationJournalKey;
161
+ let running: Promise<StarterDeleteAccountOutcome> | undefined;
162
+
163
+ /**
164
+ * Answers whether the credentials, the journal (unless kept), and the run checkpoint were cleared;
165
+ * registered steps only log. An inferred deletion keeps the journal: its
166
+ * open purchases may still need the store's answer if the account exists.
167
+ */
168
+ const signOut = async (options: { readonly keepJournal?: boolean } = {}): Promise<boolean> => {
169
+ // Every step runs; one failing or hanging does not keep the others' data around.
170
+ const required: (() => Promise<unknown> | unknown)[] = [
171
+ ...starterGuestCredentialKeys.map((key) => () => input.secureCredentials?.remove({ key })),
172
+ ...(options.keepJournal === true
173
+ ? []
174
+ : [() => input.storage.save({ key: journalKey, value: null })]),
175
+ ];
176
+ // The starter's own run checkpoint (PlayScene); a game's saves register a step.
177
+ required.push(() => input.storage.save({ key: starterRunKey, value: null }));
178
+ let cleared = true;
179
+ for (const step of [...sessionSteps, ...required, ...signOutSteps]) {
180
+ try {
181
+ await withTimeout(step);
182
+ } catch (error) {
183
+ console.error('[delete-account] local sign-out step failed', error);
184
+ if (required.includes(step)) {
185
+ cleared = false;
186
+ }
187
+ }
188
+ }
189
+ return cleared;
190
+ };
191
+
192
+ const run = async (): Promise<StarterDeleteAccountOutcome> => {
193
+ if (client === undefined) {
194
+ return { outcome: 'unavailable' };
195
+ }
196
+ try {
197
+ let confirmation: string | undefined;
198
+ try {
199
+ confirmation = await input.requestConfirmation?.();
200
+ } catch (error) {
201
+ // Nothing reached the game server, so only a refused session says
202
+ // anything about an earlier attempt.
203
+ if (error instanceof AccountDeletionError && error.code === 'UNAUTHORIZED') {
204
+ if (error.requestSent && answerLost) {
205
+ answerLost = false;
206
+ return { outcome: 'session-ended', signedOut: await signOut({ keepJournal: true }) };
207
+ }
208
+ return { outcome: 'needs-session' };
209
+ }
210
+ if (error instanceof AccountDeletionError && error.code === 'ACCOUNT_DELETION_NOT_CONFIGURED') {
211
+ return { outcome: 'unavailable' };
212
+ }
213
+ console.error('[delete-account] confirmation', error);
214
+ return { outcome: 'failed', retryable: true };
215
+ }
216
+ const receipt = await client.deleteAccount(
217
+ confirmation === undefined ? {} : { confirmation },
218
+ );
219
+ answerLost = false;
220
+ return {
221
+ outcome: 'deleted',
222
+ retainedPurchaseRecords: receipt.retainedPurchaseRecords,
223
+ signedOut: await signOut(),
224
+ };
225
+ } catch (error) {
226
+ if (!(error instanceof AccountDeletionError)) {
227
+ console.error('[delete-account]', error);
228
+ return { outcome: 'failed', retryable: true };
229
+ }
230
+ if (error.code === 'ACCOUNT_DELETION_UNREACHABLE') {
231
+ // Only a request that went out may have deleted the account.
232
+ answerLost ||= error.requestSent;
233
+ return { outcome: 'failed', retryable: true };
234
+ }
235
+ if (error.code === 'UNAUTHORIZED' && error.requestSent && answerLost) {
236
+ answerLost = false;
237
+ return { outcome: 'session-ended', signedOut: await signOut({ keepJournal: true }) };
238
+ }
239
+ if (error.requestSent && !error.retryable) {
240
+ // A definite answer from the server: the account was not deleted.
241
+ answerLost = false;
242
+ } else if (error.requestSent) {
243
+ // A retryable one (a 5xx from a proxy, a deletion whose identity step
244
+ // answered late) may hide a completed deletion: the next attempt reads
245
+ // a refused session as that.
246
+ answerLost = true;
247
+ }
248
+ switch (error.code) {
249
+ case 'UNAUTHORIZED':
250
+ return { outcome: 'needs-session' };
251
+ case 'ACCOUNT_DELETION_NOT_CONFIGURED':
252
+ return { outcome: 'unavailable' };
253
+ case 'ACCOUNT_DELETION_CONFIRMATION_REQUIRED':
254
+ // Expired or already used, the next tap asks for a new one;
255
+ // without a way to ask, the server's check cannot be met.
256
+ return input.requestConfirmation === undefined
257
+ ? { outcome: 'unavailable' }
258
+ : { outcome: 'failed', retryable: true };
259
+ default:
260
+ return { outcome: 'failed', retryable: error.retryable };
261
+ }
262
+ }
263
+ };
264
+
265
+ return {
266
+ available: client !== undefined && (input.signedIn?.() ?? true),
267
+ async hasPendingPurchase() {
268
+ try {
269
+ const loaded = await input.storage.load({ key: journalKey });
270
+ const records = (loaded?.value as { readonly records?: unknown } | null | undefined)?.records;
271
+ if (typeof records !== 'object' || records === null) {
272
+ return false;
273
+ }
274
+ return Object.values(records as Record<string, MonetizationOperationRecord>).some(
275
+ (record) => record.kind !== 'rewarded-ad' && isMonetizationOperationRecoverable(record),
276
+ );
277
+ } catch {
278
+ return false;
279
+ }
280
+ },
281
+ deleteAccount() {
282
+ running ??= run().finally(() => {
283
+ running = undefined;
284
+ });
285
+ return running;
286
+ },
287
+ };
288
+ }
289
+
290
+ function withTimeout(step: () => Promise<unknown> | unknown): Promise<unknown> {
291
+ let timer: ReturnType<typeof setTimeout> | undefined;
292
+ return Promise.race([
293
+ Promise.resolve().then(step),
294
+ new Promise((_resolve, reject) => {
295
+ timer = setTimeout(() => reject(new Error('sign-out step timed out')), signOutStepTimeoutMs);
296
+ }),
297
+ ]).finally(() => clearTimeout(timer));
298
+ }
299
+
300
+ /** Test seam: forget a lost answer remembered by an earlier deletion attempt. */
301
+ export function resetStarterAccountDeletionForTests(): void {
302
+ answerLost = false;
303
+ }
304
+
305
+ /** The confirmation shown before deleting: what goes, what stays, and a pending purchase warning. */
306
+ export function deleteAccountConfirmText(locale: Locale, pendingPurchase: boolean): string {
307
+ const text = t(locale, 'deleteAccountConfirm');
308
+ return pendingPurchase ? `${text}\n${t(locale, 'deleteAccountPendingPurchase')}` : text;
309
+ }
310
+
311
+ /** The player-facing line for an outcome, shared by the Phaser lobby and the lite button. */
312
+ export function deleteAccountOutcomeText(
313
+ locale: Locale,
314
+ result: StarterDeleteAccountOutcome,
315
+ ): string {
316
+ switch (result.outcome) {
317
+ case 'deleted':
318
+ return result.signedOut
319
+ ? t(locale, 'deleteAccountDone')
320
+ : t(locale, 'deleteAccountDoneLocalFailed');
321
+ case 'session-ended':
322
+ return result.signedOut
323
+ ? t(locale, 'deleteAccountSessionEnded')
324
+ : t(locale, 'deleteAccountDoneLocalFailed');
325
+ case 'needs-session':
326
+ return t(locale, 'deleteAccountNeedsSession');
327
+ case 'unavailable':
328
+ return t(locale, 'deleteAccountUnavailable');
329
+ default:
330
+ return result.retryable
331
+ ? t(locale, 'deleteAccountFailed')
332
+ : t(locale, 'deleteAccountRejected');
333
+ }
334
+ }
@@ -14,8 +14,13 @@ import type { GameActionReconciliationPort } from '@mpgd/game-runtime/actions';
14
14
  import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
15
15
 
16
16
  import { resolveStarterCommerceProducts } from './commerceProducts';
17
+ import type { StarterGuestSignIn } from './guestIdentity';
17
18
  import { createStorageMonetizationOperationStore } from './monetizationJournal';
18
19
  import { resolveStarterServerSession } from './serverSession';
20
+ import {
21
+ createSessionGatedGameServices,
22
+ type StarterSessionGatedServices,
23
+ } from './sessionGatedServices';
19
24
 
20
25
  const productCatalog = productCatalogJson as ProductCatalog;
21
26
 
@@ -31,24 +36,64 @@ export interface StarterMonetizationPorts {
31
36
  }
32
37
 
33
38
  export type StarterBackendMode = GameServicesRuntimeMode;
34
- export type StarterGameServices = GameServicesRuntime;
35
-
36
39
  /**
37
- * Game-services runtime over the commerce v2 reconciler: every purchase is
38
- * journaled in a durable `MonetizationOperationStore` (the reference journal
39
- * over the platform storage adapter unless the game supplies its own) and
40
- * verified through the backend ledger, with the catalog's kinds and SKUs for
41
- * the gateway target resolved without a store read.
40
+ * The game services the scenes read. In a run whose guest sign-in failed at
41
+ * boot they are session-gated (`ensureSession` present): see
42
+ * `sessionGatedServices.ts`.
42
43
  */
43
- export function createStarterGameServices(input: {
44
+ export type StarterGameServices = GameServicesRuntime & {
45
+ readonly ensureSession?: StarterSessionGatedServices['ensureSession'];
46
+ };
47
+
48
+ interface StarterGameServicesInput {
44
49
  readonly gateway: PlatformGateway;
45
50
  readonly playerId: string;
46
51
  readonly configTarget: string;
47
52
  readonly operationStore?: MonetizationOperationStore;
48
53
  readonly analytics?: AnalyticsSink;
49
54
  readonly analyticsSessionId?: string;
50
- }): StarterGameServices {
51
- const session = resolveStarterServerSession({ once: true });
55
+ }
56
+
57
+ /**
58
+ * Game-services runtime over the commerce v2 reconciler: every purchase is
59
+ * journaled in a durable `MonetizationOperationStore` (the reference journal
60
+ * over the platform storage adapter unless the game supplies its own) and
61
+ * verified through the backend ledger, with the catalog's kinds and SKUs for
62
+ * the gateway target resolved without a store read.
63
+ */
64
+ export function createStarterGameServices(input: StarterGameServicesInput): StarterGameServices {
65
+ return createRuntime(input, resolveStarterServerSession({ once: true }));
66
+ }
67
+
68
+ /**
69
+ * The game services of a run whose guest sign-in failed at boot: they come
70
+ * up for the server player once `signIn` succeeds (in the background, or
71
+ * when a purchase or restore asks again), and never for the platform's local
72
+ * placeholder. Set the server session (`setStarterServerSession`) in a
73
+ * `signIn.onSignedIn` listener registered before this, so the services read it.
74
+ */
75
+ export function createStarterSessionGatedGameServices(
76
+ input: Omit<StarterGameServicesInput, 'playerId'> & {
77
+ readonly signIn: StarterGuestSignIn;
78
+ /** Called once the services exist for the server player: run the game runtime's `reconcile()`. */
79
+ readonly onActivated?: () => void;
80
+ },
81
+ ): StarterGameServices {
82
+ const { signIn, onActivated, ...rest } = input;
83
+ return createSessionGatedGameServices({
84
+ // Configuration only (mode, target, whether a client exists); never sends a request.
85
+ probe: createRuntime({ ...rest, playerId: 'session-pending' }, undefined),
86
+ ensurePlayer: async () => (await signIn.ensure())?.playerId,
87
+ onPlayer: (listener) => signIn.onSignedIn((signedIn) => listener(signedIn.playerId)),
88
+ create: (playerId) => createStarterGameServices({ ...rest, playerId }),
89
+ ...(onActivated === undefined ? {} : { onActivated }),
90
+ });
91
+ }
92
+
93
+ function createRuntime(
94
+ input: StarterGameServicesInput,
95
+ session: ReturnType<typeof resolveStarterServerSession>,
96
+ ): GameServicesRuntime {
52
97
  return createGameServicesRuntime({
53
98
  gateway: input.gateway,
54
99
  playerId: input.playerId,