@mpgd/cli 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) 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 +3935 -1308
  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/notifications-starter.js +26 -3
  12. package/dist/release-state.d.ts +2 -0
  13. package/dist/release-state.js +4 -2
  14. package/dist/testflight-submission.d.ts +9 -0
  15. package/dist/testflight-submission.js +69 -0
  16. package/package.json +16 -16
  17. package/templates/lite-overlay/agent/acceptance.md +1 -1
  18. package/templates/lite-overlay/agent/brief.md +1 -1
  19. package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
  20. package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
  21. package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
  22. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +28 -6
  23. package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
  24. package/templates/notifications-overlay/common/notifications/topics.json +3 -0
  25. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
  26. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +111 -2
  27. package/templates/notifications-overlay/common/src/platform/notifications.ts +36 -4
  28. package/templates/phaser-game/README.md +72 -0
  29. package/templates/phaser-game/agent/acceptance.md +4 -1
  30. package/templates/phaser-game/agent/brief.md +14 -4
  31. package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +37 -0
  32. package/templates/phaser-game/gitignore +11 -0
  33. package/templates/phaser-game/legal/delete-account.html +133 -0
  34. package/templates/phaser-game/legal/privacy.html +111 -12
  35. package/templates/phaser-game/legal/support.html +1 -0
  36. package/templates/phaser-game/legal/terms.html +1 -0
  37. package/templates/phaser-game/mpgd.game.json +3 -0
  38. package/templates/phaser-game/src/env.d.ts +6 -0
  39. package/templates/phaser-game/src/i18n/messages.ts +86 -1
  40. package/templates/phaser-game/src/main.ts +69 -8
  41. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
  42. package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
  43. package/templates/phaser-game/src/platform/gameServices.ts +55 -10
  44. package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
  45. package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
  46. package/templates/phaser-game/src/platform/serverSession.ts +8 -0
  47. package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
  48. package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
  49. package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
@@ -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,