@irtio/cli 0.6.0 → 0.8.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 (30) hide show
  1. package/dist/api-keys-UTLYMZYN.js +222 -0
  2. package/dist/api.js +1 -1
  3. package/dist/bundle.js +1 -1
  4. package/dist/{chunk-3HQMVCYA.js → chunk-BQBOBFBO.js} +4 -4
  5. package/dist/{chunk-RNAH5T4W.js → chunk-IDF46P7R.js} +2 -0
  6. package/dist/{chunk-DKWG7MGO.js → chunk-JL235KIE.js} +1 -1
  7. package/dist/{chunk-OTSFRVJN.js → chunk-NOYGY3HA.js} +10 -3
  8. package/dist/{chunk-ZD4ND6X6.js → chunk-OCVALOGK.js} +1 -1
  9. package/dist/{chunk-RQSJZWQC.js → chunk-WFMRNGO5.js} +134 -105
  10. package/dist/{delete-project-VENS2B44.js → delete-project-MXUYNGAO.js} +2 -2
  11. package/dist/deploy.d.ts +3 -1
  12. package/dist/deploy.js +164 -6
  13. package/dist/{dev-QM26ONKS.js → dev-VJ2ATJTF.js} +220 -14
  14. package/dist/index.js +39 -15
  15. package/dist/init.js +3 -3
  16. package/dist/{keys-JHLMEGRA.js → keys-NXRIBJZP.js} +3 -3
  17. package/dist/{leaderboard-SYPSBPS3.js → leaderboard-ZBJBKETM.js} +65 -11
  18. package/dist/{login-2M73HBZT.js → login-3RVN5PPN.js} +2 -2
  19. package/dist/{logs-2W7CPZO5.js → logs-AT7G6YRH.js} +3 -3
  20. package/dist/{migrate-T3DZJREY.js → migrate-5YCFT63L.js} +4 -4
  21. package/dist/{ratings-VG32WFDG.js → ratings-XBLX2MUW.js} +3 -3
  22. package/dist/{rollback-SO74MVZV.js → rollback-LI4TDLQA.js} +3 -3
  23. package/dist/{rooms-VI33P4RA.js → rooms-52Q5KBUS.js} +179 -20
  24. package/dist/simulate.d.ts +163 -10
  25. package/dist/simulate.js +345 -38
  26. package/dist/{static-deploy-KOWFKWZA.js → static-deploy-7UCYINJB.js} +5 -5
  27. package/dist/{status-HF3ZEKB7.js → status-JZGKH2P6.js} +3 -3
  28. package/dist/{usage-4G23QXCH.js → usage-7S447INI.js} +3 -3
  29. package/dist/{whoami-KTMTQNHM.js → whoami-UFSWPWK6.js} +2 -2
  30. package/package.json +8 -7
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
@@ -76,7 +76,9 @@ async function loadScenario(options) {
76
76
  platform: "node",
77
77
  outfile,
78
78
  // The physics engine is resolved at runtime by whoever needs it, never bundled twice.
79
- external: ["@dimforge/rapier3d-compat"],
79
+ // All three, not just the 3D one: a 2D scenario's engine is loaded by the runtime the same
80
+ // way, and bundling a copy here would be a second instance beside it.
81
+ external: ["@dimforge/rapier3d-compat", "matter-js", "@dimforge/rapier2d-compat"],
80
82
  ...Object.keys(alias).length > 0 ? { alias } : {}
81
83
  });
82
84
  } catch (err) {
@@ -276,6 +278,13 @@ options:
276
278
  --overruns-max <n> fail above this many server tick overruns in the run window
277
279
  (default 0; the count is read from the server, and the run says
278
280
  so when it could not be read)
281
+ --queue <name> queue for rooms through the matchmaker instead of creating one:
282
+ every bot calls the real match API and joins the room its ticket
283
+ names. Needs --control
284
+ --control <https://...> the control plane the queue lives on. \`irtio dev\` runs none, so a
285
+ queued run points at a real one (staging, or a test plane)
286
+ --party <n> group each n consecutive bots into one party, so they queue
287
+ together and land in one room. Needs --queue
279
288
  --profile print where the run's bytes went, by collection and field, as the
280
289
  bots saw them
281
290
  --profile-top <n> rows in that table (default 12; implies --profile)
@@ -350,6 +359,21 @@ function parseSimulateArgs(args) {
350
359
  case "--truth":
351
360
  parsed.truth = true;
352
361
  break;
362
+ // ---- M6 lane E: testing parity ---- (D73-c)
363
+ case "--queue":
364
+ parsed.queue = value();
365
+ break;
366
+ case "--control":
367
+ parsed.control = value();
368
+ break;
369
+ case "--party": {
370
+ const size = positive(value(), "--party");
371
+ if (!Number.isInteger(size) || size < 2) {
372
+ throw new Error("irtio simulate: --party must be a whole number of bots, at least 2");
373
+ }
374
+ parsed.party = size;
375
+ break;
376
+ }
353
377
  case "--profile":
354
378
  parsed.profile = true;
355
379
  break;
@@ -378,6 +402,16 @@ function parseSimulateArgs(args) {
378
402
  throw new Error(`irtio simulate: unknown option ${JSON.stringify(arg)}`);
379
403
  }
380
404
  }
405
+ if (parsed.queue !== void 0 && parsed.control === void 0) {
406
+ throw new Error(
407
+ "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."
408
+ );
409
+ }
410
+ if (parsed.party !== void 0 && parsed.queue === void 0) {
411
+ throw new Error(
412
+ "irtio simulate: --party needs --queue. A party is a group that queues together, and a run that is not queueing has nothing to group."
413
+ );
414
+ }
381
415
  return parsed;
382
416
  }
383
417
  function monorepoPackages() {
@@ -423,16 +457,18 @@ async function loadProjectSchema(options) {
423
457
  }
424
458
  return { schema, file: entry };
425
459
  }
460
+ function is2dWorld(world) {
461
+ return world !== void 0 && world.engine === "matter2d" && !("why" in world);
462
+ }
463
+ function isRapier2dWorld(world) {
464
+ return world !== void 0 && world.engine === "rapier2d" && !("why" in world);
465
+ }
426
466
  function isUnpredictedWorld(world) {
427
- return world !== void 0 && "unpredicted" in world;
467
+ return world !== void 0 && "why" in world;
428
468
  }
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;
469
+ var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
470
+ async function bundleAndImport(entry, outfile, options) {
434
471
  await mkdir2(options.outDir, { recursive: true });
435
- const outfile = path2.join(options.outDir, "world.mjs");
436
472
  const alias = options.irtioPackages ?? monorepoPackages();
437
473
  await esbuild2.build({
438
474
  entryPoints: [entry],
@@ -440,34 +476,106 @@ async function loadProjectWorld(options) {
440
476
  format: "esm",
441
477
  platform: "node",
442
478
  outfile,
443
- external: ["@dimforge/rapier3d-compat"],
479
+ external: ["@dimforge/rapier3d-compat", "@dimforge/rapier2d-compat", "matter-js"],
444
480
  ...alias !== void 0 ? { alias } : {}
445
481
  });
446
- const module = await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
447
- 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.
451
- the shared world-builder must export the same gravity the room config uses
452
- (this is what \`irtio init --physics\` writes)`
453
- );
482
+ return await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
483
+ }
484
+ function hookMap(value) {
485
+ return typeof value === "object" && value !== null ? value : void 0;
486
+ }
487
+ async function detectRoomEngine(options) {
488
+ const entry = ROOM_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find((f) => existsSync2(f));
489
+ if (entry === void 0) return void 0;
490
+ let module;
491
+ try {
492
+ module = await bundleAndImport(entry, path2.join(options.outDir, "room-engine.mjs"), options);
493
+ } catch {
494
+ return void 0;
454
495
  }
455
- if (typeof gravity.z !== "number") {
456
- return { unpredicted: "matter2d", file: entry };
496
+ const definition = module.default ?? module.room;
497
+ const engine = definition?.config?.physics?.engine;
498
+ return typeof engine === "string" ? { engine, file: entry } : void 0;
499
+ }
500
+ async function detectMatter2dRoom(options) {
501
+ const declared = await detectRoomEngine(options);
502
+ return declared?.engine === "matter2d" ? declared.file : void 0;
503
+ }
504
+ function whyNotPredictable(world) {
505
+ if (world.bodies === void 0 || Object.keys(world.bodies).length === 0) {
506
+ return `${world.file} exports no \`bodies\`, so there are no shapes to build a local world from; bots run without client prediction.
507
+ export the body factories your client passes to \`joinRoom({ physics2d })\`.`;
508
+ }
509
+ const settle = world.engine === "matter2d" ? world.settle : void 0;
510
+ const steered = world.intents !== void 0 || settle !== void 0;
511
+ if (!steered && world.gravity.x === 0 && world.gravity.y === 0) {
512
+ const hooks = world.engine === "matter2d" ? "no `intents` and no `settle`" : "no `intents`";
513
+ return `${world.file} exports ${hooks}, and its gravity is zero, so nothing in a local world built from it could ever move a body; bots run without client prediction.
514
+ export the steering hooks your client passes to \`joinRoom({ physics2d })\` (a room that applies its forces inside \`tick()\` has none to share).`;
515
+ }
516
+ return void 0;
517
+ }
518
+ async function loadProjectWorld(options) {
519
+ const entry = WORLD_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
520
+ (f) => existsSync2(f)
521
+ );
522
+ const declared = await detectRoomEngine(options);
523
+ const planarRoom = (why) => declared === void 0 || declared.engine !== "matter2d" && declared.engine !== "rapier2d" ? void 0 : { engine: declared.engine, why: why(declared.file), file: declared.file };
524
+ if (entry === void 0) {
525
+ return planarRoom(
526
+ (room) => `${room} declares a ${declared?.engine ?? "2D"} room and there is no shared world module beside it, so bots run without client prediction.
527
+ export the \`physics2d\` block your client passes to \`joinRoom\` from \`irtio/world.ts\` to predict.`
528
+ );
457
529
  }
458
- if (typeof gravity.y !== "number") {
459
- throw new Error(
460
- `irtio simulate: ${entry} does not export a { x, y, z } gravity.
530
+ const module = await bundleAndImport(entry, path2.join(options.outDir, "world.mjs"), options);
531
+ const gravity = module.gravity;
532
+ const notAWorld = new Error(
533
+ `irtio simulate: ${entry} does not export a { x, y, z } gravity.
461
534
  the shared world-builder must export the same gravity the room config uses
462
535
  (this is what \`irtio init --physics\` writes)`
536
+ );
537
+ if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number") {
538
+ const recognised = planarRoom(
539
+ () => `${entry} exports no gravity, so it is not the client world a ${declared?.engine ?? "2D"} room predicts from; bots run without client prediction.
540
+ export the \`physics2d\` block your client passes to \`joinRoom\` from this module to predict.`
463
541
  );
542
+ if (recognised !== void 0) return recognised;
543
+ throw notAWorld;
544
+ }
545
+ const planarEngine = declared?.engine === "rapier2d" ? "rapier2d" : declared?.engine === "matter2d" ? "matter2d" : declared === void 0 && typeof gravity.z !== "number" ? "matter2d" : void 0;
546
+ if (planarEngine !== void 0) {
547
+ if (typeof gravity.y !== "number") throw notAWorld;
548
+ const common = {
549
+ gravity: { x: gravity.x, y: gravity.y },
550
+ ...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
551
+ ...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
552
+ ...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
553
+ ...typeof module.epsilon === "number" ? { epsilon: module.epsilon } : {},
554
+ ...typeof module.maxPredictedBodies === "number" ? { maxPredictedBodies: module.maxPredictedBodies } : {},
555
+ ...typeof module.smoothingHalfLifeMs === "number" ? { smoothingHalfLifeMs: module.smoothingHalfLifeMs } : {},
556
+ file: entry
557
+ };
558
+ const planar = planarEngine === "rapier2d" ? {
559
+ engine: "rapier2d",
560
+ ...common,
561
+ ...typeof module.setup === "function" ? { setup: module.setup } : {}
562
+ } : {
563
+ engine: "matter2d",
564
+ ...common,
565
+ ...typeof module.setup === "function" ? { setup: module.setup } : {},
566
+ ...hookMap(module.settle) !== void 0 ? { settle: module.settle } : {}
567
+ };
568
+ const why = whyNotPredictable(planar);
569
+ return why === void 0 ? planar : { engine: planarEngine, why, file: entry };
464
570
  }
571
+ if (typeof gravity.y !== "number" || typeof gravity.z !== "number") throw notAWorld;
465
572
  return {
573
+ engine: "rapier3d",
466
574
  gravity,
467
575
  ...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
468
576
  ...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 } : {},
577
+ ...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
578
+ ...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
471
579
  file: entry
472
580
  };
473
581
  }
@@ -493,6 +601,80 @@ function everyByteEqual(a, b) {
493
601
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
494
602
  return true;
495
603
  }
604
+ var DETERMINISM_STEPS_2D = 60;
605
+ function canonicalMatterWorld(matter, engine) {
606
+ return matter.Composite.allBodies(engine.world).map(
607
+ (b) => [b.position.x, b.position.y, b.angle, b.velocity.x, b.velocity.y, b.angularVelocity].join(
608
+ ","
609
+ )
610
+ ).join(";");
611
+ }
612
+ async function checkWorldRapier2dDeterminism(world, schema) {
613
+ const factories = world.bodies ?? {};
614
+ const exercised = schema ? schema.collections.filter(
615
+ (c) => c.physics !== void 0 && factories[c.name] !== void 0
616
+ ) : [];
617
+ if (!world.setup && exercised.length === 0) return void 0;
618
+ const { initRapier2d } = await import("@irtio/runtime");
619
+ const { defaultRecord } = await import("@irtio/schema");
620
+ const rapier = await initRapier2d();
621
+ const build3 = () => {
622
+ const w = new rapier.World({ x: world.gravity.x, y: world.gravity.y });
623
+ try {
624
+ world.setup?.(w, rapier);
625
+ for (const desc of exercised) {
626
+ try {
627
+ const spec = factories[desc.name](rapier, defaultRecord(desc), "irtio-determinism-check");
628
+ if (spec?.body === void 0) continue;
629
+ const body = w.createRigidBody(spec.body);
630
+ for (const collider of spec.colliders ?? []) w.createCollider(collider, body);
631
+ } catch {
632
+ }
633
+ }
634
+ return w.takeSnapshot();
635
+ } finally {
636
+ w.free();
637
+ }
638
+ };
639
+ const first = build3();
640
+ const second = build3();
641
+ if (first.length === second.length && everyByteEqual(first, second)) return void 0;
642
+ return `the world builder is not deterministic: two builds of ${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.`;
643
+ }
644
+ async function checkWorld2dDeterminism(world, schema) {
645
+ const factories = world.bodies ?? {};
646
+ const exercised = schema ? schema.collections.filter(
647
+ (c) => c.physics !== void 0 && factories[c.name] !== void 0
648
+ ) : [];
649
+ if (!world.setup && exercised.length === 0) return void 0;
650
+ const { initMatter } = await import("@irtio/runtime");
651
+ const { defaultRecord } = await import("@irtio/schema");
652
+ const matter = await initMatter();
653
+ const stepMs = (world.timestep ?? 1 / 60) * 1e3;
654
+ const build3 = () => {
655
+ const engine = matter.Engine.create();
656
+ engine.gravity.x = world.gravity.x;
657
+ engine.gravity.y = world.gravity.y;
658
+ try {
659
+ world.setup?.(engine, matter);
660
+ for (const desc of exercised) {
661
+ try {
662
+ const spec = factories[desc.name](matter, defaultRecord(desc), "irtio-determinism-check");
663
+ if (spec?.body !== void 0) matter.Composite.add(engine.world, spec.body);
664
+ } catch {
665
+ }
666
+ }
667
+ for (let i = 0; i < DETERMINISM_STEPS_2D; i++) matter.Engine.update(engine, stepMs);
668
+ return canonicalMatterWorld(matter, engine);
669
+ } finally {
670
+ matter.Engine.clear(engine);
671
+ }
672
+ };
673
+ const first = build3();
674
+ const second = build3();
675
+ if (first === second) return void 0;
676
+ 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.`;
677
+ }
496
678
  var TICK_HEALTH_TIMEOUT_MS = 2e3;
497
679
  function stateUrlFor(wsUrl) {
498
680
  let parsed;
@@ -647,11 +829,30 @@ function formatReport(report, schemaLoaded) {
647
829
  const convergence = report.convergenceLagMs === void 0 ? "convergence not sampled" : `convergence ${report.convergenceLagMs} ms (p50 of ${report.convergence?.samples ?? 0})`;
648
830
  const { mispredictions, mispredictionMagnitude, mispredictionMax, snaps } = report.totals;
649
831
  const misprediction = mispredictions === 0 ? "misprediction 0" : `misprediction mean ${(mispredictionMagnitude / mispredictions).toFixed(1)} max ${mispredictionMax.toFixed(1)}`;
832
+ const { messagesSent, messagesReceived, messagesDropped } = report.totals;
833
+ const messages = messagesSent + messagesReceived + messagesDropped === 0 ? "" : ` \xB7 ${messagesSent} msg out / ${messagesReceived} in` + (messagesDropped > 0 ? ` / ${messagesDropped} dropped` : "");
650
834
  lines.push(
651
835
  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}`
836
+ ` ${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
837
  )
654
838
  );
839
+ const prediction = report.prediction;
840
+ if (prediction !== void 0) {
841
+ const { syncCorrections, proxiedCorrections, suppressedCorrections } = report.totals;
842
+ lines.push(
843
+ pc.dim(
844
+ ` 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`
845
+ )
846
+ );
847
+ if (prediction.absent > 0) {
848
+ lines.push(
849
+ pc.yellow(
850
+ ` ${prediction.absent} instance(s) had no body in a local world at once, so predicted bodies passed through them.
851
+ raise maxProxyBodies, or give the collection a body factory on the client.`
852
+ )
853
+ );
854
+ }
855
+ }
655
856
  if (report.tracePath !== void 0) lines.push(pc.dim(` trace: ${report.tracePath}`));
656
857
  lines.push("");
657
858
  const failed = report.invariants.filter((i) => !i.ok);
@@ -742,7 +943,8 @@ function formatHits(rows) {
742
943
  ` ${pc.bold("hit registration")} ${pc.dim(`(${rows.length} shot(s))`)}`
743
944
  ];
744
945
  for (const row of rows) {
745
- const at = row.serverTick === void 0 ? "no tick" : `tick ${row.serverTick}${row.estimated ? " (estimated)" : ""}`;
946
+ const marks = [row.estimated ? "estimated" : void 0, row.rewound ? "rewound" : void 0].filter((m) => m !== void 0).map((m) => ` (${m})`).join("");
947
+ const at = row.serverTick === void 0 ? "no tick" : `tick ${row.serverTick}${marks}`;
746
948
  if (row.missDistance === void 0) {
747
949
  lines.push(
748
950
  pc.yellow(
@@ -758,14 +960,48 @@ function formatHits(rows) {
758
960
  const measured = rows.filter((r) => r.missDistance !== void 0);
759
961
  if (measured.length > 0) {
760
962
  const mean = measured.reduce((s, r) => s + (r.missDistance ?? 0), 0) / measured.length;
963
+ const worst = Math.max(...measured.map((r) => r.missDistance ?? 0));
964
+ const rewound = measured.filter((r) => r.rewound === true).length;
965
+ 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
966
  lines.push(
762
967
  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.`
968
+ ` mean miss ${mean.toFixed(2)}, worst ${worst.toFixed(2)} over ${measured.length} shot(s). ${verdict}`
764
969
  )
765
970
  );
766
971
  }
767
972
  return lines;
768
973
  }
974
+ function formatMatchmaking(summary, failures) {
975
+ const lines = ["", ` ${pc.bold("matchmaking")}`];
976
+ lines.push(
977
+ pc.dim(
978
+ ` ${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}` : "")
979
+ )
980
+ );
981
+ for (const split of summary.splitParties) {
982
+ lines.push(
983
+ pc.red(
984
+ ` FAIL party ${split.party} was split across ${split.rooms.length} rooms: ` + split.rooms.join(", ")
985
+ )
986
+ );
987
+ }
988
+ for (const room of summary.overfullRooms) {
989
+ lines.push(
990
+ pc.red(
991
+ ` FAIL room ${room.room} holds ${room.bots} bots on a ticket that seats ${room.size}`
992
+ )
993
+ );
994
+ }
995
+ for (const failure of failures) {
996
+ lines.push(
997
+ pc.red(` FAIL bot ${failure.requested} got no ticket: ${failure.code} \u2014 ${failure.message}`)
998
+ );
999
+ }
1000
+ if (summary.splitParties.length === 0 && summary.overfullRooms.length === 0 && failures.length === 0) {
1001
+ lines.push(pc.green(" every bot was answered, and every party landed in one room."));
1002
+ }
1003
+ return lines;
1004
+ }
769
1005
  function point(p) {
770
1006
  const parts = Object.entries(p).map(([k, v]) => `${k} ${Math.round(v * 100) / 100}`);
771
1007
  return parts.length === 0 ? "(none)" : `(${parts.join(", ")})`;
@@ -856,15 +1092,23 @@ async function runSimulation(options = {}) {
856
1092
  (c) => c.physics !== void 0
857
1093
  );
858
1094
  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
- );
1095
+ const world2d = is2dWorld(loadedWorld) ? loadedWorld : void 0;
1096
+ const worldR2d = isRapier2dWorld(loadedWorld) ? loadedWorld : void 0;
1097
+ const world = is2dWorld(loadedWorld) || isRapier2dWorld(loadedWorld) || isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
1098
+ if (world2d !== void 0) {
1099
+ log(`physics: ${world2d.file} is a matter2d world; bots predict with physics2d`);
1100
+ } else if (worldR2d !== void 0) {
1101
+ log(`physics: ${worldR2d.file} is a rapier2d world; bots predict with physics2d`);
1102
+ } else if (isUnpredictedWorld(loadedWorld)) {
1103
+ log(pc.yellow(`physics: ${loadedWorld.why}`));
863
1104
  }
864
- const world = isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
865
1105
  let worldError;
866
1106
  if (world) {
867
1107
  worldError = await checkWorldDeterminism(world);
1108
+ } else if (world2d) {
1109
+ worldError = await checkWorld2dDeterminism(world2d, loaded?.schema);
1110
+ } else if (worldR2d) {
1111
+ worldError = await checkWorldRapier2dDeterminism(worldR2d, loaded?.schema);
868
1112
  }
869
1113
  const conditionsSpec = scenario?.conditions ?? conditionsFrom(options);
870
1114
  const cheatSpec = scenario?.cheat ?? cheatFrom(options);
@@ -883,9 +1127,27 @@ async function runSimulation(options = {}) {
883
1127
  ...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {},
884
1128
  ...options.profile === true ? { profile: true } : {}
885
1129
  };
886
- const predicted = world !== void 0 && worldError === void 0;
1130
+ const predicted = (world !== void 0 || world2d !== void 0 || worldR2d !== void 0) && worldError === void 0;
1131
+ const projectKey = options.key ?? loaded?.schema?.project;
1132
+ if (options.queue !== void 0 && (projectKey === void 0 || projectKey === "")) {
1133
+ throw new RunNotPerformedError(
1134
+ "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."
1135
+ );
1136
+ }
1137
+ const match = options.queue !== void 0 && options.control !== void 0 ? {
1138
+ controlUrl: options.control,
1139
+ project: projectKey,
1140
+ queue: options.queue,
1141
+ ...options.matchTimeoutMs !== void 0 ? { timeoutMs: options.matchTimeoutMs } : {},
1142
+ ...options.party !== void 0 ? (
1143
+ // Consecutive groups of `n`: bots 0..n-1 are party 0, n..2n-1 are party 1. A
1144
+ // trailing group smaller than `n` is still a party, of whatever is left.
1145
+ { party: (index) => Math.floor(index / options.party) }
1146
+ ) : {}
1147
+ } : void 0;
887
1148
  const spawn = {
888
1149
  ...common,
1150
+ ...match !== void 0 ? { match } : {},
889
1151
  ...options.joinTimeoutMs !== void 0 ? { joinTimeoutMs: options.joinTimeoutMs } : {},
890
1152
  ...options.roomGoneGraceMs !== void 0 ? { roomGoneGraceMs: options.roomGoneGraceMs } : {}
891
1153
  };
@@ -895,7 +1157,7 @@ async function runSimulation(options = {}) {
895
1157
  ...spawn,
896
1158
  schema: loaded.schema,
897
1159
  script: scenario?.script ?? randomScript(loaded.schema, { cheat: cheatSpec ?? false }),
898
- ...predicted ? {
1160
+ ...predicted && world !== void 0 ? {
899
1161
  physics: {
900
1162
  gravity: world.gravity,
901
1163
  ...world.timestep !== void 0 ? { timestep: world.timestep } : {},
@@ -903,6 +1165,35 @@ async function runSimulation(options = {}) {
903
1165
  ...world.bodies !== void 0 ? { bodies: world.bodies } : {},
904
1166
  ...world.intents !== void 0 ? { intents: world.intents } : {}
905
1167
  }
1168
+ } : {},
1169
+ // ---- M6 lane E: testing parity ---- (D73-a)
1170
+ ...predicted && world2d !== void 0 ? {
1171
+ physics2d: {
1172
+ gravity: world2d.gravity,
1173
+ ...world2d.timestep !== void 0 ? { timestep: world2d.timestep } : {},
1174
+ ...world2d.setup !== void 0 ? { setup: world2d.setup } : {},
1175
+ ...world2d.bodies !== void 0 ? { bodies: world2d.bodies } : {},
1176
+ ...world2d.intents !== void 0 ? { intents: world2d.intents } : {},
1177
+ ...world2d.settle !== void 0 ? { settle: world2d.settle } : {},
1178
+ ...world2d.epsilon !== void 0 ? { epsilon: world2d.epsilon } : {},
1179
+ ...world2d.maxPredictedBodies !== void 0 ? { maxPredictedBodies: world2d.maxPredictedBodies } : {},
1180
+ ...world2d.smoothingHalfLifeMs !== void 0 ? { smoothingHalfLifeMs: world2d.smoothingHalfLifeMs } : {}
1181
+ }
1182
+ } : {},
1183
+ // The same `physics2d` option, under the engine discriminant: one planar option, two
1184
+ // engines, and the client dispatches on the name rather than on the gravity's shape.
1185
+ ...predicted && worldR2d !== void 0 ? {
1186
+ physics2d: {
1187
+ engine: "rapier2d",
1188
+ gravity: worldR2d.gravity,
1189
+ ...worldR2d.timestep !== void 0 ? { timestep: worldR2d.timestep } : {},
1190
+ ...worldR2d.setup !== void 0 ? { setup: worldR2d.setup } : {},
1191
+ ...worldR2d.bodies !== void 0 ? { bodies: worldR2d.bodies } : {},
1192
+ ...worldR2d.intents !== void 0 ? { intents: worldR2d.intents } : {},
1193
+ ...worldR2d.epsilon !== void 0 ? { epsilon: worldR2d.epsilon } : {},
1194
+ ...worldR2d.maxPredictedBodies !== void 0 ? { maxPredictedBodies: worldR2d.maxPredictedBodies } : {},
1195
+ ...worldR2d.smoothingHalfLifeMs !== void 0 ? { smoothingHalfLifeMs: worldR2d.smoothingHalfLifeMs } : {}
1196
+ }
906
1197
  } : {}
907
1198
  }) : await spawnBots(bots, { ...spawn, script: relayEchoScript() });
908
1199
  } catch (err) {
@@ -1049,6 +1340,9 @@ async function runSimulation(options = {}) {
1049
1340
  if (report.profile !== void 0) {
1050
1341
  for (const line of formatRunProfile(report, options.profileTop)) log(line);
1051
1342
  }
1343
+ if (report.matchmaking !== void 0) {
1344
+ for (const line of formatMatchmaking(report.matchmaking, runner.matchFailures)) log(line);
1345
+ }
1052
1346
  if (adversarial !== void 0) for (const line of formatAdversarial(adversarial)) log(line);
1053
1347
  if (hits.length > 0) for (const line of formatHits(hits)) log(line);
1054
1348
  if (truth !== void 0) for (const line of formatTruth(truth)) log(line);
@@ -1059,11 +1353,14 @@ async function runSimulation(options = {}) {
1059
1353
  log(pc.red(` FAIL world-builder: ${worldError}`));
1060
1354
  } else if (predicted) {
1061
1355
  const suppressed = report.totals.suppressedCorrections;
1356
+ const engine = world2d !== void 0 ? "matter2d" : worldR2d !== void 0 ? "rapier2d" : "rapier3d";
1062
1357
  log(
1063
1358
  pc.dim(
1064
- ` bots predicted physics from ${path2.relative(cwd, world?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
1359
+ ` bots predicted ${engine} physics from ${path2.relative(cwd, world?.file ?? world2d?.file ?? worldR2d?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
1065
1360
  )
1066
1361
  );
1362
+ } else if (hasPhysics && isUnpredictedWorld(loadedWorld)) {
1363
+ log(pc.yellow(" bots interpolated instead of predicting; see the physics line above."));
1067
1364
  } else if (hasPhysics) {
1068
1365
  log(
1069
1366
  pc.yellow(
@@ -1075,7 +1372,10 @@ async function runSimulation(options = {}) {
1075
1372
  log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
1076
1373
  }
1077
1374
  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
1375
+ 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
1376
+ // is exit 1 like every other one. The run still happened for the bots that got in, and
1377
+ // the matchmaking section names the ones that did not.
1378
+ runner.matchFailures.length > 0 || scenarioRun?.ok === false || // D43: a difference between what the clients held and what the save says the server
1079
1379
  // held is a measured violation, so it is exit 1 like every other one. A run that could
1080
1380
  // not take a save at all is not: nothing was measured, and the section says so in
1081
1381
  // words rather than turning "unread" into a verdict, which is D36's `unavailable` rule
@@ -1091,6 +1391,7 @@ async function runSimulation(options = {}) {
1091
1391
  ...truth !== void 0 ? { truth } : {},
1092
1392
  schema: loaded !== void 0,
1093
1393
  predicted,
1394
+ matchFailures: runner.matchFailures,
1094
1395
  ...worldError !== void 0 ? { worldError } : {},
1095
1396
  endedBy,
1096
1397
  ...fatal !== void 0 ? { fatal } : {},
@@ -1129,8 +1430,14 @@ export {
1129
1430
  ScenarioNotRunError,
1130
1431
  USAGE,
1131
1432
  ceilingFor,
1433
+ checkWorld2dDeterminism,
1132
1434
  checkWorldDeterminism,
1435
+ checkWorldRapier2dDeterminism,
1436
+ detectMatter2dRoom,
1437
+ detectRoomEngine,
1133
1438
  formatBuildIdentity,
1439
+ is2dWorld,
1440
+ isRapier2dWorld,
1134
1441
  isUnpredictedWorld,
1135
1442
  loadProjectSchema,
1136
1443
  loadProjectWorld,
@@ -4,11 +4,11 @@ import {
4
4
  runStaticDeploy,
5
5
  staticDeploy,
6
6
  walkStaticDir
7
- } from "./chunk-3HQMVCYA.js";
8
- import "./chunk-RQSJZWQC.js";
9
- import "./chunk-DKWG7MGO.js";
10
- import "./chunk-ZD4ND6X6.js";
11
- import "./chunk-RNAH5T4W.js";
7
+ } from "./chunk-BQBOBFBO.js";
8
+ import "./chunk-WFMRNGO5.js";
9
+ import "./chunk-JL235KIE.js";
10
+ import "./chunk-OCVALOGK.js";
11
+ import "./chunk-IDF46P7R.js";
12
12
  import "./chunk-UPHQM6NZ.js";
13
13
  export {
14
14
  USAGE,
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  readProjectConfig
3
- } from "./chunk-DKWG7MGO.js";
3
+ } from "./chunk-JL235KIE.js";
4
4
  import {
5
5
  HelpRequested,
6
6
  helpFor,
7
7
  helpRequested
8
- } from "./chunk-ZD4ND6X6.js";
8
+ } from "./chunk-OCVALOGK.js";
9
9
  import {
10
10
  createApiClient,
11
11
  isLoginRequired
12
- } from "./chunk-RNAH5T4W.js";
12
+ } from "./chunk-IDF46P7R.js";
13
13
  import {
14
14
  resolveControlUrlForUser
15
15
  } from "./chunk-UPHQM6NZ.js";
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  readProjectConfig
3
- } from "./chunk-DKWG7MGO.js";
3
+ } from "./chunk-JL235KIE.js";
4
4
  import {
5
5
  HelpRequested,
6
6
  helpFor,
7
7
  helpRequested
8
- } from "./chunk-ZD4ND6X6.js";
8
+ } from "./chunk-OCVALOGK.js";
9
9
  import {
10
10
  createApiClient,
11
11
  isLoginRequired
12
- } from "./chunk-RNAH5T4W.js";
12
+ } from "./chunk-IDF46P7R.js";
13
13
  import {
14
14
  resolveControlUrlForUser
15
15
  } from "./chunk-UPHQM6NZ.js";
@@ -2,11 +2,11 @@ import {
2
2
  HelpRequested,
3
3
  helpFor,
4
4
  helpRequested
5
- } from "./chunk-ZD4ND6X6.js";
5
+ } from "./chunk-OCVALOGK.js";
6
6
  import {
7
7
  createApiClient,
8
8
  isLoginRequired
9
- } from "./chunk-RNAH5T4W.js";
9
+ } from "./chunk-IDF46P7R.js";
10
10
  import {
11
11
  resolveControlUrlForUser
12
12
  } from "./chunk-UPHQM6NZ.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "irtio CLI: local dev server, room scaffolding, bot simulation, deploy, logs and rooms",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -45,16 +45,17 @@
45
45
  "esbuild": "^0.27.3",
46
46
  "picocolors": "^1.1.1",
47
47
  "ws": "^8.18.0",
48
- "@irtio/client": "0.6.0",
49
- "@irtio/bots": "0.6.0",
50
- "@irtio/protocol": "0.6.0",
51
- "@irtio/server": "0.6.0",
52
- "@irtio/runtime": "0.6.0",
53
- "@irtio/schema": "0.6.0"
48
+ "@irtio/bots": "0.8.0",
49
+ "@irtio/client": "0.8.0",
50
+ "@irtio/runtime": "0.8.0",
51
+ "@irtio/schema": "0.8.0",
52
+ "@irtio/server": "0.8.0",
53
+ "@irtio/protocol": "0.8.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/ws": "^8.5.13",
57
57
  "@dimforge/rapier3d-compat": "0.20.0",
58
+ "@dimforge/rapier2d-compat": "0.20.0",
58
59
  "@irtio/store": "0.0.0",
59
60
  "@irtio/supervisor": "0.0.0"
60
61
  },