@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.
- package/dist/capacitor-shell-starter.d.ts +22 -0
- package/dist/capacitor-shell-starter.js +387 -18
- package/dist/index.js +35 -4
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +363 -2
- package/dist/notifications-starter.d.ts +69 -0
- package/dist/notifications-starter.js +513 -0
- package/package.json +17 -17
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +75 -0
- package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
- package/templates/notifications-overlay/common/notifications/topics.json +222 -0
- package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
- package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
- package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
- package/templates/notifications-overlay/common/src/platform/notifications.ts +131 -0
- package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
- package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +282 -0
- package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
- package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
- package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
- 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.
|
|
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.
|
|
74
|
-
"@mpgd/platform": "0.
|
|
75
|
-
"@mpgd/
|
|
76
|
-
"@mpgd/
|
|
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.
|
|
85
|
-
"@mpgd/adapter-browser": "0.11.
|
|
86
|
-
"@mpgd/adapter-capacitor": "0.
|
|
87
|
-
"@mpgd/adapter-devvit": "0.13.
|
|
88
|
-
"@mpgd/adapter-verse8": "0.7.
|
|
89
|
-
"@mpgd/analytics": "0.4.
|
|
90
|
-
"@mpgd/
|
|
91
|
-
"@mpgd/
|
|
92
|
-
"@mpgd/
|
|
93
|
-
"@mpgd/
|
|
94
|
-
"@mpgd/
|
|
95
|
-
"@mpgd/
|
|
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).
|