@mpgd/cli 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/game-acceptance.d.ts +65 -0
- package/dist/game-acceptance.js +245 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +398 -32
- package/dist/production-target-readiness.d.ts +8 -0
- package/dist/production-target-readiness.js +207 -0
- package/package.json +15 -15
- package/templates/phaser-game/README.md +71 -1
- package/templates/phaser-game/agent/acceptance.md +12 -6
- package/templates/phaser-game/agent/brief.md +3 -1
- package/templates/phaser-game/agent/game-manifest.json +7 -0
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +3 -3
- package/templates/phaser-game/apps/target-devvit/README.md +70 -4
- package/templates/phaser-game/apps/target-devvit/devvit.json +8 -1
- package/templates/phaser-game/apps/target-devvit/package.json +15 -20
- package/templates/phaser-game/apps/target-devvit/src/client/game.html +13 -0
- package/templates/phaser-game/apps/target-devvit/src/client/game.ts +3 -0
- package/templates/phaser-game/apps/target-devvit/src/client/index.html +13 -0
- package/templates/phaser-game/apps/target-devvit/src/client/preview.ts +3 -0
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +228 -116
- package/templates/phaser-game/apps/target-devvit/src/server/postOperationStore.ts +9 -0
- package/templates/phaser-game/apps/target-devvit/tsconfig.json +7 -2
- package/templates/phaser-game/apps/target-devvit/vite.config.ts +41 -0
- package/templates/phaser-game/game.html +13 -0
- package/templates/phaser-game/gitignore +2 -0
- package/templates/phaser-game/index.html +2 -2
- package/templates/phaser-game/mpgd.game.json +8 -0
- package/templates/phaser-game/mpgd.targets.json +7 -0
- package/templates/phaser-game/package.json +10 -4
- package/templates/phaser-game/public/manifest.webmanifest +1 -0
- package/templates/phaser-game/src/entry.ts +7 -0
- package/templates/phaser-game/src/env.d.ts +3 -0
- package/templates/phaser-game/src/gameEntry.ts +3 -0
- package/templates/phaser-game/src/main.ts +11 -2
- package/templates/phaser-game/src/platform/buildGatewayModule.ts +5 -0
- package/templates/phaser-game/src/platform/buildGateways/ait.ts +11 -0
- package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +12 -0
- package/templates/phaser-game/src/platform/buildGateways/browser.ts +8 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +12 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +12 -0
- package/templates/phaser-game/src/platform/buildGateways/reddit.ts +11 -0
- package/templates/phaser-game/src/platform/buildGateways/redditSandbox.ts +15 -0
- package/templates/phaser-game/src/platform/devvitEntrypoint.ts +57 -0
- package/templates/phaser-game/src/platform/devvitInlinePreview.css +75 -0
- package/templates/phaser-game/src/platform/gameServices.ts +18 -61
- package/templates/phaser-game/src/platform/installPlatform.ts +6 -50
- package/templates/phaser-game/src/platform/microsoftStorePwa.ts +99 -0
- package/templates/phaser-game/tools/run-game-acceptance.mjs +22 -0
- package/templates/phaser-game/vite.config.ts +24 -73
- package/templates/phaser-game/vite.shared.ts +217 -0
- package/templates/phaser-game/apps/target-devvit/vite.server.config.ts +0 -37
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const ownerPathByTarget = {
|
|
5
|
+
ait: 'wrapperApp',
|
|
6
|
+
android: 'shellApp',
|
|
7
|
+
ios: 'shellApp',
|
|
8
|
+
};
|
|
9
|
+
export function assertProductionTargetReadiness(input) {
|
|
10
|
+
if (typeof input.profile !== 'string'
|
|
11
|
+
|| input.profile.length === 0
|
|
12
|
+
|| input.profile.trim() !== input.profile) {
|
|
13
|
+
throw new Error('Target build profile must be a non-empty string without surrounding whitespace.');
|
|
14
|
+
}
|
|
15
|
+
if (input.profile !== 'production') {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const ownerPathKey = ownerPathByTarget[input.target];
|
|
19
|
+
if (ownerPathKey === undefined) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const targetConfig = readTargets(input.targetsFile)[input.target];
|
|
23
|
+
if (!isRecord(targetConfig)) {
|
|
24
|
+
throw new Error(`Missing target configuration for ${input.target}.`);
|
|
25
|
+
}
|
|
26
|
+
const ownerPath = targetConfig[ownerPathKey];
|
|
27
|
+
const webDir = targetConfig.webDir;
|
|
28
|
+
if (typeof ownerPath !== 'string' || ownerPath.length === 0) {
|
|
29
|
+
throw new Error(`Production target ${input.target} is missing ${ownerPathKey}.`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof webDir !== 'string' || webDir.length === 0) {
|
|
32
|
+
throw new Error(`Production target ${input.target} is missing webDir.`);
|
|
33
|
+
}
|
|
34
|
+
const resolvedOwnerPath = path.resolve(path.dirname(input.targetsFile), ownerPath);
|
|
35
|
+
const resolvedWebDir = path.resolve(path.dirname(input.targetsFile), webDir);
|
|
36
|
+
const canonicalGameRoot = readCanonicalPath(input.gameRoot, 'game root');
|
|
37
|
+
const canonicalOwnerPath = readCanonicalPath(resolvedOwnerPath, `${input.target} ${ownerPathKey}`);
|
|
38
|
+
if (!isDedicatedChildPath(canonicalGameRoot, canonicalOwnerPath)) {
|
|
39
|
+
throw new Error(`Production target ${input.target} must use a game-owned ${ownerPathKey} inside `
|
|
40
|
+
+ `${canonicalGameRoot}; received ${canonicalOwnerPath}. `
|
|
41
|
+
+ 'Use a non-production profile for kit reference wrapper/shell smoke builds.');
|
|
42
|
+
}
|
|
43
|
+
const canonicalWebDir = readCanonicalPathAllowingMissingTail(resolvedWebDir, `${input.target} webDir`);
|
|
44
|
+
if (!isDedicatedChildPath(canonicalOwnerPath, canonicalWebDir)) {
|
|
45
|
+
throw new Error(`Production target ${input.target} must keep webDir inside its game-owned `
|
|
46
|
+
+ `${ownerPathKey} ${canonicalOwnerPath}; received ${canonicalWebDir}.`);
|
|
47
|
+
}
|
|
48
|
+
if (input.target === 'android' || input.target === 'ios') {
|
|
49
|
+
const canonicalPlatformDir = readCanonicalPathAllowingMissingTail(path.join(resolvedOwnerPath, input.target), `${input.target} native platform directory`);
|
|
50
|
+
if (!isDedicatedChildPath(canonicalOwnerPath, canonicalPlatformDir)) {
|
|
51
|
+
throw new Error(`Production target ${input.target} must keep its native platform directory inside `
|
|
52
|
+
+ `${canonicalOwnerPath}; received ${canonicalPlatformDir}.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target);
|
|
56
|
+
}
|
|
57
|
+
function readTargets(targetsFile) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(readFileSync(targetsFile, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error(`Failed to read prepared targets file ${targetsFile}: ${formatError(error)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(parsed) || !isRecord(parsed.targets)) {
|
|
66
|
+
throw new Error(`Prepared targets file ${targetsFile} must contain a targets object.`);
|
|
67
|
+
}
|
|
68
|
+
return parsed.targets;
|
|
69
|
+
}
|
|
70
|
+
// Equality is rejected: a wrapper or shell must be a dedicated child of the game root.
|
|
71
|
+
function isDedicatedChildPath(parent, candidate) {
|
|
72
|
+
const relative = path.relative(parent, candidate);
|
|
73
|
+
return relative.length > 0
|
|
74
|
+
&& relative !== '..'
|
|
75
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
76
|
+
&& !path.isAbsolute(relative);
|
|
77
|
+
}
|
|
78
|
+
function readCanonicalPath(candidate, label) {
|
|
79
|
+
try {
|
|
80
|
+
return realpathSync(candidate);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw new Error(`Production ${label} must exist: ${candidate} (${formatError(error)})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function readCanonicalPathAllowingMissingTail(candidate, label) {
|
|
87
|
+
let existingPath = candidate;
|
|
88
|
+
const missingSegments = [];
|
|
89
|
+
while (!existsSync(existingPath)) {
|
|
90
|
+
const parent = path.dirname(existingPath);
|
|
91
|
+
if (parent === existingPath) {
|
|
92
|
+
throw new Error(`Production ${label} has no existing ancestor: ${candidate}`);
|
|
93
|
+
}
|
|
94
|
+
missingSegments.unshift(path.basename(existingPath));
|
|
95
|
+
existingPath = parent;
|
|
96
|
+
}
|
|
97
|
+
return path.join(readCanonicalPath(existingPath, label), ...missingSegments);
|
|
98
|
+
}
|
|
99
|
+
function assertAuthoritativeGameServicesUrl(value, target) {
|
|
100
|
+
if (value === undefined || value.length === 0) {
|
|
101
|
+
throw new Error(`Production target ${target} requires VITE_MPGD_GAME_SERVICES_URL for an `
|
|
102
|
+
+ 'authoritative remote backend.');
|
|
103
|
+
}
|
|
104
|
+
let url;
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(value);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`Production target ${target} has an invalid game-services URL.`);
|
|
110
|
+
}
|
|
111
|
+
if (url.protocol !== 'https:'
|
|
112
|
+
|| url.username.length > 0
|
|
113
|
+
|| url.password.length > 0
|
|
114
|
+
|| isNonPublicServiceHostname(url.hostname)) {
|
|
115
|
+
throw new Error(`Production target ${target} requires a public HTTPS game-services URL `
|
|
116
|
+
+ 'without embedded credentials.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function isNonPublicServiceHostname(hostname) {
|
|
120
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/u, '');
|
|
121
|
+
if (normalized === 'localhost'
|
|
122
|
+
|| normalized.endsWith('.localhost')
|
|
123
|
+
|| normalized.endsWith('.local')) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const ipVersion = isIP(normalized);
|
|
127
|
+
if (ipVersion === 4) {
|
|
128
|
+
return isNonPublicIpv4(normalized);
|
|
129
|
+
}
|
|
130
|
+
if (ipVersion === 6) {
|
|
131
|
+
return isNonPublicIpv6(normalized);
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function isNonPublicIpv4(address) {
|
|
136
|
+
const [first = 0, second = 0, third = 0] = address.split('.').map(Number);
|
|
137
|
+
return first === 0
|
|
138
|
+
|| first === 10
|
|
139
|
+
|| first === 127
|
|
140
|
+
|| (first === 100 && second >= 64 && second <= 127)
|
|
141
|
+
|| (first === 169 && second === 254)
|
|
142
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
143
|
+
|| (first === 192 && second === 0 && third === 0)
|
|
144
|
+
|| (first === 192 && second === 0 && third === 2)
|
|
145
|
+
|| (first === 192 && second === 168)
|
|
146
|
+
|| (first === 198 && (second === 18 || second === 19))
|
|
147
|
+
|| (first === 198 && second === 51 && third === 100)
|
|
148
|
+
|| (first === 203 && second === 0 && third === 113)
|
|
149
|
+
|| first >= 224;
|
|
150
|
+
}
|
|
151
|
+
function isNonPublicIpv6(address) {
|
|
152
|
+
const words = expandIpv6(address);
|
|
153
|
+
if (words === undefined) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (words.slice(0, 7).every((word) => word === 0)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
|
|
160
|
+
return isNonPublicIpv4(`${(words[6] ?? 0) >> 8}.${(words[6] ?? 0) & 0xff}.`
|
|
161
|
+
+ `${(words[7] ?? 0) >> 8}.${(words[7] ?? 0) & 0xff}`);
|
|
162
|
+
}
|
|
163
|
+
if (words.slice(0, 6).every((word) => word === 0)) {
|
|
164
|
+
return isNonPublicIpv4(`${(words[6] ?? 0) >> 8}.${(words[6] ?? 0) & 0xff}.`
|
|
165
|
+
+ `${(words[7] ?? 0) >> 8}.${(words[7] ?? 0) & 0xff}`);
|
|
166
|
+
}
|
|
167
|
+
const first = words[0] ?? 0;
|
|
168
|
+
return (first & 0xfe00) === 0xfc00
|
|
169
|
+
|| (first & 0xffc0) === 0xfe80
|
|
170
|
+
|| (first & 0xff00) === 0xff00
|
|
171
|
+
|| (first === 0x2001 && words[1] === 0x0db8);
|
|
172
|
+
}
|
|
173
|
+
function expandIpv6(address) {
|
|
174
|
+
const sections = address.split('::');
|
|
175
|
+
if (sections.length > 2) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const head = ipv6Words(sections[0] ?? '');
|
|
179
|
+
const tail = ipv6Words(sections[1] ?? '');
|
|
180
|
+
if (head === undefined || tail === undefined) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const omitted = 8 - head.length - tail.length;
|
|
184
|
+
if ((sections.length === 1 && omitted !== 0) || (sections.length === 2 && omitted < 1)) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return [...head, ...Array.from({ length: omitted }, () => 0), ...tail];
|
|
188
|
+
}
|
|
189
|
+
function ipv6Words(section) {
|
|
190
|
+
if (section.length === 0) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const words = [];
|
|
194
|
+
for (const segment of section.split(':')) {
|
|
195
|
+
if (!/^[0-9a-f]{1,4}$/u.test(segment)) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
words.push(Number.parseInt(segment, 16));
|
|
199
|
+
}
|
|
200
|
+
return words;
|
|
201
|
+
}
|
|
202
|
+
function isRecord(value) {
|
|
203
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
204
|
+
}
|
|
205
|
+
function formatError(error) {
|
|
206
|
+
return error instanceof Error ? error.message : String(error);
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -47,20 +47,20 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/node": "^24.0.0",
|
|
50
|
-
"ttsc": "0.
|
|
51
|
-
"typescript": "7.0.
|
|
52
|
-
"@mpgd/adapter-
|
|
53
|
-
"@mpgd/adapter-
|
|
54
|
-
"@mpgd/adapter-capacitor": "0.4.
|
|
55
|
-
"@mpgd/
|
|
56
|
-
"@mpgd/
|
|
57
|
-
"@mpgd/bridge": "0.
|
|
58
|
-
"@mpgd/
|
|
59
|
-
"@mpgd/
|
|
60
|
-
"@mpgd/
|
|
61
|
-
"@mpgd/phaser-assets": "0.4.
|
|
62
|
-
"@mpgd/platform": "0.
|
|
63
|
-
"@mpgd/target-config": "0.
|
|
50
|
+
"ttsc": "0.18.4",
|
|
51
|
+
"typescript": "7.0.2",
|
|
52
|
+
"@mpgd/adapter-ait": "0.4.2",
|
|
53
|
+
"@mpgd/adapter-browser": "0.4.2",
|
|
54
|
+
"@mpgd/adapter-capacitor": "0.4.2",
|
|
55
|
+
"@mpgd/adapter-devvit": "0.6.0",
|
|
56
|
+
"@mpgd/analytics": "0.3.5",
|
|
57
|
+
"@mpgd/bridge": "0.6.0",
|
|
58
|
+
"@mpgd/game-services": "0.6.0",
|
|
59
|
+
"@mpgd/catalog": "0.3.5",
|
|
60
|
+
"@mpgd/i18n": "0.5.1",
|
|
61
|
+
"@mpgd/phaser-assets": "0.4.1",
|
|
62
|
+
"@mpgd/platform": "0.5.1",
|
|
63
|
+
"@mpgd/target-config": "0.6.1"
|
|
64
64
|
},
|
|
65
65
|
"main": "./dist/index.js",
|
|
66
66
|
"types": "./dist/index.d.ts",
|
|
@@ -32,6 +32,7 @@ pnpm exec mpgd target build-all \
|
|
|
32
32
|
--targets-file ./mpgd.targets.json \
|
|
33
33
|
--kit-path ../mpgd-kit \
|
|
34
34
|
--targets web,microsoft-store,ait,reddit \
|
|
35
|
+
--profile staging \
|
|
35
36
|
--ait-variant wrapper
|
|
36
37
|
pnpm exec mpgd target smoke-all \
|
|
37
38
|
--targets-file ./mpgd.targets.json \
|
|
@@ -51,6 +52,7 @@ pnpm --dir ../mpgd-kit mpgd target build-all \
|
|
|
51
52
|
--targets-file "$PWD/mpgd.targets.json" \
|
|
52
53
|
--kit-path ../mpgd-kit \
|
|
53
54
|
--targets web,microsoft-store,ait,reddit \
|
|
55
|
+
--profile staging \
|
|
54
56
|
--ait-variant wrapper
|
|
55
57
|
```
|
|
56
58
|
|
|
@@ -58,10 +60,37 @@ pnpm --dir ../mpgd-kit mpgd target build-all \
|
|
|
58
60
|
into `.mpgd.targets.generated.json` before calling the kit's existing target
|
|
59
61
|
build and smoke scripts.
|
|
60
62
|
|
|
63
|
+
The starter's Apps in Toss and Capacitor entries intentionally point at kit
|
|
64
|
+
reference wrappers, so their reusable validation builds use the `staging`
|
|
65
|
+
profile. Before an AIT, Android, or iOS production build, copy or create the
|
|
66
|
+
wrapper/shell under this game directory, update `mpgd.targets.json`, and set
|
|
67
|
+
`VITE_MPGD_GAME_SERVICES_URL` to a public HTTPS endpoint without embedded
|
|
68
|
+
credentials. Production preflight resolves real paths, rejects wrappers or
|
|
69
|
+
shells outside the game root (including symbolic-link escapes), and blocks
|
|
70
|
+
localhost plus literal private or reserved IP addresses.
|
|
71
|
+
|
|
61
72
|
`mpgd target doctor` verifies that release manifest artifact paths stay under
|
|
62
73
|
this game directory, even when a build used the kit reference Apps in Toss or
|
|
63
74
|
Capacitor wrappers.
|
|
64
75
|
|
|
76
|
+
## Acceptance Handoff
|
|
77
|
+
|
|
78
|
+
Run the reusable acceptance workflow before handing the game off:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
pnpm accept
|
|
82
|
+
```
|
|
83
|
+
|
|
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.
|
|
90
|
+
Each command has a 30-minute timeout by default. Pass `--timeout-ms` to the
|
|
91
|
+
underlying `mpgd game accept` command when a target matrix needs a different
|
|
92
|
+
per-step limit.
|
|
93
|
+
|
|
65
94
|
## Assets
|
|
66
95
|
|
|
67
96
|
Static assets live under `src/assets` and are declared in
|
|
@@ -116,6 +145,24 @@ soft rotate prompt on mismatch. The generated PWA manifest also declares
|
|
|
116
145
|
that manifest value aligned with the runtime policy if you make a portrait-first
|
|
117
146
|
game.
|
|
118
147
|
|
|
148
|
+
## App Icons
|
|
149
|
+
|
|
150
|
+
`mpgd.game.json` owns one canonical PNG or SVG app icon source. Target profiles
|
|
151
|
+
derive each platform's required sizes, safe zones, alpha policy, and native or
|
|
152
|
+
wrapper staging files.
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
pnpm icons:generate
|
|
156
|
+
pnpm icons:verify
|
|
157
|
+
pnpm icons:inspect
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Use each target's optional `icon` object in `mpgd.targets.json` for a profile,
|
|
161
|
+
background, source, or variant override. The release evidence still records the
|
|
162
|
+
shared canonical source separately from an override render source. Before an
|
|
163
|
+
Apps in Toss production package build, upload the generated 600x600 icon in the
|
|
164
|
+
console and configure the returned HTTPS URL as `ait.icon.externalUrl`.
|
|
165
|
+
|
|
119
166
|
## Reddit Devvit
|
|
120
167
|
|
|
121
168
|
This starter owns its Devvit app root in `apps/target-devvit`. That directory
|
|
@@ -126,10 +173,17 @@ The generated `devvit.json` app name is a Devvit-safe slug derived from the game
|
|
|
126
173
|
directory name; edit it before `devvit:init` if you need a different Reddit app
|
|
127
174
|
name.
|
|
128
175
|
|
|
176
|
+
The default post entry builds a lightweight inline preview without importing
|
|
177
|
+
Phaser. Its Play button requests the separate `game` entrypoint, whose
|
|
178
|
+
`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.
|
|
181
|
+
|
|
129
182
|
First-time Devvit setup:
|
|
130
183
|
|
|
131
184
|
```sh
|
|
132
185
|
pnpm install -w
|
|
186
|
+
pnpm icons:generate:devvit:production
|
|
133
187
|
pnpm devvit:login
|
|
134
188
|
pnpm devvit:whoami
|
|
135
189
|
pnpm devvit:init
|
|
@@ -218,7 +272,14 @@ submission.
|
|
|
218
272
|
- Microsoft Store is a game-owned PWA target that reuses the browser adapter and
|
|
219
273
|
writes to `artifacts/microsoft-store` with `manifest.webmanifest` for
|
|
220
274
|
PWABuilder packaging. Replace the starter icon and manifest metadata before
|
|
221
|
-
Store submission.
|
|
275
|
+
Store submission. Production builds also emit `pwa-release.json` and an
|
|
276
|
+
atomic offline `service-worker.js`; its deterministic revision covers every
|
|
277
|
+
precached artifact plus the game and kit Git provenance. Updates intentionally
|
|
278
|
+
wait while any old app window remains open. Listen for
|
|
279
|
+
`mpgd:pwa-update-ready` to tell players to close every window and reopen the
|
|
280
|
+
app; do not force activation with `skipWaiting()`. Keep the web manifest `id`
|
|
281
|
+
game-specific because the cache namespace uses it to isolate apps sharing an
|
|
282
|
+
origin.
|
|
222
283
|
- Reddit Devvit is game-owned in `apps/target-devvit`.
|
|
223
284
|
- Apps in Toss currently uses the kit reference wrapper at
|
|
224
285
|
`${MPGD_KIT_PATH}/apps/target-ait` for smoke packaging. Before a real Toss
|
|
@@ -242,3 +303,12 @@ VITE_MPGD_GAME_SERVICES_TRANSPORT=http
|
|
|
242
303
|
|
|
243
304
|
Use `VITE_MPGD_GAME_SERVICES_TRANSPORT=orpc` with a `/rpc` URL to test the oRPC
|
|
244
305
|
client path.
|
|
306
|
+
|
|
307
|
+
The generated starter uses `createGameServicesRuntime` for backend selection
|
|
308
|
+
and treats only the exact Vite `production` profile as production authority.
|
|
309
|
+
Production fails closed when the URL is absent or is not public HTTPS without
|
|
310
|
+
credentials: Game Services reports `missing_authoritative_backend` or
|
|
311
|
+
`invalid_authoritative_backend` and does not create a client. Process-local
|
|
312
|
+
backends require both a non-production authority mode and an explicit
|
|
313
|
+
`allowLocalBackend` opt-in, so they cannot manufacture successful production
|
|
314
|
+
purchase, rewarded-ad, or leaderboard ledger results.
|
|
@@ -3,22 +3,28 @@
|
|
|
3
3
|
Run these checks before handing off a generated game starter:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
pnpm
|
|
7
|
-
pnpm
|
|
8
|
-
pnpm
|
|
9
|
-
pnpm --dir ../mpgd-kit mpgd target smoke-all --targets-file "$PWD/mpgd.targets.json" --targets web,microsoft-store,ait,reddit
|
|
10
|
-
pnpm --dir ../mpgd-kit mpgd target doctor --targets-file "$PWD/mpgd.targets.json" --targets web,microsoft-store,ait,reddit
|
|
6
|
+
pnpm icons:generate
|
|
7
|
+
pnpm icons:verify
|
|
8
|
+
pnpm accept
|
|
11
9
|
```
|
|
12
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.
|
|
16
|
+
|
|
13
17
|
For Apps in Toss changes, use the apps-in-toss MCP before implementation and
|
|
14
18
|
keep SDK calls inside adapters or target wrappers.
|
|
15
19
|
Resolve identity and launch intents during bootstrap, treat inbound share data as
|
|
16
20
|
untrusted, and keep notification delivery on the server.
|
|
17
21
|
|
|
18
|
-
For Reddit Devvit changes, keep Devvit SDK calls inside the target wrapper and
|
|
22
|
+
For Reddit Devvit changes, keep Devvit SDK calls inside the adapter or target wrapper and
|
|
19
23
|
continue to expose game-facing behavior through `PlatformGateway`. The Devvit
|
|
20
24
|
app root is game-owned in `apps/target-devvit`; run `pnpm devvit:init` once
|
|
21
25
|
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.
|
|
22
28
|
|
|
23
29
|
For Microsoft Store changes, keep the first pass as a PWA/web target that uses
|
|
24
30
|
the browser adapter. Add a dedicated Store commerce adapter only when wiring
|
|
@@ -18,7 +18,9 @@ Useful commands:
|
|
|
18
18
|
pnpm dev
|
|
19
19
|
pnpm check
|
|
20
20
|
pnpm build
|
|
21
|
-
pnpm
|
|
21
|
+
pnpm icons:generate
|
|
22
|
+
pnpm icons:verify
|
|
23
|
+
pnpm exec mpgd target build-all --targets-file ./mpgd.targets.json --kit-path ../mpgd-kit --targets web,microsoft-store,ait,reddit --profile staging --ait-variant wrapper
|
|
22
24
|
pnpm devvit:login
|
|
23
25
|
pnpm devvit:init
|
|
24
26
|
pnpm devvit:playtest
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"id": "platform.devvit.app-root",
|
|
38
38
|
"owner": "apps/target-devvit"
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
"id": "platform.devvit.surface-split",
|
|
42
|
+
"owner": "src/platform/devvitEntrypoint.ts"
|
|
43
|
+
},
|
|
40
44
|
{
|
|
41
45
|
"id": "services.backend.game-services",
|
|
42
46
|
"owner": "src/platform/gameServices.ts"
|
|
@@ -51,6 +55,9 @@
|
|
|
51
55
|
}
|
|
52
56
|
],
|
|
53
57
|
"acceptance": [
|
|
58
|
+
"pnpm accept",
|
|
59
|
+
"pnpm icons:generate",
|
|
60
|
+
"pnpm icons:verify",
|
|
54
61
|
"pnpm check",
|
|
55
62
|
"pnpm build"
|
|
56
63
|
]
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@cloudflare/workers-types": "^5.20260707.1",
|
|
22
|
-
"@ttsc/unplugin": "0.
|
|
23
|
-
"ttsc": "0.
|
|
24
|
-
"typescript": "7.0.
|
|
22
|
+
"@ttsc/unplugin": "0.18.4",
|
|
23
|
+
"ttsc": "0.18.4",
|
|
24
|
+
"typescript": "7.0.2",
|
|
25
25
|
"vite": "^8.1.3",
|
|
26
26
|
"wrangler": "^4.107.0"
|
|
27
27
|
}
|
|
@@ -25,7 +25,73 @@ available and the CLI asks for a manual code. After that, use
|
|
|
25
25
|
`pnpm devvit:playtest`, `pnpm devvit:upload`, and `pnpm devvit:publish` from the
|
|
26
26
|
game root.
|
|
27
27
|
|
|
28
|
-
The
|
|
29
|
-
|
|
30
|
-
The
|
|
31
|
-
|
|
28
|
+
The official `@devvit/start/vite` plugin builds the game client into
|
|
29
|
+
`dist/client` and the CommonJS server bridge into `dist/server/index.cjs` in one
|
|
30
|
+
pass. The mpgd target build wraps that unified build with release provenance and
|
|
31
|
+
effective-target evidence while keeping Devvit SDK imports out of Phaser scenes.
|
|
32
|
+
The game root pins `@mpgd/cli`, so these commands use the same CLI version as the
|
|
33
|
+
generated starter's other `@mpgd/*` dependencies.
|
|
34
|
+
|
|
35
|
+
The bridge endpoint at `/api/mpgd/rpc` uses the direct oRPC Node HTTP adapter
|
|
36
|
+
from `@mpgd/bridge/orpc/node`; Express, Hono, and Fetch request conversion are
|
|
37
|
+
not required. Devvit-owned menu, scheduler, trigger, and form callbacks remain
|
|
38
|
+
thin `/internal/...` routes and delegate to shared service functions. oRPC
|
|
39
|
+
Publisher helpers can broadcast results after a task completes, but they do not
|
|
40
|
+
replace the `devvit.json` scheduler endpoint and should not use process-local
|
|
41
|
+
memory when delivery must cross instances. A thin `/api/mpgd/bridge` JSON route
|
|
42
|
+
remains available for callers that explicitly configure the adapter's legacy
|
|
43
|
+
`endpoint` option; new traffic uses oRPC by default.
|
|
44
|
+
|
|
45
|
+
The default post entry uses `index.html` for a lightweight inline preview. Its
|
|
46
|
+
Play button requests the `game` entrypoint, which loads the separate
|
|
47
|
+
`game.html` Phaser document. Keep inline UI free of game runtime imports so the
|
|
48
|
+
card remains lightweight before expansion.
|
|
49
|
+
|
|
50
|
+
`devvit playtest` runs the official unified Vite build in watch mode, so a
|
|
51
|
+
separate client/server watcher or staging prebuild is not required.
|
|
52
|
+
|
|
53
|
+
## Durable Post Operations
|
|
54
|
+
|
|
55
|
+
Use `src/server/postOperationStore.ts` with `@mpgd/adapter-devvit/server` for a
|
|
56
|
+
server action or scheduler that may receive the same custom-post operation more
|
|
57
|
+
than once. Use `{ appScope, subredditId }` as the scope, a stable `operationType`
|
|
58
|
+
in the definition, and an `operationId` for each logical publication slot. Validate
|
|
59
|
+
the public payload and launch parameters, then inject the Redis-backed store into
|
|
60
|
+
`createDevvitPostOperationCoordinator`.
|
|
61
|
+
|
|
62
|
+
The contract is duplicate-safe and ambiguity-safe, not exactly-once. It persists
|
|
63
|
+
the attempt before invoking Reddit. If Reddit accepts the post but its response is
|
|
64
|
+
lost, or the process stops before the receipt is saved, repeating `execute` returns
|
|
65
|
+
`reconciliation-required` and never blindly calls the submit function again. An
|
|
66
|
+
explicit recovery endpoint or scheduler calls `reconcile`, which requires an exact
|
|
67
|
+
match of the full canonical envelope. A missing match from a bounded listing
|
|
68
|
+
remains unresolved rather than restoring submit permission.
|
|
69
|
+
|
|
70
|
+
Keep operation definitions and `@devvit/web/server` imports inside this target
|
|
71
|
+
app. The canonical `{ mpgd, launch, payload }` envelope is public and untrusted;
|
|
72
|
+
keep private content and authoritative records in server-side storage. Exercise
|
|
73
|
+
first success, duplicate calls, interruption,
|
|
74
|
+
response loss, lease expiry with concurrent reconciliation, malformed durable
|
|
75
|
+
state, invalid launch metadata, and cross-scope key isolation before enabling a
|
|
76
|
+
scheduled or user-triggered publication flow.
|
|
77
|
+
|
|
78
|
+
## AI-assisted Devvit development
|
|
79
|
+
|
|
80
|
+
Devvit provides an optional MCP server for targeted documentation search and
|
|
81
|
+
deployed-app log inspection. See the official
|
|
82
|
+
[Devvit AI Tools guide](https://developers.reddit.com/docs/guides/ai), then
|
|
83
|
+
register it once in the developer's global Codex configuration:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
codex mcp add devvit -- npx -y @devvit/mcp
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Restart Codex or begin a new task after registering it. Use `devvit_search`
|
|
90
|
+
before broad Devvit documentation exploration. Use the experimental
|
|
91
|
+
`devvit_logs` tool for a deployed app and subreddit when diagnosing production
|
|
92
|
+
or playtest behavior, and confirm its findings with local tests and
|
|
93
|
+
`pnpm devvit:playtest`.
|
|
94
|
+
|
|
95
|
+
The MCP server is an agent-side aid only. It is not required by the generated
|
|
96
|
+
game build, must not be bundled into target artifacts, and does not replace
|
|
97
|
+
Devvit CLI authentication or release verification.
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://developers.reddit.com/schema/config-file.v1.json",
|
|
3
3
|
"name": "__DEVVIT_APP_NAME__",
|
|
4
|
+
"marketingAssets": {
|
|
5
|
+
"icon": "generated/marketing-icon.png"
|
|
6
|
+
},
|
|
4
7
|
"post": {
|
|
5
8
|
"dir": "dist/client",
|
|
6
9
|
"entrypoints": {
|
|
7
10
|
"default": {
|
|
8
11
|
"entry": "index.html",
|
|
9
12
|
"height": "tall"
|
|
13
|
+
},
|
|
14
|
+
"game": {
|
|
15
|
+
"entry": "game.html",
|
|
16
|
+
"height": "tall"
|
|
10
17
|
}
|
|
11
18
|
}
|
|
12
19
|
},
|
|
@@ -34,7 +41,7 @@
|
|
|
34
41
|
]
|
|
35
42
|
},
|
|
36
43
|
"scripts": {
|
|
37
|
-
"dev": "
|
|
44
|
+
"dev": "vite build --mode staging --watch",
|
|
38
45
|
"build": "pnpm build"
|
|
39
46
|
}
|
|
40
47
|
}
|
|
@@ -4,37 +4,32 @@
|
|
|
4
4
|
"version": "0.0.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"dev
|
|
12
|
-
"dev:server": "vite build --config vite.server.config.ts --watch",
|
|
13
|
-
"dev:watch": "concurrently --kill-others --success first \"pnpm run dev:client\" \"pnpm run dev:server\"",
|
|
7
|
+
"prepare:icon": "pnpm --dir ../.. icons:generate:devvit",
|
|
8
|
+
"prepare:icon:production": "pnpm --dir ../.. icons:generate:devvit:production",
|
|
9
|
+
"init": "pnpm run prepare:icon:production && devvit upload --config devvit.json",
|
|
10
|
+
"init:copy-paste": "pnpm run prepare:icon:production && devvit upload --copy-paste --config devvit.json",
|
|
11
|
+
"dev": "pnpm run prepare:icon && devvit playtest --config devvit.json",
|
|
14
12
|
"build": "pnpm --dir ../.. build:devvit",
|
|
15
|
-
"build:server": "vite build --config vite.server.config.ts",
|
|
16
13
|
"whoami": "devvit whoami",
|
|
17
|
-
"upload": "devvit upload --config devvit.json",
|
|
18
|
-
"publish": "devvit publish --config devvit.json",
|
|
14
|
+
"upload": "pnpm run prepare:icon:production && devvit upload --config devvit.json",
|
|
15
|
+
"publish": "pnpm run prepare:icon:production && devvit publish --config devvit.json",
|
|
19
16
|
"check": "ttsc --noEmit -p tsconfig.json",
|
|
20
17
|
"lint": "ttsc --noEmit -p tsconfig.json",
|
|
21
18
|
"format": "ttsc format",
|
|
22
19
|
"fix": "ttsc fix"
|
|
23
20
|
},
|
|
24
21
|
"dependencies": {
|
|
25
|
-
"@devvit/web": "0.13.
|
|
26
|
-
"@mpgd/
|
|
27
|
-
"
|
|
28
|
-
"helmet": "^8.1.0"
|
|
22
|
+
"@devvit/web": "0.13.7",
|
|
23
|
+
"@mpgd/adapter-devvit": "__MPGD_DEPENDENCY_VERSION_ADAPTER_DEVVIT__",
|
|
24
|
+
"@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION_BRIDGE__"
|
|
29
25
|
},
|
|
30
26
|
"devDependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@
|
|
27
|
+
"@devvit/start": "0.13.7",
|
|
28
|
+
"@ttsc/unplugin": "0.18.4",
|
|
33
29
|
"@types/node": "^24.0.0",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"typescript": "7.0.1-rc",
|
|
30
|
+
"devvit": "0.13.7",
|
|
31
|
+
"ttsc": "0.18.4",
|
|
32
|
+
"typescript": "7.0.2",
|
|
38
33
|
"vite": "^8.1.3"
|
|
39
34
|
}
|
|
40
35
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="theme-color" content="#0f172a" />
|
|
7
|
+
<title>__GAME_TITLE__</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="game"></div>
|
|
11
|
+
<script type="module" src="./game.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="theme-color" content="#0f172a" />
|
|
7
|
+
<title>__GAME_TITLE__</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="game"></div>
|
|
11
|
+
<script type="module" src="./preview.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|