@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,217 @@
1
+ import type {
2
+ GameServicesClient,
3
+ GameServicesPurchaseResult,
4
+ GameServicesRewardedAdResult,
5
+ GameServicesRuntime,
6
+ } from '@mpgd/game-services';
7
+
8
+ /** The diagnostic of a purchase refused because the run is not signed in yet (retryable). */
9
+ export const starterSessionRequiredCode = 'STARTER_SESSION_REQUIRED';
10
+
11
+ function sessionRequiredPurchase(): GameServicesPurchaseResult {
12
+ return {
13
+ status: 'failed',
14
+ purchase: {
15
+ status: 'failed',
16
+ entitlementIds: [],
17
+ diagnostic: { code: starterSessionRequiredCode, retryable: true },
18
+ },
19
+ };
20
+ }
21
+
22
+ function sessionRequiredReward(): GameServicesRewardedAdResult {
23
+ return { status: 'unavailable', reward: { status: 'unavailable', rewardGranted: false } };
24
+ }
25
+
26
+ /** A purchase, restore, or reward claim was asked for before the guest sign-in succeeded. */
27
+ export class StarterSessionRequiredError extends Error {
28
+ readonly code = 'STARTER_SESSION_REQUIRED';
29
+
30
+ constructor() {
31
+ super('The game is not signed in to its identity service yet; try again when it is online.');
32
+ this.name = 'StarterSessionRequiredError';
33
+ }
34
+ }
35
+
36
+ /** The game services of a run that booted before its guest sign-in succeeded. */
37
+ export type StarterSessionGatedServices = GameServicesRuntime & {
38
+ /**
39
+ * Signs in now if needed (once more before a purchase or restore); `true`
40
+ * when the services are up. Absent when the configuration has no client.
41
+ */
42
+ readonly ensureSession?: () => Promise<boolean>;
43
+ /**
44
+ * Calls `listener` once the services came up for the signed-in player
45
+ * (not for the services of an earlier sign-in); returns the unsubscribe.
46
+ * Delete account uses it to appear in a run that signed in late.
47
+ */
48
+ readonly onReady: (listener: () => void) => () => void;
49
+ };
50
+
51
+ /**
52
+ * Game services that come up when the player is signed in, for a run whose
53
+ * boot-time sign-in failed (offline, an outage). The real services are bound
54
+ * to a player id for their whole life (the purchase journal and the backend
55
+ * ledger are keyed by it), so they are created only once the server player
56
+ * is known (`create`), never with the platform's local placeholder:
57
+ *
58
+ * - `client` is present from the start, so the game action coordinator
59
+ * exists for the run. A purchase or reward claim signs in first
60
+ * (`ensurePlayer`); when that still fails it answers a plain refusal
61
+ * before any store UI opens (`failed` with the retryable diagnostic
62
+ * `STARTER_SESSION_REQUIRED`; a reward `unavailable`), never a thrown
63
+ * error, which would latch the action coordinator into
64
+ * `reconciliation-required` for the rest of the run. A leaderboard
65
+ * submission throws `StarterSessionRequiredError`.
66
+ * - `monetizationRecovery` is `undefined` until then, and the Restore
67
+ * Purchases control signs in first through `ensureSession`. Rewarded ads
68
+ * stay off for the whole run (`main.ts` `canShow`, `PlayScene`): the
69
+ * runtime binds its late reward evidence recovery at boot.
70
+ * - When the player becomes known, the services are created once; the
71
+ * caller runs the game runtime's `reconcile()` from `onActivated` (the
72
+ * startup recovery, with the action coordinator's). A later sign-in naming another player
73
+ * (not expected within one run) is ignored with a warning.
74
+ * - `accountDeletion` is `undefined` until then, and the services' after
75
+ * (read live); `onReady` tells Delete account to appear then.
76
+ * - `ensureSession` is present only when the configuration has a client
77
+ * (a disabled configuration has nothing a sign-in could bring up).
78
+ *
79
+ * `probe` is a runtime built from the same configuration with a placeholder
80
+ * player; only its `mode`, `reason`, `baseUrl`, `target`, and whether it has
81
+ * a client are read.
82
+ */
83
+ export function createSessionGatedGameServices(input: {
84
+ readonly probe: GameServicesRuntime;
85
+ readonly ensurePlayer: () => Promise<string | undefined>;
86
+ readonly onPlayer: (listener: (playerId: string) => void) => () => void;
87
+ readonly create: (playerId: string) => GameServicesRuntime;
88
+ /**
89
+ * Called once, when the services were created (by a background sign-in or
90
+ * one a purchase or restore started): run the game runtime's `reconcile()`.
91
+ */
92
+ readonly onActivated?: () => void;
93
+ readonly warn?: (message: string, error?: unknown) => void;
94
+ }): StarterSessionGatedServices {
95
+ const warn = input.warn ?? ((message: string, error?: unknown) => console.warn(message, error ?? ''));
96
+ let inner: GameServicesRuntime | undefined;
97
+ let innerPlayer: string | undefined;
98
+ const readyListeners = new Set<() => void>();
99
+
100
+ const activate = (playerId: string): void => {
101
+ if (inner !== undefined) {
102
+ if (playerId !== innerPlayer) {
103
+ warn(
104
+ '[game-services] a later sign-in named another player; the services keep the first until restart.',
105
+ );
106
+ }
107
+ return;
108
+ }
109
+ try {
110
+ inner = input.create(playerId);
111
+ innerPlayer = playerId;
112
+ } catch (error) {
113
+ // Stays gated: purchases keep answering the session refusal, never a raw error.
114
+ warn('[game-services] the services could not be created after sign-in', error);
115
+ return;
116
+ }
117
+ try {
118
+ input.onActivated?.();
119
+ } catch (error) {
120
+ // The services are up; only the caller's follow-up failed.
121
+ warn('[game-services] the activation callback failed', error);
122
+ }
123
+ for (const listener of [...readyListeners]) {
124
+ readyListeners.delete(listener);
125
+ try {
126
+ listener();
127
+ } catch (error) {
128
+ warn('[game-services] a ready listener failed', error);
129
+ }
130
+ }
131
+ };
132
+ input.onPlayer(activate);
133
+
134
+ const ready = async (): Promise<GameServicesRuntime> => {
135
+ if (inner === undefined) {
136
+ const playerId = await input.ensurePlayer();
137
+ if (playerId !== undefined) {
138
+ activate(playerId);
139
+ }
140
+ }
141
+ if (inner === undefined) {
142
+ throw new StarterSessionRequiredError();
143
+ }
144
+ return inner;
145
+ };
146
+ const readyClientOrUndefined = async (): Promise<GameServicesClient | undefined> => {
147
+ try {
148
+ return await readyClient();
149
+ } catch (error) {
150
+ if (error instanceof StarterSessionRequiredError) {
151
+ return undefined;
152
+ }
153
+ throw error;
154
+ }
155
+ };
156
+ const readyClient = async (): Promise<GameServicesClient> => {
157
+ const client = (await ready()).client;
158
+ if (client === undefined) {
159
+ throw new StarterSessionRequiredError();
160
+ }
161
+ return client;
162
+ };
163
+
164
+ let client: GameServicesClient | undefined;
165
+ if (input.probe.client !== undefined) {
166
+ client = {
167
+ purchase: async (purchase, options) => {
168
+ const ready = await readyClientOrUndefined();
169
+ return ready === undefined ? sessionRequiredPurchase() : ready.purchase(purchase, options);
170
+ },
171
+ claimRewardedAd: async (reward, options) => {
172
+ const ready = await readyClientOrUndefined();
173
+ return ready === undefined ? sessionRequiredReward() : ready.claimRewardedAd(reward, options);
174
+ },
175
+ submitLeaderboardScore: async (score) => (await readyClient()).submitLeaderboardScore(score),
176
+ };
177
+ // Read live: the reconciler of the services created after sign-in.
178
+ Object.defineProperty(client, 'commerceReconciler', {
179
+ enumerable: true,
180
+ get: () => inner?.client?.commerceReconciler,
181
+ });
182
+ }
183
+
184
+ const services = {
185
+ onReady(listener: () => void) {
186
+ readyListeners.add(listener);
187
+ return () => {
188
+ readyListeners.delete(listener);
189
+ };
190
+ },
191
+ ...(input.probe.reason === undefined ? {} : { reason: input.probe.reason }),
192
+ ...(input.probe.baseUrl === undefined ? {} : { baseUrl: input.probe.baseUrl }),
193
+ ...(input.probe.target === undefined ? {} : { target: input.probe.target }),
194
+ ...(client === undefined
195
+ ? {}
196
+ : {
197
+ client,
198
+ // Only a configuration with a client can come up; a disabled one offers nothing to retry.
199
+ async ensureSession() {
200
+ try {
201
+ await ready();
202
+ return true;
203
+ } catch {
204
+ return false;
205
+ }
206
+ },
207
+ }),
208
+ } as StarterSessionGatedServices;
209
+ // Read live: `undefined` until the player is known, so rewarded ads stay off until then,
210
+ // and Delete account appears once there is an account (the lobby's next visit).
211
+ Object.defineProperties(services, {
212
+ mode: { enumerable: true, get: () => inner?.mode ?? input.probe.mode },
213
+ monetizationRecovery: { enumerable: true, get: () => inner?.monetizationRecovery },
214
+ accountDeletion: { enumerable: true, get: () => inner?.accountDeletion },
215
+ });
216
+ return services;
217
+ }
@@ -1,14 +1,31 @@
1
1
  import Phaser from 'phaser';
2
2
 
3
3
  import { t } from '../i18n/messages';
4
+ import {
5
+ createStarterAccountDeletion,
6
+ deleteAccountConfirmText,
7
+ deleteAccountOutcomeText,
8
+ starterAccountDeletionConfirmation,
9
+ type StarterDeleteAccountOutcome,
10
+ } from '../platform/deleteAccount';
11
+ import {
12
+ createStarterRestorePurchases,
13
+ restoreOutcomeText,
14
+ type StarterRestoreOutcome,
15
+ } from '../platform/restorePurchases';
16
+ import { resolveStarterServerSession } from '../platform/serverSession';
4
17
  import { starterContextKey, type StarterContext } from '../runtime/gameContext';
5
18
 
6
19
  export class LobbyScene extends Phaser.Scene {
20
+ /** True while the Delete account panel is open; Enter then does not start the game. */
21
+ private deleteAccountOpen = false;
22
+
7
23
  constructor() {
8
24
  super('LobbyScene');
9
25
  }
10
26
 
11
27
  create(): void {
28
+ this.deleteAccountOpen = false;
12
29
  const rawContext = this.registry.get(starterContextKey);
13
30
 
14
31
  if (rawContext === undefined || rawContext === null) {
@@ -92,7 +109,243 @@ export class LobbyScene extends Phaser.Scene {
92
109
  })
93
110
  .setOrigin(0.5);
94
111
 
112
+ this.addRestorePurchasesButton(context);
113
+ this.addDeleteAccountEntry(context);
114
+
95
115
  this.input.once('pointerdown', () => this.scene.start('PlayScene'));
96
- this.input.keyboard?.once('keydown-ENTER', () => this.scene.start('PlayScene'));
116
+ const onEnter = (): void => {
117
+ if (!this.deleteAccountOpen) {
118
+ this.input.keyboard?.off('keydown-ENTER', onEnter);
119
+ this.scene.start('PlayScene');
120
+ }
121
+ };
122
+ this.input.keyboard?.on('keydown-ENTER', onEnter);
123
+ }
124
+
125
+ /**
126
+ * Restore Purchases, shown where the target sells through a store. App
127
+ * Review expects it while the catalog sells a non-consumable (REMOVE_ADS).
128
+ * Its tap stops propagation so it does not also start the game.
129
+ */
130
+ private addRestorePurchasesButton(context: StarterContext): void {
131
+ const services = context.gameServices;
132
+ const restore = createStarterRestorePurchases({
133
+ gateway: context.platform,
134
+ // Read at tap time: a run that signs in late gets its recovery client then.
135
+ monetizationRecovery: () => services.monetizationRecovery,
136
+ ...(services.ensureSession === undefined ? {} : { ensureSession: services.ensureSession }),
137
+ });
138
+ if (!restore.available) {
139
+ return;
140
+ }
141
+ const locale = context.locale;
142
+ const status = this.add
143
+ .text(480, 500, '', {
144
+ color: '#d6dee8',
145
+ fontFamily: 'Arial, sans-serif',
146
+ fontSize: '16px',
147
+ })
148
+ .setOrigin(0.5);
149
+ this.add
150
+ .text(480, 456, t(locale, 'restorePurchases'), {
151
+ color: '#5eead4',
152
+ fontFamily: 'Arial, sans-serif',
153
+ fontSize: '20px',
154
+ })
155
+ .setOrigin(0.5)
156
+ .setPadding(16, 10, 16, 10)
157
+ .setInteractive({ useHandCursor: true })
158
+ .on(
159
+ 'pointerdown',
160
+ (
161
+ _pointer: Phaser.Input.Pointer,
162
+ _x: number,
163
+ _y: number,
164
+ event: Phaser.Types.Input.EventData,
165
+ ) => {
166
+ event.stopPropagation();
167
+ status.setText(t(locale, 'restoreWorking'));
168
+ void restore.restore()
169
+ .catch((): StarterRestoreOutcome => ({ outcome: 'failed' }))
170
+ .then((result) => {
171
+ if (status.active) {
172
+ status.setText(restoreOutcomeText(locale, result));
173
+ }
174
+ });
175
+ },
176
+ );
177
+ }
178
+ /**
179
+ * Delete account, shown where the game has a game-services backend (App
180
+ * Store guideline 5.1.1(v) and Google Play require deletion in the app).
181
+ * The first tap explains what is deleted and kept; only the second,
182
+ * "Delete permanently", deletes. Taps stop propagation so they do not
183
+ * also start the game.
184
+ */
185
+ private addDeleteAccountEntry(context: StarterContext): void {
186
+ // No backend yet: read no session, which `once` would pin. A run whose guest sign-in
187
+ // failed at boot gets the backend after a later sign-in (`onReady`); add the entry then.
188
+ if (context.gameServices.accountDeletion === undefined) {
189
+ const onReady = (context.gameServices as { readonly onReady?: (listener: () => void) => () => void })
190
+ .onReady;
191
+ if (onReady !== undefined) {
192
+ const stop = onReady(() => {
193
+ stop();
194
+ if (this.scene.isActive()) {
195
+ this.addDeleteAccountEntry(context);
196
+ }
197
+ });
198
+ this.events.once('shutdown', stop);
199
+ }
200
+ return;
201
+ }
202
+ // The commerce client's session (`once`), so the entry and the request agree.
203
+ const session = resolveStarterServerSession({ once: true });
204
+ const confirmation = starterAccountDeletionConfirmation(session);
205
+ const deletion = createStarterAccountDeletion({
206
+ ...(context.gameServices.accountDeletion === undefined
207
+ ? {}
208
+ : { client: context.gameServices.accountDeletion }),
209
+ storage: context.platform.storage,
210
+ ...(context.platform.secureCredentials === undefined
211
+ ? {}
212
+ : { secureCredentials: context.platform.secureCredentials }),
213
+ signedIn: () => session?.current() !== undefined,
214
+ // The identity service's confirmation, so an access token alone cannot delete the account.
215
+ ...(confirmation === undefined ? {} : { requestConfirmation: confirmation }),
216
+ });
217
+ if (!deletion.available) {
218
+ if (context.gameServices.accountDeletion !== undefined && session !== undefined) {
219
+ // Signed in later (a guest session starts in the background): add it then.
220
+ const stop = session.onAuthenticated(() => {
221
+ stop();
222
+ if (this.scene.isActive()) {
223
+ this.addDeleteAccountEntry(context);
224
+ }
225
+ });
226
+ this.events.once('shutdown', stop);
227
+ }
228
+ return;
229
+ }
230
+ const locale = context.locale;
231
+ const swallow = (
232
+ _pointer: Phaser.Input.Pointer,
233
+ _x: number,
234
+ _y: number,
235
+ event: Phaser.Types.Input.EventData,
236
+ ): void => {
237
+ event.stopPropagation();
238
+ };
239
+ const entry = this.add
240
+ .text(944, 16, t(locale, 'deleteAccount'), {
241
+ color: '#fca5a5',
242
+ fontFamily: 'Arial, sans-serif',
243
+ fontSize: '16px',
244
+ })
245
+ .setOrigin(1, 0)
246
+ .setPadding(12, 8, 12, 8)
247
+ .setInteractive({ useHandCursor: true });
248
+ const panel = this.add.container(0, 0).setVisible(false).setDepth(10);
249
+ const backdrop = this.add
250
+ .rectangle(480, 270, 960, 540, 0x020617, 0.92)
251
+ .setInteractive()
252
+ .on('pointerdown', swallow);
253
+ const message = this.add
254
+ .text(480, 200, '', {
255
+ align: 'center',
256
+ color: '#ffffff',
257
+ fontFamily: 'Arial, sans-serif',
258
+ fontSize: '18px',
259
+ wordWrap: { width: 760 },
260
+ })
261
+ .setOrigin(0.5);
262
+ const status = this.add
263
+ .text(480, 470, '', {
264
+ color: '#d6dee8',
265
+ fontFamily: 'Arial, sans-serif',
266
+ fontSize: '16px',
267
+ })
268
+ .setOrigin(0.5);
269
+ const confirm = this.add
270
+ .text(360, 400, t(locale, 'deleteAccountConfirmButton'), {
271
+ backgroundColor: '#b91c1c',
272
+ color: '#ffffff',
273
+ fontFamily: 'Arial, sans-serif',
274
+ fontSize: '20px',
275
+ })
276
+ .setOrigin(0.5)
277
+ .setPadding(16, 10, 16, 10)
278
+ .setInteractive({ useHandCursor: true });
279
+ const cancel = this.add
280
+ .text(600, 400, t(locale, 'deleteAccountCancel'), {
281
+ color: '#ffffff',
282
+ fontFamily: 'Arial, sans-serif',
283
+ fontSize: '20px',
284
+ })
285
+ .setOrigin(0.5)
286
+ .setPadding(16, 10, 16, 10)
287
+ .setInteractive({ useHandCursor: true });
288
+ panel.add([backdrop, message, status, confirm, cancel]);
289
+ let deleting = false;
290
+ let signedOut = false;
291
+ entry.on('pointerdown', (...args: Parameters<typeof swallow>) => {
292
+ swallow(...args);
293
+ if (deleting) {
294
+ return;
295
+ }
296
+ this.deleteAccountOpen = true;
297
+ status.setText('');
298
+ confirm.setVisible(true);
299
+ cancel.setText(t(locale, 'deleteAccountCancel'));
300
+ message.setText(deleteAccountConfirmText(locale, false));
301
+ panel.setVisible(true);
302
+ void deletion.hasPendingPurchase().then((pending) => {
303
+ if (pending && message.active) {
304
+ message.setText(deleteAccountConfirmText(locale, true));
305
+ }
306
+ });
307
+ });
308
+ cancel.on('pointerdown', (...args: Parameters<typeof swallow>) => {
309
+ swallow(...args);
310
+ if (deleting) {
311
+ return;
312
+ }
313
+ if (signedOut) {
314
+ // A fresh start drops every in-memory trace of the deleted player
315
+ // (the session, the reconciler, the notifications runtime).
316
+ globalThis.location.reload();
317
+ return;
318
+ }
319
+ this.deleteAccountOpen = false;
320
+ panel.setVisible(false);
321
+ });
322
+ confirm.on('pointerdown', (...args: Parameters<typeof swallow>) => {
323
+ swallow(...args);
324
+ if (deleting) {
325
+ return;
326
+ }
327
+ deleting = true;
328
+ confirm.setAlpha(0.5);
329
+ cancel.setAlpha(0.5);
330
+ status.setText(t(locale, 'deleteAccountWorking'));
331
+ void deletion.deleteAccount()
332
+ .catch((): StarterDeleteAccountOutcome => ({ outcome: 'failed', retryable: true }))
333
+ .then((result) => {
334
+ deleting = false;
335
+ if (!status.active) {
336
+ return;
337
+ }
338
+ confirm.setAlpha(1);
339
+ cancel.setAlpha(1);
340
+ status.setText(deleteAccountOutcomeText(locale, result));
341
+ if (result.outcome === 'deleted' || result.outcome === 'session-ended') {
342
+ // Signed out: the entry goes; Close restarts the game.
343
+ signedOut = true;
344
+ confirm.setVisible(false);
345
+ entry.setVisible(false);
346
+ cancel.setText(t(locale, 'deleteAccountClose'));
347
+ }
348
+ });
349
+ });
97
350
  }
98
351
  }
@@ -9,6 +9,7 @@ import { createStarterRunState, stepStarterRunState, type StarterRunState } from
9
9
  import { t } from '../i18n/messages';
10
10
  import { starterContextKey, type StarterContext } from '../runtime/gameContext';
11
11
  import { createClientId } from '../runtime/id';
12
+ import { starterRunKey } from '../platform/deleteAccount';
12
13
 
13
14
  const rewardedPlacementId = 'CONTINUE_AFTER_FAIL' satisfies LogicalAdPlacementId;
14
15
 
@@ -33,7 +34,7 @@ export class PlayScene extends Phaser.Scene {
33
34
  const checkpoint = () => {
34
35
  try {
35
36
  this.context.platform.storage.saveSync?.({
36
- key: 'starter-run',
37
+ key: starterRunKey,
37
38
  value: this.state,
38
39
  });
39
40
  } catch (error) {
@@ -114,10 +115,20 @@ export class PlayScene extends Phaser.Scene {
114
115
  }
115
116
  }
116
117
 
117
- private rewardHint(): string {
118
- const rewardedAds = this.context.runtime.features.rewardedAds;
118
+ /**
119
+ * Rewarded ads need the target feature and the recovery client, and a run
120
+ * that booted before its guest sign-in (session-gated services) shows none,
121
+ * matching the runtime's `canShow` in `main.ts`.
122
+ */
123
+ private rewardedAdsAvailable(): boolean {
124
+ const services = this.context.gameServices;
125
+ return this.context.runtime.features.rewardedAds.enabled
126
+ && services.monetizationRecovery !== undefined
127
+ && services.ensureSession === undefined;
128
+ }
119
129
 
120
- if (!rewardedAds.enabled || this.context.gameServices.client === undefined || this.context.gameServices.monetizationRecovery === undefined) {
130
+ private rewardHint(): string {
131
+ if (!this.rewardedAdsAvailable() || this.context.gameServices.client === undefined) {
121
132
  return t(this.context.locale, 'rewardUnavailable');
122
133
  }
123
134
 
@@ -125,10 +136,9 @@ export class PlayScene extends Phaser.Scene {
125
136
  }
126
137
 
127
138
  private async requestRewardedAd(): Promise<void> {
128
- const rewardedAds = this.context.runtime.features.rewardedAds;
129
139
  const action = this.rewardedAction;
130
140
 
131
- if (!rewardedAds.enabled || action === undefined || this.context.gameServices.monetizationRecovery === undefined) {
141
+ if (!this.rewardedAdsAvailable() || action === undefined) {
132
142
  this.rewardText.setText(t(this.context.locale, 'rewardUnavailable'));
133
143
  return;
134
144
  }