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