@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.
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +104 -82
- package/dist/production-target-readiness.js +9 -2
- package/package.json +20 -20
- package/templates/lite-overlay/README.md +4 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +10 -0
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +5 -1
- 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/deleteAccount.ts +6 -1
- package/templates/phaser-game/src/platform/gameServices.ts +94 -7
- package/templates/phaser-game/src/platform/guestIdentity.ts +4 -3
- package/templates/phaser-game/src/platform/monetizationJournal.ts +127 -5
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +149 -21
- 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.
|
|
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": "
|
|
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/
|
|
75
|
-
"@mpgd/
|
|
76
|
-
"@mpgd/
|
|
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.
|
|
82
|
+
"ttsc": "0.34.2",
|
|
83
83
|
"typescript": "7.0.2",
|
|
84
|
-
"@mpgd/adapter-ait": "0.21.
|
|
85
|
-
"@mpgd/adapter-
|
|
86
|
-
"@mpgd/adapter-
|
|
87
|
-
"@mpgd/adapter-devvit": "0.14.
|
|
88
|
-
"@mpgd/adapter-verse8": "0.7.
|
|
89
|
-
"@mpgd/bridge": "0.
|
|
90
|
-
"@mpgd/
|
|
91
|
-
"@mpgd/
|
|
92
|
-
"@mpgd/capacitor-
|
|
93
|
-
"@mpgd/
|
|
94
|
-
"@mpgd/game-services": "0.
|
|
95
|
-
"@mpgd/
|
|
96
|
-
"@mpgd/
|
|
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
|
|
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:
|
|
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 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
|
|
|
@@ -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));
|
|
@@ -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'
|
|
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;
|