dotframe 0.1.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 (77) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +57 -0
  4. package/assets/audio/hit.mp3 +0 -0
  5. package/assets/audio/loop.mp3 +0 -0
  6. package/assets/crafter.png +0 -0
  7. package/assets/fonts/archivo-black.json +133 -0
  8. package/assets/fonts/archivo-black.png +0 -0
  9. package/assets/fonts/bangers.json +133 -0
  10. package/assets/fonts/bangers.png +0 -0
  11. package/assets/suzanne.glb +0 -0
  12. package/cli/commands/config.ts +35 -0
  13. package/cli/commands/doctor.ts +71 -0
  14. package/cli/commands/new.ts +73 -0
  15. package/cli/commands/play.ts +244 -0
  16. package/cli/commands/ship.ts +94 -0
  17. package/cli/commands/skills.ts +66 -0
  18. package/cli/commands/snap.ts +88 -0
  19. package/cli/lib.ts +148 -0
  20. package/cli/main.ts +95 -0
  21. package/cli/simkit.ts +152 -0
  22. package/cli/tsconfig.json +8 -0
  23. package/native/df_audio.c +266 -0
  24. package/native/df_native.c +605 -0
  25. package/native/ffi.macos.json +318 -0
  26. package/native/ffi.windows.json +322 -0
  27. package/native/third_party/dr_mp3.h +5430 -0
  28. package/native/third_party/stb_image.h +7988 -0
  29. package/native/third_party/stb_image_write.h +1724 -0
  30. package/native/third_party/stb_truetype.h +5079 -0
  31. package/package.json +47 -0
  32. package/scripts/build-native.sh +33 -0
  33. package/scripts/build-web.sh +10 -0
  34. package/scripts/vendor.sh +36 -0
  35. package/skills/assets/SKILL.md +11 -0
  36. package/skills/core/SKILL.md +51 -0
  37. package/skills/core/references/errors.md +22 -0
  38. package/skills/discord/SKILL.md +12 -0
  39. package/skills/export-web/SKILL.md +20 -0
  40. package/skills/game-design/SKILL.md +27 -0
  41. package/skills/game-design/references/sim-walkthrough.md +32 -0
  42. package/skills/ios/SKILL.md +19 -0
  43. package/skills/macos/SKILL.md +16 -0
  44. package/skills/netplay/SKILL.md +34 -0
  45. package/skills/relay/SKILL.md +18 -0
  46. package/src/audio.ts +19 -0
  47. package/src/draw2d.ts +901 -0
  48. package/src/ecs.ts +30 -0
  49. package/src/gltf.ts +82 -0
  50. package/src/gpu.ts +86 -0
  51. package/src/input.ts +215 -0
  52. package/src/math.ts +113 -0
  53. package/src/native/backend.ts +142 -0
  54. package/src/native/ffi.ts +42 -0
  55. package/src/native/library.ts +65 -0
  56. package/src/native/run.ts +88 -0
  57. package/src/physics.ts +57 -0
  58. package/src/platform.ts +12 -0
  59. package/src/raster2d.ts +348 -0
  60. package/src/render.ts +132 -0
  61. package/src/shapes.ts +37 -0
  62. package/src/sim.ts +56 -0
  63. package/src/storage.ts +6 -0
  64. package/src/web/run.ts +338 -0
  65. package/templates/_base/AGENTS.md +7 -0
  66. package/templates/_base/dotframe.json +15 -0
  67. package/templates/_base/gitignore +5 -0
  68. package/templates/_base/index.html +14 -0
  69. package/templates/_base/main.web.ts +27 -0
  70. package/templates/_base/package.json +12 -0
  71. package/templates/_base/sim.ts +31 -0
  72. package/templates/blank/game.ts +64 -0
  73. package/templates/fighter/game.ts +131 -0
  74. package/templates/platformer/game.ts +120 -0
  75. package/tools/check-audio.ts +57 -0
  76. package/tools/gen-library-glue.ts +83 -0
  77. package/tools/make-sprite.ts +92 -0
@@ -0,0 +1,244 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { Draw2D } from "../../src/draw2d";
4
+ import type { Sim, SimRun } from "../../src/sim";
5
+ import { CliError, type Ctx, loadConfig, print } from "../lib";
6
+ import { firstDifference, flatten, headlessRun, type InputSource, inputSource, lcg, loadSim, parseOptions } from "../simkit";
7
+
8
+ export interface PlayArgs {
9
+ inputs?: string;
10
+ mash?: string;
11
+ frames?: string;
12
+ seed?: string;
13
+ options?: string;
14
+ every?: string;
15
+ }
16
+
17
+ interface Played {
18
+ frames: number;
19
+ over: boolean;
20
+ checksum: number;
21
+ trace: { frame: number; checksum: number }[];
22
+ state: unknown;
23
+ ms: number;
24
+ }
25
+
26
+ function play(run: SimRun, source: InputSource, frames: number, every: number): Played {
27
+ const t0 = performance.now();
28
+ const trace: { frame: number; checksum: number }[] = [];
29
+ let f = 0;
30
+ for (; f < frames && !run.over(); f++) {
31
+ run.step(source.at(f));
32
+ if (every > 0 && (f + 1) % every === 0) trace.push({ frame: f + 1, checksum: run.checksum() });
33
+ }
34
+ return { frames: f, over: run.over(), checksum: run.checksum(), trace, state: run.state(), ms: Math.round(performance.now() - t0) };
35
+ }
36
+
37
+ async function setup(args: PlayArgs): Promise<{ sim: Sim; run: SimRun; source: InputSource; seed: number; options: Record<string, unknown>; root: string }> {
38
+ const config = loadConfig();
39
+ const sim = await loadSim(config);
40
+ const seed = Number(args.seed ?? "1");
41
+ const options = parseOptions(sim, args.options);
42
+ const source = inputSource(sim, config.root, args.inputs, args.mash);
43
+ const run = await headlessRun(sim, config.root);
44
+ run.start(seed, options);
45
+ return { sim, run, source, seed, options, root: config.root };
46
+ }
47
+
48
+ export async function sim(ctx: Ctx, args: PlayArgs): Promise<void> {
49
+ const { run, source, seed } = await setup(args);
50
+ const r = play(run, source, Number(args.frames ?? "600"), Number(args.every ?? "0"));
51
+ print(ctx, { seed, inputs: source.describe, ...r }, (): string =>
52
+ [`${r.frames} frames in ${r.ms} ms (${source.describe}, seed ${seed})${r.over ? ", match over" : ""}`, `checksum ${r.checksum}`, JSON.stringify(r.state, null, 2)].join("\n"),
53
+ );
54
+ }
55
+
56
+ interface Replay {
57
+ version: 1;
58
+ seed: number;
59
+ options: Record<string, unknown>;
60
+ // Encoded inputs as change points.
61
+ inputs: { frame: number; inputs: number[] }[];
62
+ frames: number;
63
+ checksums: { frame: number; checksum: number }[];
64
+ final: number;
65
+ }
66
+
67
+ export async function record(ctx: Ctx, file: string, args: PlayArgs): Promise<void> {
68
+ if (!file) throw new CliError("MISSING_ARG", "replay record needs an output file", "dotframe replay record replays/smoke.json --mash 7 --frames 1800");
69
+ const { run, source, seed, options } = await setup(args);
70
+ const frames = Number(args.frames ?? "1800");
71
+ const r = play(run, source, frames, 60);
72
+ const inputs: Replay["inputs"] = [];
73
+ for (let f = 0; f < r.frames; f++) {
74
+ const cur = source.at(f);
75
+ const last = inputs[inputs.length - 1];
76
+ if (!last || last.inputs.some((v: number, i: number): boolean => v !== cur[i])) inputs.push({ frame: f, inputs: cur.slice() });
77
+ }
78
+ const replay: Replay = { version: 1, seed, options, inputs, frames: r.frames, checksums: r.trace, final: r.checksum };
79
+ writeFileSync(resolve(process.cwd(), file), `${JSON.stringify(replay)}\n`);
80
+ print(ctx, { file, frames: r.frames, final: r.checksum, checkpoints: r.trace.length }, (): string => `recorded ${r.frames} frames to ${file} (final checksum ${r.checksum})`);
81
+ }
82
+
83
+ export async function verify(ctx: Ctx, files: string[]): Promise<void> {
84
+ if (files.length === 0) throw new CliError("MISSING_ARG", "replay verify needs one or more replay files", "dotframe replay verify replays/*.json");
85
+ const config = loadConfig();
86
+ const sim = await loadSim(config);
87
+ const results: { file: string; ok: boolean; firstMismatch: number | null; expected: number; got: number }[] = [];
88
+ for (const file of files) {
89
+ const path = resolve(process.cwd(), file);
90
+ if (!existsSync(path)) throw new CliError("REPLAY_MISSING", `${file} does not exist`, "record one with dotframe replay record");
91
+ const replay = JSON.parse(readFileSync(path, "utf8")) as Replay;
92
+ const run = await headlessRun(sim, config.root);
93
+ run.start(replay.seed, replay.options);
94
+ const source: InputSource = {
95
+ describe: file,
96
+ at: (frame: number): number[] => {
97
+ let cur = replay.inputs[0]?.inputs ?? [];
98
+ for (const k of replay.inputs) {
99
+ if (k.frame > frame) break;
100
+ cur = k.inputs;
101
+ }
102
+ return cur;
103
+ },
104
+ };
105
+ const r = play(run, source, replay.frames, 60);
106
+ const mismatch = replay.checksums.find((c, i): boolean => r.trace[i]?.checksum !== c.checksum);
107
+ results.push({ file, ok: !mismatch && r.checksum === replay.final, firstMismatch: mismatch ? mismatch.frame : r.checksum === replay.final ? null : r.frames, expected: replay.final, got: r.checksum });
108
+ }
109
+ const failed = results.filter((r): boolean => !r.ok);
110
+ print(ctx, { results, failed: failed.length }, (): string =>
111
+ results.map((r): string => (r.ok ? `ok ${r.file}` : `FAIL ${r.file}: diverges by frame ${r.firstMismatch}`)).join("\n"),
112
+ );
113
+ if (failed.length > 0) process.exit(1);
114
+ }
115
+
116
+ // A Draw2D that accepts every call and draws nothing, so render paths run headless.
117
+ const stubDraw: Draw2D = new Proxy({} as Draw2D, {
118
+ get: (_t: Draw2D, key: string | symbol): unknown => {
119
+ if (key === "measureText") return (): { width: number } => ({ width: 10 });
120
+ if (key === "getGlobalAlpha") return (): number => 1;
121
+ return (): void => {};
122
+ },
123
+ });
124
+
125
+ export interface DesyncArgs extends PlayArgs {
126
+ latency?: string;
127
+ jitter?: string;
128
+ delay?: string;
129
+ renders?: string;
130
+ }
131
+
132
+ // Each peer runs its own rollback loop against a deterministic arrival schedule, one after the other, so peers never
133
+ // share module state. Peer 1 also renders between steps: rendering must not change the simulation.
134
+ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
135
+ const config = loadConfig();
136
+ const sim = await loadSim(config);
137
+ if (sim.players !== 2) throw new CliError("UNSUPPORTED", "desync simulates two peers; this sim has " + sim.players + " players", "", "netplay");
138
+ const frames = Number(args.frames ?? "1800");
139
+ const seed = Number(args.seed ?? "1");
140
+ const latency = Math.round(Number((args.latency ?? "100ms").replace("ms", "")) / (1000 / 60));
141
+ const jitter = Math.round(Number((args.jitter ?? "0ms").replace("ms", "")) / (1000 / 60));
142
+ const delay = Number(args.delay ?? "2");
143
+ const renders = Number(args.renders ?? "3");
144
+ const every = Number(args.every ?? "30");
145
+ const options = parseOptions(sim, args.options);
146
+ const source = inputSource(sim, config.root, args.inputs, args.mash ?? (args.inputs ? undefined : "7"));
147
+ // Input a player presses at frame f applies at frame f + delay, on both peers.
148
+ const applied = (frame: number, player: number): number => (frame < delay ? sim.neutral : source.at(frame - delay)[player]);
149
+
150
+ const reference = await headlessRun(sim, config.root);
151
+ reference.start(seed, options);
152
+ const truth: number[] = [];
153
+ const truthState = new Map<number, Map<string, unknown>>();
154
+ for (let f = 0; f < frames; f++) {
155
+ reference.step([applied(f, 0), applied(f, 1)]);
156
+ truth.push(reference.checksum());
157
+ if (reference.inspect && f % every === 0) truthState.set(f, flatten(reference.inspect()));
158
+ }
159
+
160
+ const peer = async (self: number): Promise<{ checksums: number[]; states: Map<number, string>; rollbacks: number; maxDepth: number }> => {
161
+ const other = 1 - self;
162
+ const noise = lcg(seed * 31 + other);
163
+ // Arrival tick of the remote input for each frame, in order, as the link test does.
164
+ const arrival: number[] = [];
165
+ let last = 0;
166
+ for (let f = 0; f < frames; f++) {
167
+ const sent = Math.max(0, f - delay);
168
+ last = Math.max(last, sent + latency + (jitter > 0 ? Math.floor(noise() * (jitter + 1)) : 0));
169
+ arrival.push(last);
170
+ }
171
+ const run = await headlessRun(sim, config.root);
172
+ run.start(seed, options);
173
+ const snaps: unknown[] = [];
174
+ const used: number[] = [];
175
+ const known: (number | undefined)[] = [];
176
+ const checksums: number[] = [];
177
+ // First differing path per checkpoint frame; overwritten when a rollback resimulates the frame.
178
+ const states = new Map<number, string>();
179
+ let rollbacks = 0;
180
+ let maxDepth = 0;
181
+ let next = 0;
182
+ let lastKnown = sim.neutral;
183
+ const stepFrame = (f: number): void => {
184
+ snaps[f] = run.save();
185
+ const remote = known[f] ?? lastKnown;
186
+ used[f] = remote;
187
+ const inputs = [sim.neutral, sim.neutral];
188
+ inputs[self] = applied(f, self);
189
+ inputs[other] = remote;
190
+ run.step(inputs);
191
+ checksums[f] = run.checksum();
192
+ if (self === 1 && run.render) for (let r = 0; r < renders; r++) run.render(stubDraw);
193
+ const want = truthState.get(f);
194
+ if (want && run.inspect) states.set(f, firstDifference(want, flatten(run.inspect())));
195
+ };
196
+ for (let t = 0; t < frames + latency + jitter + 1; t++) {
197
+ let rollbackFrom = -1;
198
+ while (next < frames && arrival[next] <= t) {
199
+ const value = applied(next, other);
200
+ known[next] = value;
201
+ lastKnown = value;
202
+ if (next < checksums.length && used[next] !== value && rollbackFrom < 0) rollbackFrom = next;
203
+ next += 1;
204
+ }
205
+ const simulated = checksums.length;
206
+ if (rollbackFrom >= 0) {
207
+ rollbacks += 1;
208
+ maxDepth = Math.max(maxDepth, simulated - rollbackFrom);
209
+ run.restore(snaps[rollbackFrom]);
210
+ for (let f = rollbackFrom; f < simulated; f++) stepFrame(f);
211
+ }
212
+ if (t < frames) stepFrame(t);
213
+ }
214
+ return { checksums, states, rollbacks, maxDepth };
215
+ };
216
+
217
+ const peers = [await peer(0), await peer(1)];
218
+ const firstDiff = (a: number[]): number => a.findIndex((c: number, f: number): boolean => c !== truth[f]);
219
+ const firstState = (states: Map<number, string>): { frame: number; path: string } | null => {
220
+ for (const [frame, path] of [...states].sort((a, b): number => a[0] - b[0])) if (path !== "") return { frame, path };
221
+ return null;
222
+ };
223
+ const report = peers.map((p, i) => ({
224
+ peer: i,
225
+ rendersBetweenSteps: i === 1 ? renders : 0,
226
+ firstDivergentFrame: firstDiff(p.checksums),
227
+ // State differences the checksum may miss; null when the sim has no inspect().
228
+ firstStateDifference: reference.inspect ? firstState(p.states) : null,
229
+ rollbacks: p.rollbacks,
230
+ maxRollbackFrames: p.maxDepth,
231
+ }));
232
+ const ok = report.every((r): boolean => r.firstDivergentFrame < 0 && r.firstStateDifference === null);
233
+ print(ctx, { ok, frames, latencyFrames: latency, jitterFrames: jitter, delay, inputs: source.describe, peers: report }, (): string =>
234
+ [
235
+ `${frames} frames, latency ${latency}f, jitter ${jitter}f, delay ${delay}f (${source.describe})`,
236
+ ...report.map((r): string => {
237
+ const sync = r.firstDivergentFrame >= 0 ? `DESYNC (checksum) at frame ${r.firstDivergentFrame}` : r.firstStateDifference ? `STATE DIFFERS at frame ${r.firstStateDifference.frame}: ${r.firstStateDifference.path}` : "in sync";
238
+ return `peer ${r.peer}${r.rendersBetweenSteps ? " (renders)" : ""}: ${sync}, ${r.rollbacks} rollbacks, max ${r.maxRollbackFrames}f`;
239
+ }),
240
+ reference.inspect ? "" : "note: the sim has no inspect(), so only the checksum is compared",
241
+ ].filter(Boolean).join("\n"),
242
+ );
243
+ if (!ok) process.exit(1);
244
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { CliError, type Command, type Ctx, exec, gate, loadConfig, print, runSteps, target, which } from "../lib";
4
+
5
+ const skillFor = (t: string): string => (t === "web" ? "export-web" : t);
6
+
7
+ // Paths marked local-only (unlicensed for distribution) that would ship with a release build.
8
+ function licenseBlockers(root: string, paths: string[]): string[] {
9
+ return paths.filter((p: string): boolean => {
10
+ const full = resolve(root, p);
11
+ return existsSync(full) && (!statSync(full).isDirectory() || readdirSync(full).length > 0);
12
+ });
13
+ }
14
+
15
+ export async function build(ctx: Ctx, name: string | undefined, release: boolean): Promise<void> {
16
+ if (!name) throw new CliError("MISSING_ARG", "build needs a target", "dotframe build web");
17
+ const config = loadConfig();
18
+ const t = target(config, name);
19
+ if (release) {
20
+ const blocked = licenseBlockers(config.root, config.assets?.localOnly ?? []);
21
+ if (blocked.length > 0) throw new CliError("ASSETS_LOCAL_ONLY", `release build would ship assets marked local-only: ${blocked.join(", ")}`, "replace them with licensed assets, then remove them from assets.localOnly", "assets");
22
+ }
23
+ const steps = await runSteps(ctx, config.root, t.steps, skillFor(name));
24
+ const artifact = t.out ?? t.app;
25
+ print(ctx, { target: name, dryRun: ctx.dryRun, steps, artifact: artifact ? resolve(config.root, artifact) : null }, (): string =>
26
+ [...steps.map((s): string => (ctx.dryRun ? s.tail : `ok ${s.label} (${s.ms} ms)`)), artifact ? `artifact: ${artifact}` : ""].filter(Boolean).join("\n"),
27
+ );
28
+ }
29
+
30
+ export async function deploy(ctx: Ctx, name: string | undefined, prod: boolean): Promise<void> {
31
+ if (!name) throw new CliError("MISSING_ARG", "deploy needs a target", "dotframe deploy web --prod --dry-run");
32
+ const config = loadConfig();
33
+ const t = target(config, name);
34
+ const skill = skillFor(name);
35
+ if (!t.deploy || !t.out) throw new CliError("NO_DEPLOY", `target "${name}" has no deploy provider or out dir`, `add "out" and "deploy": {"provider": "vercel", ...} to the target`, skill);
36
+ const out = resolve(config.root, t.out);
37
+ // The 2026-10-03 incident: deploying the repo root put the wrong game in production.
38
+ if (out === config.root || existsSync(join(out, "dotframe.json")) || existsSync(join(out, ".git"))) {
39
+ throw new CliError("DEPLOY_SOURCE_DIR", `${t.out} looks like source, not a build`, "point out at the built folder (e.g. port/dist/web)", skill);
40
+ }
41
+ if (!existsSync(join(out, "index.html"))) throw new CliError("NOT_BUILT", `${t.out}/index.html is missing`, `dotframe build ${name}`, skill);
42
+ if (t.deploy.scope === "your-vercel-team") throw new CliError("CONFIG_PLACEHOLDER", `targets.${name}.deploy.scope is still the template placeholder`, `dotframe config set targets.${name}.deploy.scope '"<team>"'`, skill);
43
+ const argv = ["vercel", "deploy", ...(prod ? ["--prod"] : []), "--yes", "--scope", t.deploy.scope, "--name", t.deploy.project];
44
+ const plan = { target: name, provider: t.deploy.provider, project: `${t.deploy.scope}/${t.deploy.project}`, dir: out, production: prod, files: readdirSync(out).length, command: argv.join(" ") };
45
+ if (ctx.dryRun) {
46
+ print(ctx, { dryRun: true, ...plan }, (): string => `would deploy ${out} to ${plan.project}${prod ? " (production)" : " (preview)"}\n ${plan.command}`);
47
+ return;
48
+ }
49
+ gate(ctx, `deploy ${name}${prod ? " to production" : ""}`, skill);
50
+ if (!which("vercel")) throw new CliError("TOOL_MISSING", "vercel CLI not found", "npm i -g vercel", skill);
51
+ const r = await exec(ctx, config.root, { label: "vercel deploy", argv, cwd: out });
52
+ if (r.code !== 0) throw new CliError("DEPLOY_FAILED", r.tail, "vercel whoami; vercel switch " + t.deploy.scope, skill);
53
+ const url = r.tail.match(/https:\/\/\S+\.vercel\.app/g)?.pop() ?? null;
54
+ print(ctx, { ...plan, url, ms: r.ms }, (): string => `deployed ${url ?? "(url not found in output)"}`);
55
+ }
56
+
57
+ export async function relay(ctx: Ctx, region: string | undefined): Promise<void> {
58
+ const config = loadConfig();
59
+ const r = config.relay;
60
+ if (!r) throw new CliError("NO_RELAY", `dotframe.json has no "relay"`, `add {"provider": "dokploy", "compose": "<id>"} or {"provider": "fly", "app": "...", "config": "fly.toml"}`, "relay");
61
+ let command: Command;
62
+ if (r.provider === "dokploy") {
63
+ if (region) throw new CliError("UNSUPPORTED", "dokploy runs on one fixed VPS; --region needs the fly provider", "", "relay");
64
+ command = { label: "dokploy redeploy", argv: ["vps", "-y", "compose", "redeploy", r.compose] };
65
+ } else {
66
+ command = { label: "fly deploy", argv: ["fly", "deploy", "--config", r.config, "--app", r.app, "--primary-region", region ?? r.region ?? "eze", "--yes"] };
67
+ }
68
+ if (ctx.dryRun) {
69
+ print(ctx, { dryRun: true, provider: r.provider, command: command.argv.join(" ") }, (): string => `would run: ${command.argv.join(" ")}`);
70
+ return;
71
+ }
72
+ gate(ctx, "relay deploy", "relay");
73
+ if (!which(command.argv[0])) throw new CliError("TOOL_MISSING", `${command.argv[0]} not found`, r.provider === "fly" ? "brew install flyctl && fly auth login" : "bun add -g vps CLI and log in", "relay");
74
+ const res = await exec(ctx, config.root, command);
75
+ if (res.code !== 0) throw new CliError("DEPLOY_FAILED", res.tail, "", "relay");
76
+ print(ctx, { provider: r.provider, ms: res.ms }, (): string => `relay deployed via ${r.provider}`);
77
+ }
78
+
79
+ export async function device(ctx: Ctx, name: string): Promise<void> {
80
+ const config = loadConfig();
81
+ const t = target(config, name);
82
+ if (!t.app || !t.device) throw new CliError("NO_DEVICE", `target "${name}" needs "app" and "device" in dotframe.json`, "xcrun devicectl list devices", "ios");
83
+ const app = resolve(config.root, t.app);
84
+ const argv = ["xcrun", "devicectl", "device", "install", "app", "--device", t.device, app];
85
+ if (ctx.dryRun) {
86
+ print(ctx, { dryRun: true, command: argv.join(" ") }, (): string => `would run: ${argv.join(" ")}`);
87
+ return;
88
+ }
89
+ gate(ctx, `install ${name} on device ${t.device}`, "ios");
90
+ if (!existsSync(app)) throw new CliError("NOT_BUILT", `${t.app} is missing`, `dotframe build ${name}`, "ios");
91
+ const r = await exec(ctx, config.root, { label: "devicectl install", argv });
92
+ if (r.code !== 0) throw new CliError("INSTALL_FAILED", r.tail, "unlock the phone, trust this Mac, enable Developer Mode", "ios");
93
+ print(ctx, { device: t.device, app, ms: r.ms }, (): string => `installed ${app} on ${t.device}`);
94
+ }
@@ -0,0 +1,66 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { CliError, type Ctx, print } from "../lib";
4
+
5
+ // Skills ship inside the dotframe package, so the guide always matches the installed CLI.
6
+ export function skillsDir(): string {
7
+ return resolve(process.env.DOTFRAME_SKILLS_DIR ?? join(import.meta.dir, "../../skills"));
8
+ }
9
+
10
+ interface Skill {
11
+ name: string;
12
+ description: string;
13
+ path: string;
14
+ }
15
+
16
+ function frontmatter(text: string, key: string): string {
17
+ return text.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1].trim() ?? "";
18
+ }
19
+
20
+ function all(): Skill[] {
21
+ const dir = skillsDir();
22
+ if (!existsSync(dir)) throw new CliError("SKILLS_MISSING", `skills directory ${dir} not found`, "reinstall dotframe or unset DOTFRAME_SKILLS_DIR");
23
+ return readdirSync(dir)
24
+ .filter((n: string): boolean => existsSync(join(dir, n, "SKILL.md")))
25
+ .sort((a: string, b: string): number => (a === "core" ? -1 : b === "core" ? 1 : a.localeCompare(b)))
26
+ .map((name: string): Skill => {
27
+ const text = readFileSync(join(dir, name, "SKILL.md"), "utf8");
28
+ return { name, description: frontmatter(text, "description"), path: join(dir, name) };
29
+ });
30
+ }
31
+
32
+ function files(dir: string): string[] {
33
+ if (!existsSync(dir)) return [];
34
+ return readdirSync(dir).flatMap((n: string): string[] => (statSync(join(dir, n)).isDirectory() ? files(join(dir, n)) : [join(dir, n)])).sort();
35
+ }
36
+
37
+ function content(skill: Skill, full: boolean): string {
38
+ const parts = [readFileSync(join(skill.path, "SKILL.md"), "utf8").trim()];
39
+ if (full) {
40
+ for (const sub of ["references", "templates"]) {
41
+ for (const f of files(join(skill.path, sub))) parts.push(`--- ${f.slice(skill.path.length + 1)} ---\n${readFileSync(f, "utf8").trim()}`);
42
+ }
43
+ }
44
+ return parts.join("\n\n");
45
+ }
46
+
47
+ export async function skills(ctx: Ctx, args: string[], full: boolean, everything: boolean): Promise<void> {
48
+ const [op = "list", ...names] = args;
49
+ const list = all();
50
+ const find = (name: string): Skill => {
51
+ const s = list.find((k: Skill): boolean => k.name === name);
52
+ if (!s) throw new CliError("UNKNOWN_SKILL", `no skill named "${name}"`, `available: ${list.map((k: Skill): string => k.name).join(", ")}`);
53
+ return s;
54
+ };
55
+ if (op === "list") {
56
+ const width = Math.max(...list.map((s: Skill): number => s.name.length)) + 2;
57
+ print(ctx, list.map(({ name, description }) => ({ name, description })), (): string => list.map((s: Skill): string => `${s.name.padEnd(width)}${s.description}`).join("\n"));
58
+ } else if (op === "get") {
59
+ if (!everything && names.length === 0) throw new CliError("MISSING_ARG", "skills get needs a name or --all", "dotframe skills get core");
60
+ const chosen = everything ? list : names.map(find);
61
+ print(ctx, chosen.map((s: Skill) => ({ name: s.name, content: content(s, full || everything) })), (): string => chosen.map((s: Skill): string => content(s, full || everything)).join("\n\n"));
62
+ } else if (op === "path") {
63
+ const path = names[0] ? find(names[0]).path : skillsDir();
64
+ print(ctx, { path }, (): string => path);
65
+ } else throw new CliError("UNKNOWN_COMMAND", `skills ${op}`, "dotframe skills list | get <name> | path");
66
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { CliError, type Ctx, exec, loadConfig, print, which } from "../lib";
4
+ import { inputSource, loadSim, parseOptions, simPath } from "../simkit";
5
+ import type { PlayArgs } from "./play";
6
+
7
+ const ENGINE = resolve(import.meta.dir, "../../src");
8
+
9
+ // Renders one frame with the real WebGPU renderer: bundles a page that steps the sim to --frame with the given
10
+ // inputs, serves the game root, and screenshots it with agent-browser.
11
+ export async function snap(ctx: Ctx, args: PlayArgs & { frame?: string; out?: string }): Promise<void> {
12
+ const config = loadConfig();
13
+ const sim = await loadSim(config);
14
+ const frame = Number(args.frame ?? "0");
15
+ const out = resolve(process.cwd(), args.out ?? `snap-${frame}.png`);
16
+ const seed = Number(args.seed ?? "1");
17
+ const options = parseOptions(sim, args.options);
18
+ const source = inputSource(sim, config.root, args.inputs, args.mash);
19
+ const inputs = Array.from({ length: frame }, (_: unknown, f: number): number[] => source.at(f));
20
+ if (!which("agent-browser")) throw new CliError("TOOL_MISSING", "agent-browser not found (snap drives a real browser)", "npm i -g agent-browser && agent-browser install");
21
+
22
+ const work = join(config.root, ".dotframe", "snap");
23
+ mkdirSync(work, { recursive: true });
24
+ const entry = join(work, "entry.ts");
25
+ writeFileSync(
26
+ entry,
27
+ `import sim from ${JSON.stringify(simPath(config))};
28
+ import { createDraw2D } from ${JSON.stringify(join(ENGINE, "draw2d"))};
29
+ import { loadBytes, run } from ${JSON.stringify(join(ENGINE, "web/run"))};
30
+ const plan = ${JSON.stringify({ seed, options, inputs })};
31
+ const done = (data) => { const el = document.createElement("pre"); el.id = data.error ? "dotframe-error" : "dotframe-ready"; el.style.display = "none"; el.textContent = JSON.stringify(data); document.body.appendChild(el); };
32
+ run(sim.window, (p) => {
33
+ const draw = createDraw2D(p.gpu, sim.window.width, sim.window.height);
34
+ const r = sim.create({ gpu: p.gpu, load: loadBytes, headless: false, draw });
35
+ let ready = false;
36
+ let shown = 0;
37
+ r.ready.then(() => {
38
+ r.start(plan.seed, plan.options);
39
+ for (const i of plan.inputs) r.step(i);
40
+ ready = true;
41
+ }).catch((e) => done({ error: String(e) }));
42
+ return () => {
43
+ draw.begin();
44
+ if (ready && r.render) r.render(draw);
45
+ draw.end({ r: 0, g: 0, b: 0 });
46
+ if (ready && ++shown === 3) done({ frame: plan.inputs.length, checksum: r.checksum(), state: r.state() });
47
+ return true;
48
+ };
49
+ }).catch((e) => done({ error: String(e) }));
50
+ `,
51
+ );
52
+ const built = await Bun.build({ entrypoints: [entry], target: "browser" });
53
+ if (!built.success) throw new CliError("SNAP_BUILD_FAILED", built.logs.map(String).join("\n"), "the sim module must bundle for the browser");
54
+ const bundle = await built.outputs[0].text();
55
+ const html = `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;background:#000;overflow:hidden}</style><body><script type="module" src="/__dotframe/snap.js"></script>`;
56
+ const server = Bun.serve({
57
+ port: 0,
58
+ fetch: (req: Request): Response => {
59
+ const path = decodeURIComponent(new URL(req.url).pathname);
60
+ if (path === "/") return new Response(html, { headers: { "content-type": "text/html" } });
61
+ if (path === "/__dotframe/snap.js") return new Response(bundle, { headers: { "content-type": "text/javascript" } });
62
+ const file = join(config.root, path);
63
+ return existsSync(file) ? new Response(Bun.file(file)) : new Response("not found", { status: 404 });
64
+ },
65
+ });
66
+ const ab = (...a: string[]) => exec({ ...ctx, json: true }, config.root, { label: `agent-browser ${a[0]}`, argv: ["agent-browser", "--session", "dotframe-snap", ...a] });
67
+ try {
68
+ await ab("set", "viewport", String(sim.window.width), String(sim.window.height));
69
+ const opened = await ab("open", `http://localhost:${server.port}/`);
70
+ if (opened.code !== 0) throw new CliError("BROWSER_FAILED", opened.tail, "agent-browser install");
71
+ // Loading plus stepping can take a while for long inputs; poll for the marker.
72
+ let result: { frame?: number; checksum?: number; state?: unknown; error?: string } | null = null;
73
+ for (let i = 0; i < 120 && !result; i++) {
74
+ const r = await ab("eval", `(document.getElementById("dotframe-ready") || document.getElementById("dotframe-error") || {}).textContent || ""`);
75
+ const text = r.tail.trim().replace(/^"|"$/g, "").replace(/\\"/g, '"');
76
+ if (text.startsWith("{")) result = JSON.parse(text);
77
+ else await Bun.sleep(500);
78
+ }
79
+ if (!result) throw new CliError("SNAP_TIMEOUT", "the page never reported ready after 60 s", "open the page with agent-browser --headed and read the console; WebGPU may be unavailable", "core");
80
+ if (result.error) throw new CliError("SNAP_PAGE_ERROR", result.error, "WebGPU unavailable? try AGENT_BROWSER_ARGS=--enable-unsafe-webgpu", "core");
81
+ const shot = await ab("screenshot", out);
82
+ if (shot.code !== 0) throw new CliError("BROWSER_FAILED", shot.tail);
83
+ print(ctx, { out, ...result }, (): string => `frame ${result?.frame} -> ${out} (checksum ${result?.checksum})`);
84
+ } finally {
85
+ await ab("close");
86
+ server.stop(true);
87
+ }
88
+ }
package/cli/lib.ts ADDED
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export class CliError extends Error {
5
+ constructor(
6
+ readonly code: string,
7
+ message: string,
8
+ readonly fix = "",
9
+ readonly skill = "core",
10
+ readonly exit = 1,
11
+ ) {
12
+ super(message);
13
+ }
14
+ }
15
+
16
+ export interface Ctx {
17
+ json: boolean;
18
+ yes: boolean;
19
+ dryRun: boolean;
20
+ }
21
+
22
+ export function print(ctx: Ctx, data: unknown, human: () => string): void {
23
+ if (ctx.json) console.log(JSON.stringify({ ok: true, data }));
24
+ else console.log(human());
25
+ }
26
+
27
+ export function fail(ctx: Ctx, error: unknown): never {
28
+ const e = error instanceof CliError ? error : new CliError("INTERNAL", error instanceof Error ? error.message : String(error));
29
+ if (ctx.json) console.log(JSON.stringify({ ok: false, error: { code: e.code, message: e.message, fix: e.fix, skill: e.skill } }));
30
+ else {
31
+ console.error(`error ${e.code}: ${e.message}`);
32
+ if (e.fix) console.error(`fix: ${e.fix}`);
33
+ console.error(`guide: dotframe skills get ${e.skill}`);
34
+ }
35
+ process.exit(e.exit);
36
+ }
37
+
38
+ // External writes (deploys, installs, publishes) need --yes. --dry-run always wins.
39
+ export function gate(ctx: Ctx, action: string, skill: string): void {
40
+ if (!ctx.yes) throw new CliError("APPROVAL_REQUIRED", `${action} changes something outside this machine`, "review with --dry-run, then rerun with --yes", skill, 2);
41
+ }
42
+
43
+ export interface Command {
44
+ label: string;
45
+ argv: string[];
46
+ cwd?: string;
47
+ env?: Record<string, string>;
48
+ }
49
+
50
+ export interface Target {
51
+ steps: Command[];
52
+ out?: string;
53
+ app?: string;
54
+ device?: string;
55
+ deploy?: { provider: "vercel"; project: string; scope: string };
56
+ }
57
+
58
+ export interface Config {
59
+ root: string;
60
+ name: string;
61
+ sim?: string;
62
+ targets: Record<string, Target>;
63
+ relay?: { provider: "dokploy"; compose: string } | { provider: "fly"; app: string; config: string; region?: string };
64
+ links?: { path: string; target: string }[];
65
+ assets?: { localOnly?: string[] };
66
+ }
67
+
68
+ export const CONFIG_FILE = "dotframe.json";
69
+
70
+ export function findRoot(from = process.cwd()): string | null {
71
+ let dir = resolve(from);
72
+ for (;;) {
73
+ if (existsSync(join(dir, CONFIG_FILE))) return dir;
74
+ const up = dirname(dir);
75
+ if (up === dir) return null;
76
+ dir = up;
77
+ }
78
+ }
79
+
80
+ export function home(path: string): string {
81
+ return path.startsWith("~/") ? join(process.env.HOME ?? "", path.slice(2)) : path;
82
+ }
83
+
84
+ export function loadConfig(): Config {
85
+ const root = findRoot();
86
+ if (!root) throw new CliError("NO_CONFIG", `no ${CONFIG_FILE} in this directory or any parent`, "cd into a game repo, or run dotframe new <name>");
87
+ try {
88
+ return { ...(JSON.parse(readFileSync(join(root, CONFIG_FILE), "utf8")) as Omit<Config, "root">), root };
89
+ } catch (error) {
90
+ throw new CliError("BAD_CONFIG", `${CONFIG_FILE}: ${error instanceof Error ? error.message : "unreadable"}`, "fix the JSON syntax");
91
+ }
92
+ }
93
+
94
+ export function target(config: Config, name: string): Target {
95
+ const t = config.targets[name];
96
+ if (!t) throw new CliError("UNKNOWN_TARGET", `target "${name}" is not in ${CONFIG_FILE}`, `known targets: ${Object.keys(config.targets).join(", ") || "none"}`, "core");
97
+ return t;
98
+ }
99
+
100
+ export function which(bin: string): string | null {
101
+ return Bun.which(bin);
102
+ }
103
+
104
+ export interface RunResult {
105
+ label: string;
106
+ code: number;
107
+ ms: number;
108
+ tail: string;
109
+ }
110
+
111
+ // Runs a command to completion. Output streams to stderr in human mode and is captured (tail kept) in JSON mode.
112
+ export async function exec(ctx: Ctx, root: string, command: Command): Promise<RunResult> {
113
+ const t0 = performance.now();
114
+ const proc = Bun.spawn(command.argv, {
115
+ cwd: command.cwd ? resolve(root, command.cwd) : root,
116
+ env: { ...process.env, ...command.env },
117
+ stdout: "pipe",
118
+ stderr: "pipe",
119
+ });
120
+ const chunks: string[] = [];
121
+ const pump = async (stream: ReadableStream<Uint8Array>): Promise<void> => {
122
+ const decoder = new TextDecoder();
123
+ for await (const chunk of stream) {
124
+ const text = decoder.decode(chunk);
125
+ chunks.push(text);
126
+ if (!ctx.json) process.stderr.write(text);
127
+ }
128
+ };
129
+ await Promise.all([pump(proc.stdout), pump(proc.stderr)]);
130
+ const code = await proc.exited;
131
+ const tail = chunks.join("").split("\n").slice(-20).join("\n").trim();
132
+ return { label: command.label, code, ms: Math.round(performance.now() - t0), tail };
133
+ }
134
+
135
+ export async function runSteps(ctx: Ctx, root: string, steps: Command[], skill: string): Promise<RunResult[]> {
136
+ const results: RunResult[] = [];
137
+ for (const step of steps) {
138
+ if (ctx.dryRun) {
139
+ results.push({ label: step.label, code: 0, ms: 0, tail: `would run: ${step.argv.join(" ")} (cwd ${step.cwd ?? "."})` });
140
+ continue;
141
+ }
142
+ if (!ctx.json) console.error(`> ${step.label}`);
143
+ const r = await exec(ctx, root, step);
144
+ results.push(r);
145
+ if (r.code !== 0) throw new CliError("STEP_FAILED", `step "${step.label}" exited ${r.code}\n${r.tail}`, "read the output above; dotframe doctor checks the toolchain", skill);
146
+ }
147
+ return results;
148
+ }