@mpgd/cli 0.65.0 → 0.67.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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "69ac3be006da54cbb9dab9a56fe8564536483d85",
2
+ "kitGitSha": "1be07c42f7d67faaae8b908ee1a56d766b4b5120",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.65.0"
4
+ "packageVersion": "0.67.0"
5
5
  }
@@ -2623,6 +2623,13 @@ async function assertAssetPackNativeArtifact(report, artifact) {
2623
2623
  import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
2624
2624
  import { isIP } from "node:net";
2625
2625
  import path from "node:path";
2626
+
2627
+ // node_modules/.cache/mpgd-native-build/packages/platform/src/routes.js
2628
+ function isHostedGameServicesPath(value) {
2629
+ return value === value.trim() && /^\/api(?:\/[A-Za-z0-9_-]+)*\/?$/u.test(value);
2630
+ }
2631
+
2632
+ // node_modules/.cache/mpgd-native-build/packages/cli/src/production-target-readiness.js
2626
2633
  var wechatStagingAppId = "wx0000000000000000";
2627
2634
  var ownerPathByTarget = {
2628
2635
  ait: "wrapperApp",
@@ -2654,7 +2661,7 @@ function assertProductionTargetReadiness(input) {
2654
2661
  }
2655
2662
  if (ownerPathKey === void 0) {
2656
2663
  if (requiresMonetizationAuthority || targetConfig.authoritativeGameServices !== false) {
2657
- assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target);
2664
+ assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target, targetConfig.kind === "web" && requiresWebMonetizationAuthority);
2658
2665
  }
2659
2666
  return;
2660
2667
  }
@@ -2761,10 +2768,13 @@ function readCanonicalPathAllowingMissingTail(candidate, label) {
2761
2768
  }
2762
2769
  return path.join(readCanonicalPath(existingPath, label), ...missingSegments);
2763
2770
  }
2764
- function assertAuthoritativeGameServicesUrl(value, target2) {
2771
+ function assertAuthoritativeGameServicesUrl(value, target2, allowSameOrigin = false) {
2765
2772
  if (value === void 0 || value.length === 0) {
2766
2773
  throw new Error(`Production target ${target2} requires VITE_MPGD_GAME_SERVICES_URL for an authoritative remote backend.`);
2767
2774
  }
2775
+ if (allowSameOrigin && isHostedGameServicesPath(value)) {
2776
+ return;
2777
+ }
2768
2778
  let url;
2769
2779
  try {
2770
2780
  url = new URL(value);
@@ -151,6 +151,13 @@ function expression(value) {
151
151
  function quoted(value) {
152
152
  return `'${value.replaceAll("'", "''")}'`;
153
153
  }
154
+ // Linux jobs pin a runner image instead of `ubuntu-latest`, as the kit's own CI does:
155
+ // the rolling label moves to a new Ubuntu image, with different preinstalled toolchains
156
+ // (for example the Go that ttsc keys its compiled plugins on), without a workflow change.
157
+ // The generated jobs cache only the pnpm store, which no image toolchain keys, so they
158
+ // need no image-keyed cache; a game that adds one should put the image in its key.
159
+ const LINUX_RUNNER = 'ubuntu-24.04';
160
+ const MACOS_RUNNER = 'macos-26';
154
161
  function renderNativeDeployWorkflow(input) {
155
162
  const options = input.targets.map((target) => ` - ${target}`);
156
163
  const lines = [
@@ -190,7 +197,7 @@ function renderNativeDeployWorkflow(input) {
190
197
  'jobs:',
191
198
  ' validate:',
192
199
  " if: github.event_name == 'pull_request'",
193
- ' runs-on: ubuntu-latest',
200
+ ` runs-on: ${LINUX_RUNNER}`,
194
201
  ' steps:',
195
202
  ' - uses: actions/checkout@v5',
196
203
  ' with:',
@@ -229,7 +236,7 @@ function renderDeployJob(input, target) {
229
236
  ` deploy_${target}:`,
230
237
  ` if: github.event_name == 'workflow_dispatch' && github.ref == `
231
238
  + `'refs/heads/${input.releaseBranch}' && inputs.target == '${target}'`,
232
- ` runs-on: ${target === 'android' ? 'ubuntu-latest' : 'macos-26'}`,
239
+ ` runs-on: ${target === 'android' ? LINUX_RUNNER : MACOS_RUNNER}`,
233
240
  ` environment: ${quoted(input.approvalEnvironment)}`,
234
241
  ' permissions:',
235
242
  ' contents: write',
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { isIP } from 'node:net';
3
3
  import path from 'node:path';
4
+ import { isHostedGameServicesPath } from '@mpgd/platform/routes';
4
5
  const wechatStagingAppId = 'wx0000000000000000';
5
6
  const ownerPathByTarget = {
6
7
  ait: 'wrapperApp',
@@ -48,7 +49,7 @@ export function assertProductionTargetReadiness(input) {
48
49
  }
49
50
  if (ownerPathKey === undefined) {
50
51
  if (requiresMonetizationAuthority || targetConfig.authoritativeGameServices !== false) {
51
- assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target);
52
+ assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target, targetConfig.kind === 'web' && requiresWebMonetizationAuthority);
52
53
  }
53
54
  return;
54
55
  }
@@ -167,11 +168,17 @@ function readCanonicalPathAllowingMissingTail(candidate, label) {
167
168
  }
168
169
  return path.join(readCanonicalPath(existingPath, label), ...missingSegments);
169
170
  }
170
- function assertAuthoritativeGameServicesUrl(value, target) {
171
+ function assertAuthoritativeGameServicesUrl(value, target, allowSameOrigin = false) {
171
172
  if (value === undefined || value.length === 0) {
172
173
  throw new Error(`Production target ${target} requires VITE_MPGD_GAME_SERVICES_URL for an `
173
174
  + 'authoritative remote backend.');
174
175
  }
176
+ // Hosted web/PWA clients can reach an authoritative service through their
177
+ // own Pages/edge ingress. Never apply this allowance to third-party/native
178
+ // hosts, or accept paths that URL normalization can turn into another host.
179
+ if (allowSameOrigin && isHostedGameServicesPath(value)) {
180
+ return;
181
+ }
175
182
  let url;
176
183
  try {
177
184
  url = new URL(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.65.0",
3
+ "version": "0.67.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.14.0",
74
- "@mpgd/platform": "0.33.0",
75
- "@mpgd/target-config": "0.24.4",
76
- "@mpgd/phaser-assets": "0.9.1"
73
+ "@mpgd/catalog": "0.14.2",
74
+ "@mpgd/phaser-assets": "0.9.1",
75
+ "@mpgd/target-config": "0.24.6",
76
+ "@mpgd/platform": "0.35.0"
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.6",
85
- "@mpgd/adapter-browser": "0.12.2",
86
- "@mpgd/adapter-capacitor": "0.17.0",
87
- "@mpgd/analytics": "0.5.6",
88
- "@mpgd/adapter-verse8": "0.7.17",
89
- "@mpgd/adapter-devvit": "0.14.8",
90
- "@mpgd/bridge": "0.17.0",
91
- "@mpgd/game-runtime": "0.8.10",
92
- "@mpgd/capacitor-storekit": "0.5.0",
93
- "@mpgd/capacitor-play-billing": "0.5.0",
94
- "@mpgd/game-services": "0.31.0",
95
- "@mpgd/lite-shell": "0.1.0",
96
- "@mpgd/i18n": "0.6.27"
84
+ "@mpgd/adapter-ait": "0.21.8",
85
+ "@mpgd/adapter-browser": "0.12.4",
86
+ "@mpgd/adapter-capacitor": "0.19.0",
87
+ "@mpgd/analytics": "0.5.8",
88
+ "@mpgd/adapter-verse8": "0.7.19",
89
+ "@mpgd/adapter-devvit": "0.14.10",
90
+ "@mpgd/bridge": "0.18.1",
91
+ "@mpgd/capacitor-storekit": "0.7.0",
92
+ "@mpgd/capacitor-play-billing": "0.6.1",
93
+ "@mpgd/game-runtime": "0.10.0",
94
+ "@mpgd/game-services": "0.33.0",
95
+ "@mpgd/i18n": "0.6.29",
96
+ "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
99
99
  "types": "./dist/index.d.ts",
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  isPublicGameServicesUrl,
3
3
  resolveGameServicesAuthorityMode,
4
+ resolveGameServicesBaseUrl,
4
5
  type GameServicesHeaderResolver,
5
6
  } from '@mpgd/game-services';
6
7
  import {
@@ -82,7 +83,10 @@ export function createStarterWorkerNotificationBackend(
82
83
  if (platform.target === 'reddit' || targetsWithoutWorkerBackend.has(platform.configTarget)) {
83
84
  return undefined;
84
85
  }
85
- const baseUrl = input.baseUrl?.trim();
86
+ const baseUrl = resolveGameServicesBaseUrl(input.baseUrl, {
87
+ target: platform.target,
88
+ ...(globalThis.location?.origin === undefined ? {} : { pageOrigin: globalThis.location.origin }),
89
+ });
86
90
  const provider = platform.notificationsV2;
87
91
  if (baseUrl === undefined || baseUrl === '' || provider === undefined) {
88
92
  return undefined;
@@ -564,8 +564,8 @@ The game services belong to one server player for the run
564
564
  its own and lets the player switch accounts within a run gets them closed, not
565
565
  moved to the new account: from the moment the session names another player,
566
566
  nothing is verified, restored, or finished with the new player's credentials
567
- for the old player's purchases (a backend rejects those for good as an
568
- account mismatch), no purchase is journaled under one player and bound to the
567
+ for the old player's purchases (a backend answers those as another account's
568
+ purchase), no purchase is journaled under one player and bound to the
569
569
  other's store account, and purchases answer `failed` with the retryable
570
570
  diagnostic `GAME_SERVICES_PLAYER_CHANGED` before any store sheet opens.
571
571
  `main.ts` stops play at once (`onPlayerChanging`: an execution block and a
@@ -14,6 +14,9 @@ export type GameMessageKey =
14
14
  | 'reward'
15
15
  | 'rewardError'
16
16
  | 'rewardPending'
17
+ | 'rewardNotReady'
18
+ | 'rewardReconciling'
19
+ | 'rewardHistoryFull'
17
20
  | 'rewardUnavailable'
18
21
  | 'analytics'
19
22
  | 'bootError'
@@ -59,6 +62,9 @@ const messages = {
59
62
  reward: 'Rewarded ad smoke: {status}',
60
63
  rewardError: 'Rewarded ad smoke failed',
61
64
  rewardPending: 'Press R to request a rewarded ad smoke',
65
+ rewardNotReady: 'Rewarded ad smoke waits for the current purchase or ad',
66
+ rewardReconciling: 'Rewarded ad smoke waits until an earlier result is confirmed',
67
+ rewardHistoryFull: 'Rewarded ad smoke is unavailable until the game restarts',
62
68
  rewardUnavailable: 'Rewarded ads unavailable on this target',
63
69
  analytics: 'Analytics events: {count}',
64
70
  bootError: 'Failed to start game',
@@ -108,6 +114,9 @@ const messages = {
108
114
  reward: '리워드 광고 스모크: {status}',
109
115
  rewardError: '리워드 광고 스모크 실패',
110
116
  rewardPending: 'R 키로 리워드 광고 스모크 요청',
117
+ rewardNotReady: '진행 중인 구매나 광고가 끝나면 리워드 광고 스모크 요청 가능',
118
+ rewardReconciling: '이전 결과가 확인되면 리워드 광고 스모크 요청 가능',
119
+ rewardHistoryFull: '게임을 다시 시작할 때까지 리워드 광고 스모크를 사용할 수 없음',
111
120
  rewardUnavailable: '이 타깃에서는 리워드 광고를 사용할 수 없음',
112
121
  analytics: '애널리틱스 이벤트: {count}',
113
122
  bootError: '게임 시작 실패',
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  AccountDeletionError,
3
3
  GuestSessionCoordinatorError,
4
+ isCommercePurchaseOperationRecord,
4
5
  isMonetizationOperationRecoverable,
6
+ isParkedForAnotherAccount,
5
7
  type AccountDeletionClient,
6
8
  type MonetizationOperationRecord,
7
9
  } from '@mpgd/game-services';
@@ -272,7 +274,10 @@ export function createStarterAccountDeletion(input: {
272
274
  return false;
273
275
  }
274
276
  return Object.values(records as Record<string, MonetizationOperationRecord>).some(
275
- (record) => record.kind !== 'rewarded-ad' && isMonetizationOperationRecoverable(record),
277
+ (record) => record.kind !== 'rewarded-ad'
278
+ && isMonetizationOperationRecoverable(record)
279
+ // Parked as another account's purchase: nothing of this player is in progress.
280
+ && !(isCommercePurchaseOperationRecord(record) && isParkedForAnotherAccount(record)),
276
281
  );
277
282
  } catch {
278
283
  return false;
@@ -4,6 +4,7 @@ import productCatalogJson from '@mpgd/catalog/catalog.json';
4
4
  import {
5
5
  createGameServicesRuntime,
6
6
  resolveGameServicesAuthorityMode,
7
+ resolveGameServicesBaseUrl,
7
8
  resolveGameServicesTransport,
8
9
  type GameServicesRuntime,
9
10
  type GameServicesRuntimeMode,
@@ -163,6 +164,10 @@ function createRuntime(
163
164
  input: StarterGameServicesInput,
164
165
  session: ReturnType<typeof resolveStarterServerSession>,
165
166
  ): GameServicesRuntime {
167
+ const baseUrl = resolveGameServicesBaseUrl(import.meta.env.VITE_MPGD_GAME_SERVICES_URL, {
168
+ target: input.gateway.target,
169
+ ...(globalThis.location?.origin === undefined ? {} : { pageOrigin: globalThis.location.origin }),
170
+ });
166
171
  return createGameServicesRuntime({
167
172
  gateway: input.gateway,
168
173
  playerId: input.playerId,
@@ -176,9 +181,7 @@ function createRuntime(
176
181
  authorityMode: resolveGameServicesAuthorityMode(import.meta.env.MODE),
177
182
  target: import.meta.env.VITE_MPGD_GAME_SERVICES_TARGET ?? input.gateway.target,
178
183
  deploymentTarget: input.configTarget,
179
- ...(import.meta.env.VITE_MPGD_GAME_SERVICES_URL === undefined
180
- ? {}
181
- : { baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL }),
184
+ ...(baseUrl === undefined ? {} : { baseUrl }),
182
185
  transport: resolveGameServicesTransport(import.meta.env.VITE_MPGD_GAME_SERVICES_TRANSPORT),
183
186
  // A store purchase bound to another player's account is left for that player.
184
187
  // Only where the template binds purchases: elsewhere (Microsoft Store) nothing is compared.
@@ -1,6 +1,8 @@
1
1
  import {
2
+ isCommercePurchaseOperationRecord,
2
3
  isMonetizationOperationGranted,
3
4
  isMonetizationOperationRecoverable,
5
+ isParkedForAnotherAccount,
4
6
  type MonetizationOperationRecord,
5
7
  type MonetizationOperationStore,
6
8
  } from '@mpgd/game-services';
@@ -21,17 +23,55 @@ export const defaultMonetizationJournalRetentionMs = 30 * 24 * 60 * 60 * 1000;
21
23
  * age, or by the byte budget (`defaultMonetizationJournalMaxDocumentBytes`)
22
24
  * when the document would not fit, so the revocation pass and
23
25
  * `findByTransaction` keep them for the window whenever storage allows.
26
+ * Records parked as another account's purchase are the one kind of open
27
+ * record that leaves (`defaultMonetizationJournalParkedRetentionMs`).
24
28
  */
25
29
  export const defaultMonetizationJournalMaxTerminalRecords = 100;
30
+ /**
31
+ * Records parked as another account's purchase (RFC "Another account's
32
+ * purchase") leave this long after their last write (30 days): the parking,
33
+ * or the player's last Restore Purchases resend. They are open records, but
34
+ * nothing of their player is in progress: the backend's answer granted and
35
+ * recorded nothing, and the store still holds the purchase for the account it
36
+ * is bound to, which recovers it under its own key. Dropping one cannot lose
37
+ * a grant: the backend ledger decides every grant, and a later verify of the
38
+ * same purchase finds a grant whose answer was lost. It costs one more verify
39
+ * that parks it again, if the store still lists the purchase and the client
40
+ * cannot tell its binding is another account's.
41
+ */
42
+ export const defaultMonetizationJournalParkedRetentionMs = 30 * 24 * 60 * 60 * 1000;
43
+ /**
44
+ * At most this many parked records are kept across every player of the
45
+ * install, the most recently written first. A purchase dropped by this bound
46
+ * that the store keeps listing is verified again by the next pass that meets
47
+ * it, as an older client resends every such purchase. The byte budget may
48
+ * drop parked records before granted purchases. Neither drops a record in
49
+ * the in-flight window (`parkedInFlightMs`).
50
+ */
51
+ export const defaultMonetizationJournalMaxParkedRecords = 20;
52
+ /**
53
+ * The default in-flight window (10 minutes, the reconciler's
54
+ * `defaultInFlightTimeoutMs`): a parked record written this recently, or
55
+ * later than now (a clock moved back), is kept through every bound. A
56
+ * Restore Purchases resend rewrites its record before it is sent, in this
57
+ * context or another tab's, and the record must still be there when the
58
+ * backend answers. The parked record this journal wrote last is kept too. A
59
+ * parked record without a readable time is never in flight.
60
+ */
61
+ export const defaultMonetizationJournalParkedInFlightMs = 10 * 60 * 1000;
26
62
  /**
27
63
  * Upper bound of the serialized document (192 KiB, below the Devvit bridge's
28
64
  * 256 KiB per value). When the records in the window would exceed it, the
29
65
  * oldest terminal records leave first: ungranted ones without a store
30
- * transaction, then ungranted ones that still name a store transaction, then
31
- * granted purchases. Open records are never dropped.
66
+ * transaction, then ungranted ones that still name a store transaction and
67
+ * parked records (except one that may be in flight), then granted purchases.
68
+ * Other open records are never dropped.
32
69
  */
33
70
  export const defaultMonetizationJournalMaxDocumentBytes = 192 * 1024;
34
71
 
72
+ /** The time of a parked record without a readable one: newest in sort order, never in flight. */
73
+ const unreadableParkedTime = Number.MAX_SAFE_INTEGER;
74
+
35
75
  export interface CreateStorageMonetizationOperationStoreInput {
36
76
  /** The platform storage adapter (`gateway.storage`); the host owns its durability. */
37
77
  readonly storage: StorageAdapter;
@@ -43,6 +83,18 @@ export interface CreateStorageMonetizationOperationStoreInput {
43
83
  readonly maxTerminalRecords?: number;
44
84
  /** Defaults to 192 KiB of serialized JSON; see `defaultMonetizationJournalMaxDocumentBytes`. */
45
85
  readonly maxDocumentBytes?: number;
86
+ /** Defaults to 30 days; see `defaultMonetizationJournalParkedRetentionMs`. */
87
+ readonly parkedRetentionMs?: number;
88
+ /** Defaults to 20 parked records; see `defaultMonetizationJournalMaxParkedRecords`. */
89
+ readonly maxParkedRecords?: number;
90
+ /**
91
+ * Defaults to 10 minutes; see `defaultMonetizationJournalParkedInFlightMs`.
92
+ * A game that changes the reconciler's `inFlightTimeoutMs`
93
+ * (`createCommerceReconciler`) passes the same value here, so a parked
94
+ * record stays in the journal for as long as its Restore Purchases answer
95
+ * may be awaited.
96
+ */
97
+ readonly parkedInFlightMs?: number;
46
98
  readonly now?: () => number;
47
99
  /**
48
100
  * Cross-context lock for the read-modify-write of the document; defaults to
@@ -67,7 +119,8 @@ interface JournalDocument {
67
119
  * The reference durable `MonetizationOperationStore` of the starter: one JSON
68
120
  * document in the platform storage adapter, rewritten whole on every change
69
121
  * so a record and its index can never disagree, terminal records bounded by
70
- * age (and, without a ledger grant, by count; open records are always kept),
122
+ * age (and, without a ledger grant, by count; open records are kept, except
123
+ * records parked as another account's purchase, bounded by age and count),
71
124
  * with every operation
72
125
  * serialized through one promise chain so the compare-and-swap of `replace`
73
126
  * holds inside the WebView. It is crash recovery, not grant authority: the
@@ -87,10 +140,26 @@ export function createStorageMonetizationOperationStore(
87
140
  const maxTerminalRecords = input.maxTerminalRecords
88
141
  ?? defaultMonetizationJournalMaxTerminalRecords;
89
142
  const maxDocumentBytes = input.maxDocumentBytes ?? defaultMonetizationJournalMaxDocumentBytes;
143
+ const parkedRetentionMs = input.parkedRetentionMs ?? defaultMonetizationJournalParkedRetentionMs;
144
+ const parkedLimit = input.maxParkedRecords ?? defaultMonetizationJournalMaxParkedRecords;
145
+ const maxParkedRecords = parkedLimit >= 0 ? parkedLimit : 0;
146
+ const parkedInFlightMs = input.parkedInFlightMs ?? defaultMonetizationJournalParkedInFlightMs;
90
147
  const now = input.now ?? (() => Date.now());
91
148
  const locks = input.locks === undefined ? readHostLockManager() : (input.locks ?? undefined);
92
149
  const lockName = `mpgd-monetization-journal:${key}`;
93
150
  let chain: Promise<unknown> = Promise.resolve();
151
+ /**
152
+ * The parked record this journal wrote last: a Restore Purchases resend
153
+ * rewrites its record before it is sent, so this is the one whose answer
154
+ * may be awaited. No bound drops it, whatever its time says.
155
+ */
156
+ let lastWrittenParked: string | undefined;
157
+ const noteWrite = (record: MonetizationOperationRecord): void => {
158
+ const writtenAt = parkedWrittenAt(record);
159
+ if (writtenAt !== undefined && writtenAt !== unreadableParkedTime) {
160
+ lastWrittenParked = record.key;
161
+ }
162
+ };
94
163
 
95
164
  const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
96
165
  const run = locks === undefined ? operation : () => locks.request(lockName, () => operation());
@@ -113,11 +182,34 @@ export function createStorageMonetizationOperationStore(
113
182
 
114
183
  const save = async (records: Map<string, MonetizationOperationRecord>): Promise<void> => {
115
184
  const cutoff = now() - retentionMs;
185
+ const parkedCutoff = now() - parkedRetentionMs;
116
186
  const terminal: [string, number][] = [];
187
+ const parked: [string, number][] = [];
117
188
  const kept: Record<string, MonetizationOperationRecord> = {};
118
- /** Terminal records the byte budget may drop, with their drop tier (lower leaves first). */
189
+ /** Records the byte budget may drop, with their drop tier (lower leaves first). */
119
190
  const droppable: [string, number, number][] = [];
191
+ const inFlightSince = now() - parkedInFlightMs;
192
+ let inFlight = 0;
120
193
  for (const [recordKey, record] of records) {
194
+ const parkedAt = parkedWrittenAt(record);
195
+ if (
196
+ parkedAt !== undefined
197
+ && parkedAt !== unreadableParkedTime
198
+ && (recordKey === lastWrittenParked || parkedAt >= inFlightSince)
199
+ ) {
200
+ // Possibly awaiting a Restore Purchases answer: no bound drops it.
201
+ kept[recordKey] = record;
202
+ inFlight += 1;
203
+ continue;
204
+ }
205
+ if (parkedAt !== undefined) {
206
+ // Another account's purchase: nothing of this player is in progress,
207
+ // and the purchase's own account recovers it under its own key.
208
+ if (parkedAt >= parkedCutoff) {
209
+ parked.push([recordKey, parkedAt]);
210
+ }
211
+ continue;
212
+ }
121
213
  const settledAt = terminalSettledAt(record);
122
214
  if (settledAt === undefined) {
123
215
  kept[recordKey] = record;
@@ -135,6 +227,18 @@ export function createStorageMonetizationOperationStore(
135
227
  terminal.push([recordKey, settledAt]);
136
228
  }
137
229
  }
230
+ // Newest first; the records kept as in flight count against the bound.
231
+ parked.sort((left, right) => right[1] - left[1]);
232
+ for (const [recordKey, writtenAt] of parked.slice(
233
+ 0,
234
+ Math.max(0, maxParkedRecords - inFlight),
235
+ )) {
236
+ const record = records.get(recordKey);
237
+ if (record !== undefined) {
238
+ kept[recordKey] = record;
239
+ droppable.push([recordKey, writtenAt, 1]);
240
+ }
241
+ }
138
242
  // Newest first; the oldest terminal records beyond the bound are dropped.
139
243
  terminal.sort((left, right) => right[1] - left[1]);
140
244
  for (const [recordKey, settledAt] of terminal.slice(0, Math.max(0, maxTerminalRecords))) {
@@ -146,7 +250,7 @@ export function createStorageMonetizationOperationStore(
146
250
  }
147
251
  // The document must fit the host's per-value limit, or every later write
148
252
  // fails and monetization stops: the oldest record of the lowest tier leaves
149
- // until it fits. Open records are never dropped.
253
+ // until it fits. Open records other than parked ones are never dropped.
150
254
  droppable.sort((left, right) => left[2] - right[2] || left[1] - right[1]);
151
255
  let size = documentBytes(kept);
152
256
  for (const [recordKey] of droppable) {
@@ -169,6 +273,7 @@ export function createStorageMonetizationOperationStore(
169
273
  return { created: false, record: clone(existing) };
170
274
  }
171
275
  records.set(record.key, clone(record));
276
+ noteWrite(record);
172
277
  await save(records);
173
278
  return { created: true, record: clone(record) };
174
279
  });
@@ -195,6 +300,7 @@ export function createStorageMonetizationOperationStore(
195
300
  throw new Error('Stale or conflicting monetization journal write.');
196
301
  }
197
302
  records.set(record.key, clone(record));
303
+ noteWrite(record);
198
304
  await save(records);
199
305
  });
200
306
  },
@@ -245,6 +351,22 @@ function terminalSettledAt(record: MonetizationOperationRecord): number | undefi
245
351
  return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
246
352
  }
247
353
 
354
+ /**
355
+ * When a record parked as another account's purchase was last written (its
356
+ * parking, or a Restore Purchases resend), or `undefined` for any other
357
+ * record. A parked record without a readable time is never in flight and
358
+ * sorts as the newest: only the count bound and the byte budget drop it.
359
+ */
360
+ function parkedWrittenAt(record: MonetizationOperationRecord): number | undefined {
361
+ if (!isCommercePurchaseOperationRecord(record) || !isParkedForAnotherAccount(record)) {
362
+ return undefined;
363
+ }
364
+ const times = [record.updatedAt, record.anotherAccount?.reportedAt]
365
+ .map((time) => (time === undefined ? Number.NaN : Date.parse(time)))
366
+ .filter((time) => Number.isFinite(time));
367
+ return times.length === 0 ? unreadableParkedTime : Math.max(...times);
368
+ }
369
+
248
370
  /** A commerce purchase record that names a store transaction (the store may still list it). */
249
371
  function holdsStoreTransaction(record: MonetizationOperationRecord): boolean {
250
372
  return record.kind === 'commerce-purchase' && record.purchase?.transactionId !== undefined;
@@ -1,4 +1,4 @@
1
- import type { GameActionController } from '@mpgd/game-runtime/actions';
1
+ import type { GameActionAvailability, GameActionController } from '@mpgd/game-runtime/actions';
2
2
  import { bindPhaserGameScene } from '@mpgd/game-runtime/phaser';
3
3
  import Phaser from 'phaser';
4
4
 
@@ -21,6 +21,8 @@ export class PlayScene extends Phaser.Scene {
21
21
  private analyticsText!: Phaser.GameObjects.Text;
22
22
  private context!: StarterContext;
23
23
  private rewardedAction: GameActionController<'rewarded-ad'> | undefined;
24
+ private showingNotReady = false;
25
+ private requesting = false;
24
26
  private lastScore = -1;
25
27
  private lastAnalyticsCount = -1;
26
28
 
@@ -83,19 +85,67 @@ export class PlayScene extends Phaser.Scene {
83
85
  })
84
86
  .setOrigin(0.5);
85
87
 
86
- this.rewardedAction = this.context.gameRuntime.actions?.createRewardedAdController();
88
+ const actions = this.context.gameRuntime.actions;
89
+ this.rewardedAction = actions?.createRewardedAdController();
87
90
  const action = this.rewardedAction;
91
+ // Follow the runtime's availability, so the hint changes while any purchase
92
+ // (one from the shop included) or ad holds the screen, without a key press.
93
+ this.showingNotReady = false;
94
+ this.requesting = false;
95
+ const stopAvailability = actions?.subscribeAvailability((availability) => {
96
+ this.showAvailability(availability);
97
+ });
98
+ if (actions !== undefined) {
99
+ this.showAvailability(actions.getAvailability());
100
+ }
88
101
  const keyboard = this.input.keyboard;
89
102
  const onReward = () => {
90
103
  void this.requestRewardedAd();
91
104
  };
92
105
  keyboard?.on('keydown-R', onReward);
93
106
  this.events.once('shutdown', () => {
107
+ stopAvailability?.();
94
108
  action?.dispose();
95
109
  keyboard?.off('keydown-R', onReward);
96
110
  });
97
111
  }
98
112
 
113
+ private showAvailability(availability: GameActionAvailability): void {
114
+ // This scene's own request makes the actions busy too; its result text
115
+ // follows, so only someone else's purchase or ad shows the waiting text.
116
+ if (!this.rewardedAdsAvailable() || this.rewardedAction === undefined || availability === 'disposed'
117
+ || this.requesting) {
118
+ return;
119
+ }
120
+ if (availability === 'ready') {
121
+ // Restore the hint only over the waiting text, never over a result.
122
+ if (this.showingNotReady) {
123
+ this.showingNotReady = false;
124
+ this.rewardText.setText(this.rewardHint());
125
+ }
126
+ return;
127
+ }
128
+ this.showingNotReady = true;
129
+ // `history-full` lasts until the runtime is recreated; only `busy` ends
130
+ // with the other purchase or ad.
131
+ this.rewardText.setText(
132
+ t(
133
+ this.context.locale,
134
+ availability === 'history-full'
135
+ ? 'rewardHistoryFull'
136
+ : availability === 'reconciliation-required'
137
+ ? 'rewardReconciling'
138
+ : 'rewardNotReady',
139
+ ),
140
+ );
141
+ }
142
+
143
+ /** A result or error replaces the waiting text for good: `ready` must not restore the hint over it. */
144
+ private showRewardText(text: string): void {
145
+ this.showingNotReady = false;
146
+ this.rewardText.setText(text);
147
+ }
148
+
99
149
  override update(_time: number, delta: number): void {
100
150
  this.state = stepStarterRunState(this.state, delta);
101
151
 
@@ -139,13 +189,15 @@ export class PlayScene extends Phaser.Scene {
139
189
  const action = this.rewardedAction;
140
190
 
141
191
  if (!this.rewardedAdsAvailable() || action === undefined) {
142
- this.rewardText.setText(t(this.context.locale, 'rewardUnavailable'));
192
+ this.showRewardText(t(this.context.locale, 'rewardUnavailable'));
143
193
  return;
144
194
  }
145
195
 
196
+ // The tap-time guard stays: availability can change before the next frame.
146
197
  if (this.context.gameRuntime.actions?.getAvailability() !== 'ready') {
147
198
  return;
148
199
  }
200
+ this.requesting = true;
149
201
  try {
150
202
  const result = await action.execute({
151
203
  placementId: rewardedPlacementId,
@@ -155,7 +207,7 @@ export class PlayScene extends Phaser.Scene {
155
207
  if (action.isDisposed() || this.rewardedAction !== action) {
156
208
  return;
157
209
  }
158
- this.rewardText.setText(
210
+ this.showRewardText(
159
211
  result.claim?.granted === true
160
212
  ? t(this.context.locale, 'reward', { status: result.status })
161
213
  : t(this.context.locale, 'rewardError'),
@@ -169,9 +221,13 @@ export class PlayScene extends Phaser.Scene {
169
221
  },
170
222
  });
171
223
  if (!action.isDisposed() && this.rewardedAction === action) {
172
- this.rewardText.setText(t(this.context.locale, 'rewardError'));
224
+ this.showRewardText(t(this.context.locale, 'rewardError'));
173
225
  }
174
226
  console.error('[rewarded-ad]', error);
227
+ } finally {
228
+ if (this.rewardedAction === action) {
229
+ this.requesting = false;
230
+ }
175
231
  }
176
232
  }
177
233
  }