@mpgd/cli 0.52.0 → 0.54.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 (29) hide show
  1. package/dist/capacitor-shell-starter.d.ts +22 -0
  2. package/dist/capacitor-shell-starter.js +395 -18
  3. package/dist/deploy-planning.js +2 -2
  4. package/dist/index.js +122 -34
  5. package/dist/kit-upgrade.js +2 -2
  6. package/dist/native-build-info.json +2 -2
  7. package/dist/native-build-target.js +433 -2
  8. package/dist/notifications-starter.d.ts +75 -0
  9. package/dist/notifications-starter.js +522 -0
  10. package/dist/offline-playtest.js +1 -1
  11. package/dist/tooling-migration.js +1 -1
  12. package/package.json +16 -16
  13. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +104 -0
  14. package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
  15. package/templates/notifications-overlay/common/notifications/topics.json +222 -0
  16. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
  17. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
  18. package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
  19. package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
  20. package/templates/notifications-overlay/common/src/platform/notifications.ts +163 -0
  21. package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
  22. package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
  23. package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +301 -0
  24. package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
  25. package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
  26. package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
  27. package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
  28. package/templates/phaser-game/src/platform/gameServices.ts +3 -0
  29. package/templates/phaser-game/src/platform/serverSession.ts +66 -0
@@ -0,0 +1,301 @@
1
+ import Phaser from 'phaser';
2
+
3
+ import {
4
+ starterNotificationLabelsFor,
5
+ type StarterNotificationLabels,
6
+ type StarterNotificationSettings,
7
+ type StarterNotificationSettingsView,
8
+ } from '../platform/notificationSettings';
9
+ import {
10
+ createStarterNotificationSettingsModel,
11
+ subscribeStarterNotificationOpens,
12
+ } from '../platform/notifications';
13
+ import { starterContextKey, type StarterContext } from '../runtime/gameContext';
14
+
15
+ export const notificationSettingsSceneKey = 'NotificationSettingsScene';
16
+
17
+ const textStyle = { color: '#d6dee8', fontFamily: 'Arial, sans-serif', fontSize: '18px' } as const;
18
+ const buttonStyle = { ...textStyle, color: '#5eead4' } 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;
26
+
27
+ /**
28
+ * Routes notification opens: one whose route is the settings screen (the
29
+ * advertising opt-out action, a consent notice) shows the screen, after boot
30
+ * when the open started the game. Route your other topics' opens in `other`
31
+ * (see `subscribeStarterNotificationOpens`): this is the first subscriber,
32
+ * the one that receives a cold-start open. An open during play leaves the
33
+ * run; the play scene saves its checkpoint when it shuts down.
34
+ */
35
+ export function routeNotificationSettingsOpens(game: Phaser.Game, context: StarterContext): void {
36
+ const activeKeys = (): string[] => game.scene.getScenes(true).map((scene) => scene.scene.key);
37
+ const show = (): void => {
38
+ for (const key of activeKeys()) {
39
+ if (key !== notificationSettingsSceneKey) {
40
+ game.scene.stop(key);
41
+ }
42
+ }
43
+ game.scene.start(notificationSettingsSceneKey);
44
+ };
45
+ let waiting = false;
46
+ const afterBoot = (): void => {
47
+ const active = activeKeys();
48
+ if (active.length === 0 || active.includes('BootScene')) {
49
+ return;
50
+ }
51
+ game.events.off('poststep', afterBoot);
52
+ waiting = false;
53
+ show();
54
+ };
55
+ const unsubscribe = subscribeStarterNotificationOpens(context.platform, {
56
+ settings: () => {
57
+ const active = activeKeys();
58
+ if (active.length > 0 && !active.includes('BootScene')) {
59
+ show();
60
+ } else if (!waiting) {
61
+ // Booting: show the screen once the first scene after boot runs.
62
+ waiting = true;
63
+ game.events.on('poststep', afterBoot);
64
+ }
65
+ },
66
+ });
67
+ game.events.once('destroy', () => {
68
+ unsubscribe();
69
+ game.events.off('poststep', afterBoot);
70
+ });
71
+ }
72
+
73
+ /**
74
+ * The game-rendered notification settings: the OS or host permission,
75
+ * reminder topics, and the advertising consent the Korean rules require
76
+ * before any advertising push (`notificationSettings.ts` holds the
77
+ * logic). Night consent covers advertising that arrives between 21:00 and
78
+ * 08:00 KST. Every change is a tap; no switch starts on. The list has a camera
79
+ * of its own, which clips it under WebGL and Canvas alike and keeps hidden
80
+ * rows from taking taps; it scrolls by dragging.
81
+ */
82
+ export class NotificationSettingsScene extends Phaser.Scene {
83
+ private settings!: StarterNotificationSettings;
84
+ private labels!: StarterNotificationLabels;
85
+ private content!: Phaser.GameObjects.Container;
86
+ private listCamera!: Phaser.Cameras.Scene2D.Camera;
87
+ /** The last tap's outcome, in a strip that does not scroll. */
88
+ private outcome!: Phaser.GameObjects.Text;
89
+ private busy = false;
90
+ /** Each visit's token: a pending action from an earlier visit changes nothing. */
91
+ private visit = 0;
92
+
93
+ constructor() {
94
+ super(notificationSettingsSceneKey);
95
+ }
96
+
97
+ create(): void {
98
+ const rawContext = this.registry.get(starterContextKey);
99
+ if (rawContext === undefined || rawContext === null) {
100
+ throw new Error('Starter context is missing from Phaser registry.');
101
+ }
102
+ const context = rawContext as StarterContext;
103
+ this.visit += 1;
104
+ this.busy = false;
105
+ this.labels = starterNotificationLabelsFor(context.locale);
106
+ this.settings = createStarterNotificationSettingsModel(context.platform, {
107
+ locale: context.locale,
108
+ });
109
+ const title = this.add
110
+ .text(480, 40, this.labels.title, { ...textStyle, color: '#ffffff', fontSize: '30px' })
111
+ .setOrigin(0.5);
112
+ const back = this.button(480, 510, this.labels.back, () => {
113
+ this.scene.start('LobbyScene');
114
+ }).setOrigin(0.5);
115
+ this.outcome = this.add
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);
124
+ this.content = this.add.container(0, 0);
125
+ this.listCamera = this.cameras.add(0, minListTop, 960, listBottom - minListTop);
126
+ this.placeListBelowOutcome();
127
+ this.listCamera.ignore([title, back, this.outcome]);
128
+ this.cameras.main.ignore(this.content);
129
+ this.enableScroll();
130
+ const visit = this.visit;
131
+ void this.apply(visit, () => this.settings.load());
132
+ }
133
+
134
+ private enableScroll(): void {
135
+ let lastY: number | undefined;
136
+ this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
137
+ lastY = pointer.y;
138
+ });
139
+ this.input.on('pointerup', () => {
140
+ lastY = undefined;
141
+ });
142
+ this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
143
+ if (lastY === undefined || !pointer.isDown) {
144
+ return;
145
+ }
146
+ this.scrollTo(this.listCamera.scrollY - (pointer.y - lastY));
147
+ lastY = pointer.y;
148
+ });
149
+ }
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
+
157
+ private scrollTo(scrollY: number): void {
158
+ const height = this.content.getBounds().height;
159
+ this.listCamera.scrollY = Phaser.Math.Clamp(scrollY, 0, Math.max(0, height - this.listCamera.height));
160
+ }
161
+
162
+ private async apply(visit: number, action: () => Promise<StarterNotificationSettingsView>): Promise<void> {
163
+ if (this.busy) {
164
+ return;
165
+ }
166
+ this.busy = true;
167
+ try {
168
+ const view = await action();
169
+ if (visit !== this.visit || !this.sys.isActive()) {
170
+ return;
171
+ }
172
+ this.render(view);
173
+ if (view.notices.length > 0) {
174
+ // Recorded in the background: taps need not wait for it.
175
+ void this.settings.recordNoticesShown();
176
+ }
177
+ } catch (error) {
178
+ // The model reports its own failures as messages; anything else is logged.
179
+ console.error('[notifications] settings screen failed.', error);
180
+ } finally {
181
+ if (visit === this.visit) {
182
+ this.busy = false;
183
+ }
184
+ }
185
+ }
186
+
187
+ private render(view: StarterNotificationSettingsView): void {
188
+ const text = this.labels;
189
+ this.content.removeAll(true);
190
+ const visit = this.visit;
191
+ let y = 0;
192
+ const line = (value: string, style: Phaser.Types.GameObjects.Text.TextStyle = textStyle) => {
193
+ const object = this.add.text(60, y, value, { ...style, wordWrap: { width: listTextWidth } });
194
+ this.content.add(object);
195
+ y += object.height + 10;
196
+ };
197
+ const action = (value: string, run: () => Promise<StarterNotificationSettingsView>) => {
198
+ const object = this.button(60, y, `[ ${value} ]`, () => {
199
+ void this.apply(visit, run);
200
+ });
201
+ this.content.add(object);
202
+ y += object.height + 8;
203
+ };
204
+
205
+ // The outcome of the last tap stays in view above the list, wherever it is scrolled.
206
+ this.outcome.setText(view.message ?? '');
207
+ this.placeListBelowOutcome();
208
+ if (!view.available) {
209
+ if (view.message === undefined) {
210
+ line(text.unavailable);
211
+ }
212
+ this.scrollTo(0);
213
+ return;
214
+ }
215
+ if (!view.permission.perTopic) {
216
+ line(view.permission.state === 'granted' ? text.permissionOn : text.permissionOff);
217
+ if (view.permission.canRequest) {
218
+ // Explain before the one-shot OS prompt; the prompt runs only from the tap below.
219
+ line(text.explanation, { ...textStyle, fontSize: '15px' });
220
+ action(text.turnOn, () => this.settings.turnOn());
221
+ }
222
+ if (view.permission.canLogin) {
223
+ action(text.logIn, () => this.settings.logIn());
224
+ }
225
+ if (view.permission.canOpenSettings) {
226
+ action(text.openSettings, () => this.settings.openSettings());
227
+ }
228
+ if (view.permission.canRevoke) {
229
+ action(text.turnOff, () => this.settings.turnOff());
230
+ }
231
+ }
232
+ if (!view.recording && (view.reminders.length > 0 || view.advertising !== undefined)) {
233
+ // No switch without the player's records: it could not show or change the truth.
234
+ line(view.choices === 'unreadable' ? text.choicesUnreadable : text.notConnected, {
235
+ ...textStyle,
236
+ fontSize: '15px',
237
+ });
238
+ } else if (view.reminders.length > 0) {
239
+ line(text.reminders, { ...textStyle, color: '#ffffff' });
240
+ for (const reminder of view.reminders) {
241
+ action(`${reminder.label}: ${reminder.enabled ? text.on : text.off}`, () =>
242
+ this.settings.setReminder(reminder.topic, !reminder.enabled),
243
+ );
244
+ }
245
+ }
246
+ if (view.agreements.length > 0) {
247
+ // Per-topic hosts (Apps in Toss): service messages the player agrees to
248
+ // one by one; the host's settings withdraw them.
249
+ line(text.agreements, { ...textStyle, color: '#ffffff' });
250
+ for (const agreement of view.agreements) {
251
+ const state = agreement.state === 'granted' ? text.on : text.off;
252
+ if (agreement.canRequest) {
253
+ action(`${agreement.label}: ${state} - ${text.agree}`, () =>
254
+ this.settings.agree(agreement.topic),
255
+ );
256
+ } else {
257
+ line(`${agreement.label}: ${state}`);
258
+ }
259
+ }
260
+ }
261
+ if (view.recording && view.advertising !== undefined) {
262
+ const { granted, night, nightSelectable } = view.advertising;
263
+ action(`${text.advertising}: ${granted ? text.on : text.off}`, () =>
264
+ this.settings.setAdvertising({ advertising: !granted }),
265
+ );
266
+ line(text.advertisingHelp, { ...textStyle, fontSize: '15px' });
267
+ if (nightSelectable) {
268
+ action(`${text.night}: ${night ? text.on : text.off}`, () =>
269
+ this.settings.setAdvertising({ night: !night }),
270
+ );
271
+ } else {
272
+ // Night consent adds to advertising consent: not selectable without it.
273
+ line(`${text.night}: ${text.off}\n${text.nightNeedsAdvertising}`, {
274
+ ...textStyle,
275
+ color: '#7b8794',
276
+ });
277
+ }
278
+ }
279
+ if (view.notices.length > 0) {
280
+ line(text.notices, { ...textStyle, color: '#ffffff' });
281
+ for (const notice of view.notices) {
282
+ line(`${notice.title}\n${notice.body}`, { ...textStyle, fontSize: '15px' });
283
+ }
284
+ }
285
+ // Keep the scroll position across a re-render, within the new height.
286
+ this.scrollTo(this.listCamera.scrollY);
287
+ }
288
+
289
+ private button(x: number, y: number, value: string, onTap: () => void): Phaser.GameObjects.Text {
290
+ return this.add
291
+ .text(x, y, value, buttonStyle)
292
+ .setPadding(0, 8, 24, 8)
293
+ .setInteractive({ useHandCursor: true })
294
+ // A drag that scrolls the list and ends on a button is not a tap.
295
+ .on('pointerup', (pointer: Phaser.Input.Pointer) => {
296
+ if (pointer.getDistance() < 12) {
297
+ onTap();
298
+ }
299
+ });
300
+ }
301
+ }
@@ -12,6 +12,7 @@ installAitHostBridge({
12
12
  adGroupIds: runtimeConfig.adGroupIds,
13
13
  adPlacementTypes: runtimeConfig.adPlacementTypes,
14
14
  ...iapBridgeOptions(),
15
+ ...notificationBridgeOptions(),
15
16
  ...identityBridgeOptions(),
16
17
  });
17
18
 
@@ -38,6 +39,20 @@ function iapBridgeOptions(): Pick<
38
39
  return runtimeConfig.iapProducts.length === 0 ? {} : { iapProducts: runtimeConfig.iapProducts };
39
40
  }
40
41
 
42
+ /**
43
+ * Notifications v2 topics come from the notification topic catalog's
44
+ * `targets.ait` mappings at build time. Without one the host bridge offers no
45
+ * notifications event channel, so the game exposes no `notificationsV2`; with
46
+ * them the bridge asks for each topic's agreement through
47
+ * `Notification.requestAgreement` and routes notification landings. Agreements
48
+ * are recorded by the game backend, never trusted from the wrapper.
49
+ */
50
+ function notificationBridgeOptions(): Pick<InstallAitHostBridgeOptions, 'notificationTopics'> {
51
+ return Object.keys(runtimeConfig.notificationTopics).length === 0
52
+ ? {}
53
+ : { notificationTopics: runtimeConfig.notificationTopics };
54
+ }
55
+
41
56
  function identityBridgeOptions(): Pick<InstallAitHostBridgeOptions, 'dependencies'> {
42
57
  return import.meta.env.VITE_MPGD_AIT_MOCK_IDENTITY === '1'
43
58
  ? {
@@ -8,6 +8,11 @@ declare module 'virtual:mpgd-ait-runtime-config' {
8
8
  >>;
9
9
  /** Catalog products sold on Apps in Toss (`platformProductIds.ait`), read at build time. */
10
10
  readonly iapProducts: readonly import('@mpgd/adapter-ait').AitIapProductConfig[];
11
+ /** Notifications v2 topics mapped for Apps in Toss (`targets.ait`), read at build time. */
12
+ readonly notificationTopics: Readonly<Record<
13
+ string,
14
+ import('@mpgd/adapter-ait/notification-config').AitNotificationTopicMapping
15
+ >>;
11
16
  }>;
12
17
 
13
18
  export default runtimeConfig;
@@ -1,14 +1,33 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
 
3
5
  import type { AitIapProductConfig } from '@mpgd/adapter-ait';
4
6
  import { extractAitAdBridgeConfig, type AitAdBridgeConfig } from '@mpgd/adapter-ait/ad-config';
5
7
  import { extractAitIapProductConfig } from '@mpgd/adapter-ait/iap-config';
8
+ import {
9
+ readAitNotificationTopicsFile,
10
+ type AitNotificationTopicMapping,
11
+ } from '@mpgd/adapter-ait/notification-config';
6
12
  import ttsc from '@ttsc/unplugin/vite';
7
13
  import { defineConfig, type Plugin } from 'vite';
8
14
 
9
15
  const aitAppName = process.env.MPGD_AIT_APP_NAME?.trim() || '__GAME_NAME__';
10
16
  const aitAdConfig = readAitAdConfig(process.env.MPGD_AD_PLACEMENTS_FILE);
11
17
  const aitIapProducts = readAitIapProducts(process.env.MPGD_PRODUCT_CATALOG_FILE);
18
+ // Notifications v2 topics come from the notification topic catalog
19
+ // (`targets.ait`), so the wrapper and the backend share one source:
20
+ // MPGD_NOTIFICATION_TOPICS_FILE (a relative path names a file of the game),
21
+ // else notifications/topics.json when it exists. Without a catalog, or with
22
+ // no topic mapped for Apps in Toss, the host bridge exposes no notifications.
23
+ const aitNotificationTopics = readAitNotificationTopicsFile(
24
+ {
25
+ configured: process.env.MPGD_NOTIFICATION_TOPICS_FILE,
26
+ rootDir: fileURLToPath(new URL('../..', import.meta.url)),
27
+ defaultFile: 'notifications/topics.json',
28
+ },
29
+ { existsSync, readFileSync, resolve },
30
+ );
12
31
 
13
32
  export default defineConfig(({ command, isPreview }) => {
14
33
  const enableLocalAitMock = command === 'serve'
@@ -33,6 +52,7 @@ export default defineConfig(({ command, isPreview }) => {
33
52
  adGroupIds: aitAdConfig.adGroupIds,
34
53
  adPlacementTypes: aitAdConfig.adPlacementTypes,
35
54
  iapProducts: aitIapProducts,
55
+ notificationTopics: aitNotificationTopics,
36
56
  }),
37
57
  ...(command === 'build'
38
58
  ? [ttsc({ project: 'tsconfig.json', plugins: false })]
@@ -51,6 +71,7 @@ function createAitRuntimeConfigPlugin(input: Readonly<{
51
71
  readonly adGroupIds: Readonly<Record<string, string>>;
52
72
  readonly adPlacementTypes: AitAdBridgeConfig['adPlacementTypes'];
53
73
  readonly iapProducts: readonly AitIapProductConfig[];
74
+ readonly notificationTopics: Readonly<Record<string, AitNotificationTopicMapping>>;
54
75
  }>): Plugin {
55
76
  const publicId = 'virtual:mpgd-ait-runtime-config';
56
77
  const resolvedId = `\0${publicId}`;
@@ -165,3 +165,17 @@ game-owned production credentials, deployment, signing, or store evidence.
165
165
  Use `@mpgd/adapter-browser/oneplay` through `PlatformGateway`. Build and smoke the `oneplay` target with the configured kit path. The adapter initializes the H5 SDK before gameplay; `completeLoading()` awaits host acknowledgement. Exit checkpoints must be synchronous through `storage.saveSync`. Use game-owned issued placement IDs and server verification before enabling ads or purchases. Check final HTTPS hosting headers for both ONE store domain families. See the [ONE play guide](https://github.com/imjlk/mpgd-kit/blob/main/docs/ONEPLAY.md) and [official SDK guide](https://onestore-dev.gitbook.io/dev/tools/web-sdk).
166
166
 
167
167
  Wire `rewardRequests` and `commerceServer` in `src/platform/buildGateways/oneplay.ts`. Server-only `@mpgd/game-services/oneplay-reward`, `oneplay-purchase-client`, `oneplay-purchase` and `oneplay-pns` provide signed SSV, authenticated checkout intents, API V7 verification, post-ledger consume/acknowledge and signed notification recovery. The game owns durable stores, routes, an encrypted client journal and atomic cancellation fencing. A completed PNS requires completed finalization. Missing ports keep capabilities unavailable; subscriptions are unsupported. See `commerce.checkout.verified-intent` in the game manifest and the ONE play acceptance notes before enabling policies.
168
+
169
+ ## ONEplay official SDK skill
170
+
171
+ From this game's repository root, install or explicitly refresh the official instructions:
172
+
173
+ ```sh
174
+ npx skills add ONE-store/onestore_web_sdk_skills --agent codex --yes
175
+ ```
176
+
177
+ Review the resulting `.agents/skills/onestore-web-sdk` and `skills-lock.json` diff before committing.
178
+ Use the skill's doctor and pre-release checklist alongside Kit's `docs/ONEPLAY.md`.
179
+ Keep SDK imports in composition, game-services credentials on the server, and rewards behind
180
+ purchase verification / signed SSV. The official standalone HTML template is reference material,
181
+ not a replacement for the generated gateway. Do not refresh skills inside production builds.
@@ -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
+ }