dotframe 0.1.7 → 0.1.9
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/docker.ts +30 -5
- package/cli/commands/doctor.ts +10 -4
- package/cli/commands/online.ts +7 -3
- package/cli/main.ts +8 -5
- package/cli/native.ts +1 -1
- package/package.json +1 -1
- package/skill-data/export-web/SKILL.md +1 -0
- package/skill-data/ios/SKILL.md +1 -0
- package/skill-data/netplay/SKILL.md +2 -2
- package/src/probe.ts +53 -0
- package/templates/_base/src/setup.ts +10 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9
|
|
4
|
+
|
|
5
|
+
The rest of round 7, which missed the 0.1.8 merge.
|
|
6
|
+
|
|
7
|
+
- `dotframe/src/probe`: `createProbe()` publishes `globalThis.__dotframe` for `play --online`, and `createMasher(seed, random)` gives scripted inputs, so games do not write either by hand. Templates use them.
|
|
8
|
+
- `play --online --seeds a,b` picks each peer's mash seed.
|
|
9
|
+
- `doctor` fails on the template's placeholder iOS bundle id (`com.example.*`), which Apple will not register.
|
|
10
|
+
|
|
11
|
+
## 0.1.8
|
|
12
|
+
|
|
13
|
+
From dogfood round 7 (Craft Ones on 0.1.7).
|
|
14
|
+
|
|
15
|
+
- `doctor` sim:math skips files marked `// dotframe-allow-math-file`, for render-only files the sim reaches because it builds the renderer.
|
|
16
|
+
- `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.
|
|
17
|
+
- The vendor fix (doctor and native builds) names `DOTFRAME_VENDOR` for reusing an existing vendor dir.
|
|
18
|
+
|
|
3
19
|
## 0.1.7
|
|
4
20
|
|
|
5
21
|
From dogfood rounds 5 and 6 (Craft Ones on iOS, online, and on the VPS).
|
package/cli/commands/docker.ts
CHANGED
|
@@ -14,14 +14,38 @@ async function gitRoot(ctx: Ctx, root: string): Promise<string> {
|
|
|
14
14
|
return r.output.trim();
|
|
15
15
|
}
|
|
16
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
|
+
|
|
17
41
|
// 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> {
|
|
42
|
+
function files(config: Config, game: string, out: string, context: string, v: Versions): Record<string, string> {
|
|
19
43
|
const requires = (config.requires ?? []).filter((t: string): boolean => t !== "bun");
|
|
20
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` : "";
|
|
21
45
|
const at = game === "" ? "." : game;
|
|
22
46
|
return {
|
|
23
47
|
"Dockerfile.web": `# Static web build of ${config.name}, served by nginx. Generated by dotframe deploy init; edit freely.
|
|
24
|
-
FROM oven/bun
|
|
48
|
+
FROM oven/bun:${v.bun} AS build
|
|
25
49
|
${apt}WORKDIR /app
|
|
26
50
|
COPY . .
|
|
27
51
|
RUN cd ${at} && bun install --frozen-lockfile && bun node_modules/dotframe/cli/main.ts build web
|
|
@@ -32,9 +56,10 @@ COPY --from=build ${join("/app", at, out)} /usr/share/nginx/html
|
|
|
32
56
|
EXPOSE 80
|
|
33
57
|
`,
|
|
34
58
|
"Dockerfile.relay": `# Netplay relay for ${config.name}: dotframe relay serve. Generated by dotframe deploy init.
|
|
35
|
-
FROM oven/bun
|
|
59
|
+
FROM oven/bun:${v.bun}-alpine
|
|
36
60
|
WORKDIR /app
|
|
37
|
-
|
|
61
|
+
# The dotframe version the game has installed; bump it together with the game's.
|
|
62
|
+
RUN bun add dotframe@${v.dotframe}
|
|
38
63
|
ENV PORT=8787
|
|
39
64
|
EXPOSE 8787
|
|
40
65
|
CMD ["bun", "node_modules/dotframe/cli/main.ts", "relay", "serve"]
|
|
@@ -86,7 +111,7 @@ export async function deployInit(ctx: Ctx, name: string | undefined, provider: s
|
|
|
86
111
|
const repo = await gitRoot(ctx, config.root);
|
|
87
112
|
const game = relative(repo, config.root);
|
|
88
113
|
// 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) || ".");
|
|
114
|
+
const generated = files(config, game, t.out, relative(join(config.root, DIR), repo) || ".", versions(config.root, repo));
|
|
90
115
|
const dir = join(config.root, DIR);
|
|
91
116
|
const written = Object.keys(generated).map((f: string): string => join(DIR, f));
|
|
92
117
|
const existing = written.filter((f: string): boolean => existsSync(join(config.root, f)));
|
package/cli/commands/doctor.ts
CHANGED
|
@@ -47,6 +47,9 @@ export async function doctor(ctx: Ctx, fix: boolean, docker = false): Promise<vo
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
for (const [name, t] of Object.entries(config.targets)) {
|
|
50
|
+
if (t.native?.platform === "ios" && t.native.bundleId.startsWith("com.example.")) {
|
|
51
|
+
checks.push({ check: `ios:${name}:bundleId`, ok: false, detail: `bundle id ${t.native.bundleId} is the template placeholder; Apple will not register it`, fix: `dotframe config set targets.${name}.native.bundleId '"<reverse domain you own>.<game>"'`, skill: "ios" });
|
|
52
|
+
}
|
|
50
53
|
if (t.native?.platform === "ios" && t.native.team === "YOUR_TEAM_ID") {
|
|
51
54
|
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
55
|
}
|
|
@@ -147,8 +150,9 @@ async function renderCheck(sim: Sim, root: string): Promise<Check> {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
// 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
|
|
151
|
-
// `dotframe-allow-math
|
|
153
|
+
// between netplay peers and breaks replays on another machine. Render code may use them: mark a line with
|
|
154
|
+
// `dotframe-allow-math`, or a whole render-only file with `// dotframe-allow-math-file`. (The sim module builds the
|
|
155
|
+
// renderer for desync's purity check, so render files are reachable and a static scan cannot tell them apart.)
|
|
152
156
|
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
157
|
|
|
154
158
|
// The engine's own files (draw2d's arcs, raster2d) are render code, however the game imports them: a vendored
|
|
@@ -173,7 +177,9 @@ function mathCheck(simFile: string): Check {
|
|
|
173
177
|
const engine = new Map<string, boolean>();
|
|
174
178
|
for (const file of importGraph(simFile).files) {
|
|
175
179
|
if (!/\.tsx?$/.test(file) || file.includes(`${sep}node_modules${sep}`) || engineFile(file, engine)) continue;
|
|
176
|
-
readFileSync(file, "utf8")
|
|
180
|
+
const text = readFileSync(file, "utf8");
|
|
181
|
+
if (text.includes("dotframe-allow-math-file")) continue;
|
|
182
|
+
text
|
|
177
183
|
.split("\n")
|
|
178
184
|
.forEach((line: string, i: number): void => {
|
|
179
185
|
const code = line.replace(/\/\/.*$/, "");
|
|
@@ -187,6 +193,6 @@ function mathCheck(simFile: string): Check {
|
|
|
187
193
|
ok: false,
|
|
188
194
|
warn: true,
|
|
189
195
|
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",
|
|
196
|
+
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",
|
|
191
197
|
};
|
|
192
198
|
}
|
package/cli/commands/online.ts
CHANGED
|
@@ -13,13 +13,17 @@ interface Probe {
|
|
|
13
13
|
// Two browsers play one online match through a local relay, each with a scripted masher, and their checksums at
|
|
14
14
|
// every 30th confirmed frame must agree. The game's web entry honors ?room=, ?relay= and ?mash=, and publishes
|
|
15
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> {
|
|
16
|
+
export async function playOnline(ctx: Ctx, room: string | undefined, args: { frames?: string; out?: string; seeds?: string }): Promise<void> {
|
|
17
17
|
const skill = "netplay";
|
|
18
18
|
const config = loadConfig();
|
|
19
19
|
const t = target(config, "web");
|
|
20
20
|
if (!t.out) throw new CliError("NO_OUT", "the web target needs an out dir", "", "export-web");
|
|
21
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
22
|
const frames = num("frames", args.frames, 600, 60);
|
|
23
|
+
// One mash seed per peer; different seeds make the peers press different inputs, which is what exercises rollback.
|
|
24
|
+
const seedParts = (args.seeds ?? "1,2").split(",");
|
|
25
|
+
if (seedParts.length !== 2) throw new CliError("BAD_ARG", `--seeds needs two comma-separated seeds, got "${args.seeds}"`, "--seeds 7,42", skill);
|
|
26
|
+
const seeds = seedParts.map((part: string): number => num("seeds", part.trim(), 1));
|
|
23
27
|
const name = room ?? `play-${Date.now().toString(36)}`;
|
|
24
28
|
const outDir = resolve(process.cwd(), args.out ?? ".dotframe/play");
|
|
25
29
|
await runSteps({ ...ctx, json: true }, config.root, t.steps ?? [], "export-web");
|
|
@@ -46,7 +50,7 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
|
|
|
46
50
|
};
|
|
47
51
|
try {
|
|
48
52
|
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
|
|
53
|
+
const url = `http://localhost:${server.port}/?room=${encodeURIComponent(name)}&relay=${encodeURIComponent(`ws://localhost:${relay.port}`)}&mash=${seeds[i]}`;
|
|
50
54
|
const opened = await ab(session, "open", url);
|
|
51
55
|
if (opened.code !== 0) throw new CliError("BROWSER_FAILED", opened.tail, "agent-browser install", skill);
|
|
52
56
|
}
|
|
@@ -78,7 +82,7 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
|
|
|
78
82
|
if (shot.code !== 0 || !existsSync(shots[i])) throw new CliError("BROWSER_FAILED", `screenshot of peer ${i} failed: ${shot.tail}`, "", skill);
|
|
79
83
|
}
|
|
80
84
|
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 =>
|
|
85
|
+
print(ctx, { ok, room: name, seeds, frames: Math.min(a.confirmed, b.confirmed), compared: compared.length, firstMismatch: mismatch ?? null, seconds: Math.round((Date.now() - started) / 1000), screenshots: shots }, (): string =>
|
|
82
86
|
[
|
|
83
87
|
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
88
|
`screenshots: ${shots.join(", ")} (open them and look)`,
|
package/cli/main.ts
CHANGED
|
@@ -21,7 +21,7 @@ Play (headless, deterministic)
|
|
|
21
21
|
sim [--inputs f.jsonl | --mash <seed>] [--frames 600] [--seed 1] [--options json] [--every n]
|
|
22
22
|
snap --frame <n> [--out frame.png] [--inputs f | --mash <seed>] [--seed 1] [--options json]
|
|
23
23
|
replay record <file> | verify <file...>
|
|
24
|
-
play --online [room] [--frames 600] two browsers, local relay, scripted match
|
|
24
|
+
play --online [room] [--frames 600] [--seeds 1,2] two browsers, local relay, scripted match
|
|
25
25
|
desync [--latency 100ms] [--jitter 0ms] [--delay 2] [--frames 1800] [--renders 3]
|
|
26
26
|
|
|
27
27
|
Build and ship
|
|
@@ -74,11 +74,13 @@ Compares checksums every frame and the full inspect() state every --every frames
|
|
|
74
74
|
Warns when rollbacks exceed the sim's rollbackWindow.
|
|
75
75
|
|
|
76
76
|
dotframe desync --latency 474ms --jitter 40ms --mash 42 --json`,
|
|
77
|
-
play: `dotframe play --online [room] [--frames 600] [--out .dotframe/play] [--json]
|
|
77
|
+
play: `dotframe play --online [room] [--frames 600] [--seeds 1,2] [--out .dotframe/play] [--json]
|
|
78
78
|
|
|
79
|
-
Builds the web target, starts a local relay, and opens two agent-browser sessions on ?room=<room>&relay=...&mash
|
|
79
|
+
Builds the web target, starts a local relay, and opens two agent-browser sessions on ?room=<room>&relay=...&mash=<seed>
|
|
80
|
+
(one seed per peer from --seeds).
|
|
80
81
|
Waits until both confirm --frames frames, compares their checksums every 30 frames, and saves a screenshot of each.
|
|
81
|
-
The web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe (
|
|
82
|
+
The web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe: createProbe() and createMasher()
|
|
83
|
+
from dotframe/src/probe do both (templates show it).`,
|
|
82
84
|
build: `dotframe build <target> [--release] [--dry-run] [--json]
|
|
83
85
|
|
|
84
86
|
Runs targets.<target>.steps from dotframe.json, or for a native target ({"native": {"platform": "macos",
|
|
@@ -168,6 +170,7 @@ const { values, positionals } = parseArgs({
|
|
|
168
170
|
"through-over": { type: "boolean" },
|
|
169
171
|
docker: { type: "boolean" },
|
|
170
172
|
online: { type: "boolean" },
|
|
173
|
+
seeds: { type: "string" },
|
|
171
174
|
provider: { type: "string" },
|
|
172
175
|
compose: { type: "string" },
|
|
173
176
|
},
|
|
@@ -181,7 +184,7 @@ try {
|
|
|
181
184
|
if (values.version) console.log(pkg.version);
|
|
182
185
|
else if (command && values.help && COMMAND_HELP[command]) console.log(COMMAND_HELP[command]);
|
|
183
186
|
else if (!command || values.help) console.log(HELP);
|
|
184
|
-
else if (command === "play" && values.online === true) await playOnline(ctx, rest[0], { frames: v.frames, out: v.out });
|
|
187
|
+
else if (command === "play" && values.online === true) await playOnline(ctx, rest[0], { frames: v.frames, out: v.out, seeds: v.seeds });
|
|
185
188
|
else if (command === "sim") await sim(ctx, v);
|
|
186
189
|
else if (command === "snap") await snap(ctx, v);
|
|
187
190
|
else if (command === "replay" && rest[0] === "record") await record(ctx, rest[1], v);
|
package/cli/native.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function sdlLibrary(platform: NativePlatform): string {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export function vendorFix(platform: NativePlatform): string {
|
|
49
|
-
return `dotframe vendor ${platform}
|
|
49
|
+
return `dotframe vendor ${platform} (builds into ~/.dotframe/vendor), or point DOTFRAME_VENDOR at an existing vendor dir such as a dotframe checkout's vendor/`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)(["'])([^"']+)\1/g;
|
package/package.json
CHANGED
|
@@ -29,6 +29,7 @@ dotframe config set targets.web.deploy.compose '"<compose id>"' # vps compose
|
|
|
29
29
|
dotframe deploy web --dry-run && dotframe deploy web --yes
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
- Images are pinned: bun from `packageManager` (game or repo root, else the local bun) and the relay's dotframe from the version installed in the game. Rerun `deploy init --yes` after bumping either.
|
|
32
33
|
- The web image installs the tools in `requires` (dotframe.json, for example `["ffmpeg"]`) and builds with dotframe; the relay image runs `dotframe relay serve`. The build context is the repo root, so monorepo ports see workspace code.
|
|
33
34
|
- In Dokploy, route the domain's `/` to service `web` (port 80) and `/relay` to service `relay` (port 8787). For Discord, map `/` and `/relay` to that host.
|
|
34
35
|
- Dokploy builds the pushed branch, not your working tree: deploy warns about unpushed or uncommitted work. On failure it returns the build log (through `vps compose logs`).
|
package/skill-data/ios/SKILL.md
CHANGED
|
@@ -6,6 +6,7 @@ description: Build and install the iOS target of a dotframe game. Use when build
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
dotframe vendor ios # once per machine: SDL3 (Xcode generator) and wgpu-native for iOS
|
|
9
|
+
# or reuse an existing vendor (a dotframe checkout's vendor/): export DOTFRAME_VENDOR=<path>
|
|
9
10
|
dotframe doctor # vendor:ios, ios-runtime (scriptc pack matching the compiler), team placeholder
|
|
10
11
|
dotframe build ios --json # dist/ios/<Scheme>.app
|
|
11
12
|
dotframe device install ios --dry-run
|
|
@@ -15,7 +15,7 @@ dotframe relay serve # local relay on :8787, same protocol as p
|
|
|
15
15
|
dotframe play --online --frames 900 # two browsers, scripted match, checksums compared, screenshots saved
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`play --online` needs the web entry to honor `?room=`, `?relay=` and `?mash=<seed>` and to publish `globalThis.__dotframe = { frame, confirmed, status, sums }` (sums: checksum per 30th confirmed frame). The templates show
|
|
18
|
+
`play --online` needs the web entry to honor `?room=`, `?relay=` and `?mash=<seed>` and to publish `globalThis.__dotframe = { frame, confirmed, status, sums }` (sums: checksum per 30th confirmed frame). `dotframe/src/probe` does both: `createProbe()` publishes it (call `probe.update(rollback, link.status())` each step, or `probe.update(null, "local", frame)` offline), and `createMasher(seed, randomInput)` returns a function giving the scripted input for each step. `--seeds 7,42` picks each peer's seed. The templates show the wiring.
|
|
19
19
|
|
|
20
20
|
dotframe online play is rollback netcode: each peer predicts the remote input (repeat the last one), simulates ahead, and when the real input arrives and differs, restores a snapshot and resimulates. It only works if every peer computes bit-identical state from the same inputs.
|
|
21
21
|
|
|
@@ -43,7 +43,7 @@ Run several seeds and a high latency (474 ms is what a transatlantic relay measu
|
|
|
43
43
|
|
|
44
44
|
## Rules
|
|
45
45
|
|
|
46
|
-
- Simulation code uses `dotframe/src/detmath` (dsin, dcos, datan2, dexp, dlog, dpow, ...), never `Math.sin` and friends or `**`: those differ in the last bits between engines and operating systems, so peers and replays drift (Craft Ones' replays diverged between macOS and Linux at frame 120). `dotframe doctor` warns about them (`sim:math`); mark render-only lines with `// dotframe-allow-math
|
|
46
|
+
- Simulation code uses `dotframe/src/detmath` (dsin, dcos, datan2, dexp, dlog, dpow, ...), never `Math.sin` and friends or `**`: those differ in the last bits between engines and operating systems, so peers and replays drift (Craft Ones' replays diverged between macOS and Linux at frame 120). `dotframe doctor` warns about them (`sim:math`); mark render-only lines with `// dotframe-allow-math`, or a whole render-only file with `// dotframe-allow-math-file` (the sim builds the renderer for desync, so render files count as reachable). A replay passing on one machine proves nothing about another: run CI on a second OS.
|
|
47
47
|
- `desync` reports `restoreMs` early vs late; it warns when late restores cost more than twice the early ones (a heuristic for restore that replays from an earlier frame, which passes desync but stalls real rollbacks late in a match).
|
|
48
48
|
- Build checksums from numbers in a fixed order, never from `JSON.stringify`: native builds can order keys differently from JavaScript, so a web peer and a native peer would report false desyncs.
|
|
49
49
|
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The state `dotframe play --online` reads from each browser: frame, confirmed frame, link status, and this peer's
|
|
2
|
+
// checksum at every 30th confirmed frame. Call update() once per fixed step; publish() exposes it as
|
|
3
|
+
// globalThis.__dotframe.
|
|
4
|
+
import type { Rollback } from "./netplay";
|
|
5
|
+
|
|
6
|
+
export interface ProbeState {
|
|
7
|
+
frame: number;
|
|
8
|
+
confirmed: number;
|
|
9
|
+
status: string;
|
|
10
|
+
sums: Record<number, number>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Probe {
|
|
14
|
+
state: ProbeState;
|
|
15
|
+
// Online: pass the rollback and the link status. Local: pass null and the frame just simulated.
|
|
16
|
+
update: (rollback: Rollback | null, status: string, localFrame?: number) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EVERY = 30;
|
|
20
|
+
|
|
21
|
+
export function createProbe(): Probe {
|
|
22
|
+
const state: ProbeState = { frame: 0, confirmed: 0, status: "local", sums: {} };
|
|
23
|
+
(globalThis as { __dotframe?: ProbeState }).__dotframe = state;
|
|
24
|
+
return {
|
|
25
|
+
state,
|
|
26
|
+
update: (rollback: Rollback | null, status: string, localFrame?: number): void => {
|
|
27
|
+
state.status = status;
|
|
28
|
+
if (!rollback) {
|
|
29
|
+
if (localFrame !== undefined) state.frame = localFrame;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
state.frame = rollback.stats().frame;
|
|
33
|
+
state.confirmed = rollback.confirmedFrame();
|
|
34
|
+
for (let f = EVERY; f < state.confirmed; f += EVERY) if (state.sums[f] === undefined) state.sums[f] = rollback.sumAt(f) ?? 0;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A scripted masher for tests: a new random input every 6 frames from a seed, like the CLI's --mash.
|
|
40
|
+
export function createMasher(seed: number, random: (next: () => number) => number): () => number {
|
|
41
|
+
let s = seed >>> 0;
|
|
42
|
+
const next = (): number => {
|
|
43
|
+
s = (Math.imul(s, 1103515245) + 12345) >>> 0;
|
|
44
|
+
return s / 4294967296;
|
|
45
|
+
};
|
|
46
|
+
let frame = 0;
|
|
47
|
+
let input = 0;
|
|
48
|
+
return (): number => {
|
|
49
|
+
if (frame % 6 === 0) input = random(next);
|
|
50
|
+
frame += 1;
|
|
51
|
+
return input;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { Frame, RenderGpu } from "dotframe/src/gpu";
|
|
|
6
6
|
import type { Input } from "dotframe/src/input";
|
|
7
7
|
import { Key, MouseButton } from "dotframe/src/input";
|
|
8
8
|
import { createRollback, type RelayLink, type Rollback } from "dotframe/src/netplay";
|
|
9
|
+
import { createMasher, createProbe } from "dotframe/src/probe";
|
|
9
10
|
import { checksum, createGame, type Game, keyInput, PLAYERS, POINTER_BIT, randomInput, render, restore, snapshot, step, WINDOW } from "./game";
|
|
10
11
|
|
|
11
12
|
export interface SetupOptions {
|
|
@@ -15,13 +16,6 @@ export interface SetupOptions {
|
|
|
15
16
|
mash: number | null;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
// What `dotframe play --online` reads from each browser.
|
|
19
|
-
interface Probe {
|
|
20
|
-
frame: number;
|
|
21
|
-
confirmed: number;
|
|
22
|
-
status: string;
|
|
23
|
-
sums: Record<number, number>;
|
|
24
|
-
}
|
|
25
19
|
|
|
26
20
|
const STEP = 1 / 60;
|
|
27
21
|
// A full charge takes one second of holding.
|
|
@@ -47,15 +41,10 @@ export function createSetup(options: SetupOptions): (platform: SetupPlatform) =>
|
|
|
47
41
|
// Online peers must start from the same state, so the seed is fixed there.
|
|
48
42
|
const game: Game = createGame(options.link ? 1 : Math.floor(Math.random() * 1e9));
|
|
49
43
|
let rollback: Rollback | null = null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
};
|
|
55
|
-
let mashed = 0;
|
|
56
|
-
let mashInput = 0;
|
|
57
|
-
const probe: Probe = { frame: 0, confirmed: 0, status: options.link ? "connecting" : "local", sums: {} };
|
|
58
|
-
(globalThis as { __dotframe?: Probe }).__dotframe = probe;
|
|
44
|
+
const mash = options.mash === null ? null : createMasher(options.mash, randomInput);
|
|
45
|
+
// What dotframe play --online reads from each browser.
|
|
46
|
+
const probe = createProbe();
|
|
47
|
+
let localFrame = 0;
|
|
59
48
|
let simulated = -1;
|
|
60
49
|
// Hold to charge, release to act: the pattern most mouse and touch games need.
|
|
61
50
|
let charge = 0;
|
|
@@ -77,15 +66,9 @@ export function createSetup(options: SetupOptions): (platform: SetupPlatform) =>
|
|
|
77
66
|
wasDown = down;
|
|
78
67
|
const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
|
|
79
68
|
if (released) keys[0] = keys[0] | POINTER_BIT;
|
|
80
|
-
if (
|
|
81
|
-
// A new random input every 6 frames, like a mashing player.
|
|
82
|
-
if (mashed % 6 === 0) mashInput = randomInput(nextRandom);
|
|
83
|
-
keys[0] = mashInput;
|
|
84
|
-
mashed += 1;
|
|
85
|
-
}
|
|
69
|
+
if (mash) keys[0] = mash();
|
|
86
70
|
const link = options.link;
|
|
87
71
|
if (link) {
|
|
88
|
-
probe.status = link.status();
|
|
89
72
|
if (!rollback && link.status() === "paired") {
|
|
90
73
|
rollback = createRollback({
|
|
91
74
|
sim: {
|
|
@@ -102,15 +85,12 @@ export function createSetup(options: SetupOptions): (platform: SetupPlatform) =>
|
|
|
102
85
|
});
|
|
103
86
|
}
|
|
104
87
|
// Online, each browser controls its own player with player 1's keys.
|
|
105
|
-
if (rollback)
|
|
106
|
-
|
|
107
|
-
probe.frame = rollback.stats().frame;
|
|
108
|
-
probe.confirmed = rollback.confirmedFrame();
|
|
109
|
-
for (let f = 30; f < probe.confirmed; f += 30) if (probe.sums[f] === undefined) probe.sums[f] = rollback.sumAt(f) ?? 0;
|
|
110
|
-
}
|
|
88
|
+
if (rollback) rollback.tick(keys[0]);
|
|
89
|
+
probe.update(rollback, link.status());
|
|
111
90
|
} else {
|
|
112
91
|
step(game, keys.slice(0, PLAYERS));
|
|
113
|
-
|
|
92
|
+
localFrame += 1;
|
|
93
|
+
probe.update(null, "local", localFrame);
|
|
114
94
|
}
|
|
115
95
|
simulated += STEP;
|
|
116
96
|
}
|