@irtio/cli 0.5.2 → 0.6.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.
Files changed (37) hide show
  1. package/dist/api.d.ts +52 -0
  2. package/dist/api.js +15 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/{chunk-GBNHBWES.js → chunk-3HQMVCYA.js} +10 -6
  5. package/dist/{chunk-32QTPKVT.js → chunk-DKWG7MGO.js} +1 -1
  6. package/dist/{chunk-KRQUAEN2.js → chunk-OTSFRVJN.js} +12 -6
  7. package/dist/chunk-RNAH5T4W.js +96 -0
  8. package/dist/chunk-RQSJZWQC.js +452 -0
  9. package/dist/chunk-UPHQM6NZ.js +72 -0
  10. package/dist/chunk-ZD4ND6X6.js +31 -0
  11. package/dist/chunk-ZK5JLUD4.js +94 -0
  12. package/dist/credentials.d.ts +61 -0
  13. package/dist/credentials.js +20 -0
  14. package/dist/delete-project-VENS2B44.js +118 -0
  15. package/dist/deploy.d.ts +149 -0
  16. package/dist/{deploy-3SABPL3T.js → deploy.js} +166 -44
  17. package/dist/{dev-QJOGXLKM.js → dev-QM26ONKS.js} +2957 -231
  18. package/dist/index.js +97 -25
  19. package/dist/init.d.ts +1 -1
  20. package/dist/init.js +20 -4
  21. package/dist/{keys-XBORZAPI.js → keys-JHLMEGRA.js} +8 -4
  22. package/dist/leaderboard-SYPSBPS3.js +352 -0
  23. package/dist/{login-3EXB4CGX.js → login-2M73HBZT.js} +12 -5
  24. package/dist/{logs-2EOXLNWF.js → logs-2W7CPZO5.js} +9 -5
  25. package/dist/{migrate-WUO2GBMX.js → migrate-T3DZJREY.js} +12 -8
  26. package/dist/ratings-VG32WFDG.js +297 -0
  27. package/dist/{rollback-GA6UY772.js → rollback-SO74MVZV.js} +9 -5
  28. package/dist/{rooms-B66LQIIF.js → rooms-VI33P4RA.js} +36 -10
  29. package/dist/simulate.d.ts +147 -4
  30. package/dist/simulate.js +680 -53
  31. package/dist/{static-deploy-5TBH4VNA.js → static-deploy-KOWFKWZA.js} +6 -4
  32. package/dist/status-HF3ZEKB7.js +219 -0
  33. package/dist/usage-4G23QXCH.js +213 -0
  34. package/dist/{whoami-CI5D5RCC.js → whoami-KTMTQNHM.js} +8 -4
  35. package/package.json +23 -7
  36. package/dist/chunk-BPE452KF.js +0 -180
  37. package/dist/chunk-TV66QHFP.js +0 -167
package/dist/simulate.js CHANGED
@@ -1,18 +1,204 @@
1
+ import {
2
+ formatProfileTable
3
+ } from "./chunk-ZK5JLUD4.js";
1
4
  import {
2
5
  HelpRequested,
3
6
  helpFor,
4
7
  helpRequested,
5
8
  trailerFor
6
- } from "./chunk-TV66QHFP.js";
9
+ } from "./chunk-ZD4ND6X6.js";
10
+ import "./chunk-RNAH5T4W.js";
11
+ import "./chunk-UPHQM6NZ.js";
7
12
 
8
13
  // src/simulate.ts
14
+ import { existsSync as existsSync2 } from "fs";
15
+ import { mkdir as mkdir2, writeFile } from "fs/promises";
16
+ import * as path2 from "path";
17
+ import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "url";
18
+ import {
19
+ captureClientState,
20
+ cheatPredicate,
21
+ correlateShots,
22
+ describeConditions,
23
+ diffAgainstSave,
24
+ hasConditions,
25
+ makeTimeline,
26
+ randomScript,
27
+ relayEchoScript,
28
+ spawnBots
29
+ } from "@irtio/bots";
30
+ import { decodeSave } from "@irtio/runtime";
31
+ import * as esbuild2 from "esbuild";
32
+ import pc from "picocolors";
33
+
34
+ // src/scenario.ts
9
35
  import { existsSync } from "fs";
10
36
  import { mkdir } from "fs/promises";
11
37
  import * as path from "path";
12
38
  import { fileURLToPath, pathToFileURL } from "url";
13
- import { randomScript, relayEchoScript, spawnBots } from "@irtio/bots";
39
+ import { looksLikeScenario } from "@irtio/bots";
14
40
  import * as esbuild from "esbuild";
15
- import pc from "picocolors";
41
+ var TIMELINE_TIMEOUT_MS = 15e3;
42
+ var ScenarioNotRunError = class extends Error {
43
+ constructor(message, cause) {
44
+ super(message);
45
+ this.cause = cause;
46
+ }
47
+ cause;
48
+ name = "ScenarioNotRunError";
49
+ };
50
+ var importSeq = 0;
51
+ function monorepoScenarioPackages() {
52
+ const packagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
53
+ const out = {};
54
+ for (const name of ["bots", "client", "schema", "protocol", "server", "runtime"]) {
55
+ const src = path.join(packagesDir, name, "src/index.ts");
56
+ if (existsSync(src)) out[`@irtio/${name}`] = src;
57
+ }
58
+ return out;
59
+ }
60
+ async function loadScenario(options) {
61
+ const entry = path.resolve(options.cwd, options.file);
62
+ if (!existsSync(entry)) {
63
+ throw new ScenarioNotRunError(
64
+ `irtio simulate: no scenario file at ${entry}.
65
+ --scenario takes a path to a module whose default export is defineScenario({ ... })`
66
+ );
67
+ }
68
+ await mkdir(options.outDir, { recursive: true });
69
+ const outfile = path.join(options.outDir, "scenario.mjs");
70
+ const alias = { ...monorepoScenarioPackages(), ...options.irtioPackages ?? {} };
71
+ try {
72
+ await esbuild.build({
73
+ entryPoints: [entry],
74
+ bundle: true,
75
+ format: "esm",
76
+ platform: "node",
77
+ outfile,
78
+ // The physics engine is resolved at runtime by whoever needs it, never bundled twice.
79
+ external: ["@dimforge/rapier3d-compat"],
80
+ ...Object.keys(alias).length > 0 ? { alias } : {}
81
+ });
82
+ } catch (err) {
83
+ throw new ScenarioNotRunError(
84
+ `irtio simulate: ${path.relative(options.cwd, entry)} did not compile.
85
+ ` + (err instanceof Error ? err.message : String(err)),
86
+ err
87
+ );
88
+ }
89
+ let module;
90
+ try {
91
+ module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}-${importSeq++}`);
92
+ } catch (err) {
93
+ throw new ScenarioNotRunError(
94
+ `irtio simulate: ${path.relative(options.cwd, entry)} threw while loading.
95
+ ` + (err instanceof Error ? err.message : String(err)),
96
+ err
97
+ );
98
+ }
99
+ const scenario = module.default ?? module.scenario;
100
+ if (!looksLikeScenario(scenario)) {
101
+ const names = Object.keys(module).join(", ") || "nothing";
102
+ throw new ScenarioNotRunError(
103
+ `irtio simulate: ${path.relative(options.cwd, entry)} does not export a scenario.
104
+ it exports: ${names}
105
+ add \`export default defineScenario({ bots, script, assert })\` from \`@irtio/bots\``
106
+ );
107
+ }
108
+ return { scenario, file: entry };
109
+ }
110
+ function inspectorUrlFor(wsUrl, route) {
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(wsUrl);
114
+ } catch {
115
+ return void 0;
116
+ }
117
+ if (parsed.protocol === "ws:") parsed.protocol = "http:";
118
+ else if (parsed.protocol === "wss:") parsed.protocol = "https:";
119
+ else if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
120
+ parsed.pathname = `/__irt/${route}`;
121
+ parsed.search = "";
122
+ return parsed.toString();
123
+ }
124
+ function noTap(wsUrl, what) {
125
+ return new ScenarioNotRunError(
126
+ `irtio simulate: could not ${what} at ${wsUrl}.
127
+ Scenarios read the authoritative timeline, which only \`irtio dev\` exposes. Start the
128
+ room with \`irtio dev\` and point --url at it. A deployed tenant has no timeline tap yet.`
129
+ );
130
+ }
131
+ async function startTimeline(wsUrl, roomId, caps = {}) {
132
+ const base = inspectorUrlFor(wsUrl, "record.json");
133
+ if (base === void 0) throw noTap(wsUrl, "derive an inspector URL");
134
+ const url = new URL(base);
135
+ url.searchParams.set("room", roomId);
136
+ if (caps.maxTicks !== void 0) url.searchParams.set("maxTicks", String(caps.maxTicks));
137
+ if (caps.maxRecords !== void 0) url.searchParams.set("maxRecords", String(caps.maxRecords));
138
+ let response;
139
+ try {
140
+ response = await fetch(url, { signal: AbortSignal.timeout(TIMELINE_TIMEOUT_MS) });
141
+ } catch (err) {
142
+ throw noTap(
143
+ wsUrl,
144
+ `reach the timeline route (${err instanceof Error ? err.message : String(err)})`
145
+ );
146
+ }
147
+ if (!response.ok) {
148
+ throw noTap(wsUrl, `start recording room ${roomId} (the server answered ${response.status})`);
149
+ }
150
+ }
151
+ async function readTimeline(wsUrl, roomId) {
152
+ const base = inspectorUrlFor(wsUrl, "timeline.json");
153
+ if (base === void 0) throw noTap(wsUrl, "derive an inspector URL");
154
+ const url = new URL(base);
155
+ url.searchParams.set("room", roomId);
156
+ let response;
157
+ try {
158
+ response = await fetch(url, { signal: AbortSignal.timeout(TIMELINE_TIMEOUT_MS) });
159
+ } catch (err) {
160
+ throw noTap(wsUrl, `read the timeline (${err instanceof Error ? err.message : String(err)})`);
161
+ }
162
+ if (!response.ok) {
163
+ throw noTap(
164
+ wsUrl,
165
+ `read the timeline for room ${roomId} (the server answered ${response.status})`
166
+ );
167
+ }
168
+ const dump = await response.json();
169
+ if (!Array.isArray(dump?.frames)) {
170
+ throw noTap(wsUrl, `read the timeline for room ${roomId} (the answer had no frames)`);
171
+ }
172
+ return dump;
173
+ }
174
+ async function fetchSave(wsUrl, roomId) {
175
+ const base = inspectorUrlFor(wsUrl, "save.json");
176
+ if (base === void 0) {
177
+ return { error: `no inspector URL can be derived from ${wsUrl}` };
178
+ }
179
+ const url = new URL(base);
180
+ url.searchParams.set("room", roomId);
181
+ let response;
182
+ try {
183
+ response = await fetch(url, { signal: AbortSignal.timeout(TIMELINE_TIMEOUT_MS) });
184
+ } catch (err) {
185
+ return {
186
+ error: `the save route at ${wsUrl} could not be reached (${err instanceof Error ? err.message : String(err)}). Only \`irtio dev\` serves one.`
187
+ };
188
+ }
189
+ if (!response.ok) {
190
+ return { error: `the server answered ${response.status} when asked to save room ${roomId}` };
191
+ }
192
+ const body = await response.json();
193
+ if (typeof body.saveId !== "string" || typeof body.bytes !== "string") {
194
+ return { error: `the save route answered without bytes for room ${roomId}` };
195
+ }
196
+ return {
197
+ save: { saveId: body.saveId, bytes: new Uint8Array(Buffer.from(body.bytes, "base64")) }
198
+ };
199
+ }
200
+
201
+ // src/simulate.ts
16
202
  var DEFAULT_URL = "ws://localhost:7070";
17
203
  var DEFAULT_BOTS = 5;
18
204
  var DEFAULT_SECONDS = 10;
@@ -32,6 +218,36 @@ function nonNegativeInt(raw, flag) {
32
218
  }
33
219
  return value;
34
220
  }
221
+ var CONDITION_KEYS = ["rttMs", "jitterMs", "loss", "duplicate", "reorder", "reorderMs"];
222
+ function parseConditions(raw, flag) {
223
+ let value;
224
+ try {
225
+ value = JSON.parse(raw);
226
+ } catch {
227
+ throw new Error(
228
+ `irtio simulate: ${flag} needs a JSON object, for example '{"rttMs":200,"loss":0.02}'. Got ${JSON.stringify(raw)}`
229
+ );
230
+ }
231
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
232
+ throw new Error(`irtio simulate: ${flag} needs a JSON object, got ${JSON.stringify(raw)}`);
233
+ }
234
+ const out = {};
235
+ for (const [key, v] of Object.entries(value)) {
236
+ if (!CONDITION_KEYS.includes(key)) {
237
+ throw new Error(
238
+ `irtio simulate: ${flag} has no key "${key}". It takes ${CONDITION_KEYS.join(", ")}.`
239
+ );
240
+ }
241
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
242
+ throw new Error(`irtio simulate: ${flag} key "${key}" must be a non-negative number`);
243
+ }
244
+ if ((key === "loss" || key === "duplicate" || key === "reorder") && v > 1) {
245
+ throw new Error(`irtio simulate: ${flag} key "${key}" is a probability, so it is 0 to 1`);
246
+ }
247
+ out[key] = v;
248
+ }
249
+ return out;
250
+ }
35
251
  var USAGE = `usage: irtio simulate [options]
36
252
 
37
253
  Drives N real clients at a running room and checks the built-in invariants. Run it from a project
@@ -44,13 +260,25 @@ options:
44
260
  --url <ws://...> where to connect (default ${DEFAULT_URL})
45
261
  --key <projectKey> the project key to present
46
262
  --trace <path> write the frame trace here
47
- --cheat send illegal writes and expect corrections
263
+ --scenario <file> run a scenario module and assert against the recorded timeline
264
+ --cheat send illegal writes and expect corrections, from every bot
265
+ --cheat-bot <index> only this bot cheats (repeatable)
266
+ --conditions <json> inject network conditions into every bot, e.g.
267
+ '{"rttMs":200,"jitterMs":20,"loss":0.02}'. Keys: rttMs, jitterMs,
268
+ loss, duplicate, reorder, reorderMs. Loss, duplicate and reorder
269
+ touch state frames only, so a join always completes
270
+ --conditions-bot <i>:<json> conditions for one bot, overriding --conditions (repeatable)
271
+ --truth save the room at the end and diff it against what each client
272
+ received, within that client's visibility
48
273
  --misprediction-max <units> fail if one correction snaps a prediction further than this
49
274
  --snaps-max <n> fail above this many cap-exceeded reconciliations
50
275
  --corrections-max <perSec> fail above this correction rate per bot
51
276
  --overruns-max <n> fail above this many server tick overruns in the run window
52
277
  (default 0; the count is read from the server, and the run says
53
278
  so when it could not be read)
279
+ --profile print where the run's bytes went, by collection and field, as the
280
+ bots saw them
281
+ --profile-top <n> rows in that table (default 12; implies --profile)
54
282
  -h, --help print this
55
283
 
56
284
  exit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be
@@ -92,9 +320,48 @@ function parseSimulateArgs(args) {
92
320
  case "--trace":
93
321
  parsed.trace = value();
94
322
  break;
323
+ case "--scenario":
324
+ parsed.scenario = value();
325
+ break;
95
326
  case "--cheat":
96
327
  parsed.cheat = true;
97
328
  break;
329
+ case "--cheat-bot":
330
+ (parsed.cheatBots ??= []).push(nonNegativeInt(value(), "--cheat-bot"));
331
+ break;
332
+ case "--conditions":
333
+ parsed.conditions = parseConditions(value(), "--conditions");
334
+ break;
335
+ case "--conditions-bot": {
336
+ const raw = value();
337
+ const colon = raw.indexOf(":");
338
+ if (colon === -1) {
339
+ throw new Error(
340
+ `irtio simulate: --conditions-bot takes <index>:<json>, for example 0:{"rttMs":200}. Got ${JSON.stringify(raw)}`
341
+ );
342
+ }
343
+ const index = nonNegativeInt(raw.slice(0, colon), "--conditions-bot");
344
+ (parsed.conditionsPerBot ??= {})[index] = parseConditions(
345
+ raw.slice(colon + 1),
346
+ "--conditions-bot"
347
+ );
348
+ break;
349
+ }
350
+ case "--truth":
351
+ parsed.truth = true;
352
+ break;
353
+ case "--profile":
354
+ parsed.profile = true;
355
+ break;
356
+ case "--profile-top": {
357
+ const n = Number(value());
358
+ if (!Number.isInteger(n) || n < 1 || n > 200) {
359
+ throw new Error("irtio simulate: --profile-top must be a row count between 1 and 200");
360
+ }
361
+ parsed.profile = true;
362
+ parsed.profileTop = n;
363
+ break;
364
+ }
98
365
  case "--misprediction-max":
99
366
  parsed.mispredictionMax = positive(value(), "--misprediction-max");
100
367
  break;
@@ -114,29 +381,29 @@ function parseSimulateArgs(args) {
114
381
  return parsed;
115
382
  }
116
383
  function monorepoPackages() {
117
- const packagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
118
- const schema = path.join(packagesDir, "schema/src/index.ts");
119
- if (!existsSync(schema)) return void 0;
384
+ const packagesDir = path2.resolve(path2.dirname(fileURLToPath2(import.meta.url)), "../..");
385
+ const schema = path2.join(packagesDir, "schema/src/index.ts");
386
+ if (!existsSync2(schema)) return void 0;
120
387
  return {
121
388
  "@irtio/schema": schema,
122
- "@irtio/protocol": path.join(packagesDir, "protocol/src/index.ts"),
123
- "@irtio/server": path.join(packagesDir, "server/src/index.ts")
389
+ "@irtio/protocol": path2.join(packagesDir, "protocol/src/index.ts"),
390
+ "@irtio/server": path2.join(packagesDir, "server/src/index.ts")
124
391
  };
125
392
  }
126
- var importSeq = 0;
393
+ var importSeq2 = 0;
127
394
  function looksLikeSchema(value) {
128
395
  const candidate = value;
129
396
  return typeof candidate === "object" && candidate !== null && Array.isArray(candidate.collections) && candidate.hash8 instanceof Uint8Array;
130
397
  }
131
398
  async function loadProjectSchema(options) {
132
- const entry = SCHEMA_CANDIDATES.map((c) => path.resolve(options.cwd, c)).find(
133
- (f) => existsSync(f)
399
+ const entry = SCHEMA_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
400
+ (f) => existsSync2(f)
134
401
  );
135
402
  if (entry === void 0) return void 0;
136
- await mkdir(options.outDir, { recursive: true });
137
- const outfile = path.join(options.outDir, "schema.mjs");
403
+ await mkdir2(options.outDir, { recursive: true });
404
+ const outfile = path2.join(options.outDir, "schema.mjs");
138
405
  const alias = options.irtioPackages ?? monorepoPackages();
139
- await esbuild.build({
406
+ await esbuild2.build({
140
407
  entryPoints: [entry],
141
408
  bundle: true,
142
409
  format: "esm",
@@ -144,7 +411,7 @@ async function loadProjectSchema(options) {
144
411
  outfile,
145
412
  ...alias !== void 0 ? { alias } : {}
146
413
  });
147
- const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}-${importSeq++}`);
414
+ const module = await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
148
415
  const schema = module.schema ?? module.default;
149
416
  if (!looksLikeSchema(schema)) {
150
417
  const names = Object.keys(module).join(", ") || "nothing";
@@ -156,15 +423,18 @@ async function loadProjectSchema(options) {
156
423
  }
157
424
  return { schema, file: entry };
158
425
  }
426
+ function isUnpredictedWorld(world) {
427
+ return world !== void 0 && "unpredicted" in world;
428
+ }
159
429
  async function loadProjectWorld(options) {
160
- const entry = WORLD_CANDIDATES.map((c) => path.resolve(options.cwd, c)).find(
161
- (f) => existsSync(f)
430
+ const entry = WORLD_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
431
+ (f) => existsSync2(f)
162
432
  );
163
433
  if (entry === void 0) return void 0;
164
- await mkdir(options.outDir, { recursive: true });
165
- const outfile = path.join(options.outDir, "world.mjs");
434
+ await mkdir2(options.outDir, { recursive: true });
435
+ const outfile = path2.join(options.outDir, "world.mjs");
166
436
  const alias = options.irtioPackages ?? monorepoPackages();
167
- await esbuild.build({
437
+ await esbuild2.build({
168
438
  entryPoints: [entry],
169
439
  bundle: true,
170
440
  format: "esm",
@@ -173,9 +443,19 @@ async function loadProjectWorld(options) {
173
443
  external: ["@dimforge/rapier3d-compat"],
174
444
  ...alias !== void 0 ? { alias } : {}
175
445
  });
176
- const module = await import(`${pathToFileURL(outfile).href}?v=${Date.now()}-${importSeq++}`);
446
+ const module = await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
177
447
  const gravity = module.gravity;
178
- if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number" || typeof gravity.y !== "number" || typeof gravity.z !== "number") {
448
+ if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number") {
449
+ throw new Error(
450
+ `irtio simulate: ${entry} does not export a { x, y, z } gravity.
451
+ the shared world-builder must export the same gravity the room config uses
452
+ (this is what \`irtio init --physics\` writes)`
453
+ );
454
+ }
455
+ if (typeof gravity.z !== "number") {
456
+ return { unpredicted: "matter2d", file: entry };
457
+ }
458
+ if (typeof gravity.y !== "number") {
179
459
  throw new Error(
180
460
  `irtio simulate: ${entry} does not export a { x, y, z } gravity.
181
461
  the shared world-builder must export the same gravity the room config uses
@@ -195,7 +475,7 @@ async function checkWorldDeterminism(world) {
195
475
  if (!world.setup) return void 0;
196
476
  const { initPhysics } = await import("@irtio/runtime");
197
477
  const rapier = await initPhysics();
198
- const build2 = () => {
478
+ const build3 = () => {
199
479
  const w = new rapier.World({ ...world.gravity });
200
480
  try {
201
481
  world.setup(w, rapier);
@@ -204,8 +484,8 @@ async function checkWorldDeterminism(world) {
204
484
  w.free();
205
485
  }
206
486
  };
207
- const first = build2();
208
- const second = build2();
487
+ const first = build3();
488
+ const second = build3();
209
489
  if (first.length === second.length && everyByteEqual(first, second)) return void 0;
210
490
  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.`;
211
491
  }
@@ -228,6 +508,51 @@ function stateUrlFor(wsUrl) {
228
508
  parsed.search = "";
229
509
  return parsed.toString();
230
510
  }
511
+ async function readBuildIdentity(wsUrl) {
512
+ const stateUrl = stateUrlFor(wsUrl);
513
+ if (stateUrl === void 0) {
514
+ return { unavailable: `cannot derive an inspector URL from ${wsUrl}` };
515
+ }
516
+ let body;
517
+ try {
518
+ const response = await fetch(stateUrl, {
519
+ signal: AbortSignal.timeout(TICK_HEALTH_TIMEOUT_MS)
520
+ });
521
+ if (!response.ok) {
522
+ return {
523
+ unavailable: `${stateUrl} answered ${response.status}` + (response.status === 404 ? ": only `irtio dev` serves the inspector, so a deployed tenant cannot name its build here" : "")
524
+ };
525
+ }
526
+ body = await response.json();
527
+ } catch (err) {
528
+ return {
529
+ unavailable: `could not reach ${stateUrl} (${err instanceof Error ? err.message : String(err)})`
530
+ };
531
+ }
532
+ const state = body;
533
+ const bundleHash = typeof state?.bundle?.hash === "string" ? state.bundle.hash : void 0;
534
+ const schemaHash = typeof state?.bundle?.schemaHash === "string" ? state.bundle.schemaHash : void 0;
535
+ const startedAt = typeof state?.startedAt === "number" ? state.startedAt : void 0;
536
+ if (bundleHash === void 0 && startedAt === void 0) {
537
+ return { unavailable: `${stateUrl} named no bundle and no start time`, source: stateUrl };
538
+ }
539
+ return {
540
+ ...bundleHash !== void 0 ? { bundleHash } : {},
541
+ ...schemaHash !== void 0 ? { schemaHash } : {},
542
+ ...startedAt !== void 0 ? { startedAt } : {},
543
+ source: stateUrl
544
+ };
545
+ }
546
+ function formatBuildIdentity(build3) {
547
+ if (build3.unavailable !== void 0) return `build unknown: ${build3.unavailable}`;
548
+ const parts = [];
549
+ parts.push(`bundle ${build3.bundleHash ?? "unknown"}`);
550
+ if (build3.schemaHash !== void 0) parts.push(`schema ${build3.schemaHash}`);
551
+ parts.push(
552
+ build3.startedAt !== void 0 ? `server up since ${new Date(build3.startedAt).toISOString()}` : "server start time unknown"
553
+ );
554
+ return parts.join(" \xB7 ");
555
+ }
231
556
  async function readTickCounters(wsUrl, roomId) {
232
557
  const stateUrl = stateUrlFor(wsUrl);
233
558
  if (stateUrl === void 0) {
@@ -280,6 +605,13 @@ function tickHealthFrom(before, after) {
280
605
  };
281
606
  }
282
607
  var STOP_GRACE_MS = 5e3;
608
+ var TRUTH_SETTLE_MS = 400;
609
+ function delay(ms) {
610
+ return new Promise((resolve3) => {
611
+ const timer = setTimeout(resolve3, ms);
612
+ timer.unref?.();
613
+ });
614
+ }
283
615
  function ceilingFor(seconds) {
284
616
  return seconds * 3e3 + 3e4;
285
617
  }
@@ -294,7 +626,18 @@ var RunNotPerformedError = class extends Error {
294
626
  function rate(bytesPerSecond) {
295
627
  return bytesPerSecond >= 1e3 ? `${(bytesPerSecond / 1e3).toFixed(1)} kB/s` : `${Math.round(bytesPerSecond)} B/s`;
296
628
  }
297
- function formatReport(report, schemaLoaded, cheating) {
629
+ function formatRunProfile(report, top) {
630
+ const profile = report.profile;
631
+ if (!profile) return [];
632
+ const seconds = Math.max(1e-3, report.durationMs / 1e3);
633
+ const bots = Math.max(1, report.bots);
634
+ return [
635
+ `${pc.bold("bandwidth")} ${pc.dim("as seen by the bots, per bot per second")}`,
636
+ ...formatProfileTable(profile, { seconds, per: bots, ...top !== void 0 ? { top } : {} }),
637
+ ""
638
+ ];
639
+ }
640
+ function formatReport(report, schemaLoaded) {
298
641
  const lines = [];
299
642
  for (const invariant of report.invariants) {
300
643
  const mark = invariant.state === "unavailable" ? pc.yellow("n/a ") : invariant.ok ? pc.green("ok ") : pc.red("FAIL");
@@ -324,55 +667,221 @@ function formatReport(report, schemaLoaded, cheating) {
324
667
  pc.red(` ${failed.length} invariant(s) failed: ${failed.map((i) => i.name).join(", ")}`)
325
668
  );
326
669
  }
327
- if (cheating && report.totals.corrections === 0) {
670
+ if (!schemaLoaded) {
328
671
  lines.push(
329
672
  pc.yellow(
330
- " --cheat drew no corrections: the room accepted every illegal write.\n add rules to `validate` in irtio/room.ts (reject by returning `prev`, or clamp)."
673
+ " no irtio/schema.ts here, so this was a schema-less relay run: presence and messages only.\n run it from a project directory to simulate your room."
331
674
  )
332
675
  );
333
676
  }
334
- if (!schemaLoaded) {
677
+ return lines;
678
+ }
679
+ function formatScenario(run, cwd) {
680
+ const lines = [""];
681
+ lines.push(
682
+ ` ${pc.bold("scenario")} ${pc.dim(path2.relative(cwd, run.file))} ` + pc.dim(`(${run.ticks} recorded tick(s))`)
683
+ );
684
+ if (run.assertions.length === 0) {
335
685
  lines.push(
336
686
  pc.yellow(
337
- " no irtio/schema.ts here, so this was a schema-less relay run: presence and messages only.\n run it from a project directory to simulate your room."
687
+ " the scenario ran no assertions, so it proved nothing. Call timeline.check(name, fn), or throw from assert."
688
+ )
689
+ );
690
+ }
691
+ for (const assertion of run.assertions) {
692
+ const mark = assertion.ok ? pc.green("ok ") : pc.red("FAIL");
693
+ const where = assertion.tick !== void 0 ? `tick ${assertion.tick}: ` : "";
694
+ lines.push(` ${mark} ${assertion.name.padEnd(17)} ${pc.dim(`${where}${assertion.detail}`)}`);
695
+ }
696
+ if (run.dropped > 0) {
697
+ lines.push(
698
+ pc.yellow(
699
+ ` the recorder dropped ${run.dropped} tick(s) at its cap, so this recording is a tail. Shorten the run or raise the cap before trusting a pass.`
338
700
  )
339
701
  );
340
702
  }
703
+ lines.push(pc.dim(` timeline: ${run.timelinePath}`));
704
+ const failed = run.assertions.filter((a) => !a.ok);
705
+ lines.push(
706
+ failed.length === 0 && run.assertions.length > 0 ? pc.green(` every scenario assertion held (${run.assertions.length}).`) : pc.red(` ${failed.length} scenario assertion(s) failed.`)
707
+ );
708
+ return lines;
709
+ }
710
+ function formatAdversarial(run) {
711
+ const lines = ["", ` ${pc.bold("adversarial")}`];
712
+ for (const entry of run.conditions) {
713
+ if (entry.conditions === void 0) continue;
714
+ const c = entry.counters;
715
+ const did = `dropped ${c.droppedOut} out / ${c.droppedIn} in, duplicated ${c.duplicatedOut}/${c.duplicatedIn}, reordered ${c.reorderedOut}/${c.reorderedIn}`;
716
+ lines.push(
717
+ ` ${pc.dim("bot")} ${String(entry.bot).padEnd(3)} ${describeConditions(entry.conditions)}`
718
+ );
719
+ lines.push(` ${pc.dim(did)}`);
720
+ }
721
+ for (let i = 0; i < run.cheated.length; i++) {
722
+ const bot = run.cheated[i] ?? 0;
723
+ const corrections = run.correctionsPerCheater[i] ?? 0;
724
+ if (corrections > 0) {
725
+ lines.push(
726
+ ` ${pc.green("ok ")} bot ${bot} cheated and drew ${corrections} correction(s): the room refused the illegal writes.`
727
+ );
728
+ } else {
729
+ lines.push(
730
+ pc.yellow(
731
+ ` HOLE bot ${bot} cheated and drew 0 corrections: the room accepted every illegal write it sent.
732
+ Add rules to \`validate\` in irtio/room.ts: reject by returning \`prev\`, or clamp the value.`
733
+ )
734
+ );
735
+ }
736
+ }
341
737
  return lines;
342
738
  }
739
+ function formatHits(rows) {
740
+ const lines = [
741
+ "",
742
+ ` ${pc.bold("hit registration")} ${pc.dim(`(${rows.length} shot(s))`)}`
743
+ ];
744
+ for (const row of rows) {
745
+ const at = row.serverTick === void 0 ? "no tick" : `tick ${row.serverTick}${row.estimated ? " (estimated)" : ""}`;
746
+ if (row.missDistance === void 0) {
747
+ lines.push(
748
+ pc.yellow(
749
+ ` bot ${row.bot} at ${row.target}: ${at}, no distance: ${row.unresolved ?? "unknown"}`
750
+ )
751
+ );
752
+ continue;
753
+ }
754
+ lines.push(
755
+ ` ${pc.dim("bot")} ${row.bot} at ${row.collection}.${row.target}: ${at}, aimed ${point(row.aim)}, authority ${point(row.authoritative ?? {})}, ${pc.bold(`missed by ${row.missDistance.toFixed(2)}`)}`
756
+ );
757
+ }
758
+ const measured = rows.filter((r) => r.missDistance !== void 0);
759
+ if (measured.length > 0) {
760
+ const mean = measured.reduce((s, r) => s + (r.missDistance ?? 0), 0) / measured.length;
761
+ lines.push(
762
+ pc.dim(
763
+ ` mean miss ${mean.toFixed(2)} over ${measured.length} shot(s). Nothing here compensates for lag; this is what a player at this latency would experience.`
764
+ )
765
+ );
766
+ }
767
+ return lines;
768
+ }
769
+ function point(p) {
770
+ const parts = Object.entries(p).map(([k, v]) => `${k} ${Math.round(v * 100) / 100}`);
771
+ return parts.length === 0 ? "(none)" : `(${parts.join(", ")})`;
772
+ }
773
+ function formatTruth(run) {
774
+ const lines = ["", ` ${pc.bold("truth seam")}`];
775
+ if (run.diff === void 0) {
776
+ lines.push(
777
+ pc.yellow(
778
+ ` n/a no save could be decoded, so nothing was compared: ${run.error ?? "unknown"}`
779
+ )
780
+ );
781
+ return lines;
782
+ }
783
+ const diff = run.diff;
784
+ lines.push(
785
+ pc.dim(
786
+ ` save ${run.saveId ?? "(unnamed)"} at tick ${diff.saveTick}, blob format v${diff.saveVersion}`
787
+ )
788
+ );
789
+ for (const bot of diff.bots) {
790
+ const mark = bot.ok ? pc.green("ok ") : pc.red("FAIL");
791
+ lines.push(
792
+ ` ${mark} bot ${String(bot.bot).padEnd(3)} ${pc.dim(
793
+ bot.ok ? `${bot.compared} record(s) held, and every one matched the save` : `${bot.differences.length} difference(s) across ${bot.compared} record(s) held`
794
+ )}`
795
+ );
796
+ for (const d of bot.differences.slice(0, 5)) lines.push(pc.red(` ${d.detail}`));
797
+ if (bot.differences.length > 5) {
798
+ lines.push(pc.dim(` ... and ${bot.differences.length - 5} more`));
799
+ }
800
+ }
801
+ lines.push(
802
+ diff.ok ? pc.green(
803
+ ` every record the ${diff.bots.length} client(s) held matched the decoded save, and none was missing from it.`
804
+ ) : pc.red(
805
+ ` ${diff.differences.length} difference(s) between what the clients held and what the save says the server held.`
806
+ )
807
+ );
808
+ return lines;
809
+ }
810
+ function conditionsFrom(options) {
811
+ const all = options.conditions;
812
+ const perBot = options.conditionsPerBot;
813
+ if (perBot === void 0 || Object.keys(perBot).length === 0) {
814
+ return hasConditions(all) ? all : void 0;
815
+ }
816
+ return (index) => perBot[index] ?? all;
817
+ }
818
+ function cheatFrom(options) {
819
+ const named = options.cheatBots;
820
+ if (named !== void 0 && named.length > 0) {
821
+ const set = new Set(named);
822
+ return options.cheat === true ? true : (index) => set.has(index);
823
+ }
824
+ return options.cheat === true ? true : void 0;
825
+ }
343
826
  async function runSimulation(options = {}) {
344
- const cwd = path.resolve(options.cwd ?? process.cwd());
827
+ const cwd = path2.resolve(options.cwd ?? process.cwd());
345
828
  const output = [];
346
829
  const log = (line) => {
347
830
  output.push(line);
348
831
  (options.log ?? ((l) => console.log(l)))(line);
349
832
  };
350
- const bots = options.bots ?? DEFAULT_BOTS;
351
- const seconds = options.seconds ?? DEFAULT_SECONDS;
352
833
  const url = options.url ?? DEFAULT_URL;
353
- const outDir = path.join(cwd, ".irtio", "sim");
834
+ const outDir = path2.join(cwd, ".irtio", "sim");
835
+ const loadedScenario = options.scenario !== void 0 ? await loadScenario({
836
+ cwd,
837
+ outDir,
838
+ file: options.scenario,
839
+ irtioPackages: options.irtioPackages
840
+ }) : void 0;
841
+ const scenario = loadedScenario?.scenario;
842
+ const bots = scenario?.bots ?? options.bots ?? DEFAULT_BOTS;
843
+ const seconds = scenario?.seconds ?? options.seconds ?? DEFAULT_SECONDS;
354
844
  const loaded = await loadProjectSchema({
355
845
  cwd,
356
846
  outDir,
357
847
  irtioPackages: options.irtioPackages
358
848
  });
849
+ if (scenario !== void 0 && loaded === void 0) {
850
+ throw new ScenarioNotRunError(
851
+ `irtio simulate: a scenario needs the project schema, and there is no irtio/schema.ts under ${cwd}.
852
+ Scenarios assert on the room's own collections, so a relay run has nothing to assert about. Run it from the project directory.`
853
+ );
854
+ }
359
855
  const hasPhysics = loaded !== void 0 && loaded.schema.collections.some(
360
856
  (c) => c.physics !== void 0
361
857
  );
362
- const world = hasPhysics ? await loadProjectWorld({ cwd, outDir, irtioPackages: options.irtioPackages }) : void 0;
858
+ const loadedWorld = hasPhysics ? await loadProjectWorld({ cwd, outDir, irtioPackages: options.irtioPackages }) : void 0;
859
+ if (isUnpredictedWorld(loadedWorld)) {
860
+ log(
861
+ `physics: ${loadedWorld.file} is a 2D (matter2d) world; bots run without client prediction`
862
+ );
863
+ }
864
+ const world = isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
363
865
  let worldError;
364
866
  if (world) {
365
867
  worldError = await checkWorldDeterminism(world);
366
868
  }
869
+ const conditionsSpec = scenario?.conditions ?? conditionsFrom(options);
870
+ const cheatSpec = scenario?.cheat ?? cheatFrom(options);
871
+ const cheats = cheatPredicate(cheatSpec);
872
+ const wantTruth = scenario?.truth === true || options.truth === true;
367
873
  const common = {
368
874
  url,
369
875
  durationMs: seconds * 1e3,
876
+ ...conditionsSpec !== void 0 ? { conditions: conditionsSpec } : {},
877
+ ...scenario?.seed !== void 0 ? { seed: scenario.seed } : {},
370
878
  ...options.key !== void 0 ? { key: options.key } : {},
371
879
  ...options.room !== void 0 ? { room: options.room } : {},
372
880
  ...options.correctionsPerSecMax !== void 0 ? { correctionsPerSecMax: options.correctionsPerSecMax } : options.correctionsMax !== void 0 ? { correctionsPerSecMax: options.correctionsMax } : {},
373
881
  ...options.mispredictionMax !== void 0 ? { mispredictionMagnitudeMax: options.mispredictionMax } : {},
374
882
  ...options.snapsMax !== void 0 ? { snapsMax: options.snapsMax } : {},
375
- ...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {}
883
+ ...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {},
884
+ ...options.profile === true ? { profile: true } : {}
376
885
  };
377
886
  const predicted = world !== void 0 && worldError === void 0;
378
887
  const spawn = {
@@ -385,7 +894,7 @@ async function runSimulation(options = {}) {
385
894
  runner = loaded ? await spawnBots(bots, {
386
895
  ...spawn,
387
896
  schema: loaded.schema,
388
- script: randomScript(loaded.schema, { cheat: options.cheat ?? false }),
897
+ script: scenario?.script ?? randomScript(loaded.schema, { cheat: cheatSpec ?? false }),
389
898
  ...predicted ? {
390
899
  physics: {
391
900
  gravity: world.gravity,
@@ -405,57 +914,160 @@ async function runSimulation(options = {}) {
405
914
  }
406
915
  log("");
407
916
  log(
408
- `${pc.bold("irtio simulate")} \u2014 ${bots} bot${bots === 1 ? "" : "s"} \xD7 ${seconds}s on ${url} (room ${pc.bold(runner.roomId)})${options.cheat ? pc.yellow(" [cheat]") : ""}`
917
+ `${pc.bold("irtio simulate")}: ${bots} bot${bots === 1 ? "" : "s"} \xD7 ${seconds}s on ${url} (room ${pc.bold(runner.roomId)})${options.cheat ? pc.yellow(" [cheat]") : ""}`
409
918
  );
919
+ const build3 = await readBuildIdentity(url);
920
+ log(pc.dim(` ${formatBuildIdentity(build3)}`));
410
921
  log("");
922
+ if (scenario !== void 0) {
923
+ try {
924
+ await startTimeline(url, runner.roomId);
925
+ } catch (err) {
926
+ await runner.stop().catch(() => void 0);
927
+ throw err;
928
+ }
929
+ }
411
930
  const tickBefore = await readTickCounters(url, runner.roomId);
412
931
  const ceilingMs = options.ceilingMs ?? ceilingFor(seconds);
413
932
  let ceilingHit = false;
414
- const ceiling = new Promise((resolve2) => {
933
+ const ceiling = new Promise((resolve3) => {
415
934
  const timer = setTimeout(() => {
416
935
  ceilingHit = true;
417
- resolve2();
936
+ resolve3();
418
937
  }, ceilingMs);
419
938
  void runner.done().catch(() => void 0).finally(() => {
420
939
  clearTimeout(timer);
421
- resolve2();
940
+ resolve3();
422
941
  });
423
942
  });
424
943
  await ceiling;
944
+ let truth;
945
+ const clientStates = [];
946
+ if (wantTruth) {
947
+ if (loaded === void 0) {
948
+ truth = { error: "a relay run has no schema, so there is nothing to decode a save against" };
949
+ } else {
950
+ await delay(TRUTH_SETTLE_MS);
951
+ const fetched = await fetchSave(url, runner.roomId);
952
+ if ("error" in fetched) {
953
+ truth = { error: fetched.error };
954
+ } else {
955
+ await delay(TRUTH_SETTLE_MS);
956
+ for (const bot of runner.bots) {
957
+ clientStates.push({
958
+ bot: bot.index,
959
+ state: captureClientState(
960
+ loaded.schema,
961
+ bot.room.state
962
+ )
963
+ });
964
+ }
965
+ try {
966
+ const decoded = decodeSave(fetched.save.bytes, loaded.schema);
967
+ truth = {
968
+ saveId: fetched.save.saveId,
969
+ saveState: decoded.state,
970
+ diff: diffAgainstSave(clientStates, decoded.state, {
971
+ saveTick: decoded.tick,
972
+ saveVersion: decoded.version
973
+ })
974
+ };
975
+ } catch (err) {
976
+ truth = {
977
+ saveId: fetched.save.saveId,
978
+ error: `the save could not be decoded: ${err instanceof Error ? err.message : String(err)}`
979
+ };
980
+ }
981
+ }
982
+ }
983
+ }
425
984
  runner.recordTickHealth(tickHealthFrom(tickBefore, await readTickCounters(url, runner.roomId)));
985
+ let dump;
986
+ if (scenario !== void 0) {
987
+ try {
988
+ dump = await readTimeline(url, runner.roomId);
989
+ } catch (err) {
990
+ await runner.stop().catch(() => void 0);
991
+ throw err;
992
+ }
993
+ }
426
994
  const stopped = await Promise.race([
427
995
  runner.stop(),
428
- new Promise((resolve2) => {
429
- const timer = setTimeout(() => resolve2(runner.report()), STOP_GRACE_MS);
996
+ new Promise((resolve3) => {
997
+ const timer = setTimeout(() => resolve3(runner.report()), STOP_GRACE_MS);
430
998
  timer.unref?.();
431
999
  })
432
1000
  ]);
433
1001
  const endedBy = ceilingHit ? "ceiling" : runner.endedBy ?? "scripts";
434
1002
  const fatal = endedBy === "ceiling" ? `the run passed its wall-clock ceiling of ${Math.round(ceilingMs / 1e3)}s without ending on its own, so it was cut short here` : endedBy === "room-gone" ? "every bot was disconnected at once and none rejoined, so the room is gone" : void 0;
435
- await mkdir(outDir, { recursive: true });
436
- const tracePath = path.resolve(
1003
+ await mkdir2(outDir, { recursive: true });
1004
+ const tracePath = path2.resolve(
437
1005
  cwd,
438
- options.trace ?? path.join(outDir, `trace-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`)
1006
+ options.trace ?? path2.join(outDir, `trace-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`)
439
1007
  );
440
- await mkdir(path.dirname(tracePath), { recursive: true });
1008
+ await mkdir2(path2.dirname(tracePath), { recursive: true });
441
1009
  await runner.trace.save(tracePath);
442
1010
  const report = { ...stopped, tracePath };
443
- for (const line of formatReport(report, loaded !== void 0, options.cheat ?? false)) {
1011
+ const hits = runner.shots.length > 0 && dump !== void 0 ? correlateShots(runner.shots, dump) : [];
1012
+ const cheated = report.perBot.map((_, index) => index).filter((index) => cheats(index));
1013
+ const injectedAny = runner.conditions.some((c) => c.conditions !== void 0);
1014
+ const adversarial = injectedAny || cheated.length > 0 ? {
1015
+ conditions: runner.conditions,
1016
+ cheated,
1017
+ correctionsPerCheater: cheated.map((index) => report.perBot[index]?.corrections ?? 0)
1018
+ } : void 0;
1019
+ let scenarioRun;
1020
+ if (scenario !== void 0 && dump !== void 0 && loadedScenario !== void 0) {
1021
+ const timeline = makeTimeline(dump);
1022
+ const assertions = [];
1023
+ let threw;
1024
+ try {
1025
+ await scenario.assert(timeline, { hits, truth: truth?.diff });
1026
+ } catch (err) {
1027
+ threw = err instanceof Error ? err.message : String(err);
1028
+ }
1029
+ assertions.push(...timeline.results);
1030
+ if (threw !== void 0) assertions.push({ name: "assert", ok: false, detail: threw });
1031
+ const timelinePath = path2.join(
1032
+ path2.dirname(tracePath),
1033
+ `${path2.basename(tracePath, ".json")}-timeline.json`
1034
+ );
1035
+ await writeFile(timelinePath, `${JSON.stringify(dump, null, 2)}
1036
+ `, "utf8");
1037
+ scenarioRun = {
1038
+ file: loadedScenario.file,
1039
+ assertions,
1040
+ ok: assertions.length > 0 && assertions.every((a) => a.ok),
1041
+ ticks: dump.frames.length,
1042
+ dropped: dump.dropped,
1043
+ timelinePath
1044
+ };
1045
+ }
1046
+ for (const line of formatReport(report, loaded !== void 0)) {
444
1047
  log(line);
445
1048
  }
1049
+ if (report.profile !== void 0) {
1050
+ for (const line of formatRunProfile(report, options.profileTop)) log(line);
1051
+ }
1052
+ if (adversarial !== void 0) for (const line of formatAdversarial(adversarial)) log(line);
1053
+ if (hits.length > 0) for (const line of formatHits(hits)) log(line);
1054
+ if (truth !== void 0) for (const line of formatTruth(truth)) log(line);
1055
+ if (scenarioRun !== void 0) {
1056
+ for (const line of formatScenario(scenarioRun, cwd)) log(line);
1057
+ }
446
1058
  if (worldError !== void 0) {
447
1059
  log(pc.red(` FAIL world-builder: ${worldError}`));
448
1060
  } else if (predicted) {
449
1061
  const suppressed = report.totals.suppressedCorrections;
450
1062
  log(
451
1063
  pc.dim(
452
- ` bots predicted physics from ${path.relative(cwd, world?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
1064
+ ` bots predicted physics from ${path2.relative(cwd, world?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
453
1065
  )
454
1066
  );
455
1067
  } else if (hasPhysics) {
456
1068
  log(
457
1069
  pc.yellow(
458
- " 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)."
1070
+ " this schema has physics but no irtio/world.ts, so 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)."
459
1071
  )
460
1072
  );
461
1073
  }
@@ -463,10 +1075,20 @@ async function runSimulation(options = {}) {
463
1075
  log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
464
1076
  }
465
1077
  if (fatal !== void 0) log(pc.red(` run ended early: ${fatal}`));
466
- const exitCode = endedBy === "ceiling" ? 2 : !report.ok || worldError !== void 0 || runner.scriptErrors.length > 0 ? 1 : 0;
1078
+ const exitCode = endedBy === "ceiling" ? 2 : !report.ok || worldError !== void 0 || runner.scriptErrors.length > 0 || scenarioRun?.ok === false || // D43: a difference between what the clients held and what the save says the server
1079
+ // held is a measured violation, so it is exit 1 like every other one. A run that could
1080
+ // not take a save at all is not: nothing was measured, and the section says so in
1081
+ // words rather than turning "unread" into a verdict, which is D36's `unavailable` rule
1082
+ // applied to the truth seam.
1083
+ truth?.diff?.ok === false ? 1 : 0;
467
1084
  return {
468
1085
  report,
469
1086
  tracePath,
1087
+ build: build3,
1088
+ ...scenarioRun !== void 0 ? { scenario: scenarioRun } : {},
1089
+ ...adversarial !== void 0 ? { adversarial } : {},
1090
+ ...hits.length > 0 ? { hits } : {},
1091
+ ...truth !== void 0 ? { truth } : {},
470
1092
  schema: loaded !== void 0,
471
1093
  predicted,
472
1094
  ...worldError !== void 0 ? { worldError } : {},
@@ -499,17 +1121,22 @@ async function simulate(args) {
499
1121
  ...err instanceof RunNotPerformedError && localDefault ? [`nothing is answering ${DEFAULT_URL}. Start the room with \`irtio dev\`, or pass --url.`] : []
500
1122
  ]);
501
1123
  if (trailer !== void 0) console.error(pc.dim(trailer));
502
- process.exitCode = err instanceof RunNotPerformedError ? 2 : 1;
1124
+ process.exitCode = err instanceof RunNotPerformedError || err instanceof ScenarioNotRunError ? 2 : 1;
503
1125
  }
504
1126
  }
505
1127
  export {
506
1128
  RunNotPerformedError,
1129
+ ScenarioNotRunError,
507
1130
  USAGE,
508
1131
  ceilingFor,
509
1132
  checkWorldDeterminism,
1133
+ formatBuildIdentity,
1134
+ isUnpredictedWorld,
510
1135
  loadProjectSchema,
511
1136
  loadProjectWorld,
1137
+ loadScenario,
512
1138
  parseSimulateArgs,
1139
+ readBuildIdentity,
513
1140
  readTickCounters,
514
1141
  runSimulation,
515
1142
  simulate,