@mpgd/cli 0.2.0 → 0.3.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
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import completion from '@gunshi/plugin-completion';
@@ -11,8 +11,8 @@ const packageRoot = resolvePackageRoot(sourceDir);
11
11
  const detectedKitRoot = resolveDefaultKitRoot();
12
12
  const gameTemplateDir = path.resolve(packageRoot, 'templates/phaser-game');
13
13
  const cliVersion = readPackageVersion(packageRoot);
14
- const defaultMatrixTargets = 'web,ait';
15
- const defaultDependencyVersion = '^0.1.0';
14
+ const defaultMatrixTargets = 'web,ait,reddit';
15
+ const defaultDependencyVersion = `^${cliVersion}`;
16
16
  const supportedBuildTargets = [
17
17
  'browser',
18
18
  'web',
@@ -471,7 +471,7 @@ function matrixArgResources() {
471
471
  };
472
472
  }
473
473
  function createGameApp(input) {
474
- const appDir = path.resolve(process.cwd(), input.directory);
474
+ const appDir = resolveAppDirectory(input.directory);
475
475
  const gameName = path.basename(appDir);
476
476
  assertValidGameName(gameName);
477
477
  const packageName = input.packageName ?? gameName;
@@ -504,13 +504,14 @@ function createGameApp(input) {
504
504
  }
505
505
  console.log(`Created ${appDir}`);
506
506
  console.log('Next steps:');
507
- console.log(` pnpm --dir ${appDir} install`);
508
- console.log(` pnpm --dir ${appDir} dev`);
507
+ console.log(` cd ${appDir}`);
508
+ console.log(' pnpm install --filter . --filter ./apps/target-devvit');
509
+ console.log(' pnpm dev');
509
510
  console.log('Target builds require an mpgd-kit checkout:');
510
511
  console.log([
511
512
  ' mpgd target build-all',
512
513
  `--targets-file ${path.join(appDir, 'mpgd.targets.json')}`,
513
- '--targets web,ait',
514
+ `--targets ${defaultMatrixTargets}`,
514
515
  '--ait-variant wrapper',
515
516
  '--kit-path <path-to-mpgd-kit>',
516
517
  ].join(' '));
@@ -520,6 +521,14 @@ function assertValidGameName(name) {
520
521
  throw new Error('Game directory basename must use kebab-case, for example: puzzle-one');
521
522
  }
522
523
  }
524
+ function resolveAppDirectory(directory) {
525
+ const resolved = path.resolve(process.cwd(), directory);
526
+ const parent = path.dirname(resolved);
527
+ if (!existsSync(parent)) {
528
+ return resolved;
529
+ }
530
+ return path.join(realpathSync(parent), path.basename(resolved));
531
+ }
523
532
  function assertValidPackageName(name) {
524
533
  if (!/^(?:@[a-z][a-z0-9-]*\/)?[a-z][a-z0-9-]*$/.test(name)) {
525
534
  throw new Error('Package name must be kebab-case, optionally scoped, for example: @scope/puzzle-one');
@@ -541,7 +550,9 @@ function createTemplateContext(input) {
541
550
  const workspacePrefix = workspaceRoot;
542
551
  return {
543
552
  gameName: input.gameName,
553
+ devvitAppName: toDevvitAppName(input.gameName),
544
554
  gameTitle: title,
555
+ gameTitleTsLiteral: toJavaScriptStringLiteral(title),
545
556
  packageName: input.packageName,
546
557
  dependencyVersion: input.dependencyVersion,
547
558
  tsconfigExtendsLine: input.workspace
@@ -557,6 +568,7 @@ function createTemplateContext(input) {
557
568
  tsconfigWorkspaceExcludes: input.workspace
558
569
  ? [
559
570
  `,\n "${workspacePrefix}/packages/**/dist/**"`,
571
+ `,\n "${workspacePrefix}/packages/cli/templates/**"`,
560
572
  `,\n "${workspacePrefix}/adapters/**/dist/**"`,
561
573
  `,\n "${workspacePrefix}/native-plugins/**/dist/**"`,
562
574
  ].join('')
@@ -564,6 +576,15 @@ function createTemplateContext(input) {
564
576
  workspaceI18nBuildPrefix: input.workspace
565
577
  ? `pnpm --dir ${workspacePrefix} i18n:build && `
566
578
  : '',
579
+ defaultKitPath: kitPath === undefined ? '../mpgd-kit' : workspacePrefix,
580
+ pnpmWorkspaceKitPackages: input.workspace
581
+ ? [
582
+ ` - '${workspacePrefix}/packages/*'`,
583
+ ` - '${workspacePrefix}/adapters/*'`,
584
+ ` - '${workspacePrefix}/native-plugins/*'`,
585
+ ` - '${workspacePrefix}/backend/*'`,
586
+ ].join('\n')
587
+ : '',
567
588
  pascalName: toPascalCase(input.gameName),
568
589
  camelName: toCamelCase(input.gameName),
569
590
  };
@@ -590,7 +611,7 @@ function walkTemplateDir(templateDir, relativeDir, files) {
590
611
  continue;
591
612
  }
592
613
  files.push({
593
- relativePath,
614
+ relativePath: templateOutputPath(relativePath),
594
615
  content: readFileSync(path.join(templateDir, relativePath), 'utf8'),
595
616
  });
596
617
  }
@@ -598,6 +619,8 @@ function walkTemplateDir(templateDir, relativeDir, files) {
598
619
  function renderTemplate(content, context) {
599
620
  return content
600
621
  .replaceAll('__GAME_NAME__', context.gameName)
622
+ .replaceAll('__DEVVIT_APP_NAME__', context.devvitAppName)
623
+ .replaceAll('__GAME_TITLE_TS_LITERAL__', context.gameTitleTsLiteral)
601
624
  .replaceAll('__GAME_TITLE__', context.gameTitle)
602
625
  .replaceAll('__PACKAGE_NAME__', context.packageName)
603
626
  .replaceAll('__MPGD_DEPENDENCY_VERSION__', context.dependencyVersion)
@@ -605,9 +628,16 @@ function renderTemplate(content, context) {
605
628
  .replaceAll('__TSCONFIG_WORKSPACE_INCLUDES__', context.tsconfigWorkspaceIncludes)
606
629
  .replaceAll('__TSCONFIG_WORKSPACE_EXCLUDES__', context.tsconfigWorkspaceExcludes)
607
630
  .replaceAll('__WORKSPACE_I18N_BUILD_PREFIX__', context.workspaceI18nBuildPrefix)
631
+ .replaceAll('__DEFAULT_KIT_PATH__', context.defaultKitPath)
632
+ .replaceAll('__PNPM_WORKSPACE_KIT_PACKAGES__', context.pnpmWorkspaceKitPackages)
608
633
  .replaceAll('__PASCAL_NAME__', context.pascalName)
609
634
  .replaceAll('__CAMEL_NAME__', context.camelName);
610
635
  }
636
+ function templateOutputPath(relativePath) {
637
+ return path.basename(relativePath) === 'gitignore'
638
+ ? path.join(path.dirname(relativePath), '.gitignore')
639
+ : relativePath;
640
+ }
611
641
  function toPascalCase(value) {
612
642
  return value
613
643
  .split('-')
@@ -619,6 +649,22 @@ function toCamelCase(value) {
619
649
  const pascal = toPascalCase(value);
620
650
  return `${pascal[0]?.toLowerCase() ?? ''}${pascal.slice(1)}`;
621
651
  }
652
+ function toDevvitAppName(gameName) {
653
+ let appName = gameName
654
+ .replace(/[^a-z0-9-]+/g, '-')
655
+ .replace(/-+/g, '-')
656
+ .replace(/^-|-$/g, '');
657
+ if (appName.length < 3) {
658
+ appName = `${appName}-game`;
659
+ }
660
+ if (appName.length > 16) {
661
+ appName = appName.slice(0, 16).replace(/-+$/g, '');
662
+ }
663
+ return appName.length >= 3 ? appName : 'mpgd-game';
664
+ }
665
+ function toJavaScriptStringLiteral(value) {
666
+ return JSON.stringify(value);
667
+ }
622
668
  function toTemplatePath(value) {
623
669
  return value.split(path.sep).join('/');
624
670
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,7 +5,7 @@ Standalone Phaser 4 game starter generated by `@mpgd/create-game`.
5
5
  ## Local Loop
6
6
 
7
7
  ```sh
8
- pnpm install
8
+ pnpm install --filter . --filter ./apps/target-devvit
9
9
  pnpm dev
10
10
  pnpm check
11
11
  pnpm build
@@ -22,19 +22,20 @@ The starter keeps game code behind `PlatformGateway` and demonstrates:
22
22
 
23
23
  ## Target Builds
24
24
 
25
- Target builds require an `mpgd-kit` checkout because the target wrappers live in
26
- the kit repository. Pass that checkout with `--kit-path`:
25
+ Target builds use the mpgd CLI because it knows how to turn this game into
26
+ platform-specific artifacts. Keep an `mpgd-kit` checkout nearby and pass it with
27
+ `--kit-path`, or set `MPGD_KIT_PATH` once in your shell.
27
28
 
28
29
  ```sh
29
- pnpm dlx @mpgd/cli target build-all \
30
+ pnpm exec mpgd target build-all \
30
31
  --targets-file ./mpgd.targets.json \
31
32
  --kit-path ../mpgd-kit \
32
- --targets web,ait \
33
+ --targets web,ait,reddit \
33
34
  --ait-variant wrapper
34
- pnpm dlx @mpgd/cli target smoke-all \
35
+ pnpm exec mpgd target smoke-all \
35
36
  --targets-file ./mpgd.targets.json \
36
37
  --kit-path ../mpgd-kit \
37
- --targets web,ait
38
+ --targets web,ait,reddit
38
39
  ```
39
40
 
40
41
  When developing the kit itself, the same commands work through the repository
@@ -44,7 +45,7 @@ script:
44
45
  pnpm --dir ../mpgd-kit mpgd target build-all \
45
46
  --targets-file "$PWD/mpgd.targets.json" \
46
47
  --kit-path ../mpgd-kit \
47
- --targets web,ait \
48
+ --targets web,ait,reddit \
48
49
  --ait-variant wrapper
49
50
  ```
50
51
 
@@ -52,6 +53,46 @@ pnpm --dir ../mpgd-kit mpgd target build-all \
52
53
  into `.mpgd.targets.generated.json` before calling the kit's existing target
53
54
  build and smoke scripts.
54
55
 
56
+ ## Reddit Devvit
57
+
58
+ This starter owns its Devvit app root in `apps/target-devvit`. That directory
59
+ contains the generated game's `devvit.json`, server bridge, menu entry, and
60
+ Devvit CLI scripts. It intentionally does not point at the kit demo app, because
61
+ Reddit app names, uploads, and playtests belong to each individual game.
62
+ The generated `devvit.json` app name is a Devvit-safe slug derived from the game
63
+ directory name; edit it before `devvit:init` if you need a different Reddit app
64
+ name.
65
+
66
+ First-time Devvit setup:
67
+
68
+ ```sh
69
+ pnpm install --filter . --filter ./apps/target-devvit
70
+ pnpm devvit:login
71
+ pnpm devvit:whoami
72
+ pnpm devvit:init
73
+ pnpm build:devvit
74
+ pnpm devvit:playtest
75
+ ```
76
+
77
+ `devvit:login` authenticates your Reddit account. `devvit:init` performs the
78
+ first App Directory upload/link step for the app name in
79
+ `apps/target-devvit/devvit.json` using Devvit's copy-paste flow. After init, use
80
+ `pnpm devvit:playtest`, `pnpm devvit:upload`, and `pnpm devvit:publish` from
81
+ this game root.
82
+
83
+ ## Target Ownership Notes
84
+
85
+ - Browser preview is fully game-owned and writes to `artifacts/web-preview`.
86
+ - Reddit Devvit is game-owned in `apps/target-devvit`.
87
+ - Apps in Toss currently uses the kit reference wrapper at
88
+ `${MPGD_KIT_PATH}/apps/target-ait` for smoke packaging. Before a real Toss
89
+ submission, create a game-owned wrapper/config so the app name, CLI state, and
90
+ review metadata are not shared with the kit demo.
91
+ - Android and iOS currently use the kit reference Capacitor shell at
92
+ `${MPGD_KIT_PATH}/apps/mobile-capacitor`. That is useful for local artifact
93
+ checks, but production apps should own their bundle ID/package ID, signing,
94
+ icons, and store metadata in a game-specific shell.
95
+
55
96
  ## Game Services
56
97
 
57
98
  The backend client is disabled unless a backend URL is configured:
@@ -5,12 +5,14 @@ Run these checks before handing off a generated game starter:
5
5
  ```sh
6
6
  pnpm check
7
7
  pnpm build
8
- pnpm --dir ../mpgd-kit mpgd target build-all --targets-file "$PWD/mpgd.targets.json" --targets web,ait --ait-variant wrapper
9
- pnpm --dir ../mpgd-kit mpgd target smoke-all --targets-file "$PWD/mpgd.targets.json" --targets web,ait
8
+ pnpm --dir ../mpgd-kit mpgd target build-all --targets-file "$PWD/mpgd.targets.json" --targets web,ait,reddit --ait-variant wrapper
9
+ pnpm --dir ../mpgd-kit mpgd target smoke-all --targets-file "$PWD/mpgd.targets.json" --targets web,ait,reddit
10
10
  ```
11
11
 
12
12
  For Apps in Toss changes, use the apps-in-toss MCP before implementation and
13
13
  keep SDK calls inside adapters or target wrappers.
14
14
 
15
15
  For Reddit Devvit changes, keep Devvit SDK calls inside the target wrapper and
16
- continue to expose game-facing behavior through `PlatformGateway`.
16
+ continue to expose game-facing behavior through `PlatformGateway`. The Devvit
17
+ app root is game-owned in `apps/target-devvit`; run `pnpm devvit:init` once
18
+ after login before upload or playtest.
@@ -16,5 +16,8 @@ Useful commands:
16
16
  pnpm dev
17
17
  pnpm check
18
18
  pnpm build
19
- pnpm --dir ../mpgd-kit mpgd target build-all --targets-file "$PWD/mpgd.targets.json" --targets web,ait --ait-variant wrapper
19
+ pnpm exec mpgd target build-all --targets-file ./mpgd.targets.json --kit-path ../mpgd-kit --targets web,ait,reddit --ait-variant wrapper
20
+ pnpm devvit:login
21
+ pnpm devvit:init
22
+ pnpm devvit:playtest
20
23
  ```
@@ -10,7 +10,7 @@
10
10
  "platformBoundary": "PlatformGateway",
11
11
  "mcpRequirements": {
12
12
  "ait": "Use apps-in-toss MCP before Apps in Toss SDK or review-flow changes.",
13
- "reddit": "Use Devvit documentation/MCP before Devvit SDK or publish-flow changes."
13
+ "reddit": "Use Devvit documentation/MCP before Devvit SDK or publish-flow changes. Keep Reddit deployment files in apps/target-devvit."
14
14
  },
15
15
  "blocks": [
16
16
  {
@@ -21,6 +21,10 @@
21
21
  "id": "runtime.platform.gateway",
22
22
  "owner": "src/platform/installPlatform.ts"
23
23
  },
24
+ {
25
+ "id": "platform.devvit.app-root",
26
+ "owner": "apps/target-devvit"
27
+ },
24
28
  {
25
29
  "id": "services.backend.game-services",
26
30
  "owner": "src/platform/gameServices.ts"
@@ -0,0 +1,30 @@
1
+ # __GAME_TITLE__ Devvit Target
2
+
3
+ This directory is the Reddit Devvit app root for `__GAME_NAME__`.
4
+
5
+ `devvit.json` belongs to the generated game, not to the mpgd-kit checkout. That keeps the
6
+ Reddit app name, menu entry, upload history, and playtest state owned by this game.
7
+ Its `name` is generated as a Devvit-safe slug; edit it before `pnpm devvit:init`
8
+ if you want a different Reddit app name.
9
+
10
+ ## First Playtest
11
+
12
+ ```sh
13
+ pnpm install --filter . --filter ./apps/target-devvit
14
+ pnpm devvit:login
15
+ pnpm devvit:whoami
16
+ pnpm devvit:init
17
+ pnpm build:devvit
18
+ pnpm devvit:playtest
19
+ ```
20
+
21
+ `devvit:init` performs the first App Directory upload/link step with Devvit's
22
+ copy-paste flow. Login proves who you are; init uploads and links the
23
+ Reddit-side app record for the `name` in `devvit.json`. After that, use
24
+ `pnpm devvit:playtest`, `pnpm devvit:upload`, and `pnpm devvit:publish` from the
25
+ game root.
26
+
27
+ The client bundle is copied into `dist/client` by the mpgd target build. The server bridge is
28
+ compiled to `dist/server/index.cjs` and keeps Devvit SDK imports out of Phaser scenes.
29
+ The game root pins `@mpgd/cli`, so these commands use the same CLI version as the generated
30
+ starter's other `@mpgd/*` dependencies.
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "https://developers.reddit.com/schema/config-file.v1.json",
3
+ "name": "__DEVVIT_APP_NAME__",
4
+ "post": {
5
+ "dir": "dist/client",
6
+ "entrypoints": {
7
+ "default": {
8
+ "entry": "index.html",
9
+ "height": "tall"
10
+ }
11
+ }
12
+ },
13
+ "server": {
14
+ "dir": "dist/server",
15
+ "entry": "index.cjs"
16
+ },
17
+ "permissions": {
18
+ "redis": true,
19
+ "reddit": {
20
+ "enable": true
21
+ },
22
+ "payments": false,
23
+ "realtime": false
24
+ },
25
+ "menu": {
26
+ "items": [
27
+ {
28
+ "label": "Create __GAME_TITLE__ post",
29
+ "description": "Create a playable __GAME_TITLE__ post.",
30
+ "forUserType": "moderator",
31
+ "location": "subreddit",
32
+ "endpoint": "/internal/menu/create-post"
33
+ }
34
+ ]
35
+ },
36
+ "scripts": {
37
+ "dev": "pnpm dev:prepare && pnpm dev:watch",
38
+ "build": "pnpm build"
39
+ }
40
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "__PACKAGE_NAME__-target-devvit",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "init": "devvit upload --copy-paste --config devvit.json",
8
+ "dev": "devvit playtest --config devvit.json",
9
+ "dev:prepare": "pnpm --dir ../.. build:devvit:staging",
10
+ "dev:client": "APP_TARGET=reddit APP_VERSION=0.0.0-dev BUILD_ID=devvit-watch pnpm --dir ../.. exec vite build --mode staging --watch --outDir apps/target-devvit/dist/client",
11
+ "dev:server": "vite build --config vite.server.config.ts --watch",
12
+ "dev:watch": "concurrently --kill-others --success first \"pnpm run dev:client\" \"pnpm run dev:server\"",
13
+ "build": "pnpm --dir ../.. build:devvit",
14
+ "build:server": "vite build --config vite.server.config.ts",
15
+ "whoami": "devvit whoami",
16
+ "upload": "devvit upload --config devvit.json",
17
+ "publish": "devvit publish --config devvit.json",
18
+ "check": "ttsc --noEmit -p tsconfig.json",
19
+ "lint": "ttsc --noEmit -p tsconfig.json",
20
+ "format": "ttsc format",
21
+ "fix": "ttsc fix"
22
+ },
23
+ "dependencies": {
24
+ "@devvit/web": "0.13.6",
25
+ "@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION__",
26
+ "express": "^5.2.1",
27
+ "helmet": "^8.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@ttsc/unplugin": "0.16.9",
31
+ "@types/express": "^5.0.6",
32
+ "@types/node": "^24.0.0",
33
+ "concurrently": "10.0.3",
34
+ "devvit": "0.13.6",
35
+ "ttsc": "0.16.9",
36
+ "typescript": "7.0.1-rc",
37
+ "vite": "^8.1.3"
38
+ }
39
+ }
@@ -0,0 +1,656 @@
1
+ import { createServer, context, getServerPort, reddit, redis } from '@devvit/web/server';
2
+ import type { UiResponse } from '@devvit/web/shared';
3
+ import {
4
+ assertBridgeRequest,
5
+ createBridgeError,
6
+ type BridgeRequest,
7
+ type BridgeResponse,
8
+ } from '@mpgd/bridge';
9
+ import {
10
+ createBridgeRpcFetchHandler,
11
+ createBridgeRpcRouter,
12
+ defaultBridgeRpcEndpoint,
13
+ } from '@mpgd/bridge/orpc';
14
+ import express, { type Request as ExpressRequest, type Response as ExpressResponse } from 'express';
15
+ import helmet from 'helmet';
16
+
17
+ const app = express();
18
+ const redisKeyComponentPattern = /^[A-Za-z0-9:_-]{1,128}$/;
19
+ const maxStorageKeyLength = 128;
20
+ const maxEncodedStorageKeyLength = 384;
21
+ const leaderboardUpdateMaxAttempts = 3;
22
+ const leaderboardBackoffBaseMs = 25;
23
+ const leaderboardLockTtlMs = 2_000;
24
+ const leaderboardLockTtlSeconds = Math.ceil(leaderboardLockTtlMs / 1_000);
25
+ const leaderboardLockRetryBudgetMs = leaderboardLockTtlSeconds * 1_000;
26
+ const gameName = '__GAME_NAME__';
27
+ const gameTitle = __GAME_TITLE_TS_LITERAL__;
28
+ const expressManagedResponseHeaders = new Set([
29
+ 'connection',
30
+ 'content-encoding',
31
+ 'content-length',
32
+ 'transfer-encoding',
33
+ ]);
34
+
35
+ app.disable('x-powered-by');
36
+ app.use(helmet());
37
+
38
+ const bridgeRpcFetchHandler = createBridgeRpcFetchHandler(
39
+ createBridgeRpcRouter(handleBridgeRequest),
40
+ );
41
+
42
+ app.use(defaultBridgeRpcEndpoint, express.raw({ type: '*/*', limit: '1mb' }), async (
43
+ request: ExpressRequest,
44
+ response: ExpressResponse,
45
+ ): Promise<void> => {
46
+ try {
47
+ const fetchResponse = await bridgeRpcFetchHandler(expressRequestToFetchRequest(request));
48
+ await sendFetchResponse(response, fetchResponse);
49
+ } catch (error) {
50
+ const message = error instanceof Error ? error.message : 'Devvit oRPC request failed.';
51
+ console.error(`devvit oRPC internal error: ${message}`, error);
52
+ response.status(500).json(
53
+ createBridgeError(
54
+ requestIdFromBody(request.body),
55
+ 'DEVVIT_BRIDGE_INTERNAL_ERROR',
56
+ 'Devvit bridge request failed.',
57
+ true,
58
+ ),
59
+ );
60
+ }
61
+ });
62
+
63
+ app.use(express.json({ limit: '1mb' }));
64
+
65
+ app.post('/internal/menu/create-post', async (
66
+ _request: ExpressRequest,
67
+ response: ExpressResponse,
68
+ ): Promise<void> => {
69
+ const subredditName = currentSubredditName();
70
+
71
+ if (subredditName === undefined) {
72
+ response.status(200).json({
73
+ showToast: {
74
+ text: 'Could not resolve the target subreddit for this menu action.',
75
+ appearance: 'neutral',
76
+ },
77
+ } satisfies UiResponse);
78
+ return;
79
+ }
80
+
81
+ try {
82
+ const post = await reddit.submitCustomPost({
83
+ subredditName,
84
+ title: gameTitle,
85
+ entry: 'default',
86
+ textFallback: {
87
+ text: `Open this Reddit custom post to play ${gameTitle}.`,
88
+ },
89
+ postData: {
90
+ source: gameName,
91
+ createdBy: 'devvit-menu',
92
+ },
93
+ });
94
+
95
+ response.status(200).json({
96
+ showToast: {
97
+ text: `Created ${gameTitle} post ${post.id}.`,
98
+ appearance: 'success',
99
+ },
100
+ } satisfies UiResponse);
101
+ } catch (error) {
102
+ console.error(`devvit custom post creation failed: ${errorMessage(error)}`, error);
103
+ response.status(200).json({
104
+ showToast: {
105
+ text: `Could not create the ${gameTitle} post.`,
106
+ appearance: 'neutral',
107
+ },
108
+ } satisfies UiResponse);
109
+ }
110
+ });
111
+
112
+ app.post('/api/mpgd/bridge', async (
113
+ request: ExpressRequest,
114
+ response: ExpressResponse,
115
+ ): Promise<void> => {
116
+ let bridgeRequest: BridgeRequest;
117
+
118
+ try {
119
+ bridgeRequest = assertBridgeRequest(request.body);
120
+ } catch (error) {
121
+ response.status(400).json(
122
+ createBridgeError(
123
+ requestIdFromBody(request.body),
124
+ 'INVALID_BRIDGE_REQUEST',
125
+ error instanceof Error ? error.message : 'Invalid bridge request.',
126
+ ),
127
+ );
128
+ return;
129
+ }
130
+
131
+ try {
132
+ const bridgeResponse = await handleBridgeRequest(bridgeRequest);
133
+ response.status(200).json(bridgeResponse);
134
+ } catch (error) {
135
+ const message = error instanceof Error ? error.message : 'Devvit bridge request failed.';
136
+ console.error(`devvit bridge internal error: ${message}`, error);
137
+
138
+ response.status(500).json(
139
+ createBridgeError(
140
+ bridgeRequest.id,
141
+ 'DEVVIT_BRIDGE_INTERNAL_ERROR',
142
+ 'Devvit bridge request failed.',
143
+ true,
144
+ ),
145
+ );
146
+ }
147
+ });
148
+
149
+ const server = createServer(app);
150
+ const port = getServerPort();
151
+
152
+ server.on('error', (error) => {
153
+ console.error(`devvit server error: ${error.stack}`);
154
+ });
155
+
156
+ server.listen(port, () => {
157
+ console.log(`devvit server listening on ${port}`);
158
+ });
159
+
160
+ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse> {
161
+ switch (input.method) {
162
+ case 'runtime.getCapabilities':
163
+ return ok(input, {
164
+ nativeIap: false,
165
+ nativeAds: false,
166
+ rewardedAds: false,
167
+ interstitialAds: false,
168
+ nativeLeaderboard: true,
169
+ achievements: false,
170
+ cloudSave: true,
171
+ socialShare: true,
172
+ haptics: false,
173
+ localizedContent: true,
174
+ });
175
+
176
+ case 'identity.getPlayer': {
177
+ const playerId = currentPlayerId();
178
+
179
+ if (playerId === undefined) {
180
+ return ok(input, null);
181
+ }
182
+
183
+ return ok(input, {
184
+ playerId,
185
+ displayName: await currentDisplayName(playerId),
186
+ });
187
+ }
188
+
189
+ case 'commerce.getProducts':
190
+ case 'commerce.getEntitlements':
191
+ return ok(input, []);
192
+
193
+ case 'commerce.purchase':
194
+ return ok(input, {
195
+ status: 'cancelled',
196
+ entitlementIds: [],
197
+ });
198
+
199
+ case 'commerce.restore':
200
+ return ok(input, {
201
+ restoredEntitlements: [],
202
+ });
203
+
204
+ case 'ads.preload':
205
+ return ok(input, {});
206
+
207
+ case 'leaderboard.open':
208
+ return createBridgeError(
209
+ input.id,
210
+ 'DEVVIT_LEADERBOARD_OPEN_UNAVAILABLE',
211
+ 'Devvit leaderboard display is not implemented yet.',
212
+ );
213
+
214
+ case 'ads.showRewarded':
215
+ return ok(input, {
216
+ status: 'unavailable',
217
+ rewardGranted: false,
218
+ });
219
+
220
+ case 'ads.showInterstitial':
221
+ return ok(input, {
222
+ status: 'unavailable',
223
+ });
224
+
225
+ case 'leaderboard.submitScore':
226
+ return submitLeaderboardScore(input);
227
+
228
+ case 'storage.load':
229
+ return loadStorage(input);
230
+
231
+ case 'storage.save':
232
+ return saveStorage(input);
233
+
234
+ default:
235
+ return createBridgeError(
236
+ input.id,
237
+ 'UNSUPPORTED_METHOD',
238
+ `Unsupported Devvit bridge method: ${input.method}`,
239
+ );
240
+ }
241
+ }
242
+
243
+ async function submitLeaderboardScore(input: BridgeRequest): Promise<BridgeResponse> {
244
+ const payload = optionalObjectPayload(input.payload) as {
245
+ readonly leaderboardId?: unknown;
246
+ readonly score?: unknown;
247
+ };
248
+ const leaderboardId = leaderboardIdFromPayload(input, payload.leaderboardId);
249
+ const playerId = currentPlayerId();
250
+
251
+ if (typeof leaderboardId !== 'string') {
252
+ return leaderboardId;
253
+ }
254
+
255
+ if (typeof payload.score !== 'number' || !Number.isFinite(payload.score)) {
256
+ return createBridgeError(
257
+ input.id,
258
+ 'INVALID_LEADERBOARD_SCORE',
259
+ 'Finite leaderboard score is required.',
260
+ );
261
+ }
262
+
263
+ if (playerId === undefined) {
264
+ return ok(input, {
265
+ submitted: false,
266
+ });
267
+ }
268
+
269
+ const redisKey = leaderboardKey(leaderboardId);
270
+ let scoreUpdate: 'updated' | 'unchanged' | 'failed';
271
+
272
+ try {
273
+ scoreUpdate = await submitMaxLeaderboardScore(redisKey, playerId, payload.score);
274
+ } catch (error) {
275
+ console.warn(`devvit leaderboard score submission failed: ${errorMessage(error)}`);
276
+ scoreUpdate = 'failed';
277
+ }
278
+
279
+ return ok(input, {
280
+ submitted: scoreUpdate !== 'failed',
281
+ highScoreUpdated: scoreUpdate === 'updated',
282
+ });
283
+ }
284
+
285
+ async function loadStorage(input: BridgeRequest): Promise<BridgeResponse> {
286
+ const playerId = currentPlayerId();
287
+
288
+ if (playerId === undefined) {
289
+ return ok(input, null);
290
+ }
291
+
292
+ const key = storageKey(input, playerId);
293
+
294
+ if (typeof key !== 'string') {
295
+ return key;
296
+ }
297
+
298
+ const stored = await redis.get(key);
299
+
300
+ if (stored === undefined || stored === null) {
301
+ return ok(input, null);
302
+ }
303
+
304
+ try {
305
+ return ok(input, JSON.parse(stored));
306
+ } catch {
307
+ return createBridgeError(input.id, 'CORRUPTED_STORAGE_VALUE', 'Stored data is not valid JSON.');
308
+ }
309
+ }
310
+
311
+ async function saveStorage(input: BridgeRequest): Promise<BridgeResponse> {
312
+ const playerId = currentPlayerId();
313
+
314
+ if (playerId === undefined) {
315
+ return ok(input, {
316
+ saved: false,
317
+ });
318
+ }
319
+
320
+ const key = storageKey(input, playerId);
321
+
322
+ if (typeof key !== 'string') {
323
+ return key;
324
+ }
325
+
326
+ const payload = optionalObjectPayload(input.payload) as { readonly value?: unknown };
327
+
328
+ try {
329
+ await redis.set(key, JSON.stringify(payload.value ?? null));
330
+ } catch (error) {
331
+ console.warn(`devvit storage save was not persisted for key ${key}: ${errorMessage(error)}`);
332
+
333
+ return ok(input, {
334
+ saved: false,
335
+ playerId,
336
+ });
337
+ }
338
+
339
+ return ok(input, {
340
+ saved: true,
341
+ playerId,
342
+ });
343
+ }
344
+
345
+ function requestIdFromBody(input: unknown): string {
346
+ if (typeof input !== 'object' || input === null) {
347
+ return 'unknown';
348
+ }
349
+
350
+ const candidate = input as { readonly id?: unknown };
351
+ return typeof candidate.id === 'string' ? candidate.id : 'unknown';
352
+ }
353
+
354
+ function ok(input: BridgeRequest, data: unknown): BridgeResponse {
355
+ return {
356
+ id: input.id,
357
+ ok: true,
358
+ data,
359
+ };
360
+ }
361
+
362
+ function storageKey(input: BridgeRequest, playerId: string): string | BridgeResponse {
363
+ const payload = optionalObjectPayload(input.payload);
364
+
365
+ if (typeof payload.key !== 'string' || payload.key.length === 0) {
366
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Storage key is required.');
367
+ }
368
+
369
+ if (payload.key.length > maxStorageKeyLength) {
370
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Storage key is too long.');
371
+ }
372
+
373
+ const encodedKey = encodeURIComponent(payload.key);
374
+
375
+ if (encodedKey.length > maxEncodedStorageKeyLength) {
376
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Encoded storage key is too long.');
377
+ }
378
+
379
+ return `${gameName}:save:${encodeURIComponent(playerId)}:${encodedKey}`;
380
+ }
381
+
382
+ function leaderboardIdFromPayload(
383
+ input: BridgeRequest,
384
+ value: unknown,
385
+ ): string | BridgeResponse {
386
+ if (value === undefined) {
387
+ return 'default';
388
+ }
389
+
390
+ if (typeof value !== 'string' || !isValidRedisKeyComponent(value)) {
391
+ return createBridgeError(
392
+ input.id,
393
+ 'INVALID_LEADERBOARD_ID',
394
+ 'Leaderboard id format is invalid.',
395
+ );
396
+ }
397
+
398
+ return value;
399
+ }
400
+
401
+ function leaderboardKey(leaderboardId: string): string {
402
+ return `${gameName}:leaderboard:${encodeURIComponent(leaderboardId)}`;
403
+ }
404
+
405
+ async function submitMaxLeaderboardScore(
406
+ redisKey: string,
407
+ playerId: string,
408
+ score: number,
409
+ ): Promise<'updated' | 'unchanged' | 'failed'> {
410
+ const lockKey = leaderboardLockKey(redisKey, playerId);
411
+ const startedAt = Date.now();
412
+ let attempt = 0;
413
+
414
+ while (Date.now() - startedAt <= leaderboardLockRetryBudgetMs) {
415
+ const lockToken = createLockToken();
416
+ const acquiredLock = await acquireLeaderboardLock(lockKey, lockToken);
417
+
418
+ if (!acquiredLock) {
419
+ await delay(nextLeaderboardRetryDelay(attempt, startedAt));
420
+ attempt += 1;
421
+ continue;
422
+ }
423
+
424
+ try {
425
+ const currentScore = await redis.zScore(redisKey, playerId);
426
+
427
+ if (currentScore !== undefined && score <= currentScore) {
428
+ return 'unchanged';
429
+ }
430
+
431
+ if (await writeLeaderboardScoreIfLockHeld(lockKey, lockToken, redisKey, playerId, score)) {
432
+ return 'updated';
433
+ }
434
+
435
+ await delay(nextLeaderboardRetryDelay(attempt, startedAt));
436
+ attempt += 1;
437
+ } finally {
438
+ try {
439
+ await releaseLeaderboardLock(lockKey, lockToken);
440
+ } catch (error) {
441
+ console.warn(`devvit leaderboard lock release failed: ${errorMessage(error)}`);
442
+ }
443
+ }
444
+ }
445
+
446
+ console.warn(`devvit leaderboard lock contention exceeded retry budget for key: ${lockKey}`);
447
+
448
+ return 'failed';
449
+ }
450
+
451
+ function leaderboardLockKey(redisKey: string, playerId: string): string {
452
+ return `${redisKey}:lock:${encodeURIComponent(playerId)}`;
453
+ }
454
+
455
+ function createLockToken(): string {
456
+ const cryptoImpl = globalThis.crypto;
457
+
458
+ if (typeof cryptoImpl?.randomUUID === 'function') {
459
+ return cryptoImpl.randomUUID();
460
+ }
461
+
462
+ if (typeof cryptoImpl?.getRandomValues === 'function') {
463
+ const values = new Uint32Array(4);
464
+ cryptoImpl.getRandomValues(values);
465
+
466
+ return Array.from(values, (value) => value.toString(36).padStart(7, '0')).join('');
467
+ }
468
+
469
+ throw new Error('Web Crypto is required to create a Devvit leaderboard lock token.');
470
+ }
471
+
472
+ async function acquireLeaderboardLock(lockKey: string, lockToken: string): Promise<boolean> {
473
+ const result = await redis.set(lockKey, lockToken, {
474
+ nx: true,
475
+ expiration: leaderboardLockExpirationDate(),
476
+ });
477
+
478
+ return result === 'OK';
479
+ }
480
+
481
+ function leaderboardLockExpirationDate(): Date {
482
+ // Devvit Redis SetOptions expects a Date and converts it to Redis EX seconds internally.
483
+ return new Date(Date.now() + leaderboardLockTtlSeconds * 1_000);
484
+ }
485
+
486
+ async function writeLeaderboardScoreIfLockHeld(
487
+ lockKey: string,
488
+ lockToken: string,
489
+ redisKey: string,
490
+ playerId: string,
491
+ score: number,
492
+ ): Promise<boolean> {
493
+ const transaction = await redis.watch(lockKey);
494
+ const currentToken = await redis.get(lockKey);
495
+
496
+ if (currentToken !== lockToken) {
497
+ await transaction.unwatch();
498
+ return false;
499
+ }
500
+
501
+ await transaction.multi();
502
+ await transaction.zAdd(redisKey, {
503
+ member: playerId,
504
+ score,
505
+ });
506
+
507
+ const results = await transaction.exec();
508
+
509
+ return Array.isArray(results) && results.length > 0;
510
+ }
511
+
512
+ async function releaseLeaderboardLock(lockKey: string, lockToken: string): Promise<void> {
513
+ for (let attempt = 0; attempt < leaderboardUpdateMaxAttempts; attempt += 1) {
514
+ const transaction = await redis.watch(lockKey);
515
+ const currentToken = await redis.get(lockKey);
516
+
517
+ if (currentToken !== lockToken) {
518
+ await transaction.unwatch();
519
+ return;
520
+ }
521
+
522
+ await transaction.multi();
523
+ await transaction.del(lockKey);
524
+
525
+ const results = await transaction.exec();
526
+
527
+ if (Array.isArray(results) && results.length > 0) {
528
+ return;
529
+ }
530
+
531
+ await delay(leaderboardBackoffBaseMs * (attempt + 1));
532
+ }
533
+
534
+ console.warn(`devvit leaderboard lock release exhausted retries for key: ${lockKey}`);
535
+ }
536
+
537
+ function delay(ms: number): Promise<void> {
538
+ return new Promise((resolve) => {
539
+ setTimeout(resolve, ms);
540
+ });
541
+ }
542
+
543
+ function nextLeaderboardRetryDelay(attempt: number, startedAt: number): number {
544
+ const elapsed = Date.now() - startedAt;
545
+ const remaining = Math.max(0, leaderboardLockRetryBudgetMs - elapsed);
546
+ const backoff = leaderboardBackoffBaseMs * (attempt + 1);
547
+
548
+ return Math.min(backoff, remaining);
549
+ }
550
+
551
+ function isValidRedisKeyComponent(value: string): boolean {
552
+ return redisKeyComponentPattern.test(value);
553
+ }
554
+
555
+ function errorMessage(error: unknown): string {
556
+ return error instanceof Error ? error.message : String(error);
557
+ }
558
+
559
+ function currentPlayerId(): string | undefined {
560
+ const devvitContext = context as {
561
+ readonly userId?: string;
562
+ };
563
+
564
+ return devvitContext.userId;
565
+ }
566
+
567
+ async function currentDisplayName(fallbackDisplayName: string): Promise<string> {
568
+ try {
569
+ const username = await reddit.getCurrentUsername();
570
+
571
+ if (typeof username === 'string' && username.length > 0) {
572
+ return username;
573
+ }
574
+ } catch (error) {
575
+ console.warn(`devvit username lookup failed: ${errorMessage(error)}`);
576
+ }
577
+
578
+ return fallbackDisplayName;
579
+ }
580
+
581
+ function currentSubredditName(): string | undefined {
582
+ const devvitContext = context as {
583
+ readonly subredditName?: string;
584
+ };
585
+
586
+ return typeof devvitContext.subredditName === 'string' && devvitContext.subredditName.length > 0
587
+ ? devvitContext.subredditName
588
+ : undefined;
589
+ }
590
+
591
+ function optionalObjectPayload(payload: unknown): Record<string, unknown> {
592
+ if (typeof payload !== 'object' || payload === null) {
593
+ return {};
594
+ }
595
+
596
+ return payload as Record<string, unknown>;
597
+ }
598
+
599
+ function expressRequestToFetchRequest(request: ExpressRequest): Request {
600
+ const headers = new Headers();
601
+
602
+ for (const [key, value] of Object.entries(request.headers)) {
603
+ if (typeof value === 'string') {
604
+ headers.set(key, value);
605
+ } else if (Array.isArray(value)) {
606
+ headers.set(key, value.join(', '));
607
+ }
608
+ }
609
+
610
+ const init: RequestInit = {
611
+ method: request.method,
612
+ headers,
613
+ };
614
+
615
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
616
+ if (!headers.has('content-type')) {
617
+ headers.set('content-type', 'application/json');
618
+ }
619
+
620
+ init.body = requestBodyToBodyInit(request.body);
621
+ }
622
+
623
+ return new Request(expressRequestUrl(request), init);
624
+ }
625
+
626
+ function requestBodyToBodyInit(input: unknown): BodyInit {
627
+ if (input instanceof Uint8Array) {
628
+ const body = new Uint8Array(input.byteLength);
629
+ body.set(input);
630
+
631
+ return body.buffer as ArrayBuffer;
632
+ }
633
+
634
+ return JSON.stringify(input ?? null);
635
+ }
636
+
637
+ function expressRequestUrl(request: ExpressRequest): string {
638
+ const host = request.get('host') ?? 'localhost';
639
+ const protocol = request.protocol || 'https';
640
+
641
+ return `${protocol}://${host}${request.originalUrl}`;
642
+ }
643
+
644
+ async function sendFetchResponse(
645
+ response: ExpressResponse,
646
+ fetchResponse: Response,
647
+ ): Promise<void> {
648
+ fetchResponse.headers.forEach((value: string, key: string) => {
649
+ if (!expressManagedResponseHeaders.has(key.toLowerCase())) {
650
+ response.setHeader(key, value);
651
+ }
652
+ });
653
+
654
+ response.status(fetchResponse.status);
655
+ response.send(await fetchResponse.text());
656
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowSyntheticDefaultImports": true,
5
+ "types": ["node"],
6
+ "noEmit": true
7
+ },
8
+ "include": ["src/**/*.ts", "vite.server.config.ts"]
9
+ }
@@ -0,0 +1,36 @@
1
+ import { builtinModules } from 'node:module';
2
+
3
+ import ttsc from '@ttsc/unplugin/vite';
4
+ import { defineConfig } from 'vite';
5
+
6
+ const externalBuiltins = [
7
+ ...builtinModules,
8
+ ...builtinModules.map((moduleName) => `node:${moduleName}`),
9
+ ];
10
+
11
+ export default defineConfig({
12
+ plugins: [
13
+ ttsc({
14
+ project: 'tsconfig.json',
15
+ plugins: false,
16
+ }),
17
+ ],
18
+ ssr: {
19
+ noExternal: true,
20
+ },
21
+ build: {
22
+ ssr: 'src/server/index.ts',
23
+ outDir: 'dist/server',
24
+ target: 'node22',
25
+ sourcemap: false,
26
+ emptyOutDir: true,
27
+ rollupOptions: {
28
+ external: externalBuiltins,
29
+ output: {
30
+ format: 'cjs',
31
+ entryFileNames: 'index.cjs',
32
+ inlineDynamicImports: true,
33
+ },
34
+ },
35
+ },
36
+ });
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ apps/*/dist/
4
+ artifacts/
5
+ release-output/
6
+ .mpgd.targets.generated.json
7
+ .env
8
+ .env.*
9
+ apps/*/.env
10
+ .DS_Store
11
+ *.log
12
+ *.ait
@@ -33,9 +33,9 @@
33
33
  "reddit": {
34
34
  "kind": "devvit-web",
35
35
  "gameApp": ".",
36
- "wrapperApp": "${MPGD_KIT_PATH}/apps/target-devvit",
36
+ "wrapperApp": "apps/target-devvit",
37
37
  "adapter": "devvit",
38
- "webDir": "${MPGD_KIT_PATH}/apps/target-devvit/dist/client",
38
+ "webDir": "apps/target-devvit/dist/client",
39
39
  "artifact": "devvit"
40
40
  }
41
41
  }
@@ -7,6 +7,16 @@
7
7
  "scripts": {
8
8
  "dev": "__WORKSPACE_I18N_BUILD_PREFIX__vite --host 0.0.0.0",
9
9
  "build": "__WORKSPACE_I18N_BUILD_PREFIX__vite build",
10
+ "build:devvit": "pnpm exec mpgd target build reddit production --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
11
+ "build:devvit:staging": "pnpm exec mpgd target build reddit staging --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
12
+ "smoke:devvit": "pnpm exec mpgd target smoke reddit --targets-file ./mpgd.targets.json --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
13
+ "devvit:init": "pnpm --dir apps/target-devvit run init",
14
+ "devvit:login": "pnpm --dir apps/target-devvit exec devvit login",
15
+ "devvit:login:copy-paste": "pnpm --dir apps/target-devvit exec devvit login --copy-paste",
16
+ "devvit:whoami": "pnpm --dir apps/target-devvit run whoami",
17
+ "devvit:upload": "pnpm --dir apps/target-devvit run upload",
18
+ "devvit:playtest": "pnpm --dir apps/target-devvit run dev",
19
+ "devvit:publish": "pnpm --dir apps/target-devvit run publish",
10
20
  "check": "ttsc --noEmit -p tsconfig.json",
11
21
  "lint": "ttsc --noEmit -p tsconfig.json",
12
22
  "format": "ttsc format",
@@ -26,6 +36,7 @@
26
36
  "phaser": "4.2.0"
27
37
  },
28
38
  "devDependencies": {
39
+ "@mpgd/cli": "__MPGD_DEPENDENCY_VERSION__",
29
40
  "@ttsc/unplugin": "0.16.9",
30
41
  "@types/node": "^24.0.0",
31
42
  "ttsc": "0.16.9",
@@ -0,0 +1,7 @@
1
+ packages:
2
+ - '.'
3
+ - 'apps/*'
4
+ __PNPM_WORKSPACE_KIT_PACKAGES__
5
+ allowBuilds:
6
+ esbuild: true
7
+ protobufjs: true
@@ -1,6 +1,6 @@
1
1
  import type { AdPlacements, ProductCatalog } from '@mpgd/catalog';
2
- import adPlacementsJson from '@mpgd/catalog/placements.json';
3
2
  import productCatalogJson from '@mpgd/catalog/catalog.json';
3
+ import adPlacementsJson from '@mpgd/catalog/placements.json';
4
4
  import type { PlatformGateway } from '@mpgd/platform';
5
5
  import {
6
6
  createEffectiveTargetConfig,