@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/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +92 -80
- package/package.json +19 -19
- package/templates/lite-overlay/README.md +4 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +10 -0
- package/templates/phaser-game/README.md +32 -3
- package/templates/phaser-game/apps/target-ait/package.json +2 -2
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/README.md +10 -0
- package/templates/phaser-game/apps/target-devvit/package.json +2 -2
- package/templates/phaser-game/package.json +2 -2
- package/templates/phaser-game/src/i18n/messages.ts +3 -0
- package/templates/phaser-game/src/main.ts +42 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +5 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +5 -0
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +81 -1
- package/templates/phaser-game/src/platform/gameServices.ts +88 -4
- package/templates/phaser-game/src/platform/guestIdentity.ts +4 -3
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +149 -21
- package/templates/phaser-game/src/styles.css +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "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": "
|
|
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.
|
|
74
|
-
"@mpgd/platform": "0.
|
|
75
|
-
"@mpgd/target-config": "0.24.
|
|
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.
|
|
82
|
+
"ttsc": "0.34.2",
|
|
83
83
|
"typescript": "7.0.2",
|
|
84
|
-
"@mpgd/adapter-ait": "0.21.
|
|
85
|
-
"@mpgd/adapter-browser": "0.12.
|
|
86
|
-
"@mpgd/adapter-capacitor": "0.
|
|
87
|
-
"@mpgd/
|
|
88
|
-
"@mpgd/adapter-verse8": "0.7.
|
|
89
|
-
"@mpgd/
|
|
90
|
-
"@mpgd/
|
|
91
|
-
"@mpgd/game-runtime": "0.8.
|
|
92
|
-
"@mpgd/capacitor-
|
|
93
|
-
"@mpgd/capacitor-
|
|
94
|
-
"@mpgd/game-services": "0.
|
|
95
|
-
"@mpgd/
|
|
96
|
-
"@mpgd/
|
|
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:
|
|
519
|
-
|
|
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`.
|
|
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
|
|
|
@@ -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.
|
|
30
|
+
"@ttsc/unplugin": "0.34.2",
|
|
31
31
|
"@types/node": "^24.0.0",
|
|
32
32
|
"devvit": "0.15.0",
|
|
33
|
-
"ttsc": "0.
|
|
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.
|
|
84
|
+
"@ttsc/unplugin": "0.34.2",
|
|
85
85
|
"@types/node": "^24.0.0",
|
|
86
|
-
"ttsc": "0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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: {
|