dotframe 0.1.2 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3
4
+
5
+ Fixes from the Craft Ones port dogfood.
6
+
7
+ - Numeric and duration flags are validated: `--latency abc`, `--frames abc` and friends fail with `BAD_ARG` instead of a false green.
8
+ - `snap` requires `--frame` and builds in a temp directory, never in the repo.
9
+ - `new` skips `git init` inside an existing repo, copies template subfolders, and names the real cause when bun's minimum release age blocks a fresh dotframe.
10
+ - `deploy --dry-run` counts every file in the build, not the top-level entries.
11
+ - `dev` serves `index.html` with `Cache-Control: no-cache`, like production.
12
+ - Templates: content-hashed web build with a no-cache `vercel.json`, `tsconfig.json` with `@webgpu/types`, and a declared `rollbackWindow`.
13
+ - Engine: `src/web/run.ts` typechecks on TypeScript 5.9 and with Bun's types loaded.
14
+ - `doctor` fails when the deploy scope is a template placeholder, and when `render()` draws nothing with a stub Draw2D (which makes `desync` blind).
15
+ - `desync` warns when rollbacks exceed the sim's new optional `rollbackWindow`.
16
+ - Per-command help (`dotframe <command> --help`); `config set` keeps short objects and arrays on one line.
17
+ - Skills: render purity with a stub Draw2D and the forced-red check (core), rollback window (netplay), SVG rasterizing (assets).
18
+
3
19
  ## 0.1.2
4
20
 
5
21
  - `npx skills add crafter-games/dotframe` installs a `dotframe` discovery skill that points agents at `dotframe skills get core`. The guides served by the CLI moved to `skill-data/`, so they are not installed as separate skills.
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { CONFIG_FILE, CliError, type Ctx, loadConfig, print } from "../lib";
3
+ import { CONFIG_FILE, CliError, type Ctx, formatJson, loadConfig, print } from "../lib";
4
4
 
5
5
  // Dotted keys: targets.web.deploy.project
6
6
  export async function configCmd(ctx: Ctx, args: string[]): Promise<void> {
@@ -30,6 +30,6 @@ export async function configCmd(ctx: Ctx, args: string[]): Promise<void> {
30
30
  print(ctx, { dryRun: true, key, value: parsed }, (): string => `would set ${key} = ${JSON.stringify(parsed)}`);
31
31
  return;
32
32
  }
33
- writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
33
+ writeFileSync(file, `${formatJson(raw)}\n`);
34
34
  print(ctx, { key, value: parsed }, (): string => `${key} = ${JSON.stringify(parsed)}`);
35
35
  }
@@ -1,7 +1,9 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { type Config, type Ctx, findRoot, home, loadConfig, print, which } from "../lib";
4
- import { loadSim } from "../simkit";
4
+ import type { Draw2D } from "../../src/draw2d";
5
+ import type { Sim } from "../../src/sim";
6
+ import { headlessRun, loadSim } from "../simkit";
5
7
 
6
8
  interface Check {
7
9
  check: string;
@@ -33,10 +35,16 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
33
35
  try {
34
36
  const sim = await loadSim(config);
35
37
  checks.push({ check: "sim", ok: true, detail: `${config.sim} (${sim.players} players)`, fix: "", skill: "core" });
38
+ checks.push(await renderCheck(sim, config.root));
36
39
  } catch (error) {
37
40
  checks.push({ check: "sim", ok: false, detail: error instanceof Error ? error.message : "failed to load", fix: "dotframe skills get core (Sim contract)", skill: "core" });
38
41
  }
39
42
  }
43
+ for (const [name, t] of Object.entries(config.targets)) {
44
+ if (t.deploy?.scope === "your-vercel-team") {
45
+ checks.push({ check: `deploy:${name}`, ok: false, detail: "deploy scope is still the template placeholder", fix: `dotframe config set targets.${name}.deploy.scope '"<vercel team>"'`, skill: "export-web" });
46
+ }
47
+ }
40
48
  const targets = Object.keys(config.targets);
41
49
  if (targets.some((t: string): boolean => t === "web" || t === "discord")) {
42
50
  checks.push(tool("vercel", "deploy web", "npm i -g vercel", "export-web"), tool("agent-browser", "dotframe snap", "npm i -g agent-browser && agent-browser install", "core"));
@@ -69,3 +77,27 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
69
77
  );
70
78
  if (failed.length > 0) process.exit(1);
71
79
  }
80
+
81
+ // desync proves rendering is pure by calling render() with a stub Draw2D. A render that draws nothing in that
82
+ // case (for example, one that waits for a real renderer) makes the check pass without checking anything.
83
+ async function renderCheck(sim: Sim, root: string): Promise<Check> {
84
+ const base = { check: "sim:render", skill: "netplay" };
85
+ const run = await headlessRun(sim, root);
86
+ if (!run.render) return { ...base, ok: false, detail: "the sim has no render(); desync cannot check render purity", fix: "add render: (draw) => ... to the SimRun", skill: "core" };
87
+ let calls = 0;
88
+ const draw = new Proxy({} as Draw2D, {
89
+ get: (_t: Draw2D, key: string | symbol): unknown => {
90
+ if (key === "measureText") return (): { width: number } => ({ width: 10 });
91
+ if (key === "getGlobalAlpha") return (): number => 1;
92
+ return (): void => {
93
+ calls += 1;
94
+ };
95
+ },
96
+ });
97
+ run.start(1, { ...sim.options });
98
+ for (let f = 0; f < 30; f++) run.step(new Array(sim.players).fill(sim.neutral));
99
+ run.render(draw);
100
+ return calls > 0
101
+ ? { ...base, ok: true, detail: `render() drew ${calls} calls with a stub Draw2D`, fix: "" }
102
+ : { ...base, ok: false, detail: "render() made no draw calls with a stub Draw2D, so desync never exercises it", fix: "render with whatever Draw2D it is given; do not skip when platform.draw is missing" };
103
+ }
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, watch, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { CliError, type Ctx, exec, loadConfig, print, runSteps, target } from "../lib";
4
4
 
@@ -11,7 +11,9 @@ export async function create(ctx: Ctx, name: string | undefined, template: strin
11
11
  const dir = resolve(process.cwd(), name);
12
12
  if (existsSync(dir)) throw new CliError("EXISTS", `${dir} already exists`, "pick another name or delete it", "game-design");
13
13
  const files: [string, string][] = [
14
- ...readdirSync(join(TEMPLATES, "_base")).map((f: string): [string, string] => [join(TEMPLATES, "_base", f), f === "gitignore" ? ".gitignore" : f]),
14
+ ...(readdirSync(join(TEMPLATES, "_base"), { recursive: true }) as string[])
15
+ .filter((f: string): boolean => statSync(join(TEMPLATES, "_base", f)).isFile())
16
+ .map((f: string): [string, string] => [join(TEMPLATES, "_base", f), f === "gitignore" ? ".gitignore" : f]),
15
17
  [join(TEMPLATES, template, "game.ts"), "src/game.ts"],
16
18
  ];
17
19
  if (ctx.dryRun) {
@@ -25,15 +27,25 @@ export async function create(ctx: Ctx, name: string | undefined, template: strin
25
27
  mkdirSync(join(dir, ".agents/skills/dotframe"), { recursive: true });
26
28
  cpSync(resolve(import.meta.dir, "../../skills/dotframe/SKILL.md"), join(dir, ".agents/skills/dotframe/SKILL.md"));
27
29
  mkdirSync(join(dir, "replays"), { recursive: true });
30
+ // Inside an existing repo (porting a game on a branch), the new folder is part of that repo.
31
+ const insideRepo = (await exec({ ...ctx, json: true }, process.cwd(), { label: "git check", argv: ["git", "rev-parse", "--is-inside-work-tree"] })).code === 0;
28
32
  const steps = [
29
- { label: "git init", argv: ["git", "init", "-q"] },
33
+ ...(insideRepo ? [] : [{ label: "git init", argv: ["git", "init", "-q"] }]),
30
34
  ...(install ? [{ label: "install", argv: ["bun", "install"] }] : []),
31
35
  ];
32
36
  for (const step of steps) {
33
37
  const r = await exec(ctx, dir, step);
34
- if (r.code !== 0) throw new CliError("STEP_FAILED", `${step.label}: ${r.tail}`, "on a machine with a private registry, run bun install --registry https://registry.npmjs.org", "game-design");
38
+ if (r.code !== 0) {
39
+ // bun's minimum release age hides versions published in the last N seconds, including a fresh dotframe.
40
+ const fix = /minimum.?release.?age|minimumReleaseAge/i.test(r.tail)
41
+ ? `cd ${name} && bun install --minimum-release-age 0`
42
+ : /registry|401|403|ENOTFOUND/i.test(r.tail)
43
+ ? `cd ${name} && bun install --registry https://registry.npmjs.org`
44
+ : `cd ${name} && bun install, then read its output`;
45
+ throw new CliError("STEP_FAILED", `${step.label}: ${r.tail}`, fix, "game-design");
46
+ }
35
47
  }
36
- print(ctx, { dir, template, next: [`cd ${name}`, "dotframe sim --mash 7 --json", "dotframe dev"] }, (): string => `created ${dir} (${template})\nnext: cd ${name} && dotframe sim --mash 7 && dotframe dev`);
48
+ print(ctx, { dir, template, gitInit: !insideRepo, next: [`cd ${name}`, "dotframe sim --mash 7 --json", "dotframe dev"] }, (): string => `created ${dir} (${template})\nnext: cd ${name} && dotframe sim --mash 7 && dotframe dev`);
37
49
  }
38
50
 
39
51
  // Builds the web target, serves it, and rebuilds when source changes. For humans; agents use sim and snap.
@@ -47,7 +59,11 @@ export async function dev(ctx: Ctx, port: number): Promise<void> {
47
59
  port,
48
60
  fetch: (req: Request): Response => {
49
61
  const path = decodeURIComponent(new URL(req.url).pathname);
50
- return new Response(Bun.file(join(out, path === "/" ? "index.html" : path)));
62
+ const file = join(out, path === "/" ? "index.html" : path);
63
+ if (!existsSync(file)) return new Response("not found", { status: 404 });
64
+ // Same caching as production: the page is never cached, hashed bundles can be.
65
+ const headers = file.endsWith(".html") ? { "Cache-Control": "no-cache" } : undefined;
66
+ return new Response(Bun.file(file), { headers });
51
67
  },
52
68
  });
53
69
  console.error(`serving ${out} at http://localhost:${server.port}`);
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { Draw2D } from "../../src/draw2d";
4
4
  import type { Sim, SimRun } from "../../src/sim";
5
- import { CliError, type Ctx, loadConfig, print } from "../lib";
5
+ import { CliError, type Ctx, frames60, loadConfig, num, print } from "../lib";
6
6
  import { firstDifference, flatten, headlessRun, type InputSource, inputSource, lcg, loadSim, parseOptions } from "../simkit";
7
7
 
8
8
  export interface PlayArgs {
@@ -37,7 +37,7 @@ function play(run: SimRun, source: InputSource, frames: number, every: number):
37
37
  async function setup(args: PlayArgs): Promise<{ sim: Sim; run: SimRun; source: InputSource; seed: number; options: Record<string, unknown>; root: string }> {
38
38
  const config = loadConfig();
39
39
  const sim = await loadSim(config);
40
- const seed = Number(args.seed ?? "1");
40
+ const seed = num("seed", args.seed, 1);
41
41
  const options = parseOptions(sim, args.options);
42
42
  const source = inputSource(sim, config.root, args.inputs, args.mash);
43
43
  const run = await headlessRun(sim, config.root);
@@ -47,7 +47,7 @@ async function setup(args: PlayArgs): Promise<{ sim: Sim; run: SimRun; source: I
47
47
 
48
48
  export async function sim(ctx: Ctx, args: PlayArgs): Promise<void> {
49
49
  const { run, source, seed } = await setup(args);
50
- const r = play(run, source, Number(args.frames ?? "600"), Number(args.every ?? "0"));
50
+ const r = play(run, source, num("frames", args.frames, 600, 1), num("every", args.every, 0));
51
51
  print(ctx, { seed, inputs: source.describe, ...r }, (): string =>
52
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
53
  );
@@ -67,7 +67,7 @@ interface Replay {
67
67
  export async function record(ctx: Ctx, file: string, args: PlayArgs): Promise<void> {
68
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
69
  const { run, source, seed, options } = await setup(args);
70
- const frames = Number(args.frames ?? "1800");
70
+ const frames = num("frames", args.frames, 1800, 1);
71
71
  const r = play(run, source, frames, 60);
72
72
  const inputs: Replay["inputs"] = [];
73
73
  for (let f = 0; f < r.frames; f++) {
@@ -135,13 +135,13 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
135
135
  const config = loadConfig();
136
136
  const sim = await loadSim(config);
137
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");
138
+ const frames = num("frames", args.frames, 1800, 1);
139
+ const seed = num("seed", args.seed, 1);
140
+ const latency = frames60("latency", args.latency, 100);
141
+ const jitter = frames60("jitter", args.jitter, 0);
142
+ const delay = num("delay", args.delay, 2);
143
+ const renders = num("renders", args.renders, 3);
144
+ const every = num("every", args.every, 30, 1);
145
145
  const options = parseOptions(sim, args.options);
146
146
  const source = inputSource(sim, config.root, args.inputs, args.mash ?? (args.inputs ? undefined : "7"));
147
147
  // Input a player presses at frame f applies at frame f + delay, on both peers.
@@ -230,7 +230,13 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
230
230
  maxRollbackFrames: p.maxDepth,
231
231
  }));
232
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 =>
233
+ const deepest = Math.max(...report.map((r): number => r.maxRollbackFrames));
234
+ const warnings: string[] = [];
235
+ if (sim.rollbackWindow !== undefined && deepest > sim.rollbackWindow) {
236
+ warnings.push(`this link needed ${deepest}-frame rollbacks but the game's window is ${sim.rollbackWindow}: real play would stall`);
237
+ }
238
+ if (!sim.rollbackWindow) warnings.push("the sim declares no rollbackWindow, so rollback depth is not checked against the game");
239
+ print(ctx, { ok, warnings, frames, latencyFrames: latency, jitterFrames: jitter, delay, inputs: source.describe, peers: report }, (): string =>
234
240
  [
235
241
  `${frames} frames, latency ${latency}f, jitter ${jitter}f, delay ${delay}f (${source.describe})`,
236
242
  ...report.map((r): string => {
@@ -238,6 +244,7 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
238
244
  return `peer ${r.peer}${r.rendersBetweenSteps ? " (renders)" : ""}: ${sync}, ${r.rollbacks} rollbacks, max ${r.maxRollbackFrames}f`;
239
245
  }),
240
246
  reference.inspect ? "" : "note: the sim has no inspect(), so only the checksum is compared",
247
+ ...warnings.map((w: string): string => `warning: ${w}`),
241
248
  ].filter(Boolean).join("\n"),
242
249
  );
243
250
  if (!ok) process.exit(1);
@@ -4,6 +4,10 @@ import { CliError, type Command, type Ctx, exec, gate, loadConfig, print, runSte
4
4
 
5
5
  const skillFor = (t: string): string => (t === "web" ? "export-web" : t);
6
6
 
7
+ function countFiles(dir: string): number {
8
+ return readdirSync(dir, { withFileTypes: true }).reduce((n, e) => n + (e.isDirectory() ? countFiles(join(dir, e.name)) : 1), 0);
9
+ }
10
+
7
11
  // Paths marked local-only (unlicensed for distribution) that would ship with a release build.
8
12
  function licenseBlockers(root: string, paths: string[]): string[] {
9
13
  return paths.filter((p: string): boolean => {
@@ -41,7 +45,7 @@ export async function deploy(ctx: Ctx, name: string | undefined, prod: boolean):
41
45
  if (!existsSync(join(out, "index.html"))) throw new CliError("NOT_BUILT", `${t.out}/index.html is missing`, `dotframe build ${name}`, skill);
42
46
  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
47
  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(" ") };
48
+ const plan = { target: name, provider: t.deploy.provider, project: `${t.deploy.scope}/${t.deploy.project}`, dir: out, production: prod, files: countFiles(out), command: argv.join(" ") };
45
49
  if (ctx.dryRun) {
46
50
  print(ctx, { dryRun: true, ...plan }, (): string => `would deploy ${out} to ${plan.project}${prod ? " (production)" : " (preview)"}\n ${plan.command}`);
47
51
  return;
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
- import { CliError, type Ctx, exec, loadConfig, print, which } from "../lib";
4
+ import { CliError, type Ctx, exec, loadConfig, num, print, which } from "../lib";
4
5
  import { inputSource, loadSim, parseOptions, simPath } from "../simkit";
5
6
  import type { PlayArgs } from "./play";
6
7
 
@@ -11,16 +12,17 @@ const ENGINE = resolve(import.meta.dir, "../../src");
11
12
  export async function snap(ctx: Ctx, args: PlayArgs & { frame?: string; out?: string }): Promise<void> {
12
13
  const config = loadConfig();
13
14
  const sim = await loadSim(config);
14
- const frame = Number(args.frame ?? "0");
15
+ if (args.frame === undefined) throw new CliError("MISSING_ARG", "snap needs --frame <n>", "dotframe snap --frame 300 --mash 7");
16
+ const frame = num("frame", args.frame, 0);
15
17
  const out = resolve(process.cwd(), args.out ?? `snap-${frame}.png`);
16
- const seed = Number(args.seed ?? "1");
18
+ const seed = num("seed", args.seed, 1);
17
19
  const options = parseOptions(sim, args.options);
18
20
  const source = inputSource(sim, config.root, args.inputs, args.mash);
19
21
  const inputs = Array.from({ length: frame }, (_: unknown, f: number): number[] => source.at(f));
20
22
  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
23
 
22
- const work = join(config.root, ".dotframe", "snap");
23
- mkdirSync(work, { recursive: true });
24
+ // Outside the repo, so a snap never leaves files to commit.
25
+ const work = mkdtempSync(join(tmpdir(), "dotframe-snap-"));
24
26
  const entry = join(work, "entry.ts");
25
27
  writeFileSync(
26
28
  entry,
@@ -84,5 +86,6 @@ run(sim.window, (p) => {
84
86
  } finally {
85
87
  await ab("close");
86
88
  server.stop(true);
89
+ rmSync(work, { recursive: true, force: true });
87
90
  }
88
91
  }
package/cli/lib.ts CHANGED
@@ -146,3 +146,31 @@ export async function runSteps(ctx: Ctx, root: string, steps: Command[], skill:
146
146
  }
147
147
  return results;
148
148
  }
149
+
150
+ // Numeric flags: a bad value is an error, never a silent NaN that runs zero frames.
151
+ export function num(flag: string, raw: string | undefined, fallback: number, min = 0): number {
152
+ if (raw === undefined) return fallback;
153
+ const value = Number(raw);
154
+ if (raw.trim() === "" || !Number.isInteger(value) || value < min) {
155
+ throw new CliError("BAD_ARG", `--${flag} must be an integer >= ${min}, got "${raw}"`, `--${flag} ${Math.max(min, fallback)}`);
156
+ }
157
+ return value;
158
+ }
159
+
160
+ // Durations in milliseconds ("120ms" or "120"), converted to 60 Hz frames.
161
+ export function frames60(flag: string, raw: string | undefined, fallbackMs: number): number {
162
+ const text = raw ?? `${fallbackMs}ms`;
163
+ const match = text.trim().match(/^(\d+(?:\.\d+)?)(ms)?$/);
164
+ if (!match) throw new CliError("BAD_ARG", `--${flag} must be a duration like 120ms, got "${text}"`, `--${flag} ${fallbackMs}ms`);
165
+ return Math.round(Number(match[1]) / (1000 / 60));
166
+ }
167
+
168
+ // JSON with short objects and arrays kept on one line, so `config set` does not explode a hand-written file.
169
+ export function formatJson(value: unknown, indent = "", width = 100): string {
170
+ const inline = JSON.stringify(value, null, 1).replace(/\n\s*/g, " ").replace(/\[ /g, "[").replace(/ \]/g, "]").replace(/\{ /g, "{ ").replace(/ \}/g, " }");
171
+ if (value === null || typeof value !== "object" || indent.length + inline.length <= width) return inline;
172
+ const next = `${indent} `;
173
+ if (Array.isArray(value)) return `[\n${value.map((v) => next + formatJson(v, next, width)).join(",\n")}\n${indent}]`;
174
+ const entries = Object.entries(value as Record<string, unknown>);
175
+ return `{\n${entries.map(([k, v]) => `${next}${JSON.stringify(k)}: ${formatJson(v, next, width)}`).join(",\n")}\n${indent}}`;
176
+ }
package/cli/main.ts CHANGED
@@ -8,7 +8,7 @@ import { desync, record, sim, verify } from "./commands/play";
8
8
  import { snap } from "./commands/snap";
9
9
  import { skills } from "./commands/skills";
10
10
  import { create, dev } from "./commands/new";
11
- import { CliError, type Ctx, fail } from "./lib";
11
+ import { CliError, type Ctx, fail, num } from "./lib";
12
12
 
13
13
  const HELP = `dotframe ${pkg.version}: build, test, and export dotframe games
14
14
 
@@ -35,6 +35,77 @@ Project
35
35
 
36
36
  Global: --json --yes --dry-run --help --version`;
37
37
 
38
+ const INPUTS = `Inputs: --mash <seed> (random players) or --inputs f.jsonl (one [p0, p1] per frame, or {"frame": n, "inputs": [...]} held until the next line). --seed <n> seeds the sim, --options '<json>' overrides match options.`;
39
+
40
+ const COMMAND_HELP: Record<string, string> = {
41
+ sim: `dotframe sim [--mash <seed> | --inputs f.jsonl] [--frames 600] [--seed 1] [--options json] [--every n] [--json]
42
+
43
+ Runs the game headless and prints the final state and checksum. --every n adds a checksum trace.
44
+ ${INPUTS}
45
+
46
+ dotframe sim --mash 7 --frames 600 --json
47
+ dotframe sim --inputs combo.jsonl --options '{"stocks": 1}'`,
48
+ snap: `dotframe snap --frame <n> [--out snap-<n>.png] [--mash <seed> | --inputs f.jsonl] [--seed 1] [--options json] [--json]
49
+
50
+ Steps the sim to frame n in a real browser (WebGPU, through agent-browser) and screenshots it. Open the PNG and look.
51
+ ${INPUTS}
52
+
53
+ dotframe snap --frame 300 --mash 7 --out /tmp/f300.png`,
54
+ replay: `dotframe replay record <file> [--mash <seed> | --inputs f.jsonl] [--frames 1800] [--seed 1] [--options json]
55
+ dotframe replay verify <file...>
56
+
57
+ record stores seed, options, inputs, and a checksum every 60 frames. verify replays them and exits 1 with the
58
+ first divergent frame.
59
+
60
+ dotframe replay record replays/smoke.json --mash 7
61
+ dotframe replay verify replays/*.json`,
62
+ desync: `dotframe desync [--latency 100ms] [--jitter 0ms] [--delay 2] [--frames 1800] [--renders 3] [--every 30] [--mash <seed> | --inputs f]
63
+
64
+ Runs a reference sim and two rollback peers over a simulated link. Peer 1 renders --renders times per step.
65
+ Compares checksums every frame and the full inspect() state every --every frames; exits 1 on any difference.
66
+ Warns when rollbacks exceed the sim's rollbackWindow.
67
+
68
+ dotframe desync --latency 474ms --jitter 40ms --mash 42 --json`,
69
+ build: `dotframe build <target> [--release] [--dry-run] [--json]
70
+
71
+ Runs targets.<target>.steps from dotframe.json. --release refuses assets listed in assets.localOnly.
72
+
73
+ dotframe build web
74
+ dotframe build ios --release`,
75
+ deploy: `dotframe deploy <target> [--prod] [--dry-run] [--yes] [--json]
76
+
77
+ Deploys targets.<target>.out with its deploy provider. Without --yes it stops with APPROVAL_REQUIRED (exit 2).
78
+ Show the --dry-run plan to a human first.
79
+
80
+ dotframe deploy web --prod --dry-run`,
81
+ relay: `dotframe relay deploy [--region eze] [--dry-run] [--yes]
82
+
83
+ Redeploys the netplay relay (dokploy) or deploys it to a region (fly). Gated like deploy.`,
84
+ device: `dotframe device install <target> [--dry-run] [--yes]
85
+
86
+ Installs targets.<target>.app on targets.<target>.device with devicectl. Gated like deploy.`,
87
+ doctor: `dotframe doctor [--fix] [--json]
88
+
89
+ Checks tools, dotframe.json, the sim (loads, renders with a stub Draw2D), placeholders, and vendor links.
90
+ --fix creates missing vendor symlinks.`,
91
+ config: `dotframe config get [key]
92
+ dotframe config set <key> <json> [--dry-run]
93
+
94
+ Dotted keys. Values are JSON (strings need inner quotes). set rewrites dotframe.json with 2-space indent and short
95
+ objects and arrays on one line.
96
+
97
+ dotframe config set targets.web.deploy.scope '"my-team"'`,
98
+ new: `dotframe new <name> [--template fighter|platformer|blank] [--no-install] [--json]
99
+
100
+ Scaffolds a game. Inside an existing git repo it does not run git init.`,
101
+ dev: `dotframe dev [--port 5173]
102
+
103
+ Builds the web target, serves it (index.html with no-cache, as in production), and rebuilds on change.`,
104
+ skills: `dotframe skills list | get <name...> [--full] | get --all | path [name]
105
+
106
+ Guides bundled with this CLI version. Start with: dotframe skills get core`,
107
+ };
108
+
38
109
  const { values, positionals } = parseArgs({
39
110
  allowPositionals: true,
40
111
  strict: false,
@@ -74,6 +145,7 @@ const [command, ...rest] = positionals;
74
145
 
75
146
  try {
76
147
  if (values.version) console.log(pkg.version);
148
+ else if (command && values.help && COMMAND_HELP[command]) console.log(COMMAND_HELP[command]);
77
149
  else if (!command || values.help) console.log(HELP);
78
150
  else if (command === "sim") await sim(ctx, v);
79
151
  else if (command === "snap") await snap(ctx, v);
@@ -88,7 +160,7 @@ try {
88
160
  else if (command === "config") await configCmd(ctx, rest);
89
161
  else if (command === "skills") await skills(ctx, rest, values.full === true, values.all === true);
90
162
  else if (command === "new") await create(ctx, rest[0], v.template ?? "blank", values["no-install"] !== true);
91
- else if (command === "dev") await dev(ctx, Number(v.port ?? "5173"));
163
+ else if (command === "dev") await dev(ctx, num("port", v.port, 5173, 1));
92
164
  else throw new CliError("UNKNOWN_COMMAND", `unknown command: ${positionals.join(" ")}`, "dotframe --help");
93
165
  } catch (error) {
94
166
  fail(ctx, error);
package/cli/simkit.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { Sim, SimRun } from "../src/sim";
4
4
  import type { RenderGpu, Texture } from "../src/gpu";
5
- import { CliError, type Config } from "./lib";
5
+ import { CliError, type Config, num } from "./lib";
6
6
 
7
7
  let nextTexture = 1;
8
8
  // Texture ids restart per run so two runs of the same game have identical state graphs.
@@ -105,7 +105,7 @@ export function mash(sim: Sim, seed: number): InputSource {
105
105
 
106
106
  export function inputSource(sim: Sim, root: string, inputs: string | undefined, mashSeed: string | undefined): InputSource {
107
107
  if (inputs) return inputsFromFile(sim, resolve(process.cwd(), inputs));
108
- if (mashSeed !== undefined) return mash(sim, Number(mashSeed));
108
+ if (mashSeed !== undefined) return mash(sim, num("mash", mashSeed, 0));
109
109
  return { describe: "neutral", at: (): number[] => new Array(sim.players).fill(sim.neutral) };
110
110
  }
111
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotframe",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "TS-first game engine and agent-first CLI: one codebase for web, Discord, iOS and native binaries",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,3 +9,4 @@ description: Asset licensing and loading for dotframe games. Use when adding spr
9
9
  - Fonts render through SDF atlases baked by `tools/bake-font.c`. Keep the font license (for example SIL OFL) with the atlas.
10
10
  - Headless runs (`sim`, `replay`, `desync`) skip asset loading. If gameplay depends on asset data (sprite boxes, frame counts), load that data in headless too, or the sim will differ from the real game.
11
11
  - `snap` loads assets from the game root over HTTP, so asset paths must be relative to the repo root.
12
+ - The engine loads PNG, not SVG. Rasterize SVGs at build time (for example with sharp in a build step) at the largest size the game draws them, and commit or generate the PNGs.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: core
3
- description: Core dotframe usage. Read before running any dotframe command: the edit, sim, snap, replay loop, the Sim contract, the trust ladder, and how to read errors.
3
+ description: Core dotframe usage. Read before running any dotframe command. Covers the edit, sim, snap, replay loop, the Sim contract, the trust ladder, and how to read errors.
4
4
  ---
5
5
  # dotframe core
6
6
 
@@ -31,6 +31,10 @@ Never claim a visual change works from the JSON alone. Snap it and look.
31
31
  - All simulation state changes only inside `step`. `render` reads, never writes (no spawning particles or texts from draw code).
32
32
  - Randomness comes from a seeded generator that `save`/`restore` capture.
33
33
  - `inspect` returns the whole state graph so `desync` can name the exact field that differs.
34
+ - `render` must draw with whatever Draw2D it receives, including a stub that renders nothing. `desync` calls `render(stubDraw)` to prove rendering leaves state alone; a render that skips itself when there is no real renderer makes that check pass without checking anything. `dotframe doctor` fails `sim:render` when render makes no draw calls.
35
+ - Declare `rollbackWindow` (the most frames your netplay rolls back) so `desync` warns when a link needs more.
36
+
37
+ Prove a check can fail before trusting its green: make render change one field on purpose, run `desync`, and confirm it fails on peer 1 and names that field. Then revert.
34
38
 
35
39
  ## Trust ladder
36
40
 
@@ -15,6 +15,7 @@ dotframe deploy web --prod --yes # only after the human approved the plan
15
15
 
16
16
  - Deploy only the built folder (`targets.web.out`). The CLI refuses a folder that looks like source (repo root, `.git`, `dotframe.json`): that mistake once put the wrong game in production.
17
17
  - Do not connect the repo to Vercel's Git integration for these games. A push deploys the repo root.
18
+ - Templates ship `scripts/build-web.ts`, which does both of the next points. Keep it when you change the build.
18
19
  - Content-hash the bundle (`main.<hash>.js`) and serve `index.html` with `Cache-Control: no-cache`, so browsers and Discord's proxy never run a stale build.
19
20
  - `deploy` without `--prod` makes a preview. Prefer it for checks.
20
21
  - Verify a deploy by opening the URL with agent-browser and taking a screenshot, not by the exit code.
@@ -16,7 +16,7 @@ It runs a reference simulation and two peers over a deterministic simulated link
16
16
 
17
17
  - `firstDivergentFrame`: the checksum differed. A rollback or determinism bug.
18
18
  - `firstStateDifference`: `{frame, path}` of the first field that differs at a checkpoint (`--every 30`). Catches state the checksum does not cover. Needs `inspect()` in the sim.
19
- - `rollbacks`, `maxRollbackFrames`: how hard the link worked. Keep max under the game's rollback window.
19
+ - `rollbacks`, `maxRollbackFrames`: how hard the link worked. With `rollbackWindow` in the sim, `warnings` says when a link needs deeper rollbacks than the game allows (real play would stall).
20
20
 
21
21
  Run several seeds and a high latency (474 ms is what a transatlantic relay measured). A pass on one seed proves little.
22
22
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dotframe
3
- description: Agent-first CLI for the dotframe game engine. Use when building, testing, or exporting a dotframe game: creating a game from a template, simulating or replaying frames headless, rendering a frame to check what a player sees, debugging rollback netplay desyncs, or building and deploying for web, Discord Activities, iOS, and native macOS or Windows. Triggers include "make a game", "dotframe", "simulate the match", "snap a frame", "replay", "desync", "netplay", "build for iOS", "deploy the game", or any repo with a dotframe.json.
3
+ description: Agent-first CLI for the dotframe game engine. Use when building, testing, or exporting a dotframe game, such as creating a game from a template, simulating or replaying frames headless, rendering a frame to check what a player sees, debugging rollback netplay desyncs, or building and deploying for web, Discord Activities, iOS, and native macOS or Windows. Triggers include "make a game", "dotframe", "simulate the match", "snap a frame", "replay", "desync", "netplay", "build for iOS", "deploy the game", or any repo with a dotframe.json.
4
4
  allowed-tools: Bash(dotframe:*), Bash(npx dotframe:*), Bash(bunx dotframe:*)
5
5
  hidden: true
6
6
  ---
package/src/sim.ts CHANGED
@@ -33,7 +33,9 @@ export interface SimRun {
33
33
  // Optional: the whole simulation state as a plain object graph. desync walks it at checkpoints and reports the
34
34
  // first differing path, which catches state the checksum does not cover.
35
35
  inspect?: () => unknown;
36
- // Optional: draws the current frame; must not change simulation state.
36
+ // Optional: draws the current frame; must not change simulation state. It must also draw with a stub Draw2D
37
+ // that renders nothing: desync calls it that way to prove rendering leaves the simulation alone, so a render
38
+ // that skips itself without a real draw makes that check blind.
37
39
  render?: (draw: Draw2D) => void;
38
40
  }
39
41
 
@@ -49,6 +51,8 @@ export interface Sim {
49
51
  // A random input, for mashing scripts. next() returns [0, 1).
50
52
  random: (next: () => number) => number;
51
53
  create: (platform: SimPlatform) => SimRun;
54
+ // Optional: the most frames the game's netplay can roll back. desync warns when a link needs more.
55
+ rollbackWindow?: number;
52
56
  }
53
57
 
54
58
  export function defineSim(sim: Sim): Sim {
package/src/web/run.ts CHANGED
@@ -12,6 +12,8 @@ import type { Audio } from "../audio";
12
12
  import { type Input, keyCodes, type Pointer, type Touch } from "../input";
13
13
  import type { Storage } from "../storage";
14
14
 
15
+ // Engine buffers are always plain ArrayBuffer-backed; the casts below satisfy TS 5.9+ WebGPU typings, which reject
16
+ // Uint8Array<ArrayBufferLike> (it could be a SharedArrayBuffer).
15
17
  const vertexFormats: GPUVertexFormat[] = ["float32x2", "float32x3", "float32x4", "float32", "unorm8x4"];
16
18
 
17
19
  export async function loadBytes(path: string): Promise<Uint8Array> {
@@ -78,13 +80,13 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
78
80
  if (usage & BufferUsage.Index) flags |= GPUBufferUsage.INDEX;
79
81
  if (usage & BufferUsage.Uniform) flags |= GPUBufferUsage.UNIFORM;
80
82
  const buffer = device.createBuffer({ size: Math.ceil(data.byteLength / 4) * 4, usage: flags });
81
- device.queue.writeBuffer(buffer, 0, data);
83
+ device.queue.writeBuffer(buffer, 0, data as Uint8Array<ArrayBuffer>);
82
84
  buffers.push(buffer);
83
85
  return buffers.length - 1;
84
86
  },
85
87
  writeBuffer: (buffer: number, data: Uint8Array): void => {
86
88
  const target = buffers[buffer];
87
- if (target) device.queue.writeBuffer(target, 0, data);
89
+ if (target) device.queue.writeBuffer(target, 0, data as Uint8Array<ArrayBuffer>);
88
90
  },
89
91
  destroyBuffer: (buffer: number): void => {
90
92
  buffers[buffer]?.destroy();
@@ -144,7 +146,7 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
144
146
  },
145
147
  createTexture: (width: number, height: number, rgba: Uint8Array, smooth: boolean): Texture =>
146
148
  uploadTexture(width, height, smooth, (texture) =>
147
- device.queue.writeTexture({ texture }, rgba, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]),
149
+ device.queue.writeTexture({ texture }, rgba as Uint8Array<ArrayBuffer>, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]),
148
150
  ),
149
151
  createImage: async (png: Uint8Array, smooth: boolean): Promise<Texture> => {
150
152
  const bitmap = await createImageBitmap(new Blob([new Uint8Array(png)], { type: "image/png" }), { premultiplyAlpha: "none" });
@@ -189,12 +191,13 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
189
191
  };
190
192
 
191
193
  const pressed = new Set<string>();
192
- globalThis.addEventListener("keydown", (event) => {
194
+ // window, not globalThis: with Bun's types loaded, globalThis listeners receive a plain Event.
195
+ window.addEventListener("keydown", (event: KeyboardEvent) => {
193
196
  if (keyCodes.includes(event.code)) event.preventDefault();
194
197
  pressed.add(event.code);
195
198
  });
196
- globalThis.addEventListener("keyup", (event) => pressed.delete(event.code));
197
- globalThis.addEventListener("blur", () => pressed.clear());
199
+ window.addEventListener("keyup", (event: KeyboardEvent) => pressed.delete(event.code));
200
+ window.addEventListener("blur", () => pressed.clear());
198
201
  const pointer: Pointer = { x: 0, y: 0, buttons: 0 };
199
202
  const trackPointer = (event: PointerEvent): void => {
200
203
  const rect = canvas.getBoundingClientRect();
@@ -3,10 +3,7 @@
3
3
  "sim": "sim.ts",
4
4
  "targets": {
5
5
  "web": {
6
- "steps": [
7
- { "label": "bundle", "argv": ["bun", "build", "main.web.ts", "--outfile", "dist/web/main.js", "--target", "browser", "--minify"] },
8
- { "label": "page", "argv": ["cp", "index.html", "dist/web/index.html"] }
9
- ],
6
+ "steps": [{ "label": "bundle (hashed)", "argv": ["bun", "scripts/build-web.ts"] }],
10
7
  "out": "dist/web",
11
8
  "deploy": { "provider": "vercel", "project": "__NAME__", "scope": "__SCOPE__" }
12
9
  }
@@ -4,9 +4,15 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "dotframe dev",
7
- "test": "dotframe replay verify replays/*.json"
7
+ "test": "dotframe replay verify replays/*.json",
8
+ "typecheck": "tsc"
8
9
  },
9
10
  "dependencies": {
10
11
  "dotframe": "^0.1.0"
12
+ },
13
+ "devDependencies": {
14
+ "@types/bun": "^1.3.0",
15
+ "@webgpu/types": "^0.1.74",
16
+ "typescript": "^5.9.0"
11
17
  }
12
18
  }
@@ -0,0 +1,21 @@
1
+ // Builds the web target into dist/web with a content-hashed bundle (main.<hash>.js) and a never-cached
2
+ // index.html, so browsers and Discord's proxy never run a stale build.
3
+ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+
5
+ const out = "dist/web";
6
+ rmSync(out, { recursive: true, force: true });
7
+ mkdirSync(out, { recursive: true });
8
+ const built = await Bun.build({ entrypoints: ["main.web.ts"], target: "browser", minify: true, naming: "main.[hash].[ext]", outdir: out });
9
+ if (!built.success) {
10
+ for (const log of built.logs) console.error(log);
11
+ process.exit(1);
12
+ }
13
+ const bundle = built.outputs[0].path.split("/").pop() ?? "main.js";
14
+ const html = await Bun.file("index.html").text();
15
+ writeFileSync(`${out}/index.html`, html.replace("./main.js", `./${bundle}`));
16
+ writeFileSync(
17
+ `${out}/vercel.json`,
18
+ `${JSON.stringify({ headers: [{ source: "/index.html", headers: [{ key: "Cache-Control", value: "no-cache" }] }, { source: "/", headers: [{ key: "Cache-Control", value: "no-cache" }] }] }, null, 2)}\n`,
19
+ );
20
+ if (existsSync("assets")) cpSync("assets", `${out}/assets`, { recursive: true });
21
+ console.log(`${out}/${bundle}`);
@@ -7,6 +7,8 @@ export default defineSim({
7
7
  window: WINDOW,
8
8
  options: {},
9
9
  neutral: 0,
10
+ // Frames your netplay may roll back; desync warns when a link needs more. Match it when you add online play.
11
+ rollbackWindow: 20,
10
12
  encode,
11
13
  random: randomInput,
12
14
  create: (_platform: SimPlatform): SimRun => {
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "types": ["@webgpu/types", "bun"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["*.ts", "src", "scripts"]
13
+ }