@mpgd/cli 0.57.0 → 0.59.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
|
@@ -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 {
|
|
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
|
|
63
|
+
const platformPlayer =
|
|
57
64
|
(await platform.identity.getPlayer()) ?? {
|
|
58
65
|
playerId: 'local-player',
|
|
59
66
|
displayName: 'Local Player',
|
|
60
67
|
};
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
* platform
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
-
}
|
|
51
|
-
|
|
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,
|