@mpgd/cli 0.64.0 → 0.65.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.64.0",
3
+ "version": "0.65.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",
73
+ "@mpgd/catalog": "0.14.0",
74
+ "@mpgd/platform": "0.33.0",
75
+ "@mpgd/target-config": "0.24.4",
76
76
  "@mpgd/phaser-assets": "0.9.1"
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.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"
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
@@ -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 rejects those for good as an
568
+ account mismatch), 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));
@@ -13,11 +13,17 @@ import type { PlatformGateway } from '@mpgd/platform';
13
13
  import type { GameActionReconciliationPort } from '@mpgd/game-runtime/actions';
14
14
  import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
15
15
 
16
+ import {
17
+ pinStarterCommercePlayer,
18
+ resolveStarterStoreAccountBindingFor,
19
+ starterStoreAccountBindingKind,
20
+ } from './commerceAccountBinding';
16
21
  import { resolveStarterCommerceProducts } from './commerceProducts';
17
22
  import type { StarterGuestSignIn } from './guestIdentity';
18
23
  import { createStorageMonetizationOperationStore } from './monetizationJournal';
19
24
  import { resolveStarterServerSession } from './serverSession';
20
25
  import {
26
+ bindStarterServicesToPlayer,
21
27
  createSessionGatedGameServices,
22
28
  type StarterSessionGatedServices,
23
29
  } from './sessionGatedServices';
@@ -52,6 +58,20 @@ interface StarterGameServicesInput {
52
58
  readonly operationStore?: MonetizationOperationStore;
53
59
  readonly analytics?: AnalyticsSink;
54
60
  readonly analyticsSessionId?: string;
61
+ /**
62
+ * Called at once when the server session names another player than the
63
+ * services' (an account switch within the run): the services refuse
64
+ * everything from now on; block input or show a switching screen
65
+ * (`main.ts` does both). `onPlayerChanged` follows.
66
+ */
67
+ readonly onPlayerChanging?: (playerId: string) => void;
68
+ /**
69
+ * Called once after `onPlayerChanging`, when the closed services have
70
+ * stopped (an open store sheet first journals its answer, up to
71
+ * `gameServicesDisposeWaitMs`): restart the game so it boots for that
72
+ * player (`main.ts` reloads).
73
+ */
74
+ readonly onPlayerChanged?: (playerId: string) => void;
55
75
  }
56
76
 
57
77
  /**
@@ -60,9 +80,42 @@ interface StarterGameServicesInput {
60
80
  * over the platform storage adapter unless the game supplies its own) and
61
81
  * verified through the backend ledger, with the catalog's kinds and SKUs for
62
82
  * the gateway target resolved without a store read.
83
+ *
84
+ * With a server session (`setStarterServerSession`), the services belong to
85
+ * `playerId` for their whole life and fail closed for anyone else: while the
86
+ * session names no player, purchases and backend requests are refused
87
+ * (retryable); once it names another player (an account switch), the
88
+ * services close for good before that player's headers or store account
89
+ * binding reach this player's journal (purchases answer `failed` with the
90
+ * retryable diagnostic `GAME_SERVICES_PLAYER_CHANGED`), `onPlayerChanging`
91
+ * tells the host at once, and `onPlayerChanged` asks it to boot again for
92
+ * the new player once they have stopped. Pass the
93
+ * session's player as `playerId`, never the platform's local placeholder:
94
+ * services created for another player than the session's stay closed.
63
95
  */
64
96
  export function createStarterGameServices(input: StarterGameServicesInput): StarterGameServices {
65
- return createRuntime(input, resolveStarterServerSession({ once: true }));
97
+ const session = resolveStarterServerSession({ once: true });
98
+ if (session === undefined) {
99
+ return createRuntime(input, session);
100
+ }
101
+ // The store account binding follows the same player as the journal, until
102
+ // these services were disposed; throws while another player's are open.
103
+ const releasePin = pinStarterCommercePlayer(input.playerId);
104
+ let services: GameServicesRuntime;
105
+ try {
106
+ services = createRuntime(input, session);
107
+ } catch (error) {
108
+ releasePin();
109
+ throw error;
110
+ }
111
+ return bindStarterServicesToPlayer({
112
+ services,
113
+ playerId: input.playerId,
114
+ session,
115
+ onDisposed: releasePin,
116
+ ...(input.onPlayerChanging === undefined ? {} : { onPlayerChanging: input.onPlayerChanging }),
117
+ ...(input.onPlayerChanged === undefined ? {} : { onPlayerChanged: input.onPlayerChanged }),
118
+ });
66
119
  }
67
120
 
68
121
  /**
@@ -79,14 +132,30 @@ export function createStarterSessionGatedGameServices(
79
132
  readonly onActivated?: () => void;
80
133
  },
81
134
  ): StarterGameServices {
82
- const { signIn, onActivated, ...rest } = input;
135
+ const { signIn, onActivated, onPlayerChanging, onPlayerChanged, ...rest } = input;
136
+ // The inner services (a session switch) and the gate (a later sign-in) may
137
+ // both see the same change; the host hears each step once.
138
+ const once = (callback: (playerId: string) => void): ((playerId: string) => void) => {
139
+ let called = false;
140
+ return (playerId) => {
141
+ if (!called) {
142
+ called = true;
143
+ callback(playerId);
144
+ }
145
+ };
146
+ };
147
+ const hooks = {
148
+ ...(onPlayerChanging === undefined ? {} : { onPlayerChanging: once(onPlayerChanging) }),
149
+ ...(onPlayerChanged === undefined ? {} : { onPlayerChanged: once(onPlayerChanged) }),
150
+ };
83
151
  return createSessionGatedGameServices({
84
152
  // Configuration only (mode, target, whether a client exists); never sends a request.
85
153
  probe: createRuntime({ ...rest, playerId: 'session-pending' }, undefined),
86
154
  ensurePlayer: async () => (await signIn.ensure())?.playerId,
87
155
  onPlayer: (listener) => signIn.onSignedIn((signedIn) => listener(signedIn.playerId)),
88
- create: (playerId) => createStarterGameServices({ ...rest, playerId }),
156
+ create: (playerId) => createStarterGameServices({ ...rest, playerId, ...hooks }),
89
157
  ...(onActivated === undefined ? {} : { onActivated }),
158
+ ...hooks,
90
159
  });
91
160
  }
92
161
 
@@ -111,6 +180,21 @@ function createRuntime(
111
180
  ? {}
112
181
  : { baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL }),
113
182
  transport: resolveGameServicesTransport(import.meta.env.VITE_MPGD_GAME_SERVICES_TRANSPORT),
114
- ...(session === undefined ? {} : { getHeaders: () => session.getHeaders() }),
183
+ // A store purchase bound to another player's account is left for that player.
184
+ // Only where the template binds purchases: elsewhere (Microsoft Store) nothing is compared.
185
+ ...(starterStoreAccountBindingKind(input.gateway.target) === undefined
186
+ ? {}
187
+ : {
188
+ commerceAccountBinding: () =>
189
+ resolveStarterStoreAccountBindingFor(input.gateway.target, input.playerId),
190
+ }),
191
+ ...(session === undefined
192
+ ? {}
193
+ : {
194
+ getHeaders: () => session.getHeaders(),
195
+ // Requests, the store account binding, and the journal follow this
196
+ // session's player; the services refuse while it is not theirs.
197
+ sessionPlayerId: () => session.current()?.playerId,
198
+ }),
115
199
  });
116
200
  }
@@ -59,9 +59,10 @@ export type StarterGuestIdentity =
59
59
  * place: that session cannot come back, and without a linked account its
60
60
  * player cannot either. Any other failure (offline, an outage) leaves the
61
61
  * stored token alone for the next attempt (`createStarterGuestSignIn`
62
- * retries), and until one succeeds purchases are refused with the reason
63
- * (`ACCOUNT_BINDING_REQUIRED` on Google Play, a StoreKit error naming the
64
- * missing session). One attempt: the game calls it through
62
+ * retries), and until one succeeds purchases are refused before the store
63
+ * opens with a `failed` result (`ACCOUNT_BINDING_REQUIRED`, store code
64
+ * `PLAY_BILLING_ACCOUNT_LOOKUP_FAILED` or `STOREKIT_ACCOUNT_LOOKUP_FAILED`
65
+ * when the lookup throws). One attempt: the game calls it through
65
66
  * `createStarterGuestSignIn`.
66
67
  */
67
68
  export async function startStarterGuestIdentity(input: {