@mpgd/cli 0.47.0 → 0.49.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/command-launcher.d.ts +13 -0
- package/dist/command-launcher.js +24 -0
- package/dist/devvit-output-collisions.d.ts +49 -0
- package/dist/devvit-output-collisions.js +120 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +186 -10
- package/dist/microsoft-store-submission.js +11 -6
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +2134 -351
- package/dist/tooling-migration.d.ts +24 -0
- package/dist/tooling-migration.js +497 -0
- package/dist/windows-host/Core/BridgeCommand.cs +17 -0
- package/dist/windows-host/Core/Mpgd.MicrosoftStore.Host.csproj +9 -0
- package/dist/windows-host/Core/NativeBridgeContracts.cs +33 -0
- package/dist/windows-host/Core/NativeBridgeHost.cs +162 -0
- package/dist/windows-host/Core/NativeBridgeOrigin.cs +19 -0
- package/dist/windows-host/Core/NativeBridgeProtocol.cs +335 -0
- package/dist/windows-host/Core/NativeBridgeRequestGate.cs +76 -0
- package/dist/windows-host/Core/NativeBridgeResult.cs +65 -0
- package/dist/windows-host/README.md +118 -0
- package/dist/windows-host/WinUI/DispatcherQueueBridgeDispatcher.cs +39 -0
- package/dist/windows-host/WinUI/WebView2DocumentTransport.cs +46 -0
- package/dist/windows-host/WinUI/WebView2NativeBridge.cs +185 -0
- package/dist/windows-host/WinUI/WindowStoreContext.cs +20 -0
- package/dist/windows-native-tools.d.ts +4 -0
- package/dist/windows-native-tools.js +21 -0
- package/native-tools/windows-msix/WindowsMsixAcceptance.psm1 +333 -0
- package/package.json +30 -18
- package/templates/lite-overlay/.agents/skills/use-mpgd-kit/SKILL.md +22 -0
- package/templates/lite-overlay/AGENTS.md +32 -0
- package/templates/lite-overlay/README.md +37 -0
- package/templates/lite-overlay/agent/acceptance.md +27 -0
- package/templates/lite-overlay/agent/brief.md +17 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +134 -0
- package/templates/lite-overlay/docs/MPGD_KIT_WORKFLOWS.md +37 -0
- package/templates/lite-overlay/src/lite/tapGame.ts +77 -0
- package/templates/lite-overlay/src/platform/devvitEntrypoint.ts +116 -0
- package/templates/lite-overlay/src/platform/devvitInlineMode.css +146 -0
- package/templates/lite-overlay/src/runtime/createGame.ts +57 -0
- package/templates/phaser-game/AGENTS.md +5 -0
- package/templates/phaser-game/README.md +5 -3
- package/templates/phaser-game/apps/target-ait/package.json +4 -4
- package/templates/phaser-game/apps/target-ait/src/main.ts +5 -5
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +137 -18
- package/templates/phaser-game/apps/target-devvit/vite.config.ts +2 -0
- package/templates/phaser-game/package.json +8 -8
- package/templates/phaser-game/pnpm-workspace.yaml +2 -0
- package/templates/phaser-game/src/env.d.ts +2 -0
- package/templates/phaser-game/src/platform/buildGateways/ait.ts +1 -1
- package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +9 -4
- package/templates/phaser-game/src/platform/buildGateways/microsoftStore.ts +2 -5
- package/templates/phaser-game/src/platform/buildGateways/reddit.ts +1 -1
- package/templates/phaser-game/src/platform/buildGateways/verse8.ts +1 -2
- package/templates/phaser-game/src/platform/verse8Agent8Client.ts +5 -6
- package/templates/phaser-game/tools/{run-game-acceptance.mjs → run-game-acceptance.ts} +1 -8
- package/templates/phaser-game/tsconfig.tools.json +12 -0
- package/templates/phaser-game/vite.shared.ts +40 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# __GAME_TITLE__ Devvit Target
|
|
2
|
+
|
|
3
|
+
This directory is the Reddit Devvit app root for `__GAME_NAME__`.
|
|
4
|
+
|
|
5
|
+
`devvit.json` belongs to the generated game, not to the mpgd-kit checkout. That keeps the
|
|
6
|
+
Reddit app name, menu entry, upload history, and playtest state owned by this game.
|
|
7
|
+
Its `name` is generated as a Devvit-safe slug; edit it before `pnpm devvit:init`
|
|
8
|
+
if you want a different Reddit app name.
|
|
9
|
+
|
|
10
|
+
## First Playtest
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm install -w
|
|
14
|
+
pnpm devvit:login
|
|
15
|
+
pnpm devvit:whoami
|
|
16
|
+
pnpm devvit:init
|
|
17
|
+
pnpm build:devvit
|
|
18
|
+
pnpm devvit:playtest
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`devvit:init` performs the first App Directory upload/link step with Devvit's
|
|
22
|
+
logged-in account. It may open a browser for authorization. Use
|
|
23
|
+
`pnpm devvit:init:copy-paste` from the game root if the browser flow is not
|
|
24
|
+
available and the CLI asks for a manual code. After that, use
|
|
25
|
+
`pnpm devvit:playtest`, `pnpm devvit:upload`, and `pnpm devvit:publish` from the
|
|
26
|
+
game root.
|
|
27
|
+
|
|
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 behind the platform gateway.
|
|
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. The generated target exposes only
|
|
42
|
+
the oRPC bridge route; the former JSON compatibility route is not generated.
|
|
43
|
+
|
|
44
|
+
The default post entry uses `index.html` for a playable Canvas2D surface.
|
|
45
|
+
Play full screen requests the `game` entrypoint, which loads the full game from
|
|
46
|
+
the separate `game.html` document. Keep heavy imports behind the expanded entry
|
|
47
|
+
and preserve native feed scrolling in the inline surface.
|
|
48
|
+
|
|
49
|
+
Games that intentionally support inline gameplay can opt in through
|
|
50
|
+
`startDevvitWebView`; the generated default uses `startDevvitPreviewWebView`.
|
|
51
|
+
An opt-in inline surface must remain tap/click based and preserve Reddit-native
|
|
52
|
+
gestures across its complete gameplay flow.
|
|
53
|
+
|
|
54
|
+
The bridge method handlers live in `src/server/bridge.ts`; `src/server/index.ts`
|
|
55
|
+
only wires the Devvit `context`, `reddit`, and `redis` clients into them. Cloud
|
|
56
|
+
saves are bound to the authenticated Reddit user from the request context and
|
|
57
|
+
stored under `<game-name>:save:<user>:<key>`. Each value is capped at
|
|
58
|
+
`maxStorageValueBytes` and each user may hold at most `maxStorageKeysPerPlayer`
|
|
59
|
+
distinct keys, tracked in the `<game-name>:save-keys:<user>` hash and enforced
|
|
60
|
+
inside a WATCH/MULTI/EXEC transaction so concurrent saves cannot overshoot the
|
|
61
|
+
cap; saves past it fail with `DEVVIT_STORAGE_KEY_LIMIT`. Saves do not expire by
|
|
62
|
+
default; set `storageEntryTtlSeconds` to let idle saves age out. Keep
|
|
63
|
+
`bridge.ts` aligned with the kit's `apps/target-devvit/src/server/bridge.ts`
|
|
64
|
+
when upgrading so generated games inherit storage protections.
|
|
65
|
+
|
|
66
|
+
The generated bridge does not advertise or accept a generic platform
|
|
67
|
+
leaderboard. Devvit ranking should be owned by a server completion handler that
|
|
68
|
+
validates the game-specific attempt, records it through the verified leaderboard
|
|
69
|
+
provider from `@mpgd/adapter-devvit/server`, and exposes only an authenticated
|
|
70
|
+
game-scoped snapshot route. Do not enable `nativeLeaderboard` or trust a raw
|
|
71
|
+
client score submission for that flow. Keep `remoteLeaderboard` disabled too;
|
|
72
|
+
the verified completion route is not the generic `PlatformGateway` leaderboard.
|
|
73
|
+
|
|
74
|
+
`devvit playtest` runs the official unified Vite build in watch mode, so a
|
|
75
|
+
separate client/server watcher or staging prebuild is not required.
|
|
76
|
+
|
|
77
|
+
## Durable Post Operations
|
|
78
|
+
|
|
79
|
+
Use `src/server/postOperationStore.ts` with `@mpgd/adapter-devvit/server` for a
|
|
80
|
+
server action or scheduler that may receive the same custom-post operation more
|
|
81
|
+
than once. Use `{ appScope, subredditId }` as the scope, a stable `operationType`
|
|
82
|
+
in the definition, and an `operationId` for each logical publication slot. Validate
|
|
83
|
+
the public payload and launch parameters, then inject the Redis-backed store into
|
|
84
|
+
`createDevvitPostOperationCoordinator`.
|
|
85
|
+
|
|
86
|
+
The contract is duplicate-safe and ambiguity-safe, not exactly-once. It persists
|
|
87
|
+
the attempt before invoking Reddit. If Reddit accepts the post but its response is
|
|
88
|
+
lost, or the process stops before the receipt is saved, repeating `execute` returns
|
|
89
|
+
`reconciliation-required` and never blindly calls the submit function again. An
|
|
90
|
+
explicit recovery endpoint or scheduler calls `reconcile`, which requires an exact
|
|
91
|
+
match of the full canonical envelope. A missing match from a bounded listing
|
|
92
|
+
remains unresolved rather than restoring submit permission.
|
|
93
|
+
|
|
94
|
+
The generated Redis wrapper implements `DevvitIndexedDurableOperationStore`.
|
|
95
|
+
Use `listPending()` with a small limit and its scope-bound continuation cursor to
|
|
96
|
+
discover prepared, attempted, and terminal work without scanning Redis keys.
|
|
97
|
+
Listing is read-only; prepared work still requires an explicit `execute` decision,
|
|
98
|
+
attempted work must use `reconcile`, and terminal ambiguity remains fail-closed.
|
|
99
|
+
|
|
100
|
+
Keep operation definitions and `@devvit/web/server` imports inside this target
|
|
101
|
+
app. The canonical `{ mpgd, launch, payload }` envelope is public and untrusted;
|
|
102
|
+
keep private content and authoritative records in server-side storage. Exercise
|
|
103
|
+
first success, duplicate calls, interruption,
|
|
104
|
+
response loss, lease expiry with concurrent reconciliation, malformed durable
|
|
105
|
+
state, invalid launch metadata, and cross-scope key isolation before enabling a
|
|
106
|
+
scheduled or user-triggered publication flow.
|
|
107
|
+
|
|
108
|
+
## AI-assisted Devvit development
|
|
109
|
+
|
|
110
|
+
Devvit provides an optional MCP server for targeted documentation search and
|
|
111
|
+
deployed-app log inspection. See the official
|
|
112
|
+
[Devvit AI Tools guide](https://developers.reddit.com/docs/guides/ai), then
|
|
113
|
+
register it once in the developer's global Codex configuration:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
codex mcp add devvit -- mise --no-config exec node@22 -- npx -y @devvit/mcp@0.0.27
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The server needs Node 22: `@devvit/mcp` 0.0.27 depends on `better-sqlite3`
|
|
120
|
+
11.9.1, which ships prebuilt binaries for Node 22 but not 24 and does not
|
|
121
|
+
build on 26. `--no-config` keeps mise from passing your mise-managed
|
|
122
|
+
environment to the server. Without mise, register `npx -y @devvit/mcp@0.0.27`
|
|
123
|
+
only if the MCP client's `PATH` resolves `npx` from Node 22. Running
|
|
124
|
+
`codex mcp add devvit` again replaces an older registration.
|
|
125
|
+
|
|
126
|
+
Restart Codex or begin a new task after registering it. Use `devvit_search`
|
|
127
|
+
before broad Devvit documentation exploration. Use the experimental
|
|
128
|
+
`devvit_logs` tool for a deployed app and subreddit when diagnosing production
|
|
129
|
+
or playtest behavior, and confirm its findings with local tests and
|
|
130
|
+
`pnpm devvit:playtest`.
|
|
131
|
+
|
|
132
|
+
The MCP server is an agent-side aid only. It is not required by the generated
|
|
133
|
+
game build, must not be bundled into target artifacts, and does not replace
|
|
134
|
+
Devvit CLI authentication or release verification.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# mpgd-kit Lite Workflows
|
|
2
|
+
|
|
3
|
+
Start with `agent/game-manifest.json`; enabled targets are build paths and future targets are design directions.
|
|
4
|
+
|
|
5
|
+
| Concern | Owner |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Deterministic rules and state | `src/game` |
|
|
8
|
+
| Canvas2D rendering and input | `src/lite` through `@mpgd/lite-shell` |
|
|
9
|
+
| Renderer mounting and teardown | `src/runtime/createGame.ts` |
|
|
10
|
+
| Platform services | `src/platform` through `PlatformGateway` |
|
|
11
|
+
| Target build configuration | `mpgd.targets.json` and `@mpgd/target-config` |
|
|
12
|
+
| Runtime, recovery, and audio ownership | bootstrap and `@mpgd/game-runtime` |
|
|
13
|
+
| Acceptance | `agent/acceptance.md` and `pnpm accept` |
|
|
14
|
+
|
|
15
|
+
Use the generated icon and localization commands. Load browser images and fonts directly or through lite-shell font helpers; this template has no Phaser asset manifest.
|
|
16
|
+
Keep deterministic state independent of Canvas2D, DOM, network, and SDKs.
|
|
17
|
+
Reserve safe-area geometry once through CSS or viewport content bounds.
|
|
18
|
+
Never call SDKs from renderer code or grant purchases/rewards from client callbacks.
|
|
19
|
+
|
|
20
|
+
## Platform integration
|
|
21
|
+
|
|
22
|
+
Choose the owning adapter in `src/platform/buildGateways`. Configure target features and service availability rather than bypassing them in game code.
|
|
23
|
+
Keep commerce verification and grants in the backend ledger; use authenticated sessions and durable encrypted recovery journals.
|
|
24
|
+
Construct one game runtime above renderer lifetimes, preserve native presentation ownership, and reconcile existing operations without reopening checkout.
|
|
25
|
+
Use generated target build/smoke and effective-config checks before changing a release contract.
|
|
26
|
+
|
|
27
|
+
## Devvit
|
|
28
|
+
|
|
29
|
+
`apps/target-devvit` owns Reddit SDK and deployment files. The lite inline entry is playable and the expanded entry uses the same Canvas2D game.
|
|
30
|
+
Preserve native feed gestures. Keep optional heavy engines behind declared dynamic imports; static imports, HTML preloads, and CSS imports count toward first-load budget.
|
|
31
|
+
Use configured Devvit documentation/MCP for SDK or publish-flow changes and run real playtest for platform acceptance.
|
|
32
|
+
|
|
33
|
+
## Target handoff
|
|
34
|
+
|
|
35
|
+
Run focused checks, `pnpm check`, `pnpm build`, and `pnpm accept`. Preserve generated evidence.
|
|
36
|
+
Use official target tooling and configured skills for native signing, Apps in Toss, ONE play, and Verse8 acceptance.
|
|
37
|
+
Keep credentials, signing material, and personal MCP configuration out of source control.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCanvasRenderer,
|
|
3
|
+
createLiteShell,
|
|
4
|
+
type CanvasSurface,
|
|
5
|
+
type LiteShell,
|
|
6
|
+
} from '@mpgd/lite-shell';
|
|
7
|
+
|
|
8
|
+
import { createStarterRunState, stepStarterRunState, type StarterRunState } from '../game/state';
|
|
9
|
+
|
|
10
|
+
export interface TapGameOptions {
|
|
11
|
+
readonly mount: HTMLElement;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly onHit?: (hits: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renderer-agnostic starter gameplay: tap the orbiting target. State lives in
|
|
18
|
+
* `src/game/state.ts`; this file only draws and routes taps. Swap
|
|
19
|
+
* `createCanvasRenderer` for another `LiteRenderer` (dom/svg) when one exists.
|
|
20
|
+
*/
|
|
21
|
+
export function createTapGame(options: TapGameOptions): LiteShell<CanvasSurface> {
|
|
22
|
+
let run: StarterRunState = createStarterRunState();
|
|
23
|
+
let hits = 0;
|
|
24
|
+
let pulse = 0;
|
|
25
|
+
const target = (width: number, height: number) => {
|
|
26
|
+
const angle = run.phase * Math.PI * 2;
|
|
27
|
+
const radius = Math.min(width, height) * 0.28;
|
|
28
|
+
return {
|
|
29
|
+
x: width / 2 + Math.cos(angle) * radius,
|
|
30
|
+
y: height / 2 + Math.sin(angle) * radius,
|
|
31
|
+
r: 22,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const shell = createLiteShell<CanvasSurface>({
|
|
36
|
+
mount: options.mount,
|
|
37
|
+
renderer: createCanvasRenderer(),
|
|
38
|
+
frame({ context, size }, deltaMs) {
|
|
39
|
+
run = stepStarterRunState(run, deltaMs);
|
|
40
|
+
pulse = Math.max(0, pulse - deltaMs / 300);
|
|
41
|
+
const { width, height } = size;
|
|
42
|
+
context.fillStyle = '#07111f';
|
|
43
|
+
context.fillRect(0, 0, width, height);
|
|
44
|
+
const dot = target(width, height);
|
|
45
|
+
context.beginPath();
|
|
46
|
+
context.arc(dot.x, dot.y, dot.r * (1 + pulse * 0.4), 0, Math.PI * 2);
|
|
47
|
+
context.fillStyle = pulse > 0 ? '#5eead4' : '#38bdf8';
|
|
48
|
+
context.fill();
|
|
49
|
+
context.fillStyle = '#ffffff';
|
|
50
|
+
context.font = '600 16px system-ui, sans-serif';
|
|
51
|
+
context.textAlign = 'center';
|
|
52
|
+
context.fillText(`${options.title} · ${hits}`, width / 2, 28);
|
|
53
|
+
// Keep animating only while the document is visible; the loop sleeps otherwise.
|
|
54
|
+
return document.visibilityState === 'visible';
|
|
55
|
+
},
|
|
56
|
+
onTap({ x, y }) {
|
|
57
|
+
const dot = target(shell.size.width, shell.size.height);
|
|
58
|
+
if (Math.hypot(x - dot.x, y - dot.y) <= dot.r * 1.6) {
|
|
59
|
+
hits += 1;
|
|
60
|
+
pulse = 1;
|
|
61
|
+
options.onHit?.(hits);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const onVisibilityChange = (): void => shell.invalidate();
|
|
66
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
67
|
+
return {
|
|
68
|
+
...shell,
|
|
69
|
+
get size() {
|
|
70
|
+
return shell.size;
|
|
71
|
+
},
|
|
72
|
+
dispose() {
|
|
73
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
74
|
+
shell.dispose();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { requestDevvitExpandedMode, startDevvitPreviewWebView } from '@mpgd/adapter-devvit/web';
|
|
2
|
+
|
|
3
|
+
import { createTapGame } from '../lite/tapGame';
|
|
4
|
+
|
|
5
|
+
const expandedRequestRecoveryMs = 8_000;
|
|
6
|
+
|
|
7
|
+
await startDevvitPreviewWebView({
|
|
8
|
+
async mountInlinePreview() {
|
|
9
|
+
await import('./devvitInlineMode.css');
|
|
10
|
+
renderInlineLaunchScreen();
|
|
11
|
+
},
|
|
12
|
+
async loadExpandedGame() {
|
|
13
|
+
await import('../main');
|
|
14
|
+
},
|
|
15
|
+
onModeUnavailable(error) {
|
|
16
|
+
if (!(error instanceof ReferenceError)) {
|
|
17
|
+
console.warn('[devvit] web view mode unavailable; loading expanded gameplay.', error);
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function renderInlineLaunchScreen(): void {
|
|
23
|
+
const launchScreen = document.createElement('main');
|
|
24
|
+
launchScreen.className = 'devvit-launch-screen';
|
|
25
|
+
|
|
26
|
+
const eyebrow = document.createElement('p');
|
|
27
|
+
eyebrow.className = 'devvit-launch-screen__eyebrow';
|
|
28
|
+
eyebrow.textContent = 'Play in feed';
|
|
29
|
+
|
|
30
|
+
const title = document.createElement('h1');
|
|
31
|
+
title.textContent = '__GAME_TITLE__';
|
|
32
|
+
|
|
33
|
+
const description = document.createElement('p');
|
|
34
|
+
description.className = 'devvit-launch-screen__description';
|
|
35
|
+
description.textContent = 'Tap to play here, or open the full game.';
|
|
36
|
+
|
|
37
|
+
// Lite template: the inline surface is playable. It uses the same lite shell
|
|
38
|
+
// as the expanded game, never loads Phaser, and keeps feed scrolling (taps only).
|
|
39
|
+
const stage = document.createElement('div');
|
|
40
|
+
stage.className = 'devvit-launch-screen__stage';
|
|
41
|
+
stage.style.width = 'min(100%, 22rem)';
|
|
42
|
+
stage.style.height = '9rem';
|
|
43
|
+
|
|
44
|
+
const actions = document.createElement('div');
|
|
45
|
+
actions.className = 'devvit-launch-screen__actions';
|
|
46
|
+
|
|
47
|
+
const expandButton = document.createElement('button');
|
|
48
|
+
expandButton.className = 'devvit-launch-screen__button';
|
|
49
|
+
expandButton.type = 'button';
|
|
50
|
+
expandButton.textContent = 'Play full screen';
|
|
51
|
+
|
|
52
|
+
const status = document.createElement('p');
|
|
53
|
+
status.className = 'devvit-launch-screen__status';
|
|
54
|
+
status.setAttribute('aria-live', 'polite');
|
|
55
|
+
status.setAttribute('role', 'status');
|
|
56
|
+
|
|
57
|
+
let nextRequestId = 0;
|
|
58
|
+
let activeRequestId: number | undefined;
|
|
59
|
+
let recoveryTimeout: ReturnType<typeof globalThis.setTimeout> | undefined;
|
|
60
|
+
const recover = (requestId: number, message: string) => {
|
|
61
|
+
if (activeRequestId !== requestId) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
activeRequestId = undefined;
|
|
66
|
+
if (recoveryTimeout !== undefined) {
|
|
67
|
+
globalThis.clearTimeout(recoveryTimeout);
|
|
68
|
+
recoveryTimeout = undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
expandButton.disabled = false;
|
|
72
|
+
status.textContent = message;
|
|
73
|
+
};
|
|
74
|
+
expandButton.addEventListener('click', (event) => {
|
|
75
|
+
const requestId = ++nextRequestId;
|
|
76
|
+
|
|
77
|
+
activeRequestId = requestId;
|
|
78
|
+
expandButton.disabled = true;
|
|
79
|
+
status.textContent = 'Opening full screen…';
|
|
80
|
+
recoveryTimeout = globalThis.setTimeout(() => {
|
|
81
|
+
recover(requestId, 'Full screen did not open. Try again.');
|
|
82
|
+
}, expandedRequestRecoveryMs);
|
|
83
|
+
|
|
84
|
+
void requestDevvitExpandedMode(event, 'game')
|
|
85
|
+
.catch((error: unknown) => {
|
|
86
|
+
console.error('[devvit] expanded mode request failed.', error);
|
|
87
|
+
recover(requestId, 'Full screen is unavailable. Try again.');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
const recoverAfterReturn = () => {
|
|
91
|
+
if (activeRequestId !== undefined) {
|
|
92
|
+
recover(activeRequestId, '');
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
globalThis.addEventListener('focus', recoverAfterReturn);
|
|
96
|
+
globalThis.addEventListener('pageshow', recoverAfterReturn);
|
|
97
|
+
|
|
98
|
+
actions.append(expandButton);
|
|
99
|
+
launchScreen.append(eyebrow, title, description, stage, actions, status);
|
|
100
|
+
const body = requireDocumentBody();
|
|
101
|
+
|
|
102
|
+
body.classList.add('devvit-inline-mode-host');
|
|
103
|
+
delete body.dataset.mpgdPreserveBrowserTouchGestures;
|
|
104
|
+
body.replaceChildren(launchScreen);
|
|
105
|
+
createTapGame({ mount: stage, title: '__GAME_TITLE__' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requireDocumentBody(): HTMLElement {
|
|
109
|
+
const body = document.body;
|
|
110
|
+
|
|
111
|
+
if (body === null) {
|
|
112
|
+
throw new Error('Devvit inline mode requires a document body.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
html,
|
|
2
|
+
body {
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100%;
|
|
5
|
+
margin: 0;
|
|
6
|
+
overflow: hidden;
|
|
7
|
+
overscroll-behavior: auto;
|
|
8
|
+
touch-action: pan-y;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
body.devvit-inline-mode-host {
|
|
12
|
+
background: #0f172a;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.devvit-launch-screen,
|
|
16
|
+
.devvit-launch-screen * {
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.devvit-launch-screen {
|
|
21
|
+
position: fixed;
|
|
22
|
+
inset: 0;
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100dvh;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
display: grid;
|
|
27
|
+
place-content: safe center;
|
|
28
|
+
justify-items: center;
|
|
29
|
+
gap: 0.75rem;
|
|
30
|
+
--inline-padding-block: clamp(1.25rem, 5vw, 3rem);
|
|
31
|
+
--inline-padding-inline: clamp(1.25rem, 5vw, 3rem);
|
|
32
|
+
padding-block:
|
|
33
|
+
calc(var(--inline-padding-block) + env(safe-area-inset-top, 0px))
|
|
34
|
+
calc(var(--inline-padding-block) + env(safe-area-inset-bottom, 0px));
|
|
35
|
+
padding-inline:
|
|
36
|
+
calc(var(--inline-padding-inline) + env(safe-area-inset-left, 0px))
|
|
37
|
+
calc(var(--inline-padding-inline) + env(safe-area-inset-right, 0px));
|
|
38
|
+
background:
|
|
39
|
+
radial-gradient(circle at 85% 15%, rgb(56 189 248 / 0.2), transparent 38%),
|
|
40
|
+
linear-gradient(145deg, #0f172a, #172554);
|
|
41
|
+
color: #f8fafc;
|
|
42
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
43
|
+
text-align: center;
|
|
44
|
+
touch-action: pan-y;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.devvit-launch-screen__eyebrow,
|
|
48
|
+
.devvit-launch-screen__description,
|
|
49
|
+
.devvit-launch-screen__status,
|
|
50
|
+
.devvit-launch-screen h1 {
|
|
51
|
+
margin: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.devvit-launch-screen__eyebrow {
|
|
55
|
+
color: #7dd3fc;
|
|
56
|
+
font-size: 0.75rem;
|
|
57
|
+
font-weight: 700;
|
|
58
|
+
letter-spacing: 0.12em;
|
|
59
|
+
text-transform: uppercase;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.devvit-launch-screen h1 {
|
|
63
|
+
display: -webkit-box;
|
|
64
|
+
max-width: 18ch;
|
|
65
|
+
max-height: 1.9em;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
font-size: clamp(2rem, 9vw, 4.5rem);
|
|
68
|
+
line-height: 0.95;
|
|
69
|
+
overflow-wrap: anywhere;
|
|
70
|
+
-webkit-box-orient: vertical;
|
|
71
|
+
-webkit-line-clamp: 2;
|
|
72
|
+
line-clamp: 2;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.devvit-launch-screen__description {
|
|
76
|
+
max-width: 36rem;
|
|
77
|
+
color: #cbd5e1;
|
|
78
|
+
line-height: 1.5;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.devvit-launch-screen__actions {
|
|
82
|
+
width: min(100%, 22rem);
|
|
83
|
+
display: grid;
|
|
84
|
+
margin-top: 0.5rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.devvit-launch-screen__button {
|
|
88
|
+
min-height: 2.75rem;
|
|
89
|
+
padding: 0.65rem 1.25rem;
|
|
90
|
+
border: 1px solid transparent;
|
|
91
|
+
border-radius: 999px;
|
|
92
|
+
color: #082f49;
|
|
93
|
+
background: #7dd3fc;
|
|
94
|
+
font: inherit;
|
|
95
|
+
font-weight: 800;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.devvit-launch-screen__button:disabled {
|
|
100
|
+
cursor: wait;
|
|
101
|
+
opacity: 0.65;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.devvit-launch-screen__button:focus-visible {
|
|
105
|
+
outline: 3px solid #f8fafc;
|
|
106
|
+
outline-offset: 3px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.devvit-launch-screen__status {
|
|
110
|
+
min-height: 1.5em;
|
|
111
|
+
color: #fda4af;
|
|
112
|
+
font-size: 0.875rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@media (hover: hover) {
|
|
116
|
+
.devvit-launch-screen__button:not(:disabled):hover {
|
|
117
|
+
filter: brightness(1.1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@media (max-width: 420px) {
|
|
122
|
+
.devvit-launch-screen__actions,
|
|
123
|
+
.devvit-launch-screen__button {
|
|
124
|
+
width: 100%;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@media (max-height: 440px) {
|
|
129
|
+
.devvit-launch-screen {
|
|
130
|
+
gap: 0.45rem;
|
|
131
|
+
--inline-padding-block: 0.5rem;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.devvit-launch-screen h1 {
|
|
135
|
+
font-size: clamp(1.75rem, 8vw, 2.5rem);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.devvit-launch-screen__description {
|
|
139
|
+
font-size: 0.875rem;
|
|
140
|
+
line-height: 1.3;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.devvit-launch-screen__actions {
|
|
144
|
+
margin-top: 0.2rem;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { bindGameAudio } from '@mpgd/game-runtime/audio';
|
|
2
|
+
import type { CanvasSurface, LiteShell } from '@mpgd/lite-shell';
|
|
3
|
+
|
|
4
|
+
import { createTapGame } from '../lite/tapGame';
|
|
5
|
+
import type { StarterContext } from './gameContext';
|
|
6
|
+
|
|
7
|
+
/** Lite template: no Phaser. Mount a renderer-agnostic lite shell into the game element. */
|
|
8
|
+
export function createStarterGame(input: {
|
|
9
|
+
readonly mountId: string;
|
|
10
|
+
readonly preserveBrowserTouchGestures?: boolean;
|
|
11
|
+
readonly context: StarterContext;
|
|
12
|
+
}): LiteShell<CanvasSurface> {
|
|
13
|
+
const mount = document.getElementById(input.mountId);
|
|
14
|
+
if (mount === null) {
|
|
15
|
+
throw new Error(`Missing game mount #${input.mountId}.`);
|
|
16
|
+
}
|
|
17
|
+
mount.style.width = '100%';
|
|
18
|
+
mount.style.height = '100%';
|
|
19
|
+
let muted = false;
|
|
20
|
+
const audio = bindGameAudio({
|
|
21
|
+
execution: input.context.gameRuntime.execution,
|
|
22
|
+
sink: {
|
|
23
|
+
getMuted: () => muted,
|
|
24
|
+
setMuted: (next) => {
|
|
25
|
+
muted = next;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
onError: (error) => {
|
|
29
|
+
console.error('[game-audio]', error);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const shell = createTapGame({ mount, title: '__GAME_TITLE__' });
|
|
33
|
+
let disposed = false;
|
|
34
|
+
const dispose = (): void => {
|
|
35
|
+
if (disposed) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
disposed = true;
|
|
39
|
+
globalThis.removeEventListener('pagehide', onPageHide);
|
|
40
|
+
audio.dispose();
|
|
41
|
+
input.context.gameRuntime.dispose();
|
|
42
|
+
shell.dispose();
|
|
43
|
+
};
|
|
44
|
+
const onPageHide = (event: PageTransitionEvent): void => {
|
|
45
|
+
if (!event.persisted) {
|
|
46
|
+
dispose();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
globalThis.addEventListener('pagehide', onPageHide);
|
|
50
|
+
return {
|
|
51
|
+
...shell,
|
|
52
|
+
get size() {
|
|
53
|
+
return shell.size;
|
|
54
|
+
},
|
|
55
|
+
dispose,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -27,6 +27,11 @@ This repository is a standalone Phaser game generated with `@mpgd/create-game`.
|
|
|
27
27
|
|
|
28
28
|
## Tooling and checks
|
|
29
29
|
|
|
30
|
+
- Author game code, tests, and development tools in TypeScript (`.ts`). Include every tool in
|
|
31
|
+
`tsconfig.tools.json` and run it with `ttsx --project tsconfig.tools.json`.
|
|
32
|
+
- Use `.mjs`, `.cjs`, or authored `.js` only when a concrete consumer cannot run TypeScript
|
|
33
|
+
(for example a browser fixture or a JavaScript-only config loader). Document that constraint
|
|
34
|
+
beside the file. Convenience or avoiding type errors is not an exception.
|
|
30
35
|
- Use `ttsx`, not `tsx`, for TypeScript scripts.
|
|
31
36
|
- Use `ttsc` and `@ttsc/lint`; do not add ESLint or Prettier for TypeScript.
|
|
32
37
|
- Run focused checks, then `pnpm check` and `pnpm build`.
|
|
@@ -459,9 +459,11 @@ HTTPS game-services backend that verifies purchase or reward evidence.
|
|
|
459
459
|
Verse8-signed accounts from self-signed guest accounts. Ads are available
|
|
460
460
|
only when placement mappings are present. VXShop stays capability-disabled
|
|
461
461
|
until the gateway receives the literal `purchaseEventAuthority:
|
|
462
|
-
'agent8-server'`, catalog product mappings, and an
|
|
463
|
-
|
|
464
|
-
|
|
462
|
+
'agent8-server'`, catalog product mappings, and `purchases`, an
|
|
463
|
+
authenticated Agent8 source (`listPurchases`) for the purchases the server
|
|
464
|
+
recorded. The client purchase result is always `launched`; grants belong
|
|
465
|
+
only in the reserved Agent8 `$onItemPurchased` event, which the backend
|
|
466
|
+
ledger looks up when the reconciler verifies the purchase. Do not use
|
|
465
467
|
`VXShop.onClose`, client metadata, or a client-created transaction ID as
|
|
466
468
|
purchase evidence. Keep `@agent8/gameserver-node` in the separate Agent8
|
|
467
469
|
server project and use `@mpgd/adapter-verse8/agent8` there for the locked,
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
"fix": "ttsc fix"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@apps-in-toss/web-framework": "3.
|
|
19
|
+
"@apps-in-toss/web-framework": "3.6.0",
|
|
20
20
|
"@mpgd/adapter-ait": "__MPGD_DEPENDENCY_VERSION_ADAPTER_AIT__"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@apps-in-toss/cli": "3.
|
|
24
|
-
"@ttsc/unplugin": "0.
|
|
23
|
+
"@apps-in-toss/cli": "3.6.0",
|
|
24
|
+
"@ttsc/unplugin": "0.32.1",
|
|
25
25
|
"@types/node": "^24.0.0",
|
|
26
|
-
"ttsc": "0.
|
|
26
|
+
"ttsc": "0.32.1",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vite": "8.1.3"
|
|
29
29
|
}
|
|
@@ -26,14 +26,14 @@ if (app !== null) {
|
|
|
26
26
|
* The IAP products come from the catalog (`platformProductIds.ait`) at build
|
|
27
27
|
* time, so the wrapper and the game agree on every SKU. The host bridge still
|
|
28
28
|
* sells nothing until the game supplies its server hooks (`prepareIap`,
|
|
29
|
-
* `verifyIapProductGrant`,
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* `verifyIapProductGrant`, see the mpgd-kit APPS_IN_TOSS_PRODUCTION_EVIDENCE
|
|
30
|
+
* guide): without them every purchase and every `commerce.v2.*` method fails
|
|
31
|
+
* closed, and the grant callback only ever confirms through the game backend.
|
|
32
|
+
* Ownership is read from the backend ledger, not from the wrapper.
|
|
33
33
|
*/
|
|
34
34
|
function iapBridgeOptions(): Pick<
|
|
35
35
|
InstallAitHostBridgeOptions,
|
|
36
|
-
'iapProducts' | 'prepareIap' | 'verifyIapProductGrant'
|
|
36
|
+
'iapProducts' | 'prepareIap' | 'verifyIapProductGrant'
|
|
37
37
|
> {
|
|
38
38
|
return runtimeConfig.iapProducts.length === 0 ? {} : { iapProducts: runtimeConfig.iapProducts };
|
|
39
39
|
}
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@devvit/start": "0.14.1",
|
|
29
|
-
"@ttsc/unplugin": "0.
|
|
29
|
+
"@ttsc/unplugin": "0.32.1",
|
|
30
30
|
"@types/node": "^24.0.0",
|
|
31
31
|
"devvit": "0.14.1",
|
|
32
|
-
"ttsc": "0.
|
|
32
|
+
"ttsc": "0.32.1",
|
|
33
33
|
"typescript": "7.0.2",
|
|
34
34
|
"vite": "7.3.6"
|
|
35
35
|
}
|