@mpgd/cli 0.64.0 → 0.66.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 (24) hide show
  1. package/dist/native-build-info.json +2 -2
  2. package/dist/native-build-target.js +104 -82
  3. package/dist/production-target-readiness.js +9 -2
  4. package/package.json +20 -20
  5. package/templates/lite-overlay/README.md +4 -0
  6. package/templates/lite-overlay/apps/target-devvit/README.md +10 -0
  7. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +5 -1
  8. package/templates/phaser-game/README.md +32 -3
  9. package/templates/phaser-game/apps/target-ait/package.json +2 -2
  10. package/templates/phaser-game/apps/target-cloudflare-pages/package.json +2 -2
  11. package/templates/phaser-game/apps/target-devvit/README.md +10 -0
  12. package/templates/phaser-game/apps/target-devvit/package.json +2 -2
  13. package/templates/phaser-game/package.json +2 -2
  14. package/templates/phaser-game/src/i18n/messages.ts +3 -0
  15. package/templates/phaser-game/src/main.ts +42 -0
  16. package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +5 -0
  17. package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +5 -0
  18. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +81 -1
  19. package/templates/phaser-game/src/platform/deleteAccount.ts +6 -1
  20. package/templates/phaser-game/src/platform/gameServices.ts +94 -7
  21. package/templates/phaser-game/src/platform/guestIdentity.ts +4 -3
  22. package/templates/phaser-game/src/platform/monetizationJournal.ts +127 -5
  23. package/templates/phaser-game/src/platform/sessionGatedServices.ts +149 -21
  24. package/templates/phaser-game/src/styles.css +12 -0
@@ -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.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -63,37 +63,37 @@
63
63
  "gunshi": "^0.35.1",
64
64
  "semver": "^7.8.5",
65
65
  "sharp": "0.35.5",
66
- "typia": "13.0.2",
66
+ "typia": "15.2.1",
67
67
  "undici": "^7.29.1",
68
68
  "vite": "8.1.3",
69
69
  "yauzl": "3.4.0",
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/catalog": "0.13.0",
74
- "@mpgd/platform": "0.32.0",
75
- "@mpgd/target-config": "0.24.3",
76
- "@mpgd/phaser-assets": "0.9.1"
73
+ "@mpgd/catalog": "0.14.1",
74
+ "@mpgd/phaser-assets": "0.9.1",
75
+ "@mpgd/platform": "0.34.0",
76
+ "@mpgd/target-config": "0.24.5"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
80
80
  "@types/semver": "^7.8.0",
81
81
  "@types/yauzl": "3.4.0",
82
- "ttsc": "0.32.1",
82
+ "ttsc": "0.34.2",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.21.5",
85
- "@mpgd/adapter-browser": "0.12.1",
86
- "@mpgd/adapter-capacitor": "0.16.3",
87
- "@mpgd/adapter-devvit": "0.14.7",
88
- "@mpgd/adapter-verse8": "0.7.16",
89
- "@mpgd/bridge": "0.16.4",
90
- "@mpgd/analytics": "0.5.5",
91
- "@mpgd/game-runtime": "0.8.9",
92
- "@mpgd/capacitor-play-billing": "0.4.5",
93
- "@mpgd/capacitor-storekit": "0.4.5",
94
- "@mpgd/game-services": "0.30.5",
95
- "@mpgd/i18n": "0.6.26",
96
- "@mpgd/lite-shell": "0.1.0"
84
+ "@mpgd/adapter-ait": "0.21.7",
85
+ "@mpgd/adapter-capacitor": "0.18.0",
86
+ "@mpgd/adapter-browser": "0.12.3",
87
+ "@mpgd/adapter-devvit": "0.14.9",
88
+ "@mpgd/adapter-verse8": "0.7.18",
89
+ "@mpgd/bridge": "0.18.0",
90
+ "@mpgd/capacitor-play-billing": "0.6.0",
91
+ "@mpgd/analytics": "0.5.7",
92
+ "@mpgd/capacitor-storekit": "0.6.0",
93
+ "@mpgd/game-runtime": "0.9.0",
94
+ "@mpgd/game-services": "0.32.0",
95
+ "@mpgd/lite-shell": "0.1.0",
96
+ "@mpgd/i18n": "0.6.28"
97
97
  },
98
98
  "main": "./dist/index.js",
99
99
  "types": "./dist/index.d.ts",
@@ -16,6 +16,10 @@ pnpm accept
16
16
  Standalone games install the public `@mpgd/lite-shell` package. Add `--workspace --kit-path <mpgd-kit>` to generate a game linked to a local Kit checkout.
17
17
  The CLI supports the canvas renderer; DOM and SVG renderers are planned.
18
18
 
19
+ ttsc 0.34 compiles its type-transform plugins with Go 1.27 or later. It uses
20
+ the Go it bundles unless `TTSC_GO_BINARY` names another one; if your mise or
21
+ CI configuration pins Go for ttsc, pin 1.27 or later (the Kit uses 1.27.3).
22
+
19
23
  ## Ownership
20
24
 
21
25
  - `src/game/state.ts`: deterministic state.
@@ -41,6 +41,16 @@ replace the `devvit.json` scheduler endpoint and should not use process-local
41
41
  memory when delivery must cross instances. The generated target exposes only
42
42
  the oRPC bridge route; the former JSON compatibility route is not generated.
43
43
 
44
+ The bridge client (game `@mpgd/adapter-devvit`) and the bridge server
45
+ (`src/server`) must ship in the same app build: they share one `@mpgd/bridge`
46
+ and therefore one oRPC version. oRPC error bodies are not compatible across
47
+ oRPC 2.0.0-beta.34 (`@mpgd/bridge` 0.16.4 and older used 2.0.0-beta.14), so a
48
+ client and server from different kit releases would turn every oRPC error into a
49
+ malformed-response failure. Bridge method errors and request-validation
50
+ failures travel inside the bridge response envelope and are unaffected; only
51
+ internal server errors and oRPC transport errors (413, undecodable bodies) use
52
+ the oRPC error body.
53
+
44
54
  The default post entry uses `index.html` for a playable Canvas2D surface.
45
55
  Play full screen requests the `game` entrypoint, which loads the full game from
46
56
  the separate `game.html` document. Keep heavy imports behind the expanded entry
@@ -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;
@@ -16,6 +16,10 @@ pnpm check
16
16
  pnpm build
17
17
  ```
18
18
 
19
+ ttsc 0.34 compiles its type-transform plugins with Go 1.27 or later. It uses
20
+ the Go it bundles unless `TTSC_GO_BINARY` names another one; if your mise or
21
+ CI configuration pins Go for ttsc, pin 1.27 or later (the Kit uses 1.27.3).
22
+
19
23
  The starter keeps game code behind `PlatformGateway` and demonstrates:
20
24
 
21
25
  - target-aware adapter selection through `APP_TARGET`
@@ -515,8 +519,8 @@ session: Google Play's `obfuscatedAccountId` and StoreKit's `appAccountToken`
515
519
  are derived from that player's id (`src/platform/commerceAccountBinding.ts`),
516
520
  and the backend checks them before it grants. The native shell's local player
517
521
  (`android-local-player` / `ios-local-player`) is the same on every install, so
518
- it binds nothing: Play refuses with `ACCOUNT_BINDING_REQUIRED` and StoreKit
519
- throws an error naming the missing session.
522
+ it binds nothing: both stores refuse the purchase before the sheet opens with
523
+ a `failed` result whose code is `ACCOUNT_BINDING_REQUIRED`.
520
524
 
521
525
  Point the game at the kit's reference guest identity Worker
522
526
  (`apps/game-identity-worker` in mpgd-kit, deployed per
@@ -553,7 +557,32 @@ be claimed. The scenes' `player` and `identitySession` are the boot values
553
557
  for the whole run (the platform's player when the boot sign-in failed) and
554
558
  the server player from the next launch on: key game-owned save data by
555
559
  something that does not change, such as the platform storage itself, not by
556
- `player.playerId`. A native shell only reaches `https:` origins, so test on a
560
+ `player.playerId`.
561
+
562
+ The game services belong to one server player for the run
563
+ (`src/platform/gameServices.ts`). A game that replaces the guest session with
564
+ its own and lets the player switch accounts within a run gets them closed, not
565
+ moved to the new account: from the moment the session names another player,
566
+ nothing is verified, restored, or finished with the new player's credentials
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
+ other's store account, and purchases answer `failed` with the retryable
570
+ diagnostic `GAME_SERVICES_PLAYER_CHANGED` before any store sheet opens.
571
+ `main.ts` stops play at once (`onPlayerChanging`: an execution block and a
572
+ "Switching player..." screen), and once the old services have stopped (an
573
+ open store sheet first records its answer) it reloads the game
574
+ (`restartForAnotherPlayer`), which boots again for the new player; replace the
575
+ reload with your own restart if it does not suit the target. The old player's
576
+ open purchases stay in the journal and recover when that player signs in
577
+ again; store purchases bound to another player's account are left for that
578
+ player instead of being verified with this one's session. Create the services
579
+ only with the server session's player, never the native shell's local
580
+ placeholder: services created for another player than the session's are
581
+ closed at once (and say so in the console), and services for a second player cannot be
582
+ created while the first player's are still open
583
+ (`StarterCommercePinConflictError`).
584
+
585
+ A native shell only reaches `https:` origins, so test on a
557
586
  device or emulator through an https tunnel to a local Worker. Account linking (Sign in with Apple or Google) is not
558
587
  part of the reference Worker yet.
559
588
 
@@ -21,9 +21,9 @@
21
21
  },
22
22
  "devDependencies": {
23
23
  "@apps-in-toss/cli": "3.9.0",
24
- "@ttsc/unplugin": "0.32.1",
24
+ "@ttsc/unplugin": "0.34.2",
25
25
  "@types/node": "^24.0.0",
26
- "ttsc": "0.32.1",
26
+ "ttsc": "0.34.2",
27
27
  "typescript": "7.0.2",
28
28
  "vite": "8.1.3"
29
29
  }
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@cloudflare/workers-types": "^5.20260707.1",
22
- "@ttsc/unplugin": "0.32.1",
23
- "ttsc": "0.32.1",
22
+ "@ttsc/unplugin": "0.34.2",
23
+ "ttsc": "0.34.2",
24
24
  "typescript": "7.0.2",
25
25
  "vite": "8.1.3",
26
26
  "wrangler": "^4.107.0"
@@ -41,6 +41,16 @@ replace the `devvit.json` scheduler endpoint and should not use process-local
41
41
  memory when delivery must cross instances. The generated target exposes only
42
42
  the oRPC bridge route; the former JSON compatibility route is not generated.
43
43
 
44
+ The bridge client (game `@mpgd/adapter-devvit`) and the bridge server
45
+ (`src/server`) must ship in the same app build: they share one `@mpgd/bridge`
46
+ and therefore one oRPC version. oRPC error bodies are not compatible across
47
+ oRPC 2.0.0-beta.34 (`@mpgd/bridge` 0.16.4 and older used 2.0.0-beta.14), so a
48
+ client and server from different kit releases would turn every oRPC error into a
49
+ malformed-response failure. Bridge method errors and request-validation
50
+ failures travel inside the bridge response envelope and are unaffected; only
51
+ internal server errors and oRPC transport errors (413, undecodable bodies) use
52
+ the oRPC error body.
53
+
44
54
  The default post entry uses `index.html` for a fixed, non-scrolling preview.
45
55
  Play full screen requests the `game` entrypoint, which loads Phaser from the
46
56
  separate `game.html` document. Keep preview UI free of game runtime imports so
@@ -27,10 +27,10 @@
27
27
  },
28
28
  "devDependencies": {
29
29
  "@devvit/start": "0.15.0",
30
- "@ttsc/unplugin": "0.32.1",
30
+ "@ttsc/unplugin": "0.34.2",
31
31
  "@types/node": "^24.0.0",
32
32
  "devvit": "0.15.0",
33
- "ttsc": "0.32.1",
33
+ "ttsc": "0.34.2",
34
34
  "typescript": "7.0.2",
35
35
  "vite": "7.3.6"
36
36
  }
@@ -81,9 +81,9 @@
81
81
  "@ait-co/console-cli": "^0.1.49",
82
82
  "@cloudflare/workers-types": "^5.20260707.1",
83
83
  "@mpgd/cli": "__MPGD_DEPENDENCY_VERSION_CLI__",
84
- "@ttsc/unplugin": "0.32.1",
84
+ "@ttsc/unplugin": "0.34.2",
85
85
  "@types/node": "^24.0.0",
86
- "ttsc": "0.32.1",
86
+ "ttsc": "0.34.2",
87
87
  "typescript": "7.0.2",
88
88
  "vite": "8.1.3",
89
89
  "wrangler": "^4.107.0"
@@ -28,6 +28,7 @@ export type GameMessageKey =
28
28
  | 'restoreNeedsSession'
29
29
  | 'restoreNeedsConnection'
30
30
  | 'restoreFailed'
31
+ | 'switchingPlayer'
31
32
  | 'deleteAccount'
32
33
  | 'deleteAccountConfirm'
33
34
  | 'deleteAccountPendingPurchase'
@@ -72,6 +73,7 @@ const messages = {
72
73
  restoreNeedsSession: 'Restoring purchases needs a game server session',
73
74
  restoreNeedsConnection: 'Could not reach the game server. Tap to try again.',
74
75
  restoreFailed: 'Could not restore purchases. Try again later.',
76
+ switchingPlayer: 'Switching player...',
75
77
  deleteAccount: 'Delete account',
76
78
  deleteAccountConfirm:
77
79
  'Delete your account? Your progress, scores, and notification settings are erased and you are signed out. '
@@ -120,6 +122,7 @@ const messages = {
120
122
  restoreNeedsSession: '구매를 복원하려면 게임 서버 세션이 필요합니다',
121
123
  restoreNeedsConnection: '게임 서버에 연결하지 못했습니다. 다시 시도하려면 누르세요.',
122
124
  restoreFailed: '구매를 복원하지 못했습니다. 나중에 다시 시도하세요.',
125
+ switchingPlayer: '플레이어를 전환하는 중...',
123
126
  deleteAccount: '계정 삭제',
124
127
  deleteAccountConfirm:
125
128
  '계정을 삭제할까요? 진행 상황, 점수, 알림 설정이 지워지고 로그아웃됩니다. '
@@ -27,6 +27,9 @@ import { setStarterServerSession } from './platform/serverSession';
27
27
  import { installPlatform } from './platform/installPlatform';
28
28
  import { installMicrosoftStorePwa } from './platform/microsoftStorePwa';
29
29
 
30
+ /** Set by the first restart for another player (before bootstrap, which may call it). */
31
+ let restartingForAnotherPlayer = false;
32
+
30
33
  await bootstrap();
31
34
 
32
35
  /** Install target services, analytics, and viewport state before starting the game. */
@@ -150,6 +153,17 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
150
153
  ...(options.monetization === undefined ? {} : { operationStore: options.monetization.operationStore }),
151
154
  analytics: analyticsSink,
152
155
  analyticsSessionId,
156
+ // The services are one player's for the run: another player signing in closes them
157
+ // (purchases fail closed, retryably). Play stops at once; the game boots again for
158
+ // that player once the old services have stopped.
159
+ onPlayerChanging: () => {
160
+ gameRuntime?.execution.acquireBlock({
161
+ reason: 'game-services:player-change',
162
+ channels: ['simulation', 'gameplay-input', 'audio'],
163
+ });
164
+ renderPlayerSwitch(locale);
165
+ },
166
+ onPlayerChanged: restartForAnotherPlayer,
153
167
  };
154
168
  // Configured but not signed in yet: the services wait for the server player, never
155
169
  // binding purchases or the journal to the platform's local placeholder.
@@ -216,6 +230,34 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
216
230
  }
217
231
  }
218
232
 
233
+ /**
234
+ * Another player signed in to the server session within the run (a game's own
235
+ * account switch; the reference guest never changes player). The game
236
+ * services already closed for the boot player; the scenes, the action
237
+ * coordinator, and the notifications still hold it, so the game boots again
238
+ * and builds them all for the new player. Replace the reload with your own
239
+ * restart (back to the title, then `bootstrap()`) if a reload does not suit
240
+ * the target.
241
+ */
242
+ function restartForAnotherPlayer(): void {
243
+ if (!restartingForAnotherPlayer) {
244
+ restartingForAnotherPlayer = true;
245
+ globalThis.location?.reload();
246
+ }
247
+ }
248
+
249
+ /** Cover the game while it restarts for another player (taps never reach it meanwhile). */
250
+ function renderPlayerSwitch(locale: Locale): void {
251
+ if (document.querySelector('.player-switch') !== null) {
252
+ return;
253
+ }
254
+ const overlay = document.createElement('div');
255
+ overlay.className = 'player-switch';
256
+ overlay.setAttribute('role', 'status');
257
+ overlay.textContent = t(locale, 'switchingPlayer');
258
+ document.body.append(overlay);
259
+ }
260
+
219
261
  /** Resolve a verified platform session or fall back to a local guest. */
220
262
  async function resolveIdentitySession(
221
263
  platform: PlatformGateway,
@@ -51,6 +51,11 @@ function createStoreProviders(
51
51
  return [
52
52
  createCapacitorPlayBillingServiceProvider({
53
53
  products,
54
+ // Sales switch (none by default): to refuse NEW purchases without
55
+ // touching recovery, add `purchaseGate: async () => ({ allowed })`,
56
+ // `allowed` read from the game's own server flag. A refusal answers
57
+ // ITEM_UNAVAILABLE / PURCHASES_PAUSED before the store opens; paid
58
+ // purchases keep reconciling. See the adapter-capacitor README.
54
59
  // The backend's resolveObfuscatedAccountId must derive the same value; see commerceAccountBinding.ts.
55
60
  accountBinding: async () => {
56
61
  const gateway = resolveGateway();
@@ -48,6 +48,11 @@ function createStoreProviders(
48
48
  return [
49
49
  createCapacitorStoreKitServiceProvider({
50
50
  products,
51
+ // Sales switch (none by default): to refuse NEW purchases without
52
+ // touching recovery, add `purchaseGate: async () => ({ allowed })`,
53
+ // `allowed` read from the game's own server flag. A refusal answers
54
+ // ITEM_UNAVAILABLE / PURCHASES_PAUSED before the store opens; paid
55
+ // purchases keep reconciling. See the adapter-capacitor README.
51
56
  // The backend's resolveAppAccountToken must derive the same value; see commerceAccountBinding.ts.
52
57
  getAppAccountToken: async () => {
53
58
  const gateway = resolveGateway();
@@ -30,6 +30,37 @@ export function setStarterCommerceServerPlayer(source: (() => string | undefined
30
30
  serverPlayer = source;
31
31
  }
32
32
 
33
+ /** The open pins: one per game services instance, all for the same player. */
34
+ const pins = new Set<{ readonly playerId: string }>();
35
+
36
+ /** Another player's game services are still open; dispose them before creating these. */
37
+ export class StarterCommercePinConflictError extends Error {
38
+ constructor() {
39
+ super("Another player's game services are still open; dispose them before creating these.");
40
+ this.name = 'StarterCommercePinConflictError';
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Pins purchases to the player a game services instance was created for
46
+ * (`createStarterGameServices` does, and releases it once those services
47
+ * were disposed): while the session names anyone else, a purchase binds
48
+ * nothing and is refused before any store sheet, so no purchase is
49
+ * journaled under one player and bound to another's store account. Returns
50
+ * the release (idempotent). Throws `StarterCommercePinConflictError` while
51
+ * an earlier instance for another player is still open.
52
+ */
53
+ export function pinStarterCommercePlayer(playerId: string): () => void {
54
+ if ([...pins].some((pin) => pin.playerId !== playerId)) {
55
+ throw new StarterCommercePinConflictError();
56
+ }
57
+ const pin = { playerId };
58
+ pins.add(pin);
59
+ return () => {
60
+ pins.delete(pin);
61
+ };
62
+ }
63
+
33
64
  /**
34
65
  * The player id a purchase is bound to: the server session's player when
35
66
  * the game set one (`setStarterCommerceServerPlayer`), else an
@@ -37,10 +68,20 @@ export function setStarterCommerceServerPlayer(source: (() => string | undefined
37
68
  * platform-anonymous session (the native shell's `android-local-player` /
38
69
  * `ios-local-player`) or a locally asserted id would give every install the
39
70
  * same binding, so it binds nothing; the backend binds the purchase to the
40
- * player its ingress authentication resolved anyway.
71
+ * player its ingress authentication resolved anyway. While game services
72
+ * are pinned to a player (`pinStarterCommercePlayer`), any other player
73
+ * binds nothing too.
41
74
  */
42
75
  export async function resolveStarterCommercePlayerId(
43
76
  gateway: Pick<PlatformGateway, 'identity'>,
77
+ ): Promise<string | undefined> {
78
+ const playerId = await resolveSessionPlayerId(gateway);
79
+ // Another player than the services' (an account switch): bind nothing.
80
+ return [...pins].some((pin) => pin.playerId !== playerId) ? undefined : playerId;
81
+ }
82
+
83
+ async function resolveSessionPlayerId(
84
+ gateway: Pick<PlatformGateway, 'identity'>,
44
85
  ): Promise<string | undefined> {
45
86
  if (serverPlayer !== undefined) {
46
87
  const playerId = serverPlayer()?.trim();
@@ -89,6 +130,45 @@ export async function resolveStoreKitAppAccountToken(
89
130
  return toUuid(await digestPlayerId(playerId));
90
131
  }
91
132
 
133
+ /**
134
+ * The store account binding the native providers above give `playerId`'s
135
+ * purchases on `target` (`android`: Google Play's obfuscated account id,
136
+ * `ios`: StoreKit's app account token), derived from the player id alone;
137
+ * `undefined` on other targets. The game services runtime compares store
138
+ * purchases with it (`commerceAccountBinding`) so another player's purchase
139
+ * is never verified for this one.
140
+ */
141
+ export async function resolveStarterStoreAccountBindingFor(
142
+ target: string,
143
+ playerId: string,
144
+ ): Promise<StorePurchaseAccountBinding | undefined> {
145
+ const kind = starterStoreAccountBindingKind(target);
146
+ if (kind === undefined) {
147
+ return undefined;
148
+ }
149
+ const digest = await digestPlayerId(playerId);
150
+ return kind === 'obfuscated-account-id'
151
+ ? { kind, value: toHex(digest) }
152
+ : { kind, value: toUuid(digest) };
153
+ }
154
+
155
+ /**
156
+ * The kind of store account binding this module derives for `target`'s
157
+ * purchases, or `undefined` where it binds none (every target but the
158
+ * native stores: Microsoft Store purchases, for one, carry their own
159
+ * claimed scope). Pass `commerceAccountBinding` only where this answers a
160
+ * kind: a runtime given a binding source that never answers would leave every
161
+ * bound purchase alone.
162
+ */
163
+ export function starterStoreAccountBindingKind(
164
+ target: string,
165
+ ): 'obfuscated-account-id' | 'app-account-token' | undefined {
166
+ if (target === 'android') {
167
+ return 'obfuscated-account-id';
168
+ }
169
+ return target === 'ios' ? 'app-account-token' : undefined;
170
+ }
171
+
92
172
  async function digestPlayerId(playerId: string): Promise<Uint8Array> {
93
173
  const bytes = new TextEncoder().encode(`${starterAccountBindingDigestPrefix}${playerId}`);
94
174
  return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
@@ -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;