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 +16 -0
- package/cli/commands/config.ts +2 -2
- package/cli/commands/doctor.ts +33 -1
- package/cli/commands/new.ts +22 -6
- package/cli/commands/play.ts +19 -12
- package/cli/commands/ship.ts +5 -1
- package/cli/commands/snap.ts +9 -6
- package/cli/lib.ts +28 -0
- package/cli/main.ts +74 -2
- package/cli/simkit.ts +2 -2
- package/package.json +1 -1
- package/skill-data/assets/SKILL.md +1 -0
- package/skill-data/core/SKILL.md +5 -1
- package/skill-data/export-web/SKILL.md +1 -0
- package/skill-data/netplay/SKILL.md +1 -1
- package/skills/dotframe/SKILL.md +1 -1
- package/src/sim.ts +5 -1
- package/src/web/run.ts +9 -6
- package/templates/_base/dotframe.json +1 -4
- package/templates/_base/package.json +7 -1
- package/templates/_base/scripts/build-web.ts +21 -0
- package/templates/_base/sim.ts +2 -0
- package/templates/_base/tsconfig.json +13 -0
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.
|
package/cli/commands/config.ts
CHANGED
|
@@ -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, `${
|
|
33
|
+
writeFileSync(file, `${formatJson(raw)}\n`);
|
|
34
34
|
print(ctx, { key, value: parsed }, (): string => `${key} = ${JSON.stringify(parsed)}`);
|
|
35
35
|
}
|
package/cli/commands/doctor.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/cli/commands/new.ts
CHANGED
|
@@ -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")
|
|
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)
|
|
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
|
-
|
|
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}`);
|
package/cli/commands/play.ts
CHANGED
|
@@ -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 =
|
|
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,
|
|
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 =
|
|
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 =
|
|
139
|
-
const seed =
|
|
140
|
-
const latency =
|
|
141
|
-
const jitter =
|
|
142
|
-
const delay =
|
|
143
|
-
const renders =
|
|
144
|
-
const every =
|
|
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
|
-
|
|
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);
|
package/cli/commands/ship.ts
CHANGED
|
@@ -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:
|
|
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;
|
package/cli/commands/snap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
23
|
-
|
|
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,
|
|
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,
|
|
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
|
@@ -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.
|
package/skill-data/core/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: core
|
|
3
|
-
description: Core dotframe usage. Read before running any dotframe command
|
|
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.
|
|
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
|
|
package/skills/dotframe/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
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}`);
|
package/templates/_base/sim.ts
CHANGED
|
@@ -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
|
+
}
|