@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
@@ -0,0 +1,301 @@
1
+ import {
2
+ createGuestServerSession,
3
+ createGuestSessionCoordinator,
4
+ createGuestSessionHttpBackend,
5
+ GuestSessionCoordinatorError,
6
+ type GuestServerSession,
7
+ type GuestSessionBackend,
8
+ type GuestSessionCoordinator,
9
+ } from '@mpgd/game-services';
10
+ import type { SecureCredentialStore } from '@mpgd/platform';
11
+
12
+ import { setStarterCommerceServerPlayer } from './commerceAccountBinding';
13
+ import { onStarterAccountDeleted } from './deleteAccount';
14
+
15
+ /**
16
+ * How long one identity request may hold the boot: 5 seconds. Offline, the
17
+ * game starts after one timeout (three on the rare path that replaces an
18
+ * ended session) and runs without a server session. The limit stays on the
19
+ * coordinator's later requests too (refreshes in play), which are small.
20
+ */
21
+ export const starterGuestIdentityTimeoutMs = 5_000;
22
+
23
+ /** Where the installation id is kept, next to the refresh token in the device's secure store. */
24
+ export const starterInstallationIdKey = 'mpgd.guest.installation';
25
+
26
+ export type StarterGuestIdentity =
27
+ /** No `VITE_MPGD_IDENTITY_URL`: the game runs on the platform's local player and sells nothing on the stores. */
28
+ | { readonly status: 'not-configured' }
29
+ /** The target has no secure credential store (a browser build), so no refresh token can be kept. */
30
+ | { readonly status: 'unavailable' }
31
+ /**
32
+ * Signed in: `playerId` is the server player that purchases, rewards, and
33
+ * notifications use; pass `serverSession` to `setStarterServerSession`.
34
+ */
35
+ | {
36
+ readonly status: 'signed-in';
37
+ readonly playerId: string;
38
+ /** `guest` until a later account linking makes the session `authenticated`. */
39
+ readonly identityLevel: 'guest' | 'authenticated';
40
+ readonly coordinator: GuestSessionCoordinator;
41
+ readonly serverSession: GuestServerSession;
42
+ }
43
+ /** The identity service could not be reached or answered an error; the next launch tries again. */
44
+ | { readonly status: 'failed'; readonly code: string };
45
+
46
+ /**
47
+ * Signs the player in to the guest identity service at `identityUrl`
48
+ * (`VITE_MPGD_IDENTITY_URL`, the reference Worker in the kit's
49
+ * `apps/game-identity-worker`): a server-issued guest on the first launch,
50
+ * the same guest restored from the device's Keychain or Keystore after that.
51
+ * On success it binds store purchases to the server player
52
+ * (`setStarterCommerceServerPlayer`) and returns the server session for
53
+ * `setStarterServerSession`, whose header the game-services client and the
54
+ * notifications runtime send (`main.ts` does both). Call it once, before the
55
+ * game services are created.
56
+ *
57
+ * A refresh token the service refused for good (revoked, replayed, or idle
58
+ * past the service's window) is logged out and a new guest is issued in its
59
+ * place: that session cannot come back, and without a linked account its
60
+ * player cannot either. Any other failure (offline, an outage) leaves the
61
+ * stored token alone for the next attempt (`createStarterGuestSignIn`
62
+ * retries), and until one succeeds purchases are refused with the reason
63
+ * (`ACCOUNT_BINDING_REQUIRED` on Google Play, a StoreKit error naming the
64
+ * missing session). One attempt: the game calls it through
65
+ * `createStarterGuestSignIn`.
66
+ */
67
+ export async function startStarterGuestIdentity(input: {
68
+ readonly identityUrl: string | undefined;
69
+ readonly credentials: SecureCredentialStore | undefined;
70
+ readonly warn?: (message: string) => void;
71
+ /** Tests replace the HTTP backend; a game passes nothing. */
72
+ readonly backend?: GuestSessionBackend;
73
+ readonly createInstallationId?: () => string;
74
+ }): Promise<StarterGuestIdentity> {
75
+ const warn = input.warn ?? ((message: string) => console.warn(message));
76
+ const identityUrl = input.identityUrl?.trim();
77
+ if (identityUrl === undefined || identityUrl === '') {
78
+ return { status: 'not-configured' };
79
+ }
80
+ const credentials = input.credentials;
81
+ if (credentials === undefined) {
82
+ warn(
83
+ '[identity] VITE_MPGD_IDENTITY_URL is set, but this target has no secure credential store; the game runs without a server session.',
84
+ );
85
+ return { status: 'unavailable' };
86
+ }
87
+ try {
88
+ const backend = input.backend
89
+ ?? createGuestSessionHttpBackend({ baseUrl: identityUrl, timeoutMs: starterGuestIdentityTimeoutMs });
90
+ const installationId = await loadInstallationId(credentials, input.createInstallationId);
91
+ const start = async () => {
92
+ const coordinator = createGuestSessionCoordinator({ backend, credentials, installationId });
93
+ return { coordinator, view: await coordinator.start() };
94
+ };
95
+ let started: Awaited<ReturnType<typeof start>>;
96
+ try {
97
+ started = await start();
98
+ } catch (error) {
99
+ if (!(error instanceof GuestSessionCoordinatorError) || error.code !== 'GUEST_SESSION_REJECTED') {
100
+ throw error;
101
+ }
102
+ warn(
103
+ '[identity] The stored guest session was ended by the identity service; starting a new guest.',
104
+ );
105
+ // Logout removes the dead token, so the next coordinator issues a new guest.
106
+ await createGuestSessionCoordinator({ backend, credentials, installationId }).logout();
107
+ started = await start();
108
+ }
109
+ const serverSession = createGuestServerSession(started.coordinator);
110
+ setStarterCommerceServerPlayer(() => serverSession.current()?.playerId);
111
+ // A deleted account: close the coordinator (and wait for a refresh in flight)
112
+ // before its credentials are removed, so a late refresh cannot save them again.
113
+ onStarterAccountDeleted(() => started.coordinator.logout(), { phase: 'session' });
114
+ return {
115
+ status: 'signed-in',
116
+ playerId: started.view.serverUserId,
117
+ identityLevel: started.view.identityLevel,
118
+ coordinator: started.coordinator,
119
+ serverSession,
120
+ };
121
+ } catch (error) {
122
+ const code = error instanceof GuestSessionCoordinatorError
123
+ ? error.code
124
+ : 'GUEST_IDENTITY_FAILED';
125
+ warn(
126
+ `[identity] Guest sign-in failed (${code}); purchases stay unavailable until a sign-in succeeds.`,
127
+ );
128
+ // Purchases bind to the server player or to nothing: never to the platform's local player.
129
+ setStarterCommerceServerPlayer(() => undefined);
130
+ return { status: 'failed', code };
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The installation id the identity service issues guests for: random, made
136
+ * once, and kept in the secure store so a restored session keeps its
137
+ * installation. It is never authority, only the key of the service's
138
+ * per-installation limit and of the notification endpoints.
139
+ */
140
+ async function loadInstallationId(
141
+ credentials: SecureCredentialStore,
142
+ create: (() => string) | undefined,
143
+ ): Promise<string> {
144
+ const stored = await credentials.load({ key: starterInstallationIdKey });
145
+ if (stored !== null && /^[A-Za-z0-9_-]{8,128}$/u.test(stored)) {
146
+ return stored;
147
+ }
148
+ // randomUUID needs a secure context; getRandomValues does not.
149
+ const installationId = create?.() ?? globalThis.crypto.randomUUID?.()
150
+ ?? [...globalThis.crypto.getRandomValues(new Uint8Array(16))]
151
+ .map((byte) => byte.toString(16).padStart(2, '0'))
152
+ .join('');
153
+ await credentials.save({ key: starterInstallationIdKey, value: installationId });
154
+ return installationId;
155
+ }
156
+
157
+ export type StarterSignedIn = Extract<StarterGuestIdentity, { readonly status: 'signed-in' }>;
158
+
159
+ /** Waits between background sign-in retries after a failure: 5 s, 15 s, 1 min, then every 5 min. */
160
+ export const starterGuestSignInRetryDelaysMs: readonly number[] = Object.freeze([
161
+ 5_000, 15_000, 60_000, 300_000,
162
+ ]);
163
+
164
+ /**
165
+ * The game's guest sign-in for the whole run: the first attempt at boot, and
166
+ * when that fails (offline, an outage), more attempts in the background
167
+ * (`starterGuestSignInRetryDelaysMs`, and at once when the browser reports
168
+ * `online`) until one succeeds. `ensure()` also tries at once, so a purchase
169
+ * or restore started after the network returned signs in first. Listeners
170
+ * hear the first success; the signed-in session never changes after that
171
+ * within the run.
172
+ */
173
+ export interface StarterGuestSignIn {
174
+ /** False without `VITE_MPGD_IDENTITY_URL` or a secure credential store: nothing is ever tried. */
175
+ readonly configured: boolean;
176
+ current(): StarterSignedIn | undefined;
177
+ /** The session, signing in now when there is none (joins an attempt in flight). */
178
+ ensure(): Promise<StarterSignedIn | undefined>;
179
+ /** Called once, when the first sign-in succeeds (at once when it already has); returns the unsubscribe. */
180
+ onSignedIn(listener: (signedIn: StarterSignedIn) => void): () => void;
181
+ /** Stops the background retries. */
182
+ dispose(): void;
183
+ }
184
+
185
+ export function createStarterGuestSignIn(input: Parameters<typeof startStarterGuestIdentity>[0] & {
186
+ /** Tests inject the timer; the game passes nothing. */
187
+ readonly schedule?: (callback: () => void, delayMs: number) => () => void;
188
+ /** Subscribes to "the network is back"; defaults to the window `online` event. */
189
+ readonly onOnline?: (callback: () => void) => () => void;
190
+ }): StarterGuestSignIn {
191
+ const identityUrl = input.identityUrl?.trim();
192
+ const configured = identityUrl !== undefined && identityUrl !== '' && input.credentials !== undefined;
193
+ const schedule = input.schedule ?? ((callback: () => void, delayMs: number) => {
194
+ const timer = setTimeout(callback, delayMs);
195
+ return () => clearTimeout(timer);
196
+ });
197
+ const listeners = new Set<(signedIn: StarterSignedIn) => void>();
198
+ let signedIn: StarterSignedIn | undefined;
199
+ let attempt: Promise<StarterSignedIn | undefined> | undefined;
200
+ /** Failed background attempts: on-demand ones (a purchase, a restore tap) do not lengthen the backoff. */
201
+ let backgroundFailures = 0;
202
+ let cancelRetry: (() => void) | undefined;
203
+ let disposed = false;
204
+ let stopOnline: (() => void) | undefined;
205
+
206
+ const scheduleRetry = () => {
207
+ if (disposed || signedIn !== undefined || cancelRetry !== undefined || !configured) {
208
+ return;
209
+ }
210
+ const delays = starterGuestSignInRetryDelaysMs;
211
+ const delay = delays[Math.min(backgroundFailures, delays.length - 1)] ?? 300_000;
212
+ cancelRetry = schedule(() => {
213
+ cancelRetry = undefined;
214
+ void attemptNow(true);
215
+ }, delay);
216
+ };
217
+
218
+ function ensure(): Promise<StarterSignedIn | undefined> {
219
+ return attemptNow(false);
220
+ }
221
+
222
+ function attemptNow(background: boolean): Promise<StarterSignedIn | undefined> {
223
+ if (signedIn !== undefined) {
224
+ return Promise.resolve(signedIn);
225
+ }
226
+ if (attempt !== undefined) {
227
+ return attempt;
228
+ }
229
+ if (!configured) {
230
+ // One attempt reports why (and warns, for a missing secure store); nothing to retry.
231
+ attempt = startStarterGuestIdentity(input).then(() => undefined);
232
+ return attempt;
233
+ }
234
+ cancelRetry?.();
235
+ cancelRetry = undefined;
236
+ attempt = startStarterGuestIdentity(input).then((result) => {
237
+ attempt = undefined;
238
+ if (result.status !== 'signed-in') {
239
+ if (background) {
240
+ backgroundFailures += 1;
241
+ }
242
+ scheduleRetry();
243
+ return undefined;
244
+ }
245
+ signedIn = result;
246
+ stopOnline?.();
247
+ for (const listener of [...listeners]) {
248
+ try {
249
+ listener(result);
250
+ } catch (error) {
251
+ console.error('[identity] a sign-in listener failed', error);
252
+ }
253
+ }
254
+ listeners.clear();
255
+ return result;
256
+ });
257
+ return attempt;
258
+ }
259
+
260
+ if (configured) {
261
+ const subscribeOnline = input.onOnline ?? ((callback: () => void) => {
262
+ if (typeof globalThis.addEventListener !== 'function') {
263
+ return () => undefined;
264
+ }
265
+ globalThis.addEventListener('online', callback);
266
+ return () => globalThis.removeEventListener('online', callback);
267
+ });
268
+ stopOnline = subscribeOnline(() => {
269
+ if (!disposed && signedIn === undefined) {
270
+ void ensure();
271
+ }
272
+ });
273
+ }
274
+
275
+ return {
276
+ configured,
277
+ current: () => signedIn,
278
+ ensure,
279
+ onSignedIn(listener) {
280
+ // Already signed in (a retry won the race with the caller): heard at once.
281
+ if (signedIn !== undefined) {
282
+ try {
283
+ listener(signedIn);
284
+ } catch (error) {
285
+ console.error('[identity] a sign-in listener failed', error);
286
+ }
287
+ return () => undefined;
288
+ }
289
+ listeners.add(listener);
290
+ return () => {
291
+ listeners.delete(listener);
292
+ };
293
+ },
294
+ dispose() {
295
+ disposed = true;
296
+ cancelRetry?.();
297
+ cancelRetry = undefined;
298
+ stopOnline?.();
299
+ },
300
+ };
301
+ }
@@ -0,0 +1,205 @@
1
+ import type { RecoverableMonetizationClient } from '@mpgd/game-services';
2
+ import type { Locale } from '@mpgd/i18n';
3
+ import type { PlatformGateway } from '@mpgd/platform';
4
+
5
+ import { t } from '../i18n/messages';
6
+ import { resolveStarterCommercePlayerId } from './commerceAccountBinding';
7
+
8
+ /**
9
+ * Stores whose purchases are bound to the player (StoreKit
10
+ * `appAccountToken`, Google Play `obfuscatedAccountId`): restoring needs the
11
+ * server session's player (`guestIdentity.ts`), or an authenticated,
12
+ * non-local platform session, so the backend can match the purchases.
13
+ */
14
+ const accountBoundStores: ReadonlySet<string> = new Set(['app-store', 'google-play']);
15
+
16
+ /** Reconciler states of a purchase the backend ledger has granted. */
17
+ const settledStates: ReadonlySet<string> = new Set(['granted', 'finishing', 'finished']);
18
+
19
+ type RestoreRecovery = Pick<RecoverableMonetizationClient, 'restorePurchases' | 'commerceReconciler'>;
20
+
21
+ export type StarterRestoreOutcome =
22
+ /**
23
+ * `granted`: purchases the backend ledger granted in this pass (0 when
24
+ * nothing new). `restoredHere`: how many of them the backend granted this
25
+ * player from a purchase another guest of the same store account made or
26
+ * holds (a reinstall, a new device, a second device); absent when none.
27
+ */
28
+ | { readonly outcome: 'restored'; readonly granted: number; readonly restoredHere?: number }
29
+ /**
30
+ * Some purchases are still unconfirmed; `granted` were confirmed in this
31
+ * pass. `atHolderLimit`: how many of the unconfirmed ones are already held
32
+ * by the most devices the backend allows (`PURCHASE_HOLDER_LIMIT_REACHED`).
33
+ */
34
+ | {
35
+ readonly outcome: 'incomplete';
36
+ readonly granted: number;
37
+ readonly restoredHere?: number;
38
+ readonly atHolderLimit?: number;
39
+ }
40
+ /** No server session can bind the purchases (no identity service configured). */
41
+ | { readonly outcome: 'needs-session' }
42
+ /** The guest sign-in failed again (offline, an outage); the control is a retry. */
43
+ | { readonly outcome: 'needs-connection' }
44
+ | { readonly outcome: 'failed' }
45
+ | { readonly outcome: 'unavailable' };
46
+
47
+ export interface StarterRestorePurchases {
48
+ /** Whether the game should show a Restore Purchases control on this target. */
49
+ readonly available: boolean;
50
+ /**
51
+ * Asks the store to resynchronize (StoreKit `AppStore.sync()`; Google Play
52
+ * re-reads owned items) and runs the reconciler, which verifies every
53
+ * purchase through the backend ledger before anything is granted. A second
54
+ * tap while one runs joins it.
55
+ */
56
+ restore(): Promise<StarterRestoreOutcome>;
57
+ }
58
+
59
+ /**
60
+ * Restore Purchases for the starter's store or settings UI. App Review
61
+ * expects a restore control in an app that sells non-consumables (the
62
+ * catalog's `REMOVE_ADS`); the grant itself still comes only from the
63
+ * backend ledger through `monetizationRecovery.restorePurchases()`.
64
+ */
65
+ export function createStarterRestorePurchases(input: {
66
+ readonly gateway: Pick<PlatformGateway, 'commerce' | 'identity'>;
67
+ /**
68
+ * The recovery client, or a getter for it: a run whose guest sign-in
69
+ * failed at boot has none until a later sign-in (`ensureSession`).
70
+ */
71
+ readonly monetizationRecovery?:
72
+ | RestoreRecovery
73
+ | (() => RestoreRecovery | undefined);
74
+ /**
75
+ * Signs in now if the run is not signed in yet (the session-gated game
76
+ * services' `ensureSession`); a restore then tries once more before
77
+ * answering `needs-connection`, which the control shows as a retry.
78
+ */
79
+ readonly ensureSession?: () => Promise<boolean>;
80
+ }): StarterRestorePurchases {
81
+ const commerce = input.gateway.commerce;
82
+ const source = input.monetizationRecovery;
83
+ const recoveryNow = (): RestoreRecovery | undefined =>
84
+ typeof source === 'function' ? source() : source;
85
+ // Shown while the run can still sign in, so the player has a retry, never a dead control.
86
+ const available = commerce !== undefined && (recoveryNow() !== undefined || input.ensureSession !== undefined);
87
+ let running: Promise<StarterRestoreOutcome> | undefined;
88
+ const run = async (): Promise<StarterRestoreOutcome> => {
89
+ if (commerce === undefined) {
90
+ return { outcome: 'unavailable' };
91
+ }
92
+ if (input.ensureSession !== undefined && !(await input.ensureSession())) {
93
+ return { outcome: 'needs-connection' };
94
+ }
95
+ const recovery = recoveryNow();
96
+ if (recovery === undefined) {
97
+ // Signed in (or nothing to sign in), and still no recovery client: nothing a retry would change.
98
+ return { outcome: 'unavailable' };
99
+ }
100
+ if (accountBoundStores.has(commerce.store)
101
+ && await resolveStarterCommercePlayerId(input.gateway) === undefined) {
102
+ return { outcome: 'needs-session' };
103
+ }
104
+ // The reconciler reports store failures as events, not rejections. A
105
+ // failed resync (`restore`) is only this restore's; a failed read of the
106
+ // store's purchases (`available`, the whole restore on stores without a
107
+ // resync such as Google Play) may also come from a concurrent startup or
108
+ // resume pass, so it makes the result unconfirmed rather than failed.
109
+ let resyncFailed = false;
110
+ let readFailed = false;
111
+ // A purchase found, verified and finished within this pass is no longer
112
+ // recoverable afterwards, so the summaries miss it; its transitions do not.
113
+ const settledKeys = new Set<string>();
114
+ // Purchases the backend granted from another guest of this device's
115
+ // store account (a reinstall, a new device): "restored to this device".
116
+ const restoredKeys = new Set<string>();
117
+ // Purchases already held by the most devices the backend allows.
118
+ const limitKeys = new Set<string>();
119
+ const unsubscribe = recovery.commerceReconciler?.subscribe((event) => {
120
+ if (event.type === 'verify-result') {
121
+ if (event.response.verified && event.response.restoredAcrossPlayers === true) {
122
+ restoredKeys.add(event.record.idempotencyKey);
123
+ } else if (!event.response.verified
124
+ && event.response.reason === 'PURCHASE_HOLDER_LIMIT_REACHED') {
125
+ limitKeys.add(event.record.idempotencyKey);
126
+ }
127
+ } else if (event.type === 'transition' && settledStates.has(event.record.state)) {
128
+ settledKeys.add(event.record.idempotencyKey);
129
+ } else if (event.type === 'failure' && event.stage === 'restore') {
130
+ resyncFailed = true;
131
+ } else if (event.type === 'failure' && event.stage === 'available') {
132
+ readFailed = true;
133
+ }
134
+ });
135
+ try {
136
+ const summaries = (await recovery.restorePurchases())
137
+ .filter((summary) => summary.kind !== 'rewarded-ad');
138
+ if (resyncFailed) {
139
+ return { outcome: 'failed' };
140
+ }
141
+ for (const summary of summaries) {
142
+ if (summary.status === 'granted') {
143
+ settledKeys.add(summary.idempotencyKey);
144
+ }
145
+ }
146
+ const granted = settledKeys.size;
147
+ const restoredHere = [...restoredKeys].filter((key) => settledKeys.has(key)).length;
148
+ const restoredCount = restoredHere === 0 ? {} : { restoredHere };
149
+ const atHolderLimit = [...limitKeys].filter((key) => !settledKeys.has(key)).length;
150
+ // A purchase the backend has not settled yet (verify unreachable, still
151
+ // pending at the store, waiting on an operator, at the holder limit) is
152
+ // reported as such, never as "nothing to restore".
153
+ if (atHolderLimit > 0 || readFailed || summaries.some((summary) => summary.status === 'pending'
154
+ || summary.status === 'action-required' || summary.status === 'failed')) {
155
+ return {
156
+ outcome: 'incomplete',
157
+ granted,
158
+ ...restoredCount,
159
+ ...(atHolderLimit === 0 ? {} : { atHolderLimit }),
160
+ };
161
+ }
162
+ return { outcome: 'restored', granted, ...restoredCount };
163
+ } catch (error) {
164
+ console.error('[restore-purchases]', error);
165
+ return { outcome: 'failed' };
166
+ } finally {
167
+ unsubscribe?.();
168
+ }
169
+ };
170
+ return {
171
+ available,
172
+ restore() {
173
+ running ??= run().finally(() => {
174
+ running = undefined;
175
+ });
176
+ return running;
177
+ },
178
+ };
179
+ }
180
+
181
+ /** The player-facing line for a restore outcome, shared by the Phaser lobby and the lite button. */
182
+ export function restoreOutcomeText(locale: Locale, result: StarterRestoreOutcome): string {
183
+ switch (result.outcome) {
184
+ case 'restored':
185
+ if (result.granted === 0) {
186
+ return t(locale, 'restoreNothing');
187
+ }
188
+ return (result.restoredHere ?? 0) > 0
189
+ ? t(locale, 'restoreToDevice', { count: result.granted })
190
+ : t(locale, 'restoreDone', { count: result.granted });
191
+ case 'incomplete':
192
+ if ((result.atHolderLimit ?? 0) > 0) {
193
+ return t(locale, 'restoreHolderLimit');
194
+ }
195
+ return result.granted === 0
196
+ ? t(locale, 'restoreIncomplete')
197
+ : t(locale, 'restorePartial', { count: result.granted });
198
+ case 'needs-session':
199
+ return t(locale, 'restoreNeedsSession');
200
+ case 'needs-connection':
201
+ return t(locale, 'restoreNeedsConnection');
202
+ default:
203
+ return t(locale, 'restoreFailed');
204
+ }
205
+ }
@@ -45,6 +45,14 @@ export interface StarterServerSession {
45
45
  ): () => void;
46
46
  /** The current session's request headers, such as `{ authorization: 'Bearer …' }`. */
47
47
  readonly getHeaders: GameServicesHeaderResolver;
48
+ /**
49
+ * An account deletion confirmation from the identity service, which the
50
+ * game-services deletion route requires besides the access token
51
+ * (`createGuestServerSession` answers the coordinator's). Optional: a
52
+ * session without it (a development session) sends none, which only a
53
+ * development server accepts.
54
+ */
55
+ requestAccountDeletionConfirmation?(): Promise<string>;
48
56
  }
49
57
 
50
58
  /** The game's session, or the development one (see `developmentServerSession.ts`). */