@mpgd/cli 0.53.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.
@@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { DOMParser } from '@xmldom/xmldom';
7
7
  import { satisfies, valid, validRange } from 'semver';
8
- import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
8
+ import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsCapacitorTemplateSupportFiles, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
9
9
  import { isNonPublicServiceHostname } from './production-target-readiness.js';
10
10
  import { assertAndroidSettingsAppProject, assertAndroidSettingsNoAppRemap, assertIosReleaseInfoPlistExpansion, assertIosReleasePackagingSettings, assertIosReleaseProductName, assertNativeShellIdentity, hasAndroidDisplayNameResourceOverride, hasAndroidManifestSourceSetOverride, hasAndroidResourceSourceSetOverride, hasGradleIdentityMutation, hasGradleTaskAction, maskGradleStrings, readIosAppTargetId, readIosReleaseInfoPlist, stripGradleComments, } from './native-shell-identity.js';
11
11
  const shellPath = 'apps/mobile-capacitor';
@@ -2793,6 +2793,14 @@ function planNotificationsProvider(input) {
2793
2793
  files.push({ path: file, content: starterFile(overlayRoot, file) });
2794
2794
  }
2795
2795
  }
2796
+ // Base-template modules they import, missing from a game an earlier release created.
2797
+ if (input.templateRoot !== undefined) {
2798
+ for (const file of notificationsCapacitorTemplateSupportFiles) {
2799
+ if (readExisting(safeDestination(gameRoot, file)) === undefined) {
2800
+ files.push({ path: file, content: starterFile(input.templateRoot, file) });
2801
+ }
2802
+ }
2803
+ }
2796
2804
  warnings.push('The notification settings screen is not added to an existing game; render one from '
2797
2805
  + 'src/platform/notificationSettings.ts (see docs/NOTIFICATIONS.md) so players can turn '
2798
2806
  + 'reminders and advertising consent on and off.');
@@ -356,8 +356,8 @@ function assertGameOwnedNativePaths(gameRoot, target, config) {
356
356
  throw new Error(`${target} ${field} still points to a Kit checkout; initialize a game-owned shell.`);
357
357
  }
358
358
  const expanded = value
359
- .replaceAll('${MPGD_GAME_ROOT}', root)
360
- .replaceAll('${MPGD_GAME_APP_ROOT}', root);
359
+ .replaceAll('${MPGD_GAME_ROOT}', () => root)
360
+ .replaceAll('${MPGD_GAME_APP_ROOT}', () => root);
361
361
  const candidate = resolve(root, expanded);
362
362
  let ancestor = candidate;
363
363
  while (!existsSync(ancestor)) {
package/dist/index.js CHANGED
@@ -2976,7 +2976,14 @@ function createGameApp(input) {
2976
2976
  relativePath: file.relativePath,
2977
2977
  content: file.content,
2978
2978
  });
2979
- return prepared === undefined ? [] : [{ ...file, content: renderTemplate(prepared, context) }];
2979
+ return prepared === undefined
2980
+ ? []
2981
+ : [
2982
+ {
2983
+ ...file,
2984
+ content: renderTemplate(prepared, context, file.relativePath),
2985
+ },
2986
+ ];
2980
2987
  });
2981
2988
  if (input.dryRun) {
2982
2989
  console.info(`Would create ${path.relative(process.cwd(), appDir) || appDir}:`);
@@ -3187,8 +3194,10 @@ function assertValidGameTitle(title) {
3187
3194
  if (title.trim().length === 0) {
3188
3195
  throw new Error('Game title cannot be empty.');
3189
3196
  }
3190
- if (/[\u0000-\u001f<>"'\\]/u.test(title)) {
3191
- throw new Error('Game title cannot contain control characters, quotes, backslashes, or angle brackets.');
3197
+ // Quotes, backslashes, and `$` are allowed: JSON files and JS string literals
3198
+ // escape them. `<` would break the generated HTML title.
3199
+ if (/[\u0000-\u001f<>]/u.test(title)) {
3200
+ throw new Error('Game title cannot contain control characters or angle brackets.');
3192
3201
  }
3193
3202
  }
3194
3203
  function collectTemplateFiles(templateDir) {
@@ -3210,28 +3219,79 @@ function walkTemplateDir(templateDir, relativeDir, files) {
3210
3219
  });
3211
3220
  }
3212
3221
  }
3213
- function renderTemplate(content, context) {
3214
- let rendered = content
3215
- .replaceAll("from '@mpgd/cli/devvit'", `from ${context.devvitOutputPluginImport}`)
3216
- .replaceAll('__GAME_NAME__', context.gameName)
3217
- .replaceAll('__DEVVIT_APP_NAME__', context.devvitAppName)
3218
- .replaceAll('__GAME_TITLE_TS_LITERAL__', context.gameTitleTsLiteral)
3219
- .replaceAll('__GAME_TITLE__', context.gameTitle)
3220
- .replaceAll('__LEGAL_LAST_UPDATED__', context.legalLastUpdated)
3221
- .replaceAll('__PACKAGE_NAME__', context.packageName)
3222
- .replaceAll('__MPGD_DEPENDENCY_VERSION__', context.defaultDependencyVersion)
3223
- .replaceAll('__TSCONFIG_EXTENDS_LINE__', context.tsconfigExtendsLine)
3224
- .replaceAll('__TSCONFIG_ROOT_DIR__', context.tsconfigRootDir)
3225
- .replaceAll('__TSCONFIG_WORKSPACE_INCLUDES__', context.tsconfigWorkspaceIncludes)
3226
- .replaceAll('__TSCONFIG_WORKSPACE_EXCLUDES__', context.tsconfigWorkspaceExcludes)
3227
- .replaceAll('__WORKSPACE_I18N_BUILD_PREFIX__', context.workspaceI18nBuildPrefix)
3228
- .replaceAll('__DEFAULT_KIT_PATH__', context.defaultKitPath)
3229
- .replaceAll('__RECOMMENDED_MATRIX_TARGETS__', context.recommendedMatrixTargets)
3230
- .replaceAll('__PNPM_WORKSPACE_KIT_PACKAGES__', context.pnpmWorkspaceKitPackages)
3231
- .replaceAll('__PASCAL_NAME__', context.pascalName)
3232
- .replaceAll('__CAMEL_NAME__', context.camelName);
3222
+ /**
3223
+ * String replacements treat `$&`, `$'`, `` $` ``, and `$$` as patterns.
3224
+ * A function replacer inserts `value` literally.
3225
+ */
3226
+ function replaceLiteral(content, placeholder, value) {
3227
+ return content.replaceAll(placeholder, () => value);
3228
+ }
3229
+ /** Interior of a JSON string, without the surrounding quotes. */
3230
+ function jsonStringContents(value) {
3231
+ return JSON.stringify(value).slice(1, -1);
3232
+ }
3233
+ /**
3234
+ * Placeholders substituted inside an existing JSON string. Structural
3235
+ * fragments (`__TSCONFIG_EXTENDS_LINE__` and the workspace include/exclude
3236
+ * lists) stay raw so their quotes remain JSON syntax.
3237
+ */
3238
+ function jsonEscapedPlaceholder(placeholder) {
3239
+ return placeholder === '__GAME_TITLE__'
3240
+ || placeholder === '__GAME_NAME__'
3241
+ || placeholder === '__PACKAGE_NAME__'
3242
+ || placeholder === '__DEVVIT_APP_NAME__'
3243
+ || placeholder === '__DEFAULT_KIT_PATH__'
3244
+ || placeholder === '__WORKSPACE_I18N_BUILD_PREFIX__'
3245
+ || placeholder === '__TSCONFIG_ROOT_DIR__'
3246
+ || placeholder === '__LEGAL_LAST_UPDATED__'
3247
+ || placeholder === '__MPGD_DEPENDENCY_VERSION__'
3248
+ || placeholder.startsWith('__MPGD_DEPENDENCY_VERSION_');
3249
+ }
3250
+ /** Interior of a single-quoted JS string. Backslash first, then quote. */
3251
+ function singleQuotedContents(value) {
3252
+ return value.replaceAll('\\', () => '\\\\').replaceAll("'", () => "\\'");
3253
+ }
3254
+ function renderTemplate(content, context, relativePath) {
3255
+ // `.webmanifest` is JSON. Hosted PWA deploy and Microsoft Store submission parse it.
3256
+ const jsonFile = relativePath.endsWith('.json') || relativePath.endsWith('.webmanifest');
3257
+ const typescriptFile = relativePath.endsWith('.ts');
3258
+ const insert = (placeholder, value) => {
3259
+ if (jsonFile && jsonEscapedPlaceholder(placeholder)) {
3260
+ return jsonStringContents(value);
3261
+ }
3262
+ // Template titles sit in single quotes (`title: '__GAME_TITLE__'`).
3263
+ if (typescriptFile && placeholder === '__GAME_TITLE__') {
3264
+ return singleQuotedContents(value);
3265
+ }
3266
+ return value;
3267
+ };
3268
+ const pairs = [
3269
+ [`from '@mpgd/cli/devvit'`, `from ${context.devvitOutputPluginImport}`],
3270
+ ['__GAME_NAME__', context.gameName],
3271
+ ['__DEVVIT_APP_NAME__', context.devvitAppName],
3272
+ // Longer than `__GAME_TITLE__`, so a title is not written into the literal name.
3273
+ ['__GAME_TITLE_TS_LITERAL__', context.gameTitleTsLiteral],
3274
+ ['__GAME_TITLE__', context.gameTitle],
3275
+ ['__LEGAL_LAST_UPDATED__', context.legalLastUpdated],
3276
+ ['__PACKAGE_NAME__', context.packageName],
3277
+ ['__MPGD_DEPENDENCY_VERSION__', context.defaultDependencyVersion],
3278
+ ['__TSCONFIG_EXTENDS_LINE__', context.tsconfigExtendsLine],
3279
+ ['__TSCONFIG_ROOT_DIR__', context.tsconfigRootDir],
3280
+ ['__TSCONFIG_WORKSPACE_INCLUDES__', context.tsconfigWorkspaceIncludes],
3281
+ ['__TSCONFIG_WORKSPACE_EXCLUDES__', context.tsconfigWorkspaceExcludes],
3282
+ ['__WORKSPACE_I18N_BUILD_PREFIX__', context.workspaceI18nBuildPrefix],
3283
+ ['__DEFAULT_KIT_PATH__', context.defaultKitPath],
3284
+ ['__RECOMMENDED_MATRIX_TARGETS__', context.recommendedMatrixTargets],
3285
+ ['__PNPM_WORKSPACE_KIT_PACKAGES__', context.pnpmWorkspaceKitPackages],
3286
+ ['__PASCAL_NAME__', context.pascalName],
3287
+ ['__CAMEL_NAME__', context.camelName],
3288
+ ];
3289
+ let rendered = content;
3290
+ for (const [placeholder, value] of pairs) {
3291
+ rendered = replaceLiteral(rendered, placeholder, insert(placeholder, value));
3292
+ }
3233
3293
  for (const replacement of context.mpgdDependencyVersionReplacements) {
3234
- rendered = rendered.replaceAll(replacement.placeholder, replacement.version);
3294
+ rendered = replaceLiteral(rendered, replacement.placeholder, insert(replacement.placeholder, replacement.version));
3235
3295
  }
3236
3296
  return rendered;
3237
3297
  }
@@ -3336,8 +3396,8 @@ function resolveStandaloneNativeBuild(targetsFile, requestedTarget, values) {
3336
3396
  return undefined;
3337
3397
  }
3338
3398
  const candidate = path.resolve(gameRoot, configuredPath
3339
- .replaceAll('${MPGD_GAME_ROOT}', gameRoot)
3340
- .replaceAll('${MPGD_GAME_APP_ROOT}', gameRoot));
3399
+ .replaceAll('${MPGD_GAME_ROOT}', () => gameRoot)
3400
+ .replaceAll('${MPGD_GAME_APP_ROOT}', () => gameRoot));
3341
3401
  let existingAncestor = candidate;
3342
3402
  while (!existsSync(existingAncestor)) {
3343
3403
  const parent = path.dirname(existingAncestor);
@@ -3495,10 +3555,7 @@ function formatError(error) {
3495
3555
  }
3496
3556
  function replaceTargetTokens(value, context) {
3497
3557
  if (typeof value === 'string') {
3498
- return value
3499
- .replaceAll('${MPGD_KIT_PATH}', context.kitPath)
3500
- .replaceAll('${MPGD_GAME_ROOT}', context.gameRoot)
3501
- .replaceAll('${MPGD_GAME_APP_ROOT}', context.gameRoot);
3558
+ return replaceLiteral(replaceLiteral(replaceLiteral(value, '${MPGD_KIT_PATH}', context.kitPath), '${MPGD_GAME_ROOT}', context.gameRoot), '${MPGD_GAME_APP_ROOT}', context.gameRoot);
3502
3559
  }
3503
3560
  if (Array.isArray(value)) {
3504
3561
  return value.map((entry) => replaceTargetTokens(entry, context));
@@ -278,8 +278,8 @@ function discoverGameManifests(gameRoot, notes, blockers, targetsFileName) {
278
278
  continue;
279
279
  }
280
280
  const resolvedLocation = location
281
- .replaceAll('${MPGD_GAME_ROOT}', targetsDirectory)
282
- .replaceAll('${MPGD_GAME_APP_ROOT}', targetsDirectory);
281
+ .replaceAll('${MPGD_GAME_ROOT}', () => targetsDirectory)
282
+ .replaceAll('${MPGD_GAME_APP_ROOT}', () => targetsDirectory);
283
283
  if (resolvedLocation.includes('$' + '{')) {
284
284
  notes.push('Skipped target with unresolved path token: ' + location);
285
285
  continue;
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "78bcaad6a3e53131be44f749c27b9377a2fd96dc",
2
+ "kitGitSha": "95ea69d3e9755256b27a3b0ea230812a632ed03b",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.53.0"
4
+ "packageVersion": "0.54.0"
5
5
  }
@@ -6899,6 +6899,72 @@ var timestampPattern = new RegExp("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2})
6899
6899
  var fcmAddressKinds = Object.freeze(["fcm-token"]);
6900
6900
  var fcmReservedDataKeys = Object.freeze(["topic", "route"]);
6901
6901
  var tokenRefreshMarginMs = 5 * 60 * 1e3;
6902
+ var sha256K = new Uint32Array([
6903
+ 1116352408,
6904
+ 1899447441,
6905
+ 3049323471,
6906
+ 3921009573,
6907
+ 961987163,
6908
+ 1508970993,
6909
+ 2453635748,
6910
+ 2870763221,
6911
+ 3624381080,
6912
+ 310598401,
6913
+ 607225278,
6914
+ 1426881987,
6915
+ 1925078388,
6916
+ 2162078206,
6917
+ 2614888103,
6918
+ 3248222580,
6919
+ 3835390401,
6920
+ 4022224774,
6921
+ 264347078,
6922
+ 604807628,
6923
+ 770255983,
6924
+ 1249150122,
6925
+ 1555081692,
6926
+ 1996064986,
6927
+ 2554220882,
6928
+ 2821834349,
6929
+ 2952996808,
6930
+ 3210313671,
6931
+ 3336571891,
6932
+ 3584528711,
6933
+ 113926993,
6934
+ 338241895,
6935
+ 666307205,
6936
+ 773529912,
6937
+ 1294757372,
6938
+ 1396182291,
6939
+ 1695183700,
6940
+ 1986661051,
6941
+ 2177026350,
6942
+ 2456956037,
6943
+ 2730485921,
6944
+ 2820302411,
6945
+ 3259730800,
6946
+ 3345764771,
6947
+ 3516065817,
6948
+ 3600352804,
6949
+ 4094571909,
6950
+ 275423344,
6951
+ 430227734,
6952
+ 506948616,
6953
+ 659060556,
6954
+ 883997877,
6955
+ 958139571,
6956
+ 1322822218,
6957
+ 1537002063,
6958
+ 1747873779,
6959
+ 1955562222,
6960
+ 2024104815,
6961
+ 2227730452,
6962
+ 2361852424,
6963
+ 2428436474,
6964
+ 2756734187,
6965
+ 3204031479,
6966
+ 3329325298
6967
+ ]);
6902
6968
 
6903
6969
  // node_modules/.cache/mpgd-native-build/packages/game-services/src/notifications/registry.js
6904
6970
  var notificationHistoryRetentionDays = 30;
@@ -14739,6 +14805,7 @@ var notificationsCapacitorGateways = Object.freeze({
14739
14805
  var notificationsCapacitorGatewayModules = Object.freeze([
14740
14806
  "src/platform/capacitorNotifications.ts",
14741
14807
  "src/platform/notifications.ts",
14808
+ "src/platform/notificationBackend.ts",
14742
14809
  "src/platform/notificationSettings.ts",
14743
14810
  "src/platform/notificationOpens.ts"
14744
14811
  ]);
@@ -14749,6 +14816,9 @@ var notificationsCapacitorSupportFiles = Object.freeze([
14749
14816
  "tools/validate-notifications.ts",
14750
14817
  "docs/NOTIFICATIONS.md"
14751
14818
  ]);
14819
+ var notificationsCapacitorTemplateSupportFiles = Object.freeze([
14820
+ "src/platform/serverSession.ts"
14821
+ ]);
14752
14822
 
14753
14823
  // node_modules/.cache/mpgd-native-build/packages/cli/src/native-shell-identity.js
14754
14824
  function assertAndroidSettingsAppProject(source) {
@@ -58,6 +58,12 @@ export declare const notificationsCapacitorGatewayModules: readonly string[];
58
58
  * catalog files they read, the validator, and the game's notifications doc.
59
59
  */
60
60
  export declare const notificationsCapacitorSupportFiles: readonly string[];
61
+ /**
62
+ * Files of the base game template the notifications modules import, which a
63
+ * game created by an earlier CLI release may not have: written from the
64
+ * template when missing, kept when the game has one.
65
+ */
66
+ export declare const notificationsCapacitorTemplateSupportFiles: readonly string[];
61
67
  /** Whether a Capacitor gateway already registers a notifications provider. */
62
68
  export declare function capacitorGatewayRegistersNotifications(source: string): boolean;
63
69
  /**
@@ -460,6 +460,7 @@ export const notificationsCapacitorGateways = Object.freeze({
460
460
  export const notificationsCapacitorGatewayModules = Object.freeze([
461
461
  'src/platform/capacitorNotifications.ts',
462
462
  'src/platform/notifications.ts',
463
+ 'src/platform/notificationBackend.ts',
463
464
  'src/platform/notificationSettings.ts',
464
465
  'src/platform/notificationOpens.ts',
465
466
  ]);
@@ -475,6 +476,14 @@ export const notificationsCapacitorSupportFiles = Object.freeze([
475
476
  'tools/validate-notifications.ts',
476
477
  'docs/NOTIFICATIONS.md',
477
478
  ]);
479
+ /**
480
+ * Files of the base game template the notifications modules import, which a
481
+ * game created by an earlier CLI release may not have: written from the
482
+ * template when missing, kept when the game has one.
483
+ */
484
+ export const notificationsCapacitorTemplateSupportFiles = Object.freeze([
485
+ 'src/platform/serverSession.ts',
486
+ ]);
478
487
  /** Whether a Capacitor gateway already registers a notifications provider. */
479
488
  export function capacitorGatewayRegistersNotifications(source) {
480
489
  // One pass over strings and comments: neither registers anything, and a
@@ -3125,7 +3125,7 @@ function assembleOfflineHtml(html, bundledEntry, entryAttributes) {
3125
3125
  || output.indexOf(offlineEntryPlaceholder) !== output.lastIndexOf(offlineEntryPlaceholder)) {
3126
3126
  throw new Error('Offline playtest could not preserve the module entry position.');
3127
3127
  }
3128
- output = output.replace(offlineEntryPlaceholder, inlineScript);
3128
+ output = output.replace(offlineEntryPlaceholder, () => inlineScript);
3129
3129
  if (findActiveHtmlStartTag(output, 'body') !== undefined) {
3130
3130
  return `${output.trim()}\n`;
3131
3131
  }
@@ -399,7 +399,7 @@ function migrateKnownRunner(source, template) {
399
399
  if (defaults.length !== 1) {
400
400
  return undefined;
401
401
  }
402
- const rendered = template.replace("'__DEFAULT_KIT_PATH__'", JSON.stringify(defaults[0]));
402
+ const rendered = template.replace("'__DEFAULT_KIT_PATH__'", () => JSON.stringify(defaults[0]));
403
403
  const canonical = (value) => JSON.stringify(value, (key, item) => ['start', 'end', 'raw'].includes(key) ? undefined : item);
404
404
  return canonical(ast) === canonical(parse(rendered, { ecmaVersion: 'latest', sourceType: 'module' }))
405
405
  ? rendered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,10 +70,10 @@
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/catalog": "0.10.4",
74
- "@mpgd/platform": "0.26.0",
75
- "@mpgd/target-config": "0.23.4",
76
- "@mpgd/phaser-assets": "0.9.1"
73
+ "@mpgd/catalog": "0.10.5",
74
+ "@mpgd/platform": "0.27.0",
75
+ "@mpgd/phaser-assets": "0.9.1",
76
+ "@mpgd/target-config": "0.23.5"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,18 +81,18 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.20.0",
85
- "@mpgd/adapter-browser": "0.11.4",
86
- "@mpgd/adapter-capacitor": "0.12.0",
87
- "@mpgd/adapter-devvit": "0.13.2",
88
- "@mpgd/adapter-verse8": "0.7.4",
89
- "@mpgd/analytics": "0.4.13",
90
- "@mpgd/bridge": "0.14.1",
91
- "@mpgd/capacitor-storekit": "0.3.4",
92
- "@mpgd/game-services": "0.26.0",
93
- "@mpgd/capacitor-play-billing": "0.3.4",
94
- "@mpgd/i18n": "0.6.18",
95
- "@mpgd/game-runtime": "0.7.4",
84
+ "@mpgd/adapter-ait": "0.20.1",
85
+ "@mpgd/adapter-browser": "0.11.5",
86
+ "@mpgd/adapter-devvit": "0.13.3",
87
+ "@mpgd/adapter-verse8": "0.7.5",
88
+ "@mpgd/bridge": "0.14.2",
89
+ "@mpgd/adapter-capacitor": "0.12.1",
90
+ "@mpgd/analytics": "0.4.14",
91
+ "@mpgd/capacitor-play-billing": "0.3.5",
92
+ "@mpgd/capacitor-storekit": "0.3.5",
93
+ "@mpgd/game-services": "0.27.0",
94
+ "@mpgd/i18n": "0.6.19",
95
+ "@mpgd/game-runtime": "0.7.5",
96
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
@@ -57,13 +57,42 @@ left for you.
57
57
 
58
58
  Consent records (reminders, 광고성 정보 수신동의, night consent), endpoint
59
59
  registration, server reminders, and consent notices go through the game
60
- backend. The kit does not ship that runtime's backend client yet, so
61
- `createStarterNotificationBackend` in `src/platform/notifications.ts` returns
62
- `undefined` and the settings screen asks only for the OS or host permission
63
- (and withdraws a Devvit opt-in the game rendered). Once your backend serves
64
- the notification routes, return its `NotificationRuntime` there with
65
- `listConsent`, which reads the signed-in player's consent records (the
66
- game-services Worker's `GET /consent`). The settings screen shows each
60
+ backend. `createStarterNotificationBackend` in
61
+ `src/platform/notifications.ts` builds the notifications runtime
62
+ (`@mpgd/platform/notifications-runtime`) over the game-services Worker's
63
+ notification routes (`@mpgd/game-services/notifications/client`) when the
64
+ game has both:
65
+
66
+ - a game-services URL, `VITE_MPGD_GAME_SERVICES_URL`, the one the commerce
67
+ client uses (a public https URL in production); and
68
+ - a server-backed session, which the game sets with
69
+ `setStarterServerSession` in `src/platform/serverSession.ts` at the top of
70
+ `src/main.ts`, before `bootstrap()`: the signed-in `{ playerId, sessionId }`
71
+ (`undefined` until the player signs in), a sign-in subscription, and the
72
+ session's `Authorization` header. The commerce client then sends the same
73
+ header. `sessionId` must be the stable session-family id: the `sessionId` your
74
+ identity service answers to the Worker's `authenticateNotificationSession`
75
+ for this session's header, the same across token refreshes (see the kit's
76
+ `docs/CLOUDFLARE_WORKER_DEPLOY.md`, "Sessions"). Do not pass a per-token
77
+ or rotating id: a `GuestSessionCoordinator` view's `sessionId` may rotate
78
+ on refresh, so it is not that id.
79
+
80
+ Without either, it returns `undefined` and the settings screen asks only
81
+ for the OS or host permission (and withdraws a Devvit opt-in the game
82
+ rendered). With them, the runtime registers the device for the signed-in
83
+ player, keeps its registration state in the platform storage, and
84
+ `listConsent` reads the player's consent records (the Worker's
85
+ `GET /consent`). The runtime also loads the Worker's registry entry for
86
+ its target (`GET /providers`) and turns notifications into a configuration
87
+ error when the provider disagrees with it; a Worker it cannot reach is
88
+ logged and the runtime runs without that check.
89
+
90
+ Reddit never gets this backend, even with a URL and a session: a Reddit
91
+ game's notifications backend is its Devvit server, which keeps the opt-in,
92
+ the records, and the sends, and the Worker serves no `reddit` target. The
93
+ Worker serves the Android, iOS, and Apps in Toss providers.
94
+
95
+ The settings screen shows each
67
96
  switch from the latest record, read every time it opens and after every
68
97
  change, so a choice made on another device shows. A tap reads the records
69
98
  again and writes only what changes. It keeps nothing on the device, and
@@ -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
+ }