@mpgd/cli 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/capacitor-shell-starter.d.ts +22 -0
  2. package/dist/capacitor-shell-starter.js +395 -18
  3. package/dist/deploy-planning.js +2 -2
  4. package/dist/index.js +122 -34
  5. package/dist/kit-upgrade.js +2 -2
  6. package/dist/native-build-info.json +2 -2
  7. package/dist/native-build-target.js +433 -2
  8. package/dist/notifications-starter.d.ts +75 -0
  9. package/dist/notifications-starter.js +522 -0
  10. package/dist/offline-playtest.js +1 -1
  11. package/dist/tooling-migration.js +1 -1
  12. package/package.json +16 -16
  13. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +104 -0
  14. package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
  15. package/templates/notifications-overlay/common/notifications/topics.json +222 -0
  16. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
  17. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
  18. package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
  19. package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
  20. package/templates/notifications-overlay/common/src/platform/notifications.ts +163 -0
  21. package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
  22. package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
  23. package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +301 -0
  24. package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
  25. package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
  26. package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
  27. package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
  28. package/templates/phaser-game/src/platform/gameServices.ts +3 -0
  29. package/templates/phaser-game/src/platform/serverSession.ts +66 -0
@@ -0,0 +1,163 @@
1
+ import { resolveNotificationTopicsForTarget, type NotificationTopicCatalog } from '@mpgd/catalog';
2
+ import type { NotificationTopicDeclaration } from '@mpgd/platform/notifications';
3
+ import type { TargetConfiguredGateway } from '@mpgd/target-config';
4
+
5
+ import notificationTopicsJson from '../../notifications/topics.json';
6
+ import {
7
+ createStarterWorkerNotificationBackend,
8
+ type StarterNotificationBackend,
9
+ } from './notificationBackend';
10
+ import { resolveStarterServerSession } from './serverSession';
11
+ import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
12
+ import {
13
+ createStarterNotificationSettings,
14
+ type StarterNotificationSettings,
15
+ } from './notificationSettings';
16
+
17
+ /**
18
+ * The game's notification topic catalog (`notifications/topics.json`), the
19
+ * one source for every target's provider, the Apps in Toss wrapper, the
20
+ * Devvit server, and the game backend (`MPGD_NOTIFICATIONS_CATALOG`).
21
+ * `pnpm validate:notifications` checks it with `notifications/deployment.json`.
22
+ *
23
+ * The starter topics' purposes follow the notifications RFC (open question 1):
24
+ * `daily-ready` and `streak-at-risk` are `requested` reminders the player
25
+ * turns on, `friend-challenge` is `service`, and `events` is the sample
26
+ * `advertising` topic, mapped to iOS only: an Android FCM push cannot carry
27
+ * the opt-out action the Korean rules require yet. A topic whose copy offers a reward, a benefit, or a
28
+ * promotion is advertising: declare it so, never `requested`.
29
+ */
30
+ export const starterNotificationCatalog = notificationTopicsJson as NotificationTopicCatalog;
31
+
32
+ /** The consent text version recorded with advertising and night consent; bump it when the text changes. */
33
+ export const starterAdvertisingConsentTextVersion = '1';
34
+
35
+ /** The topics this deployment target maps, each with its mapping, as providers read them. */
36
+ export function starterNotificationTopics(configTarget: string): readonly NotificationTopicDeclaration[] {
37
+ try {
38
+ return resolveNotificationTopicsForTarget(starterNotificationCatalog, configTarget);
39
+ } catch (error) {
40
+ // A catalog that cannot be read turns notifications off, never the game.
41
+ console.error('[notifications] the topic catalog could not be read.', error);
42
+ return [];
43
+ }
44
+ }
45
+
46
+ export type { StarterNotificationBackend } from './notificationBackend';
47
+
48
+ /**
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.
57
+ *
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.
67
+ */
68
+ export function createStarterNotificationBackend(
69
+ platform: TargetConfiguredGateway,
70
+ ): StarterNotificationBackend | undefined {
71
+ return createStarterWorkerNotificationBackend(platform, {
72
+ baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL,
73
+ mode: import.meta.env.MODE,
74
+ session: resolveStarterServerSession(),
75
+ topics: starterNotificationTopics(platform.configTarget),
76
+ defaultLocale: starterNotificationCatalog.defaultLocale,
77
+ log: (message, error) => warnOnce(platform, message, error),
78
+ });
79
+ }
80
+
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>();
100
+ /** Per gateway, for the process: agreements given on a host that gives no read. */
101
+ const agreements = new WeakMap<TargetConfiguredGateway, Set<string>>();
102
+
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
+ }
115
+ }
116
+ return backend;
117
+ }
118
+
119
+ /** The settings screen's model for this gateway and locale. */
120
+ export function createStarterNotificationSettingsModel(
121
+ platform: TargetConfiguredGateway,
122
+ input: { readonly locale: string },
123
+ ): StarterNotificationSettings {
124
+ const requestUpgrade = platform.identity.requestUpgrade?.bind(platform.identity);
125
+ const backend = starterNotificationBackend(platform);
126
+ let agreed = agreements.get(platform);
127
+ if (agreed === undefined) {
128
+ agreed = new Set<string>();
129
+ agreements.set(platform, agreed);
130
+ }
131
+ return createStarterNotificationSettings({
132
+ provider: platform.notificationsV2,
133
+ runtime: backend?.runtime,
134
+ ...(backend === undefined ? {} : { listConsent: () => backend.listConsent() }),
135
+ topics: starterNotificationTopics(platform.configTarget),
136
+ ...(starterNotificationCatalog.notices === undefined
137
+ ? {}
138
+ : { noticeCopy: starterNotificationCatalog.notices.copy }),
139
+ defaultLocale: starterNotificationCatalog.defaultLocale,
140
+ consentTextVersion: starterAdvertisingConsentTextVersion,
141
+ agreedThisSession: agreed,
142
+ // Devvit: a logged-out player logs in before the opt-in (the gateway's loginPrompt).
143
+ ...(requestUpgrade === undefined
144
+ ? {}
145
+ : { requestLogin: () => requestUpgrade({ reason: 'notifications' }) }),
146
+ locale: input.locale,
147
+ });
148
+ }
149
+
150
+ /**
151
+ * The game's one listener for notification opens (see
152
+ * `routeNotificationOpens` in `notificationOpens.ts`), over the runtime when
153
+ * the game has one, else the provider. Returns the unsubscribe.
154
+ */
155
+ export function subscribeStarterNotificationOpens(
156
+ platform: TargetConfiguredGateway,
157
+ routes: NotificationOpenRoutes,
158
+ ): () => void {
159
+ const source = starterNotificationBackend(platform)?.runtime ?? platform.notificationsV2;
160
+ return source === undefined ? () => undefined : routeNotificationOpens(source, routes);
161
+ }
162
+
163
+ export { starterNotificationSettingsRoute } from './notificationOpens';
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import { validateNotificationTopicCatalog } from '@mpgd/catalog';
4
+ import {
5
+ assertNotificationDeployment,
6
+ notificationDeploymentPlaceholders,
7
+ referenceNotificationProviders,
8
+ referenceNotificationTargetProviders,
9
+ type NotificationDeployment,
10
+ } from '@mpgd/platform/notifications';
11
+
12
+ // Checks notifications/topics.json against the notifications v2 rules, the
13
+ // providers each target enables (android and ios: FCM, ait: Apps in Toss,
14
+ // reddit: Devvit), and notifications/deployment.json, the settings the game
15
+ // backend takes as MPGD_NOTIFICATIONS_DEPLOYMENT. Run it before every
16
+ // release: `pnpm validate:notifications`.
17
+
18
+ const gameRoot = new URL('../', import.meta.url);
19
+
20
+ function readJson(relative: string): unknown {
21
+ return JSON.parse(readFileSync(new URL(relative, gameRoot), 'utf8')) as unknown;
22
+ }
23
+
24
+ const problems: string[] = [];
25
+ let deployment: NotificationDeployment | undefined;
26
+ try {
27
+ // Placeholders are reported below, so the catalog is still checked against
28
+ // the deployment's rules in the same run.
29
+ deployment = assertNotificationDeployment(readJson('notifications/deployment.json'), {
30
+ allowPlaceholders: true,
31
+ });
32
+ } catch (error) {
33
+ problems.push(
34
+ `notifications/deployment.json: ${error instanceof Error ? error.message : String(error)}`,
35
+ );
36
+ }
37
+ // Under the Korean rules every advertising push names its sender and their
38
+ // phone number or address; the starter ships a placeholder contact. The game
39
+ // backend refuses a placeholder too (`assertNotificationDeployment`), so a
40
+ // Worker given this file would not start its notifications.
41
+ for (const field of deployment === undefined
42
+ ? []
43
+ : notificationDeploymentPlaceholders(deployment)) {
44
+ problems.push(
45
+ field === 'advertisingSender.contact'
46
+ ? `notifications/deployment.json: replace the ${field} placeholder with the sender's real contact `
47
+ + '(a phone number or an address), which every advertising push carries.'
48
+ : `notifications/deployment.json: replace the ${field} placeholder with the real value.`,
49
+ );
50
+ }
51
+ const result = validateNotificationTopicCatalog(readJson('notifications/topics.json'), {
52
+ targetProviders: Object.fromEntries(
53
+ Object.entries(referenceNotificationTargetProviders).map(([target, provider]) => [
54
+ target,
55
+ referenceNotificationProviders[provider],
56
+ ]),
57
+ ),
58
+ ...(deployment === undefined ? {} : { deployment }),
59
+ });
60
+ // A refused deployment is reported above, not again as a missing one.
61
+ problems.unshift(
62
+ ...result.issues
63
+ .filter((issue) => deployment !== undefined || !issue.path.startsWith('deployment.'))
64
+ .map((issue) => `${issue.path}: ${issue.message}`),
65
+ );
66
+ if (!result.valid || problems.length > 0) {
67
+ console.error(
68
+ `Notification settings are not ready:\n${problems.map((problem) => `- ${problem}`).join('\n')}`,
69
+ );
70
+ process.exitCode = 1;
71
+ } else {
72
+ console.info(
73
+ `Notification topics: ${result.catalog.topics.length}; advertising rules: ${deployment?.advertisingRules ?? 'none'}.`,
74
+ );
75
+ }
@@ -0,0 +1,200 @@
1
+ import {
2
+ starterNotificationLabelsFor,
3
+ type StarterNotificationSettings,
4
+ type StarterNotificationSettingsView,
5
+ } from '../platform/notificationSettings';
6
+ import {
7
+ createStarterNotificationSettingsModel,
8
+ subscribeStarterNotificationOpens,
9
+ } from '../platform/notifications';
10
+ import type { StarterContext } from '../runtime/gameContext';
11
+
12
+ /**
13
+ * A minimal notification settings panel for the lite template: a button over
14
+ * the game opens a DOM panel with the OS or host permission, reminders, and
15
+ * the advertising and night consent (`notificationSettings.ts` holds the
16
+ * logic). Every change is a tap; no switch starts on. A notification open
17
+ * whose route is the settings screen (the advertising opt-out action, a
18
+ * consent notice) opens the panel too. Returns the disposer.
19
+ */
20
+ export function mountNotificationSettingsPanel(mount: HTMLElement, context: StarterContext): () => void {
21
+ if (context.platform.notificationsV2 === undefined) {
22
+ // This target has no notifications: no button that opens an empty panel.
23
+ return () => undefined;
24
+ }
25
+ const text = starterNotificationLabelsFor(context.locale);
26
+ let settings: StarterNotificationSettings | undefined;
27
+ let busy = false;
28
+ const panel = document.createElement('div');
29
+ panel.setAttribute('role', 'dialog');
30
+ panel.setAttribute('aria-label', text.title);
31
+ panel.style.cssText = 'position:absolute;inset:8%;overflow:auto;padding:16px;'
32
+ + 'background:#0b1726;color:#d6dee8;font:14px system-ui,sans-serif;border-radius:12px;display:none;';
33
+ const openButton = document.createElement('button');
34
+ openButton.type = 'button';
35
+ openButton.textContent = text.title;
36
+ openButton.style.cssText = 'position:absolute;top:8px;right:8px;padding:8px 12px;';
37
+ // Taps on the panel stay out of the game's own tap handling.
38
+ for (const element of [panel, openButton]) {
39
+ element.addEventListener('pointerdown', (event) => event.stopPropagation());
40
+ }
41
+
42
+ const model = (): StarterNotificationSettings => {
43
+ settings ??= createStarterNotificationSettingsModel(context.platform, {
44
+ locale: context.locale,
45
+ });
46
+ return settings;
47
+ };
48
+ const apply = async (action: () => Promise<StarterNotificationSettingsView>): Promise<void> => {
49
+ if (busy) {
50
+ return;
51
+ }
52
+ busy = true;
53
+ try {
54
+ const view = await action();
55
+ // A panel closed or removed meanwhile shows nothing, so records nothing.
56
+ if (panel.style.display === 'none' || !panel.isConnected) {
57
+ return;
58
+ }
59
+ render(view);
60
+ if (view.notices.length > 0) {
61
+ // Recorded in the background: taps need not wait for it.
62
+ void model().recordNoticesShown();
63
+ }
64
+ } catch (error) {
65
+ // The model reports its own failures as messages; anything else is logged.
66
+ console.error('[notifications] settings panel failed.', error);
67
+ } finally {
68
+ busy = false;
69
+ }
70
+ };
71
+ const button = (label: string, run: () => Promise<StarterNotificationSettingsView>): HTMLButtonElement => {
72
+ const element = document.createElement('button');
73
+ element.type = 'button';
74
+ element.textContent = label;
75
+ element.style.display = 'block';
76
+ element.style.margin = '6px 0';
77
+ element.addEventListener('click', () => {
78
+ void apply(run);
79
+ });
80
+ return element;
81
+ };
82
+ const paragraph = (value: string): HTMLParagraphElement => {
83
+ const element = document.createElement('p');
84
+ element.textContent = value;
85
+ // Notices put their title and body on two lines.
86
+ element.style.whiteSpace = 'pre-line';
87
+ return element;
88
+ };
89
+ const render = (view: StarterNotificationSettingsView): void => {
90
+ const current = model();
91
+ const close = document.createElement('button');
92
+ close.type = 'button';
93
+ close.textContent = text.close;
94
+ close.addEventListener('click', () => {
95
+ panel.style.display = 'none';
96
+ });
97
+ const children: HTMLElement[] = [close];
98
+ if (!view.available) {
99
+ children.push(paragraph(view.message ?? text.unavailable));
100
+ panel.replaceChildren(...children);
101
+ return;
102
+ }
103
+ if (!view.permission.perTopic) {
104
+ children.push(
105
+ paragraph(view.permission.state === 'granted' ? text.permissionOn : text.permissionOff),
106
+ );
107
+ if (view.permission.canRequest) {
108
+ // Explain before the one-shot OS prompt; the prompt runs only from the tap below.
109
+ children.push(paragraph(text.explanation));
110
+ children.push(button(text.turnOn, () => current.turnOn()));
111
+ }
112
+ if (view.permission.canLogin) {
113
+ children.push(button(text.logIn, () => current.logIn()));
114
+ }
115
+ if (view.permission.canOpenSettings) {
116
+ children.push(button(text.openSettings, () => current.openSettings()));
117
+ }
118
+ if (view.permission.canRevoke) {
119
+ children.push(button(text.turnOff, () => current.turnOff()));
120
+ }
121
+ }
122
+ if (!view.recording && (view.reminders.length > 0 || view.advertising !== undefined)) {
123
+ // No switch without the player's records: it could not show or change the truth.
124
+ children.push(
125
+ paragraph(view.choices === 'unreadable' ? text.choicesUnreadable : text.notConnected),
126
+ );
127
+ } else if (view.reminders.length > 0) {
128
+ children.push(paragraph(text.reminders));
129
+ for (const reminder of view.reminders) {
130
+ children.push(
131
+ button(`${reminder.label}: ${reminder.enabled ? text.on : text.off}`, () =>
132
+ current.setReminder(reminder.topic, !reminder.enabled),
133
+ ),
134
+ );
135
+ }
136
+ }
137
+ if (view.agreements.length > 0) {
138
+ // Per-topic hosts (Apps in Toss): service messages the player agrees to
139
+ // one by one; the host's settings withdraw them.
140
+ children.push(paragraph(text.agreements));
141
+ for (const agreement of view.agreements) {
142
+ const state = agreement.state === 'granted' ? text.on : text.off;
143
+ children.push(
144
+ agreement.canRequest
145
+ ? button(`${agreement.label}: ${state} - ${text.agree}`, () =>
146
+ current.agree(agreement.topic),
147
+ )
148
+ : paragraph(`${agreement.label}: ${state}`),
149
+ );
150
+ }
151
+ }
152
+ if (view.recording && view.advertising !== undefined) {
153
+ const { granted, night, nightSelectable } = view.advertising;
154
+ children.push(
155
+ button(`${text.advertising}: ${granted ? text.on : text.off}`, () =>
156
+ current.setAdvertising({ advertising: !granted }),
157
+ ),
158
+ );
159
+ children.push(paragraph(text.advertisingHelp));
160
+ const nightSwitch = button(`${text.night}: ${night ? text.on : text.off}`, () =>
161
+ current.setAdvertising({ night: !night }),
162
+ );
163
+ // Night consent adds to advertising consent: not selectable without it.
164
+ nightSwitch.disabled = !nightSelectable;
165
+ children.push(nightSwitch);
166
+ if (!nightSelectable) {
167
+ children.push(paragraph(text.nightNeedsAdvertising));
168
+ }
169
+ }
170
+ if (view.notices.length > 0) {
171
+ children.push(paragraph(text.notices));
172
+ for (const notice of view.notices) {
173
+ children.push(paragraph(`${notice.title}\n${notice.body}`));
174
+ }
175
+ }
176
+ if (view.message !== undefined) {
177
+ children.push(paragraph(view.message));
178
+ }
179
+ panel.replaceChildren(...children);
180
+ };
181
+
182
+ const open = (): void => {
183
+ panel.style.display = 'block';
184
+ const current = model();
185
+ void apply(() => current.load());
186
+ };
187
+ openButton.addEventListener('click', open);
188
+ // The game's one listener for opens, which receives a cold-start open:
189
+ // route your other topics' opens in `other` here.
190
+ const unsubscribe = subscribeStarterNotificationOpens(context.platform, { settings: open });
191
+ if (getComputedStyle(mount).position === 'static') {
192
+ mount.style.position = 'relative';
193
+ }
194
+ mount.append(openButton, panel);
195
+ return () => {
196
+ unsubscribe();
197
+ openButton.remove();
198
+ panel.remove();
199
+ };
200
+ }