dotframe 0.1.8 → 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 CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 0.1.8
4
12
 
5
13
  From dogfood round 7 (Craft Ones on 0.1.7).
@@ -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
  }
@@ -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 + 1}`;
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=1|2.
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 (templates do).`,
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotframe",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "TS-first game engine and agent-first CLI: one codebase for web, Discord, iOS and native binaries",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 it.
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
 
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
- let mashState = options.mash ?? 0;
51
- const nextRandom = (): number => {
52
- mashState = (Math.imul(mashState, 1103515245) + 12345) >>> 0;
53
- return mashState / 4294967296;
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 (options.mash !== null) {
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
- rollback.tick(keys[0]);
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
- probe.frame += 1;
92
+ localFrame += 1;
93
+ probe.update(null, "local", localFrame);
114
94
  }
115
95
  simulated += STEP;
116
96
  }