@mpgd/cli 0.5.0 → 0.7.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 (44) 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 +469 -2
  5. package/dist/production-target-readiness.d.ts +8 -0
  6. package/dist/production-target-readiness.js +207 -0
  7. package/package.json +12 -12
  8. package/templates/phaser-game/README.md +71 -1
  9. package/templates/phaser-game/agent/acceptance.md +14 -6
  10. package/templates/phaser-game/agent/brief.md +3 -1
  11. package/templates/phaser-game/agent/game-manifest.json +11 -0
  12. package/templates/phaser-game/apps/target-devvit/README.md +30 -0
  13. package/templates/phaser-game/apps/target-devvit/devvit.json +7 -0
  14. package/templates/phaser-game/apps/target-devvit/package.json +8 -5
  15. package/templates/phaser-game/apps/target-devvit/src/server/index.ts +46 -1
  16. package/templates/phaser-game/apps/target-devvit/src/server/postOperationStore.ts +9 -0
  17. package/templates/phaser-game/apps/target-devvit/vite.server.config.ts +2 -1
  18. package/templates/phaser-game/game.html +13 -0
  19. package/templates/phaser-game/gitignore +2 -0
  20. package/templates/phaser-game/index.html +2 -2
  21. package/templates/phaser-game/mpgd.game.json +8 -0
  22. package/templates/phaser-game/mpgd.targets.json +6 -0
  23. package/templates/phaser-game/package.json +7 -1
  24. package/templates/phaser-game/public/manifest.webmanifest +1 -0
  25. package/templates/phaser-game/src/entry.ts +7 -0
  26. package/templates/phaser-game/src/env.d.ts +4 -0
  27. package/templates/phaser-game/src/gameEntry.ts +3 -0
  28. package/templates/phaser-game/src/main.ts +48 -3
  29. package/templates/phaser-game/src/platform/buildGatewayModule.ts +5 -0
  30. package/templates/phaser-game/src/platform/buildGateways/ait.ts +11 -0
  31. package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +12 -0
  32. package/templates/phaser-game/src/platform/buildGateways/browser.ts +8 -0
  33. package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +12 -0
  34. package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +12 -0
  35. package/templates/phaser-game/src/platform/buildGateways/reddit.ts +11 -0
  36. package/templates/phaser-game/src/platform/buildGateways/redditSandbox.ts +15 -0
  37. package/templates/phaser-game/src/platform/devvitEntrypoint.ts +57 -0
  38. package/templates/phaser-game/src/platform/devvitInlinePreview.css +75 -0
  39. package/templates/phaser-game/src/platform/gameServices.ts +18 -61
  40. package/templates/phaser-game/src/platform/installPlatform.ts +6 -50
  41. package/templates/phaser-game/src/platform/microsoftStorePwa.ts +99 -0
  42. package/templates/phaser-game/src/runtime/gameContext.ts +3 -1
  43. package/templates/phaser-game/tools/run-game-acceptance.mjs +22 -0
  44. package/templates/phaser-game/vite.config.ts +161 -2
@@ -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.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,18 +49,18 @@
49
49
  "@types/node": "^24.0.0",
50
50
  "ttsc": "0.16.9",
51
51
  "typescript": "7.0.1-rc",
52
- "@mpgd/adapter-ait": "0.3.3",
53
- "@mpgd/adapter-browser": "0.3.2",
54
- "@mpgd/adapter-capacitor": "0.3.3",
55
- "@mpgd/adapter-devvit": "0.3.3",
56
- "@mpgd/analytics": "0.3.2",
57
- "@mpgd/bridge": "0.4.0",
58
- "@mpgd/catalog": "0.3.2",
59
- "@mpgd/game-services": "0.3.3",
52
+ "@mpgd/adapter-ait": "0.4.1",
53
+ "@mpgd/adapter-devvit": "0.5.0",
54
+ "@mpgd/analytics": "0.3.4",
55
+ "@mpgd/adapter-browser": "0.4.1",
56
+ "@mpgd/adapter-capacitor": "0.4.1",
57
+ "@mpgd/bridge": "0.5.0",
58
+ "@mpgd/catalog": "0.3.4",
59
+ "@mpgd/game-services": "0.5.0",
60
+ "@mpgd/i18n": "0.5.0",
61
+ "@mpgd/platform": "0.5.0",
60
62
  "@mpgd/phaser-assets": "0.4.0",
61
- "@mpgd/i18n": "0.4.0",
62
- "@mpgd/target-config": "0.4.0",
63
- "@mpgd/platform": "0.3.2"
63
+ "@mpgd/target-config": "0.6.0"
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,20 +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.
19
+ Resolve identity and launch intents during bootstrap, treat inbound share data as
20
+ untrusted, and keep notification delivery on the server.
15
21
 
16
- 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
17
23
  continue to expose game-facing behavior through `PlatformGateway`. The Devvit
18
24
  app root is game-owned in `apps/target-devvit`; run `pnpm devvit:init` once
19
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.
20
28
 
21
29
  For Microsoft Store changes, keep the first pass as a PWA/web target that uses
22
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
@@ -25,6 +25,10 @@
25
25
  "id": "runtime.platform.gateway",
26
26
  "owner": "src/platform/installPlatform.ts"
27
27
  },
28
+ {
29
+ "id": "platform.gateway.bootstrap-intents",
30
+ "owner": "src/main.ts"
31
+ },
28
32
  {
29
33
  "id": "runtime.viewport.orientation-policy",
30
34
  "owner": "src/main.ts"
@@ -33,6 +37,10 @@
33
37
  "id": "platform.devvit.app-root",
34
38
  "owner": "apps/target-devvit"
35
39
  },
40
+ {
41
+ "id": "platform.devvit.surface-split",
42
+ "owner": "src/platform/devvitEntrypoint.ts"
43
+ },
36
44
  {
37
45
  "id": "services.backend.game-services",
38
46
  "owner": "src/platform/gameServices.ts"
@@ -47,6 +55,9 @@
47
55
  }
48
56
  ],
49
57
  "acceptance": [
58
+ "pnpm accept",
59
+ "pnpm icons:generate",
60
+ "pnpm icons:verify",
50
61
  "pnpm check",
51
62
  "pnpm build"
52
63
  ]
@@ -29,3 +29,33 @@ The client bundle is copied into `dist/client` by the mpgd target build. The ser
29
29
  compiled to `dist/server/index.cjs` and keeps Devvit SDK imports out of Phaser scenes.
30
30
  The game root pins `@mpgd/cli`, so these commands use the same CLI version as the generated
31
31
  starter's other `@mpgd/*` dependencies.
32
+
33
+ The default post entry uses `index.html` for a lightweight inline preview. Its
34
+ Play button requests the `game` entrypoint, which loads the separate
35
+ `game.html` Phaser document. Keep inline UI free of game runtime imports so the
36
+ card remains lightweight before expansion.
37
+
38
+ ## Durable Post Operations
39
+
40
+ Use `src/server/postOperationStore.ts` with `@mpgd/adapter-devvit/server` for a
41
+ server action or scheduler that may receive the same custom-post operation more
42
+ than once. Use `{ appScope, subredditId }` as the scope, a stable `operationType`
43
+ in the definition, and an `operationId` for each logical publication slot. Validate
44
+ the public payload and launch parameters, then inject the Redis-backed store into
45
+ `createDevvitPostOperationCoordinator`.
46
+
47
+ The contract is duplicate-safe and ambiguity-safe, not exactly-once. It persists
48
+ the attempt before invoking Reddit. If Reddit accepts the post but its response is
49
+ lost, or the process stops before the receipt is saved, repeating `execute` returns
50
+ `reconciliation-required` and never blindly calls the submit function again. An
51
+ explicit recovery endpoint or scheduler calls `reconcile`, which requires an exact
52
+ match of the full canonical envelope. A missing match from a bounded listing
53
+ remains unresolved rather than restoring submit permission.
54
+
55
+ Keep operation definitions and `@devvit/web/server` imports inside this target
56
+ app. The canonical `{ mpgd, launch, payload }` envelope is public and untrusted;
57
+ keep private content and authoritative records in server-side storage. Exercise
58
+ first success, duplicate calls, interruption,
59
+ response loss, lease expiry with concurrent reconciliation, malformed durable
60
+ state, invalid launch metadata, and cross-scope key isolation before enabling a
61
+ scheduled or user-triggered publication flow.
@@ -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
  },
@@ -4,9 +4,11 @@
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",
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",
10
12
  "dev:prepare": "pnpm --dir ../.. build:devvit:staging",
11
13
  "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
14
  "dev:server": "vite build --config vite.server.config.ts --watch",
@@ -14,8 +16,8 @@
14
16
  "build": "pnpm --dir ../.. build:devvit",
15
17
  "build:server": "vite build --config vite.server.config.ts",
16
18
  "whoami": "devvit whoami",
17
- "upload": "devvit upload --config devvit.json",
18
- "publish": "devvit publish --config devvit.json",
19
+ "upload": "pnpm run prepare:icon:production && devvit upload --config devvit.json",
20
+ "publish": "pnpm run prepare:icon:production && devvit publish --config devvit.json",
19
21
  "check": "ttsc --noEmit -p tsconfig.json",
20
22
  "lint": "ttsc --noEmit -p tsconfig.json",
21
23
  "format": "ttsc format",
@@ -23,6 +25,7 @@
23
25
  },
24
26
  "dependencies": {
25
27
  "@devvit/web": "0.13.6",
28
+ "@mpgd/adapter-devvit": "__MPGD_DEPENDENCY_VERSION_ADAPTER_DEVVIT__",
26
29
  "@mpgd/bridge": "__MPGD_DEPENDENCY_VERSION_BRIDGE__",
27
30
  "express": "^5.2.1",
28
31
  "helmet": "^8.1.0"
@@ -168,7 +168,7 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
168
168
  nativeLeaderboard: true,
169
169
  achievements: false,
170
170
  cloudSave: true,
171
- socialShare: true,
171
+ socialShare: false,
172
172
  haptics: false,
173
173
  localizedContent: true,
174
174
  });
@@ -186,6 +186,51 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
186
186
  });
187
187
  }
188
188
 
189
+ case 'identity.getSession': {
190
+ const playerId = currentPlayerId();
191
+
192
+ return ok(
193
+ input,
194
+ playerId === undefined
195
+ ? {
196
+ identityLevel: 'guest',
197
+ trustLevel: 'local',
198
+ }
199
+ : {
200
+ identityLevel: 'authenticated',
201
+ playerId,
202
+ trustLevel: 'server-verified',
203
+ },
204
+ );
205
+ }
206
+
207
+ case 'identity.requestUpgrade': {
208
+ const authenticated = currentPlayerId() !== undefined;
209
+
210
+ return ok(input, {
211
+ status: authenticated ? 'completed' : 'unavailable',
212
+ reloadExpected: false,
213
+ });
214
+ }
215
+
216
+ case 'presentation.getLaunchIntent':
217
+ return ok(input, { entry: 'home' });
218
+
219
+ case 'presentation.requestGameSurface':
220
+ return ok(input, 'unavailable');
221
+
222
+ case 'share.share':
223
+ return ok(input, { status: 'unavailable' });
224
+
225
+ case 'share.readInboundShare':
226
+ return ok(input, null);
227
+
228
+ case 'notifications.getStatus':
229
+ return ok(input, 'approval-required');
230
+
231
+ case 'notifications.requestSubscription':
232
+ return ok(input, 'unavailable');
233
+
189
234
  case 'commerce.getProducts':
190
235
  case 'commerce.getEntitlements':
191
236
  return ok(input, []);
@@ -0,0 +1,9 @@
1
+ import { redis } from '@devvit/web/server';
2
+ import {
3
+ createDevvitRedisPostOperationStore,
4
+ type DevvitDurableOperationStore,
5
+ } from '@mpgd/adapter-devvit/server';
6
+
7
+ export function createPostOperationStore(): DevvitDurableOperationStore {
8
+ return createDevvitRedisPostOperationStore(redis);
9
+ }
@@ -22,6 +22,7 @@ export default defineConfig({
22
22
  ssr: 'src/server/index.ts',
23
23
  outDir: 'dist/server',
24
24
  target: 'node22',
25
+ minify: 'esbuild',
25
26
  sourcemap: false,
26
27
  emptyOutDir: true,
27
28
  rollupOptions: {
@@ -29,7 +30,7 @@ export default defineConfig({
29
30
  output: {
30
31
  format: 'cjs',
31
32
  entryFileNames: 'index.cjs',
32
- inlineDynamicImports: true,
33
+ codeSplitting: false,
33
34
  },
34
35
  },
35
36
  },
@@ -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="/src/gameEntry.ts"></script>
12
+ </body>
13
+ </html>
@@ -1,9 +1,11 @@
1
1
  node_modules/
2
2
  dist/
3
3
  apps/*/dist/
4
+ apps/target-devvit/generated/
4
5
  artifacts/
5
6
  release-output/
6
7
  .mpgd.targets.generated.json
8
+ .mpgd/
7
9
  .env
8
10
  .env.*
9
11
  apps/*/.env
@@ -4,11 +4,11 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="theme-color" content="#0f172a" />
7
- <link rel="manifest" href="/manifest.webmanifest" />
7
+ <link rel="manifest" href="./manifest.webmanifest" />
8
8
  <title>__GAME_TITLE__</title>
9
9
  </head>
10
10
  <body>
11
11
  <div id="game"></div>
12
- <script type="module" src="/src/main.ts"></script>
12
+ <script type="module" src="/src/entry.ts"></script>
13
13
  </body>
14
14
  </html>