@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,131 @@
1
+ import { resolveNotificationTopicsForTarget, type NotificationTopicCatalog } from '@mpgd/catalog';
2
+ import type { PlatformGateway } from '@mpgd/platform';
3
+ import type { TargetConfiguredGateway } from '@mpgd/target-config';
4
+ import type {
5
+ NotificationConsentRecord,
6
+ NotificationRuntime,
7
+ NotificationTopicDeclaration,
8
+ } from '@mpgd/platform/notifications';
9
+
10
+ import notificationTopicsJson from '../../notifications/topics.json';
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
+ /**
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
+ }
57
+
58
+ /**
59
+ * The game's notifications backend.
60
+ *
61
+ * The kit does not ship that runtime's backend client yet: the game-services
62
+ * Worker's notification routes need a session from the identity side, which
63
+ * comes in a later kit release. Until then this returns `undefined`, and the
64
+ * notification settings screen asks only for the OS or host permission and
65
+ * shows no reminder, advertising, or night switch, because nothing could
66
+ * record them. Return your own runtime and consent reader here when your
67
+ * backend implements those routes.
68
+ */
69
+ export function createStarterNotificationBackend(
70
+ _platform: PlatformGateway,
71
+ ): StarterNotificationBackend | undefined {
72
+ return undefined;
73
+ }
74
+
75
+ const backends = new WeakMap<PlatformGateway, StarterNotificationBackend | undefined>();
76
+ /** Per gateway, for the process: agreements given on a host that gives no read. */
77
+ const agreements = new WeakMap<PlatformGateway, Set<string>>();
78
+
79
+ /** One backend per gateway: a runtime subscribes to its provider once, for the process. */
80
+ function starterNotificationBackend(platform: PlatformGateway): StarterNotificationBackend | undefined {
81
+ if (!backends.has(platform)) {
82
+ backends.set(platform, createStarterNotificationBackend(platform));
83
+ }
84
+ return backends.get(platform);
85
+ }
86
+
87
+ /** The settings screen's model for this gateway and locale. */
88
+ export function createStarterNotificationSettingsModel(
89
+ platform: TargetConfiguredGateway,
90
+ input: { readonly locale: string },
91
+ ): StarterNotificationSettings {
92
+ const requestUpgrade = platform.identity.requestUpgrade?.bind(platform.identity);
93
+ const backend = starterNotificationBackend(platform);
94
+ let agreed = agreements.get(platform);
95
+ if (agreed === undefined) {
96
+ agreed = new Set<string>();
97
+ agreements.set(platform, agreed);
98
+ }
99
+ return createStarterNotificationSettings({
100
+ provider: platform.notificationsV2,
101
+ runtime: backend?.runtime,
102
+ ...(backend === undefined ? {} : { listConsent: () => backend.listConsent() }),
103
+ topics: starterNotificationTopics(platform.configTarget),
104
+ ...(starterNotificationCatalog.notices === undefined
105
+ ? {}
106
+ : { noticeCopy: starterNotificationCatalog.notices.copy }),
107
+ defaultLocale: starterNotificationCatalog.defaultLocale,
108
+ consentTextVersion: starterAdvertisingConsentTextVersion,
109
+ agreedThisSession: agreed,
110
+ // Devvit: a logged-out player logs in before the opt-in (the gateway's loginPrompt).
111
+ ...(requestUpgrade === undefined
112
+ ? {}
113
+ : { requestLogin: () => requestUpgrade({ reason: 'notifications' }) }),
114
+ locale: input.locale,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * The game's one listener for notification opens (see
120
+ * `routeNotificationOpens` in `notificationOpens.ts`), over the runtime when
121
+ * the game has one, else the provider. Returns the unsubscribe.
122
+ */
123
+ export function subscribeStarterNotificationOpens(
124
+ platform: PlatformGateway,
125
+ routes: NotificationOpenRoutes,
126
+ ): () => void {
127
+ const source = starterNotificationBackend(platform)?.runtime ?? platform.notificationsV2;
128
+ return source === undefined ? () => undefined : routeNotificationOpens(source, routes);
129
+ }
130
+
131
+ 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
+ }
@@ -0,0 +1,282 @@
1
+ import Phaser from 'phaser';
2
+
3
+ import {
4
+ starterNotificationLabelsFor,
5
+ type StarterNotificationLabels,
6
+ type StarterNotificationSettings,
7
+ type StarterNotificationSettingsView,
8
+ } from '../platform/notificationSettings';
9
+ import {
10
+ createStarterNotificationSettingsModel,
11
+ subscribeStarterNotificationOpens,
12
+ } from '../platform/notifications';
13
+ import { starterContextKey, type StarterContext } from '../runtime/gameContext';
14
+
15
+ export const notificationSettingsSceneKey = 'NotificationSettingsScene';
16
+
17
+ const textStyle = { color: '#d6dee8', fontFamily: 'Arial, sans-serif', fontSize: '18px' } as const;
18
+ const buttonStyle = { ...textStyle, color: '#5eead4' } as const;
19
+ /** The list's viewport on the 960x540 game: below the title and the outcome strip, above Back. */
20
+ const list = { top: 110, height: 370, width: 960 } as const;
21
+
22
+ /**
23
+ * Routes notification opens: one whose route is the settings screen (the
24
+ * advertising opt-out action, a consent notice) shows the screen, after boot
25
+ * when the open started the game. Route your other topics' opens in `other`
26
+ * (see `subscribeStarterNotificationOpens`): this is the first subscriber,
27
+ * the one that receives a cold-start open. An open during play leaves the
28
+ * run; the play scene saves its checkpoint when it shuts down.
29
+ */
30
+ export function routeNotificationSettingsOpens(game: Phaser.Game, context: StarterContext): void {
31
+ const activeKeys = (): string[] => game.scene.getScenes(true).map((scene) => scene.scene.key);
32
+ const show = (): void => {
33
+ for (const key of activeKeys()) {
34
+ if (key !== notificationSettingsSceneKey) {
35
+ game.scene.stop(key);
36
+ }
37
+ }
38
+ game.scene.start(notificationSettingsSceneKey);
39
+ };
40
+ let waiting = false;
41
+ const afterBoot = (): void => {
42
+ const active = activeKeys();
43
+ if (active.length === 0 || active.includes('BootScene')) {
44
+ return;
45
+ }
46
+ game.events.off('poststep', afterBoot);
47
+ waiting = false;
48
+ show();
49
+ };
50
+ const unsubscribe = subscribeStarterNotificationOpens(context.platform, {
51
+ settings: () => {
52
+ const active = activeKeys();
53
+ if (active.length > 0 && !active.includes('BootScene')) {
54
+ show();
55
+ } else if (!waiting) {
56
+ // Booting: show the screen once the first scene after boot runs.
57
+ waiting = true;
58
+ game.events.on('poststep', afterBoot);
59
+ }
60
+ },
61
+ });
62
+ game.events.once('destroy', () => {
63
+ unsubscribe();
64
+ game.events.off('poststep', afterBoot);
65
+ });
66
+ }
67
+
68
+ /**
69
+ * The game-rendered notification settings: the OS or host permission,
70
+ * reminder topics, and the advertising consent the Korean rules require
71
+ * before any advertising push (`notificationSettings.ts` holds the
72
+ * logic). Night consent covers advertising that arrives between 21:00 and
73
+ * 08:00 KST. Every change is a tap; no switch starts on. The list has a camera
74
+ * of its own, which clips it under WebGL and Canvas alike and keeps hidden
75
+ * rows from taking taps; it scrolls by dragging.
76
+ */
77
+ export class NotificationSettingsScene extends Phaser.Scene {
78
+ private settings!: StarterNotificationSettings;
79
+ private labels!: StarterNotificationLabels;
80
+ private content!: Phaser.GameObjects.Container;
81
+ private listCamera!: Phaser.Cameras.Scene2D.Camera;
82
+ /** The last tap's outcome, in a strip that does not scroll. */
83
+ private outcome!: Phaser.GameObjects.Text;
84
+ private busy = false;
85
+ /** Each visit's token: a pending action from an earlier visit changes nothing. */
86
+ private visit = 0;
87
+
88
+ constructor() {
89
+ super(notificationSettingsSceneKey);
90
+ }
91
+
92
+ create(): void {
93
+ const rawContext = this.registry.get(starterContextKey);
94
+ if (rawContext === undefined || rawContext === null) {
95
+ throw new Error('Starter context is missing from Phaser registry.');
96
+ }
97
+ const context = rawContext as StarterContext;
98
+ this.visit += 1;
99
+ this.busy = false;
100
+ this.labels = starterNotificationLabelsFor(context.locale);
101
+ this.settings = createStarterNotificationSettingsModel(context.platform, {
102
+ locale: context.locale,
103
+ });
104
+ const title = this.add
105
+ .text(480, 40, this.labels.title, { ...textStyle, color: '#ffffff', fontSize: '30px' })
106
+ .setOrigin(0.5);
107
+ const back = this.button(480, 510, this.labels.back, () => {
108
+ this.scene.start('LobbyScene');
109
+ }).setOrigin(0.5);
110
+ this.outcome = this.add
111
+ .text(480, 88, '', { ...textStyle, color: '#f59e0b', fontSize: '16px' })
112
+ .setOrigin(0.5);
113
+ this.content = this.add.container(0, 0);
114
+ this.listCamera = this.cameras.add(0, list.top, list.width, list.height);
115
+ this.listCamera.ignore([title, back, this.outcome]);
116
+ this.cameras.main.ignore(this.content);
117
+ this.enableScroll();
118
+ const visit = this.visit;
119
+ void this.apply(visit, () => this.settings.load());
120
+ }
121
+
122
+ private enableScroll(): void {
123
+ let lastY: number | undefined;
124
+ this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
125
+ lastY = pointer.y;
126
+ });
127
+ this.input.on('pointerup', () => {
128
+ lastY = undefined;
129
+ });
130
+ this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
131
+ if (lastY === undefined || !pointer.isDown) {
132
+ return;
133
+ }
134
+ this.scrollTo(this.listCamera.scrollY - (pointer.y - lastY));
135
+ lastY = pointer.y;
136
+ });
137
+ }
138
+
139
+ private scrollTo(scrollY: number): void {
140
+ const height = this.content.getBounds().height;
141
+ this.listCamera.scrollY = Phaser.Math.Clamp(scrollY, 0, Math.max(0, height - list.height));
142
+ }
143
+
144
+ private async apply(visit: number, action: () => Promise<StarterNotificationSettingsView>): Promise<void> {
145
+ if (this.busy) {
146
+ return;
147
+ }
148
+ this.busy = true;
149
+ try {
150
+ const view = await action();
151
+ if (visit !== this.visit || !this.sys.isActive()) {
152
+ return;
153
+ }
154
+ this.render(view);
155
+ if (view.notices.length > 0) {
156
+ // Recorded in the background: taps need not wait for it.
157
+ void this.settings.recordNoticesShown();
158
+ }
159
+ } catch (error) {
160
+ // The model reports its own failures as messages; anything else is logged.
161
+ console.error('[notifications] settings screen failed.', error);
162
+ } finally {
163
+ if (visit === this.visit) {
164
+ this.busy = false;
165
+ }
166
+ }
167
+ }
168
+
169
+ private render(view: StarterNotificationSettingsView): void {
170
+ const text = this.labels;
171
+ this.content.removeAll(true);
172
+ const visit = this.visit;
173
+ let y = 0;
174
+ const line = (value: string, style: Phaser.Types.GameObjects.Text.TextStyle = textStyle) => {
175
+ const object = this.add.text(60, y, value, { ...style, wordWrap: { width: 840 } });
176
+ this.content.add(object);
177
+ y += object.height + 10;
178
+ };
179
+ const action = (value: string, run: () => Promise<StarterNotificationSettingsView>) => {
180
+ const object = this.button(60, y, `[ ${value} ]`, () => {
181
+ void this.apply(visit, run);
182
+ });
183
+ this.content.add(object);
184
+ y += object.height + 8;
185
+ };
186
+
187
+ // The outcome of the last tap stays in view above the list, wherever it is scrolled.
188
+ this.outcome.setText(view.message ?? '');
189
+ if (!view.available) {
190
+ if (view.message === undefined) {
191
+ line(text.unavailable);
192
+ }
193
+ this.scrollTo(0);
194
+ return;
195
+ }
196
+ if (!view.permission.perTopic) {
197
+ line(view.permission.state === 'granted' ? text.permissionOn : text.permissionOff);
198
+ if (view.permission.canRequest) {
199
+ // Explain before the one-shot OS prompt; the prompt runs only from the tap below.
200
+ line(text.explanation, { ...textStyle, fontSize: '15px' });
201
+ action(text.turnOn, () => this.settings.turnOn());
202
+ }
203
+ if (view.permission.canLogin) {
204
+ action(text.logIn, () => this.settings.logIn());
205
+ }
206
+ if (view.permission.canOpenSettings) {
207
+ action(text.openSettings, () => this.settings.openSettings());
208
+ }
209
+ if (view.permission.canRevoke) {
210
+ action(text.turnOff, () => this.settings.turnOff());
211
+ }
212
+ }
213
+ if (!view.recording && (view.reminders.length > 0 || view.advertising !== undefined)) {
214
+ // No switch without the player's records: it could not show or change the truth.
215
+ line(view.choices === 'unreadable' ? text.choicesUnreadable : text.notConnected, {
216
+ ...textStyle,
217
+ fontSize: '15px',
218
+ });
219
+ } else if (view.reminders.length > 0) {
220
+ line(text.reminders, { ...textStyle, color: '#ffffff' });
221
+ for (const reminder of view.reminders) {
222
+ action(`${reminder.label}: ${reminder.enabled ? text.on : text.off}`, () =>
223
+ this.settings.setReminder(reminder.topic, !reminder.enabled),
224
+ );
225
+ }
226
+ }
227
+ if (view.agreements.length > 0) {
228
+ // Per-topic hosts (Apps in Toss): service messages the player agrees to
229
+ // one by one; the host's settings withdraw them.
230
+ line(text.agreements, { ...textStyle, color: '#ffffff' });
231
+ for (const agreement of view.agreements) {
232
+ const state = agreement.state === 'granted' ? text.on : text.off;
233
+ if (agreement.canRequest) {
234
+ action(`${agreement.label}: ${state} - ${text.agree}`, () =>
235
+ this.settings.agree(agreement.topic),
236
+ );
237
+ } else {
238
+ line(`${agreement.label}: ${state}`);
239
+ }
240
+ }
241
+ }
242
+ if (view.recording && view.advertising !== undefined) {
243
+ const { granted, night, nightSelectable } = view.advertising;
244
+ action(`${text.advertising}: ${granted ? text.on : text.off}`, () =>
245
+ this.settings.setAdvertising({ advertising: !granted }),
246
+ );
247
+ line(text.advertisingHelp, { ...textStyle, fontSize: '15px' });
248
+ if (nightSelectable) {
249
+ action(`${text.night}: ${night ? text.on : text.off}`, () =>
250
+ this.settings.setAdvertising({ night: !night }),
251
+ );
252
+ } else {
253
+ // Night consent adds to advertising consent: not selectable without it.
254
+ line(`${text.night}: ${text.off}\n${text.nightNeedsAdvertising}`, {
255
+ ...textStyle,
256
+ color: '#7b8794',
257
+ });
258
+ }
259
+ }
260
+ if (view.notices.length > 0) {
261
+ line(text.notices, { ...textStyle, color: '#ffffff' });
262
+ for (const notice of view.notices) {
263
+ line(`${notice.title}\n${notice.body}`, { ...textStyle, fontSize: '15px' });
264
+ }
265
+ }
266
+ // Keep the scroll position across a re-render, within the new height.
267
+ this.scrollTo(this.listCamera.scrollY);
268
+ }
269
+
270
+ private button(x: number, y: number, value: string, onTap: () => void): Phaser.GameObjects.Text {
271
+ return this.add
272
+ .text(x, y, value, buttonStyle)
273
+ .setPadding(0, 8, 24, 8)
274
+ .setInteractive({ useHandCursor: true })
275
+ // A drag that scrolls the list and ends on a button is not a tap.
276
+ .on('pointerup', (pointer: Phaser.Input.Pointer) => {
277
+ if (pointer.getDistance() < 12) {
278
+ onTap();
279
+ }
280
+ });
281
+ }
282
+ }