@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
@@ -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";
@@ -19,27 +19,42 @@ import "fs";
19
19
  import "fs/promises";
20
20
  import "path";
21
21
  import pc from "picocolors";
22
- var USAGE = `usage: irtio rooms [options]
22
+ var USAGE = `usage: irtio rooms [--active|--idle] [--idle-longer-than <d>] [options]
23
+ irtio rooms get <room> [options]
24
+ irtio rooms set <room> --retention <d|forever|clear> [options]
23
25
  irtio rooms saves <room> [options]
24
26
  irtio rooms restore <room> --save <id> [options]
25
- irtio rooms delete <room> [options]
27
+ irtio rooms delete <room> [--force] [options]
26
28
 
27
- Lists a project's rooms, with the retention window each one's type declares. Rows not reported for
28
- longer than the control plane's row-pruning window (30 days by default) are pruned; that prunes the
29
- listing only and never a room's stored state.
29
+ Lists a project's rooms, with the retention window in force for each one and when it will be
30
+ deleted. Rows not reported for longer than the control plane's row-pruning window (30 days by
31
+ default) are pruned; that prunes the listing only and never a room's stored state.
30
32
 
31
33
  subcommands:
34
+ get <room> one room in full: its type and size class, how many save generations
35
+ it holds, how big its stored state is, and the exact deletion time
36
+ set <room> --retention <d|forever|clear>
37
+ override the retention window for THIS room. A duration like 30m, 6h
38
+ or 7d keeps it that long past its last activity; forever keeps it
39
+ indefinitely; clear goes back to what its type declares
32
40
  saves <room> list a room's save generations, newest first
33
41
  restore <room> --save <id>
34
42
  bring a room back from a save. This DISCARDS its current state, and
35
43
  stops the tenant if it is live
36
44
  delete <room> delete a room's stored state: its snapshot, its saves and its alarms.
37
- This is permanent. Refused while the room is awake
45
+ This is permanent. Refused while the room is awake unless --force
46
+
47
+ list filters:
48
+ --active only rooms that are awake in a live tenant
49
+ --idle only rooms that are not
50
+ --idle-longer-than <d>
51
+ only rooms whose last report is older than a duration like 30m, 6h or 7d
38
52
 
39
53
  options:
54
+ --force with delete: disconnect the room's clients first, then delete it
40
55
  --project <id> project id (default: the project file)
41
56
  -c, --config <f> the project file to read (default irtio.json)
42
- --url <control> control plane (default: your stored login)
57
+ --url <control> control plane (default: your stored login, or IRT_API_KEY)
43
58
  -h, --help print this
44
59
  `;
45
60
  function parseRoomsArgs(args) {
@@ -47,7 +62,7 @@ function parseRoomsArgs(args) {
47
62
  const parsed = { sub: "list" };
48
63
  let rest = args;
49
64
  const first = args[0];
50
- if (first === "saves" || first === "restore" || first === "delete") {
65
+ if (first === "saves" || first === "restore" || first === "delete" || first === "get" || first === "set") {
51
66
  parsed.sub = first;
52
67
  const room = args[1];
53
68
  if (room === void 0 || room.startsWith("-")) {
@@ -65,6 +80,33 @@ function parseRoomsArgs(args) {
65
80
  parsed.save = value;
66
81
  break;
67
82
  }
83
+ // ---- M6 lane A: rooms API ----
84
+ case "--active":
85
+ parsed.active = true;
86
+ break;
87
+ case "--idle":
88
+ parsed.active = false;
89
+ break;
90
+ case "--idle-longer-than": {
91
+ const value = rest[++i];
92
+ if (value === void 0) {
93
+ throw new Error("irtio rooms: --idle-longer-than needs a duration like 30m, 6h or 7d");
94
+ }
95
+ parsed.idleLongerThan = value;
96
+ break;
97
+ }
98
+ case "--retention": {
99
+ const value = rest[++i];
100
+ if (value === void 0) {
101
+ throw new Error("irtio rooms: --retention needs a duration, forever, or clear");
102
+ }
103
+ parsed.retention = value;
104
+ break;
105
+ }
106
+ case "--force":
107
+ parsed.force = true;
108
+ break;
109
+ // ---- end M6 lane A ----
68
110
  case "--project": {
69
111
  const value = rest[++i];
70
112
  if (value === void 0) throw new Error("irtio rooms: --project needs a value");
@@ -94,6 +136,23 @@ function parseRoomsArgs(args) {
94
136
  "irtio rooms restore: --save <id> is required \u2014 run `irtio rooms saves <room>` to see them"
95
137
  );
96
138
  }
139
+ if (parsed.sub === "set" && parsed.retention === void 0) {
140
+ throw new Error(
141
+ "irtio rooms set: --retention <d|forever|clear> is required. A duration like 7d keeps this room that long past its last activity, forever keeps it indefinitely, and clear goes back to what its type declares"
142
+ );
143
+ }
144
+ if (parsed.active !== void 0 && parsed.sub !== "list") {
145
+ throw new Error("irtio rooms: --active and --idle are filters for the listing");
146
+ }
147
+ if (parsed.idleLongerThan !== void 0 && parsed.sub !== "list") {
148
+ throw new Error("irtio rooms: --idle-longer-than is a filter for the listing");
149
+ }
150
+ if (parsed.force === true && parsed.sub !== "delete") {
151
+ throw new Error("irtio rooms: --force belongs to `irtio rooms delete`");
152
+ }
153
+ if (parsed.retention !== void 0 && parsed.sub !== "set") {
154
+ throw new Error("irtio rooms: --retention belongs to `irtio rooms set`");
155
+ }
97
156
  return parsed;
98
157
  }
99
158
  function formatBytes(bytes) {
@@ -114,16 +173,60 @@ function formatAge(iso, now) {
114
173
  function statusColor(status) {
115
174
  return status === "active" || status === "running" ? pc.green(status) : status === "draining" ? pc.yellow(status) : pc.dim(status);
116
175
  }
117
- function formatTable(rows) {
176
+ function formatTable(rows, now) {
118
177
  if (rows.length === 0) return [pc.dim("no rooms")];
119
178
  const idWidth = Math.max(4, ...rows.map((r) => r.roomId.length));
120
- const out = [pc.dim(`${"ROOM".padEnd(idWidth)} STATUS RETAIN LAST SEEN`)];
179
+ const sharded = rows.some((r) => (r.shard ?? 0) > 0);
180
+ const head = `${"ROOM".padEnd(idWidth)} STATUS RETAIN DELETES LAST SEEN` + (sharded ? " SHARD" : "");
181
+ const out = [pc.dim(head)];
121
182
  for (const r of rows) {
122
183
  const retention = r.retention ?? "forever";
184
+ const mark = r.retentionOverride ? "*" : "";
123
185
  out.push(
124
- `${r.roomId.padEnd(idWidth)} ${statusColor(r.status).padEnd(10)} ${retention.padEnd(6)} ${pc.dim(r.lastSeen)}`
186
+ `${r.roomId.padEnd(idWidth)} ${statusColor(r.status).padEnd(10)} ${`${retention}${mark}`.padEnd(6)} ${formatDeadline(r.deletesAt, now).padEnd(13)} ${pc.dim(r.lastSeen)}` + (sharded ? ` ${String(r.shard ?? 0)}` : "")
125
187
  );
126
188
  }
189
+ if (rows.some((r) => r.retentionOverride)) {
190
+ out.push(pc.dim("* set on this room, not declared by its type"));
191
+ }
192
+ return out;
193
+ }
194
+ function formatDeadline(deletesAt, now) {
195
+ if (!deletesAt) return pc.dim("never");
196
+ const ms = Date.parse(deletesAt) - now;
197
+ if (!Number.isFinite(ms)) return pc.dim("never");
198
+ if (ms <= 0) return pc.yellow("next sweep");
199
+ const mins = Math.floor(ms / 6e4);
200
+ if (mins < 60) return `in ${mins}m`;
201
+ const hours = Math.floor(mins / 60);
202
+ if (hours < 48) return `in ${hours}h`;
203
+ return `in ${Math.floor(hours / 24)}d`;
204
+ }
205
+ function formatDetail(room, now) {
206
+ const line = (label, value) => `${pc.dim(label.padEnd(12))}${value}`;
207
+ const out = [
208
+ line("room", pc.bold(room.roomId)),
209
+ line("status", statusColor(room.status)),
210
+ line("type", room.type ?? pc.dim("not a room id"))
211
+ ];
212
+ out.push(line("class", room.class ?? pc.dim("not declared (billed as small)")));
213
+ if (room.clients !== null && room.clients !== void 0) {
214
+ const max = room.maxClients ? `/${room.maxClients}` : "";
215
+ out.push(line("clients", `${room.clients}${max}`));
216
+ }
217
+ out.push(line("retention", room.retention ?? "forever"));
218
+ if (room.retentionOverride) {
219
+ out.push(line("", pc.dim(`set on this room (${room.retentionOverride}), not by its type`)));
220
+ }
221
+ out.push(line("deletes", formatDeadline(room.deletesAt, now)));
222
+ out.push(line("saves", String(room.saves)));
223
+ out.push(
224
+ line(
225
+ "state",
226
+ room.stateBytes === void 0 ? pc.dim("unknown while the room is awake; read it after it sleeps") : formatBytes(room.stateBytes)
227
+ )
228
+ );
229
+ out.push(line("last seen", pc.dim(room.lastSeen)));
127
230
  return out;
128
231
  }
129
232
  function formatSaves(rows, now) {
@@ -180,8 +283,16 @@ async function runDeleteRoom(options) {
180
283
  log(
181
284
  `${pc.yellow("deleting")} room ${pc.bold(options.room)}: its snapshot, its saves and its alarms`
182
285
  );
286
+ if (options.force === true) {
287
+ log(
288
+ pc.yellow(
289
+ "forced: if the room is awake, every client in it is disconnected first and sees E_ROOM_DELETED. They cannot rejoin the same room; a fresh one starts empty"
290
+ )
291
+ );
292
+ }
183
293
  const result = await client.del(
184
- `/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}`
294
+ `/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}`,
295
+ options.force === true ? { force: "true" } : {}
185
296
  );
186
297
  log(pc.green(`deleted ${options.room}: ${result.objects} stored object(s) removed`));
187
298
  log(pc.dim("player data and leaderboard scores are keyed to the player and are untouched"));
@@ -191,10 +302,40 @@ async function runRooms(options) {
191
302
  const log = options.log ?? ((line) => console.log(line));
192
303
  const controlUrl = await resolveControlUrlForUser(options.controlUrl);
193
304
  const client = options.client ?? await createApiClient(controlUrl);
194
- const rows = await client.get(`/v1/projects/${options.project}/rooms`);
195
- for (const line of formatTable(rows)) log(line);
305
+ const rows = await client.get(`/v1/projects/${options.project}/rooms`, {
306
+ ...options.active !== void 0 ? { active: String(options.active) } : {},
307
+ ...options.idleLongerThan !== void 0 ? { "idle-longer-than": options.idleLongerThan } : {}
308
+ });
309
+ for (const line of formatTable(rows, options.now ?? Date.now())) log(line);
196
310
  return rows;
197
311
  }
312
+ async function runGetRoom(options) {
313
+ const log = options.log ?? ((line) => console.log(line));
314
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
315
+ const client = options.client ?? await createApiClient(controlUrl);
316
+ const room = await client.get(
317
+ `/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}`
318
+ );
319
+ for (const line of formatDetail(room, options.now ?? Date.now())) log(line);
320
+ return room;
321
+ }
322
+ async function runSetRoom(options) {
323
+ const log = options.log ?? ((line) => console.log(line));
324
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
325
+ const client = options.client ?? await createApiClient(controlUrl);
326
+ const retention = options.retention === "clear" ? null : options.retention;
327
+ const room = await client.patch(
328
+ `/v1/projects/${options.project}/rooms/${encodeURIComponent(options.room)}`,
329
+ { retention }
330
+ );
331
+ log(
332
+ retention === null ? pc.green(
333
+ `${options.room}: override cleared, back to its type's window (${room.retention ?? "forever"})`
334
+ ) : pc.green(`${options.room}: retention set to ${retention}`)
335
+ );
336
+ for (const line of formatDetail(room, options.now ?? Date.now())) log(line);
337
+ return room;
338
+ }
198
339
  async function rooms(args, deps = {}) {
199
340
  const log = deps.log ?? ((line) => console.log(line));
200
341
  const errorLog = deps.errorLog ?? ((line) => console.error(line));
@@ -216,15 +357,31 @@ async function rooms(args, deps = {}) {
216
357
  if (parsed.sub === "saves") {
217
358
  await runSaves({ ...common, room: parsed.room });
218
359
  } else if (parsed.sub === "delete") {
219
- await runDeleteRoom({ ...common, room: parsed.room });
360
+ await runDeleteRoom({
361
+ ...common,
362
+ room: parsed.room,
363
+ ...parsed.force === true ? { force: true } : {}
364
+ });
220
365
  } else if (parsed.sub === "restore") {
221
366
  await runRestore({
222
367
  ...common,
223
368
  room: parsed.room,
224
369
  saveId: parsed.save
225
370
  });
371
+ } else if (parsed.sub === "get") {
372
+ await runGetRoom({ ...common, room: parsed.room });
373
+ } else if (parsed.sub === "set") {
374
+ await runSetRoom({
375
+ ...common,
376
+ room: parsed.room,
377
+ retention: parsed.retention
378
+ });
226
379
  } else {
227
- await runRooms(common);
380
+ await runRooms({
381
+ ...common,
382
+ ...parsed.active !== void 0 ? { active: parsed.active } : {},
383
+ ...parsed.idleLongerThan !== void 0 ? { idleLongerThan: parsed.idleLongerThan } : {}
384
+ });
228
385
  }
229
386
  } catch (err) {
230
387
  if (err instanceof HelpRequested) {
@@ -246,7 +403,9 @@ export {
246
403
  parseRoomsArgs,
247
404
  rooms,
248
405
  runDeleteRoom,
406
+ runGetRoom,
249
407
  runRestore,
250
408
  runRooms,
251
- runSaves
409
+ runSaves,
410
+ runSetRoom
252
411
  };
@@ -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,122 @@ 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 rapier2d world, as `joinRoom({ physics2d: { engine: 'rapier2d', … } })` wants it.
164
+ *
165
+ * The 2D sibling of {@link LoadedWorld2d}, and it differs in exactly the ways the engine does:
166
+ * there is no `settle` (the world applies gravity, so a crate nobody steers falls on its own —
167
+ * a rapier2d world that had one would fall twice as fast locally), and the default `epsilon` is
168
+ * already right, because a Rapier world is metre-scale by construction rather than by convention.
169
+ */
170
+ interface LoadedWorldRapier2d {
171
+ readonly engine: 'rapier2d';
172
+ readonly gravity: {
173
+ x: number;
174
+ y: number;
175
+ };
176
+ readonly timestep?: number | undefined;
177
+ readonly setup?: ((world: unknown, rapier: unknown) => void) | undefined;
178
+ readonly bodies?: Record<string, (...args: never[]) => unknown> | undefined;
179
+ readonly intents?: Record<string, (...args: never[]) => void> | undefined;
180
+ readonly epsilon?: number | undefined;
181
+ readonly maxPredictedBodies?: number | undefined;
182
+ readonly smoothingHalfLifeMs?: number | undefined;
183
+ readonly file: string;
184
+ }
185
+ /**
186
+ * A matter2d project this loader recognises and cannot predict from: run it, say why in one
187
+ * sentence, and never pretend to a predictor that does not exist.
188
+ *
189
+ * `bugs.md` #21 introduced this for every matter2d project, because there was no client
190
+ * prediction at all. D73-a narrows it to the two cases that are still true: a world module with
191
+ * no `bodies` (there is nothing to build), and an **inert** one — no `intents`, no `settle`, and
192
+ * zero gravity, so nothing in a local world built from it could ever move a body.
193
+ * `examples/matter-chase` is the second case by design: its room applies thrust by hand inside
194
+ * `tick()`, so there is no steering hook to share and its own client does not predict either.
118
195
  */
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
196
  interface UnpredictedWorld {
122
- readonly unpredicted: 'matter2d';
197
+ /** Which planar engine the room declares. Both can be recognised and refused. */
198
+ readonly engine: 'matter2d' | 'rapier2d';
199
+ /** One sentence, printed by the run, naming what would have to change. */
200
+ readonly why: string;
123
201
  readonly file: string;
124
202
  }
125
- declare function isUnpredictedWorld(world: LoadedWorld | UnpredictedWorld | undefined): world is UnpredictedWorld;
126
- declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWorld | UnpredictedWorld | undefined>;
203
+ type LoadedAnyWorld = LoadedWorld | LoadedWorld2d | LoadedWorldRapier2d | UnpredictedWorld;
204
+ /** Narrows a loaded world to a matter2d one bots can predict with. */
205
+ declare function is2dWorld(world: LoadedAnyWorld | undefined): world is LoadedWorld2d;
206
+ /** Narrows a loaded world to a rapier2d one bots can predict with. */
207
+ declare function isRapier2dWorld(world: LoadedAnyWorld | undefined): world is LoadedWorldRapier2d;
208
+ declare function isUnpredictedWorld(world: LoadedAnyWorld | undefined): world is UnpredictedWorld;
209
+ /**
210
+ * Which engine does this project's room declare, and in which file?
211
+ *
212
+ * D73-a asked this question of matter2d only, and for one reason: a matter2d project whose world
213
+ * module is not a client world — `games/dive`, whose engine gravity is zero and applied per body,
214
+ * so there is no `gravity` to export — used to be refused at the door with "does not export a
215
+ * { x, y, z } gravity", which is why `irtio simulate` had never been run against dive at all.
216
+ *
217
+ * rapier2d widens it from a yes/no to a name, because the old fallback cannot tell the two planar
218
+ * engines apart: both export a `{ x, y }` gravity, and guessing from that shape alone would run a
219
+ * rapier2d project's bots on matter.js and blame the mispredictions on netcode. The room's own
220
+ * declaration is the only thing that actually knows, so it is asked first and the gravity shape is
221
+ * kept only as the fallback for a project whose room this loader could not read.
222
+ *
223
+ * `undefined` for every other answer: no room module, one that will not build or import, or one
224
+ * whose config declares no physics. None of those is an error here — the world module's own error
225
+ * is the right one to raise.
226
+ */
227
+ declare function detectRoomEngine(options: LoadSchemaOptions): Promise<{
228
+ engine: string;
229
+ file: string;
230
+ } | undefined>;
231
+ /** D73-a's original question, kept as the narrow form of {@link detectRoomEngine}. */
232
+ declare function detectMatter2dRoom(options: LoadSchemaOptions): Promise<string | undefined>;
233
+ /**
234
+ * Bundles and imports the project's shared world-builder module, so the bots predict physics the
235
+ * way a browser client would.
236
+ */
237
+ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedAnyWorld | undefined>;
127
238
  /**
128
239
  * The build-twice determinism check: builds two worlds from the shared `setup` and compares
129
240
  * Rapier's own snapshots byte for byte. A builder that is not pure over synced inputs
@@ -132,6 +243,36 @@ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWor
132
243
  * description, or `undefined` when the worlds agree.
133
244
  */
134
245
  declare function checkWorldDeterminism(world: LoadedWorld): Promise<string | undefined>;
246
+ /**
247
+ * The rapier2d determinism check: the Rapier one, in a plane.
248
+ *
249
+ * It is the 3D check rather than the matter2d one, because the 2D build has the same
250
+ * `takeSnapshot()` the 3D build has — two builds of the same `setup` either produce the same bytes
251
+ * or the builder is not pure over synced inputs. No stepping is needed to make small randomness
252
+ * visible: it is already in the snapshot.
253
+ *
254
+ * The body factories are exercised too, against each collection's zero record, the way the
255
+ * matter2d check does it — a `Math.random()` in a factory is exactly as damaging as one in
256
+ * `setup`, and the snapshot sees it for free.
257
+ */
258
+ declare function checkWorldRapier2dDeterminism(world: LoadedWorldRapier2d, schema?: AnySchema): Promise<string | undefined>;
259
+ /**
260
+ * D73-a: the matter2d determinism check.
261
+ *
262
+ * The Rapier check compares two `takeSnapshot()` byte strings, and matter.js has no snapshot at
263
+ * all (`packages/runtime/src/core/matter.ts`'s header says so), so this one is built the only way
264
+ * that is left: build the world twice through the engine the runtime loads, step both the same
265
+ * number of times with the same (empty) intent stream, and compare every body's pose and velocity
266
+ * through a canonical encoding. Two builds that disagree came from a builder that is not pure over
267
+ * synced inputs, which is the same fault the Rapier check catches and the same reason it matters —
268
+ * every "misprediction" it causes is blamed on netcode.
269
+ *
270
+ * Stepping, rather than comparing the two worlds as built, is what makes it catch randomness that
271
+ * is small at t=0: a crate spawned a thousandth of a unit off lands in a different place. The body
272
+ * factories are exercised too, against each collection's zero record, so a `Math.random()` in a
273
+ * factory is caught and not only one in `setup`.
274
+ */
275
+ declare function checkWorld2dDeterminism(world: LoadedWorld2d, schema?: AnySchema): Promise<string | undefined>;
135
276
  /**
136
277
  * The dev server's JSON view, which is where a local run reads the server's own tick counters.
137
278
  * `irtio dev` serves it on the socket's own port; nothing else does, and that is deliberate — a
@@ -221,6 +362,12 @@ interface RunSimulationOptions extends Partial<SimulateArgs> {
221
362
  * is a fatal run condition, not a clean end.
222
363
  */
223
364
  readonly ceilingMs?: number;
365
+ /**
366
+ * D73-c: how long a queued bot waits for its ticket. The control plane caps this at two minutes
367
+ * and applies its own default (30 s) when it is omitted; a run that wants to see a queue *not*
368
+ * fill needs a bound shorter than its own patience.
369
+ */
370
+ readonly matchTimeoutMs?: number;
224
371
  /** @internal Test seam, same as `startDev`'s. */
225
372
  readonly irtioPackages?: Record<string, string> | undefined;
226
373
  }
@@ -296,6 +443,12 @@ interface SimulationRun {
296
443
  readonly schema: boolean;
297
444
  /** `true` when the bots predicted physics from a shared world-builder module (D22 part 2). */
298
445
  readonly predicted: boolean;
446
+ /** D73-c: bots that never got a ticket. Empty on a run that did not queue. */
447
+ readonly matchFailures: readonly {
448
+ requested: number;
449
+ code: string;
450
+ message: string;
451
+ }[];
299
452
  /** Result of the build-twice world-builder check: the failure text, or `undefined` if it held
300
453
  * (or did not apply). A failure also fails the run. */
301
454
  readonly worldError?: string;
@@ -312,4 +465,4 @@ interface SimulationRun {
312
465
  declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
313
466
  declare function simulate(args: readonly string[]): Promise<void>;
314
467
 
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 };
468
+ export { type AdversarialRun, type BuildIdentity, type LoadSchemaOptions, type LoadedAnyWorld, type LoadedWorld2d, type LoadedWorldRapier2d, RunNotPerformedError, type RunSimulationOptions, ScenarioNotRunError, type ScenarioRun, type SimulateArgs, type SimulationEnd, type SimulationRun, type TruthRun, USAGE, type UnpredictedWorld, ceilingFor, checkWorld2dDeterminism, checkWorldDeterminism, checkWorldRapier2dDeterminism, detectMatter2dRoom, detectRoomEngine, formatBuildIdentity, is2dWorld, isRapier2dWorld, isUnpredictedWorld, loadProjectSchema, loadProjectWorld, loadScenario, parseSimulateArgs, readBuildIdentity, readTickCounters, runSimulation, simulate, stateUrlFor, tickHealthFrom };