@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/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
- export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, } from './game-acceptance.js';
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 target acceptance with handoff reports.',
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.9.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.2",
53
- "@mpgd/adapter-browser": "0.4.2",
54
- "@mpgd/analytics": "0.3.5",
55
- "@mpgd/adapter-capacitor": "0.4.2",
56
- "@mpgd/adapter-devvit": "0.7.0",
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.3.5",
59
- "@mpgd/game-services": "0.7.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/i18n": "0.5.1",
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, and the configured
86
- target build/smoke matrix. Results are written as JSON and Markdown under
87
- `artifacts/acceptance`. Add a non-interactive `playtest` package script when the
88
- game has an automated browser scenario; interactive platform playtests remain
89
- separate operator workflows.
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 preview without importing
177
- Phaser. Its Play button requests the separate `game` entrypoint, whose
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 when customizing the preview so Reddit cards do not pay the
180
- game runtime cost before expansion.
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 scripts,
12
- game build, kit TypeScript graph preflight, and the reusable target build/smoke
13
- matrix. It writes `artifacts/acceptance/acceptance-report.json` and
14
- `acceptance-report.md` for handoff. Missing optional `test` or `playtest` scripts
15
- are recorded as skipped; check and build remain required.
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 only the lightweight inline preview and
27
- the `game` entry opens the separate expanded Phaser document.
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
@@ -38,7 +38,7 @@
38
38
  "owner": "apps/target-devvit"
39
39
  },
40
40
  {
41
- "id": "platform.devvit.surface-split",
41
+ "id": "platform.devvit.view-modes",
42
42
  "owner": "src/platform/devvitEntrypoint.ts"
43
43
  },
44
44
  {
@@ -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 preview. Its
45
- Play button requests the `game` entrypoint, which loads the separate
46
- `game.html` Phaser document. Keep inline UI free of game runtime imports so the
47
- card remains lightweight before expansion.
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: true,
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 submitLeaderboardScore(input);
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 DevvitDurableOperationStore,
4
+ type DevvitIndexedDurableOperationStore,
5
5
  } from '@mpgd/adapter-devvit/server';
6
6
 
7
- export function createPostOperationStore(): DevvitDurableOperationStore {
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
  }
@@ -76,6 +76,8 @@ async function bootstrap(): Promise<void> {
76
76
 
77
77
  createStarterGame({
78
78
  mountId: 'game',
79
+ preserveBrowserTouchGestures:
80
+ document.body.dataset.mpgdPreserveBrowserTouchGestures === 'true',
79
81
  context: {
80
82
  platform,
81
83
  runtime,