@irtio/cli 0.1.0 → 0.3.0

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.
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createApiClient,
3
3
  isLoginRequired
4
- } from "./chunk-J3G6AUJY.js";
4
+ } from "./chunk-I37DLT7K.js";
5
5
  import {
6
6
  resolveControlUrlForUser
7
- } from "./chunk-UYN5PWLT.js";
7
+ } from "./chunk-NVUKSP5U.js";
8
8
 
9
9
  // src/logs.ts
10
10
  import { existsSync } from "fs";
@@ -28,6 +28,12 @@ function parseLogsArgs(args) {
28
28
  parsed.since = value;
29
29
  break;
30
30
  }
31
+ case "--room": {
32
+ const value = args[++i];
33
+ if (value === void 0) throw new Error("irtio logs: --room needs a value");
34
+ parsed.room = value;
35
+ break;
36
+ }
31
37
  case "--project": {
32
38
  const value = args[++i];
33
39
  if (value === void 0) throw new Error("irtio logs: --project needs a value");
@@ -63,7 +69,8 @@ async function runLogs(options) {
63
69
  let polls = 0;
64
70
  for (; ; ) {
65
71
  const rows = await client.get(`/v1/projects/${options.project}/logs`, {
66
- since
72
+ since,
73
+ ...options.room !== void 0 ? { room: options.room } : {}
67
74
  });
68
75
  for (const row of rows) log(formatRow(row));
69
76
  const last = rows[rows.length - 1];
@@ -101,6 +108,7 @@ async function logs(args, deps = {}) {
101
108
  follow: parsed.follow,
102
109
  log,
103
110
  ...parsed.since !== void 0 ? { since: parsed.since } : {},
111
+ ...parsed.room !== void 0 ? { room: parsed.room } : {},
104
112
  ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
105
113
  ...deps.client !== void 0 ? { client: deps.client } : {}
106
114
  });
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  bundleRoom
3
- } from "./chunk-ZWCLCYCS.js";
3
+ } from "./chunk-KRQUAEN2.js";
4
4
  import {
5
5
  createApiClient,
6
6
  isLoginRequired
7
- } from "./chunk-J3G6AUJY.js";
7
+ } from "./chunk-I37DLT7K.js";
8
8
  import {
9
9
  resolveControlUrlForUser
10
- } from "./chunk-UYN5PWLT.js";
10
+ } from "./chunk-NVUKSP5U.js";
11
11
 
12
12
  // src/migrate.ts
13
13
  import { existsSync } from "fs";
@@ -101,8 +101,10 @@ export function up(state: MigrationState, s: MigrationHelpers): MigrationState {
101
101
  return state;
102
102
  }
103
103
 
104
- // \`down\` is scaffolded and stored but unused until rollback ships \u2014 uncomment and fill it
105
- // in if you want it recorded now.
104
+ // \`down\` is scaffolded and stored, but nothing calls it. \`irtio rollback\` does NOT run it: it
105
+ // restores the pre-migration save generation the \`migrate\` deploy wrote, which is how it can
106
+ // undo a migration you never wrote a \`down\` for. Fill this in only if you want the inverse
107
+ // transform recorded next to the forward one.
106
108
  //
107
109
  // export function down(state: MigrationState, s: MigrationHelpers): MigrationState {
108
110
  // return state;
@@ -0,0 +1,122 @@
1
+ import {
2
+ createApiClient,
3
+ isLoginRequired
4
+ } from "./chunk-I37DLT7K.js";
5
+ import {
6
+ resolveControlUrlForUser
7
+ } from "./chunk-NVUKSP5U.js";
8
+
9
+ // src/rollback.ts
10
+ import { existsSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import * as path from "path";
13
+ import pc from "picocolors";
14
+ function parseRollbackArgs(args) {
15
+ const parsed = {};
16
+ for (let i = 0; i < args.length; i++) {
17
+ const arg = args[i];
18
+ switch (arg) {
19
+ case "--project": {
20
+ const value = args[++i];
21
+ if (value === void 0) throw new Error("irtio rollback: --project needs a value");
22
+ parsed.project = value;
23
+ break;
24
+ }
25
+ case "--url": {
26
+ const value = args[++i];
27
+ if (value === void 0) throw new Error("irtio rollback: --url needs a value");
28
+ parsed.url = value;
29
+ break;
30
+ }
31
+ default: {
32
+ if (parsed.version !== void 0) {
33
+ throw new Error(`irtio rollback: unknown option ${JSON.stringify(arg)}`);
34
+ }
35
+ const version = Number(arg);
36
+ if (!Number.isInteger(version) || version < 1) {
37
+ throw new Error("irtio rollback: <version> must be a positive integer");
38
+ }
39
+ parsed.version = version;
40
+ }
41
+ }
42
+ }
43
+ if (parsed.version === void 0) {
44
+ throw new Error("irtio rollback: a version is required \u2014 irtio rollback <version>");
45
+ }
46
+ return parsed;
47
+ }
48
+ async function runRollback(options) {
49
+ const log = options.log ?? ((line) => console.log(line));
50
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
51
+ const client = options.client ?? await createApiClient(controlUrl);
52
+ log(
53
+ `${pc.yellow("rolling back")} to v${options.version} \u2014 rooms that migrated past it ${pc.yellow("discard")} everything written since their migration`
54
+ );
55
+ const result = await client.post(`/v1/projects/${options.project}/rollback`, {
56
+ version: options.version
57
+ });
58
+ log(
59
+ pc.green(
60
+ `v${result.rolledBack.map((v) => `${v}`).join(", v")} rolled back; v${result.version} serves again`
61
+ )
62
+ );
63
+ for (const room of result.rooms) {
64
+ if (room.action === "restored") {
65
+ log(pc.dim(` ${room.roomId}: restores its pre-migration state on its next start`));
66
+ } else {
67
+ log(pc.yellow(` ${room.roomId}: untouched \u2014 ${room.reason ?? "no pre-migration state"}`));
68
+ }
69
+ }
70
+ if (result.rooms.length === 0) log(pc.dim(" no rooms on record for this project"));
71
+ if (result.applied === "stopped") {
72
+ log(pc.dim("the tenant was stopped; the next join brings rooms back on the older version"));
73
+ } else {
74
+ log(pc.dim("no tenant was running; the next placement serves the older version"));
75
+ }
76
+ return result;
77
+ }
78
+ async function readIrtioJsonProject(cwd) {
79
+ const file = path.join(cwd, "irtio.json");
80
+ if (!existsSync(file)) return void 0;
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(await readFile(file, "utf8"));
84
+ } catch {
85
+ throw new Error(`irtio rollback: ${file} is not valid JSON`);
86
+ }
87
+ return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
88
+ }
89
+ async function rollback(args, deps = {}) {
90
+ const log = deps.log ?? ((line) => console.log(line));
91
+ const errorLog = deps.errorLog ?? ((line) => console.error(line));
92
+ try {
93
+ const parsed = parseRollbackArgs(args);
94
+ const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
95
+ if (project === void 0) {
96
+ throw new Error(
97
+ "irtio rollback: no project id \u2014 pass --project or run this from a project with irtio.json"
98
+ );
99
+ }
100
+ await runRollback({
101
+ project,
102
+ version: parsed.version,
103
+ log,
104
+ ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
105
+ ...deps.client !== void 0 ? { client: deps.client } : {}
106
+ });
107
+ } catch (err) {
108
+ if (isLoginRequired(err)) {
109
+ errorLog(pc.red("not logged in"));
110
+ errorLog("run: irtio login");
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+ errorLog(pc.red(err instanceof Error ? err.message : String(err)));
115
+ process.exitCode = 1;
116
+ }
117
+ }
118
+ export {
119
+ parseRollbackArgs,
120
+ rollback,
121
+ runRollback
122
+ };
@@ -0,0 +1,199 @@
1
+ import {
2
+ createApiClient,
3
+ isLoginRequired
4
+ } from "./chunk-I37DLT7K.js";
5
+ import {
6
+ resolveControlUrlForUser
7
+ } from "./chunk-NVUKSP5U.js";
8
+
9
+ // src/rooms.ts
10
+ import { existsSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import * as path from "path";
13
+ import pc from "picocolors";
14
+ function parseRoomsArgs(args) {
15
+ const parsed = { sub: "list" };
16
+ let rest = args;
17
+ const first = args[0];
18
+ if (first === "saves" || first === "restore") {
19
+ parsed.sub = first;
20
+ const room = args[1];
21
+ if (room === void 0 || room.startsWith("-")) {
22
+ throw new Error(`irtio rooms ${first}: needs a room id`);
23
+ }
24
+ parsed.room = room;
25
+ rest = args.slice(2);
26
+ }
27
+ for (let i = 0; i < rest.length; i++) {
28
+ const arg = rest[i];
29
+ switch (arg) {
30
+ case "--save": {
31
+ const value = rest[++i];
32
+ if (value === void 0) throw new Error("irtio rooms: --save needs a value");
33
+ parsed.save = value;
34
+ break;
35
+ }
36
+ case "--project": {
37
+ const value = rest[++i];
38
+ if (value === void 0) throw new Error("irtio rooms: --project needs a value");
39
+ parsed.project = value;
40
+ break;
41
+ }
42
+ case "--url": {
43
+ const value = rest[++i];
44
+ if (value === void 0) throw new Error("irtio rooms: --url needs a value");
45
+ parsed.url = value;
46
+ break;
47
+ }
48
+ default:
49
+ throw new Error(`irtio rooms: unknown option ${JSON.stringify(arg)}`);
50
+ }
51
+ }
52
+ if (parsed.sub === "restore" && parsed.save === void 0) {
53
+ throw new Error(
54
+ "irtio rooms restore: --save <id> is required \u2014 run `irtio rooms saves <room>` to see them"
55
+ );
56
+ }
57
+ return parsed;
58
+ }
59
+ function formatBytes(bytes) {
60
+ if (bytes < 1024) return `${bytes} B`;
61
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
62
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
63
+ }
64
+ function formatAge(iso, now) {
65
+ const ms = now - Date.parse(iso);
66
+ if (!Number.isFinite(ms) || ms < 0) return "\u2014";
67
+ const mins = Math.floor(ms / 6e4);
68
+ if (mins < 1) return "just now";
69
+ if (mins < 60) return `${mins}m ago`;
70
+ const hours = Math.floor(mins / 60);
71
+ if (hours < 24) return `${hours}h ago`;
72
+ return `${Math.floor(hours / 24)}d ago`;
73
+ }
74
+ function statusColor(status) {
75
+ return status === "active" || status === "running" ? pc.green(status) : status === "draining" ? pc.yellow(status) : pc.dim(status);
76
+ }
77
+ function formatTable(rows) {
78
+ if (rows.length === 0) return [pc.dim("no rooms")];
79
+ const idWidth = Math.max(4, ...rows.map((r) => r.roomId.length));
80
+ const out = [pc.dim(`${"ROOM".padEnd(idWidth)} STATUS LAST SEEN`)];
81
+ for (const r of rows) {
82
+ out.push(
83
+ `${r.roomId.padEnd(idWidth)} ${statusColor(r.status).padEnd(10)} ${pc.dim(r.lastSeen)}`
84
+ );
85
+ }
86
+ return out;
87
+ }
88
+ function formatSaves(rows, now) {
89
+ if (rows.length === 0) {
90
+ return [pc.dim("no saves"), pc.dim("a room writes one when its code calls room.save()")];
91
+ }
92
+ const idWidth = Math.max(7, ...rows.map((r) => r.saveId.length));
93
+ const out = [pc.dim(`${"SAVE ID".padEnd(idWidth)} AGE SIZE DEPLOY`)];
94
+ for (const r of rows) {
95
+ out.push(
96
+ `${r.saveId.padEnd(idWidth)} ${formatAge(r.createdAt, now).padEnd(10)} ${formatBytes(r.bytes).padEnd(8)} ${pc.dim(`v${r.version}`)}`
97
+ );
98
+ }
99
+ return out;
100
+ }
101
+ async function runSaves(options) {
102
+ const log = options.log ?? ((line) => console.log(line));
103
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
104
+ const client = options.client ?? await createApiClient(controlUrl);
105
+ const rows = await client.get(
106
+ `/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}/saves`
107
+ );
108
+ for (const line of formatSaves(rows, options.now ?? Date.now())) log(line);
109
+ return rows;
110
+ }
111
+ async function runRestore(options) {
112
+ const log = options.log ?? ((line) => console.log(line));
113
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
114
+ const client = options.client ?? await createApiClient(controlUrl);
115
+ log(
116
+ `${pc.yellow("discarding")} the current state of room ${pc.bold(options.room)} and restoring save ${pc.bold(options.saveId)}`
117
+ );
118
+ const result = await client.post(
119
+ `/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}/restore`,
120
+ { saveId: options.saveId }
121
+ );
122
+ if (result.applied === "stopped") {
123
+ log(pc.green(`restored: ${options.room} was live, so its tenant was stopped`));
124
+ log(
125
+ pc.dim(
126
+ "the room comes back from the save on the next join; every other room in this project was stopped too and comes back from its own snapshot, unchanged"
127
+ )
128
+ );
129
+ } else {
130
+ log(pc.green(`queued: ${options.room} was not running`));
131
+ log(pc.dim("the restore is applied at the next placement \u2014 the next join starts it"));
132
+ }
133
+ return result;
134
+ }
135
+ async function runRooms(options) {
136
+ const log = options.log ?? ((line) => console.log(line));
137
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
138
+ const client = options.client ?? await createApiClient(controlUrl);
139
+ const rows = await client.get(`/v1/projects/${options.project}/rooms`);
140
+ for (const line of formatTable(rows)) log(line);
141
+ return rows;
142
+ }
143
+ async function readIrtioJsonProject(cwd) {
144
+ const file = path.join(cwd, "irtio.json");
145
+ if (!existsSync(file)) return void 0;
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(await readFile(file, "utf8"));
149
+ } catch {
150
+ throw new Error(`irtio rooms: ${file} is not valid JSON`);
151
+ }
152
+ return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
153
+ }
154
+ async function rooms(args, deps = {}) {
155
+ const log = deps.log ?? ((line) => console.log(line));
156
+ const errorLog = deps.errorLog ?? ((line) => console.error(line));
157
+ try {
158
+ const parsed = parseRoomsArgs(args);
159
+ const project = parsed.project ?? await readIrtioJsonProject(process.cwd());
160
+ if (project === void 0) {
161
+ throw new Error(
162
+ "irtio rooms: no project id \u2014 pass --project or run this from a project with irtio.json"
163
+ );
164
+ }
165
+ const common = {
166
+ project,
167
+ log,
168
+ ...parsed.url !== void 0 ? { controlUrl: parsed.url } : {},
169
+ ...deps.client !== void 0 ? { client: deps.client } : {}
170
+ };
171
+ if (parsed.sub === "saves") {
172
+ await runSaves({ ...common, room: parsed.room });
173
+ } else if (parsed.sub === "restore") {
174
+ await runRestore({
175
+ ...common,
176
+ room: parsed.room,
177
+ saveId: parsed.save
178
+ });
179
+ } else {
180
+ await runRooms(common);
181
+ }
182
+ } catch (err) {
183
+ if (isLoginRequired(err)) {
184
+ errorLog(pc.red("not logged in"));
185
+ errorLog("run: irtio login");
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ errorLog(pc.red(err instanceof Error ? err.message : String(err)));
190
+ process.exitCode = 1;
191
+ }
192
+ }
193
+ export {
194
+ parseRoomsArgs,
195
+ rooms,
196
+ runRestore,
197
+ runRooms,
198
+ runSaves
199
+ };
@@ -23,6 +23,9 @@ interface SimulateArgs {
23
23
  url?: string;
24
24
  key?: string;
25
25
  trace?: string;
26
+ mispredictionMax?: number;
27
+ snapsMax?: number;
28
+ correctionsMax?: number;
26
29
  }
27
30
  /** Hand-rolled, like `parseDevArgs`: the CLI has no argument-parsing dependency. */
28
31
  declare function parseSimulateArgs(args: readonly string[]): SimulateArgs;
@@ -40,10 +43,43 @@ declare function loadProjectSchema(options: LoadSchemaOptions): Promise<{
40
43
  schema: AnySchema;
41
44
  file: string;
42
45
  } | undefined>;
46
+ /** The shared world-builder's exports, as `joinRoom({ physics })` wants them. */
47
+ interface LoadedWorld {
48
+ readonly gravity: {
49
+ x: number;
50
+ y: number;
51
+ z: number;
52
+ };
53
+ readonly timestep?: number | undefined;
54
+ readonly setup?: ((world: unknown, rapier: unknown) => void) | undefined;
55
+ readonly bodies?: Record<string, (...args: never[]) => unknown> | undefined;
56
+ readonly intents?: Record<string, (...args: never[]) => void> | undefined;
57
+ readonly file: string;
58
+ }
59
+ /**
60
+ * Bundles and imports the project's shared world-builder module, so the bots predict physics the
61
+ * way a browser client would. The engine stays external — it must be the project's own copy,
62
+ * resolved at import time, exactly as a room bundle resolves it.
63
+ */
64
+ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWorld | undefined>;
65
+ /**
66
+ * The build-twice determinism check: builds two worlds from the shared `setup` and compares
67
+ * Rapier's own snapshots byte for byte. A builder that is not pure over synced inputs
68
+ * (`Math.random()`, a clock, module state) produces different worlds on the two sides, and every
69
+ * "misprediction" it causes would be blamed on netcode — catch it here instead. Returns an error
70
+ * description, or `undefined` when the worlds agree.
71
+ */
72
+ declare function checkWorldDeterminism(world: LoadedWorld): Promise<string | undefined>;
43
73
  interface RunSimulationOptions extends Partial<SimulateArgs> {
44
74
  /** Project root; defaults to `process.cwd()`. */
45
75
  readonly cwd?: string;
46
76
  readonly log?: (line: string) => void;
77
+ /**
78
+ * Correction-storm threshold override (per bot per second; `@irtio/bots` default 5). A
79
+ * physics room judges one correction opportunity per tick, so contact-heavy moments on slow
80
+ * hardware can burst past the cursor-tuned default without anything being wrong.
81
+ */
82
+ readonly correctionsPerSecMax?: number;
47
83
  /** @internal Test seam, same as `startDev`'s. */
48
84
  readonly irtioPackages?: Record<string, string> | undefined;
49
85
  }
@@ -52,6 +88,11 @@ interface SimulationRun {
52
88
  readonly tracePath: string;
53
89
  /** `false` when there was no schema module and the run fell back to a relay simulation. */
54
90
  readonly schema: boolean;
91
+ /** `true` when the bots predicted physics from a shared world-builder module (D22 part 2). */
92
+ readonly predicted: boolean;
93
+ /** Result of the build-twice world-builder check: the failure text, or `undefined` if it held
94
+ * (or did not apply). A failure also fails the run. */
95
+ readonly worldError?: string;
55
96
  /** Everything that was printed, in order — the same lines `log` received. */
56
97
  readonly output: readonly string[];
57
98
  }
@@ -59,4 +100,4 @@ interface SimulationRun {
59
100
  declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
60
101
  declare function simulate(args: readonly string[]): Promise<void>;
61
102
 
62
- export { type LoadSchemaOptions, type RunSimulationOptions, type SimulateArgs, type SimulationRun, loadProjectSchema, parseSimulateArgs, runSimulation, simulate };
103
+ export { type LoadSchemaOptions, type RunSimulationOptions, type SimulateArgs, type SimulationRun, checkWorldDeterminism, loadProjectSchema, loadProjectWorld, parseSimulateArgs, runSimulation, simulate };
package/dist/simulate.js CHANGED
@@ -10,6 +10,7 @@ var DEFAULT_URL = "ws://localhost:7070";
10
10
  var DEFAULT_BOTS = 5;
11
11
  var DEFAULT_SECONDS = 10;
12
12
  var SCHEMA_CANDIDATES = ["irtio/schema.ts", "irtio/schema.js", "schema.ts"];
13
+ var WORLD_CANDIDATES = ["irtio/world.ts", "irtio/world.js", "world.ts"];
13
14
  function positive(raw, flag) {
14
15
  const value = Number(raw);
15
16
  if (!Number.isFinite(value) || value <= 0) {
@@ -17,6 +18,13 @@ function positive(raw, flag) {
17
18
  }
18
19
  return value;
19
20
  }
21
+ function nonNegativeInt(raw, flag) {
22
+ const value = Number(raw);
23
+ if (!Number.isInteger(value) || value < 0) {
24
+ throw new Error(`irtio simulate: ${flag} must be a non-negative integer`);
25
+ }
26
+ return value;
27
+ }
20
28
  function parseSimulateArgs(args) {
21
29
  const parsed = { bots: DEFAULT_BOTS, seconds: DEFAULT_SECONDS, cheat: false };
22
30
  for (let i = 0; i < args.length; i++) {
@@ -55,6 +63,15 @@ function parseSimulateArgs(args) {
55
63
  case "--cheat":
56
64
  parsed.cheat = true;
57
65
  break;
66
+ case "--misprediction-max":
67
+ parsed.mispredictionMax = positive(value(), "--misprediction-max");
68
+ break;
69
+ case "--snaps-max":
70
+ parsed.snapsMax = nonNegativeInt(value(), "--snaps-max");
71
+ break;
72
+ case "--corrections-max":
73
+ parsed.correctionsMax = positive(value(), "--corrections-max");
74
+ break;
58
75
  default:
59
76
  throw new Error(`irtio simulate: unknown option ${JSON.stringify(arg)}`);
60
77
  }
@@ -71,6 +88,7 @@ function monorepoPackages() {
71
88
  "@irtio/server": path.join(packagesDir, "server/src/index.ts")
72
89
  };
73
90
  }
91
+ var importSeq = 0;
74
92
  function looksLikeSchema(value) {
75
93
  const candidate = value;
76
94
  return typeof candidate === "object" && candidate !== null && Array.isArray(candidate.collections) && candidate.hash8 instanceof Uint8Array;
@@ -91,7 +109,7 @@ async function loadProjectSchema(options) {
91
109
  outfile,
92
110
  ...alias !== void 0 ? { alias } : {}
93
111
  });
94
- const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}`);
112
+ const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}-${importSeq++}`);
95
113
  const schema = module.schema ?? module.default;
96
114
  if (!looksLikeSchema(schema)) {
97
115
  const names = Object.keys(module).join(", ") || "nothing";
@@ -103,6 +121,63 @@ async function loadProjectSchema(options) {
103
121
  }
104
122
  return { schema, file: entry };
105
123
  }
124
+ async function loadProjectWorld(options) {
125
+ const entry = WORLD_CANDIDATES.map((c) => path.resolve(options.cwd, c)).find(
126
+ (f) => existsSync(f)
127
+ );
128
+ if (entry === void 0) return void 0;
129
+ await mkdir(options.outDir, { recursive: true });
130
+ const outfile = path.join(options.outDir, "world.mjs");
131
+ const alias = options.irtioPackages ?? monorepoPackages();
132
+ await esbuild.build({
133
+ entryPoints: [entry],
134
+ bundle: true,
135
+ format: "esm",
136
+ platform: "node",
137
+ outfile,
138
+ external: ["@dimforge/rapier3d-compat"],
139
+ ...alias !== void 0 ? { alias } : {}
140
+ });
141
+ const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}-${importSeq++}`);
142
+ const gravity = module.gravity;
143
+ if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number" || typeof gravity.y !== "number" || typeof gravity.z !== "number") {
144
+ throw new Error(
145
+ `irtio simulate: ${entry} does not export a { x, y, z } gravity.
146
+ the shared world-builder must export the same gravity the room config uses
147
+ (this is what \`irtio init --physics\` writes)`
148
+ );
149
+ }
150
+ return {
151
+ gravity,
152
+ ...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
153
+ ...typeof module.setup === "function" ? { setup: module.setup } : {},
154
+ ...typeof module.bodies === "object" && module.bodies !== null ? { bodies: module.bodies } : {},
155
+ ...typeof module.intents === "object" && module.intents !== null ? { intents: module.intents } : {},
156
+ file: entry
157
+ };
158
+ }
159
+ async function checkWorldDeterminism(world) {
160
+ if (!world.setup) return void 0;
161
+ const { initPhysics } = await import("@irtio/runtime");
162
+ const rapier = await initPhysics();
163
+ const build2 = () => {
164
+ const w = new rapier.World({ ...world.gravity });
165
+ try {
166
+ world.setup(w, rapier);
167
+ return w.takeSnapshot();
168
+ } finally {
169
+ w.free();
170
+ }
171
+ };
172
+ const first = build2();
173
+ const second = build2();
174
+ if (first.length === second.length && everyByteEqual(first, second)) return void 0;
175
+ return `the world builder is not deterministic: two builds of setup() from ${world.file} disagree. It must be pure over synced inputs \u2014 no Math.random(), no clock, no module state; put seeds and level parameters in synced state instead.`;
176
+ }
177
+ function everyByteEqual(a, b) {
178
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
179
+ return true;
180
+ }
106
181
  function rate(bytesPerSecond) {
107
182
  return bytesPerSecond >= 1e3 ? `${(bytesPerSecond / 1e3).toFixed(1)} kB/s` : `${Math.round(bytesPerSecond)} B/s`;
108
183
  }
@@ -114,9 +189,11 @@ function formatReport(report, schemaLoaded, cheating) {
114
189
  }
115
190
  lines.push("");
116
191
  const convergence = report.convergenceLagMs === void 0 ? "convergence not sampled" : `convergence ${report.convergenceLagMs} ms (p50 of ${report.convergence?.samples ?? 0})`;
192
+ const { mispredictions, mispredictionMagnitude, mispredictionMax, snaps } = report.totals;
193
+ const misprediction = mispredictions === 0 ? "misprediction 0" : `misprediction mean ${(mispredictionMagnitude / mispredictions).toFixed(1)} max ${mispredictionMax.toFixed(1)}`;
117
194
  lines.push(
118
195
  pc.dim(
119
- ` ${report.framesPerSecond} frames/s \xB7 in ${rate(report.bytesInPerSecondPerBot)}/bot \xB7 out ${rate(report.bytesOutPerSecondPerBot)}/bot \xB7 ${report.totals.corrections} corrections \xB7 ${convergence}`
196
+ ` ${report.framesPerSecond} frames/s \xB7 in ${rate(report.bytesInPerSecondPerBot)}/bot \xB7 out ${rate(report.bytesOutPerSecondPerBot)}/bot \xB7 ${report.totals.corrections} corrections \xB7 ${misprediction} \xB7 ${snaps} snap(s) \xB7 ${convergence}`
120
197
  )
121
198
  );
122
199
  if (report.tracePath !== void 0) lines.push(pc.dim(` trace: ${report.tracePath}`));
@@ -161,16 +238,37 @@ async function runSimulation(options = {}) {
161
238
  outDir,
162
239
  irtioPackages: options.irtioPackages
163
240
  });
241
+ const hasPhysics = loaded !== void 0 && loaded.schema.collections.some(
242
+ (c) => c.physics !== void 0
243
+ );
244
+ const world = hasPhysics ? await loadProjectWorld({ cwd, outDir, irtioPackages: options.irtioPackages }) : void 0;
245
+ let worldError;
246
+ if (world) {
247
+ worldError = await checkWorldDeterminism(world);
248
+ }
164
249
  const common = {
165
250
  url,
166
251
  durationMs: seconds * 1e3,
167
252
  ...options.key !== void 0 ? { key: options.key } : {},
168
- ...options.room !== void 0 ? { room: options.room } : {}
253
+ ...options.room !== void 0 ? { room: options.room } : {},
254
+ ...options.correctionsPerSecMax !== void 0 ? { correctionsPerSecMax: options.correctionsPerSecMax } : options.correctionsMax !== void 0 ? { correctionsPerSecMax: options.correctionsMax } : {},
255
+ ...options.mispredictionMax !== void 0 ? { mispredictionMagnitudeMax: options.mispredictionMax } : {},
256
+ ...options.snapsMax !== void 0 ? { snapsMax: options.snapsMax } : {}
169
257
  };
258
+ const predicted = world !== void 0 && worldError === void 0;
170
259
  const runner = loaded ? await spawnBots(bots, {
171
260
  ...common,
172
261
  schema: loaded.schema,
173
- script: randomScript(loaded.schema, { cheat: options.cheat ?? false })
262
+ script: randomScript(loaded.schema, { cheat: options.cheat ?? false }),
263
+ ...predicted ? {
264
+ physics: {
265
+ gravity: world.gravity,
266
+ ...world.timestep !== void 0 ? { timestep: world.timestep } : {},
267
+ ...world.setup !== void 0 ? { setup: world.setup } : {},
268
+ ...world.bodies !== void 0 ? { bodies: world.bodies } : {},
269
+ ...world.intents !== void 0 ? { intents: world.intents } : {}
270
+ }
271
+ } : {}
174
272
  }) : await spawnBots(bots, { ...common, script: relayEchoScript() });
175
273
  log("");
176
274
  log(
@@ -190,10 +288,33 @@ async function runSimulation(options = {}) {
190
288
  for (const line of formatReport(report, loaded !== void 0, options.cheat ?? false)) {
191
289
  log(line);
192
290
  }
291
+ if (worldError !== void 0) {
292
+ log(pc.red(` FAIL world-builder: ${worldError}`));
293
+ } else if (predicted) {
294
+ const suppressed = report.totals.suppressedCorrections;
295
+ log(
296
+ pc.dim(
297
+ ` bots predicted physics from ${path.relative(cwd, world?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
298
+ )
299
+ );
300
+ } else if (hasPhysics) {
301
+ log(
302
+ pc.yellow(
303
+ " this schema has physics but no irtio/world.ts \u2014 bots interpolated instead of predicting, so body-field corrections count as body-sync, not misprediction.\n export { gravity, setup, bodies, intents } from irtio/world.ts to simulate prediction (what `irtio init --physics` writes)."
304
+ )
305
+ );
306
+ }
193
307
  for (const failure of runner.scriptErrors) {
194
308
  log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
195
309
  }
196
- return { report, tracePath, schema: loaded !== void 0, output };
310
+ return {
311
+ report,
312
+ tracePath,
313
+ schema: loaded !== void 0,
314
+ predicted,
315
+ ...worldError !== void 0 ? { worldError } : {},
316
+ output
317
+ };
197
318
  }
198
319
  async function simulate(args) {
199
320
  let parsed;
@@ -206,7 +327,7 @@ async function simulate(args) {
206
327
  }
207
328
  try {
208
329
  const run = await runSimulation(parsed);
209
- if (!run.report.ok) process.exitCode = 1;
330
+ if (!run.report.ok || run.worldError !== void 0) process.exitCode = 1;
210
331
  } catch (err) {
211
332
  console.error(pc.red(err instanceof Error ? err.message : String(err)));
212
333
  console.error(pc.dim("is `irtio dev` running? pass --url to point somewhere else."));
@@ -214,7 +335,9 @@ async function simulate(args) {
214
335
  }
215
336
  }
216
337
  export {
338
+ checkWorldDeterminism,
217
339
  loadProjectSchema,
340
+ loadProjectWorld,
218
341
  parseSimulateArgs,
219
342
  runSimulation,
220
343
  simulate