@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.
- package/dist/capacitor-shell-starter.js +9 -1
- package/dist/deploy-planning.js +2 -2
- package/dist/index.js +87 -30
- package/dist/kit-upgrade.js +2 -2
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +70 -0
- package/dist/notifications-starter.d.ts +6 -0
- package/dist/notifications-starter.js +9 -0
- package/dist/offline-playtest.js +1 -1
- package/dist/tooling-migration.js +1 -1
- package/package.json +17 -17
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +36 -7
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
- package/templates/notifications-overlay/common/src/platform/notifications.ts +67 -35
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +26 -7
- package/templates/phaser-game/src/platform/gameServices.ts +3 -0
- package/templates/phaser-game/src/platform/serverSession.ts +66 -0
|
@@ -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.');
|
package/dist/deploy-planning.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
3191
|
-
|
|
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
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
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.
|
|
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));
|
package/dist/kit-upgrade.js
CHANGED
|
@@ -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;
|
|
@@ -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
|
package/dist/offline-playtest.js
CHANGED
|
@@ -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.
|
|
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.
|
|
74
|
-
"@mpgd/platform": "0.
|
|
75
|
-
"@mpgd/
|
|
76
|
-
"@mpgd/
|
|
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.
|
|
85
|
-
"@mpgd/adapter-browser": "0.11.
|
|
86
|
-
"@mpgd/adapter-
|
|
87
|
-
"@mpgd/adapter-
|
|
88
|
-
"@mpgd/
|
|
89
|
-
"@mpgd/
|
|
90
|
-
"@mpgd/
|
|
91
|
-
"@mpgd/capacitor-
|
|
92
|
-
"@mpgd/
|
|
93
|
-
"@mpgd/
|
|
94
|
-
"@mpgd/i18n": "0.6.
|
|
95
|
-
"@mpgd/game-runtime": "0.7.
|
|
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.
|
|
61
|
-
`
|
|
62
|
-
`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
game-services
|
|
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 {
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
69
|
+
platform: TargetConfiguredGateway,
|
|
71
70
|
): StarterNotificationBackend | undefined {
|
|
72
|
-
return
|
|
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
|
-
|
|
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<
|
|
101
|
+
const agreements = new WeakMap<TargetConfiguredGateway, Set<string>>();
|
|
78
102
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
20
|
-
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,
|
|
112
|
-
|
|
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,
|
|
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 -
|
|
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:
|
|
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
|
+
}
|