@mpgd/cli 0.53.0 → 0.54.1

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.
@@ -0,0 +1,157 @@
1
+ import {
2
+ isPublicGameServicesUrl,
3
+ resolveGameServicesAuthorityMode,
4
+ type GameServicesHeaderResolver,
5
+ } from '@mpgd/game-services';
6
+ import {
7
+ createGameServicesNotificationsClient,
8
+ type GameServicesNotificationIdentity,
9
+ type GameServicesNotificationsFetch,
10
+ } from '@mpgd/game-services/notifications/client';
11
+ import type {
12
+ NotificationConsentRecord,
13
+ NotificationRuntime,
14
+ NotificationTopicDeclaration,
15
+ } from '@mpgd/platform/notifications';
16
+ import {
17
+ createNotificationRuntime,
18
+ createNotificationRuntimeStorage,
19
+ } from '@mpgd/platform/notifications-runtime';
20
+ import type { TargetConfiguredGateway } from '@mpgd/target-config';
21
+
22
+ /**
23
+ * The game backend's notifications surface the starter uses: the runtime
24
+ * (notifications RFC "Runtime"), which composes the target's provider with
25
+ * the backend that keeps consent records, endpoint registrations, server
26
+ * reminders, and notices; and the signed-in player's consent records (the
27
+ * game-services Worker's `GET /consent`), which the settings screen shows.
28
+ */
29
+ export interface StarterNotificationBackend {
30
+ readonly runtime: NotificationRuntime;
31
+ listConsent(): Promise<readonly NotificationConsentRecord[]>;
32
+ }
33
+
34
+ /** The server-backed session the Worker client needs (see `serverSession.ts`). */
35
+ export interface StarterWorkerSession extends GameServicesNotificationIdentity {
36
+ readonly getHeaders: GameServicesHeaderResolver;
37
+ }
38
+
39
+ export interface StarterWorkerNotificationInput {
40
+ /** `VITE_MPGD_GAME_SERVICES_URL`. */
41
+ readonly baseUrl: string | undefined;
42
+ /** The Vite mode (`import.meta.env.MODE`), which decides the production URL rule. */
43
+ readonly mode: string;
44
+ readonly session: StarterWorkerSession | undefined;
45
+ /** This target's topics, each with its mapping. */
46
+ readonly topics: readonly NotificationTopicDeclaration[];
47
+ readonly defaultLocale: string;
48
+ /** Logs a message, once per gateway and message. */
49
+ readonly log: (message: string, error?: unknown) => void;
50
+ /** Defaults to `globalThis.fetch`. */
51
+ readonly fetch?: GameServicesNotificationsFetch;
52
+ }
53
+
54
+ /**
55
+ * Config targets whose notifications backend is not the game-services
56
+ * Worker. A Reddit game's backend is its Devvit server: the opt-in, the
57
+ * consent records, and the sends live there, and the Worker serves no
58
+ * `reddit` target. A runtime over the Worker would send the game's session
59
+ * header to a backend that cannot use it, so Reddit keeps no backend here,
60
+ * as before R1, until the Devvit server has a runtime client of its own.
61
+ */
62
+ const targetsWithoutWorkerBackend: ReadonlySet<string> = new Set(['reddit']);
63
+
64
+ /**
65
+ * The notifications runtime over the game-services Worker's notification
66
+ * routes (`@mpgd/game-services/notifications/client`), when the target's
67
+ * backend is the Worker, the game has a game-services URL and a
68
+ * server-backed session, and the target exposes a notifications provider.
69
+ * Otherwise `undefined`.
70
+ *
71
+ * The runtime keeps its registration state in the platform storage, starts
72
+ * here, and compares its provider with the Worker's registry entry for this
73
+ * target (`GET /providers`), loaded in the background: a Worker it cannot
74
+ * reach is logged once and the runtime runs without the comparison.
75
+ */
76
+ export function createStarterWorkerNotificationBackend(
77
+ platform: TargetConfiguredGateway,
78
+ input: StarterWorkerNotificationInput,
79
+ ): StarterNotificationBackend | undefined {
80
+ // By platform, and by config target name for a gateway that reports another platform.
81
+ if (platform.target === 'reddit' || targetsWithoutWorkerBackend.has(platform.configTarget)) {
82
+ return undefined;
83
+ }
84
+ const baseUrl = input.baseUrl?.trim();
85
+ const provider = platform.notificationsV2;
86
+ if (baseUrl === undefined || baseUrl === '' || provider === undefined) {
87
+ return undefined;
88
+ }
89
+ const { session } = input;
90
+ if (session === undefined) {
91
+ input.log(
92
+ '[notifications] VITE_MPGD_GAME_SERVICES_URL is set but no server session is: call setStarterServerSession '
93
+ + '(src/platform/serverSession.ts) before the game starts. The settings screen shows no switch until then.',
94
+ );
95
+ return undefined;
96
+ }
97
+ try {
98
+ if (!isAllowedGameServicesUrl(baseUrl, input.mode)) {
99
+ // The session's credentials never travel in clear text or to a private host in production.
100
+ input.log(
101
+ '[notifications] VITE_MPGD_GAME_SERVICES_URL must be an absolute http(s) URL, and a public https URL '
102
+ + 'without credentials in production; notifications stay off.',
103
+ );
104
+ return undefined;
105
+ }
106
+ const client = createGameServicesNotificationsClient({
107
+ baseUrl,
108
+ identity: session,
109
+ getHeaders: () => session.getHeaders(),
110
+ // The catalog target whose mapping applies to this device (android or ios share FCM).
111
+ target: platform.configTarget,
112
+ ...(input.fetch === undefined ? {} : { fetch: input.fetch }),
113
+ });
114
+ const runtime = createNotificationRuntime({
115
+ provider,
116
+ backend: client,
117
+ catalog: input.topics,
118
+ defaultLocale: input.defaultLocale,
119
+ storage: createNotificationRuntimeStorage({ storage: platform.storage }),
120
+ // This target's entry in the Worker's registry, or none when the Worker does not serve it.
121
+ async loadRegistryEntry() {
122
+ const { providers, targets } = await client.getProviders();
123
+ const id = targets[platform.configTarget];
124
+ return id === undefined ? undefined : providers.find((entry) => entry.provider === id);
125
+ },
126
+ onError(error) {
127
+ console.error('[notifications]', error);
128
+ },
129
+ });
130
+ // It subscribes to the provider at once; a backend that cannot answer yet does not fail the game.
131
+ runtime.start().catch((error: unknown) => {
132
+ console.error('[notifications] the runtime could not start.', error);
133
+ });
134
+ return { runtime, listConsent: () => client.listConsent() };
135
+ } catch (error) {
136
+ // A configuration the client refuses turns notifications off, never the game.
137
+ input.log('[notifications] the notifications backend could not be created.', error);
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Whether the game-services URL may carry the session's credentials: any
144
+ * absolute http(s) URL outside production builds, and in production the
145
+ * public https URL the commerce client requires (`isPublicGameServicesUrl`).
146
+ */
147
+ function isAllowedGameServicesUrl(baseUrl: string, mode: string): boolean {
148
+ if (resolveGameServicesAuthorityMode(mode) === 'production') {
149
+ return isPublicGameServicesUrl(baseUrl);
150
+ }
151
+ try {
152
+ const { protocol } = new URL(baseUrl);
153
+ return protocol === 'https:' || protocol === 'http:';
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
@@ -1,13 +1,13 @@
1
1
  import { resolveNotificationTopicsForTarget, type NotificationTopicCatalog } from '@mpgd/catalog';
2
- import type { PlatformGateway } from '@mpgd/platform';
2
+ import type { NotificationTopicDeclaration } from '@mpgd/platform/notifications';
3
3
  import type { TargetConfiguredGateway } from '@mpgd/target-config';
4
- import type {
5
- NotificationConsentRecord,
6
- NotificationRuntime,
7
- NotificationTopicDeclaration,
8
- } from '@mpgd/platform/notifications';
9
4
 
10
5
  import notificationTopicsJson from '../../notifications/topics.json';
6
+ import {
7
+ createStarterWorkerNotificationBackend,
8
+ type StarterNotificationBackend,
9
+ } from './notificationBackend';
10
+ import { resolveStarterServerSession } from './serverSession';
11
11
  import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
12
12
  import {
13
13
  createStarterNotificationSettings,
@@ -43,45 +43,77 @@ export function starterNotificationTopics(configTarget: string): readonly Notifi
43
43
  }
44
44
  }
45
45
 
46
- /**
47
- * The game backend's notifications surface the starter uses: the runtime
48
- * (notifications RFC "Runtime"), which composes the target's provider with
49
- * the backend that keeps consent records, endpoint registrations, server
50
- * reminders, and notices; and the signed-in player's consent records (the
51
- * game-services Worker's `GET /consent`), which the settings screen shows.
52
- */
53
- export interface StarterNotificationBackend {
54
- readonly runtime: NotificationRuntime;
55
- listConsent(): Promise<readonly NotificationConsentRecord[]>;
56
- }
46
+ export type { StarterNotificationBackend } from './notificationBackend';
57
47
 
58
48
  /**
59
- * The game's notifications backend.
49
+ * The game's notifications backend: the notifications runtime over the
50
+ * game-services Worker's notification routes (see `notificationBackend.ts`),
51
+ * when the game has both a game-services URL (`VITE_MPGD_GAME_SERVICES_URL`,
52
+ * as the commerce client uses) and a server-backed session
53
+ * (`setStarterServerSession` in `serverSession.ts`), and the target exposes
54
+ * a notifications provider. Otherwise `undefined`: the notification settings
55
+ * screen then asks only for the OS or host permission and shows no
56
+ * reminder, advertising, or night switch, because nothing could record them.
60
57
  *
61
- * The kit does not ship that runtime's backend client yet: the game-services
62
- * Worker's notification routes need a session from the identity side, which
63
- * comes in a later kit release. Until then this returns `undefined`, and the
64
- * notification settings screen asks only for the OS or host permission and
65
- * shows no reminder, advertising, or night switch, because nothing could
66
- * record them. Return your own runtime and consent reader here when your
67
- * backend implements those routes.
58
+ * Reddit gets no Worker backend: its notifications backend is the Devvit
59
+ * server, which keeps the opt-in, the records, and the sends. The Worker
60
+ * serves the Android, iOS, and Apps in Toss providers and binds a device to
61
+ * the session its `Authorization` header names, through its
62
+ * `GAME_SERVICES_NOTIFICATIONS_AUTH` binding to your identity service
63
+ * (docs/CLOUDFLARE_WORKER_DEPLOY.md, "Sessions"). Call this through the
64
+ * settings model or `subscribeStarterNotificationOpens`, which keep one
65
+ * runtime per gateway: a second call starts a second runtime on the same
66
+ * provider.
68
67
  */
69
68
  export function createStarterNotificationBackend(
70
- _platform: PlatformGateway,
69
+ platform: TargetConfiguredGateway,
71
70
  ): StarterNotificationBackend | undefined {
72
- return undefined;
71
+ return createStarterWorkerNotificationBackend(platform, {
72
+ baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL,
73
+ mode: import.meta.env.MODE,
74
+ session: resolveStarterServerSession(),
75
+ topics: starterNotificationTopics(platform.configTarget),
76
+ defaultLocale: starterNotificationCatalog.defaultLocale,
77
+ log: (message, error) => warnOnce(platform, message, error),
78
+ });
73
79
  }
74
80
 
75
- const backends = new WeakMap<PlatformGateway, StarterNotificationBackend | undefined>();
81
+ /** Messages already logged per gateway: a screen opened again does not log them again. */
82
+ const warned = new WeakMap<TargetConfiguredGateway, Set<string>>();
83
+ function warnOnce(platform: TargetConfiguredGateway, message: string, error?: unknown): void {
84
+ let messages = warned.get(platform);
85
+ if (messages === undefined) {
86
+ messages = new Set();
87
+ warned.set(platform, messages);
88
+ }
89
+ if (!messages.has(message)) {
90
+ messages.add(message);
91
+ if (error === undefined) {
92
+ console.warn(message);
93
+ } else {
94
+ console.error(message, error);
95
+ }
96
+ }
97
+ }
98
+
99
+ const backends = new WeakMap<TargetConfiguredGateway, StarterNotificationBackend>();
76
100
  /** Per gateway, for the process: agreements given on a host that gives no read. */
77
- const agreements = new WeakMap<PlatformGateway, Set<string>>();
101
+ const agreements = new WeakMap<TargetConfiguredGateway, Set<string>>();
78
102
 
79
- /** One backend per gateway: a runtime subscribes to its provider once, for the process. */
80
- function starterNotificationBackend(platform: PlatformGateway): StarterNotificationBackend | undefined {
81
- if (!backends.has(platform)) {
82
- backends.set(platform, createStarterNotificationBackend(platform));
103
+ /**
104
+ * One backend per gateway: a runtime subscribes to its provider once, for the
105
+ * process. A gateway without one yet (no session set) is asked again on the
106
+ * next use.
107
+ */
108
+ function starterNotificationBackend(platform: TargetConfiguredGateway): StarterNotificationBackend | undefined {
109
+ let backend = backends.get(platform);
110
+ if (backend === undefined) {
111
+ backend = createStarterNotificationBackend(platform);
112
+ if (backend !== undefined) {
113
+ backends.set(platform, backend);
114
+ }
83
115
  }
84
- return backends.get(platform);
116
+ return backend;
85
117
  }
86
118
 
87
119
  /** The settings screen's model for this gateway and locale. */
@@ -121,7 +153,7 @@ export function createStarterNotificationSettingsModel(
121
153
  * the game has one, else the provider. Returns the unsubscribe.
122
154
  */
123
155
  export function subscribeStarterNotificationOpens(
124
- platform: PlatformGateway,
156
+ platform: TargetConfiguredGateway,
125
157
  routes: NotificationOpenRoutes,
126
158
  ): () => void {
127
159
  const source = starterNotificationBackend(platform)?.runtime ?? platform.notificationsV2;
@@ -16,8 +16,13 @@ export const notificationSettingsSceneKey = 'NotificationSettingsScene';
16
16
 
17
17
  const textStyle = { color: '#d6dee8', fontFamily: 'Arial, sans-serif', fontSize: '18px' } as const;
18
18
  const buttonStyle = { ...textStyle, color: '#5eead4' } as const;
19
- /** The list's viewport on the 960x540 game: below the title and the outcome strip, above Back. */
20
- const list = { top: 110, height: 370, width: 960 } as const;
19
+ /** List rows and the outcome strip share this column (x=60..900) on the 960x540 game. */
20
+ const listTextWidth = 840;
21
+ /** Below the title. The outcome strip grows downward from here. */
22
+ const outcomeTop = 68;
23
+ /** The list ends above the Back button. */
24
+ const listBottom = 480;
25
+ const minListTop = 110;
21
26
 
22
27
  /**
23
28
  * Routes notification opens: one whose route is the settings screen (the
@@ -108,10 +113,17 @@ export class NotificationSettingsScene extends Phaser.Scene {
108
113
  this.scene.start('LobbyScene');
109
114
  }).setOrigin(0.5);
110
115
  this.outcome = this.add
111
- .text(480, 88, '', { ...textStyle, color: '#f59e0b', fontSize: '16px' })
112
- .setOrigin(0.5);
116
+ .text(480, outcomeTop, '', {
117
+ ...textStyle,
118
+ align: 'center',
119
+ color: '#f59e0b',
120
+ fontSize: '16px',
121
+ wordWrap: { width: listTextWidth },
122
+ })
123
+ .setOrigin(0.5, 0);
113
124
  this.content = this.add.container(0, 0);
114
- this.listCamera = this.cameras.add(0, list.top, list.width, list.height);
125
+ this.listCamera = this.cameras.add(0, minListTop, 960, listBottom - minListTop);
126
+ this.placeListBelowOutcome();
115
127
  this.listCamera.ignore([title, back, this.outcome]);
116
128
  this.cameras.main.ignore(this.content);
117
129
  this.enableScroll();
@@ -136,9 +148,15 @@ export class NotificationSettingsScene extends Phaser.Scene {
136
148
  });
137
149
  }
138
150
 
151
+ private placeListBelowOutcome(): void {
152
+ const top = Math.max(minListTop, Math.ceil(this.outcome.y + this.outcome.height) + 8);
153
+ this.listCamera.setPosition(0, top);
154
+ this.listCamera.setSize(960, Math.max(0, listBottom - top));
155
+ }
156
+
139
157
  private scrollTo(scrollY: number): void {
140
158
  const height = this.content.getBounds().height;
141
- this.listCamera.scrollY = Phaser.Math.Clamp(scrollY, 0, Math.max(0, height - list.height));
159
+ this.listCamera.scrollY = Phaser.Math.Clamp(scrollY, 0, Math.max(0, height - this.listCamera.height));
142
160
  }
143
161
 
144
162
  private async apply(visit: number, action: () => Promise<StarterNotificationSettingsView>): Promise<void> {
@@ -172,7 +190,7 @@ export class NotificationSettingsScene extends Phaser.Scene {
172
190
  const visit = this.visit;
173
191
  let y = 0;
174
192
  const line = (value: string, style: Phaser.Types.GameObjects.Text.TextStyle = textStyle) => {
175
- const object = this.add.text(60, y, value, { ...style, wordWrap: { width: 840 } });
193
+ const object = this.add.text(60, y, value, { ...style, wordWrap: { width: listTextWidth } });
176
194
  this.content.add(object);
177
195
  y += object.height + 10;
178
196
  };
@@ -186,6 +204,7 @@ export class NotificationSettingsScene extends Phaser.Scene {
186
204
 
187
205
  // The outcome of the last tap stays in view above the list, wherever it is scrolled.
188
206
  this.outcome.setText(view.message ?? '');
207
+ this.placeListBelowOutcome();
189
208
  if (!view.available) {
190
209
  if (view.message === undefined) {
191
210
  line(text.unavailable);
@@ -15,6 +15,7 @@ import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
15
15
 
16
16
  import { resolveStarterCommerceProducts } from './commerceProducts';
17
17
  import { createStorageMonetizationOperationStore } from './monetizationJournal';
18
+ import { resolveStarterServerSession } from './serverSession';
18
19
 
19
20
  const productCatalog = productCatalogJson as ProductCatalog;
20
21
 
@@ -47,6 +48,7 @@ export function createStarterGameServices(input: {
47
48
  readonly analytics?: AnalyticsSink;
48
49
  readonly analyticsSessionId?: string;
49
50
  }): StarterGameServices {
51
+ const session = resolveStarterServerSession({ once: true });
50
52
  return createGameServicesRuntime({
51
53
  gateway: input.gateway,
52
54
  playerId: input.playerId,
@@ -64,5 +66,6 @@ export function createStarterGameServices(input: {
64
66
  ? {}
65
67
  : { baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL }),
66
68
  transport: resolveGameServicesTransport(import.meta.env.VITE_MPGD_GAME_SERVICES_TRANSPORT),
69
+ ...(session === undefined ? {} : { getHeaders: () => session.getHeaders() }),
67
70
  });
68
71
  }
@@ -0,0 +1,66 @@
1
+ import type { GameServicesHeaderResolver } from '@mpgd/game-services';
2
+
3
+ /**
4
+ * The game's server-backed session: who is signed in to the game backend,
5
+ * and the `Authorization` header for that session. The starter has none, so
6
+ * `resolveStarterServerSession` returns `undefined`. Set yours with
7
+ * `setStarterServerSession`, over your own session holder and identity
8
+ * service. The commerce client then sends its header. In a game created with
9
+ * `--providers notifications`, the notifications runtime uses the same
10
+ * session and header for the game-services Worker's notification routes.
11
+ *
12
+ * `sessionId` must be the stable session-family id: the `sessionId` your
13
+ * identity service answers to the Worker's `authenticateNotificationSession`
14
+ * for this session's header, the same across token refreshes. Do not pass a
15
+ * per-token or rotating id: a `GuestSessionCoordinator` view's `sessionId`
16
+ * may rotate on refresh, so it is not that id.
17
+ */
18
+ export interface StarterServerSession {
19
+ /**
20
+ * The signed-in player and session, or `undefined` while signed out.
21
+ * `sessionId` is the stable session-family id (see above).
22
+ */
23
+ current(): { readonly playerId: string; readonly sessionId: string } | undefined;
24
+ /** Calls `listener` when a session becomes current (sign-in, restore, account switch); returns the unsubscribe. */
25
+ onAuthenticated(
26
+ listener: (session: { readonly playerId: string; readonly sessionId: string }) => void,
27
+ ): () => void;
28
+ /** The current session's request headers, such as `{ authorization: 'Bearer …' }`. */
29
+ readonly getHeaders: GameServicesHeaderResolver;
30
+ }
31
+
32
+ let serverSession: StarterServerSession | undefined;
33
+ /** Whether the commerce client read the session already: it reads it once. */
34
+ let readOnce = false;
35
+
36
+ /**
37
+ * The game's server-backed session; `undefined` until the game sets one with
38
+ * `setStarterServerSession`. `once` marks a reader that keeps what it reads
39
+ * (the commerce client), so a later change warns.
40
+ */
41
+ export function resolveStarterServerSession(
42
+ options: { readonly once?: boolean } = {},
43
+ ): StarterServerSession | undefined {
44
+ if (options.once === true) {
45
+ readOnce = true;
46
+ }
47
+ return serverSession;
48
+ }
49
+
50
+ /**
51
+ * Sets the game's server-backed session. Call it at the top of `main.ts`,
52
+ * before `bootstrap()`: `createStarterGameServices` reads it once, and the
53
+ * notification settings and opens read it until a backend exists. Its
54
+ * `current()` may answer `undefined` until the player signs in. A session
55
+ * set after the commerce client was created does not reach that client,
56
+ * which this warns about.
57
+ */
58
+ export function setStarterServerSession(session: StarterServerSession | undefined): void {
59
+ if (readOnce && session !== undefined && session !== serverSession) {
60
+ console.warn(
61
+ '[game-services] setStarterServerSession ran after the commerce client was created; '
62
+ + "it does not send this session's header. Call it before bootstrap().",
63
+ );
64
+ }
65
+ serverSession = session;
66
+ }