@mpgd/cli 0.9.0 → 0.10.1
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/game-acceptance.d.ts +14 -0
- package/dist/game-acceptance.js +318 -5
- package/dist/gameplay-e2e.d.ts +121 -0
- package/dist/gameplay-e2e.js +684 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +57 -3
- package/package.json +11 -11
- package/templates/phaser-game/README.md +14 -9
- package/templates/phaser-game/agent/acceptance.md +13 -7
- package/templates/phaser-game/agent/game-manifest.json +1 -1
- package/templates/phaser-game/apps/target-devvit/README.md +18 -4
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +4 -223
- package/templates/phaser-game/apps/target-devvit/src/server/postOperationStore.ts +2 -2
- package/templates/phaser-game/mpgd.game.json +42 -0
- package/templates/phaser-game/src/main.ts +2 -0
- package/templates/phaser-game/src/platform/devvitEntrypoint.ts +104 -29
- package/templates/phaser-game/src/platform/devvitInlineMode.css +133 -0
- package/templates/phaser-game/src/runtime/createGame.ts +6 -0
- package/templates/phaser-game/src/platform/devvitInlinePreview.css +0 -75
package/dist/index.js
CHANGED
|
@@ -7,7 +7,9 @@ import i18n, { defineI18n } from '@gunshi/plugin-i18n';
|
|
|
7
7
|
import resources from '@gunshi/resources';
|
|
8
8
|
import { cli } from 'gunshi';
|
|
9
9
|
import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
|
|
10
|
-
|
|
10
|
+
import { resolveGameplayE2EReportFile } from './gameplay-e2e.js';
|
|
11
|
+
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
|
|
12
|
+
export { collectGameplayE2EPathEvidence, defaultGameplayE2EReportFile, maximumGameplayE2EStates, parseGameplayE2EPlan, readGameplayE2EPlan, renderGameplayE2EMarkdown, resolveGameplayE2EReportFile, runGameplayE2E, } from './gameplay-e2e.js';
|
|
11
13
|
export { assertProductionTargetReadiness, } from './production-target-readiness.js';
|
|
12
14
|
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
15
|
const packageRoot = resolvePackageRoot(sourceDir);
|
|
@@ -61,6 +63,7 @@ const acceptanceStepIds = {
|
|
|
61
63
|
playtest: 'playtest',
|
|
62
64
|
targetBuild: 'target-build',
|
|
63
65
|
targetSmoke: 'target-smoke',
|
|
66
|
+
gameplayE2E: 'gameplay-e2e',
|
|
64
67
|
};
|
|
65
68
|
export async function runMpgdCli(args) {
|
|
66
69
|
await cli([...args], entryCommand, {
|
|
@@ -227,8 +230,8 @@ const gameCommand = defineI18n({
|
|
|
227
230
|
name: 'accept',
|
|
228
231
|
description: 'Run the reusable game handoff acceptance workflow.',
|
|
229
232
|
resource: commandResource({
|
|
230
|
-
en: 'Run check, test, build, graph, playtest, and
|
|
231
|
-
ko: 'check, test, build, graph, playtest, 타깃 인수 검증과 인계 리포트를 실행합니다.',
|
|
233
|
+
en: 'Run check, test, build, graph, playtest, target, and gameplay acceptance with handoff reports.',
|
|
234
|
+
ko: 'check, test, build, graph, playtest, 타깃 및 gameplay 인수 검증과 인계 리포트를 실행합니다.',
|
|
232
235
|
}, {
|
|
233
236
|
game: {
|
|
234
237
|
en: 'Game root containing package.json and mpgd.targets.json.',
|
|
@@ -246,6 +249,10 @@ const gameCommand = defineI18n({
|
|
|
246
249
|
en: 'Optional game-owned package script used for automated playtesting.',
|
|
247
250
|
ko: '자동 플레이테스트에 사용할 선택적 게임 소유 패키지 스크립트.',
|
|
248
251
|
},
|
|
252
|
+
'gameplay-script': {
|
|
253
|
+
en: 'Optional game-owned package script used for target gameplay E2E.',
|
|
254
|
+
ko: '타깃 gameplay E2E에 사용할 선택적 게임 소유 패키지 스크립트.',
|
|
255
|
+
},
|
|
249
256
|
'report-dir': {
|
|
250
257
|
en: 'JSON and Markdown handoff report directory.',
|
|
251
258
|
ko: 'JSON 및 Markdown 인계 리포트 디렉터리.',
|
|
@@ -296,6 +303,12 @@ const gameCommand = defineI18n({
|
|
|
296
303
|
default: 'playtest',
|
|
297
304
|
description: 'Game-owned package script used for automated playtesting.',
|
|
298
305
|
},
|
|
306
|
+
'gameplay-script': {
|
|
307
|
+
type: 'string',
|
|
308
|
+
required: false,
|
|
309
|
+
default: 'gameplay:e2e',
|
|
310
|
+
description: 'Game-owned package script used for target gameplay E2E.',
|
|
311
|
+
},
|
|
299
312
|
'report-dir': {
|
|
300
313
|
type: 'string',
|
|
301
314
|
required: false,
|
|
@@ -327,6 +340,11 @@ const gameCommand = defineI18n({
|
|
|
327
340
|
required: false,
|
|
328
341
|
description: 'Skip the game-owned playtest package script.',
|
|
329
342
|
},
|
|
343
|
+
'skip-gameplay-e2e': {
|
|
344
|
+
type: 'boolean',
|
|
345
|
+
required: false,
|
|
346
|
+
description: 'Skip the game-owned target gameplay E2E package script.',
|
|
347
|
+
},
|
|
330
348
|
'skip-target-build': {
|
|
331
349
|
type: 'boolean',
|
|
332
350
|
required: false,
|
|
@@ -353,12 +371,14 @@ const gameCommand = defineI18n({
|
|
|
353
371
|
aitVariant: readOptionalString(ctx.values['ait-variant']) ?? 'wrapper',
|
|
354
372
|
...(iosVariant === undefined ? {} : { iosVariant }),
|
|
355
373
|
playtestScript: readOptionalString(ctx.values['playtest-script']) ?? 'playtest',
|
|
374
|
+
gameplayScript: readOptionalString(ctx.values['gameplay-script']) ?? 'gameplay:e2e',
|
|
356
375
|
...(reportDir === undefined ? {} : { reportDir }),
|
|
357
376
|
...(kitPath === undefined ? {} : { kitPath }),
|
|
358
377
|
timeoutMs,
|
|
359
378
|
skipTest: ctx.values['skip-test'] === true,
|
|
360
379
|
skipGraph: ctx.values['skip-graph'] === true,
|
|
361
380
|
skipPlaytest: ctx.values['skip-playtest'] === true,
|
|
381
|
+
skipGameplayE2E: ctx.values['skip-gameplay-e2e'] === true,
|
|
362
382
|
skipTargetBuild: ctx.values['skip-target-build'] === true,
|
|
363
383
|
skipTargetSmoke: ctx.values['skip-target-smoke'] === true,
|
|
364
384
|
});
|
|
@@ -445,6 +465,14 @@ function acceptGame(input) {
|
|
|
445
465
|
...(input.kitPath === undefined ? {} : { 'kit-path': input.kitPath }),
|
|
446
466
|
});
|
|
447
467
|
const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
468
|
+
const gameplayE2EEnabled = !input.skipGameplayE2E
|
|
469
|
+
&& gamePackage.scripts?.[input.gameplayScript] !== undefined;
|
|
470
|
+
const gameplayE2ETargets = gameplayE2EEnabled
|
|
471
|
+
? parseTargetList(input.targets, {
|
|
472
|
+
...process.env,
|
|
473
|
+
MPGD_PLATFORM_TARGETS_FILE: targetsFile,
|
|
474
|
+
})
|
|
475
|
+
: [];
|
|
448
476
|
const steps = [
|
|
449
477
|
packageScriptAcceptanceStep({
|
|
450
478
|
id: acceptanceStepIds.check,
|
|
@@ -519,24 +547,45 @@ function acceptGame(input) {
|
|
|
519
547
|
...(input.iosVariant === undefined ? {} : { iosVariant: input.iosVariant }),
|
|
520
548
|
skipped: input.skipTargetSmoke,
|
|
521
549
|
}),
|
|
550
|
+
packageScriptAcceptanceStep({
|
|
551
|
+
id: acceptanceStepIds.gameplayE2E,
|
|
552
|
+
label: 'Target gameplay E2E',
|
|
553
|
+
script: input.gameplayScript,
|
|
554
|
+
gameRoot,
|
|
555
|
+
packageManager,
|
|
556
|
+
scripts: gamePackage.scripts,
|
|
557
|
+
required: false,
|
|
558
|
+
skipped: input.skipGameplayE2E,
|
|
559
|
+
}),
|
|
522
560
|
];
|
|
523
561
|
const reportDir = path.resolve(gameRoot, input.reportDir ?? 'artifacts/acceptance');
|
|
524
562
|
const releaseManifestFile = resolveGameAcceptanceReleaseManifestFile(gameRoot);
|
|
563
|
+
const gameplayE2EReportFile = resolveGameplayE2EReportFile(gameRoot);
|
|
525
564
|
const result = runGameAcceptance({
|
|
526
565
|
gameRoot,
|
|
527
566
|
reportDir,
|
|
528
567
|
...(input.skipTargetBuild
|
|
529
568
|
? {}
|
|
530
569
|
: { releaseManifestFile }),
|
|
570
|
+
...(gameplayE2EEnabled
|
|
571
|
+
? {
|
|
572
|
+
gameplayE2EReportFile,
|
|
573
|
+
requireGameplayE2EReport: true,
|
|
574
|
+
gameplayE2EStepId: acceptanceStepIds.gameplayE2E,
|
|
575
|
+
gameplayE2ETargets,
|
|
576
|
+
}
|
|
577
|
+
: {}),
|
|
531
578
|
options: {
|
|
532
579
|
targets: input.targets,
|
|
533
580
|
profile: input.profile,
|
|
534
581
|
aitVariant: input.aitVariant,
|
|
535
582
|
iosVariant: input.iosVariant ?? null,
|
|
536
583
|
playtestScript: input.playtestScript,
|
|
584
|
+
gameplayScript: input.gameplayScript,
|
|
537
585
|
skipTest: input.skipTest,
|
|
538
586
|
skipGraph: input.skipGraph,
|
|
539
587
|
skipPlaytest: input.skipPlaytest,
|
|
588
|
+
skipGameplayE2E: input.skipGameplayE2E,
|
|
540
589
|
skipTargetBuild: input.skipTargetBuild,
|
|
541
590
|
skipTargetSmoke: input.skipTargetSmoke,
|
|
542
591
|
timeoutMs: String(input.timeoutMs),
|
|
@@ -546,7 +595,12 @@ function acceptGame(input) {
|
|
|
546
595
|
env: {
|
|
547
596
|
...process.env,
|
|
548
597
|
MPGD_KIT_PATH: kitPath,
|
|
598
|
+
MPGD_ACCEPTANCE_TARGETS: gameplayE2EEnabled
|
|
599
|
+
? gameplayE2ETargets.join(',')
|
|
600
|
+
: input.targets,
|
|
601
|
+
MPGD_ACCEPTANCE_PROFILE: input.profile,
|
|
549
602
|
MPGD_RELEASE_MANIFEST_FILE: releaseManifestFile,
|
|
603
|
+
MPGD_GAMEPLAY_E2E_REPORT_FILE: gameplayE2EReportFile,
|
|
550
604
|
},
|
|
551
605
|
});
|
|
552
606
|
if (result.report.status === 'failed') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -49,18 +49,18 @@
|
|
|
49
49
|
"@types/node": "^24.0.0",
|
|
50
50
|
"ttsc": "0.18.4",
|
|
51
51
|
"typescript": "7.0.2",
|
|
52
|
-
"@mpgd/adapter-ait": "0.4.
|
|
53
|
-
"@mpgd/adapter-browser": "0.4.
|
|
54
|
-
"@mpgd/
|
|
55
|
-
"@mpgd/adapter-
|
|
56
|
-
"@mpgd/
|
|
52
|
+
"@mpgd/adapter-ait": "0.4.3",
|
|
53
|
+
"@mpgd/adapter-browser": "0.4.3",
|
|
54
|
+
"@mpgd/adapter-capacitor": "0.4.3",
|
|
55
|
+
"@mpgd/adapter-devvit": "0.8.1",
|
|
56
|
+
"@mpgd/analytics": "0.3.6",
|
|
57
57
|
"@mpgd/bridge": "0.6.0",
|
|
58
|
-
"@mpgd/catalog": "0.
|
|
59
|
-
"@mpgd/game-services": "0.
|
|
58
|
+
"@mpgd/catalog": "0.4.0",
|
|
59
|
+
"@mpgd/game-services": "0.8.1",
|
|
60
|
+
"@mpgd/platform": "0.6.0",
|
|
61
|
+
"@mpgd/i18n": "0.5.2",
|
|
60
62
|
"@mpgd/phaser-assets": "0.4.1",
|
|
61
|
-
"@mpgd/
|
|
62
|
-
"@mpgd/platform": "0.5.1",
|
|
63
|
-
"@mpgd/target-config": "0.6.1"
|
|
63
|
+
"@mpgd/target-config": "0.7.0"
|
|
64
64
|
},
|
|
65
65
|
"main": "./dist/index.js",
|
|
66
66
|
"types": "./dist/index.d.ts",
|
|
@@ -82,11 +82,15 @@ pnpm accept
|
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
The command runs `check`, an optional `test` script, `build`, the kit's ttsc
|
|
85
|
-
graph preflight, an optional game-owned `playtest` script,
|
|
86
|
-
target build/smoke matrix
|
|
87
|
-
`artifacts/acceptance`. Add a
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
graph preflight, an optional game-owned browser `playtest` script, the configured
|
|
86
|
+
target build/smoke matrix, and an optional target `gameplay:e2e` script. Results
|
|
87
|
+
are written as JSON and Markdown under `artifacts/acceptance`. Add a
|
|
88
|
+
non-interactive `playtest` script for a browser scenario. Add `gameplay:e2e` only
|
|
89
|
+
after this game has a real target automation and state-inspection driver; the
|
|
90
|
+
manifest plan in `mpgd.game.json` is reusable, but the driver remains
|
|
91
|
+
game-owned. The kit validates and attaches its hashed artifact, release
|
|
92
|
+
manifest, screenshots, and pause/resume session evidence. See
|
|
93
|
+
`docs/GAMEPLAY_E2E.md` in the mpgd-kit repository for the driver contract.
|
|
90
94
|
Each command has a 30-minute timeout by default. Pass `--timeout-ms` to the
|
|
91
95
|
underlying `mpgd game accept` command when a target matrix needs a different
|
|
92
96
|
per-step limit.
|
|
@@ -173,11 +177,12 @@ The generated `devvit.json` app name is a Devvit-safe slug derived from the game
|
|
|
173
177
|
directory name; edit it before `devvit:init` if you need a different Reddit app
|
|
174
178
|
name.
|
|
175
179
|
|
|
176
|
-
The default post entry builds a lightweight inline
|
|
177
|
-
Phaser.
|
|
180
|
+
The default post entry builds a lightweight inline mode launch screen without
|
|
181
|
+
importing Phaser eagerly. Play here loads gameplay inside the post only after a
|
|
182
|
+
user click; Open expanded mode requests the separate `game` entrypoint, whose
|
|
178
183
|
`game.html` document starts the Phaser bundle directly. Keep these physical
|
|
179
|
-
entries separate
|
|
180
|
-
|
|
184
|
+
entries separate so feed cards meet Devvit's inline mode performance and
|
|
185
|
+
gesture requirements while still supporting playable posts.
|
|
181
186
|
|
|
182
187
|
First-time Devvit setup:
|
|
183
188
|
|
|
@@ -8,11 +8,15 @@ pnpm icons:verify
|
|
|
8
8
|
pnpm accept
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
`pnpm accept` runs the configured game check, optional test and playtest
|
|
12
|
-
game build, kit TypeScript graph preflight,
|
|
13
|
-
matrix
|
|
14
|
-
`acceptance-report.
|
|
15
|
-
|
|
11
|
+
`pnpm accept` runs the configured game check, optional test and browser playtest
|
|
12
|
+
scripts, game build, kit TypeScript graph preflight, the reusable target
|
|
13
|
+
build/smoke matrix, and an optional `gameplay:e2e` target script. It writes
|
|
14
|
+
`artifacts/acceptance/acceptance-report.json` and `acceptance-report.md` for
|
|
15
|
+
handoff. Missing optional `test`, `playtest`, or `gameplay:e2e` scripts are
|
|
16
|
+
recorded as skipped; check and build remain required. When `gameplay:e2e`
|
|
17
|
+
exists, it must execute the `mpgd.game.json` acceptance states through a
|
|
18
|
+
game-owned driver and write the standard hashed report requested by
|
|
19
|
+
`MPGD_GAMEPLAY_E2E_REPORT_FILE`.
|
|
16
20
|
|
|
17
21
|
For Apps in Toss changes, use the apps-in-toss MCP before implementation and
|
|
18
22
|
keep SDK calls inside adapters or target wrappers.
|
|
@@ -23,8 +27,10 @@ For Reddit Devvit changes, keep Devvit SDK calls inside the adapter or target wr
|
|
|
23
27
|
continue to expose game-facing behavior through `PlatformGateway`. The Devvit
|
|
24
28
|
app root is game-owned in `apps/target-devvit`; run `pnpm devvit:init` once
|
|
25
29
|
after login before upload or playtest.
|
|
26
|
-
Verify the default post entry renders
|
|
27
|
-
the `game` entry opens the
|
|
30
|
+
Verify the default post entry renders a lightweight inline mode launch screen,
|
|
31
|
+
Play here starts gameplay inside the post, and the `game` entry opens the
|
|
32
|
+
separate expanded mode Phaser document. Inline gameplay must remain tap/click
|
|
33
|
+
only and must not trap feed scrolling.
|
|
28
34
|
|
|
29
35
|
For Microsoft Store changes, keep the first pass as a PWA/web target that uses
|
|
30
36
|
the browser adapter. Add a dedicated Store commerce adapter only when wiring
|
|
@@ -41,10 +41,18 @@ 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 default post entry uses `index.html` for a lightweight inline
|
|
45
|
-
Play
|
|
46
|
-
`game
|
|
47
|
-
|
|
44
|
+
The default post entry uses `index.html` for a lightweight inline mode launch
|
|
45
|
+
screen. Play here loads Phaser inside the post after a user click. Open expanded
|
|
46
|
+
mode requests the `game` entrypoint, which loads the separate `game.html`
|
|
47
|
+
document. Keep launch UI free of eager game runtime imports, and keep inline
|
|
48
|
+
gameplay tap/click only so the post does not trap feed scrolling.
|
|
49
|
+
|
|
50
|
+
The generated bridge does not advertise or accept a generic platform
|
|
51
|
+
leaderboard. Devvit ranking should be owned by a server completion handler that
|
|
52
|
+
validates the game-specific attempt, records it through the verified leaderboard
|
|
53
|
+
provider from `@mpgd/adapter-devvit/server`, and exposes only an authenticated
|
|
54
|
+
game-scoped snapshot route. Do not enable `nativeLeaderboard` or trust a raw
|
|
55
|
+
client score submission for that flow.
|
|
48
56
|
|
|
49
57
|
`devvit playtest` runs the official unified Vite build in watch mode, so a
|
|
50
58
|
separate client/server watcher or staging prebuild is not required.
|
|
@@ -66,6 +74,12 @@ explicit recovery endpoint or scheduler calls `reconcile`, which requires an exa
|
|
|
66
74
|
match of the full canonical envelope. A missing match from a bounded listing
|
|
67
75
|
remains unresolved rather than restoring submit permission.
|
|
68
76
|
|
|
77
|
+
The generated Redis wrapper implements `DevvitIndexedDurableOperationStore`.
|
|
78
|
+
Use `listPending()` with a small limit and its scope-bound continuation cursor to
|
|
79
|
+
discover prepared, attempted, and terminal work without scanning Redis keys.
|
|
80
|
+
Listing is read-only; prepared work still requires an explicit `execute` decision,
|
|
81
|
+
attempted work must use `reconcile`, and terminal ambiguity remains fail-closed.
|
|
82
|
+
|
|
69
83
|
Keep operation definitions and `@devvit/web/server` imports inside this target
|
|
70
84
|
app. The canonical `{ mpgd, launch, payload }` envelope is public and untrusted;
|
|
71
85
|
keep private content and authoritative records in server-side storage. Exercise
|
|
@@ -13,15 +13,9 @@ import {
|
|
|
13
13
|
} from '@mpgd/bridge/orpc';
|
|
14
14
|
import { createBridgeRpcNodeHandler } from '@mpgd/bridge/orpc/node';
|
|
15
15
|
|
|
16
|
-
const redisKeyComponentPattern = /^[A-Za-z0-9:_-]{1,128}$/;
|
|
17
16
|
const maxStorageKeyLength = 128;
|
|
18
17
|
const maxEncodedStorageKeyLength = 384;
|
|
19
18
|
const maxRequestBodySize = 1_048_576;
|
|
20
|
-
const leaderboardUpdateMaxAttempts = 3;
|
|
21
|
-
const leaderboardBackoffBaseMs = 25;
|
|
22
|
-
const leaderboardLockTtlMs = 2_000;
|
|
23
|
-
const leaderboardLockTtlSeconds = Math.ceil(leaderboardLockTtlMs / 1_000);
|
|
24
|
-
const leaderboardLockRetryBudgetMs = leaderboardLockTtlSeconds * 1_000;
|
|
25
19
|
const gameName = '__GAME_NAME__';
|
|
26
20
|
const gameTitle = __GAME_TITLE_TS_LITERAL__;
|
|
27
21
|
const bridgeRpcHandler = createBridgeRpcNodeHandler(
|
|
@@ -143,7 +137,7 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
|
|
|
143
137
|
nativeAds: false,
|
|
144
138
|
rewardedAds: false,
|
|
145
139
|
interstitialAds: false,
|
|
146
|
-
nativeLeaderboard:
|
|
140
|
+
nativeLeaderboard: false,
|
|
147
141
|
achievements: false,
|
|
148
142
|
cloudSave: true,
|
|
149
143
|
socialShare: false,
|
|
@@ -246,7 +240,9 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
|
|
|
246
240
|
});
|
|
247
241
|
|
|
248
242
|
case 'leaderboard.submitScore':
|
|
249
|
-
return
|
|
243
|
+
return ok(input, {
|
|
244
|
+
submitted: false,
|
|
245
|
+
});
|
|
250
246
|
|
|
251
247
|
case 'storage.load':
|
|
252
248
|
return loadStorage(input);
|
|
@@ -263,48 +259,6 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
|
|
|
263
259
|
}
|
|
264
260
|
}
|
|
265
261
|
|
|
266
|
-
async function submitLeaderboardScore(input: BridgeRequest): Promise<BridgeResponse> {
|
|
267
|
-
const payload = optionalObjectPayload(input.payload) as {
|
|
268
|
-
readonly leaderboardId?: unknown;
|
|
269
|
-
readonly score?: unknown;
|
|
270
|
-
};
|
|
271
|
-
const leaderboardId = leaderboardIdFromPayload(input, payload.leaderboardId);
|
|
272
|
-
const playerId = currentPlayerId();
|
|
273
|
-
|
|
274
|
-
if (typeof leaderboardId !== 'string') {
|
|
275
|
-
return leaderboardId;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (typeof payload.score !== 'number' || !Number.isFinite(payload.score)) {
|
|
279
|
-
return createBridgeError(
|
|
280
|
-
input.id,
|
|
281
|
-
'INVALID_LEADERBOARD_SCORE',
|
|
282
|
-
'Finite leaderboard score is required.',
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (playerId === undefined) {
|
|
287
|
-
return ok(input, {
|
|
288
|
-
submitted: false,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const redisKey = leaderboardKey(leaderboardId);
|
|
293
|
-
let scoreUpdate: 'updated' | 'unchanged' | 'failed';
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
scoreUpdate = await submitMaxLeaderboardScore(redisKey, playerId, payload.score);
|
|
297
|
-
} catch (error) {
|
|
298
|
-
console.warn(`devvit leaderboard score submission failed: ${errorMessage(error)}`);
|
|
299
|
-
scoreUpdate = 'failed';
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return ok(input, {
|
|
303
|
-
submitted: scoreUpdate !== 'failed',
|
|
304
|
-
highScoreUpdated: scoreUpdate === 'updated',
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
262
|
async function loadStorage(input: BridgeRequest): Promise<BridgeResponse> {
|
|
309
263
|
const playerId = currentPlayerId();
|
|
310
264
|
|
|
@@ -393,179 +347,6 @@ function storageKey(input: BridgeRequest, playerId: string): string | BridgeResp
|
|
|
393
347
|
return `${gameName}:save:${encodeURIComponent(playerId)}:${encodedKey}`;
|
|
394
348
|
}
|
|
395
349
|
|
|
396
|
-
function leaderboardIdFromPayload(
|
|
397
|
-
input: BridgeRequest,
|
|
398
|
-
value: unknown,
|
|
399
|
-
): string | BridgeResponse {
|
|
400
|
-
if (value === undefined) {
|
|
401
|
-
return 'default';
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (typeof value !== 'string' || !isValidRedisKeyComponent(value)) {
|
|
405
|
-
return createBridgeError(
|
|
406
|
-
input.id,
|
|
407
|
-
'INVALID_LEADERBOARD_ID',
|
|
408
|
-
'Leaderboard id format is invalid.',
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return value;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function leaderboardKey(leaderboardId: string): string {
|
|
416
|
-
return `${gameName}:leaderboard:${encodeURIComponent(leaderboardId)}`;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
async function submitMaxLeaderboardScore(
|
|
420
|
-
redisKey: string,
|
|
421
|
-
playerId: string,
|
|
422
|
-
score: number,
|
|
423
|
-
): Promise<'updated' | 'unchanged' | 'failed'> {
|
|
424
|
-
const lockKey = leaderboardLockKey(redisKey, playerId);
|
|
425
|
-
const startedAt = Date.now();
|
|
426
|
-
let attempt = 0;
|
|
427
|
-
|
|
428
|
-
while (Date.now() - startedAt <= leaderboardLockRetryBudgetMs) {
|
|
429
|
-
const lockToken = createLockToken();
|
|
430
|
-
const acquiredLock = await acquireLeaderboardLock(lockKey, lockToken);
|
|
431
|
-
|
|
432
|
-
if (!acquiredLock) {
|
|
433
|
-
await delay(nextLeaderboardRetryDelay(attempt, startedAt));
|
|
434
|
-
attempt += 1;
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
try {
|
|
439
|
-
const currentScore = await redis.zScore(redisKey, playerId);
|
|
440
|
-
|
|
441
|
-
if (currentScore !== undefined && score <= currentScore) {
|
|
442
|
-
return 'unchanged';
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (await writeLeaderboardScoreIfLockHeld(lockKey, lockToken, redisKey, playerId, score)) {
|
|
446
|
-
return 'updated';
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
await delay(nextLeaderboardRetryDelay(attempt, startedAt));
|
|
450
|
-
attempt += 1;
|
|
451
|
-
} finally {
|
|
452
|
-
try {
|
|
453
|
-
await releaseLeaderboardLock(lockKey, lockToken);
|
|
454
|
-
} catch (error) {
|
|
455
|
-
console.warn(`devvit leaderboard lock release failed: ${errorMessage(error)}`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
console.warn(`devvit leaderboard lock contention exceeded retry budget for key: ${lockKey}`);
|
|
461
|
-
|
|
462
|
-
return 'failed';
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function leaderboardLockKey(redisKey: string, playerId: string): string {
|
|
466
|
-
return `${redisKey}:lock:${encodeURIComponent(playerId)}`;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function createLockToken(): string {
|
|
470
|
-
const cryptoImpl = globalThis.crypto;
|
|
471
|
-
|
|
472
|
-
if (typeof cryptoImpl?.randomUUID === 'function') {
|
|
473
|
-
return cryptoImpl.randomUUID();
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
if (typeof cryptoImpl?.getRandomValues === 'function') {
|
|
477
|
-
const values = new Uint32Array(4);
|
|
478
|
-
cryptoImpl.getRandomValues(values);
|
|
479
|
-
|
|
480
|
-
return Array.from(values, (value) => value.toString(36).padStart(7, '0')).join('');
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
throw new Error('Web Crypto is required to create a Devvit leaderboard lock token.');
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
async function acquireLeaderboardLock(lockKey: string, lockToken: string): Promise<boolean> {
|
|
487
|
-
const result = await redis.set(lockKey, lockToken, {
|
|
488
|
-
nx: true,
|
|
489
|
-
expiration: leaderboardLockExpirationDate(),
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
return result === 'OK';
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function leaderboardLockExpirationDate(): Date {
|
|
496
|
-
// Devvit Redis SetOptions expects a Date and converts it to Redis EX seconds internally.
|
|
497
|
-
return new Date(Date.now() + leaderboardLockTtlMs);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async function writeLeaderboardScoreIfLockHeld(
|
|
501
|
-
lockKey: string,
|
|
502
|
-
lockToken: string,
|
|
503
|
-
redisKey: string,
|
|
504
|
-
playerId: string,
|
|
505
|
-
score: number,
|
|
506
|
-
): Promise<boolean> {
|
|
507
|
-
const transaction = await redis.watch(lockKey);
|
|
508
|
-
const currentToken = await redis.get(lockKey);
|
|
509
|
-
|
|
510
|
-
if (currentToken !== lockToken) {
|
|
511
|
-
await transaction.unwatch();
|
|
512
|
-
return false;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
await transaction.multi();
|
|
516
|
-
await transaction.zAdd(redisKey, {
|
|
517
|
-
member: playerId,
|
|
518
|
-
score,
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
const results = await transaction.exec();
|
|
522
|
-
|
|
523
|
-
return Array.isArray(results) && results.length > 0;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async function releaseLeaderboardLock(lockKey: string, lockToken: string): Promise<void> {
|
|
527
|
-
for (let attempt = 0; attempt < leaderboardUpdateMaxAttempts; attempt += 1) {
|
|
528
|
-
const transaction = await redis.watch(lockKey);
|
|
529
|
-
const currentToken = await redis.get(lockKey);
|
|
530
|
-
|
|
531
|
-
if (currentToken !== lockToken) {
|
|
532
|
-
await transaction.unwatch();
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
await transaction.multi();
|
|
537
|
-
await transaction.del(lockKey);
|
|
538
|
-
|
|
539
|
-
const results = await transaction.exec();
|
|
540
|
-
|
|
541
|
-
if (Array.isArray(results) && results.length > 0) {
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
await delay(leaderboardBackoffBaseMs * (attempt + 1));
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
console.warn(`devvit leaderboard lock release exhausted retries for key: ${lockKey}`);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function delay(ms: number): Promise<void> {
|
|
552
|
-
return new Promise((resolve) => {
|
|
553
|
-
setTimeout(resolve, ms);
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
function nextLeaderboardRetryDelay(attempt: number, startedAt: number): number {
|
|
558
|
-
const elapsed = Date.now() - startedAt;
|
|
559
|
-
const remaining = Math.max(0, leaderboardLockRetryBudgetMs - elapsed);
|
|
560
|
-
const backoff = leaderboardBackoffBaseMs * (attempt + 1);
|
|
561
|
-
|
|
562
|
-
return Math.min(backoff, remaining);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function isValidRedisKeyComponent(value: string): boolean {
|
|
566
|
-
return redisKeyComponentPattern.test(value);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
350
|
function errorMessage(error: unknown): string {
|
|
570
351
|
return error instanceof Error ? error.message : String(error);
|
|
571
352
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { redis } from '@devvit/web/server';
|
|
2
2
|
import {
|
|
3
3
|
createDevvitRedisPostOperationStore,
|
|
4
|
-
type
|
|
4
|
+
type DevvitIndexedDurableOperationStore,
|
|
5
5
|
} from '@mpgd/adapter-devvit/server';
|
|
6
6
|
|
|
7
|
-
export function createPostOperationStore():
|
|
7
|
+
export function createPostOperationStore(): DevvitIndexedDurableOperationStore {
|
|
8
8
|
return createDevvitRedisPostOperationStore(redis);
|
|
9
9
|
}
|
|
@@ -4,5 +4,47 @@
|
|
|
4
4
|
"source": "public/icon.svg",
|
|
5
5
|
"backgroundColor": "#0f172a"
|
|
6
6
|
}
|
|
7
|
+
},
|
|
8
|
+
"acceptance": {
|
|
9
|
+
"gameplay": {
|
|
10
|
+
"schemaVersion": 1,
|
|
11
|
+
"states": [
|
|
12
|
+
{
|
|
13
|
+
"id": "launch-ready",
|
|
14
|
+
"label": "Launch ready",
|
|
15
|
+
"expectation": "The lobby is rendered and ready for primary input.",
|
|
16
|
+
"actions": [
|
|
17
|
+
{
|
|
18
|
+
"type": "wait",
|
|
19
|
+
"durationMs": 500
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "primary-input",
|
|
25
|
+
"label": "Primary input",
|
|
26
|
+
"expectation": "The game enters its playable state after primary input.",
|
|
27
|
+
"actions": [
|
|
28
|
+
{
|
|
29
|
+
"type": "tap",
|
|
30
|
+
"x": 0.5,
|
|
31
|
+
"y": 0.72
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "resume-session",
|
|
37
|
+
"label": "Resume session",
|
|
38
|
+
"expectation": "The active game session remains healthy after pause and resume.",
|
|
39
|
+
"actions": [
|
|
40
|
+
{
|
|
41
|
+
"type": "pause-resume",
|
|
42
|
+
"backgroundMs": 1000,
|
|
43
|
+
"expectSameSession": true
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
7
49
|
}
|
|
8
50
|
}
|