@mpgd/cli 0.56.0 → 0.58.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 (49) hide show
  1. package/dist/capacitor-shell-starter.d.ts +30 -0
  2. package/dist/capacitor-shell-starter.js +243 -11
  3. package/dist/index.js +170 -11
  4. package/dist/legal-site-output.d.ts +18 -0
  5. package/dist/legal-site-output.js +46 -1
  6. package/dist/native-build-info.json +2 -2
  7. package/dist/native-build-target.js +3935 -1308
  8. package/dist/native-deploy-run.js +4 -0
  9. package/dist/native-shell-store-policy.d.ts +123 -0
  10. package/dist/native-shell-store-policy.js +816 -0
  11. package/dist/notifications-starter.js +26 -3
  12. package/dist/release-state.d.ts +2 -0
  13. package/dist/release-state.js +4 -2
  14. package/dist/testflight-submission.d.ts +9 -0
  15. package/dist/testflight-submission.js +69 -0
  16. package/package.json +16 -16
  17. package/templates/lite-overlay/agent/acceptance.md +1 -1
  18. package/templates/lite-overlay/agent/brief.md +1 -1
  19. package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
  20. package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
  21. package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
  22. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +28 -6
  23. package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
  24. package/templates/notifications-overlay/common/notifications/topics.json +3 -0
  25. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
  26. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +111 -2
  27. package/templates/notifications-overlay/common/src/platform/notifications.ts +36 -4
  28. package/templates/phaser-game/README.md +72 -0
  29. package/templates/phaser-game/agent/acceptance.md +4 -1
  30. package/templates/phaser-game/agent/brief.md +14 -4
  31. package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +37 -0
  32. package/templates/phaser-game/gitignore +11 -0
  33. package/templates/phaser-game/legal/delete-account.html +133 -0
  34. package/templates/phaser-game/legal/privacy.html +111 -12
  35. package/templates/phaser-game/legal/support.html +1 -0
  36. package/templates/phaser-game/legal/terms.html +1 -0
  37. package/templates/phaser-game/mpgd.game.json +3 -0
  38. package/templates/phaser-game/src/env.d.ts +6 -0
  39. package/templates/phaser-game/src/i18n/messages.ts +86 -1
  40. package/templates/phaser-game/src/main.ts +69 -8
  41. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
  42. package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
  43. package/templates/phaser-game/src/platform/gameServices.ts +55 -10
  44. package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
  45. package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
  46. package/templates/phaser-game/src/platform/serverSession.ts +8 -0
  47. package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
  48. package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
  49. package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
@@ -48,30 +48,129 @@
48
48
  <a href="/privacy/" aria-current="page">Privacy</a>
49
49
  <a href="/support/">Support</a>
50
50
  <a href="/terms/">Terms</a>
51
+ <a href="/delete-account/">Delete account</a>
51
52
  </nav>
52
53
  <h1>__GAME_TITLE__ Privacy Policy</h1>
53
54
  <p>Last updated: __LEGAL_LAST_UPDATED__</p>
54
55
  <p>
55
- __GAME_TITLE__ collects only the information needed to run the game,
56
- keep player progress, operate platform features, and respond to support
57
- requests.
56
+ This policy explains what __GAME_TITLE__ (the "game") and its operator,
57
+ [REPLACE: legal name of the developer or company] ("we"), collect, why,
58
+ how long we keep it, and how to have it deleted.
58
59
  </p>
60
+ <!--
61
+ Starting point, not legal advice. Before publishing, replace every
62
+ REPLACE marker in square brackets (mpgd legal build warns while any remain) and
63
+ delete what the game does not do or use.
64
+ -->
59
65
  <h2>Information We Process</h2>
66
+ <ul>
67
+ <li>
68
+ <strong>Player identifiers.</strong> A random guest player ID issued by
69
+ our game server, a random ID for this installation of the game, and,
70
+ when you sign in, the account ID of the sign-in provider. Store purchases are bound to a one-way hash of your signed-in player ID.
71
+ </li>
72
+ <li>
73
+ <strong>Game progress.</strong> Saves, settings, scores, and the
74
+ leaderboard entries you submit.
75
+ </li>
76
+ <li>
77
+ <strong>Purchases.</strong> The products you buy and the store's
78
+ transaction records (Google Play order IDs and a one-way hash of the
79
+ purchase token, which our server sends to Google to verify the
80
+ purchase; App Store transaction and original transaction IDs), kept in
81
+ our purchase ledger so a purchase is granted once and can be
82
+ restored. We never receive your payment card details.
83
+ </li>
84
+ <li>
85
+ <strong>Rewarded ads.</strong> Records of ads you chose to watch for a
86
+ reward, so the reward is granted once.
87
+ </li>
88
+ <li>
89
+ <strong>Notifications.</strong> If you allow notifications, a push
90
+ token for your device and your notification choices, including any
91
+ consent to advertising messages and when you gave it.
92
+ </li>
93
+ <li>
94
+ <strong>Technical data.</strong> App version, platform, coarse device
95
+ type, and error details needed to operate and fix the game.
96
+ </li>
97
+ <li><strong>Support.</strong> Messages you send us.</li>
98
+ </ul>
99
+ <h2>How We Use Information</h2>
60
100
  <p>
61
- The game may process platform player identifiers, gameplay progress,
62
- leaderboard scores, purchase or reward validation records, coarse
63
- device/runtime information, and support messages you choose to send.
101
+ To run the game and its online features, keep your progress and
102
+ purchases, grant purchases and rewards exactly once, prevent fraud and
103
+ cheating, send the notifications you asked for, answer support requests,
104
+ and meet store and legal requirements. We do not sell your personal
105
+ information. [REPLACE: keep "We do not track you across other
106
+ companies' apps or websites." only if the game shows no personalized
107
+ ads and uses no other tracking; otherwise describe the tracking and how
108
+ to refuse it.]
64
109
  </p>
65
- <h2>How We Use Information</h2>
110
+ <h2>Third-Party Services</h2>
111
+ <!-- Keep only the services the game uses. -->
112
+ <p>
113
+ These third-party services are involved in running the game; some act on
114
+ our behalf and some under their own privacy policies.
115
+ </p>
116
+ <ul>
117
+ <li>
118
+ Google Play Billing and the Google Play Developer API (Google) or Apple
119
+ StoreKit and the App Store Server API (Apple): purchases and their
120
+ verification.
121
+ </li>
122
+ <li>
123
+ Firebase Cloud Messaging (Google): delivering push notifications.
124
+ </li>
125
+ <li>
126
+ Google AdMob and the User Messaging Platform (Google): showing ads and
127
+ asking for consent where required. AdMob may use the device
128
+ advertising identifier where the platform and your choices allow it.
129
+ </li>
130
+ <li>
131
+ Cloudflare: hosting our game server, database, and these pages.
132
+ </li>
133
+ <li>[REPLACE: any other analytics, crash-reporting, or sign-in service.]</li>
134
+ </ul>
135
+ <h2>Retention</h2>
136
+ <p>
137
+ We keep account, progress, and notification data while your account is
138
+ active and delete it when you delete your account [REPLACE: and/or
139
+ after N months of inactivity]. We keep purchase records (product,
140
+ store order or transaction number, and date) for 5 years from the
141
+ purchase or its last refund, whichever is later, for payment and supply
142
+ record obligations
143
+ [REPLACE: the laws that apply to you, for example Korea's Act on
144
+ Consumer Protection in Electronic Commerce], also after the rest of
145
+ your data is deleted, stored separately and without your player ID. Server logs are kept for
146
+ [REPLACE: period].
147
+ </p>
148
+ <h2 id="delete-account">Deleting Your Account and Data</h2>
149
+ <p>
150
+ You can delete your account and the data linked to it in the game
151
+ ([REPLACE: menu path, for example the lobby's Delete account button])
152
+ or, without reinstalling the game, by following the steps on our
153
+ <a href="/delete-account/">account deletion page</a>. The deletion
154
+ page lists what is deleted and what we must keep (see Retention).
155
+ </p>
156
+ <h2>Your Choices</h2>
157
+ <p>
158
+ You can turn notifications off in the device settings [REPLACE: and in
159
+ the game's settings, if it has a notification settings screen], change
160
+ your ad privacy choices [REPLACE: from the game's privacy options, if it
161
+ shows ads with a consent form], and ask us for a copy or correction of
162
+ your data. [REPLACE: rights and contact points required where you
163
+ operate, for example GDPR, UK GDPR, CCPA, or Korea's PIPA.]
164
+ </p>
165
+ <h2>Children</h2>
66
166
  <p>
67
- We use this information to provide gameplay features, prevent duplicate
68
- grants, maintain leaderboards, troubleshoot issues, and satisfy platform
69
- review or safety requirements.
167
+ [REPLACE: who the game is for. If it is directed at children, describe
168
+ the stricter handling the store programs require.]
70
169
  </p>
71
170
  <h2>Contact</h2>
72
171
  <p>
73
- Replace this template before release with the game owner's support
74
- contact and jurisdiction-specific privacy disclosures.
172
+ [REPLACE: support email or form, postal address, and data protection
173
+ contact if required.]
75
174
  </p>
76
175
  </main>
77
176
  </body>
@@ -48,6 +48,7 @@
48
48
  <a href="/privacy/">Privacy</a>
49
49
  <a href="/support/" aria-current="page">Support</a>
50
50
  <a href="/terms/">Terms</a>
51
+ <a href="/delete-account/">Delete account</a>
51
52
  </nav>
52
53
  <h1>__GAME_TITLE__ Support</h1>
53
54
  <p>
@@ -48,6 +48,7 @@
48
48
  <a href="/privacy/">Privacy</a>
49
49
  <a href="/support/">Support</a>
50
50
  <a href="/terms/" aria-current="page">Terms</a>
51
+ <a href="/delete-account/">Delete account</a>
51
52
  </nav>
52
53
  <h1>__GAME_TITLE__ Terms</h1>
53
54
  <p>Last updated: __LEGAL_LAST_UPDATED__</p>
@@ -8,6 +8,9 @@
8
8
  "ui": {
9
9
  "textSelection": "disabled"
10
10
  },
11
+ "native": {
12
+ "orientation": "landscape"
13
+ },
11
14
  "acceptance": {
12
15
  "gameplay": {
13
16
  "schemaVersion": 1,
@@ -19,6 +19,12 @@ interface ImportMetaEnv {
19
19
  readonly VITE_MPGD_GAME_SERVICES_URL?: string;
20
20
  readonly VITE_MPGD_GAME_SERVICES_TARGET?: string;
21
21
  readonly VITE_MPGD_GAME_SERVICES_TRANSPORT?: string;
22
+ /**
23
+ * The guest identity service (the kit's reference Worker,
24
+ * `apps/game-identity-worker`): an https URL, or http on a local
25
+ * development host. See `src/platform/guestIdentity.ts`.
26
+ */
27
+ readonly VITE_MPGD_IDENTITY_URL?: string;
22
28
  /**
23
29
  * Local development only: `<playerId>` or `<playerId>.<installationId>`
24
30
  * for an unauthenticated notifications session (see `serverSession.ts`).
@@ -16,7 +16,32 @@ export type GameMessageKey =
16
16
  | 'rewardPending'
17
17
  | 'rewardUnavailable'
18
18
  | 'analytics'
19
- | 'bootError';
19
+ | 'bootError'
20
+ | 'restorePurchases'
21
+ | 'restoreWorking'
22
+ | 'restoreDone'
23
+ | 'restoreToDevice'
24
+ | 'restoreHolderLimit'
25
+ | 'restoreNothing'
26
+ | 'restoreIncomplete'
27
+ | 'restorePartial'
28
+ | 'restoreNeedsSession'
29
+ | 'restoreNeedsConnection'
30
+ | 'restoreFailed'
31
+ | 'deleteAccount'
32
+ | 'deleteAccountConfirm'
33
+ | 'deleteAccountPendingPurchase'
34
+ | 'deleteAccountConfirmButton'
35
+ | 'deleteAccountCancel'
36
+ | 'deleteAccountClose'
37
+ | 'deleteAccountWorking'
38
+ | 'deleteAccountDone'
39
+ | 'deleteAccountDoneLocalFailed'
40
+ | 'deleteAccountSessionEnded'
41
+ | 'deleteAccountNeedsSession'
42
+ | 'deleteAccountUnavailable'
43
+ | 'deleteAccountFailed'
44
+ | 'deleteAccountRejected';
20
45
 
21
46
  const messages = {
22
47
  en: {
@@ -36,6 +61,36 @@ const messages = {
36
61
  rewardUnavailable: 'Rewarded ads unavailable on this target',
37
62
  analytics: 'Analytics events: {count}',
38
63
  bootError: 'Failed to start game',
64
+ restorePurchases: 'Restore purchases',
65
+ restoreWorking: 'Restoring purchases...',
66
+ restoreDone: 'Purchases restored ({count} confirmed)',
67
+ restoreToDevice: 'Restored to this device ({count} confirmed)',
68
+ restoreHolderLimit: 'This purchase is already in use on the maximum number of devices',
69
+ restoreNothing: 'No new purchases to restore',
70
+ restoreIncomplete: 'Some purchases are not confirmed yet. Try again later.',
71
+ restorePartial: 'Purchases restored ({count}); others are not confirmed yet. Try again later.',
72
+ restoreNeedsSession: 'Restoring purchases needs a game server session',
73
+ restoreNeedsConnection: 'Could not reach the game server. Tap to try again.',
74
+ restoreFailed: 'Could not restore purchases. Try again later.',
75
+ deleteAccount: 'Delete account',
76
+ deleteAccountConfirm:
77
+ 'Delete your account? Your progress, scores, and notification settings are erased and you are signed out. '
78
+ + 'Purchases are not refunded; ask the store you bought from for a refund. Items you bought once (such as Remove ads) can be restored to a new account with Restore purchases while the store account still owns them. '
79
+ + 'Purchase records are kept for 5 years from the purchase or its last refund, for payment and supply record obligations, without your player ID.',
80
+ deleteAccountPendingPurchase:
81
+ 'A purchase is still being processed. Deleting now loses it: Google Play refunds it, but for an App Store purchase ask Apple for the refund.',
82
+ deleteAccountConfirmButton: 'Delete permanently',
83
+ deleteAccountCancel: 'Cancel',
84
+ deleteAccountClose: 'Close',
85
+ deleteAccountWorking: 'Deleting your account...',
86
+ deleteAccountDone: 'Your account was deleted and you are signed out.',
87
+ deleteAccountDoneLocalFailed:
88
+ 'Your account was deleted, but this device could not forget its sign-in. Clear the app data or reinstall the app.',
89
+ deleteAccountSessionEnded: 'Your session has ended: your account deletion most likely completed.',
90
+ deleteAccountNeedsSession: 'Sign in to delete your account.',
91
+ deleteAccountUnavailable: 'Account deletion is not available here. Use the deletion page linked from the privacy policy.',
92
+ deleteAccountFailed: 'Could not delete your account. Try again.',
93
+ deleteAccountRejected: 'Could not delete your account. Contact support.',
39
94
  },
40
95
  ko: {
41
96
  title: '__GAME_TITLE__',
@@ -54,6 +109,36 @@ const messages = {
54
109
  rewardUnavailable: '이 타깃에서는 리워드 광고를 사용할 수 없음',
55
110
  analytics: '애널리틱스 이벤트: {count}',
56
111
  bootError: '게임 시작 실패',
112
+ restorePurchases: '구매 복원',
113
+ restoreWorking: '구매 복원 중...',
114
+ restoreDone: '구매 복원 완료 ({count}건 확인)',
115
+ restoreToDevice: '이 기기로 구매를 복원했습니다 ({count}건 확인)',
116
+ restoreHolderLimit: '이 구매는 이미 허용된 최대 기기 수에서 사용 중입니다',
117
+ restoreNothing: '새로 복원할 구매가 없습니다',
118
+ restoreIncomplete: '아직 확인되지 않은 구매가 있습니다. 나중에 다시 시도하세요.',
119
+ restorePartial: '{count}건 복원, 아직 확인되지 않은 구매가 있습니다. 나중에 다시 시도하세요.',
120
+ restoreNeedsSession: '구매를 복원하려면 게임 서버 세션이 필요합니다',
121
+ restoreNeedsConnection: '게임 서버에 연결하지 못했습니다. 다시 시도하려면 누르세요.',
122
+ restoreFailed: '구매를 복원하지 못했습니다. 나중에 다시 시도하세요.',
123
+ deleteAccount: '계정 삭제',
124
+ deleteAccountConfirm:
125
+ '계정을 삭제할까요? 진행 상황, 점수, 알림 설정이 지워지고 로그아웃됩니다. '
126
+ + '구매는 환불되지 않습니다. 환불은 구매한 스토어에 요청하세요. 한 번 구매한 항목(광고 제거 등)은 스토어 계정이 계속 보유하고 있으면 구매 복원으로 새 계정에서 다시 받을 수 있습니다. '
127
+ + '구매 기록은 거래기록 보존 의무에 따라 구매 또는 마지막 환불 시점부터 5년 동안 플레이어 ID 없이 보관됩니다.',
128
+ deleteAccountPendingPurchase:
129
+ '아직 처리 중인 구매가 있습니다. 지금 삭제하면 이 구매는 지급되지 않습니다. Google Play는 자동으로 환불하지만, App Store 구매는 Apple에 환불을 요청해야 합니다.',
130
+ deleteAccountConfirmButton: '영구 삭제',
131
+ deleteAccountCancel: '취소',
132
+ deleteAccountClose: '닫기',
133
+ deleteAccountWorking: '계정을 삭제하는 중...',
134
+ deleteAccountDone: '계정이 삭제되었고 로그아웃되었습니다.',
135
+ deleteAccountDoneLocalFailed:
136
+ '계정은 삭제되었지만 이 기기의 로그인 정보를 지우지 못했습니다. 앱 데이터를 지우거나 앱을 다시 설치하세요.',
137
+ deleteAccountSessionEnded: '세션이 종료되었습니다. 계정 삭제가 완료되었을 가능성이 높습니다.',
138
+ deleteAccountNeedsSession: '계정을 삭제하려면 로그인하세요.',
139
+ deleteAccountUnavailable: '여기서는 계정을 삭제할 수 없습니다. 개인정보처리방침에 연결된 삭제 페이지를 이용하세요.',
140
+ deleteAccountFailed: '계정을 삭제하지 못했습니다. 다시 시도하세요.',
141
+ deleteAccountRejected: '계정을 삭제하지 못했습니다. 고객 지원에 문의하세요.',
57
142
  },
58
143
  } satisfies Record<Locale, Record<GameMessageKey, string>>;
59
144
 
@@ -17,7 +17,13 @@ import { t } from './i18n/messages';
17
17
  import { createClientId } from './runtime/id';
18
18
  import { createStarterGame } from './runtime/createGame';
19
19
  import { detectRuntime } from './platform/runtimeDetector';
20
- import { createStarterGameServices, type StarterMonetizationPorts } from './platform/gameServices';
20
+ import {
21
+ createStarterGameServices,
22
+ createStarterSessionGatedGameServices,
23
+ type StarterMonetizationPorts,
24
+ } from './platform/gameServices';
25
+ import { createStarterGuestSignIn } from './platform/guestIdentity';
26
+ import { setStarterServerSession } from './platform/serverSession';
21
27
  import { installPlatform } from './platform/installPlatform';
22
28
  import { installMicrosoftStorePwa } from './platform/microsoftStorePwa';
23
29
 
@@ -28,6 +34,7 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
28
34
  let gameRuntime: GamePlatformRuntime | undefined;
29
35
  let locale: Locale = 'en';
30
36
  let disposeMicrosoftStorePwa: (() => void) | undefined;
37
+ let disposeGuestSignIn: (() => void) | undefined;
31
38
 
32
39
  try {
33
40
  if (__APP_TARGET__ === 'ait') {
@@ -53,15 +60,45 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
53
60
  runtime: runtime.config.runtime,
54
61
  orientationPolicy,
55
62
  });
56
- const player =
63
+ const platformPlayer =
57
64
  (await platform.identity.getPlayer()) ?? {
58
65
  playerId: 'local-player',
59
66
  displayName: 'Local Player',
60
67
  };
61
- const [identitySession, launchIntent] = await Promise.all([
62
- resolveIdentitySession(platform, player.playerId),
68
+ // The server-issued guest (VITE_MPGD_IDENTITY_URL): tried at boot, and when that fails,
69
+ // again in the background and before any purchase or restore (`guestIdentity.ts`).
70
+ const guestSignIn = createStarterGuestSignIn({
71
+ identityUrl: import.meta.env.VITE_MPGD_IDENTITY_URL,
72
+ credentials: platform.secureCredentials,
73
+ });
74
+ disposeGuestSignIn = () => guestSignIn.dispose();
75
+ const [bootSignIn, platformSession, launchIntent] = await Promise.all([
76
+ guestSignIn.ensure(),
77
+ resolveIdentitySession(platform, platformPlayer.playerId),
63
78
  resolveLaunchIntent(platform),
64
79
  ]);
80
+ // A background retry may have won while the other boot reads were pending.
81
+ const guestIdentity = guestSignIn.current() ?? bootSignIn;
82
+ // Before the game services read the session: at once, or when a later attempt succeeds
83
+ // (notifications then come up on their next open; the session-gated services read it).
84
+ if (guestIdentity !== undefined) {
85
+ setStarterServerSession(guestIdentity.serverSession);
86
+ } else {
87
+ guestSignIn.onSignedIn((later) => setStarterServerSession(later.serverSession));
88
+ }
89
+ // Signed in at boot, the server player replaces the platform's (on the native shell a local
90
+ // placeholder) everywhere. Signed in later, the scenes keep this run's boot player (see
91
+ // README, "Guest identity"); only the purchase and notification services use the server player.
92
+ const player = guestIdentity !== undefined
93
+ ? { ...platformPlayer, playerId: guestIdentity.playerId }
94
+ : platformPlayer;
95
+ const identitySession: IdentitySession = guestIdentity !== undefined
96
+ ? {
97
+ identityLevel: guestIdentity.identityLevel,
98
+ playerId: guestIdentity.playerId,
99
+ trustLevel: 'server-verified',
100
+ }
101
+ : platformSession;
65
102
  locale = resolveTargetMpgdLocale({
66
103
  capabilities: runtime.capabilities,
67
104
  ...(platform.gameSettings?.getLocale === undefined ? {} : { preferredLocales: [platform.gameSettings.getLocale()] }),
@@ -76,10 +113,16 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
76
113
  sessionId: analyticsSessionId,
77
114
  sink: analyticsSink,
78
115
  });
116
+ const sessionGated = guestIdentity === undefined && guestSignIn.configured;
79
117
  const ownedRuntime = createGamePlatformRuntime({
80
118
  gateway: platform,
81
119
  initialLifecycleState: document.visibilityState === 'hidden' ? 'inactive' : 'active',
82
120
  canShow: (placement) => {
121
+ // A run that booted before its sign-in shows no rewarded ad: the runtime's late
122
+ // reward evidence recovery is bound at boot, which had no recovery client.
123
+ if (placement.format === 'rewarded' && sessionGated) {
124
+ return false;
125
+ }
83
126
  const enabled = placement.format === 'rewarded'
84
127
  ? runtime.config.features.rewardedAds
85
128
  : runtime.config.features.interstitialAds;
@@ -100,15 +143,32 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
100
143
  onObserverError: (error) => {
101
144
  console.error('[game-runtime]', error);
102
145
  },
103
- createServices: (gateway) =>
104
- createStarterGameServices({
146
+ createServices: (gateway) => {
147
+ const servicesInput = {
105
148
  gateway,
106
- playerId: identitySession.playerId ?? player.playerId,
107
149
  configTarget: runtime.configTarget,
108
150
  ...(options.monetization === undefined ? {} : { operationStore: options.monetization.operationStore }),
109
151
  analytics: analyticsSink,
110
152
  analyticsSessionId,
111
- }),
153
+ };
154
+ // Configured but not signed in yet: the services wait for the server player, never
155
+ // binding purchases or the journal to the platform's local placeholder.
156
+ return sessionGated
157
+ ? createStarterSessionGatedGameServices({
158
+ ...servicesInput,
159
+ signIn: guestSignIn,
160
+ // The startup recovery the boot ran without a player, with the action coordinator's.
161
+ onActivated: () => {
162
+ void gameRuntime?.reconcile().catch((error: unknown) => {
163
+ console.error('[game-services] recovery after sign-in failed', error);
164
+ });
165
+ },
166
+ })
167
+ : createStarterGameServices({
168
+ ...servicesInput,
169
+ playerId: identitySession.playerId ?? player.playerId,
170
+ });
171
+ },
112
172
  });
113
173
  gameRuntime = ownedRuntime;
114
174
  const gameServices = ownedRuntime.services;
@@ -149,6 +209,7 @@ async function bootstrap(options: { readonly monetization?: StarterMonetizationP
149
209
  });
150
210
  } catch (error) {
151
211
  gameRuntime?.dispose();
212
+ disposeGuestSignIn?.();
152
213
  disposeMicrosoftStorePwa?.();
153
214
  renderBootstrapError(error, locale);
154
215
  console.error('[bootstrap]', error);
@@ -8,23 +8,44 @@ import type { StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
8
8
  * (`resolveObfuscatedAccountId` of the Play verifier,
9
9
  * `resolveAppAccountToken` of the App Store verifier), which is how the
10
10
  * backend ties a store purchase to the player before it grants. This module
11
- * derives both from a SHA-256 digest of the authenticated player id; a game
12
- * with its own account scheme replaces these two functions and the backend
11
+ * derives both from a SHA-256 digest of the server player id (the reference
12
+ * identity Worker, apps/game-identity-worker, derives the same); a game with
13
+ * its own account scheme replaces these two functions and the backend
13
14
  * resolvers together. Nothing here grants anything.
14
15
  */
15
16
  export const starterAccountBindingDigestPrefix = 'mpgd-commerce-account:';
16
17
 
18
+ /** The game's server player source; see `setStarterCommerceServerPlayer`. */
19
+ let serverPlayer: (() => string | undefined) | undefined;
20
+
21
+ /**
22
+ * Binds store purchases to the player of the game's server session (the
23
+ * identity service's player, a server-issued guest included) instead of the
24
+ * platform session. A generated game's `guestIdentity.ts` sets it when
25
+ * `VITE_MPGD_IDENTITY_URL` is configured; `undefined` restores the platform
26
+ * session. While it is set, no server player (not signed in yet, or the
27
+ * sign-in failed) binds nothing: the platform's local player never stands in.
28
+ */
29
+ export function setStarterCommerceServerPlayer(source: (() => string | undefined) | undefined): void {
30
+ serverPlayer = source;
31
+ }
32
+
17
33
  /**
18
- * The authenticated player id a purchase is bound to. Only an
19
- * `authenticated` session whose trust is not `local` binds: a guest, a
20
- * platform-anonymous session (the native shell's default
21
- * `android-local-player` / `ios-local-player`), or a locally asserted id
22
- * would give every install the same binding, and the backend binds the
23
- * purchase to its own authenticated player id anyway.
34
+ * The player id a purchase is bound to: the server session's player when
35
+ * the game set one (`setStarterCommerceServerPlayer`), else an
36
+ * `authenticated` platform session whose trust is not `local`. A
37
+ * platform-anonymous session (the native shell's `android-local-player` /
38
+ * `ios-local-player`) or a locally asserted id would give every install the
39
+ * same binding, so it binds nothing; the backend binds the purchase to the
40
+ * player its ingress authentication resolved anyway.
24
41
  */
25
42
  export async function resolveStarterCommercePlayerId(
26
43
  gateway: Pick<PlatformGateway, 'identity'>,
27
44
  ): Promise<string | undefined> {
45
+ if (serverPlayer !== undefined) {
46
+ const playerId = serverPlayer()?.trim();
47
+ return playerId === undefined || playerId.length === 0 ? undefined : playerId;
48
+ }
28
49
  let session: IdentitySession | undefined;
29
50
  try {
30
51
  session = await gateway.identity.getSession?.();
@@ -38,7 +59,11 @@ export async function resolveStarterCommercePlayerId(
38
59
  return playerId === undefined || playerId.length === 0 ? undefined : playerId;
39
60
  }
40
61
 
41
- /** Google Play `obfuscatedAccountId`: 64 hex characters, within Play's 64-character limit. */
62
+ /**
63
+ * Google Play `obfuscatedAccountId`: 64 hex characters, within Play's
64
+ * 64-character limit. `undefined` without a player, which the Play
65
+ * provider refuses with `ACCOUNT_BINDING_REQUIRED`.
66
+ */
42
67
  export async function resolvePlayBillingAccountBinding(
43
68
  gateway: Pick<PlatformGateway, 'identity'>,
44
69
  ): Promise<StorePurchaseAccountBinding | undefined> {
@@ -55,7 +80,11 @@ export async function resolveStoreKitAppAccountToken(
55
80
  ): Promise<string> {
56
81
  const playerId = await resolveStarterCommercePlayerId(gateway);
57
82
  if (playerId === undefined) {
58
- throw new Error('A StoreKit purchase requires an authenticated player session.');
83
+ throw new Error(
84
+ 'A StoreKit purchase is bound to the player of a server session, and there is none: '
85
+ + 'sign in to an identity service first (a generated game: VITE_MPGD_IDENTITY_URL and '
86
+ + "src/platform/guestIdentity.ts). The native shell's local player cannot bind a purchase.",
87
+ );
59
88
  }
60
89
  return toUuid(await digestPlayerId(playerId));
61
90
  }