@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.
- package/dist/api-keys-UTLYMZYN.js +222 -0
- package/dist/api.js +1 -1
- package/dist/bundle.js +1 -1
- package/dist/{chunk-3HQMVCYA.js → chunk-BQBOBFBO.js} +4 -4
- package/dist/{chunk-RNAH5T4W.js → chunk-IDF46P7R.js} +2 -0
- package/dist/{chunk-DKWG7MGO.js → chunk-JL235KIE.js} +1 -1
- package/dist/{chunk-OTSFRVJN.js → chunk-NOYGY3HA.js} +10 -3
- package/dist/{chunk-ZD4ND6X6.js → chunk-OCVALOGK.js} +1 -1
- package/dist/{chunk-RQSJZWQC.js → chunk-WFMRNGO5.js} +134 -105
- package/dist/{delete-project-VENS2B44.js → delete-project-MXUYNGAO.js} +2 -2
- package/dist/deploy.d.ts +3 -1
- package/dist/deploy.js +164 -6
- package/dist/{dev-QM26ONKS.js → dev-VJ2ATJTF.js} +220 -14
- package/dist/index.js +39 -15
- package/dist/init.js +3 -3
- package/dist/{keys-JHLMEGRA.js → keys-NXRIBJZP.js} +3 -3
- package/dist/{leaderboard-SYPSBPS3.js → leaderboard-ZBJBKETM.js} +65 -11
- package/dist/{login-2M73HBZT.js → login-3RVN5PPN.js} +2 -2
- package/dist/{logs-2W7CPZO5.js → logs-AT7G6YRH.js} +3 -3
- package/dist/{migrate-T3DZJREY.js → migrate-5YCFT63L.js} +4 -4
- package/dist/{ratings-VG32WFDG.js → ratings-XBLX2MUW.js} +3 -3
- package/dist/{rollback-SO74MVZV.js → rollback-LI4TDLQA.js} +3 -3
- package/dist/{rooms-VI33P4RA.js → rooms-52Q5KBUS.js} +179 -20
- package/dist/simulate.d.ts +163 -10
- package/dist/simulate.js +345 -38
- package/dist/{static-deploy-KOWFKWZA.js → static-deploy-7UCYINJB.js} +5 -5
- package/dist/{status-HF3ZEKB7.js → status-JZGKH2P6.js} +3 -3
- package/dist/{usage-4G23QXCH.js → usage-7S447INI.js} +3 -3
- package/dist/{whoami-KTMTQNHM.js → whoami-UFSWPWK6.js} +2 -2
- package/package.json +8 -7
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
readProjectConfig
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-JL235KIE.js";
|
|
4
4
|
import {
|
|
5
5
|
HelpRequested,
|
|
6
6
|
helpFor,
|
|
7
7
|
helpRequested
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-OCVALOGK.js";
|
|
9
9
|
import {
|
|
10
10
|
createApiClient,
|
|
11
11
|
isLoginRequired
|
|
12
|
-
} from "./chunk-
|
|
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
|
|
28
|
-
longer than the control plane's row-pruning window (30 days by
|
|
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
|
|
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
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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
|
};
|
package/dist/simulate.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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 };
|