dotframe 0.1.5 → 0.1.7
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 +22 -0
- package/cli/commands/docker.ts +179 -0
- package/cli/commands/doctor.ts +70 -7
- package/cli/commands/online.ts +93 -0
- package/cli/commands/play.ts +17 -3
- package/cli/commands/ship.ts +23 -9
- package/cli/ios.ts +271 -0
- package/cli/lib.ts +4 -2
- package/cli/main.ts +44 -11
- package/cli/native.ts +10 -5
- package/cli/relay.ts +65 -0
- package/native/df_native.c +5 -0
- package/package.json +1 -1
- package/scripts/vendor.sh +11 -5
- package/skill-data/core/SKILL.md +6 -0
- package/skill-data/export-web/SKILL.md +14 -0
- package/skill-data/ios/SKILL.md +45 -10
- package/skill-data/macos/SKILL.md +27 -0
- package/skill-data/netplay/SKILL.md +17 -0
- package/src/checksum.ts +22 -0
- package/src/detmath.ts +217 -0
- package/src/netplay.ts +232 -0
- package/src/relay-client.ts +42 -0
- package/templates/_base/dotframe.json +10 -1
- package/templates/_base/main.ios.ts +19 -0
- package/templates/_base/main.native.ts +2 -1
- package/templates/_base/main.web.ts +9 -1
- package/templates/_base/sim.ts +2 -4
- package/templates/_base/src/setup.ts +112 -11
- package/templates/_base/tsconfig.json +1 -1
- package/templates/blank/game.ts +10 -1
- package/templates/fighter/game.ts +15 -1
- package/templates/platformer/game.ts +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7
|
|
4
|
+
|
|
5
|
+
From dogfood rounds 5 and 6 (Craft Ones on iOS, online, and on the VPS).
|
|
6
|
+
|
|
7
|
+
- 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).
|
|
8
|
+
- Engine: `src/netplay`, a game-agnostic rollback engine over the Sim contract's shape, and `src/relay-client` (web) for the relay.
|
|
9
|
+
- `relay serve` runs the netplay relay locally; `play --online` drives a scripted match in two browsers through it and compares checksums.
|
|
10
|
+
- `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.
|
|
11
|
+
- 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.
|
|
12
|
+
- `doctor` warns about platform-dependent math in code the sim reaches (`sim:math`).
|
|
13
|
+
- `desync` reports restore cost and warns when it grows with the frame.
|
|
14
|
+
- Native: touches no longer synthesize a mouse (phantom pointer on iOS).
|
|
15
|
+
- Templates: online play through the relay (`?room=`), `main.ios.ts`, an aspect-filling canvas, and snapshot and restore without structuredClone or Object.assign.
|
|
16
|
+
|
|
17
|
+
## 0.1.6
|
|
18
|
+
|
|
19
|
+
From the Craft Ones native port.
|
|
20
|
+
|
|
21
|
+
- Native: mouse buttons read 0 while the window lacks input focus, so clicks in other apps no longer reach the game. The pointer position stays global, so aiming is unchanged.
|
|
22
|
+
- Templates: `src/setup.ts` reads the pointer and touches with a hold-to-charge, release-to-act pattern (a charge meter while holding, a tone on release), on web and native. Each `game.ts` declares the `POINTER_BIT` a release sets.
|
|
23
|
+
- Skills: the macos guide gains "Porting existing TypeScript" (what scriptc rejects and the fix for each, plus runtime traps such as structural coercion copying and out-of-range reads) and "Debugging a native crash" with lldb. Netplay warns against checksums built on `JSON.stringify`.
|
|
24
|
+
|
|
3
25
|
## 0.1.5
|
|
4
26
|
|
|
5
27
|
- Native builds follow the entry's import graph instead of copying the game folder: relative imports that leave the game root and workspace packages that resolve to source are staged with their relative layout and rewritten to relative paths, so ports inside monorepos compile.
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
// game: the game's path inside the repo ("" at the root); context: the repo root, relative to deploy/.
|
|
18
|
+
function files(config: Config, game: string, out: string, context: string): Record<string, string> {
|
|
19
|
+
const requires = (config.requires ?? []).filter((t: string): boolean => t !== "bun");
|
|
20
|
+
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` : "";
|
|
21
|
+
const at = game === "" ? "." : game;
|
|
22
|
+
return {
|
|
23
|
+
"Dockerfile.web": `# Static web build of ${config.name}, served by nginx. Generated by dotframe deploy init; edit freely.
|
|
24
|
+
FROM oven/bun:1 AS build
|
|
25
|
+
${apt}WORKDIR /app
|
|
26
|
+
COPY . .
|
|
27
|
+
RUN cd ${at} && bun install --frozen-lockfile && bun node_modules/dotframe/cli/main.ts build web
|
|
28
|
+
|
|
29
|
+
FROM nginx:1.27-alpine
|
|
30
|
+
COPY ${join(at, DIR, "nginx.conf")} /etc/nginx/conf.d/default.conf
|
|
31
|
+
COPY --from=build ${join("/app", at, out)} /usr/share/nginx/html
|
|
32
|
+
EXPOSE 80
|
|
33
|
+
`,
|
|
34
|
+
"Dockerfile.relay": `# Netplay relay for ${config.name}: dotframe relay serve. Generated by dotframe deploy init.
|
|
35
|
+
FROM oven/bun:1-alpine
|
|
36
|
+
WORKDIR /app
|
|
37
|
+
RUN bun add dotframe@${pkg.version}
|
|
38
|
+
ENV PORT=8787
|
|
39
|
+
EXPOSE 8787
|
|
40
|
+
CMD ["bun", "node_modules/dotframe/cli/main.ts", "relay", "serve"]
|
|
41
|
+
`,
|
|
42
|
+
"nginx.conf": `server {
|
|
43
|
+
listen 80;
|
|
44
|
+
server_name _;
|
|
45
|
+
root /usr/share/nginx/html;
|
|
46
|
+
index index.html;
|
|
47
|
+
gzip on;
|
|
48
|
+
gzip_types text/css application/javascript application/json image/svg+xml;
|
|
49
|
+
gzip_min_length 1024;
|
|
50
|
+
# The bundle name is content-hashed; index.html must never be cached (Discord caches aggressively).
|
|
51
|
+
location = /index.html { add_header Cache-Control "no-cache"; }
|
|
52
|
+
location / { try_files $uri $uri/ /index.html; }
|
|
53
|
+
location ~* \\.(png|mp3|js|json)$ { expires 7d; add_header Cache-Control "public"; }
|
|
54
|
+
}
|
|
55
|
+
`,
|
|
56
|
+
"compose.yaml": `# ${config.name}: the game at / and the netplay relay at /relay on the same host. In Dokploy, point the domain's
|
|
57
|
+
# / at service web (port 80) and /relay at service relay (port 8787).
|
|
58
|
+
services:
|
|
59
|
+
web:
|
|
60
|
+
build:
|
|
61
|
+
context: ${context}
|
|
62
|
+
dockerfile: ${join(at, DIR, "Dockerfile.web")}
|
|
63
|
+
restart: unless-stopped
|
|
64
|
+
expose:
|
|
65
|
+
- "80"
|
|
66
|
+
relay:
|
|
67
|
+
build:
|
|
68
|
+
context: .
|
|
69
|
+
dockerfile: Dockerfile.relay
|
|
70
|
+
restart: unless-stopped
|
|
71
|
+
environment:
|
|
72
|
+
PORT: "8787"
|
|
73
|
+
expose:
|
|
74
|
+
- "8787"
|
|
75
|
+
`,
|
|
76
|
+
".dockerignore": "**/node_modules\n**/dist\n**/.dotframe\n.git\n",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function deployInit(ctx: Ctx, name: string | undefined, provider: string | undefined, compose: string | undefined): Promise<void> {
|
|
81
|
+
if (!name) throw new CliError("MISSING_ARG", "deploy init needs a target", "dotframe deploy init web --provider dokploy", "export-web");
|
|
82
|
+
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");
|
|
83
|
+
const config = loadConfig();
|
|
84
|
+
const t = target(config, name);
|
|
85
|
+
if (!t.out) throw new CliError("NO_OUT", `target "${name}" needs an out dir`, "", "export-web");
|
|
86
|
+
const repo = await gitRoot(ctx, config.root);
|
|
87
|
+
const game = relative(repo, config.root);
|
|
88
|
+
// compose.yaml sits in <game>/deploy; the web build context is the repo root, so workspace code is visible.
|
|
89
|
+
const generated = files(config, game, t.out, relative(join(config.root, DIR), repo) || ".");
|
|
90
|
+
const dir = join(config.root, DIR);
|
|
91
|
+
const written = Object.keys(generated).map((f: string): string => join(DIR, f));
|
|
92
|
+
const existing = written.filter((f: string): boolean => existsSync(join(config.root, f)));
|
|
93
|
+
if (ctx.dryRun) {
|
|
94
|
+
print(ctx, { dryRun: true, files: written, existing }, (): string => `would write ${written.join(", ")}${existing.length ? ` (overwrites ${existing.join(", ")})` : ""}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
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");
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
for (const [f, text] of Object.entries(generated)) writeFileSync(join(dir, f), text);
|
|
100
|
+
// The repo-root .dockerignore is the one docker reads for a repo-root context.
|
|
101
|
+
if (!existsSync(join(repo, ".dockerignore"))) writeFileSync(join(repo, ".dockerignore"), generated[".dockerignore"]);
|
|
102
|
+
const raw = JSON.parse(readFileSync(join(config.root, CONFIG_FILE), "utf8")) as { targets: Record<string, { deploy?: unknown }> };
|
|
103
|
+
raw.targets[name].deploy = { provider: "dokploy", compose: compose ?? "", dir: DIR };
|
|
104
|
+
writeFileSync(join(config.root, CONFIG_FILE), `${formatJson(raw)}\n`);
|
|
105
|
+
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 =>
|
|
106
|
+
[`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"),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface Deployment {
|
|
111
|
+
id: string;
|
|
112
|
+
status: string;
|
|
113
|
+
created: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function vpsJson<T>(ctx: Ctx, root: string, args: string[]): Promise<T> {
|
|
117
|
+
const r = await exec({ ...ctx, json: true }, root, { label: `vps ${args.join(" ")}`, argv: ["vps", ...args, "--json"] });
|
|
118
|
+
if (r.code !== 0) throw new CliError("DEPLOY_FAILED", r.tail, "vps doctor; check the profile and API key", "export-web");
|
|
119
|
+
return JSON.parse(r.output) as T;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function deployDokploy(ctx: Ctx, config: Config, name: string, compose: string): Promise<void> {
|
|
123
|
+
const skill = "export-web";
|
|
124
|
+
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);
|
|
125
|
+
for (const f of ["compose.yaml", "Dockerfile.web", "nginx.conf"]) {
|
|
126
|
+
if (!existsSync(join(config.root, DIR, f))) throw new CliError("NOT_BUILT", `${DIR}/${f} is missing`, `dotframe deploy init ${name} --provider dokploy`, skill);
|
|
127
|
+
}
|
|
128
|
+
// Dokploy builds what is on the remote branch, not this working tree.
|
|
129
|
+
const status = await exec({ ...ctx, json: true }, config.root, { label: "git status", argv: ["git", "status", "--porcelain", "--branch"] });
|
|
130
|
+
const unpushed = /\[ahead \d+/.test(status.output.split("\n")[0] ?? "");
|
|
131
|
+
const dirty = status.output.split("\n").slice(1).some((l: string): boolean => l.trim() !== "");
|
|
132
|
+
const plan = { target: name, provider: "dokploy", compose, command: `vps -y compose redeploy ${compose}`, unpushedCommits: unpushed, uncommittedChanges: dirty };
|
|
133
|
+
if (ctx.dryRun) {
|
|
134
|
+
print(ctx, { dryRun: true, ...plan }, (): string =>
|
|
135
|
+
[`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"),
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
gate(ctx, `deploy ${name} to Dokploy`, skill);
|
|
140
|
+
if (!which("vps")) throw new CliError("TOOL_MISSING", "vps CLI not found", "install crafter-station/vps-cli and log in", skill);
|
|
141
|
+
const before = await vpsJson<Deployment[]>(ctx, config.root, ["compose", "deployments", compose]);
|
|
142
|
+
const known = new Set(before.map((d: Deployment): string => d.id));
|
|
143
|
+
const started = await exec({ ...ctx, json: true }, config.root, { label: "vps redeploy", argv: ["vps", "-y", "compose", "redeploy", compose, "--json"] });
|
|
144
|
+
if (started.code !== 0) throw new CliError("DEPLOY_FAILED", started.tail, "", skill);
|
|
145
|
+
if (!ctx.json) console.error("> waiting for the Dokploy deployment");
|
|
146
|
+
// Wait for the new deployment to reach a terminal state; a build failure carries its log.
|
|
147
|
+
for (;;) {
|
|
148
|
+
await Bun.sleep(5000);
|
|
149
|
+
const now = await vpsJson<Deployment[]>(ctx, config.root, ["compose", "deployments", compose]);
|
|
150
|
+
const fresh = now.find((d: Deployment): boolean => !known.has(d.id));
|
|
151
|
+
if (!fresh || fresh.status === "running" || fresh.status === "queued") continue;
|
|
152
|
+
if (fresh.status === "done") {
|
|
153
|
+
print(ctx, { ...plan, deployment: fresh.id, status: fresh.status }, (): string => `deployed: compose ${compose}, deployment ${fresh.id}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const logs = await vpsJson<{ log: string }>(ctx, config.root, ["compose", "logs", compose, "--deployment", fresh.id, "--tail", "60"]);
|
|
157
|
+
const logDir = join(config.root, ".dotframe", "logs");
|
|
158
|
+
mkdirSync(logDir, { recursive: true });
|
|
159
|
+
const log = join(logDir, `deploy-${name}.log`);
|
|
160
|
+
writeFileSync(log, logs.log);
|
|
161
|
+
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);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Builds the web image locally: what Dokploy would run, minus the network round trip.
|
|
166
|
+
export async function dockerCheck(ctx: Ctx, config: Config): Promise<{ check: string; ok: boolean; detail: string; fix: string; skill: string }> {
|
|
167
|
+
const base = { check: "docker:web", skill: "export-web" };
|
|
168
|
+
const dockerfile = join(config.root, DIR, "Dockerfile.web");
|
|
169
|
+
if (!existsSync(dockerfile)) return { ...base, ok: false, detail: `${DIR}/Dockerfile.web is missing`, fix: "dotframe deploy init web --provider dokploy" };
|
|
170
|
+
if (!which("docker")) return { ...base, ok: false, detail: "docker not found", fix: "install Docker Desktop or OrbStack" };
|
|
171
|
+
const repo = await gitRoot(ctx, config.root);
|
|
172
|
+
const r = await exec({ ...ctx, json: true }, repo, { label: "docker build", argv: ["docker", "build", "-f", dockerfile, "-t", `${config.name}-web:check`, "."] });
|
|
173
|
+
if (r.code === 0) return { ...base, ok: true, detail: `${DIR}/Dockerfile.web builds`, fix: "" };
|
|
174
|
+
const logDir = join(config.root, ".dotframe", "logs");
|
|
175
|
+
mkdirSync(logDir, { recursive: true });
|
|
176
|
+
writeFileSync(join(logDir, "docker-web.log"), r.output);
|
|
177
|
+
const cause = r.output.split("\n").filter((l: string): boolean => /error|not found/i.test(l)).slice(-3).join(" | ");
|
|
178
|
+
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` };
|
|
179
|
+
}
|
package/cli/commands/doctor.ts
CHANGED
|
@@ -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.
|
|
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,48 @@ 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` to silence it.
|
|
152
|
+
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(]/;
|
|
153
|
+
|
|
154
|
+
// The engine's own files (draw2d's arcs, raster2d) are render code, however the game imports them: a vendored
|
|
155
|
+
// submodule, node_modules, or a relative path.
|
|
156
|
+
function engineFile(file: string, cache: Map<string, boolean>): boolean {
|
|
157
|
+
for (let dir = dirname(file); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
158
|
+
const known = cache.get(dir);
|
|
159
|
+
if (known !== undefined) return known;
|
|
160
|
+
const pkg = join(dir, "package.json");
|
|
161
|
+
if (existsSync(pkg)) {
|
|
162
|
+
const isEngine = (JSON.parse(readFileSync(pkg, "utf8")) as { name?: string }).name === "dotframe";
|
|
163
|
+
cache.set(dir, isEngine);
|
|
164
|
+
return isEngine;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mathCheck(simFile: string): Check {
|
|
171
|
+
const base = { check: "sim:math", skill: "netplay" };
|
|
172
|
+
const hits: string[] = [];
|
|
173
|
+
const engine = new Map<string, boolean>();
|
|
174
|
+
for (const file of importGraph(simFile).files) {
|
|
175
|
+
if (!/\.tsx?$/.test(file) || file.includes(`${sep}node_modules${sep}`) || engineFile(file, engine)) continue;
|
|
176
|
+
readFileSync(file, "utf8")
|
|
177
|
+
.split("\n")
|
|
178
|
+
.forEach((line: string, i: number): void => {
|
|
179
|
+
const code = line.replace(/\/\/.*$/, "");
|
|
180
|
+
if (NONDETERMINISTIC.test(code) && !line.includes("dotframe-allow-math")) hits.push(`${relative(process.cwd(), file)}:${i + 1}`);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (hits.length === 0) return { ...base, ok: true, detail: "no Math.sin/cos/exp/pow or ** in code the sim reaches", fix: "" };
|
|
184
|
+
const shown = hits.slice(0, 8).join(", ") + (hits.length > 8 ? `, and ${hits.length - 8} more` : "");
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
ok: false,
|
|
188
|
+
warn: true,
|
|
189
|
+
detail: `${hits.length} platform-dependent math call(s) in code the sim reaches: ${shown}`,
|
|
190
|
+
fix: "use dotframe/src/detmath (dsin, dcos, datan2, dexp, dpow, ...) in simulation code; mark render-only lines with // dotframe-allow-math",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -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
|
+
}
|
package/cli/commands/play.ts
CHANGED
|
@@ -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
|
-
|
|
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 => {
|
package/cli/commands/ship.ts
CHANGED
|
@@ -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", ...}
|
|
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 (
|
|
55
|
-
const argv = ["vercel", "deploy", ...(prod ? ["--prod"] : []), "--yes", "--scope",
|
|
56
|
-
const plan = { target: name, provider:
|
|
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 " +
|
|
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
|
-
|
|
95
|
-
const
|
|
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
|
|
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");
|