@mpgd/cli 0.70.0 → 0.71.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/index.js CHANGED
@@ -41,9 +41,7 @@ import { promotePlayRelease } from './play-release-promote.js';
41
41
  import { applyStoreProducts, planStoreProducts, saveStoreProductPlan, } from './store-products-command.js';
42
42
  import { formatAppStoreProductPlan } from './store-products-app-store.js';
43
43
  import { formatPlayProductPlan } from './store-products-play.js';
44
- import { applyStorePresence, planStorePresence, saveStorePresencePlan, } from './store-presence-command.js';
45
- import { formatPlayPresencePlan } from './store-presence-play.js';
46
- import { formatAppStorePresencePlan } from './store-presence-app-store.js';
44
+ import { applyStorePresence, planStorePresence, renderStorePresencePlan, saveStorePresencePlan, } from './store-presence-command.js';
47
45
  import { applyAppStoreReleaseCommand, changeAppStorePhasedReleaseCommand, planAppStoreReleaseCommand, publishAppStoreVersionCommand, readAppStoreReleaseStatusCommand, saveAppStoreReleasePlan, } from './app-store-release-command.js';
48
46
  import { formatAppStoreReleasePlan } from './app-store-release.js';
49
47
  import { changePlayRollout } from './play-release-rollout.js';
@@ -953,21 +951,28 @@ const storeCommand = defineI18n({
953
951
  store: { type: 'string', required: true, description: 'google-play or app-store.' },
954
952
  presence: { type: 'string', required: false, description: 'Store presence file path, relative to the current directory; defaults to mpgd.store-presence.json in the game.' },
955
953
  out: { type: 'string', required: false, description: 'New plan JSON path for apply; existing files are not overwritten.' },
954
+ json: { type: 'boolean', required: false, description: 'Print the plan (warnings included) as one JSON document.' },
956
955
  },
957
956
  run: async (ctx) => {
958
957
  const presence = readOptionalString(ctx.values.presence);
958
+ const json = ctx.values.json === true;
959
959
  const result = await planStorePresence({
960
960
  game: readOptionalString(ctx.values.game) ?? '.',
961
961
  profile: readOptionalString(ctx.values.profile) ?? 'beta',
962
962
  store: readRequiredCliOption(ctx.values.store, '--store'),
963
963
  ...(presence === undefined ? {} : { presenceFile: presence }),
964
964
  });
965
- console.info(result.plan.store === 'google-play'
966
- ? formatPlayPresencePlan(result.plan)
967
- : formatAppStorePresencePlan(result.plan));
965
+ process.stdout.write(`${renderStorePresencePlan(result, json)}\n`);
968
966
  const out = readOptionalString(ctx.values.out);
969
967
  if (out !== undefined) {
970
- console.info(`Wrote store presence plan: ${saveStorePresencePlan(out, result)}`);
968
+ // With --json, stdout carries only the JSON document.
969
+ const written = `Wrote store presence plan: ${saveStorePresencePlan(out, result)}`;
970
+ if (json) {
971
+ console.error(written);
972
+ }
973
+ else {
974
+ console.info(written);
975
+ }
971
976
  }
972
977
  },
973
978
  }),
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "1fc86080fee3e7f6089a6ba5d063a892fa8524bb",
2
+ "kitGitSha": "481d1c53f2f0d4c5127eeeedfa7ebe306c0459ea",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.70.0"
4
+ "packageVersion": "0.71.0"
5
5
  }
@@ -4557,7 +4557,8 @@ var notificationSuppressionReasons = Object.freeze([
4557
4557
  "quiet-hours",
4558
4558
  "frequency-capped",
4559
4559
  "provider-limit",
4560
- "advertising-sender-missing"
4560
+ "advertising-sender-missing",
4561
+ "reconfirmation-overdue"
4561
4562
  ]);
4562
4563
  var notificationInputBounds = Object.freeze({
4563
4564
  keyLength: 2048,
@@ -4693,6 +4694,33 @@ var notificationNoticeVariantFallbacks = Object.freeze({
4693
4694
  "consent-result.advertising.refused": "consent-result.advertising.withdrawn",
4694
4695
  "consent-result.night.refused": "consent-result.night.withdrawn"
4695
4696
  });
4697
+ var everyRequiredNoticeVariant = Object.freeze(notificationNoticeVariants.filter((variant) => notificationNoticeVariantFallbacks[variant] === void 0));
4698
+ var notificationReconfirmationListDays = 60;
4699
+ var notificationNoticeLeadDays = 7;
4700
+ var notificationRefreshedConsentKinds = Object.freeze([
4701
+ "advertising",
4702
+ "night"
4703
+ ]);
4704
+ var koreanRefreshIntervalMonths = 24;
4705
+ var notificationKoreanConsentRefresh = deepFreeze({
4706
+ advertising: {
4707
+ intervalMonths: koreanRefreshIntervalMonths,
4708
+ noticeLeadDays: notificationReconfirmationListDays,
4709
+ pushLeadDays: notificationNoticeLeadDays,
4710
+ onOverdue: "pause"
4711
+ },
4712
+ night: {
4713
+ intervalMonths: koreanRefreshIntervalMonths,
4714
+ noticeLeadDays: notificationReconfirmationListDays,
4715
+ pushLeadDays: notificationNoticeLeadDays,
4716
+ onOverdue: "pause"
4717
+ }
4718
+ });
4719
+ var noConsentRefresh = Object.freeze({});
4720
+ var notificationKoreanNoticePolicy = deepFreeze({
4721
+ consentResults: true,
4722
+ refresh: notificationKoreanConsentRefresh
4723
+ });
4696
4724
  function isNotificationCalendarDate(date) {
4697
4725
  return !Number.isNaN(calendarDateMillis(date));
4698
4726
  }
@@ -17213,6 +17241,7 @@ var phaserEdits = {
17213
17241
  "import {",
17214
17242
  " NotificationSettingsScene,",
17215
17243
  " routeNotificationSettingsOpens,",
17244
+ " showNotificationLaunchNotices,",
17216
17245
  "} from '../scenes/NotificationSettingsScene';",
17217
17246
  ""
17218
17247
  ].join("\n")
@@ -17227,6 +17256,8 @@ var phaserEdits = {
17227
17256
  " ownGameAudio(game, input.context);",
17228
17257
  " // The advertising opt-out action and consent notices open the settings screen.",
17229
17258
  " routeNotificationSettingsOpens(game, input.context);",
17259
+ " // Listed consent notices, once per launch, without pausing the game.",
17260
+ " showNotificationLaunchNotices(game, input.context);",
17230
17261
  ""
17231
17262
  ].join("\n")
17232
17263
  }
@@ -280,6 +280,7 @@ const phaserEdits = {
280
280
  'import {',
281
281
  ' NotificationSettingsScene,',
282
282
  ' routeNotificationSettingsOpens,',
283
+ ' showNotificationLaunchNotices,',
283
284
  "} from '../scenes/NotificationSettingsScene';",
284
285
  '',
285
286
  ].join('\n'),
@@ -294,6 +295,8 @@ const phaserEdits = {
294
295
  ' ownGameAudio(game, input.context);',
295
296
  ' // The advertising opt-out action and consent notices open the settings screen.',
296
297
  ' routeNotificationSettingsOpens(game, input.context);',
298
+ ' // Listed consent notices, once per launch, without pausing the game.',
299
+ ' showNotificationLaunchNotices(game, input.context);',
297
300
  '',
298
301
  ].join('\n'),
299
302
  },
@@ -35,6 +35,12 @@ export interface StorePresencePorts {
35
35
  }>;
36
36
  }
37
37
  export declare function planStorePresence(options: StorePresencePlanOptions, environment?: NodeJS.ProcessEnv, ports?: Partial<StorePresencePorts>): Promise<StorePresencePlanFile>;
38
+ /**
39
+ * What `store presence plan` prints: the plan for the terminal, or with
40
+ * `--json` the plan file itself (the same document `--out` saves, warnings
41
+ * included) as one JSON document.
42
+ */
43
+ export declare function renderStorePresencePlan(result: StorePresencePlanFile, json: boolean): string;
38
44
  export declare function saveStorePresencePlan(file: string, plan: StorePresencePlanFile): string;
39
45
  export interface StorePresenceApplyOptions {
40
46
  readonly planFile: string;
@@ -2,8 +2,8 @@ import { existsSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { openAscStorePresenceSession } from './app-store-presence-asc.js';
4
4
  import { readStorePresenceSource, storePresencePlanCommand, } from './store-presence.js';
5
- import { applyAppStorePresence, planAppStorePresence, } from './store-presence-app-store.js';
6
- import { applyPlayPresence, planPlayPresence, } from './store-presence-play.js';
5
+ import { applyAppStorePresence, formatAppStorePresencePlan, planAppStorePresence, } from './store-presence-app-store.js';
6
+ import { applyPlayPresence, formatPlayPresencePlan, planPlayPresence, } from './store-presence-play.js';
7
7
  import { assertStoreSyncStore, readStorePlanFile, relocateStoreSyncFile, resolveStoreSyncApp, StorePlanStaleError, writeStorePlanFile, } from './store-sync.js';
8
8
  const defaultPorts = {
9
9
  googlePlay: loadPlayStoreListing,
@@ -36,6 +36,19 @@ export async function planStorePresence(options, environment = process.env, port
36
36
  plan,
37
37
  };
38
38
  }
39
+ /**
40
+ * What `store presence plan` prints: the plan for the terminal, or with
41
+ * `--json` the plan file itself (the same document `--out` saves, warnings
42
+ * included) as one JSON document.
43
+ */
44
+ export function renderStorePresencePlan(result, json) {
45
+ if (json) {
46
+ return JSON.stringify(result, null, 2);
47
+ }
48
+ return result.plan.store === 'google-play'
49
+ ? formatPlayPresencePlan(result.plan)
50
+ : formatAppStorePresencePlan(result.plan);
51
+ }
39
52
  export function saveStorePresencePlan(file, plan) {
40
53
  return writeStorePlanFile(file, plan);
41
54
  }
@@ -66,6 +66,13 @@ export interface PlayPresencePlan {
66
66
  readonly imageRemovals: number;
67
67
  readonly changed: boolean;
68
68
  readonly notes: readonly string[];
69
+ /**
70
+ * Where Play's default language listing would fall short of Play's
71
+ * publishing requirements or recommendations once apply has run (Play's
72
+ * images plus the file's changes). Never blocks apply, and not part of
73
+ * the stale check.
74
+ */
75
+ readonly warnings: readonly string[];
69
76
  /** What the sync cannot do; the reviewer does it in Play Console. */
70
77
  readonly manual: readonly string[];
71
78
  /** Digest of everything read from Play. */
@@ -82,6 +89,36 @@ export interface PlayPresencePlanInput {
82
89
  }
83
90
  /** Read Play through a throwaway edit and plan; never writes. */
84
91
  export declare function planPlayPresence(input: PlayPresencePlanInput, port: PlayStoreListingPort): Promise<PlayPresencePlan>;
92
+ /**
93
+ * Google Play's store listing graphics rules, from Play Console Help, "Add
94
+ * preview assets to showcase your app"
95
+ * (https://support.google.com/googleplay/android-developer/answer/9866151),
96
+ * checked 2026-11-01:
97
+ *
98
+ * - "You must provide an app icon to publish your store listing" and "You
99
+ * must provide a feature graphic to publish your store listing."
100
+ * - "You must provide a minimum of two screenshots across different device
101
+ * types to publish your store listing", and "up to 8 screenshots for each
102
+ * supported device type". The help page does not say which device
103
+ * types count; the sync asks for two in the phone set.
104
+ * - Tablets (7-inch and 10-inch) and Chromebooks: "you can add a minimum of
105
+ * 4 screenshots", 1,080 to 7,680 px, 16:9 or 9:16. That is a
106
+ * recommendation, not a publishing requirement; the help page states no
107
+ * effect of a missing tablet set on tablet visibility, and the large
108
+ * screen badge depends on the adaptive app quality tiers
109
+ * (https://developer.android.com/docs/quality-guidelines/adaptive-app-quality),
110
+ * not on screenshots.
111
+ *
112
+ * Translations need no graphics of their own: "If you add text translations
113
+ * without localized graphic assets, your app's graphic assets show from the
114
+ * default language"
115
+ * (https://support.google.com/googleplay/android-developer/answer/9844778).
116
+ * So only the default language is checked.
117
+ */
118
+ export declare const googlePlayPublishingGraphics: Readonly<{
119
+ phoneScreenshots: 2;
120
+ largeScreenScreenshots: 4;
121
+ }>;
85
122
  export interface PlayPresenceApplyResult {
86
123
  readonly packageName: string;
87
124
  readonly status: 'committed' | 'unchanged';
@@ -12,7 +12,7 @@ export async function planPlayPresence(input, port) {
12
12
  const local = readLocalGraphics(input.directory, play);
13
13
  const editId = await port.insertEdit(input.packageName, input.signal);
14
14
  try {
15
- return await planInEdit(input, play, local, port, editId);
15
+ return await planInEdit(input, play, local, port, editId, true);
16
16
  }
17
17
  finally {
18
18
  await discardEdit(port, input.packageName, editId);
@@ -48,7 +48,7 @@ function readLocalGraphics(directory, play) {
48
48
  }
49
49
  return result;
50
50
  }
51
- async function planInEdit(input, play, local, port, editId) {
51
+ async function planInEdit(input, play, local, port, editId, checkPublishing) {
52
52
  const { packageName, signal } = input;
53
53
  const [details, remoteListings] = await Promise.all([
54
54
  port.getDetails(packageName, editId, signal),
@@ -98,13 +98,39 @@ async function planInEdit(input, play, local, port, editId) {
98
98
  .map((listing) => String(listing.language))
99
99
  .filter((language) => !named.has(language.toLowerCase()))
100
100
  .sort();
101
- const defaultLanguage = typeof details.defaultLanguage === 'string'
101
+ // An empty value is no default language.
102
+ const defaultLanguage = typeof details.defaultLanguage === 'string' && details.defaultLanguage.trim() !== ''
102
103
  ? details.defaultLanguage
103
104
  : undefined;
104
105
  if (defaultLanguage !== undefined && !named.has(defaultLanguage.toLowerCase())) {
105
106
  notes.push(`Play's default language ${defaultLanguage} is not in the file; its listing stays as Play has it.`);
106
107
  }
107
108
  const plannedDetails = planDetails(details, play, notes);
109
+ if (checkPublishing && defaultLanguage === undefined) {
110
+ notes.push("Play reports no default language, so the listing's publishing graphics were not checked.");
111
+ }
112
+ let warnings = [];
113
+ if (checkPublishing && defaultLanguage !== undefined) {
114
+ try {
115
+ warnings = await publishingWarnings({
116
+ input,
117
+ editId,
118
+ port,
119
+ language: defaultLanguage,
120
+ remote: remoteByLanguage.get(defaultLanguage.toLowerCase()),
121
+ planned: planned.find((language) => language.language.toLowerCase() === defaultLanguage.toLowerCase()),
122
+ });
123
+ }
124
+ catch (error) {
125
+ // Warnings are advisory: a failed read leaves them unchecked, but a
126
+ // cancelled plan stays cancelled.
127
+ if (signal?.aborted === true || (error instanceof Error && error.name === 'AbortError')) {
128
+ throw error;
129
+ }
130
+ notes.push(`Play's ${defaultLanguage} images could not be read (${error instanceof Error ? error.message : String(error)}), `
131
+ + "so the listing's publishing graphics were not checked.");
132
+ }
133
+ }
108
134
  const manual = manualSteps(input.presence);
109
135
  const imageRemovals = planned.reduce((sum, language) => sum + language.images.reduce((count, slot) => count + (slot.complete && isMultiImage(slot.imageType) ? slot.remove.length : 0), 0), 0);
110
136
  const changed = plannedDetails !== undefined || planned.some((language) => language.action !== 'unchanged' || language.images.some((slot) => slot.action !== 'unchanged'));
@@ -130,6 +156,7 @@ async function planInEdit(input, play, local, port, editId) {
130
156
  imageRemovals,
131
157
  changed,
132
158
  notes,
159
+ warnings,
133
160
  manual,
134
161
  remoteSha256,
135
162
  desiredSha256,
@@ -266,6 +293,91 @@ function planDetails(details, play, notes) {
266
293
  }
267
294
  return { changes, write };
268
295
  }
296
+ /**
297
+ * Google Play's store listing graphics rules, from Play Console Help, "Add
298
+ * preview assets to showcase your app"
299
+ * (https://support.google.com/googleplay/android-developer/answer/9866151),
300
+ * checked 2026-11-01:
301
+ *
302
+ * - "You must provide an app icon to publish your store listing" and "You
303
+ * must provide a feature graphic to publish your store listing."
304
+ * - "You must provide a minimum of two screenshots across different device
305
+ * types to publish your store listing", and "up to 8 screenshots for each
306
+ * supported device type". The help page does not say which device
307
+ * types count; the sync asks for two in the phone set.
308
+ * - Tablets (7-inch and 10-inch) and Chromebooks: "you can add a minimum of
309
+ * 4 screenshots", 1,080 to 7,680 px, 16:9 or 9:16. That is a
310
+ * recommendation, not a publishing requirement; the help page states no
311
+ * effect of a missing tablet set on tablet visibility, and the large
312
+ * screen badge depends on the adaptive app quality tiers
313
+ * (https://developer.android.com/docs/quality-guidelines/adaptive-app-quality),
314
+ * not on screenshots.
315
+ *
316
+ * Translations need no graphics of their own: "If you add text translations
317
+ * without localized graphic assets, your app's graphic assets show from the
318
+ * default language"
319
+ * (https://support.google.com/googleplay/android-developer/answer/9844778).
320
+ * So only the default language is checked.
321
+ */
322
+ export const googlePlayPublishingGraphics = Object.freeze({
323
+ phoneScreenshots: 2,
324
+ largeScreenScreenshots: 4,
325
+ });
326
+ const publishingSlots = [
327
+ 'icon',
328
+ 'featureGraphic',
329
+ 'phoneScreenshots',
330
+ 'sevenInchScreenshots',
331
+ 'tenInchScreenshots',
332
+ ];
333
+ /**
334
+ * Warnings for Play's default language listing, counted after apply: the
335
+ * plan's result for each slot the file names, Play's images for the others.
336
+ */
337
+ async function publishingWarnings(input) {
338
+ const { packageName, signal } = input.input;
339
+ const plannedSlots = new Map((input.planned?.images ?? []).map((slot) => [slot.imageType, slot]));
340
+ const unread = input.remote === undefined
341
+ ? []
342
+ : publishingSlots.filter((slot) => !plannedSlots.has(slot));
343
+ const remoteCounts = await mapWithConcurrency(unread, readConcurrency, async (imageType) => {
344
+ const images = await input.port.listImages({
345
+ packageName,
346
+ editId: input.editId,
347
+ language: String(input.remote?.language),
348
+ imageType,
349
+ ...(signal === undefined ? {} : { signal }),
350
+ });
351
+ return [imageType, images.length];
352
+ });
353
+ const remote = new Map(remoteCounts);
354
+ const count = (imageType) => {
355
+ const slot = plannedSlots.get(imageType);
356
+ return slot === undefined
357
+ ? (remote.get(imageType) ?? 0)
358
+ : slot.keep.length + slot.upload.length;
359
+ };
360
+ const prefix = `${input.planned?.language ?? input.language} (Play's default language)`;
361
+ const warnings = [];
362
+ if (count('icon') === 0) {
363
+ warnings.push(`${prefix} has no icon after apply; Play needs one to publish the store listing.`);
364
+ }
365
+ if (count('featureGraphic') === 0) {
366
+ warnings.push(`${prefix} has no featureGraphic after apply; Play needs a feature graphic to publish the store listing.`);
367
+ }
368
+ const phone = count('phoneScreenshots');
369
+ if (phone < googlePlayPublishingGraphics.phoneScreenshots) {
370
+ warnings.push(`${prefix} has ${phone} phoneScreenshots after apply; Play needs at least `
371
+ + `${googlePlayPublishingGraphics.phoneScreenshots} screenshots across device types to publish the store `
372
+ + `listing, and the sync asks for ${googlePlayPublishingGraphics.phoneScreenshots} in the phone set.`);
373
+ }
374
+ if (count('sevenInchScreenshots') === 0 && count('tenInchScreenshots') === 0) {
375
+ warnings.push(`${prefix} has no sevenInchScreenshots or tenInchScreenshots after apply; Play does not require tablet `
376
+ + `screenshots to publish, but recommends at least ${googlePlayPublishingGraphics.largeScreenScreenshots} `
377
+ + '(1,080 to 7,680 px, 16:9 or 9:16) to show the app on tablets.');
378
+ }
379
+ return warnings;
380
+ }
269
381
  function manualSteps(presence) {
270
382
  const steps = [];
271
383
  if (presence.privacyPolicyUrl !== undefined) {
@@ -312,7 +424,8 @@ export async function applyPlayPresence(input) {
312
424
  // A commit that failed without a definitive answer may have landed.
313
425
  let commitUncertain = false;
314
426
  try {
315
- const current = await planInEdit(input.plan, play, local, port, editId);
427
+ // Warnings never block apply, so apply does not read Play for them.
428
+ const current = await planInEdit(input.plan, play, local, port, editId, false);
316
429
  if (!current.changed) {
317
430
  await discardEdit(port, packageName, editId);
318
431
  return { packageName, status: 'unchanged', languages: [] };
@@ -485,6 +598,9 @@ export function formatPlayPresencePlan(plan) {
485
598
  if (plan.imageRemovals > 0) {
486
599
  lines.push(` IMAGE REMOVAL: ${plan.imageRemovals} image(s); apply needs --confirm-image-removal`);
487
600
  }
601
+ for (const warning of plan.warnings) {
602
+ lines.push(` warning ${warning}`);
603
+ }
488
604
  for (const note of plan.notes) {
489
605
  lines.push(` note ${note}`);
490
606
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.70.0",
3
+ "version": "0.71.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.15.2",
74
- "@mpgd/platform": "0.36.0",
75
- "@mpgd/target-config": "0.24.9",
76
- "@mpgd/phaser-assets": "0.10.0"
73
+ "@mpgd/catalog": "0.15.3",
74
+ "@mpgd/platform": "0.37.0",
75
+ "@mpgd/phaser-assets": "0.10.0",
76
+ "@mpgd/target-config": "0.24.10"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,19 +81,19 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.34.2",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.21.11",
85
- "@mpgd/adapter-browser": "0.12.6",
86
- "@mpgd/adapter-capacitor": "0.19.3",
87
- "@mpgd/adapter-devvit": "0.15.0",
88
- "@mpgd/adapter-verse8": "0.7.22",
89
- "@mpgd/bridge": "0.19.1",
90
- "@mpgd/capacitor-play-billing": "0.6.4",
91
- "@mpgd/capacitor-storekit": "0.7.3",
92
- "@mpgd/game-runtime": "0.10.3",
93
- "@mpgd/analytics": "0.5.10",
94
- "@mpgd/game-services": "0.35.0",
95
- "@mpgd/lite-shell": "0.1.0",
96
- "@mpgd/i18n": "0.6.31"
84
+ "@mpgd/adapter-ait": "0.22.0",
85
+ "@mpgd/adapter-browser": "0.12.7",
86
+ "@mpgd/adapter-capacitor": "0.19.4",
87
+ "@mpgd/adapter-verse8": "0.7.23",
88
+ "@mpgd/analytics": "0.5.11",
89
+ "@mpgd/bridge": "0.20.0",
90
+ "@mpgd/capacitor-play-billing": "0.6.5",
91
+ "@mpgd/game-runtime": "0.10.4",
92
+ "@mpgd/capacitor-storekit": "0.7.4",
93
+ "@mpgd/game-services": "0.36.0",
94
+ "@mpgd/adapter-devvit": "0.15.1",
95
+ "@mpgd/i18n": "0.6.32",
96
+ "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
99
99
  "types": "./dist/index.d.ts",
@@ -15,6 +15,7 @@ left for you.
15
15
  | `src/platform/capacitorNotifications.ts` | The Android and iOS provider (FCM and local reminders), registered by the Capacitor gateways. |
16
16
  | `src/platform/notificationSettings.ts` | The settings screen's logic: permission, reminders, advertising and night consent, notices. |
17
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/platform/launchNoticesBanner.ts` | The box that shows the listed consent notices once per launch. |
18
19
  | `src/scenes/NotificationSettingsScene.ts` or `src/lite/notificationSettingsPanel.ts` | The settings screen the game renders. |
19
20
  | `tools/validate-notifications.ts` | `pnpm validate:notifications`. |
20
21
 
@@ -137,6 +138,18 @@ advertising consent is granted, and turning advertising consent off also
137
138
  withdraws a granted night consent in the same call (two records, two result
138
139
  notices).
139
140
 
141
+ **Consent notices at launch.** Once per launch, after the notifications
142
+ runtime starts (10 seconds at most), the game reads the consent notices the
143
+ server lists and, when there is one, shows them in a small box at the top
144
+ that does not pause the game, overdue ones first, and records each one as
145
+ given once the player can see it: on screen while the page is visible
146
+ (`showStarterLaunchNotices`). A player who signs in after launch gets the
147
+ read when the session becomes current. Under the Korean rules advertising to a player
148
+ pauses while a re-confirmation (every two years) is past its due date and
149
+ not recorded, so this is how a player who never opens the settings screen
150
+ ends the pause. Keep it, or show the notices in your own launch UI and
151
+ record them the same way.
152
+
140
153
  ## Try it locally
141
154
 
142
155
  You can see the settings switches record consent before you have an
@@ -0,0 +1,102 @@
1
+ import {
2
+ createStarterNoticeVisibility,
3
+ starterNotificationLabelsFor,
4
+ type StarterLaunchNotices,
5
+ } from './notificationSettings';
6
+
7
+ /**
8
+ * The launch notices (`showStarterLaunchNotices`) as a small box at the top
9
+ * of the page, over the game: a heading, each notice, and a close button.
10
+ * It takes no focus and pauses nothing; only its own area takes taps, and the
11
+ * game goes on under and around it. `onSeen` runs once for each notice the
12
+ * player can see: on screen, not scrolled out of the box, while the page is
13
+ * visible (`createStarterNoticeVisibility`), so a notice is recorded only
14
+ * once it was seen. Returns the disposer, which closing the box also runs.
15
+ */
16
+ export function mountStarterLaunchNotices(
17
+ notices: StarterLaunchNotices['notices'],
18
+ locale: string,
19
+ options: {
20
+ readonly onSeen: (index: number) => void;
21
+ readonly parent?: HTMLElement;
22
+ },
23
+ ): () => void {
24
+ const text = starterNotificationLabelsFor(locale);
25
+ const box = document.createElement('div');
26
+ box.setAttribute('role', 'status');
27
+ box.setAttribute('aria-label', text.notices);
28
+ Object.assign(box.style, {
29
+ position: 'fixed',
30
+ top: '12px',
31
+ left: '50%',
32
+ transform: 'translateX(-50%)',
33
+ width: 'min(560px, calc(100% - 32px))',
34
+ maxHeight: '50vh',
35
+ overflowY: 'auto',
36
+ boxSizing: 'border-box',
37
+ padding: '12px 16px',
38
+ borderRadius: '8px',
39
+ background: 'rgba(15, 23, 42, 0.94)',
40
+ color: '#d6dee8',
41
+ font: '14px Arial, sans-serif',
42
+ zIndex: '1000',
43
+ });
44
+ const heading = document.createElement('strong');
45
+ heading.textContent = text.notices;
46
+ box.append(heading);
47
+ const visibility = createStarterNoticeVisibility({
48
+ pageVisible: () => document.visibilityState === 'visible',
49
+ onSeen: options.onSeen,
50
+ });
51
+ const paragraphs = notices.map((notice, index) => {
52
+ const paragraph = document.createElement('p');
53
+ paragraph.style.whiteSpace = 'pre-line';
54
+ paragraph.textContent = `${notice.title}\n${notice.body}`;
55
+ paragraph.dataset['noticeIndex'] = String(index);
56
+ box.append(paragraph);
57
+ return paragraph;
58
+ });
59
+ const close = document.createElement('button');
60
+ close.type = 'button';
61
+ close.textContent = text.close;
62
+ box.append(close);
63
+ // A notice counts as seen once most of it shows in the box (its scroll
64
+ // area, which stays on screen), or, for one taller than the box, once it
65
+ // fills most of the box. Without IntersectionObserver every notice counts
66
+ // as on screen, so it is seen while the page is visible.
67
+ const observer = typeof IntersectionObserver === 'function'
68
+ ? new IntersectionObserver((entries) => {
69
+ for (const entry of entries) {
70
+ const index = Number((entry.target as HTMLElement).dataset['noticeIndex']);
71
+ const shown = entry.intersectionRect.height;
72
+ const area = entry.rootBounds?.height ?? 0;
73
+ visibility.intersecting(
74
+ index,
75
+ entry.isIntersecting && (entry.intersectionRatio >= 0.6 || (area > 0 && shown >= area * 0.6)),
76
+ );
77
+ }
78
+ }, { root: box, threshold: Array.from({ length: 21 }, (_, step) => step / 20) })
79
+ : undefined;
80
+ const onVisibilityChange = (): void => visibility.pageVisibilityChanged();
81
+ document.addEventListener('visibilitychange', onVisibilityChange);
82
+ (options.parent ?? document.body).append(box);
83
+ if (observer === undefined) {
84
+ paragraphs.forEach((_paragraph, index) => visibility.intersecting(index, true));
85
+ } else {
86
+ for (const paragraph of paragraphs) {
87
+ observer.observe(paragraph);
88
+ }
89
+ }
90
+ let disposed = false;
91
+ const dispose = (): void => {
92
+ if (disposed) {
93
+ return;
94
+ }
95
+ disposed = true;
96
+ observer?.disconnect();
97
+ document.removeEventListener('visibilitychange', onVisibilityChange);
98
+ box.remove();
99
+ };
100
+ close.addEventListener('click', dispose);
101
+ return dispose;
102
+ }
@@ -31,6 +31,8 @@ import type { TargetConfiguredGateway } from '@mpgd/target-config';
31
31
  export interface StarterNotificationBackend {
32
32
  readonly runtime: NotificationRuntime;
33
33
  listConsent(): Promise<readonly NotificationConsentRecord[]>;
34
+ /** Settles once the runtime has started, or failed to (logged); it never rejects. */
35
+ readonly started?: Promise<void>;
34
36
  }
35
37
 
36
38
  /** The server-backed session the Worker client needs (see `serverSession.ts`). */
@@ -153,10 +155,10 @@ export function createStarterWorkerNotificationBackend(
153
155
  },
154
156
  });
155
157
  // It subscribes to the provider at once; a backend that cannot answer yet does not fail the game.
156
- runtime.start().catch((error: unknown) => {
158
+ const started = runtime.start().catch((error: unknown) => {
157
159
  console.error('[notifications] the runtime could not start.', error);
158
160
  });
159
- return { runtime, listConsent: () => client.listConsent() };
161
+ return { runtime, listConsent: () => client.listConsent(), started };
160
162
  } catch (error) {
161
163
  // A configuration the client refuses turns notifications off, never the game.
162
164
  input.log('[notifications] the notifications backend could not be created.', error);
@@ -261,10 +263,10 @@ export function createStarterHostedNotificationBackend(
261
263
  console.error('[notifications]', error);
262
264
  },
263
265
  });
264
- runtime.start().catch((error: unknown) => {
266
+ const started = runtime.start().catch((error: unknown) => {
265
267
  console.error('[notifications] the runtime could not start.', error);
266
268
  });
267
- return { runtime, listConsent: () => backend.listConsent() };
269
+ return { runtime, listConsent: () => backend.listConsent(), started };
268
270
  } catch (error) {
269
271
  // A configuration the runtime refuses turns notifications off, never the game.
270
272
  input.log('[notifications] the notifications backend could not be created.', error);
@@ -732,7 +732,7 @@ function combinedState(
732
732
  */
733
733
  function renderNotices(
734
734
  listed: readonly NotificationNotice[],
735
- input: StarterNotificationSettingsInput,
735
+ input: Pick<StarterNotificationSettingsInput, 'noticeCopy' | 'locale' | 'defaultLocale'>,
736
736
  ): readonly { readonly notice: NotificationNotice; readonly text: { readonly title: string; readonly body: string } }[] {
737
737
  const noticeCopy = input.noticeCopy;
738
738
  if (noticeCopy === undefined) {
@@ -759,3 +759,148 @@ function renderNotices(
759
759
  ];
760
760
  });
761
761
  }
762
+
763
+ /**
764
+ * The consent notices shown once at launch, so a player who never opens the
765
+ * settings screen still sees them: under the Korean rules advertising pauses
766
+ * while a re-confirmation is overdue, until the game records it (notifications
767
+ * RFC "Consent refresh").
768
+ */
769
+ export interface StarterLaunchNotices {
770
+ /** In the player's language, overdue ones (past their `dueBy`) first. */
771
+ readonly notices: readonly { readonly title: string; readonly body: string; readonly overdue: boolean }[];
772
+ /**
773
+ * Records the notices at these indexes of `notices` as given in the game,
774
+ * each at most once; call it for the ones the player has seen. Never throws.
775
+ */
776
+ record(indexes: readonly number[]): Promise<void>;
777
+ }
778
+
779
+ export interface StarterLaunchNoticesInput {
780
+ /** The game's notifications runtime; nothing is read without one. */
781
+ readonly runtime: NotificationRuntime | undefined;
782
+ /** `gateway.notificationsV2`; nothing is read on a target without notifications. */
783
+ readonly provider: NotificationProvider | undefined;
784
+ /**
785
+ * Settles once the runtime has started; the notices are read after it, or
786
+ * after `startTimeoutMs` at the latest, since `notices.list()` checks the
787
+ * session and the capability itself.
788
+ */
789
+ readonly started?: Promise<unknown>;
790
+ /** How long the read waits for `started`; 10 seconds by default. */
791
+ readonly startTimeoutMs?: number;
792
+ /** `notices.copy` of the catalog; without it no notice can be shown. */
793
+ readonly noticeCopy?: NotificationNoticeCopy;
794
+ readonly defaultLocale: string;
795
+ readonly locale: string;
796
+ /** The clock that says which notices are overdue; the device's by default. */
797
+ readonly now?: () => string;
798
+ }
799
+
800
+ /** How long the launch read waits for the runtime to start, at most. */
801
+ export const starterLaunchNoticesStartTimeoutMs = 10_000;
802
+
803
+ /**
804
+ * Reads the listed notices once the runtime has started (or its start has
805
+ * taken `startTimeoutMs`) and renders them, or `undefined` when there is
806
+ * nothing to show (no runtime, no provider, no copy, no notice, or a read
807
+ * that failed, which is logged). It never throws and never prompts: the
808
+ * game goes on while it reads.
809
+ */
810
+ export async function readStarterLaunchNotices(
811
+ input: StarterLaunchNoticesInput,
812
+ ): Promise<StarterLaunchNotices | undefined> {
813
+ const { runtime } = input;
814
+ if (runtime === undefined || input.provider === undefined || input.noticeCopy === undefined) {
815
+ return undefined;
816
+ }
817
+ try {
818
+ if (input.started !== undefined) {
819
+ let timer: ReturnType<typeof setTimeout> | undefined;
820
+ await Promise.race([
821
+ input.started,
822
+ new Promise<void>((resolve) => {
823
+ timer = setTimeout(resolve, input.startTimeoutMs ?? starterLaunchNoticesStartTimeoutMs);
824
+ }),
825
+ ]).finally(() => clearTimeout(timer));
826
+ }
827
+ const shown = renderNotices(await runtime.notices.list(), input);
828
+ if (shown.length === 0) {
829
+ return undefined;
830
+ }
831
+ const today = toSeoulCalendarDate(input.now?.() ?? new Date().toISOString());
832
+ const ordered = shown
833
+ .map((entry) => ({ ...entry, overdue: entry.notice.dueBy < today }))
834
+ // Overdue first; the list's own order (by dueBy) otherwise.
835
+ .sort((left, right) => Number(right.overdue) - Number(left.overdue));
836
+ const recorded = new Set<number>();
837
+ return {
838
+ notices: ordered.map(({ text, overdue }) => ({ ...text, overdue })),
839
+ async record(indexes) {
840
+ const fresh = [...new Set(indexes)].flatMap((index) => {
841
+ const entry = recorded.has(index) ? undefined : ordered[index];
842
+ if (entry === undefined) {
843
+ return [];
844
+ }
845
+ recorded.add(index);
846
+ return [entry.notice];
847
+ });
848
+ // `async` so a runtime that throws synchronously is a rejection here too.
849
+ const results = await Promise.allSettled(
850
+ fresh.map(async (notice) => runtime.notices.recordShown(notice)),
851
+ );
852
+ for (const result of results) {
853
+ if (result.status === 'rejected') {
854
+ // The settings screen lists it again, and the server may push it.
855
+ console.warn('[notifications] a launch notice could not be recorded.', result.reason);
856
+ }
857
+ }
858
+ },
859
+ };
860
+ } catch (error) {
861
+ console.warn('[notifications] the launch notices could not be read.', error);
862
+ return undefined;
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Which launch notices the player has seen: one that is on screen while the
868
+ * page is visible. The banner reports each notice's intersection with the
869
+ * screen and each page visibility change; a notice on screen while the page
870
+ * is hidden counts once the page shows again. `onSeen` runs once per notice.
871
+ */
872
+ export function createStarterNoticeVisibility(input: {
873
+ readonly pageVisible: () => boolean;
874
+ readonly onSeen: (index: number) => void;
875
+ }): {
876
+ readonly intersecting: (index: number, onScreen: boolean) => void;
877
+ readonly pageVisibilityChanged: () => void;
878
+ } {
879
+ const onScreen = new Set<number>();
880
+ const seen = new Set<number>();
881
+ const see = (index: number): void => {
882
+ if (!seen.has(index)) {
883
+ seen.add(index);
884
+ input.onSeen(index);
885
+ }
886
+ };
887
+ return {
888
+ intersecting(index, visible) {
889
+ if (!visible) {
890
+ onScreen.delete(index);
891
+ return;
892
+ }
893
+ onScreen.add(index);
894
+ if (input.pageVisible()) {
895
+ see(index);
896
+ }
897
+ },
898
+ pageVisibilityChanged() {
899
+ if (input.pageVisible()) {
900
+ for (const index of onScreen) {
901
+ see(index);
902
+ }
903
+ }
904
+ },
905
+ };
906
+ }
@@ -14,6 +14,8 @@ import { resolveStarterServerSession } from './serverSession';
14
14
  import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
15
15
  import {
16
16
  createStarterNotificationSettings,
17
+ readStarterLaunchNotices,
18
+ type StarterLaunchNotices,
17
19
  type StarterNotificationSettings,
18
20
  } from './notificationSettings';
19
21
 
@@ -179,6 +181,95 @@ export function createStarterNotificationSettingsModel(
179
181
  });
180
182
  }
181
183
 
184
+ /** Gateways whose launch notices were read this launch. */
185
+ const launchNoticesRead = new WeakSet<TargetConfiguredGateway>();
186
+
187
+ /**
188
+ * Shows the consent notices the server lists, once per launch: once the
189
+ * runtime has started (or its start has taken 10 seconds), it reads the
190
+ * listed notices and, when there is one, calls `show` with them in the
191
+ * player's language, overdue ones first. `show` mounts them and calls
192
+ * `seen(index)` for each one the player can see; only those are recorded as
193
+ * given. Under the Korean rules advertising pauses while a re-confirmation
194
+ * is overdue, until the game records it, so a player who never opens the
195
+ * settings screen still ends the pause at the next launch.
196
+ *
197
+ * - **Signed out at launch.** With a server session whose player is not
198
+ * signed in yet, it reads once a session becomes current
199
+ * (`onAuthenticated`), while `signal` is not aborted.
200
+ * - **Never in the way.** It runs in the background: it never blocks the
201
+ * game or throws. A target without notifications or a game without a
202
+ * backend shows nothing. When `show` answers `false` (the game went away),
203
+ * nothing is recorded, and a later call in this process reads again.
204
+ */
205
+ export function showStarterLaunchNotices(
206
+ platform: TargetConfiguredGateway,
207
+ input: {
208
+ readonly locale: string;
209
+ readonly show: (
210
+ notices: StarterLaunchNotices['notices'],
211
+ seen: (index: number) => void,
212
+ ) => boolean;
213
+ /** Aborted when the game goes away: a pending sign-in read is dropped. */
214
+ readonly signal?: AbortSignal;
215
+ },
216
+ ): Promise<void> {
217
+ if (launchNoticesRead.has(platform) || input.signal?.aborted === true) {
218
+ return Promise.resolve();
219
+ }
220
+ const backend = starterNotificationBackend(platform);
221
+ if (backend === undefined) {
222
+ return Promise.resolve();
223
+ }
224
+ launchNoticesRead.add(platform);
225
+ const session = hostedBackendFor(platform) ? undefined : resolveStarterServerSession();
226
+ if (session !== undefined && session.current() === undefined) {
227
+ // Signed out at launch: read once a player signs in, while the game runs.
228
+ launchNoticesRead.delete(platform);
229
+ let stop: (() => void) | undefined;
230
+ const retry = (): void => {
231
+ stop?.();
232
+ input.signal?.removeEventListener('abort', cancel);
233
+ void showStarterLaunchNotices(platform, input);
234
+ };
235
+ const cancel = (): void => stop?.();
236
+ stop = session.onAuthenticated(retry);
237
+ input.signal?.addEventListener('abort', cancel, { once: true });
238
+ return Promise.resolve();
239
+ }
240
+ return (async () => {
241
+ const launch = await readStarterLaunchNotices({
242
+ runtime: backend.runtime,
243
+ provider: platform.notificationsV2,
244
+ ...(backend.started === undefined ? {} : { started: backend.started }),
245
+ ...(starterNotificationCatalog.notices === undefined
246
+ ? {}
247
+ : { noticeCopy: starterNotificationCatalog.notices.copy }),
248
+ defaultLocale: starterNotificationCatalog.defaultLocale,
249
+ locale: input.locale,
250
+ });
251
+ if (launch === undefined) {
252
+ return;
253
+ }
254
+ const shown = input.signal?.aborted !== true
255
+ && input.show(launch.notices, (index) => {
256
+ void launch.record([index]);
257
+ });
258
+ if (!shown) {
259
+ // Not on screen: nothing was given, so a later mount may read again.
260
+ launchNoticesRead.delete(platform);
261
+ }
262
+ })().catch((error: unknown) => {
263
+ console.warn('[notifications] the launch notices could not be shown.', error);
264
+ });
265
+ }
266
+
267
+ /** Whether this gateway's notifications run over a hosted backend (Devvit), which needs no game session. */
268
+ function hostedBackendFor(platform: TargetConfiguredGateway): boolean {
269
+ return platform.notificationsV2 !== undefined
270
+ && resolveStarterHostedNotificationBackend(platform.configTarget) !== undefined;
271
+ }
272
+
182
273
  /**
183
274
  * The game's one listener for notification opens (see
184
275
  * `routeNotificationOpens` in `notificationOpens.ts`), over the runtime when
@@ -1,3 +1,4 @@
1
+ import { mountStarterLaunchNotices } from '../platform/launchNoticesBanner';
1
2
  import {
2
3
  starterNotificationLabelsFor,
3
4
  type StarterNotificationSettings,
@@ -5,6 +6,7 @@ import {
5
6
  } from '../platform/notificationSettings';
6
7
  import {
7
8
  createStarterNotificationSettingsModel,
9
+ showStarterLaunchNotices,
8
10
  subscribeStarterNotificationOpens,
9
11
  } from '../platform/notifications';
10
12
  import type { StarterContext } from '../runtime/gameContext';
@@ -15,7 +17,8 @@ import type { StarterContext } from '../runtime/gameContext';
15
17
  * the advertising and night consent (`notificationSettings.ts` holds the
16
18
  * logic). Every change is a tap; no switch starts on. A notification open
17
19
  * whose route is the settings screen (the advertising opt-out action, a
18
- * consent notice) opens the panel too. Returns the disposer.
20
+ * consent notice) opens the panel too, and the listed consent notices show
21
+ * once per launch (`showStarterLaunchNotices`). Returns the disposer.
19
22
  */
20
23
  export function mountNotificationSettingsPanel(mount: HTMLElement, context: StarterContext): () => void {
21
24
  if (context.platform.notificationsV2 === undefined) {
@@ -192,8 +195,27 @@ export function mountNotificationSettingsPanel(mount: HTMLElement, context: Star
192
195
  mount.style.position = 'relative';
193
196
  }
194
197
  mount.append(openButton, panel);
198
+ // Once per launch, the listed consent notices in a box that does not pause
199
+ // the game, each recorded as given once the player can see it: an overdue
200
+ // re-confirmation pauses advertising under the Korean rules until the game
201
+ // records it.
202
+ const lifetime = new AbortController();
203
+ let disposeLaunchNotices: (() => void) | undefined;
204
+ void showStarterLaunchNotices(context.platform, {
205
+ locale: context.locale,
206
+ signal: lifetime.signal,
207
+ show: (notices, seen) => {
208
+ if (lifetime.signal.aborted) {
209
+ return false;
210
+ }
211
+ disposeLaunchNotices = mountStarterLaunchNotices(notices, context.locale, { onSeen: seen });
212
+ return true;
213
+ },
214
+ });
195
215
  return () => {
216
+ lifetime.abort();
196
217
  unsubscribe();
218
+ disposeLaunchNotices?.();
197
219
  openButton.remove();
198
220
  panel.remove();
199
221
  };
@@ -1,5 +1,6 @@
1
1
  import Phaser from 'phaser';
2
2
 
3
+ import { mountStarterLaunchNotices } from '../platform/launchNoticesBanner';
3
4
  import {
4
5
  starterNotificationLabelsFor,
5
6
  type StarterNotificationLabels,
@@ -8,6 +9,7 @@ import {
8
9
  } from '../platform/notificationSettings';
9
10
  import {
10
11
  createStarterNotificationSettingsModel,
12
+ showStarterLaunchNotices,
11
13
  subscribeStarterNotificationOpens,
12
14
  } from '../platform/notifications';
13
15
  import { starterContextKey, type StarterContext } from '../runtime/gameContext';
@@ -70,6 +72,33 @@ export function routeNotificationSettingsOpens(game: Phaser.Game, context: Start
70
72
  });
71
73
  }
72
74
 
75
+ /**
76
+ * Shows the consent notices the server lists once per launch, in a small box
77
+ * over the game that does not pause it, and records them as given (see
78
+ * `showStarterLaunchNotices`): a player who never opens the settings screen
79
+ * still sees an overdue re-confirmation, which under the Korean rules pauses
80
+ * advertising until it is recorded.
81
+ */
82
+ export function showNotificationLaunchNotices(game: Phaser.Game, context: StarterContext): void {
83
+ let dispose: (() => void) | undefined;
84
+ const lifetime = new AbortController();
85
+ game.events.once('destroy', () => {
86
+ lifetime.abort();
87
+ dispose?.();
88
+ });
89
+ void showStarterLaunchNotices(context.platform, {
90
+ locale: context.locale,
91
+ signal: lifetime.signal,
92
+ show: (notices, seen) => {
93
+ if (lifetime.signal.aborted) {
94
+ return false;
95
+ }
96
+ dispose = mountStarterLaunchNotices(notices, context.locale, { onSeen: seen });
97
+ return true;
98
+ },
99
+ });
100
+ }
101
+
73
102
  /**
74
103
  * The game-rendered notification settings: the OS or host permission,
75
104
  * reminder topics, and the advertising consent the Korean rules require