@mpgd/cli 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/dist/capacitor-shell-starter.d.ts +22 -0
  2. package/dist/capacitor-shell-starter.js +387 -18
  3. package/dist/index.js +35 -4
  4. package/dist/native-build-info.json +2 -2
  5. package/dist/native-build-target.js +363 -2
  6. package/dist/notifications-starter.d.ts +69 -0
  7. package/dist/notifications-starter.js +513 -0
  8. package/package.json +17 -17
  9. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +75 -0
  10. package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
  11. package/templates/notifications-overlay/common/notifications/topics.json +222 -0
  12. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
  13. package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
  14. package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
  15. package/templates/notifications-overlay/common/src/platform/notifications.ts +131 -0
  16. package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
  17. package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
  18. package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +282 -0
  19. package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
  20. package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
  21. package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
  22. package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
@@ -0,0 +1,513 @@
1
+ /**
2
+ * The notifications starter (`mpgd game create --providers notifications`):
3
+ * the files `templates/notifications-overlay` adds to a generated game, and the
4
+ * anchored edits it makes to the base template, so a game without
5
+ * notifications ships none of it (notifications v2 RFC, PR C1).
6
+ *
7
+ * Every edit names an anchor that must occur exactly once in the base file,
8
+ * so a template change that moves an anchor fails the CLI's tests instead of
9
+ * generating a half-wired game.
10
+ */
11
+ /** The provider id that selects the notifications starter. */
12
+ export const notificationsProviderId = 'notifications';
13
+ /** The push plugin the notifications starter adds (FCM on Android and iOS). */
14
+ export const notificationsMessagingPlugin = '@capacitor-firebase/messaging';
15
+ /**
16
+ * The npm packages a game with notifications declares. The two Capacitor
17
+ * plugins are the optional peers `@mpgd/adapter-capacitor/notifications`
18
+ * loads, at the versions the adapter is tested with; `firebase` is the
19
+ * package the messaging plugin's web fallback imports, which the bundler
20
+ * must resolve even though a native shell never loads it.
21
+ */
22
+ export const notificationsGameDependencies = Object.freeze({
23
+ [notificationsMessagingPlugin]: '8.5.2',
24
+ '@capacitor/local-notifications': '8.3.1',
25
+ firebase: '12.19.0',
26
+ });
27
+ /**
28
+ * The `firebase` range the starter's `@capacitor-firebase/messaging` version
29
+ * declares as its peer (its web fallback imports the SDK). A game that keeps
30
+ * its own `firebase` must be in it before the starter adds the plugin; the
31
+ * starter test pins it to the plugin's published metadata.
32
+ */
33
+ export const notificationsMessagingFirebaseRange = '^12.6.0';
34
+ /** The plugins `mpgd target init capacitor --providers notifications` installs into the shell. */
35
+ export const notificationsShellPlugins = Object.freeze([
36
+ notificationsMessagingPlugin,
37
+ '@capacitor/local-notifications',
38
+ ]);
39
+ /** Never installed next to the messaging plugin: on iOS both claim Capacitor's one push handler. */
40
+ export const conflictingPushPlugin = '@capacitor/push-notifications';
41
+ const gatewayImport = {
42
+ anchor: "import type { RuntimeConfig } from '../runtimeDetector';\n",
43
+ replacement: [
44
+ "import { createStarterCapacitorNotifications } from '../capacitorNotifications';",
45
+ "import type { RuntimeConfig } from '../runtimeDetector';",
46
+ '',
47
+ ].join('\n'),
48
+ };
49
+ const gatewayProviders = (platform) => ({
50
+ anchor: ' const providers = createStoreProviders(runtime, () => gateway);\n',
51
+ replacement: [
52
+ ' const providers = [',
53
+ ' ...createStoreProviders(runtime, () => gateway),',
54
+ ' // Notifications v2 (FCM and local reminders); `notifications/topics.json`',
55
+ ' // maps the topics, and the shell needs the two plugins',
56
+ ' // (`mpgd target init capacitor --providers notifications`).',
57
+ ` ...(await createStarterCapacitorNotifications('${platform}', runtime.configTarget)),`,
58
+ ' ];',
59
+ '',
60
+ ].join('\n'),
61
+ });
62
+ /** Edits to base template files, by path, for both templates unless noted. */
63
+ const commonEdits = {
64
+ 'src/platform/buildGateways/capacitorAndroid.ts': [gatewayImport, gatewayProviders('android')],
65
+ 'src/platform/buildGateways/capacitorIos.ts': [gatewayImport, gatewayProviders('ios')],
66
+ 'src/platform/buildGateways/reddit.ts': [
67
+ {
68
+ anchor: "import { createDevvitWebPaymentsClient } from '@mpgd/adapter-devvit/web';\n",
69
+ replacement: [
70
+ 'import {',
71
+ ' createDevvitWebLoginPrompt,',
72
+ ' createDevvitWebPaymentsClient,',
73
+ "} from '@mpgd/adapter-devvit/web';",
74
+ '',
75
+ ].join('\n'),
76
+ },
77
+ {
78
+ anchor: "import type { RuntimeConfig } from '../runtimeDetector';\n",
79
+ replacement: [
80
+ "import { starterNotificationTopics } from '../notifications';",
81
+ "import type { RuntimeConfig } from '../runtimeDetector';",
82
+ '',
83
+ ].join('\n'),
84
+ },
85
+ {
86
+ anchor: ' ...(commerce === undefined ? {} : { commerce }),\n',
87
+ replacement: [
88
+ ' ...(commerce === undefined ? {} : { commerce }),',
89
+ ' // Notifications v2: the game-rendered opt-in the server bridge serves',
90
+ ' // (a gated beta: `integrations.notifications` stays approval-required',
91
+ ' // until Reddit approves the app), and Reddit login for a logged-out',
92
+ ' // player, who must log in before opting in.',
93
+ ' ...notificationOptions(runtime),',
94
+ ' loginPrompt: createDevvitWebLoginPrompt(),',
95
+ '',
96
+ ].join('\n'),
97
+ },
98
+ {
99
+ anchor: 'function createCommerceOptions(): DevvitGatewayCommerceOptions | undefined {\n',
100
+ replacement: [
101
+ 'function notificationOptions(',
102
+ ' runtime: RuntimeConfig,',
103
+ "): Pick<Parameters<typeof createDevvitPlatformGateway>[0], 'notifications'> {",
104
+ ' const topics = starterNotificationTopics(runtime.configTarget).map((topic) => topic.id);',
105
+ ' return topics.length === 0 ? {} : { notifications: { topics } };',
106
+ '}',
107
+ '',
108
+ 'function createCommerceOptions(): DevvitGatewayCommerceOptions | undefined {',
109
+ '',
110
+ ].join('\n'),
111
+ },
112
+ ],
113
+ 'apps/target-devvit/src/server/index.ts': [
114
+ {
115
+ anchor: "import { context, createServer, getServerPort, payments, reddit, redis } from '@devvit/web/server';\n",
116
+ replacement: [
117
+ 'import {',
118
+ ' context,',
119
+ ' createServer,',
120
+ ' getServerPort,',
121
+ ' notifications,',
122
+ ' payments,',
123
+ ' reddit,',
124
+ ' redis,',
125
+ "} from '@devvit/web/server';",
126
+ '',
127
+ ].join('\n'),
128
+ },
129
+ {
130
+ anchor: [
131
+ 'import {',
132
+ ' createDevvitCommerceBridgeProducts,',
133
+ ' createDevvitCommerceV2BridgeHandler,',
134
+ "} from '@mpgd/adapter-devvit/server';",
135
+ '',
136
+ ].join('\n'),
137
+ replacement: [
138
+ 'import {',
139
+ ' createDevvitCommerceBridgeProducts,',
140
+ ' createDevvitCommerceV2BridgeHandler,',
141
+ ' createDevvitNotificationService,',
142
+ ' createDevvitNotificationsV2BridgeHandler,',
143
+ "} from '@mpgd/adapter-devvit/server';",
144
+ '',
145
+ ].join('\n'),
146
+ },
147
+ {
148
+ anchor: "import type { ProductCatalog } from '@mpgd/catalog';\n",
149
+ replacement: [
150
+ "import { resolveNotificationTopicsForTarget, type NotificationTopicCatalog, type ProductCatalog } from '@mpgd/catalog';",
151
+ "import { assertNotificationDeployment } from '@mpgd/platform/notifications';",
152
+ '',
153
+ ].join('\n'),
154
+ },
155
+ {
156
+ anchor: "import devvitManifest from '../../devvit.json';\n",
157
+ replacement: [
158
+ "import notificationDeploymentJson from '../../../../notifications/deployment.json';",
159
+ "import notificationTopicsJson from '../../../../notifications/topics.json';",
160
+ "import devvitManifest from '../../devvit.json';",
161
+ '',
162
+ ].join('\n'),
163
+ },
164
+ {
165
+ anchor: [
166
+ 'const handleBridgeRequest = createDevvitBridgeHandler({',
167
+ ' redis,',
168
+ ' currentPlayerId,',
169
+ ' currentDisplayName,',
170
+ ' storageKeyNamespace: gameName,',
171
+ ' ...(commerce === undefined ? {} : { commerce }),',
172
+ '});',
173
+ '',
174
+ ].join('\n'),
175
+ replacement: [
176
+ '/**',
177
+ ' * Notifications v2 (`notifications/topics.json`, mapped for the `reddit`',
178
+ ' * target): the game-rendered opt-in for the logged-in requester, and the',
179
+ ' * service that records consent and sends server reminders over the Devvit',
180
+ ' * notifications plugin. Devvit notifications are a gated beta: the game',
181
+ ' * shows them only once `integrations.notifications` is `available`.',
182
+ ' */',
183
+ 'const notificationTopicCatalog = notificationTopicsJson as NotificationTopicCatalog;',
184
+ 'const redditNotificationTopics = readRedditNotificationTopics();',
185
+ "const notificationService: Parameters<typeof handleNotificationTask>[0]['notifications'] =",
186
+ ' redditNotificationTopics.length === 0 ? undefined : createNotificationService();',
187
+ '// The opt-in is served only with the service that sends; a refused catalog turns both off.',
188
+ 'const notificationsBridge = notificationService === undefined',
189
+ ' ? undefined',
190
+ ' : createDevvitNotificationsV2BridgeHandler({',
191
+ ' notifications,',
192
+ ' currentUserId: currentPlayerId,',
193
+ ' topics: redditNotificationTopics,',
194
+ ' });',
195
+ 'const handleBridgeRequest = createDevvitBridgeHandler({',
196
+ ' redis,',
197
+ ' currentPlayerId,',
198
+ ' currentDisplayName,',
199
+ ' storageKeyNamespace: gameName,',
200
+ ' ...(commerce === undefined ? {} : { commerce }),',
201
+ ' ...(notificationsBridge === undefined ? {} : { notifications: notificationsBridge }),',
202
+ '});',
203
+ '',
204
+ ].join('\n'),
205
+ },
206
+ {
207
+ anchor: [
208
+ '/**',
209
+ ' * The notifications v2 service (`createDevvitNotificationService` from',
210
+ ' * `@mpgd/adapter-devvit/server`). None until the game declares a topic',
211
+ ' * catalog, so the scheduler tasks below do no work.',
212
+ ' */',
213
+ "const notificationService: Parameters<typeof handleNotificationTask>[0]['notifications'] = undefined;",
214
+ '',
215
+ ].join('\n'),
216
+ replacement: [
217
+ '/** The topic ids the catalog maps for Reddit; none when the catalog cannot be read. */',
218
+ 'function readRedditNotificationTopics(): readonly string[] {',
219
+ ' try {',
220
+ " return resolveNotificationTopicsForTarget(notificationTopicCatalog, 'reddit').map((topic) => topic.id);",
221
+ ' } catch (error) {',
222
+ ' console.error(`devvit notifications disabled: ${errorMessage(error)}`);',
223
+ ' return [];',
224
+ ' }',
225
+ '}',
226
+ '',
227
+ '/**',
228
+ ' * The notifications v2 service over Redis and the Devvit plugin; the',
229
+ ' * scheduler tasks below run its reminders and purges. A catalog or',
230
+ ' * deployment the service refuses turns notifications off, never the game',
231
+ ' * server.',
232
+ ' */',
233
+ "function createNotificationService(): Parameters<typeof handleNotificationTask>[0]['notifications'] {",
234
+ ' try {',
235
+ ' return createDevvitNotificationService({',
236
+ ' redis,',
237
+ ' notifications,',
238
+ ' catalog: notificationTopicCatalog,',
239
+ ' deployment: assertNotificationDeployment(notificationDeploymentJson),',
240
+ ' }).service;',
241
+ ' } catch (error) {',
242
+ ' console.error(`devvit notifications disabled: ${errorMessage(error)}`);',
243
+ ' return undefined;',
244
+ ' }',
245
+ '}',
246
+ '',
247
+ ].join('\n'),
248
+ },
249
+ ],
250
+ };
251
+ const phaserEdits = {
252
+ 'src/runtime/createGame.ts': [
253
+ {
254
+ anchor: "import { LobbyScene } from '../scenes/LobbyScene';\n",
255
+ replacement: [
256
+ "import { LobbyScene } from '../scenes/LobbyScene';",
257
+ 'import {',
258
+ ' NotificationSettingsScene,',
259
+ ' routeNotificationSettingsOpens,',
260
+ "} from '../scenes/NotificationSettingsScene';",
261
+ '',
262
+ ].join('\n'),
263
+ },
264
+ {
265
+ anchor: ' scene: [BootScene, LobbyScene, PlayScene],\n',
266
+ replacement: ' scene: [BootScene, LobbyScene, PlayScene, NotificationSettingsScene],\n',
267
+ },
268
+ {
269
+ anchor: ' ownGameAudio(game, input.context);\n',
270
+ replacement: [
271
+ ' ownGameAudio(game, input.context);',
272
+ ' // The advertising opt-out action and consent notices open the settings screen.',
273
+ ' routeNotificationSettingsOpens(game, input.context);',
274
+ '',
275
+ ].join('\n'),
276
+ },
277
+ ],
278
+ 'src/scenes/LobbyScene.ts': [
279
+ {
280
+ anchor: "import { starterContextKey, type StarterContext } from '../runtime/gameContext';\n",
281
+ replacement: [
282
+ "import { starterContextKey, type StarterContext } from '../runtime/gameContext';",
283
+ "import { notificationSettingsSceneKey } from './NotificationSettingsScene';",
284
+ '',
285
+ ].join('\n'),
286
+ },
287
+ {
288
+ anchor: " this.input.once('pointerdown', () => this.scene.start('PlayScene'));\n",
289
+ replacement: [
290
+ ' // The settings button shows only where this target has notifications.',
291
+ ' const notificationsButton = context.platform.notificationsV2 === undefined',
292
+ ' ? undefined',
293
+ ' : this.add',
294
+ " .text(940, 16, locale === 'ko' ? '알림 설정' : 'Notifications', {",
295
+ " color: '#5eead4',",
296
+ " fontFamily: 'Arial, sans-serif',",
297
+ " fontSize: '22px',",
298
+ ' })',
299
+ ' .setOrigin(1, 0)',
300
+ ' .setPadding(16, 12, 16, 12)',
301
+ ' .setInteractive({ useHandCursor: true });',
302
+ " notificationsButton?.on('pointerup', () => this.scene.start(notificationSettingsSceneKey));",
303
+ ' // A tap anywhere else starts the game; the scene drops this listener on shutdown.',
304
+ " this.input.on('pointerdown', (_pointer: Phaser.Input.Pointer, over: Phaser.GameObjects.GameObject[]) => {",
305
+ ' if (notificationsButton === undefined || !over.includes(notificationsButton)) {',
306
+ " this.scene.start('PlayScene');",
307
+ ' }',
308
+ ' });',
309
+ '',
310
+ ].join('\n'),
311
+ },
312
+ ],
313
+ };
314
+ const liteEdits = {
315
+ 'src/runtime/createGame.ts': [
316
+ {
317
+ anchor: "import { createTapGame } from '../lite/tapGame';\n",
318
+ replacement: [
319
+ "import { mountNotificationSettingsPanel } from '../lite/notificationSettingsPanel';",
320
+ "import { createTapGame } from '../lite/tapGame';",
321
+ '',
322
+ ].join('\n'),
323
+ },
324
+ {
325
+ anchor: " const shell = createTapGame({ mount, title: '__GAME_TITLE__' });\n",
326
+ replacement: [
327
+ " const shell = createTapGame({ mount, title: '__GAME_TITLE__' });",
328
+ ' const disposeNotificationSettings = mountNotificationSettingsPanel(mount, input.context);',
329
+ '',
330
+ ].join('\n'),
331
+ },
332
+ {
333
+ anchor: ' audio.dispose();\n',
334
+ replacement: [' audio.dispose();', ' disposeNotificationSettings();', ''].join('\n'),
335
+ },
336
+ ],
337
+ };
338
+ const readmeSection = [
339
+ '',
340
+ '## Notifications',
341
+ '',
342
+ 'This game was created with `--providers notifications`. Its topics live in',
343
+ '`notifications/topics.json` and the backend settings (`advertisingRules`,',
344
+ 'the advertising sender) in `notifications/deployment.json`; run',
345
+ '`pnpm validate:notifications` after every change and before every release.',
346
+ 'Replace the sender contact placeholder in `notifications/deployment.json`',
347
+ 'before any advertising is sent. The notification settings screen asks for the',
348
+ 'OS or host permission and records reminder, advertising (광고성 정보',
349
+ '수신동의), and night consent through the notifications runtime. See',
350
+ '`docs/NOTIFICATIONS.md` and, for Android and iOS, the mpgd-kit Firebase setup',
351
+ 'guide; `mpgd target init capacitor --providers notifications` installs the two',
352
+ 'Capacitor plugins into a game-owned shell.',
353
+ '',
354
+ ].join('\n');
355
+ function applyEdits(path, content, edits) {
356
+ let next = content;
357
+ for (const { anchor, replacement } of edits) {
358
+ const first = next.indexOf(anchor);
359
+ if (first === -1 || next.indexOf(anchor, first + anchor.length) !== -1) {
360
+ throw new Error(`The notifications starter could not find exactly one anchor in ${path}; the template changed: ${JSON.stringify(anchor.split('\n')[0])}`);
361
+ }
362
+ next = `${next.slice(0, first)}${replacement}${next.slice(first + anchor.length)}`;
363
+ }
364
+ return next;
365
+ }
366
+ function editPackageJson(content) {
367
+ const value = JSON.parse(content);
368
+ value.scripts = {
369
+ ...value.scripts,
370
+ 'validate:notifications': 'ttsx --project tsconfig.tools.json tools/validate-notifications.ts',
371
+ };
372
+ value.dependencies = Object.fromEntries(Object.entries({ ...value.dependencies, ...notificationsGameDependencies })
373
+ .sort(([left], [right]) => left.localeCompare(right)));
374
+ return `${JSON.stringify(value, null, 2)}\n`;
375
+ }
376
+ /** The Devvit server reads the deployment settings through `@mpgd/platform/notifications`. */
377
+ function editDevvitPackageJson(content) {
378
+ const value = JSON.parse(content);
379
+ value.dependencies = Object.fromEntries(Object.entries({
380
+ ...value.dependencies,
381
+ '@mpgd/platform': '__MPGD_DEPENDENCY_VERSION_PLATFORM__',
382
+ }).sort(([left], [right]) => left.localeCompare(right)));
383
+ return `${JSON.stringify(value, null, 2)}\n`;
384
+ }
385
+ function editTargets(content) {
386
+ const value = JSON.parse(content);
387
+ // Android, iOS, and Apps in Toss expose notifications once their provider
388
+ // is ready; the provider still reports its own readiness (no plugin, no
389
+ // mapped topic), which the target config follows. Reddit keeps the
390
+ // default `approval-required` until Reddit approves the app, and exposes
391
+ // the login prompt the opt-in needs.
392
+ for (const name of ['android', 'ios', 'ait']) {
393
+ const target = value.targets[name];
394
+ if (target !== undefined) {
395
+ target.integrations = { ...target.integrations, notifications: 'available' };
396
+ }
397
+ }
398
+ const reddit = value.targets.reddit;
399
+ if (reddit !== undefined) {
400
+ reddit.integrations = { ...reddit.integrations, identityUpgrade: 'available' };
401
+ }
402
+ return `${JSON.stringify(value, null, 2)}\n`;
403
+ }
404
+ /**
405
+ * Applies the notifications starter to a generated game's template files:
406
+ * adds the overlay's files (`common` and the template's own) and edits the
407
+ * base files at their anchors. Throws when an anchor is missing, so a
408
+ * template change cannot silently drop the wiring.
409
+ */
410
+ export function applyNotificationsStarter(baseFiles, overlay) {
411
+ const edits = {
412
+ ...commonEdits,
413
+ ...(overlay.template === 'phaser' ? phaserEdits : liteEdits),
414
+ };
415
+ const pending = new Set(Object.keys(edits));
416
+ const edited = baseFiles.map((file) => {
417
+ const fileEdits = edits[file.relativePath];
418
+ let content = file.content;
419
+ if (fileEdits !== undefined) {
420
+ pending.delete(file.relativePath);
421
+ content = applyEdits(file.relativePath, content, fileEdits);
422
+ }
423
+ else if (file.relativePath === 'package.json') {
424
+ content = editPackageJson(content);
425
+ }
426
+ else if (file.relativePath === 'apps/target-devvit/package.json') {
427
+ content = editDevvitPackageJson(content);
428
+ }
429
+ else if (file.relativePath === 'mpgd.targets.json') {
430
+ content = editTargets(content);
431
+ }
432
+ else if (file.relativePath === 'README.md') {
433
+ content = `${content.trimEnd()}\n${readmeSection}`;
434
+ }
435
+ return content === file.content ? file : { ...file, content };
436
+ });
437
+ if (pending.size > 0) {
438
+ throw new Error(`The notifications starter is missing template files: ${[...pending].sort().join(', ')}`);
439
+ }
440
+ const added = [
441
+ ...overlay.common,
442
+ ...(overlay.template === 'phaser' ? overlay.phaser : overlay.lite),
443
+ ];
444
+ const existing = new Set(edited.map((file) => file.relativePath));
445
+ for (const file of added) {
446
+ // Also refuses a path the overlay's parts both carry, so neither wins silently.
447
+ if (existing.has(file.relativePath)) {
448
+ throw new Error(`The notifications starter would overwrite ${file.relativePath}.`);
449
+ }
450
+ existing.add(file.relativePath);
451
+ }
452
+ return [...edited, ...added];
453
+ }
454
+ /** The Capacitor gateways the notifications starter wires, by native platform. */
455
+ export const notificationsCapacitorGateways = Object.freeze({
456
+ android: 'src/platform/buildGateways/capacitorAndroid.ts',
457
+ ios: 'src/platform/buildGateways/capacitorIos.ts',
458
+ });
459
+ /** The starter modules a wired Capacitor gateway imports, directly or through one another. */
460
+ export const notificationsCapacitorGatewayModules = Object.freeze([
461
+ 'src/platform/capacitorNotifications.ts',
462
+ 'src/platform/notifications.ts',
463
+ 'src/platform/notificationSettings.ts',
464
+ 'src/platform/notificationOpens.ts',
465
+ ]);
466
+ /**
467
+ * The files `mpgd target init capacitor --providers notifications` writes
468
+ * with a wired gateway when the game has none: the modules it imports, the
469
+ * catalog files they read, the validator, and the game's notifications doc.
470
+ */
471
+ export const notificationsCapacitorSupportFiles = Object.freeze([
472
+ ...notificationsCapacitorGatewayModules,
473
+ 'notifications/topics.json',
474
+ 'notifications/deployment.json',
475
+ 'tools/validate-notifications.ts',
476
+ 'docs/NOTIFICATIONS.md',
477
+ ]);
478
+ /** Whether a Capacitor gateway already registers a notifications provider. */
479
+ export function capacitorGatewayRegistersNotifications(source) {
480
+ // One pass over strings and comments: neither registers anything, and a
481
+ // string that holds `//` or `/*` does not start a comment. Regex literals
482
+ // are not tokenized, so a quote or `/*` inside one can hide a call or
483
+ // expose a commented one; such a gateway gets a warning or keeps its
484
+ // wiring as it is, and is never rewritten wrongly.
485
+ const code = source.replace(/'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`|\/\/[^\n]*|\/\*[\s\S]*?\*\//gu, (token) => (token.startsWith('/') ? ' ' : '""'));
486
+ return /\b(?:createStarterCapacitorNotifications|createCapacitorNotificationServiceProvider)\s*\(/u
487
+ .test(code);
488
+ }
489
+ /**
490
+ * The template's Capacitor gateway with the notifications provider wired in:
491
+ * the same anchored edits `--providers notifications` makes at generation.
492
+ */
493
+ export function wireCapacitorGatewayNotifications(relativePath, source) {
494
+ const edits = commonEdits[relativePath];
495
+ if (edits === undefined || !Object.values(notificationsCapacitorGateways).includes(relativePath)) {
496
+ throw new Error(`${relativePath} is not a Capacitor gateway the notifications starter wires.`);
497
+ }
498
+ return applyEdits(relativePath, source, edits);
499
+ }
500
+ /** Reads `--providers` for `mpgd game create`: only `notifications` is a game-level provider today. */
501
+ export function readGameCreateProviders(value) {
502
+ if (value === undefined) {
503
+ return [];
504
+ }
505
+ const ids = [...new Set(value.split(',').map((id) => id.trim()).filter((id) => id.length > 0))];
506
+ for (const id of ids) {
507
+ if (id !== notificationsProviderId) {
508
+ throw new Error(`Unsupported --providers ${id} for mpgd game create; expected: ${notificationsProviderId}. `
509
+ + 'Store plugins (play-billing, storekit) are installed with "mpgd target init capacitor --providers ...".');
510
+ }
511
+ }
512
+ return ids;
513
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.52.0",
3
+ "version": "0.53.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.3",
74
- "@mpgd/platform": "0.25.0",
75
- "@mpgd/phaser-assets": "0.9.1",
76
- "@mpgd/target-config": "0.23.3"
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"
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.19.0",
85
- "@mpgd/adapter-browser": "0.11.3",
86
- "@mpgd/adapter-capacitor": "0.11.1",
87
- "@mpgd/adapter-devvit": "0.13.1",
88
- "@mpgd/adapter-verse8": "0.7.3",
89
- "@mpgd/analytics": "0.4.12",
90
- "@mpgd/capacitor-play-billing": "0.3.3",
91
- "@mpgd/bridge": "0.14.0",
92
- "@mpgd/capacitor-storekit": "0.3.3",
93
- "@mpgd/game-services": "0.25.1",
94
- "@mpgd/game-runtime": "0.7.3",
95
- "@mpgd/i18n": "0.6.17",
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",
96
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
@@ -0,0 +1,75 @@
1
+ # Notifications in __GAME_TITLE__
2
+
3
+ This game was created with `mpgd game create --providers notifications`. The
4
+ mpgd-kit notifications guide (`docs/NOTIFICATIONS.md` in mpgd-kit) explains
5
+ the whole flow; this page lists what the starter put in this game and what is
6
+ left for you.
7
+
8
+ ## Files
9
+
10
+ | File | What it is |
11
+ | --- | --- |
12
+ | `notifications/topics.json` | The topic catalog: each topic's purpose, channels, routes, copy, and per-target mapping. The one source for every target and the backend. |
13
+ | `notifications/deployment.json` | The backend settings: `advertisingRules` (`kr`), the advertising sender, and the zone. |
14
+ | `src/platform/notifications.ts` | Reads the catalog for a target, and the place to return your notifications runtime. |
15
+ | `src/platform/capacitorNotifications.ts` | The Android and iOS provider (FCM and local reminders), registered by the Capacitor gateways. |
16
+ | `src/platform/notificationSettings.ts` | The settings screen's logic: permission, reminders, advertising and night consent, notices. |
17
+ | `src/platform/notificationOpens.ts` | The game's one listener for notification opens: the settings route, and an `other` route for your topics. |
18
+ | `src/scenes/NotificationSettingsScene.ts` or `src/lite/notificationSettingsPanel.ts` | The settings screen the game renders. |
19
+ | `tools/validate-notifications.ts` | `pnpm validate:notifications`. |
20
+
21
+ ## Before you release
22
+
23
+ 1. Replace `REPLACE-WITH-SENDER-PHONE-OR-ADDRESS` in
24
+ `notifications/deployment.json` with the sender's phone number or
25
+ address. Every advertising push carries it under the Korean rules, and
26
+ `pnpm validate:notifications` fails until you do. The game backend
27
+ refuses the placeholder as well: a Worker given this file as is does not
28
+ start its notifications.
29
+ 2. Keep the sender in `notices.copy` (`[__GAME_TITLE__]` in every variant) the
30
+ same as `advertisingSender.name` in `notifications/deployment.json`: a
31
+ consent notice must name the sender.
32
+ 3. Run `pnpm validate:notifications` after every catalog change.
33
+ 4. Decide each topic's purpose with your own legal review. The starter's
34
+ `daily-ready` and `streak-at-risk` are `requested`: off until the player
35
+ turns them on, and their copy states only the player's own progress. If
36
+ you add a reward, a benefit, or a promotion to them, or send them to
37
+ players who did not turn them on, declare them `advertising` instead.
38
+ 5. Android and iOS: set up Firebase (see the mpgd-kit Firebase guide,
39
+ `docs/FIREBASE_NOTIFICATIONS.md`), then run
40
+ `mpgd target init capacitor --providers notifications` to install the two
41
+ plugins into the game-owned shell. Never install
42
+ `@capacitor/push-notifications` next to them.
43
+ 6. Advertising on Android: the sample `events` topic is mapped to iOS only.
44
+ An Android FCM notification cannot carry the opt-out action that the
45
+ Korean rules require on every advertising push (a settings path in the
46
+ text does not replace it), and the kit does not render Android
47
+ advertising natively yet. Do not add an `android` mapping to an
48
+ advertising topic until it does.
49
+ 7. Apps in Toss: register each functional-message template in the console and
50
+ add its code to the topic's `targets.ait` mapping
51
+ (`{ "templateSetCode": "...", "agreement": "required" }`). Apps in Toss
52
+ cannot deliver advertising topics.
53
+ 8. Reddit: apply for Devvit notifications; keep `integrations.notifications`
54
+ at `approval-required` until Reddit approves the app and its copy.
55
+
56
+ ## The notifications runtime
57
+
58
+ Consent records (reminders, 광고성 정보 수신동의, night consent), endpoint
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
67
+ switch from the latest record, read every time it opens and after every
68
+ change, so a choice made on another device shows. A tap reads the records
69
+ again and writes only what changes. It keeps nothing on the device, and
70
+ shows no switch when the records cannot be read.
71
+
72
+ Night consent adds to 광고성 정보 수신동의: the screen offers it only while
73
+ advertising consent is granted, and turning advertising consent off also
74
+ withdraws a granted night consent in the same call (two records, two result
75
+ notices).
@@ -0,0 +1,8 @@
1
+ {
2
+ "advertisingRules": "kr",
3
+ "advertisingSender": {
4
+ "name": "__GAME_TITLE__",
5
+ "contact": "REPLACE-WITH-SENDER-PHONE-OR-ADDRESS"
6
+ },
7
+ "timeZone": "Asia/Seoul"
8
+ }