@irtio/cli 0.6.0 → 0.7.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 (28) hide show
  1. package/dist/api-keys-UTLYMZYN.js +222 -0
  2. package/dist/api.js +1 -1
  3. package/dist/{chunk-3HQMVCYA.js → chunk-BQBOBFBO.js} +4 -4
  4. package/dist/{chunk-RNAH5T4W.js → chunk-IDF46P7R.js} +2 -0
  5. package/dist/{chunk-DKWG7MGO.js → chunk-JL235KIE.js} +1 -1
  6. package/dist/{chunk-ZD4ND6X6.js → chunk-OCVALOGK.js} +1 -1
  7. package/dist/{chunk-RQSJZWQC.js → chunk-WFMRNGO5.js} +134 -105
  8. package/dist/{delete-project-VENS2B44.js → delete-project-MXUYNGAO.js} +2 -2
  9. package/dist/deploy.d.ts +3 -1
  10. package/dist/deploy.js +163 -5
  11. package/dist/{dev-QM26ONKS.js → dev-AUZ4OLA3.js} +139 -10
  12. package/dist/index.js +39 -15
  13. package/dist/init.js +2 -2
  14. package/dist/{keys-JHLMEGRA.js → keys-NXRIBJZP.js} +3 -3
  15. package/dist/{leaderboard-SYPSBPS3.js → leaderboard-ZBJBKETM.js} +65 -11
  16. package/dist/{login-2M73HBZT.js → login-3RVN5PPN.js} +2 -2
  17. package/dist/{logs-2W7CPZO5.js → logs-AT7G6YRH.js} +3 -3
  18. package/dist/{migrate-T3DZJREY.js → migrate-FMXTRVUV.js} +3 -3
  19. package/dist/{ratings-VG32WFDG.js → ratings-XBLX2MUW.js} +3 -3
  20. package/dist/{rollback-SO74MVZV.js → rollback-LI4TDLQA.js} +3 -3
  21. package/dist/{rooms-VI33P4RA.js → rooms-52Q5KBUS.js} +179 -20
  22. package/dist/simulate.d.ts +115 -10
  23. package/dist/simulate.js +270 -36
  24. package/dist/{static-deploy-KOWFKWZA.js → static-deploy-7UCYINJB.js} +5 -5
  25. package/dist/{status-HF3ZEKB7.js → status-JZGKH2P6.js} +3 -3
  26. package/dist/{usage-4G23QXCH.js → usage-7S447INI.js} +3 -3
  27. package/dist/{whoami-KTMTQNHM.js → whoami-UFSWPWK6.js} +2 -2
  28. package/package.json +7 -7
@@ -80,8 +80,14 @@ interface SimulateArgs {
80
80
  profile?: boolean;
81
81
  /** D65: rows in that table. Implies `profile`. */
82
82
  profileTop?: number;
83
+ /** Queue for rooms instead of building one. Needs `control`. */
84
+ queue?: string;
85
+ /** The control plane the queue lives on. `irtio dev` runs none. */
86
+ control?: string;
87
+ /** Group this many consecutive bots into one party each. */
88
+ party?: number;
83
89
  }
84
- declare const USAGE = "usage: irtio simulate [options]\n\nDrives N real clients at a running room and checks the built-in invariants. Run it from a project\ndirectory so it can load irtio/schema.ts; without one it falls back to a relay run.\n\noptions:\n --bots <n> how many clients to spawn (default 5)\n --seconds <n> how long to play for (default 10)\n --room <code> join this room instead of creating one\n --url <ws://...> where to connect (default ws://localhost:7070)\n --key <projectKey> the project key to present\n --trace <path> write the frame trace here\n --scenario <file> run a scenario module and assert against the recorded timeline\n --cheat send illegal writes and expect corrections, from every bot\n --cheat-bot <index> only this bot cheats (repeatable)\n --conditions <json> inject network conditions into every bot, e.g.\n '{\"rttMs\":200,\"jitterMs\":20,\"loss\":0.02}'. Keys: rttMs, jitterMs,\n loss, duplicate, reorder, reorderMs. Loss, duplicate and reorder\n touch state frames only, so a join always completes\n --conditions-bot <i>:<json> conditions for one bot, overriding --conditions (repeatable)\n --truth save the room at the end and diff it against what each client\n received, within that client's visibility\n --misprediction-max <units> fail if one correction snaps a prediction further than this\n --snaps-max <n> fail above this many cap-exceeded reconciliations\n --corrections-max <perSec> fail above this correction rate per bot\n --overruns-max <n> fail above this many server tick overruns in the run window\n (default 0; the count is read from the server, and the run says\n so when it could not be read)\n --profile print where the run's bytes went, by collection and field, as the\n bots saw them\n --profile-top <n> rows in that table (default 12; implies --profile)\n -h, --help print this\n\nexit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be\nperformed (nobody joined, or it passed its wall-clock ceiling).\n";
90
+ declare const USAGE = "usage: irtio simulate [options]\n\nDrives N real clients at a running room and checks the built-in invariants. Run it from a project\ndirectory so it can load irtio/schema.ts; without one it falls back to a relay run.\n\noptions:\n --bots <n> how many clients to spawn (default 5)\n --seconds <n> how long to play for (default 10)\n --room <code> join this room instead of creating one\n --url <ws://...> where to connect (default ws://localhost:7070)\n --key <projectKey> the project key to present\n --trace <path> write the frame trace here\n --scenario <file> run a scenario module and assert against the recorded timeline\n --cheat send illegal writes and expect corrections, from every bot\n --cheat-bot <index> only this bot cheats (repeatable)\n --conditions <json> inject network conditions into every bot, e.g.\n '{\"rttMs\":200,\"jitterMs\":20,\"loss\":0.02}'. Keys: rttMs, jitterMs,\n loss, duplicate, reorder, reorderMs. Loss, duplicate and reorder\n touch state frames only, so a join always completes\n --conditions-bot <i>:<json> conditions for one bot, overriding --conditions (repeatable)\n --truth save the room at the end and diff it against what each client\n received, within that client's visibility\n --misprediction-max <units> fail if one correction snaps a prediction further than this\n --snaps-max <n> fail above this many cap-exceeded reconciliations\n --corrections-max <perSec> fail above this correction rate per bot\n --overruns-max <n> fail above this many server tick overruns in the run window\n (default 0; the count is read from the server, and the run says\n so when it could not be read)\n --queue <name> queue for rooms through the matchmaker instead of creating one:\n every bot calls the real match API and joins the room its ticket\n names. Needs --control\n --control <https://...> the control plane the queue lives on. `irtio dev` runs none, so a\n queued run points at a real one (staging, or a test plane)\n --party <n> group each n consecutive bots into one party, so they queue\n together and land in one room. Needs --queue\n --profile print where the run's bytes went, by collection and field, as the\n bots saw them\n --profile-top <n> rows in that table (default 12; implies --profile)\n -h, --help print this\n\nexit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be\nperformed (nobody joined, or it passed its wall-clock ceiling).\n";
85
91
  /** Hand-rolled, like `parseDevArgs`: the CLI has no argument-parsing dependency. */
86
92
  declare function parseSimulateArgs(args: readonly string[]): SimulateArgs;
87
93
  interface LoadSchemaOptions {
@@ -100,6 +106,7 @@ declare function loadProjectSchema(options: LoadSchemaOptions): Promise<{
100
106
  } | undefined>;
101
107
  /** The shared world-builder's exports, as `joinRoom({ physics })` wants them. */
102
108
  interface LoadedWorld {
109
+ readonly engine: 'rapier3d';
103
110
  readonly gravity: {
104
111
  x: number;
105
112
  y: number;
@@ -112,18 +119,87 @@ interface LoadedWorld {
112
119
  readonly file: string;
113
120
  }
114
121
  /**
115
- * Bundles and imports the project's shared world-builder module, so the bots predict physics the
116
- * way a browser client would. The engine stays external — it must be the project's own copy,
117
- * resolved at import time, exactly as a room bundle resolves it.
122
+ * D73-a: a matter2d world, as `joinRoom({ physics2d })` wants it.
123
+ *
124
+ * `bugs.md` #21 taught this loader to *recognise* a 2D world rather than refuse it, and stopped
125
+ * there, on a comment saying matter2d had no client prediction. That stopped being true when
126
+ * `joinRoom({ physics2d })` shipped (M5 part 4.5), so a 2D world is now loaded to be predicted
127
+ * with, exactly as the Rapier one is.
128
+ *
129
+ * The source is the shared world module, which exports `gravity` (2D) and whichever of `timestep`,
130
+ * `setup`, `bodies`, `intents` and `settle` it has — the same object a browser hands
131
+ * `joinRoom({ physics2d })`. The room's own `physics` declaration is deliberately **not** a
132
+ * source: it is the server's half, and the client half (`settle`, the per-step force over the
133
+ * bodies nobody steers, and the exact timestep) is written on the client. A world built from the
134
+ * room alone would leave every unsteered body hanging in the air locally and be corrected on
135
+ * every tick, which is a worse answer than not predicting.
136
+ */
137
+ interface LoadedWorld2d {
138
+ readonly engine: 'matter2d';
139
+ readonly gravity: {
140
+ x: number;
141
+ y: number;
142
+ };
143
+ readonly timestep?: number | undefined;
144
+ readonly setup?: ((engine: unknown, matter: unknown) => void) | undefined;
145
+ readonly bodies?: Record<string, (...args: never[]) => unknown> | undefined;
146
+ readonly intents?: Record<string, (...args: never[]) => void> | undefined;
147
+ readonly settle?: Record<string, (...args: never[]) => void> | undefined;
148
+ /**
149
+ * The three tuning numbers `ClientPhysics2dOptions` takes, when the world module states them.
150
+ *
151
+ * `epsilon` is the one that matters and it is the one nobody expects: it is a distance in the
152
+ * world's own units, and its default (0.05) is sized for a metre-scale world. A matter.js world
153
+ * in pixels — matter's own convention, and what every matter.js tutorial writes — is two orders
154
+ * of magnitude coarser, so the default calls every ordinary contact resolution a misprediction.
155
+ * A world that states its scale here gets the same suppression a metre-scale world gets for free.
156
+ */
157
+ readonly epsilon?: number | undefined;
158
+ readonly maxPredictedBodies?: number | undefined;
159
+ readonly smoothingHalfLifeMs?: number | undefined;
160
+ readonly file: string;
161
+ }
162
+ /**
163
+ * A matter2d project this loader recognises and cannot predict from: run it, say why in one
164
+ * sentence, and never pretend to a predictor that does not exist.
165
+ *
166
+ * `bugs.md` #21 introduced this for every matter2d project, because there was no client
167
+ * prediction at all. D73-a narrows it to the two cases that are still true: a world module with
168
+ * no `bodies` (there is nothing to build), and an **inert** one — no `intents`, no `settle`, and
169
+ * zero gravity, so nothing in a local world built from it could ever move a body.
170
+ * `examples/matter-chase` is the second case by design: its room applies thrust by hand inside
171
+ * `tick()`, so there is no steering hook to share and its own client does not predict either.
118
172
  */
119
- /** `bugs.md` #21: a 2D world module, recognised rather than refused. There is nothing to predict
120
- * from it in this release, so the caller drops the predicted-world path and runs anyway. */
121
173
  interface UnpredictedWorld {
122
- readonly unpredicted: 'matter2d';
174
+ readonly engine: 'matter2d';
175
+ /** One sentence, printed by the run, naming what would have to change. */
176
+ readonly why: string;
123
177
  readonly file: string;
124
178
  }
125
- declare function isUnpredictedWorld(world: LoadedWorld | UnpredictedWorld | undefined): world is UnpredictedWorld;
126
- declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWorld | UnpredictedWorld | undefined>;
179
+ type LoadedAnyWorld = LoadedWorld | LoadedWorld2d | UnpredictedWorld;
180
+ /** Narrows a loaded world to a matter2d one bots can predict with. */
181
+ declare function is2dWorld(world: LoadedAnyWorld | undefined): world is LoadedWorld2d;
182
+ declare function isUnpredictedWorld(world: LoadedAnyWorld | undefined): world is UnpredictedWorld;
183
+ /**
184
+ * D73-a: does this project's room declare matter2d?
185
+ *
186
+ * The one question the room module is asked, and the reason it is asked at all: a matter2d project
187
+ * whose world module is not a client world — `games/dive`, whose engine gravity is zero and
188
+ * applied per body, so there is no `gravity` to export and its `physics2d` block is composed in
189
+ * `main.ts` — used to be refused at the door with "does not export a { x, y, z } gravity", which
190
+ * is why `irtio simulate` had never been run against dive at all. Recognising it here turns that
191
+ * refusal into a run with a sentence.
192
+ *
193
+ * `undefined` for every other answer: no room module, one that will not build or import, or one
194
+ * declaring another engine. None of those is an error here — the world module's own error is the
195
+ * right one to raise.
196
+ */
197
+ declare function detectMatter2dRoom(options: LoadSchemaOptions): Promise<string | undefined>;
198
+ /**
199
+ * Bundles and imports the project's shared world-builder module, so the bots predict physics the
200
+ * way a browser client would.
201
+ */
202
+ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedAnyWorld | undefined>;
127
203
  /**
128
204
  * The build-twice determinism check: builds two worlds from the shared `setup` and compares
129
205
  * Rapier's own snapshots byte for byte. A builder that is not pure over synced inputs
@@ -132,6 +208,23 @@ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWor
132
208
  * description, or `undefined` when the worlds agree.
133
209
  */
134
210
  declare function checkWorldDeterminism(world: LoadedWorld): Promise<string | undefined>;
211
+ /**
212
+ * D73-a: the matter2d determinism check.
213
+ *
214
+ * The Rapier check compares two `takeSnapshot()` byte strings, and matter.js has no snapshot at
215
+ * all (`packages/runtime/src/core/matter.ts`'s header says so), so this one is built the only way
216
+ * that is left: build the world twice through the engine the runtime loads, step both the same
217
+ * number of times with the same (empty) intent stream, and compare every body's pose and velocity
218
+ * through a canonical encoding. Two builds that disagree came from a builder that is not pure over
219
+ * synced inputs, which is the same fault the Rapier check catches and the same reason it matters —
220
+ * every "misprediction" it causes is blamed on netcode.
221
+ *
222
+ * Stepping, rather than comparing the two worlds as built, is what makes it catch randomness that
223
+ * is small at t=0: a crate spawned a thousandth of a unit off lands in a different place. The body
224
+ * factories are exercised too, against each collection's zero record, so a `Math.random()` in a
225
+ * factory is caught and not only one in `setup`.
226
+ */
227
+ declare function checkWorld2dDeterminism(world: LoadedWorld2d, schema?: AnySchema): Promise<string | undefined>;
135
228
  /**
136
229
  * The dev server's JSON view, which is where a local run reads the server's own tick counters.
137
230
  * `irtio dev` serves it on the socket's own port; nothing else does, and that is deliberate — a
@@ -221,6 +314,12 @@ interface RunSimulationOptions extends Partial<SimulateArgs> {
221
314
  * is a fatal run condition, not a clean end.
222
315
  */
223
316
  readonly ceilingMs?: number;
317
+ /**
318
+ * D73-c: how long a queued bot waits for its ticket. The control plane caps this at two minutes
319
+ * and applies its own default (30 s) when it is omitted; a run that wants to see a queue *not*
320
+ * fill needs a bound shorter than its own patience.
321
+ */
322
+ readonly matchTimeoutMs?: number;
224
323
  /** @internal Test seam, same as `startDev`'s. */
225
324
  readonly irtioPackages?: Record<string, string> | undefined;
226
325
  }
@@ -296,6 +395,12 @@ interface SimulationRun {
296
395
  readonly schema: boolean;
297
396
  /** `true` when the bots predicted physics from a shared world-builder module (D22 part 2). */
298
397
  readonly predicted: boolean;
398
+ /** D73-c: bots that never got a ticket. Empty on a run that did not queue. */
399
+ readonly matchFailures: readonly {
400
+ requested: number;
401
+ code: string;
402
+ message: string;
403
+ }[];
299
404
  /** Result of the build-twice world-builder check: the failure text, or `undefined` if it held
300
405
  * (or did not apply). A failure also fails the run. */
301
406
  readonly worldError?: string;
@@ -312,4 +417,4 @@ interface SimulationRun {
312
417
  declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
313
418
  declare function simulate(args: readonly string[]): Promise<void>;
314
419
 
315
- export { type AdversarialRun, type BuildIdentity, type LoadSchemaOptions, RunNotPerformedError, type RunSimulationOptions, ScenarioNotRunError, type ScenarioRun, type SimulateArgs, type SimulationEnd, type SimulationRun, type TruthRun, USAGE, type UnpredictedWorld, ceilingFor, checkWorldDeterminism, formatBuildIdentity, isUnpredictedWorld, loadProjectSchema, loadProjectWorld, loadScenario, parseSimulateArgs, readBuildIdentity, readTickCounters, runSimulation, simulate, stateUrlFor, tickHealthFrom };
420
+ export { type AdversarialRun, type BuildIdentity, type LoadSchemaOptions, type LoadedAnyWorld, type LoadedWorld2d, RunNotPerformedError, type RunSimulationOptions, ScenarioNotRunError, type ScenarioRun, type SimulateArgs, type SimulationEnd, type SimulationRun, type TruthRun, USAGE, type UnpredictedWorld, ceilingFor, checkWorld2dDeterminism, checkWorldDeterminism, detectMatter2dRoom, formatBuildIdentity, is2dWorld, isUnpredictedWorld, loadProjectSchema, loadProjectWorld, loadScenario, parseSimulateArgs, readBuildIdentity, readTickCounters, runSimulation, simulate, stateUrlFor, tickHealthFrom };
package/dist/simulate.js CHANGED
@@ -6,8 +6,8 @@ import {
6
6
  helpFor,
7
7
  helpRequested,
8
8
  trailerFor
9
- } from "./chunk-ZD4ND6X6.js";
10
- import "./chunk-RNAH5T4W.js";
9
+ } from "./chunk-OCVALOGK.js";
10
+ import "./chunk-IDF46P7R.js";
11
11
  import "./chunk-UPHQM6NZ.js";
12
12
 
13
13
  // src/simulate.ts
@@ -276,6 +276,13 @@ options:
276
276
  --overruns-max <n> fail above this many server tick overruns in the run window
277
277
  (default 0; the count is read from the server, and the run says
278
278
  so when it could not be read)
279
+ --queue <name> queue for rooms through the matchmaker instead of creating one:
280
+ every bot calls the real match API and joins the room its ticket
281
+ names. Needs --control
282
+ --control <https://...> the control plane the queue lives on. \`irtio dev\` runs none, so a
283
+ queued run points at a real one (staging, or a test plane)
284
+ --party <n> group each n consecutive bots into one party, so they queue
285
+ together and land in one room. Needs --queue
279
286
  --profile print where the run's bytes went, by collection and field, as the
280
287
  bots saw them
281
288
  --profile-top <n> rows in that table (default 12; implies --profile)
@@ -350,6 +357,21 @@ function parseSimulateArgs(args) {
350
357
  case "--truth":
351
358
  parsed.truth = true;
352
359
  break;
360
+ // ---- M6 lane E: testing parity ---- (D73-c)
361
+ case "--queue":
362
+ parsed.queue = value();
363
+ break;
364
+ case "--control":
365
+ parsed.control = value();
366
+ break;
367
+ case "--party": {
368
+ const size = positive(value(), "--party");
369
+ if (!Number.isInteger(size) || size < 2) {
370
+ throw new Error("irtio simulate: --party must be a whole number of bots, at least 2");
371
+ }
372
+ parsed.party = size;
373
+ break;
374
+ }
353
375
  case "--profile":
354
376
  parsed.profile = true;
355
377
  break;
@@ -378,6 +400,16 @@ function parseSimulateArgs(args) {
378
400
  throw new Error(`irtio simulate: unknown option ${JSON.stringify(arg)}`);
379
401
  }
380
402
  }
403
+ if (parsed.queue !== void 0 && parsed.control === void 0) {
404
+ throw new Error(
405
+ "irtio simulate: --queue needs --control. The matchmaker lives on the control plane and `irtio dev` runs none, so there is no queue at a dev server to join."
406
+ );
407
+ }
408
+ if (parsed.party !== void 0 && parsed.queue === void 0) {
409
+ throw new Error(
410
+ "irtio simulate: --party needs --queue. A party is a group that queues together, and a run that is not queueing has nothing to group."
411
+ );
412
+ }
381
413
  return parsed;
382
414
  }
383
415
  function monorepoPackages() {
@@ -423,16 +455,15 @@ async function loadProjectSchema(options) {
423
455
  }
424
456
  return { schema, file: entry };
425
457
  }
458
+ function is2dWorld(world) {
459
+ return world !== void 0 && world.engine === "matter2d" && !("why" in world);
460
+ }
426
461
  function isUnpredictedWorld(world) {
427
- return world !== void 0 && "unpredicted" in world;
462
+ return world !== void 0 && "why" in world;
428
463
  }
429
- async function loadProjectWorld(options) {
430
- const entry = WORLD_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
431
- (f) => existsSync2(f)
432
- );
433
- if (entry === void 0) return void 0;
464
+ var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
465
+ async function bundleAndImport(entry, outfile, options) {
434
466
  await mkdir2(options.outDir, { recursive: true });
435
- const outfile = path2.join(options.outDir, "world.mjs");
436
467
  const alias = options.irtioPackages ?? monorepoPackages();
437
468
  await esbuild2.build({
438
469
  entryPoints: [entry],
@@ -440,34 +471,93 @@ async function loadProjectWorld(options) {
440
471
  format: "esm",
441
472
  platform: "node",
442
473
  outfile,
443
- external: ["@dimforge/rapier3d-compat"],
474
+ external: ["@dimforge/rapier3d-compat", "matter-js"],
444
475
  ...alias !== void 0 ? { alias } : {}
445
476
  });
446
- const module = await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
477
+ return await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
478
+ }
479
+ function hookMap(value) {
480
+ return typeof value === "object" && value !== null ? value : void 0;
481
+ }
482
+ async function detectMatter2dRoom(options) {
483
+ const entry = ROOM_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find((f) => existsSync2(f));
484
+ if (entry === void 0) return void 0;
485
+ let module;
486
+ try {
487
+ module = await bundleAndImport(entry, path2.join(options.outDir, "room-engine.mjs"), options);
488
+ } catch {
489
+ return void 0;
490
+ }
491
+ const definition = module.default ?? module.room;
492
+ return definition?.config?.physics?.engine === "matter2d" ? entry : void 0;
493
+ }
494
+ function whyNotPredictable(world) {
495
+ if (world.bodies === void 0 || Object.keys(world.bodies).length === 0) {
496
+ return `${world.file} exports no \`bodies\`, so there are no shapes to build a local world from; bots run without client prediction.
497
+ export the body factories your client passes to \`joinRoom({ physics2d })\`.`;
498
+ }
499
+ const steered = world.intents !== void 0 || world.settle !== void 0;
500
+ if (!steered && world.gravity.x === 0 && world.gravity.y === 0) {
501
+ return `${world.file} exports no \`intents\` and no \`settle\`, and its gravity is zero, so nothing in a local world built from it could ever move a body; bots run without client prediction.
502
+ export the steering hooks your client passes to \`joinRoom({ physics2d })\` (a room that applies its forces inside \`tick()\` has none to share).`;
503
+ }
504
+ return void 0;
505
+ }
506
+ async function loadProjectWorld(options) {
507
+ const entry = WORLD_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
508
+ (f) => existsSync2(f)
509
+ );
510
+ const matter2dRoom = async (why) => {
511
+ const room = await detectMatter2dRoom(options);
512
+ return room === void 0 ? void 0 : { engine: "matter2d", why: why(room), file: room };
513
+ };
514
+ if (entry === void 0) {
515
+ return matter2dRoom(
516
+ (room) => `${room} declares a matter2d room and there is no shared world module beside it, so bots run without client prediction.
517
+ export the \`physics2d\` block your client passes to \`joinRoom\` from \`irtio/world.ts\` (gravity, bodies, intents, settle) to predict.`
518
+ );
519
+ }
520
+ const module = await bundleAndImport(entry, path2.join(options.outDir, "world.mjs"), options);
447
521
  const gravity = module.gravity;
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.
522
+ const notAWorld = new Error(
523
+ `irtio simulate: ${entry} does not export a { x, y, z } gravity.
451
524
  the shared world-builder must export the same gravity the room config uses
452
525
  (this is what \`irtio init --physics\` writes)`
526
+ );
527
+ if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number") {
528
+ const recognised = await matter2dRoom(
529
+ () => `${entry} exports no gravity, so it is not the client world a matter2d room predicts from; bots run without client prediction.
530
+ export the \`physics2d\` block your client passes to \`joinRoom\` (gravity, bodies, intents, settle) from this module to predict.`
453
531
  );
532
+ if (recognised !== void 0) return recognised;
533
+ throw notAWorld;
454
534
  }
455
535
  if (typeof gravity.z !== "number") {
456
- return { unpredicted: "matter2d", file: entry };
457
- }
458
- if (typeof gravity.y !== "number") {
459
- throw new Error(
460
- `irtio simulate: ${entry} does not export a { x, y, z } gravity.
461
- the shared world-builder must export the same gravity the room config uses
462
- (this is what \`irtio init --physics\` writes)`
463
- );
536
+ if (typeof gravity.y !== "number") throw notAWorld;
537
+ const world2d = {
538
+ engine: "matter2d",
539
+ gravity: { x: gravity.x, y: gravity.y },
540
+ ...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
541
+ ...typeof module.setup === "function" ? { setup: module.setup } : {},
542
+ ...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
543
+ ...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
544
+ ...hookMap(module.settle) !== void 0 ? { settle: module.settle } : {},
545
+ ...typeof module.epsilon === "number" ? { epsilon: module.epsilon } : {},
546
+ ...typeof module.maxPredictedBodies === "number" ? { maxPredictedBodies: module.maxPredictedBodies } : {},
547
+ ...typeof module.smoothingHalfLifeMs === "number" ? { smoothingHalfLifeMs: module.smoothingHalfLifeMs } : {},
548
+ file: entry
549
+ };
550
+ const why = whyNotPredictable(world2d);
551
+ return why === void 0 ? world2d : { engine: "matter2d", why, file: entry };
464
552
  }
553
+ if (typeof gravity.y !== "number") throw notAWorld;
465
554
  return {
555
+ engine: "rapier3d",
466
556
  gravity,
467
557
  ...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
468
558
  ...typeof module.setup === "function" ? { setup: module.setup } : {},
469
- ...typeof module.bodies === "object" && module.bodies !== null ? { bodies: module.bodies } : {},
470
- ...typeof module.intents === "object" && module.intents !== null ? { intents: module.intents } : {},
559
+ ...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
560
+ ...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
471
561
  file: entry
472
562
  };
473
563
  }
@@ -493,6 +583,48 @@ function everyByteEqual(a, b) {
493
583
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
494
584
  return true;
495
585
  }
586
+ var DETERMINISM_STEPS_2D = 60;
587
+ function canonicalMatterWorld(matter, engine) {
588
+ return matter.Composite.allBodies(engine.world).map(
589
+ (b) => [b.position.x, b.position.y, b.angle, b.velocity.x, b.velocity.y, b.angularVelocity].join(
590
+ ","
591
+ )
592
+ ).join(";");
593
+ }
594
+ async function checkWorld2dDeterminism(world, schema) {
595
+ const factories = world.bodies ?? {};
596
+ const exercised = schema ? schema.collections.filter(
597
+ (c) => c.physics !== void 0 && factories[c.name] !== void 0
598
+ ) : [];
599
+ if (!world.setup && exercised.length === 0) return void 0;
600
+ const { initMatter } = await import("@irtio/runtime");
601
+ const { defaultRecord } = await import("@irtio/schema");
602
+ const matter = await initMatter();
603
+ const stepMs = (world.timestep ?? 1 / 60) * 1e3;
604
+ const build3 = () => {
605
+ const engine = matter.Engine.create();
606
+ engine.gravity.x = world.gravity.x;
607
+ engine.gravity.y = world.gravity.y;
608
+ try {
609
+ world.setup?.(engine, matter);
610
+ for (const desc of exercised) {
611
+ try {
612
+ const spec = factories[desc.name](matter, defaultRecord(desc), "irtio-determinism-check");
613
+ if (spec?.body !== void 0) matter.Composite.add(engine.world, spec.body);
614
+ } catch {
615
+ }
616
+ }
617
+ for (let i = 0; i < DETERMINISM_STEPS_2D; i++) matter.Engine.update(engine, stepMs);
618
+ return canonicalMatterWorld(matter, engine);
619
+ } finally {
620
+ matter.Engine.clear(engine);
621
+ }
622
+ };
623
+ const first = build3();
624
+ const second = build3();
625
+ if (first === second) return void 0;
626
+ return `the world builder is not deterministic: two builds of ${world.file} disagree after ${DETERMINISM_STEPS_2D} steps. 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.`;
627
+ }
496
628
  var TICK_HEALTH_TIMEOUT_MS = 2e3;
497
629
  function stateUrlFor(wsUrl) {
498
630
  let parsed;
@@ -647,11 +779,30 @@ function formatReport(report, schemaLoaded) {
647
779
  const convergence = report.convergenceLagMs === void 0 ? "convergence not sampled" : `convergence ${report.convergenceLagMs} ms (p50 of ${report.convergence?.samples ?? 0})`;
648
780
  const { mispredictions, mispredictionMagnitude, mispredictionMax, snaps } = report.totals;
649
781
  const misprediction = mispredictions === 0 ? "misprediction 0" : `misprediction mean ${(mispredictionMagnitude / mispredictions).toFixed(1)} max ${mispredictionMax.toFixed(1)}`;
782
+ const { messagesSent, messagesReceived, messagesDropped } = report.totals;
783
+ const messages = messagesSent + messagesReceived + messagesDropped === 0 ? "" : ` \xB7 ${messagesSent} msg out / ${messagesReceived} in` + (messagesDropped > 0 ? ` / ${messagesDropped} dropped` : "");
650
784
  lines.push(
651
785
  pc.dim(
652
- ` ${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}`
786
+ ` ${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}${messages}`
653
787
  )
654
788
  );
789
+ const prediction = report.prediction;
790
+ if (prediction !== void 0) {
791
+ const { syncCorrections, proxiedCorrections, suppressedCorrections } = report.totals;
792
+ lines.push(
793
+ pc.dim(
794
+ ` prediction: ${prediction.bots}/${report.bots} bot(s) held a local world \xB7 peak ${prediction.proxies} proxied, ${prediction.absent} absent \xB7 ${mispredictions} misprediction(s), ${suppressedCorrections} within-epsilon, ${proxiedCorrections} proxied, ${syncCorrections} body-sync`
795
+ )
796
+ );
797
+ if (prediction.absent > 0) {
798
+ lines.push(
799
+ pc.yellow(
800
+ ` ${prediction.absent} instance(s) had no body in a local world at once, so predicted bodies passed through them.
801
+ raise maxProxyBodies, or give the collection a body factory on the client.`
802
+ )
803
+ );
804
+ }
805
+ }
655
806
  if (report.tracePath !== void 0) lines.push(pc.dim(` trace: ${report.tracePath}`));
656
807
  lines.push("");
657
808
  const failed = report.invariants.filter((i) => !i.ok);
@@ -742,7 +893,8 @@ function formatHits(rows) {
742
893
  ` ${pc.bold("hit registration")} ${pc.dim(`(${rows.length} shot(s))`)}`
743
894
  ];
744
895
  for (const row of rows) {
745
- const at = row.serverTick === void 0 ? "no tick" : `tick ${row.serverTick}${row.estimated ? " (estimated)" : ""}`;
896
+ const marks = [row.estimated ? "estimated" : void 0, row.rewound ? "rewound" : void 0].filter((m) => m !== void 0).map((m) => ` (${m})`).join("");
897
+ const at = row.serverTick === void 0 ? "no tick" : `tick ${row.serverTick}${marks}`;
746
898
  if (row.missDistance === void 0) {
747
899
  lines.push(
748
900
  pc.yellow(
@@ -758,12 +910,46 @@ function formatHits(rows) {
758
910
  const measured = rows.filter((r) => r.missDistance !== void 0);
759
911
  if (measured.length > 0) {
760
912
  const mean = measured.reduce((s, r) => s + (r.missDistance ?? 0), 0) / measured.length;
913
+ const worst = Math.max(...measured.map((r) => r.missDistance ?? 0));
914
+ const rewound = measured.filter((r) => r.rewound === true).length;
915
+ const verdict = rewound === 0 ? "Nothing here compensates for lag; this is what a player at this latency would experience." : `${rewound} of ${measured.length} shot(s) were resolved through room.rewind, against the world the shooter was looking at.`;
761
916
  lines.push(
762
917
  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.`
918
+ ` mean miss ${mean.toFixed(2)}, worst ${worst.toFixed(2)} over ${measured.length} shot(s). ${verdict}`
919
+ )
920
+ );
921
+ }
922
+ return lines;
923
+ }
924
+ function formatMatchmaking(summary, failures) {
925
+ const lines = ["", ` ${pc.bold("matchmaking")}`];
926
+ lines.push(
927
+ pc.dim(
928
+ ` ${summary.tickets} ticket(s), ${summary.rooms} room(s), wait p50 ${summary.waitP50Ms} ms p95 ${summary.waitP95Ms} ms max ${summary.waitMaxMs} ms, ${summary.timeouts} timeout(s)` + (summary.backfills > 0 ? `, ${summary.backfills} backfill(s)` : "") + (summary.parties > 0 ? `, parties intact ${summary.partiesIntact}/${summary.parties}` : "")
929
+ )
930
+ );
931
+ for (const split of summary.splitParties) {
932
+ lines.push(
933
+ pc.red(
934
+ ` FAIL party ${split.party} was split across ${split.rooms.length} rooms: ` + split.rooms.join(", ")
935
+ )
936
+ );
937
+ }
938
+ for (const room of summary.overfullRooms) {
939
+ lines.push(
940
+ pc.red(
941
+ ` FAIL room ${room.room} holds ${room.bots} bots on a ticket that seats ${room.size}`
764
942
  )
765
943
  );
766
944
  }
945
+ for (const failure of failures) {
946
+ lines.push(
947
+ pc.red(` FAIL bot ${failure.requested} got no ticket: ${failure.code} \u2014 ${failure.message}`)
948
+ );
949
+ }
950
+ if (summary.splitParties.length === 0 && summary.overfullRooms.length === 0 && failures.length === 0) {
951
+ lines.push(pc.green(" every bot was answered, and every party landed in one room."));
952
+ }
767
953
  return lines;
768
954
  }
769
955
  function point(p) {
@@ -856,15 +1042,18 @@ async function runSimulation(options = {}) {
856
1042
  (c) => c.physics !== void 0
857
1043
  );
858
1044
  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
- );
1045
+ const world2d = is2dWorld(loadedWorld) ? loadedWorld : void 0;
1046
+ const world = is2dWorld(loadedWorld) || isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
1047
+ if (world2d !== void 0) {
1048
+ log(`physics: ${world2d.file} is a matter2d world; bots predict with physics2d`);
1049
+ } else if (isUnpredictedWorld(loadedWorld)) {
1050
+ log(pc.yellow(`physics: ${loadedWorld.why}`));
863
1051
  }
864
- const world = isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
865
1052
  let worldError;
866
1053
  if (world) {
867
1054
  worldError = await checkWorldDeterminism(world);
1055
+ } else if (world2d) {
1056
+ worldError = await checkWorld2dDeterminism(world2d, loaded?.schema);
868
1057
  }
869
1058
  const conditionsSpec = scenario?.conditions ?? conditionsFrom(options);
870
1059
  const cheatSpec = scenario?.cheat ?? cheatFrom(options);
@@ -883,9 +1072,27 @@ async function runSimulation(options = {}) {
883
1072
  ...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {},
884
1073
  ...options.profile === true ? { profile: true } : {}
885
1074
  };
886
- const predicted = world !== void 0 && worldError === void 0;
1075
+ const predicted = (world !== void 0 || world2d !== void 0) && worldError === void 0;
1076
+ const projectKey = options.key ?? loaded?.schema?.project;
1077
+ if (options.queue !== void 0 && (projectKey === void 0 || projectKey === "")) {
1078
+ throw new RunNotPerformedError(
1079
+ "irtio simulate: --queue needs a project key, and this schema carries none.\n build the schema with `irtio` so it names its project, or pass --key."
1080
+ );
1081
+ }
1082
+ const match = options.queue !== void 0 && options.control !== void 0 ? {
1083
+ controlUrl: options.control,
1084
+ project: projectKey,
1085
+ queue: options.queue,
1086
+ ...options.matchTimeoutMs !== void 0 ? { timeoutMs: options.matchTimeoutMs } : {},
1087
+ ...options.party !== void 0 ? (
1088
+ // Consecutive groups of `n`: bots 0..n-1 are party 0, n..2n-1 are party 1. A
1089
+ // trailing group smaller than `n` is still a party, of whatever is left.
1090
+ { party: (index) => Math.floor(index / options.party) }
1091
+ ) : {}
1092
+ } : void 0;
887
1093
  const spawn = {
888
1094
  ...common,
1095
+ ...match !== void 0 ? { match } : {},
889
1096
  ...options.joinTimeoutMs !== void 0 ? { joinTimeoutMs: options.joinTimeoutMs } : {},
890
1097
  ...options.roomGoneGraceMs !== void 0 ? { roomGoneGraceMs: options.roomGoneGraceMs } : {}
891
1098
  };
@@ -895,7 +1102,7 @@ async function runSimulation(options = {}) {
895
1102
  ...spawn,
896
1103
  schema: loaded.schema,
897
1104
  script: scenario?.script ?? randomScript(loaded.schema, { cheat: cheatSpec ?? false }),
898
- ...predicted ? {
1105
+ ...predicted && world !== void 0 ? {
899
1106
  physics: {
900
1107
  gravity: world.gravity,
901
1108
  ...world.timestep !== void 0 ? { timestep: world.timestep } : {},
@@ -903,6 +1110,20 @@ async function runSimulation(options = {}) {
903
1110
  ...world.bodies !== void 0 ? { bodies: world.bodies } : {},
904
1111
  ...world.intents !== void 0 ? { intents: world.intents } : {}
905
1112
  }
1113
+ } : {},
1114
+ // ---- M6 lane E: testing parity ---- (D73-a)
1115
+ ...predicted && world2d !== void 0 ? {
1116
+ physics2d: {
1117
+ gravity: world2d.gravity,
1118
+ ...world2d.timestep !== void 0 ? { timestep: world2d.timestep } : {},
1119
+ ...world2d.setup !== void 0 ? { setup: world2d.setup } : {},
1120
+ ...world2d.bodies !== void 0 ? { bodies: world2d.bodies } : {},
1121
+ ...world2d.intents !== void 0 ? { intents: world2d.intents } : {},
1122
+ ...world2d.settle !== void 0 ? { settle: world2d.settle } : {},
1123
+ ...world2d.epsilon !== void 0 ? { epsilon: world2d.epsilon } : {},
1124
+ ...world2d.maxPredictedBodies !== void 0 ? { maxPredictedBodies: world2d.maxPredictedBodies } : {},
1125
+ ...world2d.smoothingHalfLifeMs !== void 0 ? { smoothingHalfLifeMs: world2d.smoothingHalfLifeMs } : {}
1126
+ }
906
1127
  } : {}
907
1128
  }) : await spawnBots(bots, { ...spawn, script: relayEchoScript() });
908
1129
  } catch (err) {
@@ -1049,6 +1270,9 @@ async function runSimulation(options = {}) {
1049
1270
  if (report.profile !== void 0) {
1050
1271
  for (const line of formatRunProfile(report, options.profileTop)) log(line);
1051
1272
  }
1273
+ if (report.matchmaking !== void 0) {
1274
+ for (const line of formatMatchmaking(report.matchmaking, runner.matchFailures)) log(line);
1275
+ }
1052
1276
  if (adversarial !== void 0) for (const line of formatAdversarial(adversarial)) log(line);
1053
1277
  if (hits.length > 0) for (const line of formatHits(hits)) log(line);
1054
1278
  if (truth !== void 0) for (const line of formatTruth(truth)) log(line);
@@ -1059,11 +1283,14 @@ async function runSimulation(options = {}) {
1059
1283
  log(pc.red(` FAIL world-builder: ${worldError}`));
1060
1284
  } else if (predicted) {
1061
1285
  const suppressed = report.totals.suppressedCorrections;
1286
+ const engine = world2d !== void 0 ? "matter2d" : "rapier3d";
1062
1287
  log(
1063
1288
  pc.dim(
1064
- ` bots predicted physics from ${path2.relative(cwd, world?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
1289
+ ` bots predicted ${engine} physics from ${path2.relative(cwd, world?.file ?? world2d?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
1065
1290
  )
1066
1291
  );
1292
+ } else if (hasPhysics && isUnpredictedWorld(loadedWorld)) {
1293
+ log(pc.yellow(" bots interpolated instead of predicting; see the physics line above."));
1067
1294
  } else if (hasPhysics) {
1068
1295
  log(
1069
1296
  pc.yellow(
@@ -1075,7 +1302,10 @@ async function runSimulation(options = {}) {
1075
1302
  log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
1076
1303
  }
1077
1304
  if (fatal !== void 0) log(pc.red(` run ended early: ${fatal}`));
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
1305
+ const exitCode = endedBy === "ceiling" ? 2 : !report.ok || worldError !== void 0 || runner.scriptErrors.length > 0 || // D73-c: a bot that never got a ticket is a measured failure of the front door, so it
1306
+ // is exit 1 like every other one. The run still happened for the bots that got in, and
1307
+ // the matchmaking section names the ones that did not.
1308
+ runner.matchFailures.length > 0 || scenarioRun?.ok === false || // D43: a difference between what the clients held and what the save says the server
1079
1309
  // held is a measured violation, so it is exit 1 like every other one. A run that could
1080
1310
  // not take a save at all is not: nothing was measured, and the section says so in
1081
1311
  // words rather than turning "unread" into a verdict, which is D36's `unavailable` rule
@@ -1091,6 +1321,7 @@ async function runSimulation(options = {}) {
1091
1321
  ...truth !== void 0 ? { truth } : {},
1092
1322
  schema: loaded !== void 0,
1093
1323
  predicted,
1324
+ matchFailures: runner.matchFailures,
1094
1325
  ...worldError !== void 0 ? { worldError } : {},
1095
1326
  endedBy,
1096
1327
  ...fatal !== void 0 ? { fatal } : {},
@@ -1129,8 +1360,11 @@ export {
1129
1360
  ScenarioNotRunError,
1130
1361
  USAGE,
1131
1362
  ceilingFor,
1363
+ checkWorld2dDeterminism,
1132
1364
  checkWorldDeterminism,
1365
+ detectMatter2dRoom,
1133
1366
  formatBuildIdentity,
1367
+ is2dWorld,
1134
1368
  isUnpredictedWorld,
1135
1369
  loadProjectSchema,
1136
1370
  loadProjectWorld,