@mpgd/cli 0.65.0 → 0.67.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 +12 -2
- package/dist/native-deploy-workflow.js +9 -2
- package/dist/production-target-readiness.js +9 -2
- package/package.json +18 -18
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +5 -1
- package/templates/phaser-game/README.md +2 -2
- package/templates/phaser-game/src/i18n/messages.ts +9 -0
- package/templates/phaser-game/src/platform/deleteAccount.ts +6 -1
- package/templates/phaser-game/src/platform/gameServices.ts +6 -3
- package/templates/phaser-game/src/platform/monetizationJournal.ts +127 -5
- package/templates/phaser-game/src/scenes/PlayScene.ts +61 -5
|
@@ -2623,6 +2623,13 @@ async function assertAssetPackNativeArtifact(report, artifact) {
|
|
|
2623
2623
|
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
|
|
2624
2624
|
import { isIP } from "node:net";
|
|
2625
2625
|
import path from "node:path";
|
|
2626
|
+
|
|
2627
|
+
// node_modules/.cache/mpgd-native-build/packages/platform/src/routes.js
|
|
2628
|
+
function isHostedGameServicesPath(value) {
|
|
2629
|
+
return value === value.trim() && /^\/api(?:\/[A-Za-z0-9_-]+)*\/?$/u.test(value);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// node_modules/.cache/mpgd-native-build/packages/cli/src/production-target-readiness.js
|
|
2626
2633
|
var wechatStagingAppId = "wx0000000000000000";
|
|
2627
2634
|
var ownerPathByTarget = {
|
|
2628
2635
|
ait: "wrapperApp",
|
|
@@ -2654,7 +2661,7 @@ function assertProductionTargetReadiness(input) {
|
|
|
2654
2661
|
}
|
|
2655
2662
|
if (ownerPathKey === void 0) {
|
|
2656
2663
|
if (requiresMonetizationAuthority || targetConfig.authoritativeGameServices !== false) {
|
|
2657
|
-
assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target);
|
|
2664
|
+
assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target, targetConfig.kind === "web" && requiresWebMonetizationAuthority);
|
|
2658
2665
|
}
|
|
2659
2666
|
return;
|
|
2660
2667
|
}
|
|
@@ -2761,10 +2768,13 @@ function readCanonicalPathAllowingMissingTail(candidate, label) {
|
|
|
2761
2768
|
}
|
|
2762
2769
|
return path.join(readCanonicalPath(existingPath, label), ...missingSegments);
|
|
2763
2770
|
}
|
|
2764
|
-
function assertAuthoritativeGameServicesUrl(value, target2) {
|
|
2771
|
+
function assertAuthoritativeGameServicesUrl(value, target2, allowSameOrigin = false) {
|
|
2765
2772
|
if (value === void 0 || value.length === 0) {
|
|
2766
2773
|
throw new Error(`Production target ${target2} requires VITE_MPGD_GAME_SERVICES_URL for an authoritative remote backend.`);
|
|
2767
2774
|
}
|
|
2775
|
+
if (allowSameOrigin && isHostedGameServicesPath(value)) {
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2768
2778
|
let url;
|
|
2769
2779
|
try {
|
|
2770
2780
|
url = new URL(value);
|
|
@@ -151,6 +151,13 @@ function expression(value) {
|
|
|
151
151
|
function quoted(value) {
|
|
152
152
|
return `'${value.replaceAll("'", "''")}'`;
|
|
153
153
|
}
|
|
154
|
+
// Linux jobs pin a runner image instead of `ubuntu-latest`, as the kit's own CI does:
|
|
155
|
+
// the rolling label moves to a new Ubuntu image, with different preinstalled toolchains
|
|
156
|
+
// (for example the Go that ttsc keys its compiled plugins on), without a workflow change.
|
|
157
|
+
// The generated jobs cache only the pnpm store, which no image toolchain keys, so they
|
|
158
|
+
// need no image-keyed cache; a game that adds one should put the image in its key.
|
|
159
|
+
const LINUX_RUNNER = 'ubuntu-24.04';
|
|
160
|
+
const MACOS_RUNNER = 'macos-26';
|
|
154
161
|
function renderNativeDeployWorkflow(input) {
|
|
155
162
|
const options = input.targets.map((target) => ` - ${target}`);
|
|
156
163
|
const lines = [
|
|
@@ -190,7 +197,7 @@ function renderNativeDeployWorkflow(input) {
|
|
|
190
197
|
'jobs:',
|
|
191
198
|
' validate:',
|
|
192
199
|
" if: github.event_name == 'pull_request'",
|
|
193
|
-
|
|
200
|
+
` runs-on: ${LINUX_RUNNER}`,
|
|
194
201
|
' steps:',
|
|
195
202
|
' - uses: actions/checkout@v5',
|
|
196
203
|
' with:',
|
|
@@ -229,7 +236,7 @@ function renderDeployJob(input, target) {
|
|
|
229
236
|
` deploy_${target}:`,
|
|
230
237
|
` if: github.event_name == 'workflow_dispatch' && github.ref == `
|
|
231
238
|
+ `'refs/heads/${input.releaseBranch}' && inputs.target == '${target}'`,
|
|
232
|
-
` runs-on: ${target === 'android' ?
|
|
239
|
+
` runs-on: ${target === 'android' ? LINUX_RUNNER : MACOS_RUNNER}`,
|
|
233
240
|
` environment: ${quoted(input.approvalEnvironment)}`,
|
|
234
241
|
' permissions:',
|
|
235
242
|
' contents: write',
|
|
@@ -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.67.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"parse5": "7.3.0",
|
|
71
71
|
"postcss": "8.5.28",
|
|
72
72
|
"postcss-value-parser": "4.2.0",
|
|
73
|
-
"@mpgd/catalog": "0.14.
|
|
74
|
-
"@mpgd/
|
|
75
|
-
"@mpgd/target-config": "0.24.
|
|
76
|
-
"@mpgd/
|
|
73
|
+
"@mpgd/catalog": "0.14.2",
|
|
74
|
+
"@mpgd/phaser-assets": "0.9.1",
|
|
75
|
+
"@mpgd/target-config": "0.24.6",
|
|
76
|
+
"@mpgd/platform": "0.35.0"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@types/node": "^24.0.0",
|
|
@@ -81,19 +81,19 @@
|
|
|
81
81
|
"@types/yauzl": "3.4.0",
|
|
82
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/analytics": "0.5.
|
|
88
|
-
"@mpgd/adapter-verse8": "0.7.
|
|
89
|
-
"@mpgd/adapter-devvit": "0.14.
|
|
90
|
-
"@mpgd/bridge": "0.
|
|
91
|
-
"@mpgd/
|
|
92
|
-
"@mpgd/capacitor-
|
|
93
|
-
"@mpgd/
|
|
94
|
-
"@mpgd/game-services": "0.
|
|
95
|
-
"@mpgd/
|
|
96
|
-
"@mpgd/
|
|
84
|
+
"@mpgd/adapter-ait": "0.21.8",
|
|
85
|
+
"@mpgd/adapter-browser": "0.12.4",
|
|
86
|
+
"@mpgd/adapter-capacitor": "0.19.0",
|
|
87
|
+
"@mpgd/analytics": "0.5.8",
|
|
88
|
+
"@mpgd/adapter-verse8": "0.7.19",
|
|
89
|
+
"@mpgd/adapter-devvit": "0.14.10",
|
|
90
|
+
"@mpgd/bridge": "0.18.1",
|
|
91
|
+
"@mpgd/capacitor-storekit": "0.7.0",
|
|
92
|
+
"@mpgd/capacitor-play-billing": "0.6.1",
|
|
93
|
+
"@mpgd/game-runtime": "0.10.0",
|
|
94
|
+
"@mpgd/game-services": "0.33.0",
|
|
95
|
+
"@mpgd/i18n": "0.6.29",
|
|
96
|
+
"@mpgd/lite-shell": "0.1.0"
|
|
97
97
|
},
|
|
98
98
|
"main": "./dist/index.js",
|
|
99
99
|
"types": "./dist/index.d.ts",
|
|
@@ -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;
|
|
@@ -564,8 +564,8 @@ The game services belong to one server player for the run
|
|
|
564
564
|
its own and lets the player switch accounts within a run gets them closed, not
|
|
565
565
|
moved to the new account: from the moment the session names another player,
|
|
566
566
|
nothing is verified, restored, or finished with the new player's credentials
|
|
567
|
-
for the old player's purchases (a backend
|
|
568
|
-
|
|
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
569
|
other's store account, and purchases answer `failed` with the retryable
|
|
570
570
|
diagnostic `GAME_SERVICES_PLAYER_CHANGED` before any store sheet opens.
|
|
571
571
|
`main.ts` stops play at once (`onPlayerChanging`: an execution block and a
|
|
@@ -14,6 +14,9 @@ export type GameMessageKey =
|
|
|
14
14
|
| 'reward'
|
|
15
15
|
| 'rewardError'
|
|
16
16
|
| 'rewardPending'
|
|
17
|
+
| 'rewardNotReady'
|
|
18
|
+
| 'rewardReconciling'
|
|
19
|
+
| 'rewardHistoryFull'
|
|
17
20
|
| 'rewardUnavailable'
|
|
18
21
|
| 'analytics'
|
|
19
22
|
| 'bootError'
|
|
@@ -59,6 +62,9 @@ const messages = {
|
|
|
59
62
|
reward: 'Rewarded ad smoke: {status}',
|
|
60
63
|
rewardError: 'Rewarded ad smoke failed',
|
|
61
64
|
rewardPending: 'Press R to request a rewarded ad smoke',
|
|
65
|
+
rewardNotReady: 'Rewarded ad smoke waits for the current purchase or ad',
|
|
66
|
+
rewardReconciling: 'Rewarded ad smoke waits until an earlier result is confirmed',
|
|
67
|
+
rewardHistoryFull: 'Rewarded ad smoke is unavailable until the game restarts',
|
|
62
68
|
rewardUnavailable: 'Rewarded ads unavailable on this target',
|
|
63
69
|
analytics: 'Analytics events: {count}',
|
|
64
70
|
bootError: 'Failed to start game',
|
|
@@ -108,6 +114,9 @@ const messages = {
|
|
|
108
114
|
reward: '리워드 광고 스모크: {status}',
|
|
109
115
|
rewardError: '리워드 광고 스모크 실패',
|
|
110
116
|
rewardPending: 'R 키로 리워드 광고 스모크 요청',
|
|
117
|
+
rewardNotReady: '진행 중인 구매나 광고가 끝나면 리워드 광고 스모크 요청 가능',
|
|
118
|
+
rewardReconciling: '이전 결과가 확인되면 리워드 광고 스모크 요청 가능',
|
|
119
|
+
rewardHistoryFull: '게임을 다시 시작할 때까지 리워드 광고 스모크를 사용할 수 없음',
|
|
111
120
|
rewardUnavailable: '이 타깃에서는 리워드 광고를 사용할 수 없음',
|
|
112
121
|
analytics: '애널리틱스 이벤트: {count}',
|
|
113
122
|
bootError: '게임 시작 실패',
|
|
@@ -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;
|
|
@@ -4,6 +4,7 @@ import productCatalogJson from '@mpgd/catalog/catalog.json';
|
|
|
4
4
|
import {
|
|
5
5
|
createGameServicesRuntime,
|
|
6
6
|
resolveGameServicesAuthorityMode,
|
|
7
|
+
resolveGameServicesBaseUrl,
|
|
7
8
|
resolveGameServicesTransport,
|
|
8
9
|
type GameServicesRuntime,
|
|
9
10
|
type GameServicesRuntimeMode,
|
|
@@ -163,6 +164,10 @@ function createRuntime(
|
|
|
163
164
|
input: StarterGameServicesInput,
|
|
164
165
|
session: ReturnType<typeof resolveStarterServerSession>,
|
|
165
166
|
): GameServicesRuntime {
|
|
167
|
+
const baseUrl = resolveGameServicesBaseUrl(import.meta.env.VITE_MPGD_GAME_SERVICES_URL, {
|
|
168
|
+
target: input.gateway.target,
|
|
169
|
+
...(globalThis.location?.origin === undefined ? {} : { pageOrigin: globalThis.location.origin }),
|
|
170
|
+
});
|
|
166
171
|
return createGameServicesRuntime({
|
|
167
172
|
gateway: input.gateway,
|
|
168
173
|
playerId: input.playerId,
|
|
@@ -176,9 +181,7 @@ function createRuntime(
|
|
|
176
181
|
authorityMode: resolveGameServicesAuthorityMode(import.meta.env.MODE),
|
|
177
182
|
target: import.meta.env.VITE_MPGD_GAME_SERVICES_TARGET ?? input.gateway.target,
|
|
178
183
|
deploymentTarget: input.configTarget,
|
|
179
|
-
...(
|
|
180
|
-
? {}
|
|
181
|
-
: { baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL }),
|
|
184
|
+
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
182
185
|
transport: resolveGameServicesTransport(import.meta.env.VITE_MPGD_GAME_SERVICES_TRANSPORT),
|
|
183
186
|
// A store purchase bound to another player's account is left for that player.
|
|
184
187
|
// Only where the template binds purchases: elsewhere (Microsoft Store) nothing is compared.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
isCommercePurchaseOperationRecord,
|
|
2
3
|
isMonetizationOperationGranted,
|
|
3
4
|
isMonetizationOperationRecoverable,
|
|
5
|
+
isParkedForAnotherAccount,
|
|
4
6
|
type MonetizationOperationRecord,
|
|
5
7
|
type MonetizationOperationStore,
|
|
6
8
|
} from '@mpgd/game-services';
|
|
@@ -21,17 +23,55 @@ export const defaultMonetizationJournalRetentionMs = 30 * 24 * 60 * 60 * 1000;
|
|
|
21
23
|
* age, or by the byte budget (`defaultMonetizationJournalMaxDocumentBytes`)
|
|
22
24
|
* when the document would not fit, so the revocation pass and
|
|
23
25
|
* `findByTransaction` keep them for the window whenever storage allows.
|
|
26
|
+
* Records parked as another account's purchase are the one kind of open
|
|
27
|
+
* record that leaves (`defaultMonetizationJournalParkedRetentionMs`).
|
|
24
28
|
*/
|
|
25
29
|
export const defaultMonetizationJournalMaxTerminalRecords = 100;
|
|
30
|
+
/**
|
|
31
|
+
* Records parked as another account's purchase (RFC "Another account's
|
|
32
|
+
* purchase") leave this long after their last write (30 days): the parking,
|
|
33
|
+
* or the player's last Restore Purchases resend. They are open records, but
|
|
34
|
+
* nothing of their player is in progress: the backend's answer granted and
|
|
35
|
+
* recorded nothing, and the store still holds the purchase for the account it
|
|
36
|
+
* is bound to, which recovers it under its own key. Dropping one cannot lose
|
|
37
|
+
* a grant: the backend ledger decides every grant, and a later verify of the
|
|
38
|
+
* same purchase finds a grant whose answer was lost. It costs one more verify
|
|
39
|
+
* that parks it again, if the store still lists the purchase and the client
|
|
40
|
+
* cannot tell its binding is another account's.
|
|
41
|
+
*/
|
|
42
|
+
export const defaultMonetizationJournalParkedRetentionMs = 30 * 24 * 60 * 60 * 1000;
|
|
43
|
+
/**
|
|
44
|
+
* At most this many parked records are kept across every player of the
|
|
45
|
+
* install, the most recently written first. A purchase dropped by this bound
|
|
46
|
+
* that the store keeps listing is verified again by the next pass that meets
|
|
47
|
+
* it, as an older client resends every such purchase. The byte budget may
|
|
48
|
+
* drop parked records before granted purchases. Neither drops a record in
|
|
49
|
+
* the in-flight window (`parkedInFlightMs`).
|
|
50
|
+
*/
|
|
51
|
+
export const defaultMonetizationJournalMaxParkedRecords = 20;
|
|
52
|
+
/**
|
|
53
|
+
* The default in-flight window (10 minutes, the reconciler's
|
|
54
|
+
* `defaultInFlightTimeoutMs`): a parked record written this recently, or
|
|
55
|
+
* later than now (a clock moved back), is kept through every bound. A
|
|
56
|
+
* Restore Purchases resend rewrites its record before it is sent, in this
|
|
57
|
+
* context or another tab's, and the record must still be there when the
|
|
58
|
+
* backend answers. The parked record this journal wrote last is kept too. A
|
|
59
|
+
* parked record without a readable time is never in flight.
|
|
60
|
+
*/
|
|
61
|
+
export const defaultMonetizationJournalParkedInFlightMs = 10 * 60 * 1000;
|
|
26
62
|
/**
|
|
27
63
|
* Upper bound of the serialized document (192 KiB, below the Devvit bridge's
|
|
28
64
|
* 256 KiB per value). When the records in the window would exceed it, the
|
|
29
65
|
* oldest terminal records leave first: ungranted ones without a store
|
|
30
|
-
* transaction, then ungranted ones that still name a store transaction
|
|
31
|
-
*
|
|
66
|
+
* transaction, then ungranted ones that still name a store transaction and
|
|
67
|
+
* parked records (except one that may be in flight), then granted purchases.
|
|
68
|
+
* Other open records are never dropped.
|
|
32
69
|
*/
|
|
33
70
|
export const defaultMonetizationJournalMaxDocumentBytes = 192 * 1024;
|
|
34
71
|
|
|
72
|
+
/** The time of a parked record without a readable one: newest in sort order, never in flight. */
|
|
73
|
+
const unreadableParkedTime = Number.MAX_SAFE_INTEGER;
|
|
74
|
+
|
|
35
75
|
export interface CreateStorageMonetizationOperationStoreInput {
|
|
36
76
|
/** The platform storage adapter (`gateway.storage`); the host owns its durability. */
|
|
37
77
|
readonly storage: StorageAdapter;
|
|
@@ -43,6 +83,18 @@ export interface CreateStorageMonetizationOperationStoreInput {
|
|
|
43
83
|
readonly maxTerminalRecords?: number;
|
|
44
84
|
/** Defaults to 192 KiB of serialized JSON; see `defaultMonetizationJournalMaxDocumentBytes`. */
|
|
45
85
|
readonly maxDocumentBytes?: number;
|
|
86
|
+
/** Defaults to 30 days; see `defaultMonetizationJournalParkedRetentionMs`. */
|
|
87
|
+
readonly parkedRetentionMs?: number;
|
|
88
|
+
/** Defaults to 20 parked records; see `defaultMonetizationJournalMaxParkedRecords`. */
|
|
89
|
+
readonly maxParkedRecords?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Defaults to 10 minutes; see `defaultMonetizationJournalParkedInFlightMs`.
|
|
92
|
+
* A game that changes the reconciler's `inFlightTimeoutMs`
|
|
93
|
+
* (`createCommerceReconciler`) passes the same value here, so a parked
|
|
94
|
+
* record stays in the journal for as long as its Restore Purchases answer
|
|
95
|
+
* may be awaited.
|
|
96
|
+
*/
|
|
97
|
+
readonly parkedInFlightMs?: number;
|
|
46
98
|
readonly now?: () => number;
|
|
47
99
|
/**
|
|
48
100
|
* Cross-context lock for the read-modify-write of the document; defaults to
|
|
@@ -67,7 +119,8 @@ interface JournalDocument {
|
|
|
67
119
|
* The reference durable `MonetizationOperationStore` of the starter: one JSON
|
|
68
120
|
* document in the platform storage adapter, rewritten whole on every change
|
|
69
121
|
* so a record and its index can never disagree, terminal records bounded by
|
|
70
|
-
* age (and, without a ledger grant, by count; open records are
|
|
122
|
+
* age (and, without a ledger grant, by count; open records are kept, except
|
|
123
|
+
* records parked as another account's purchase, bounded by age and count),
|
|
71
124
|
* with every operation
|
|
72
125
|
* serialized through one promise chain so the compare-and-swap of `replace`
|
|
73
126
|
* holds inside the WebView. It is crash recovery, not grant authority: the
|
|
@@ -87,10 +140,26 @@ export function createStorageMonetizationOperationStore(
|
|
|
87
140
|
const maxTerminalRecords = input.maxTerminalRecords
|
|
88
141
|
?? defaultMonetizationJournalMaxTerminalRecords;
|
|
89
142
|
const maxDocumentBytes = input.maxDocumentBytes ?? defaultMonetizationJournalMaxDocumentBytes;
|
|
143
|
+
const parkedRetentionMs = input.parkedRetentionMs ?? defaultMonetizationJournalParkedRetentionMs;
|
|
144
|
+
const parkedLimit = input.maxParkedRecords ?? defaultMonetizationJournalMaxParkedRecords;
|
|
145
|
+
const maxParkedRecords = parkedLimit >= 0 ? parkedLimit : 0;
|
|
146
|
+
const parkedInFlightMs = input.parkedInFlightMs ?? defaultMonetizationJournalParkedInFlightMs;
|
|
90
147
|
const now = input.now ?? (() => Date.now());
|
|
91
148
|
const locks = input.locks === undefined ? readHostLockManager() : (input.locks ?? undefined);
|
|
92
149
|
const lockName = `mpgd-monetization-journal:${key}`;
|
|
93
150
|
let chain: Promise<unknown> = Promise.resolve();
|
|
151
|
+
/**
|
|
152
|
+
* The parked record this journal wrote last: a Restore Purchases resend
|
|
153
|
+
* rewrites its record before it is sent, so this is the one whose answer
|
|
154
|
+
* may be awaited. No bound drops it, whatever its time says.
|
|
155
|
+
*/
|
|
156
|
+
let lastWrittenParked: string | undefined;
|
|
157
|
+
const noteWrite = (record: MonetizationOperationRecord): void => {
|
|
158
|
+
const writtenAt = parkedWrittenAt(record);
|
|
159
|
+
if (writtenAt !== undefined && writtenAt !== unreadableParkedTime) {
|
|
160
|
+
lastWrittenParked = record.key;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
94
163
|
|
|
95
164
|
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
96
165
|
const run = locks === undefined ? operation : () => locks.request(lockName, () => operation());
|
|
@@ -113,11 +182,34 @@ export function createStorageMonetizationOperationStore(
|
|
|
113
182
|
|
|
114
183
|
const save = async (records: Map<string, MonetizationOperationRecord>): Promise<void> => {
|
|
115
184
|
const cutoff = now() - retentionMs;
|
|
185
|
+
const parkedCutoff = now() - parkedRetentionMs;
|
|
116
186
|
const terminal: [string, number][] = [];
|
|
187
|
+
const parked: [string, number][] = [];
|
|
117
188
|
const kept: Record<string, MonetizationOperationRecord> = {};
|
|
118
|
-
/**
|
|
189
|
+
/** Records the byte budget may drop, with their drop tier (lower leaves first). */
|
|
119
190
|
const droppable: [string, number, number][] = [];
|
|
191
|
+
const inFlightSince = now() - parkedInFlightMs;
|
|
192
|
+
let inFlight = 0;
|
|
120
193
|
for (const [recordKey, record] of records) {
|
|
194
|
+
const parkedAt = parkedWrittenAt(record);
|
|
195
|
+
if (
|
|
196
|
+
parkedAt !== undefined
|
|
197
|
+
&& parkedAt !== unreadableParkedTime
|
|
198
|
+
&& (recordKey === lastWrittenParked || parkedAt >= inFlightSince)
|
|
199
|
+
) {
|
|
200
|
+
// Possibly awaiting a Restore Purchases answer: no bound drops it.
|
|
201
|
+
kept[recordKey] = record;
|
|
202
|
+
inFlight += 1;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (parkedAt !== undefined) {
|
|
206
|
+
// Another account's purchase: nothing of this player is in progress,
|
|
207
|
+
// and the purchase's own account recovers it under its own key.
|
|
208
|
+
if (parkedAt >= parkedCutoff) {
|
|
209
|
+
parked.push([recordKey, parkedAt]);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
121
213
|
const settledAt = terminalSettledAt(record);
|
|
122
214
|
if (settledAt === undefined) {
|
|
123
215
|
kept[recordKey] = record;
|
|
@@ -135,6 +227,18 @@ export function createStorageMonetizationOperationStore(
|
|
|
135
227
|
terminal.push([recordKey, settledAt]);
|
|
136
228
|
}
|
|
137
229
|
}
|
|
230
|
+
// Newest first; the records kept as in flight count against the bound.
|
|
231
|
+
parked.sort((left, right) => right[1] - left[1]);
|
|
232
|
+
for (const [recordKey, writtenAt] of parked.slice(
|
|
233
|
+
0,
|
|
234
|
+
Math.max(0, maxParkedRecords - inFlight),
|
|
235
|
+
)) {
|
|
236
|
+
const record = records.get(recordKey);
|
|
237
|
+
if (record !== undefined) {
|
|
238
|
+
kept[recordKey] = record;
|
|
239
|
+
droppable.push([recordKey, writtenAt, 1]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
138
242
|
// Newest first; the oldest terminal records beyond the bound are dropped.
|
|
139
243
|
terminal.sort((left, right) => right[1] - left[1]);
|
|
140
244
|
for (const [recordKey, settledAt] of terminal.slice(0, Math.max(0, maxTerminalRecords))) {
|
|
@@ -146,7 +250,7 @@ export function createStorageMonetizationOperationStore(
|
|
|
146
250
|
}
|
|
147
251
|
// The document must fit the host's per-value limit, or every later write
|
|
148
252
|
// fails and monetization stops: the oldest record of the lowest tier leaves
|
|
149
|
-
// until it fits. Open records are never dropped.
|
|
253
|
+
// until it fits. Open records other than parked ones are never dropped.
|
|
150
254
|
droppable.sort((left, right) => left[2] - right[2] || left[1] - right[1]);
|
|
151
255
|
let size = documentBytes(kept);
|
|
152
256
|
for (const [recordKey] of droppable) {
|
|
@@ -169,6 +273,7 @@ export function createStorageMonetizationOperationStore(
|
|
|
169
273
|
return { created: false, record: clone(existing) };
|
|
170
274
|
}
|
|
171
275
|
records.set(record.key, clone(record));
|
|
276
|
+
noteWrite(record);
|
|
172
277
|
await save(records);
|
|
173
278
|
return { created: true, record: clone(record) };
|
|
174
279
|
});
|
|
@@ -195,6 +300,7 @@ export function createStorageMonetizationOperationStore(
|
|
|
195
300
|
throw new Error('Stale or conflicting monetization journal write.');
|
|
196
301
|
}
|
|
197
302
|
records.set(record.key, clone(record));
|
|
303
|
+
noteWrite(record);
|
|
198
304
|
await save(records);
|
|
199
305
|
});
|
|
200
306
|
},
|
|
@@ -245,6 +351,22 @@ function terminalSettledAt(record: MonetizationOperationRecord): number | undefi
|
|
|
245
351
|
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
|
|
246
352
|
}
|
|
247
353
|
|
|
354
|
+
/**
|
|
355
|
+
* When a record parked as another account's purchase was last written (its
|
|
356
|
+
* parking, or a Restore Purchases resend), or `undefined` for any other
|
|
357
|
+
* record. A parked record without a readable time is never in flight and
|
|
358
|
+
* sorts as the newest: only the count bound and the byte budget drop it.
|
|
359
|
+
*/
|
|
360
|
+
function parkedWrittenAt(record: MonetizationOperationRecord): number | undefined {
|
|
361
|
+
if (!isCommercePurchaseOperationRecord(record) || !isParkedForAnotherAccount(record)) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const times = [record.updatedAt, record.anotherAccount?.reportedAt]
|
|
365
|
+
.map((time) => (time === undefined ? Number.NaN : Date.parse(time)))
|
|
366
|
+
.filter((time) => Number.isFinite(time));
|
|
367
|
+
return times.length === 0 ? unreadableParkedTime : Math.max(...times);
|
|
368
|
+
}
|
|
369
|
+
|
|
248
370
|
/** A commerce purchase record that names a store transaction (the store may still list it). */
|
|
249
371
|
function holdsStoreTransaction(record: MonetizationOperationRecord): boolean {
|
|
250
372
|
return record.kind === 'commerce-purchase' && record.purchase?.transactionId !== undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GameActionController } from '@mpgd/game-runtime/actions';
|
|
1
|
+
import type { GameActionAvailability, GameActionController } from '@mpgd/game-runtime/actions';
|
|
2
2
|
import { bindPhaserGameScene } from '@mpgd/game-runtime/phaser';
|
|
3
3
|
import Phaser from 'phaser';
|
|
4
4
|
|
|
@@ -21,6 +21,8 @@ export class PlayScene extends Phaser.Scene {
|
|
|
21
21
|
private analyticsText!: Phaser.GameObjects.Text;
|
|
22
22
|
private context!: StarterContext;
|
|
23
23
|
private rewardedAction: GameActionController<'rewarded-ad'> | undefined;
|
|
24
|
+
private showingNotReady = false;
|
|
25
|
+
private requesting = false;
|
|
24
26
|
private lastScore = -1;
|
|
25
27
|
private lastAnalyticsCount = -1;
|
|
26
28
|
|
|
@@ -83,19 +85,67 @@ export class PlayScene extends Phaser.Scene {
|
|
|
83
85
|
})
|
|
84
86
|
.setOrigin(0.5);
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
const actions = this.context.gameRuntime.actions;
|
|
89
|
+
this.rewardedAction = actions?.createRewardedAdController();
|
|
87
90
|
const action = this.rewardedAction;
|
|
91
|
+
// Follow the runtime's availability, so the hint changes while any purchase
|
|
92
|
+
// (one from the shop included) or ad holds the screen, without a key press.
|
|
93
|
+
this.showingNotReady = false;
|
|
94
|
+
this.requesting = false;
|
|
95
|
+
const stopAvailability = actions?.subscribeAvailability((availability) => {
|
|
96
|
+
this.showAvailability(availability);
|
|
97
|
+
});
|
|
98
|
+
if (actions !== undefined) {
|
|
99
|
+
this.showAvailability(actions.getAvailability());
|
|
100
|
+
}
|
|
88
101
|
const keyboard = this.input.keyboard;
|
|
89
102
|
const onReward = () => {
|
|
90
103
|
void this.requestRewardedAd();
|
|
91
104
|
};
|
|
92
105
|
keyboard?.on('keydown-R', onReward);
|
|
93
106
|
this.events.once('shutdown', () => {
|
|
107
|
+
stopAvailability?.();
|
|
94
108
|
action?.dispose();
|
|
95
109
|
keyboard?.off('keydown-R', onReward);
|
|
96
110
|
});
|
|
97
111
|
}
|
|
98
112
|
|
|
113
|
+
private showAvailability(availability: GameActionAvailability): void {
|
|
114
|
+
// This scene's own request makes the actions busy too; its result text
|
|
115
|
+
// follows, so only someone else's purchase or ad shows the waiting text.
|
|
116
|
+
if (!this.rewardedAdsAvailable() || this.rewardedAction === undefined || availability === 'disposed'
|
|
117
|
+
|| this.requesting) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (availability === 'ready') {
|
|
121
|
+
// Restore the hint only over the waiting text, never over a result.
|
|
122
|
+
if (this.showingNotReady) {
|
|
123
|
+
this.showingNotReady = false;
|
|
124
|
+
this.rewardText.setText(this.rewardHint());
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.showingNotReady = true;
|
|
129
|
+
// `history-full` lasts until the runtime is recreated; only `busy` ends
|
|
130
|
+
// with the other purchase or ad.
|
|
131
|
+
this.rewardText.setText(
|
|
132
|
+
t(
|
|
133
|
+
this.context.locale,
|
|
134
|
+
availability === 'history-full'
|
|
135
|
+
? 'rewardHistoryFull'
|
|
136
|
+
: availability === 'reconciliation-required'
|
|
137
|
+
? 'rewardReconciling'
|
|
138
|
+
: 'rewardNotReady',
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A result or error replaces the waiting text for good: `ready` must not restore the hint over it. */
|
|
144
|
+
private showRewardText(text: string): void {
|
|
145
|
+
this.showingNotReady = false;
|
|
146
|
+
this.rewardText.setText(text);
|
|
147
|
+
}
|
|
148
|
+
|
|
99
149
|
override update(_time: number, delta: number): void {
|
|
100
150
|
this.state = stepStarterRunState(this.state, delta);
|
|
101
151
|
|
|
@@ -139,13 +189,15 @@ export class PlayScene extends Phaser.Scene {
|
|
|
139
189
|
const action = this.rewardedAction;
|
|
140
190
|
|
|
141
191
|
if (!this.rewardedAdsAvailable() || action === undefined) {
|
|
142
|
-
this.
|
|
192
|
+
this.showRewardText(t(this.context.locale, 'rewardUnavailable'));
|
|
143
193
|
return;
|
|
144
194
|
}
|
|
145
195
|
|
|
196
|
+
// The tap-time guard stays: availability can change before the next frame.
|
|
146
197
|
if (this.context.gameRuntime.actions?.getAvailability() !== 'ready') {
|
|
147
198
|
return;
|
|
148
199
|
}
|
|
200
|
+
this.requesting = true;
|
|
149
201
|
try {
|
|
150
202
|
const result = await action.execute({
|
|
151
203
|
placementId: rewardedPlacementId,
|
|
@@ -155,7 +207,7 @@ export class PlayScene extends Phaser.Scene {
|
|
|
155
207
|
if (action.isDisposed() || this.rewardedAction !== action) {
|
|
156
208
|
return;
|
|
157
209
|
}
|
|
158
|
-
this.
|
|
210
|
+
this.showRewardText(
|
|
159
211
|
result.claim?.granted === true
|
|
160
212
|
? t(this.context.locale, 'reward', { status: result.status })
|
|
161
213
|
: t(this.context.locale, 'rewardError'),
|
|
@@ -169,9 +221,13 @@ export class PlayScene extends Phaser.Scene {
|
|
|
169
221
|
},
|
|
170
222
|
});
|
|
171
223
|
if (!action.isDisposed() && this.rewardedAction === action) {
|
|
172
|
-
this.
|
|
224
|
+
this.showRewardText(t(this.context.locale, 'rewardError'));
|
|
173
225
|
}
|
|
174
226
|
console.error('[rewarded-ad]', error);
|
|
227
|
+
} finally {
|
|
228
|
+
if (this.rewardedAction === action) {
|
|
229
|
+
this.requesting = false;
|
|
230
|
+
}
|
|
175
231
|
}
|
|
176
232
|
}
|
|
177
233
|
}
|