dotframe 0.1.6 → 0.1.8

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8
4
+
5
+ From dogfood round 7 (Craft Ones on 0.1.7).
6
+
7
+ - `doctor` sim:math skips files marked `// dotframe-allow-math-file`, for render-only files the sim reaches because it builds the renderer.
8
+ - `deploy init` pins images: bun from `packageManager` (game or repo root, else the local bun) and the relay's dotframe from the version installed in the game.
9
+ - The vendor fix (doctor and native builds) names `DOTFRAME_VENDOR` for reusing an existing vendor dir.
10
+
11
+ ## 0.1.7
12
+
13
+ From dogfood rounds 5 and 6 (Craft Ones on iOS, online, and on the VPS).
14
+
15
+ - Engine: `src/detmath` (sin, cos, tan, atan, atan2, exp, log, pow, hypot from + - * / and sqrt with fdlibm kernels; the same bits on macOS, Linux arm64 and Linux x64) and `src/checksum` (fixed-order number hash).
16
+ - Engine: `src/netplay`, a game-agnostic rollback engine over the Sim contract's shape, and `src/relay-client` (web) for the relay.
17
+ - `relay serve` runs the netplay relay locally; `play --online` drives a scripted match in two browsers through it and compares checksums.
18
+ - `deploy init <target> --provider dokploy` writes the Docker files (web, relay, nginx, compose); `deploy` redeploys through vps and returns the build log on failure. `requires` in dotframe.json lists build tools; `doctor --docker` builds the image locally.
19
+ - Native iOS target: `{"native": {"platform": "ios", ...}}` generates the host, plist, project, glue and icon and runs xcodegen and xcodebuild. `vendor ios`; doctor checks the scriptc iOS runtime pack.
20
+ - `doctor` warns about platform-dependent math in code the sim reaches (`sim:math`).
21
+ - `desync` reports restore cost and warns when it grows with the frame.
22
+ - Native: touches no longer synthesize a mouse (phantom pointer on iOS).
23
+ - Templates: online play through the relay (`?room=`), `main.ios.ts`, an aspect-filling canvas, and snapshot and restore without structuredClone or Object.assign.
24
+
3
25
  ## 0.1.6
4
26
 
5
27
  From the Craft Ones native port.
@@ -0,0 +1,204 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import pkg from "../../package.json" with { type: "json" };
4
+ import { CliError, CONFIG_FILE, type Config, type Ctx, exec, formatJson, gate, loadConfig, print, target, which } from "../lib";
5
+
6
+ // Web plus relay as a docker compose stack (Dokploy builds it from the git repo): the game at / and the relay at
7
+ // /relay on the same host. Generated once by `deploy init`, then committed and edited like any other source.
8
+
9
+ const DIR = "deploy";
10
+
11
+ async function gitRoot(ctx: Ctx, root: string): Promise<string> {
12
+ const r = await exec({ ...ctx, json: true }, root, { label: "git root", argv: ["git", "rev-parse", "--show-toplevel"] });
13
+ if (r.code !== 0) throw new CliError("NOT_A_REPO", "Dokploy builds from a git repository, and this game is not in one", "git init, commit, and push to GitHub first", "export-web");
14
+ return r.output.trim();
15
+ }
16
+
17
+ interface Versions {
18
+ bun: string;
19
+ dotframe: string;
20
+ }
21
+
22
+ // Pinned versions, so an image built tomorrow is the image built today: bun from packageManager (the game's
23
+ // package.json, then the repo root's), else the bun running this CLI; dotframe as installed in the game.
24
+ function versions(gameRoot: string, repo: string): Versions {
25
+ let bun = Bun.version;
26
+ for (const dir of [gameRoot, repo]) {
27
+ const file = join(dir, "package.json");
28
+ if (!existsSync(file)) continue;
29
+ const manager = (JSON.parse(readFileSync(file, "utf8")) as { packageManager?: string }).packageManager ?? "";
30
+ const match = manager.match(/^bun@(\d+\.\d+\.\d+)/);
31
+ if (match) {
32
+ bun = match[1];
33
+ break;
34
+ }
35
+ }
36
+ const installed = join(gameRoot, "node_modules", "dotframe", "package.json");
37
+ const dotframe = existsSync(installed) ? (JSON.parse(readFileSync(installed, "utf8")) as { version: string }).version : pkg.version;
38
+ return { bun, dotframe };
39
+ }
40
+
41
+ // game: the game's path inside the repo ("" at the root); context: the repo root, relative to deploy/.
42
+ function files(config: Config, game: string, out: string, context: string, v: Versions): Record<string, string> {
43
+ const requires = (config.requires ?? []).filter((t: string): boolean => t !== "bun");
44
+ const apt = requires.length > 0 ? `RUN apt-get update && apt-get install -y --no-install-recommends ${requires.join(" ")} && rm -rf /var/lib/apt/lists/*\n` : "";
45
+ const at = game === "" ? "." : game;
46
+ return {
47
+ "Dockerfile.web": `# Static web build of ${config.name}, served by nginx. Generated by dotframe deploy init; edit freely.
48
+ FROM oven/bun:${v.bun} AS build
49
+ ${apt}WORKDIR /app
50
+ COPY . .
51
+ RUN cd ${at} && bun install --frozen-lockfile && bun node_modules/dotframe/cli/main.ts build web
52
+
53
+ FROM nginx:1.27-alpine
54
+ COPY ${join(at, DIR, "nginx.conf")} /etc/nginx/conf.d/default.conf
55
+ COPY --from=build ${join("/app", at, out)} /usr/share/nginx/html
56
+ EXPOSE 80
57
+ `,
58
+ "Dockerfile.relay": `# Netplay relay for ${config.name}: dotframe relay serve. Generated by dotframe deploy init.
59
+ FROM oven/bun:${v.bun}-alpine
60
+ WORKDIR /app
61
+ # The dotframe version the game has installed; bump it together with the game's.
62
+ RUN bun add dotframe@${v.dotframe}
63
+ ENV PORT=8787
64
+ EXPOSE 8787
65
+ CMD ["bun", "node_modules/dotframe/cli/main.ts", "relay", "serve"]
66
+ `,
67
+ "nginx.conf": `server {
68
+ listen 80;
69
+ server_name _;
70
+ root /usr/share/nginx/html;
71
+ index index.html;
72
+ gzip on;
73
+ gzip_types text/css application/javascript application/json image/svg+xml;
74
+ gzip_min_length 1024;
75
+ # The bundle name is content-hashed; index.html must never be cached (Discord caches aggressively).
76
+ location = /index.html { add_header Cache-Control "no-cache"; }
77
+ location / { try_files $uri $uri/ /index.html; }
78
+ location ~* \\.(png|mp3|js|json)$ { expires 7d; add_header Cache-Control "public"; }
79
+ }
80
+ `,
81
+ "compose.yaml": `# ${config.name}: the game at / and the netplay relay at /relay on the same host. In Dokploy, point the domain's
82
+ # / at service web (port 80) and /relay at service relay (port 8787).
83
+ services:
84
+ web:
85
+ build:
86
+ context: ${context}
87
+ dockerfile: ${join(at, DIR, "Dockerfile.web")}
88
+ restart: unless-stopped
89
+ expose:
90
+ - "80"
91
+ relay:
92
+ build:
93
+ context: .
94
+ dockerfile: Dockerfile.relay
95
+ restart: unless-stopped
96
+ environment:
97
+ PORT: "8787"
98
+ expose:
99
+ - "8787"
100
+ `,
101
+ ".dockerignore": "**/node_modules\n**/dist\n**/.dotframe\n.git\n",
102
+ };
103
+ }
104
+
105
+ export async function deployInit(ctx: Ctx, name: string | undefined, provider: string | undefined, compose: string | undefined): Promise<void> {
106
+ if (!name) throw new CliError("MISSING_ARG", "deploy init needs a target", "dotframe deploy init web --provider dokploy", "export-web");
107
+ if (provider !== "dokploy") throw new CliError("UNSUPPORTED", `deploy init supports --provider dokploy (got ${provider ?? "none"}); vercel needs no files`, "dotframe deploy init web --provider dokploy", "export-web");
108
+ const config = loadConfig();
109
+ const t = target(config, name);
110
+ if (!t.out) throw new CliError("NO_OUT", `target "${name}" needs an out dir`, "", "export-web");
111
+ const repo = await gitRoot(ctx, config.root);
112
+ const game = relative(repo, config.root);
113
+ // compose.yaml sits in <game>/deploy; the web build context is the repo root, so workspace code is visible.
114
+ const generated = files(config, game, t.out, relative(join(config.root, DIR), repo) || ".", versions(config.root, repo));
115
+ const dir = join(config.root, DIR);
116
+ const written = Object.keys(generated).map((f: string): string => join(DIR, f));
117
+ const existing = written.filter((f: string): boolean => existsSync(join(config.root, f)));
118
+ if (ctx.dryRun) {
119
+ print(ctx, { dryRun: true, files: written, existing }, (): string => `would write ${written.join(", ")}${existing.length ? ` (overwrites ${existing.join(", ")})` : ""}`);
120
+ return;
121
+ }
122
+ if (existing.length > 0 && !ctx.yes) throw new CliError("EXISTS", `${existing.join(", ")} already exist`, "rerun with --yes to overwrite, or edit them by hand", "export-web");
123
+ mkdirSync(dir, { recursive: true });
124
+ for (const [f, text] of Object.entries(generated)) writeFileSync(join(dir, f), text);
125
+ // The repo-root .dockerignore is the one docker reads for a repo-root context.
126
+ if (!existsSync(join(repo, ".dockerignore"))) writeFileSync(join(repo, ".dockerignore"), generated[".dockerignore"]);
127
+ const raw = JSON.parse(readFileSync(join(config.root, CONFIG_FILE), "utf8")) as { targets: Record<string, { deploy?: unknown }> };
128
+ raw.targets[name].deploy = { provider: "dokploy", compose: compose ?? "", dir: DIR };
129
+ writeFileSync(join(config.root, CONFIG_FILE), `${formatJson(raw)}\n`);
130
+ print(ctx, { files: written, next: ["git add deploy dotframe.json && git commit && git push", "create the compose in Dokploy from this repo (compose path below)", `dotframe config set targets.${name}.deploy.compose '"<compose id>"'`] }, (): string =>
131
+ [`wrote ${written.join(", ")}`, `compose path for Dokploy: ${join(game, DIR, "compose.yaml")}`, `then: dotframe config set targets.${name}.deploy.compose '"<compose id>"' and dotframe deploy ${name}`].join("\n"),
132
+ );
133
+ }
134
+
135
+ interface Deployment {
136
+ id: string;
137
+ status: string;
138
+ created: string;
139
+ }
140
+
141
+ async function vpsJson<T>(ctx: Ctx, root: string, args: string[]): Promise<T> {
142
+ const r = await exec({ ...ctx, json: true }, root, { label: `vps ${args.join(" ")}`, argv: ["vps", ...args, "--json"] });
143
+ if (r.code !== 0) throw new CliError("DEPLOY_FAILED", r.tail, "vps doctor; check the profile and API key", "export-web");
144
+ return JSON.parse(r.output) as T;
145
+ }
146
+
147
+ export async function deployDokploy(ctx: Ctx, config: Config, name: string, compose: string): Promise<void> {
148
+ const skill = "export-web";
149
+ if (!compose) throw new CliError("CONFIG_PLACEHOLDER", `targets.${name}.deploy.compose is empty`, `dotframe config set targets.${name}.deploy.compose '"<compose id>"' (vps compose list)`, skill);
150
+ for (const f of ["compose.yaml", "Dockerfile.web", "nginx.conf"]) {
151
+ if (!existsSync(join(config.root, DIR, f))) throw new CliError("NOT_BUILT", `${DIR}/${f} is missing`, `dotframe deploy init ${name} --provider dokploy`, skill);
152
+ }
153
+ // Dokploy builds what is on the remote branch, not this working tree.
154
+ const status = await exec({ ...ctx, json: true }, config.root, { label: "git status", argv: ["git", "status", "--porcelain", "--branch"] });
155
+ const unpushed = /\[ahead \d+/.test(status.output.split("\n")[0] ?? "");
156
+ const dirty = status.output.split("\n").slice(1).some((l: string): boolean => l.trim() !== "");
157
+ const plan = { target: name, provider: "dokploy", compose, command: `vps -y compose redeploy ${compose}`, unpushedCommits: unpushed, uncommittedChanges: dirty };
158
+ if (ctx.dryRun) {
159
+ print(ctx, { dryRun: true, ...plan }, (): string =>
160
+ [`would redeploy compose ${compose} from the remote branch`, unpushed ? "warning: local commits are not pushed; Dokploy will not see them" : "", dirty ? "warning: uncommitted changes will not deploy" : ""].filter(Boolean).join("\n"),
161
+ );
162
+ return;
163
+ }
164
+ gate(ctx, `deploy ${name} to Dokploy`, skill);
165
+ if (!which("vps")) throw new CliError("TOOL_MISSING", "vps CLI not found", "install crafter-station/vps-cli and log in", skill);
166
+ const before = await vpsJson<Deployment[]>(ctx, config.root, ["compose", "deployments", compose]);
167
+ const known = new Set(before.map((d: Deployment): string => d.id));
168
+ const started = await exec({ ...ctx, json: true }, config.root, { label: "vps redeploy", argv: ["vps", "-y", "compose", "redeploy", compose, "--json"] });
169
+ if (started.code !== 0) throw new CliError("DEPLOY_FAILED", started.tail, "", skill);
170
+ if (!ctx.json) console.error("> waiting for the Dokploy deployment");
171
+ // Wait for the new deployment to reach a terminal state; a build failure carries its log.
172
+ for (;;) {
173
+ await Bun.sleep(5000);
174
+ const now = await vpsJson<Deployment[]>(ctx, config.root, ["compose", "deployments", compose]);
175
+ const fresh = now.find((d: Deployment): boolean => !known.has(d.id));
176
+ if (!fresh || fresh.status === "running" || fresh.status === "queued") continue;
177
+ if (fresh.status === "done") {
178
+ print(ctx, { ...plan, deployment: fresh.id, status: fresh.status }, (): string => `deployed: compose ${compose}, deployment ${fresh.id}`);
179
+ return;
180
+ }
181
+ const logs = await vpsJson<{ log: string }>(ctx, config.root, ["compose", "logs", compose, "--deployment", fresh.id, "--tail", "60"]);
182
+ const logDir = join(config.root, ".dotframe", "logs");
183
+ mkdirSync(logDir, { recursive: true });
184
+ const log = join(logDir, `deploy-${name}.log`);
185
+ writeFileSync(log, logs.log);
186
+ throw new CliError("DEPLOY_FAILED", `Dokploy deployment ${fresh.id} ended ${fresh.status}:\n${logs.log.split("\n").slice(-15).join("\n")}`, `read ${log}; reproduce with dotframe doctor --docker`, skill, 1, log);
187
+ }
188
+ }
189
+
190
+ // Builds the web image locally: what Dokploy would run, minus the network round trip.
191
+ export async function dockerCheck(ctx: Ctx, config: Config): Promise<{ check: string; ok: boolean; detail: string; fix: string; skill: string }> {
192
+ const base = { check: "docker:web", skill: "export-web" };
193
+ const dockerfile = join(config.root, DIR, "Dockerfile.web");
194
+ if (!existsSync(dockerfile)) return { ...base, ok: false, detail: `${DIR}/Dockerfile.web is missing`, fix: "dotframe deploy init web --provider dokploy" };
195
+ if (!which("docker")) return { ...base, ok: false, detail: "docker not found", fix: "install Docker Desktop or OrbStack" };
196
+ const repo = await gitRoot(ctx, config.root);
197
+ const r = await exec({ ...ctx, json: true }, repo, { label: "docker build", argv: ["docker", "build", "-f", dockerfile, "-t", `${config.name}-web:check`, "."] });
198
+ if (r.code === 0) return { ...base, ok: true, detail: `${DIR}/Dockerfile.web builds`, fix: "" };
199
+ const logDir = join(config.root, ".dotframe", "logs");
200
+ mkdirSync(logDir, { recursive: true });
201
+ writeFileSync(join(logDir, "docker-web.log"), r.output);
202
+ const cause = r.output.split("\n").filter((l: string): boolean => /error|not found/i.test(l)).slice(-3).join(" | ");
203
+ return { ...base, ok: false, detail: `docker build failed: ${cause || "see log"}`, fix: `read ${join(".dotframe", "logs", "docker-web.log")}; missing binaries go in "requires" in dotframe.json` };
204
+ }
@@ -1,14 +1,18 @@
1
- import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { type Config, type Ctx, findRoot, home, loadConfig, print, which } from "../lib";
4
4
  import type { Draw2D } from "../../src/draw2d";
5
5
  import type { Sim } from "../../src/sim";
6
- import { vendorFix, vendorStatus } from "../native";
6
+ import { importGraph, vendorFix, vendorStatus } from "../native";
7
+ import { iosPackFix, iosRuntimePack } from "../ios";
8
+ import { dockerCheck } from "./docker";
7
9
  import { firstDifference, flatten, headlessRun, loadSim } from "../simkit";
8
10
 
9
11
  interface Check {
10
12
  check: string;
11
13
  ok: boolean;
14
+ // A finding worth reading that does not fail doctor.
15
+ warn?: boolean;
12
16
  detail: string;
13
17
  fix: string;
14
18
  skill: string;
@@ -19,7 +23,7 @@ const tool = (bin: string, why: string, fix: string, skill: string): Check => {
19
23
  return { check: `tool:${bin}`, ok: path !== null, detail: path ?? `missing (${why})`, fix: path ? "" : fix, skill };
20
24
  };
21
25
 
22
- export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
26
+ export async function doctor(ctx: Ctx, fix: boolean, docker = false): Promise<void> {
23
27
  const checks: Check[] = [tool("bun", "runs the CLI and the web build", "curl -fsSL https://bun.sh/install | bash", "core")];
24
28
  let config: Config | null = null;
25
29
  if (!findRoot()) checks.push({ check: "config", ok: false, detail: "no dotframe.json here or above", fix: "cd into a game repo or dotframe new <name>", skill: "core" });
@@ -37,12 +41,16 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
37
41
  const sim = await loadSim(config);
38
42
  checks.push({ check: "sim", ok: true, detail: `${config.sim} (${sim.players} players)`, fix: "", skill: "core" });
39
43
  checks.push(await renderCheck(sim, config.root));
44
+ checks.push(mathCheck(resolve(config.root, config.sim as string)));
40
45
  } catch (error) {
41
46
  checks.push({ check: "sim", ok: false, detail: error instanceof Error ? error.message : "failed to load", fix: "dotframe skills get core (Sim contract)", skill: "core" });
42
47
  }
43
48
  }
44
49
  for (const [name, t] of Object.entries(config.targets)) {
45
- if (t.deploy?.scope === "your-vercel-team") {
50
+ if (t.native?.platform === "ios" && t.native.team === "YOUR_TEAM_ID") {
51
+ checks.push({ check: `ios:${name}`, ok: false, detail: "the iOS team is still the template placeholder", fix: `dotframe config set targets.${name}.native.team '"<Apple team id>"'`, skill: "ios" });
52
+ }
53
+ if (t.deploy?.provider === "vercel" && t.deploy.scope === "your-vercel-team") {
46
54
  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" });
47
55
  }
48
56
  }
@@ -52,6 +60,16 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
52
60
  checks.push({ check: `vendor:${name}`, ok: v.missing.length === 0, detail: v.missing.length === 0 ? `${v.dir} (${v.sdl})` : `missing in ${v.dir}: ${v.missing.join(", ")}`, fix: v.missing.length === 0 ? "" : vendorFix(t.native.platform), skill: t.native.platform });
53
61
  checks.push(tool("scriptc", "native builds", "npm i -g scriptc", t.native.platform));
54
62
  if (t.native.platform === "windows") checks.push(tool("zig", "windows cross builds", "brew install zig", "macos"));
63
+ if (t.native.platform === "ios") {
64
+ checks.push(tool("xcodegen", "iOS project", "brew install xcodegen", "ios"));
65
+ const pack = await iosRuntimePack(ctx, config.root);
66
+ checks.push({ check: `ios-runtime:${name}`, ok: pack.path !== null, detail: pack.path ? `@scriptc/runtime-ios-arm64 ${pack.want}` : pack.found ? `runtime pack ${pack.found} does not match scriptc ${pack.want}` : `@scriptc/runtime-ios-arm64 ${pack.want} not installed`, fix: pack.path ? "" : iosPackFix(pack.want), skill: "ios" });
67
+ }
68
+ }
69
+ for (const bin of config.requires ?? []) checks.push(tool(bin, "listed in requires", `brew install ${bin}`, "export-web"));
70
+ if (docker) {
71
+ const dockerized = Object.values(config.targets).some((t) => t.deploy?.provider === "dokploy");
72
+ checks.push(dockerized ? await dockerCheck(ctx, config) : { check: "docker:web", ok: false, detail: "no target deploys with provider dokploy", fix: "dotframe deploy init web --provider dokploy", skill: "export-web" });
55
73
  }
56
74
  const targets = Object.keys(config.targets);
57
75
  if (targets.some((t: string): boolean => t === "web" || t === "discord")) {
@@ -79,9 +97,9 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
79
97
  checks.push({ check: `link:${link.path}`, ok, detail, fix: ok ? "" : existsSync(want) ? "dotframe doctor --fix" : `${want} does not exist: run dotframe's scripts/vendor.sh first`, skill: "ios" });
80
98
  }
81
99
  }
82
- const failed = checks.filter((c: Check): boolean => !c.ok);
100
+ const failed = checks.filter((c: Check): boolean => !c.ok && !c.warn);
83
101
  print(ctx, { ok: failed.length === 0, checks }, (): string =>
84
- checks.map((c: Check): string => `${c.ok ? "ok " : "FAIL"} ${c.check}: ${c.detail}${c.fix ? `\n fix: ${c.fix}` : ""}`).join("\n"),
102
+ checks.map((c: Check): string => `${c.ok ? "ok " : c.warn ? "WARN" : "FAIL"} ${c.check}: ${c.detail}${c.fix ? `\n fix: ${c.fix}` : ""}`).join("\n"),
85
103
  );
86
104
  if (failed.length > 0) process.exit(1);
87
105
  }
@@ -127,3 +145,51 @@ async function renderCheck(sim: Sim, root: string): Promise<Check> {
127
145
  if (impure) return { ...base, ok: false, detail: `render() changed simulation state at ${impure}`, fix: "move that write into step(); render must only read", skill: "netplay" };
128
146
  return { ...base, ok: true, detail: `render() drew ${calls} calls with a stub Draw2D and left state unchanged${run.inspect ? "" : " (checksum only; add inspect() for every field)"}`, fix: "" };
129
147
  }
148
+
149
+ // Math.sin and friends differ in the last bits between engines and OSes, so simulation code that calls them drifts
150
+ // between netplay peers and breaks replays on another machine. Render code may use them: mark a line with
151
+ // `dotframe-allow-math`, or a whole render-only file with `// dotframe-allow-math-file`. (The sim module builds the
152
+ // renderer for desync's purity check, so render files are reachable and a static scan cannot tell them apart.)
153
+ const NONDETERMINISTIC = /\bMath\.(sin|cos|tan|asin|acos|atan|atan2|sinh|cosh|tanh|asinh|acosh|atanh|exp|expm1|log|log1p|log2|log10|pow|cbrt|hypot)\b|[\w)\]]\s*\*\*\s*[\w(]/;
154
+
155
+ // The engine's own files (draw2d's arcs, raster2d) are render code, however the game imports them: a vendored
156
+ // submodule, node_modules, or a relative path.
157
+ function engineFile(file: string, cache: Map<string, boolean>): boolean {
158
+ for (let dir = dirname(file); dirname(dir) !== dir; dir = dirname(dir)) {
159
+ const known = cache.get(dir);
160
+ if (known !== undefined) return known;
161
+ const pkg = join(dir, "package.json");
162
+ if (existsSync(pkg)) {
163
+ const isEngine = (JSON.parse(readFileSync(pkg, "utf8")) as { name?: string }).name === "dotframe";
164
+ cache.set(dir, isEngine);
165
+ return isEngine;
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+
171
+ function mathCheck(simFile: string): Check {
172
+ const base = { check: "sim:math", skill: "netplay" };
173
+ const hits: string[] = [];
174
+ const engine = new Map<string, boolean>();
175
+ for (const file of importGraph(simFile).files) {
176
+ if (!/\.tsx?$/.test(file) || file.includes(`${sep}node_modules${sep}`) || engineFile(file, engine)) continue;
177
+ const text = readFileSync(file, "utf8");
178
+ if (text.includes("dotframe-allow-math-file")) continue;
179
+ text
180
+ .split("\n")
181
+ .forEach((line: string, i: number): void => {
182
+ const code = line.replace(/\/\/.*$/, "");
183
+ if (NONDETERMINISTIC.test(code) && !line.includes("dotframe-allow-math")) hits.push(`${relative(process.cwd(), file)}:${i + 1}`);
184
+ });
185
+ }
186
+ if (hits.length === 0) return { ...base, ok: true, detail: "no Math.sin/cos/exp/pow or ** in code the sim reaches", fix: "" };
187
+ const shown = hits.slice(0, 8).join(", ") + (hits.length > 8 ? `, and ${hits.length - 8} more` : "");
188
+ return {
189
+ ...base,
190
+ ok: false,
191
+ warn: true,
192
+ detail: `${hits.length} platform-dependent math call(s) in code the sim reaches: ${shown}`,
193
+ fix: "use dotframe/src/detmath (dsin, dcos, datan2, dexp, dpow, ...) in simulation code; mark render-only lines with // dotframe-allow-math, or a render-only file with // dotframe-allow-math-file",
194
+ };
195
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { CliError, type Ctx, exec, loadConfig, num, print, runSteps, target, which } from "../lib";
4
+ import { startRelay } from "../relay";
5
+
6
+ interface Probe {
7
+ frame: number;
8
+ confirmed: number;
9
+ status: string;
10
+ sums: Record<string, number>;
11
+ }
12
+
13
+ // Two browsers play one online match through a local relay, each with a scripted masher, and their checksums at
14
+ // every 30th confirmed frame must agree. The game's web entry honors ?room=, ?relay= and ?mash=, and publishes
15
+ // globalThis.__dotframe = { frame, confirmed, status, sums } (the templates do; see the netplay skill).
16
+ export async function playOnline(ctx: Ctx, room: string | undefined, args: { frames?: string; out?: string }): Promise<void> {
17
+ const skill = "netplay";
18
+ const config = loadConfig();
19
+ const t = target(config, "web");
20
+ if (!t.out) throw new CliError("NO_OUT", "the web target needs an out dir", "", "export-web");
21
+ if (!which("agent-browser")) throw new CliError("TOOL_MISSING", "agent-browser not found (play drives two browsers)", "npm i -g agent-browser && agent-browser install", skill);
22
+ const frames = num("frames", args.frames, 600, 60);
23
+ const name = room ?? `play-${Date.now().toString(36)}`;
24
+ const outDir = resolve(process.cwd(), args.out ?? ".dotframe/play");
25
+ await runSteps({ ...ctx, json: true }, config.root, t.steps ?? [], "export-web");
26
+ const web = resolve(config.root, t.out);
27
+ const relay = startRelay(0);
28
+ const server = Bun.serve({
29
+ port: 0,
30
+ fetch: (req: Request): Response => {
31
+ const path = decodeURIComponent(new URL(req.url).pathname);
32
+ const file = join(web, path === "/" ? "index.html" : path);
33
+ return existsSync(file) ? new Response(Bun.file(file)) : new Response("not found", { status: 404 });
34
+ },
35
+ });
36
+ const sessions = ["dotframe-play-0", "dotframe-play-1"];
37
+ const ab = (session: string, ...a: string[]) => exec({ ...ctx, json: true }, config.root, { label: `agent-browser ${a[0]}`, argv: ["agent-browser", "--session", session, ...a] });
38
+ const read = async (session: string): Promise<Probe | null> => {
39
+ const r = await ab(session, "eval", "JSON.stringify(globalThis.__dotframe ?? null)");
40
+ try {
41
+ const text = JSON.parse(r.tail.trim()) as string;
42
+ return JSON.parse(text) as Probe | null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+ try {
48
+ for (const [i, session] of sessions.entries()) {
49
+ const url = `http://localhost:${server.port}/?room=${encodeURIComponent(name)}&relay=${encodeURIComponent(`ws://localhost:${relay.port}`)}&mash=${i + 1}`;
50
+ const opened = await ab(session, "open", url);
51
+ if (opened.code !== 0) throw new CliError("BROWSER_FAILED", opened.tail, "agent-browser install", skill);
52
+ }
53
+ let probes: (Probe | null)[] = [null, null];
54
+ const started = Date.now();
55
+ // Wait until both peers confirm `frames` frames, or stop making progress.
56
+ let last = -1;
57
+ let lastChange = Date.now();
58
+ for (;;) {
59
+ probes = [await read(sessions[0]), await read(sessions[1])];
60
+ const confirmed = Math.min(...probes.map((p) => p?.confirmed ?? 0));
61
+ if (confirmed >= frames) break;
62
+ if (confirmed !== last) {
63
+ last = confirmed;
64
+ lastChange = Date.now();
65
+ }
66
+ if (Date.now() - lastChange > 20000) {
67
+ throw new CliError("PLAY_STALLED", `no progress for 20 s: peers at ${probes.map((p) => (p ? `${p.status} ${p.confirmed}` : "no __dotframe")).join(" / ")}`, "the web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe (see the netplay skill)", skill);
68
+ }
69
+ await Bun.sleep(500);
70
+ }
71
+ const [a, b] = probes as Probe[];
72
+ const compared = Object.keys(a.sums).filter((f: string): boolean => b.sums[f] !== undefined).map(Number).sort((x, y) => x - y);
73
+ const mismatch = compared.find((f: number): boolean => a.sums[f] !== b.sums[f]);
74
+ const shots = sessions.map((_s, i) => join(outDir, `peer-${i}.png`));
75
+ mkdirSync(outDir, { recursive: true });
76
+ for (const [i, session] of sessions.entries()) {
77
+ const shot = await ab(session, "screenshot", shots[i]);
78
+ if (shot.code !== 0 || !existsSync(shots[i])) throw new CliError("BROWSER_FAILED", `screenshot of peer ${i} failed: ${shot.tail}`, "", skill);
79
+ }
80
+ const ok = mismatch === undefined && compared.length > 0;
81
+ print(ctx, { ok, room: name, frames: Math.min(a.confirmed, b.confirmed), compared: compared.length, firstMismatch: mismatch ?? null, seconds: Math.round((Date.now() - started) / 1000), screenshots: shots }, (): string =>
82
+ [
83
+ ok ? `in sync: ${compared.length} checksums compared over ${Math.min(a.confirmed, b.confirmed)} confirmed frames` : mismatch !== undefined ? `DESYNC at frame ${mismatch}` : "no checksums to compare",
84
+ `screenshots: ${shots.join(", ")} (open them and look)`,
85
+ ].join("\n"),
86
+ );
87
+ if (!ok) process.exit(1);
88
+ } finally {
89
+ for (const session of sessions) await ab(session, "close");
90
+ server.stop(true);
91
+ relay.stop(true);
92
+ }
93
+ }
@@ -160,7 +160,7 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
160
160
  if (reference.inspect && f % every === 0) truthState.set(f, flatten(reference.inspect()));
161
161
  }
162
162
 
163
- const peer = async (self: number): Promise<{ checksums: number[]; states: Map<number, string>; rollbacks: number; maxDepth: number }> => {
163
+ const peer = async (self: number): Promise<{ checksums: number[]; states: Map<number, string>; rollbacks: number; maxDepth: number; restores: { frame: number; ms: number }[] }> => {
164
164
  const other = 1 - self;
165
165
  const noise = lcg(seed * 31 + other);
166
166
  // Arrival tick of the remote input for each frame, in order, as the link test does.
@@ -181,6 +181,8 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
181
181
  const states = new Map<number, string>();
182
182
  let rollbacks = 0;
183
183
  let maxDepth = 0;
184
+ // Time per restore by the frame it restores to: a restore that replays from frame 0 grows with the match.
185
+ const restores: { frame: number; ms: number }[] = [];
184
186
  let next = 0;
185
187
  let lastKnown = sim.neutral;
186
188
  const stepFrame = (f: number): void => {
@@ -209,12 +211,14 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
209
211
  if (rollbackFrom >= 0) {
210
212
  rollbacks += 1;
211
213
  maxDepth = Math.max(maxDepth, simulated - rollbackFrom);
214
+ const t0 = performance.now();
212
215
  run.restore(snaps[rollbackFrom]);
216
+ restores.push({ frame: rollbackFrom, ms: performance.now() - t0 });
213
217
  for (let f = rollbackFrom; f < simulated; f++) stepFrame(f);
214
218
  }
215
219
  if (t < frames) stepFrame(t);
216
220
  }
217
- return { checksums, states, rollbacks, maxDepth };
221
+ return { checksums, states, rollbacks, maxDepth, restores };
218
222
  };
219
223
 
220
224
  const peers = [await peer(0), await peer(1)];
@@ -239,7 +243,17 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
239
243
  warnings.push(`this link needed ${deepest}-frame rollbacks but the game's window is ${sim.rollbackWindow}: real play would stall`);
240
244
  }
241
245
  if (!sim.rollbackWindow) warnings.push("the sim declares no rollbackWindow, so rollback depth is not checked against the game");
242
- print(ctx, { ok, warnings, frames, latencyFrames: latency, jitterFrames: jitter, delay, inputs: source.describe, peers: report }, (): string =>
246
+ // Restore cost early vs late in the run (first and last tenth of the rollbacks). Heuristic: when late restores cost
247
+ // more than twice the early ones, restore probably replays from an earlier point, and real rollbacks will stall
248
+ // late in a match even though desync passes.
249
+ const restores = peers[0].restores;
250
+ const tenth = Math.max(1, Math.floor(restores.length / 10));
251
+ const mean = (xs: { ms: number }[]): number => xs.reduce((a, r) => a + r.ms, 0) / Math.max(1, xs.length);
252
+ const restoreMs = { early: mean(restores.slice(0, tenth)), late: mean(restores.slice(-tenth)) };
253
+ if (restores.length >= 20 && restoreMs.late > 2 * restoreMs.early) {
254
+ warnings.push(`restore cost grows with the frame: ${restoreMs.early.toFixed(3)} ms early vs ${restoreMs.late.toFixed(3)} ms late; restore should copy a snapshot, not replay`);
255
+ }
256
+ print(ctx, { ok, warnings, restoreMs, frames, latencyFrames: latency, jitterFrames: jitter, delay, inputs: source.describe, peers: report }, (): string =>
243
257
  [
244
258
  `${frames} frames, latency ${latency}f, jitter ${jitter}f, delay ${delay}f (${source.describe})`,
245
259
  ...report.map((r): string => {
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { buildIos } from "../ios";
4
+ import { deployDokploy } from "./docker";
3
5
  import { buildNative, ENGINE, type NativePlatform, vendorDir, vendorStatus } from "../native";
4
6
  import { CliError, type Command, type Ctx, exec, gate, loadConfig, print, runSteps, target, which } from "../lib";
5
7
 
@@ -25,6 +27,12 @@ export async function build(ctx: Ctx, name: string | undefined, release: boolean
25
27
  const blocked = licenseBlockers(config.root, config.assets?.localOnly ?? []);
26
28
  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");
27
29
  }
30
+ if (t.native?.platform === "ios") {
31
+ const r = await buildIos(ctx, config.root, config.name, name, t.native);
32
+ if (ctx.dryRun) return;
33
+ print(ctx, { target: name, steps: r.steps.map(({ output: _o, ...s }) => s), artifact: r.app, log: r.log }, (): string => `artifact: ${r.app}\nlog: ${r.log}`);
34
+ return;
35
+ }
28
36
  if (t.native) {
29
37
  const r = await buildNative(ctx, config.root, config.name, name, t.native);
30
38
  if (ctx.dryRun) return;
@@ -44,16 +52,19 @@ export async function deploy(ctx: Ctx, name: string | undefined, prod: boolean):
44
52
  const config = loadConfig();
45
53
  const t = target(config, name);
46
54
  const skill = skillFor(name);
47
- 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);
55
+ 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", ...}, or run dotframe deploy init ${name} --provider dokploy`, skill);
56
+ // Dokploy builds the image from the repo, so there is no local build folder to check.
57
+ if (t.deploy.provider === "dokploy") return deployDokploy(ctx, config, name, t.deploy.compose);
58
+ const vercel = t.deploy;
48
59
  const out = resolve(config.root, t.out);
49
60
  // The 2026-10-03 incident: deploying the repo root put the wrong game in production.
50
61
  if (out === config.root || existsSync(join(out, "dotframe.json")) || existsSync(join(out, ".git"))) {
51
62
  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);
52
63
  }
53
64
  if (!existsSync(join(out, "index.html"))) throw new CliError("NOT_BUILT", `${t.out}/index.html is missing`, `dotframe build ${name}`, skill);
54
- 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);
55
- const argv = ["vercel", "deploy", ...(prod ? ["--prod"] : []), "--yes", "--scope", t.deploy.scope, "--name", t.deploy.project];
56
- 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(" ") };
65
+ if (vercel.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);
66
+ const argv = ["vercel", "deploy", ...(prod ? ["--prod"] : []), "--yes", "--scope", vercel.scope, "--name", vercel.project];
67
+ const plan = { target: name, provider: vercel.provider, project: `${vercel.scope}/${vercel.project}`, dir: out, production: prod, files: countFiles(out), command: argv.join(" ") };
57
68
  if (ctx.dryRun) {
58
69
  print(ctx, { dryRun: true, ...plan }, (): string => `would deploy ${out} to ${plan.project}${prod ? " (production)" : " (preview)"}\n ${plan.command}`);
59
70
  return;
@@ -61,7 +72,7 @@ export async function deploy(ctx: Ctx, name: string | undefined, prod: boolean):
61
72
  gate(ctx, `deploy ${name}${prod ? " to production" : ""}`, skill);
62
73
  if (!which("vercel")) throw new CliError("TOOL_MISSING", "vercel CLI not found", "npm i -g vercel", skill);
63
74
  const r = await exec(ctx, config.root, { label: "vercel deploy", argv, cwd: out });
64
- if (r.code !== 0) throw new CliError("DEPLOY_FAILED", r.tail, "vercel whoami; vercel switch " + t.deploy.scope, skill);
75
+ if (r.code !== 0) throw new CliError("DEPLOY_FAILED", r.tail, "vercel whoami; vercel switch " + vercel.scope, skill);
65
76
  const url = r.tail.match(/https:\/\/\S+\.vercel\.app/g)?.pop() ?? null;
66
77
  print(ctx, { ...plan, url, ms: r.ms }, (): string => `deployed ${url ?? "(url not found in output)"}`);
67
78
  }
@@ -91,8 +102,11 @@ export async function relay(ctx: Ctx, region: string | undefined): Promise<void>
91
102
  export async function device(ctx: Ctx, name: string): Promise<void> {
92
103
  const config = loadConfig();
93
104
  const t = target(config, name);
94
- if (!t.app || !t.device) throw new CliError("NO_DEVICE", `target "${name}" needs "app" and "device" in dotframe.json`, "xcrun devicectl list devices", "ios");
95
- const app = resolve(config.root, t.app);
105
+ // Native iOS targets build into dist/<target>/<Scheme>.app; others name the app explicitly.
106
+ const builtApp = t.native?.platform === "ios" && existsSync(join(config.root, "dist", name)) ? readdirSync(join(config.root, "dist", name)).find((f: string): boolean => f.endsWith(".app")) : undefined;
107
+ const appPath = t.app ?? (builtApp ? join("dist", name, builtApp) : undefined);
108
+ if (!appPath || !t.device) throw new CliError("NO_DEVICE", `target "${name}" needs "device" in dotframe.json${appPath ? "" : " and a built app (dotframe build ios)"}`, "xcrun devicectl list devices", "ios");
109
+ const app = resolve(config.root, appPath);
96
110
  const argv = ["xcrun", "devicectl", "device", "install", "app", "--device", t.device, app];
97
111
  if (ctx.dryRun) {
98
112
  print(ctx, { dryRun: true, command: argv.join(" ") }, (): string => `would run: ${argv.join(" ")}`);
@@ -107,7 +121,7 @@ export async function device(ctx: Ctx, name: string): Promise<void> {
107
121
 
108
122
  // Downloads wgpu-native and builds SDL3 into the vendor dir (default ~/.dotframe/vendor). Local only, slow once.
109
123
  export async function vendor(ctx: Ctx, platform: string | undefined): Promise<void> {
110
- if (platform !== "macos" && platform !== "windows") throw new CliError("MISSING_ARG", "vendor needs macos or windows", "dotframe vendor macos", "macos");
124
+ if (platform !== "macos" && platform !== "windows" && platform !== "ios") throw new CliError("MISSING_ARG", "vendor needs macos, windows or ios", "dotframe vendor macos", "macos");
111
125
  const p = platform as NativePlatform;
112
126
  const dir = vendorDir(p);
113
127
  const before = vendorStatus(p);
@@ -120,7 +134,7 @@ export async function vendor(ctx: Ctx, platform: string | undefined): Promise<vo
120
134
  print(ctx, { dryRun: true, platform: p, dir, missing: before.missing }, (): string => `would download and build ${before.missing.join(", ")} into ${dir}`);
121
135
  return;
122
136
  }
123
- for (const tool of ["curl", "cmake", "unzip", ...(p === "windows" ? ["zig"] : [])]) {
137
+ for (const tool of ["curl", "cmake", "unzip", ...(p === "windows" ? ["zig"] : []), ...(p === "ios" ? ["xcodebuild"] : [])]) {
124
138
  if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, `brew install ${tool}`, "macos");
125
139
  }
126
140
  await runSteps(ctx, ENGINE, [command], "macos");