@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 +54 -8
- package/package.json +1 -1
- package/templates/phaser-game/README.md +49 -8
- package/templates/phaser-game/agent/acceptance.md +5 -3
- package/templates/phaser-game/agent/brief.md +4 -1
- package/templates/phaser-game/agent/game-manifest.json +5 -1
- package/templates/phaser-game/apps/target-devvit/README.md +30 -0
- package/templates/phaser-game/apps/target-devvit/devvit.json +40 -0
- package/templates/phaser-game/apps/target-devvit/package.json +39 -0
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +656 -0
- package/templates/phaser-game/apps/target-devvit/tsconfig.json +9 -0
- package/templates/phaser-game/apps/target-devvit/vite.server.config.ts +36 -0
- package/templates/phaser-game/gitignore +12 -0
- package/templates/phaser-game/mpgd.targets.json +2 -2
- package/templates/phaser-game/package.json +11 -0
- package/templates/phaser-game/pnpm-workspace.yaml +7 -0
- package/templates/phaser-game/src/platform/installPlatform.ts +1 -1
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 =
|
|
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 =
|
|
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(`
|
|
508
|
-
console.log(
|
|
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
|
-
|
|
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
|
@@ -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
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
|
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,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
|
+
});
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"reddit": {
|
|
34
34
|
"kind": "devvit-web",
|
|
35
35
|
"gameApp": ".",
|
|
36
|
-
"wrapperApp": "
|
|
36
|
+
"wrapperApp": "apps/target-devvit",
|
|
37
37
|
"adapter": "devvit",
|
|
38
|
-
"webDir": "
|
|
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",
|
|
@@ -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,
|