@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.
- 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 +1663 -899
- 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/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 +17 -17
- 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 +12 -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/notifications.ts +17 -3
- 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/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
|
@@ -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`). */
|