@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.
Files changed (51) hide show
  1. package/dist/game-acceptance.d.ts +65 -0
  2. package/dist/game-acceptance.js +245 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +398 -32
  5. package/dist/production-target-readiness.d.ts +8 -0
  6. package/dist/production-target-readiness.js +207 -0
  7. package/package.json +15 -15
  8. package/templates/phaser-game/README.md +71 -1
  9. package/templates/phaser-game/agent/acceptance.md +12 -6
  10. package/templates/phaser-game/agent/brief.md +3 -1
  11. package/templates/phaser-game/agent/game-manifest.json +7 -0
  12. package/templates/phaser-game/apps/target-cloudflare-pages/package.json +3 -3
  13. package/templates/phaser-game/apps/target-devvit/README.md +70 -4
  14. package/templates/phaser-game/apps/target-devvit/devvit.json +8 -1
  15. package/templates/phaser-game/apps/target-devvit/package.json +15 -20
  16. package/templates/phaser-game/apps/target-devvit/src/client/game.html +13 -0
  17. package/templates/phaser-game/apps/target-devvit/src/client/game.ts +3 -0
  18. package/templates/phaser-game/apps/target-devvit/src/client/index.html +13 -0
  19. package/templates/phaser-game/apps/target-devvit/src/client/preview.ts +3 -0
  20. package/templates/phaser-game/apps/target-devvit/src/server/index.ts +228 -116
  21. package/templates/phaser-game/apps/target-devvit/src/server/postOperationStore.ts +9 -0
  22. package/templates/phaser-game/apps/target-devvit/tsconfig.json +7 -2
  23. package/templates/phaser-game/apps/target-devvit/vite.config.ts +41 -0
  24. package/templates/phaser-game/game.html +13 -0
  25. package/templates/phaser-game/gitignore +2 -0
  26. package/templates/phaser-game/index.html +2 -2
  27. package/templates/phaser-game/mpgd.game.json +8 -0
  28. package/templates/phaser-game/mpgd.targets.json +7 -0
  29. package/templates/phaser-game/package.json +10 -4
  30. package/templates/phaser-game/public/manifest.webmanifest +1 -0
  31. package/templates/phaser-game/src/entry.ts +7 -0
  32. package/templates/phaser-game/src/env.d.ts +3 -0
  33. package/templates/phaser-game/src/gameEntry.ts +3 -0
  34. package/templates/phaser-game/src/main.ts +11 -2
  35. package/templates/phaser-game/src/platform/buildGatewayModule.ts +5 -0
  36. package/templates/phaser-game/src/platform/buildGateways/ait.ts +11 -0
  37. package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +12 -0
  38. package/templates/phaser-game/src/platform/buildGateways/browser.ts +8 -0
  39. package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +12 -0
  40. package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +12 -0
  41. package/templates/phaser-game/src/platform/buildGateways/reddit.ts +11 -0
  42. package/templates/phaser-game/src/platform/buildGateways/redditSandbox.ts +15 -0
  43. package/templates/phaser-game/src/platform/devvitEntrypoint.ts +57 -0
  44. package/templates/phaser-game/src/platform/devvitInlinePreview.css +75 -0
  45. package/templates/phaser-game/src/platform/gameServices.ts +18 -61
  46. package/templates/phaser-game/src/platform/installPlatform.ts +6 -50
  47. package/templates/phaser-game/src/platform/microsoftStorePwa.ts +99 -0
  48. package/templates/phaser-game/tools/run-game-acceptance.mjs +22 -0
  49. package/templates/phaser-game/vite.config.ts +24 -73
  50. package/templates/phaser-game/vite.shared.ts +217 -0
  51. 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.6.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.16.9",
51
- "typescript": "7.0.1-rc",
52
- "@mpgd/adapter-browser": "0.4.0",
53
- "@mpgd/adapter-devvit": "0.4.0",
54
- "@mpgd/adapter-capacitor": "0.4.0",
55
- "@mpgd/analytics": "0.3.3",
56
- "@mpgd/adapter-ait": "0.4.0",
57
- "@mpgd/bridge": "0.5.0",
58
- "@mpgd/catalog": "0.3.3",
59
- "@mpgd/i18n": "0.4.1",
60
- "@mpgd/game-services": "0.4.0",
61
- "@mpgd/phaser-assets": "0.4.0",
62
- "@mpgd/platform": "0.4.0",
63
- "@mpgd/target-config": "0.5.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 check
7
- pnpm build
8
- pnpm --dir ../mpgd-kit mpgd target build-all --targets-file "$PWD/mpgd.targets.json" --targets web,microsoft-store,ait,reddit --ait-variant wrapper
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 exec mpgd target build-all --targets-file ./mpgd.targets.json --kit-path ../mpgd-kit --targets web,microsoft-store,ait,reddit --ait-variant wrapper
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.16.9",
23
- "ttsc": "0.16.9",
24
- "typescript": "7.0.1-rc",
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 client bundle is copied into `dist/client` by the mpgd target build. The server bridge is
29
- compiled to `dist/server/index.cjs` and keeps Devvit SDK imports out of Phaser scenes.
30
- The game root pins `@mpgd/cli`, so these commands use the same CLI version as the generated
31
- starter's other `@mpgd/*` dependencies.
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": "pnpm dev:prepare && pnpm dev:watch",
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
- "init": "devvit upload --config devvit.json",
8
- "init:copy-paste": "devvit upload --copy-paste --config devvit.json",
9
- "dev": "devvit playtest --config devvit.json",
10
- "dev:prepare": "pnpm --dir ../.. build:devvit:staging",
11
- "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",
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.6",
26
- "@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION_BRIDGE__",
27
- "express": "^5.2.1",
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
- "@ttsc/unplugin": "0.16.9",
32
- "@types/express": "^5.0.6",
27
+ "@devvit/start": "0.13.7",
28
+ "@ttsc/unplugin": "0.18.4",
33
29
  "@types/node": "^24.0.0",
34
- "concurrently": "10.0.3",
35
- "devvit": "0.13.6",
36
- "ttsc": "0.16.9",
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,3 @@
1
+ await import('../../../../src/gameEntry');
2
+
3
+ export {};
@@ -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>
@@ -0,0 +1,3 @@
1
+ await import('../../../../src/entry');
2
+
3
+ export {};