@irtio/cli 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-keys-UTLYMZYN.js +222 -0
- package/dist/api.d.ts +52 -0
- package/dist/api.js +15 -0
- package/dist/bundle.js +1 -1
- package/dist/{chunk-GBNHBWES.js → chunk-BQBOBFBO.js} +10 -6
- package/dist/chunk-IDF46P7R.js +98 -0
- package/dist/{chunk-32QTPKVT.js → chunk-JL235KIE.js} +1 -1
- package/dist/chunk-OCVALOGK.js +31 -0
- package/dist/{chunk-KRQUAEN2.js → chunk-OTSFRVJN.js} +12 -6
- package/dist/chunk-UPHQM6NZ.js +72 -0
- package/dist/chunk-WFMRNGO5.js +481 -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-MXUYNGAO.js +118 -0
- package/dist/deploy.d.ts +151 -0
- package/dist/{deploy-3SABPL3T.js → deploy.js} +324 -44
- package/dist/{dev-QJOGXLKM.js → dev-AUZ4OLA3.js} +3066 -211
- package/dist/index.js +121 -25
- package/dist/init.d.ts +1 -1
- package/dist/init.js +20 -4
- package/dist/{keys-XBORZAPI.js → keys-NXRIBJZP.js} +8 -4
- package/dist/leaderboard-ZBJBKETM.js +406 -0
- package/dist/{login-3EXB4CGX.js → login-3RVN5PPN.js} +12 -5
- package/dist/{logs-2EOXLNWF.js → logs-AT7G6YRH.js} +9 -5
- package/dist/{migrate-WUO2GBMX.js → migrate-FMXTRVUV.js} +12 -8
- package/dist/ratings-XBLX2MUW.js +297 -0
- package/dist/{rollback-GA6UY772.js → rollback-LI4TDLQA.js} +9 -5
- package/dist/rooms-52Q5KBUS.js +411 -0
- package/dist/simulate.d.ts +254 -6
- package/dist/simulate.js +925 -64
- package/dist/{static-deploy-5TBH4VNA.js → static-deploy-7UCYINJB.js} +6 -4
- package/dist/status-JZGKH2P6.js +219 -0
- package/dist/usage-7S447INI.js +213 -0
- package/dist/{whoami-CI5D5RCC.js → whoami-UFSWPWK6.js} +8 -4
- package/package.json +23 -7
- package/dist/chunk-BPE452KF.js +0 -180
- package/dist/chunk-TV66QHFP.js +0 -167
- package/dist/rooms-B66LQIIF.js +0 -226
package/dist/simulate.js
CHANGED
|
@@ -1,18 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatProfileTable
|
|
3
|
+
} from "./chunk-ZK5JLUD4.js";
|
|
1
4
|
import {
|
|
2
5
|
HelpRequested,
|
|
3
6
|
helpFor,
|
|
4
7
|
helpRequested,
|
|
5
8
|
trailerFor
|
|
6
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-OCVALOGK.js";
|
|
10
|
+
import "./chunk-IDF46P7R.js";
|
|
11
|
+
import "./chunk-UPHQM6NZ.js";
|
|
7
12
|
|
|
8
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
|
|
9
35
|
import { existsSync } from "fs";
|
|
10
36
|
import { mkdir } from "fs/promises";
|
|
11
37
|
import * as path from "path";
|
|
12
38
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
13
|
-
import {
|
|
39
|
+
import { looksLikeScenario } from "@irtio/bots";
|
|
14
40
|
import * as esbuild from "esbuild";
|
|
15
|
-
|
|
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
|
|
16
202
|
var DEFAULT_URL = "ws://localhost:7070";
|
|
17
203
|
var DEFAULT_BOTS = 5;
|
|
18
204
|
var DEFAULT_SECONDS = 10;
|
|
@@ -32,6 +218,36 @@ function nonNegativeInt(raw, flag) {
|
|
|
32
218
|
}
|
|
33
219
|
return value;
|
|
34
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
|
+
}
|
|
35
251
|
var USAGE = `usage: irtio simulate [options]
|
|
36
252
|
|
|
37
253
|
Drives N real clients at a running room and checks the built-in invariants. Run it from a project
|
|
@@ -44,13 +260,32 @@ options:
|
|
|
44
260
|
--url <ws://...> where to connect (default ${DEFAULT_URL})
|
|
45
261
|
--key <projectKey> the project key to present
|
|
46
262
|
--trace <path> write the frame trace here
|
|
47
|
-
--
|
|
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
|
|
48
273
|
--misprediction-max <units> fail if one correction snaps a prediction further than this
|
|
49
274
|
--snaps-max <n> fail above this many cap-exceeded reconciliations
|
|
50
275
|
--corrections-max <perSec> fail above this correction rate per bot
|
|
51
276
|
--overruns-max <n> fail above this many server tick overruns in the run window
|
|
52
277
|
(default 0; the count is read from the server, and the run says
|
|
53
278
|
so when it could not be read)
|
|
279
|
+
--queue <name> queue for rooms through the matchmaker instead of creating one:
|
|
280
|
+
every bot calls the real match API and joins the room its ticket
|
|
281
|
+
names. Needs --control
|
|
282
|
+
--control <https://...> the control plane the queue lives on. \`irtio dev\` runs none, so a
|
|
283
|
+
queued run points at a real one (staging, or a test plane)
|
|
284
|
+
--party <n> group each n consecutive bots into one party, so they queue
|
|
285
|
+
together and land in one room. Needs --queue
|
|
286
|
+
--profile print where the run's bytes went, by collection and field, as the
|
|
287
|
+
bots saw them
|
|
288
|
+
--profile-top <n> rows in that table (default 12; implies --profile)
|
|
54
289
|
-h, --help print this
|
|
55
290
|
|
|
56
291
|
exit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be
|
|
@@ -92,9 +327,63 @@ function parseSimulateArgs(args) {
|
|
|
92
327
|
case "--trace":
|
|
93
328
|
parsed.trace = value();
|
|
94
329
|
break;
|
|
330
|
+
case "--scenario":
|
|
331
|
+
parsed.scenario = value();
|
|
332
|
+
break;
|
|
95
333
|
case "--cheat":
|
|
96
334
|
parsed.cheat = true;
|
|
97
335
|
break;
|
|
336
|
+
case "--cheat-bot":
|
|
337
|
+
(parsed.cheatBots ??= []).push(nonNegativeInt(value(), "--cheat-bot"));
|
|
338
|
+
break;
|
|
339
|
+
case "--conditions":
|
|
340
|
+
parsed.conditions = parseConditions(value(), "--conditions");
|
|
341
|
+
break;
|
|
342
|
+
case "--conditions-bot": {
|
|
343
|
+
const raw = value();
|
|
344
|
+
const colon = raw.indexOf(":");
|
|
345
|
+
if (colon === -1) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`irtio simulate: --conditions-bot takes <index>:<json>, for example 0:{"rttMs":200}. Got ${JSON.stringify(raw)}`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
const index = nonNegativeInt(raw.slice(0, colon), "--conditions-bot");
|
|
351
|
+
(parsed.conditionsPerBot ??= {})[index] = parseConditions(
|
|
352
|
+
raw.slice(colon + 1),
|
|
353
|
+
"--conditions-bot"
|
|
354
|
+
);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "--truth":
|
|
358
|
+
parsed.truth = true;
|
|
359
|
+
break;
|
|
360
|
+
// ---- M6 lane E: testing parity ---- (D73-c)
|
|
361
|
+
case "--queue":
|
|
362
|
+
parsed.queue = value();
|
|
363
|
+
break;
|
|
364
|
+
case "--control":
|
|
365
|
+
parsed.control = value();
|
|
366
|
+
break;
|
|
367
|
+
case "--party": {
|
|
368
|
+
const size = positive(value(), "--party");
|
|
369
|
+
if (!Number.isInteger(size) || size < 2) {
|
|
370
|
+
throw new Error("irtio simulate: --party must be a whole number of bots, at least 2");
|
|
371
|
+
}
|
|
372
|
+
parsed.party = size;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "--profile":
|
|
376
|
+
parsed.profile = true;
|
|
377
|
+
break;
|
|
378
|
+
case "--profile-top": {
|
|
379
|
+
const n = Number(value());
|
|
380
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) {
|
|
381
|
+
throw new Error("irtio simulate: --profile-top must be a row count between 1 and 200");
|
|
382
|
+
}
|
|
383
|
+
parsed.profile = true;
|
|
384
|
+
parsed.profileTop = n;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
98
387
|
case "--misprediction-max":
|
|
99
388
|
parsed.mispredictionMax = positive(value(), "--misprediction-max");
|
|
100
389
|
break;
|
|
@@ -111,32 +400,42 @@ function parseSimulateArgs(args) {
|
|
|
111
400
|
throw new Error(`irtio simulate: unknown option ${JSON.stringify(arg)}`);
|
|
112
401
|
}
|
|
113
402
|
}
|
|
403
|
+
if (parsed.queue !== void 0 && parsed.control === void 0) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
"irtio simulate: --queue needs --control. The matchmaker lives on the control plane and `irtio dev` runs none, so there is no queue at a dev server to join."
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (parsed.party !== void 0 && parsed.queue === void 0) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
"irtio simulate: --party needs --queue. A party is a group that queues together, and a run that is not queueing has nothing to group."
|
|
411
|
+
);
|
|
412
|
+
}
|
|
114
413
|
return parsed;
|
|
115
414
|
}
|
|
116
415
|
function monorepoPackages() {
|
|
117
|
-
const packagesDir =
|
|
118
|
-
const schema =
|
|
119
|
-
if (!
|
|
416
|
+
const packagesDir = path2.resolve(path2.dirname(fileURLToPath2(import.meta.url)), "../..");
|
|
417
|
+
const schema = path2.join(packagesDir, "schema/src/index.ts");
|
|
418
|
+
if (!existsSync2(schema)) return void 0;
|
|
120
419
|
return {
|
|
121
420
|
"@irtio/schema": schema,
|
|
122
|
-
"@irtio/protocol":
|
|
123
|
-
"@irtio/server":
|
|
421
|
+
"@irtio/protocol": path2.join(packagesDir, "protocol/src/index.ts"),
|
|
422
|
+
"@irtio/server": path2.join(packagesDir, "server/src/index.ts")
|
|
124
423
|
};
|
|
125
424
|
}
|
|
126
|
-
var
|
|
425
|
+
var importSeq2 = 0;
|
|
127
426
|
function looksLikeSchema(value) {
|
|
128
427
|
const candidate = value;
|
|
129
428
|
return typeof candidate === "object" && candidate !== null && Array.isArray(candidate.collections) && candidate.hash8 instanceof Uint8Array;
|
|
130
429
|
}
|
|
131
430
|
async function loadProjectSchema(options) {
|
|
132
|
-
const entry = SCHEMA_CANDIDATES.map((c) =>
|
|
133
|
-
(f) =>
|
|
431
|
+
const entry = SCHEMA_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
|
|
432
|
+
(f) => existsSync2(f)
|
|
134
433
|
);
|
|
135
434
|
if (entry === void 0) return void 0;
|
|
136
|
-
await
|
|
137
|
-
const outfile =
|
|
435
|
+
await mkdir2(options.outDir, { recursive: true });
|
|
436
|
+
const outfile = path2.join(options.outDir, "schema.mjs");
|
|
138
437
|
const alias = options.irtioPackages ?? monorepoPackages();
|
|
139
|
-
await
|
|
438
|
+
await esbuild2.build({
|
|
140
439
|
entryPoints: [entry],
|
|
141
440
|
bundle: true,
|
|
142
441
|
format: "esm",
|
|
@@ -144,7 +443,7 @@ async function loadProjectSchema(options) {
|
|
|
144
443
|
outfile,
|
|
145
444
|
...alias !== void 0 ? { alias } : {}
|
|
146
445
|
});
|
|
147
|
-
const module = await import(`${
|
|
446
|
+
const module = await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
|
|
148
447
|
const schema = module.schema ?? module.default;
|
|
149
448
|
if (!looksLikeSchema(schema)) {
|
|
150
449
|
const names = Object.keys(module).join(", ") || "nothing";
|
|
@@ -156,38 +455,109 @@ async function loadProjectSchema(options) {
|
|
|
156
455
|
}
|
|
157
456
|
return { schema, file: entry };
|
|
158
457
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
458
|
+
function is2dWorld(world) {
|
|
459
|
+
return world !== void 0 && world.engine === "matter2d" && !("why" in world);
|
|
460
|
+
}
|
|
461
|
+
function isUnpredictedWorld(world) {
|
|
462
|
+
return world !== void 0 && "why" in world;
|
|
463
|
+
}
|
|
464
|
+
var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
|
|
465
|
+
async function bundleAndImport(entry, outfile, options) {
|
|
466
|
+
await mkdir2(options.outDir, { recursive: true });
|
|
166
467
|
const alias = options.irtioPackages ?? monorepoPackages();
|
|
167
|
-
await
|
|
468
|
+
await esbuild2.build({
|
|
168
469
|
entryPoints: [entry],
|
|
169
470
|
bundle: true,
|
|
170
471
|
format: "esm",
|
|
171
472
|
platform: "node",
|
|
172
473
|
outfile,
|
|
173
|
-
external: ["@dimforge/rapier3d-compat"],
|
|
474
|
+
external: ["@dimforge/rapier3d-compat", "matter-js"],
|
|
174
475
|
...alias !== void 0 ? { alias } : {}
|
|
175
476
|
});
|
|
176
|
-
|
|
477
|
+
return await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
|
|
478
|
+
}
|
|
479
|
+
function hookMap(value) {
|
|
480
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
481
|
+
}
|
|
482
|
+
async function detectMatter2dRoom(options) {
|
|
483
|
+
const entry = ROOM_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find((f) => existsSync2(f));
|
|
484
|
+
if (entry === void 0) return void 0;
|
|
485
|
+
let module;
|
|
486
|
+
try {
|
|
487
|
+
module = await bundleAndImport(entry, path2.join(options.outDir, "room-engine.mjs"), options);
|
|
488
|
+
} catch {
|
|
489
|
+
return void 0;
|
|
490
|
+
}
|
|
491
|
+
const definition = module.default ?? module.room;
|
|
492
|
+
return definition?.config?.physics?.engine === "matter2d" ? entry : void 0;
|
|
493
|
+
}
|
|
494
|
+
function whyNotPredictable(world) {
|
|
495
|
+
if (world.bodies === void 0 || Object.keys(world.bodies).length === 0) {
|
|
496
|
+
return `${world.file} exports no \`bodies\`, so there are no shapes to build a local world from; bots run without client prediction.
|
|
497
|
+
export the body factories your client passes to \`joinRoom({ physics2d })\`.`;
|
|
498
|
+
}
|
|
499
|
+
const steered = world.intents !== void 0 || world.settle !== void 0;
|
|
500
|
+
if (!steered && world.gravity.x === 0 && world.gravity.y === 0) {
|
|
501
|
+
return `${world.file} exports no \`intents\` and no \`settle\`, and its gravity is zero, so nothing in a local world built from it could ever move a body; bots run without client prediction.
|
|
502
|
+
export the steering hooks your client passes to \`joinRoom({ physics2d })\` (a room that applies its forces inside \`tick()\` has none to share).`;
|
|
503
|
+
}
|
|
504
|
+
return void 0;
|
|
505
|
+
}
|
|
506
|
+
async function loadProjectWorld(options) {
|
|
507
|
+
const entry = WORLD_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
|
|
508
|
+
(f) => existsSync2(f)
|
|
509
|
+
);
|
|
510
|
+
const matter2dRoom = async (why) => {
|
|
511
|
+
const room = await detectMatter2dRoom(options);
|
|
512
|
+
return room === void 0 ? void 0 : { engine: "matter2d", why: why(room), file: room };
|
|
513
|
+
};
|
|
514
|
+
if (entry === void 0) {
|
|
515
|
+
return matter2dRoom(
|
|
516
|
+
(room) => `${room} declares a matter2d room and there is no shared world module beside it, so bots run without client prediction.
|
|
517
|
+
export the \`physics2d\` block your client passes to \`joinRoom\` from \`irtio/world.ts\` (gravity, bodies, intents, settle) to predict.`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const module = await bundleAndImport(entry, path2.join(options.outDir, "world.mjs"), options);
|
|
177
521
|
const gravity = module.gravity;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
`irtio simulate: ${entry} does not export a { x, y, z } gravity.
|
|
522
|
+
const notAWorld = new Error(
|
|
523
|
+
`irtio simulate: ${entry} does not export a { x, y, z } gravity.
|
|
181
524
|
the shared world-builder must export the same gravity the room config uses
|
|
182
525
|
(this is what \`irtio init --physics\` writes)`
|
|
526
|
+
);
|
|
527
|
+
if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number") {
|
|
528
|
+
const recognised = await matter2dRoom(
|
|
529
|
+
() => `${entry} exports no gravity, so it is not the client world a matter2d room predicts from; bots run without client prediction.
|
|
530
|
+
export the \`physics2d\` block your client passes to \`joinRoom\` (gravity, bodies, intents, settle) from this module to predict.`
|
|
183
531
|
);
|
|
532
|
+
if (recognised !== void 0) return recognised;
|
|
533
|
+
throw notAWorld;
|
|
534
|
+
}
|
|
535
|
+
if (typeof gravity.z !== "number") {
|
|
536
|
+
if (typeof gravity.y !== "number") throw notAWorld;
|
|
537
|
+
const world2d = {
|
|
538
|
+
engine: "matter2d",
|
|
539
|
+
gravity: { x: gravity.x, y: gravity.y },
|
|
540
|
+
...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
|
|
541
|
+
...typeof module.setup === "function" ? { setup: module.setup } : {},
|
|
542
|
+
...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
|
|
543
|
+
...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
|
|
544
|
+
...hookMap(module.settle) !== void 0 ? { settle: module.settle } : {},
|
|
545
|
+
...typeof module.epsilon === "number" ? { epsilon: module.epsilon } : {},
|
|
546
|
+
...typeof module.maxPredictedBodies === "number" ? { maxPredictedBodies: module.maxPredictedBodies } : {},
|
|
547
|
+
...typeof module.smoothingHalfLifeMs === "number" ? { smoothingHalfLifeMs: module.smoothingHalfLifeMs } : {},
|
|
548
|
+
file: entry
|
|
549
|
+
};
|
|
550
|
+
const why = whyNotPredictable(world2d);
|
|
551
|
+
return why === void 0 ? world2d : { engine: "matter2d", why, file: entry };
|
|
184
552
|
}
|
|
553
|
+
if (typeof gravity.y !== "number") throw notAWorld;
|
|
185
554
|
return {
|
|
555
|
+
engine: "rapier3d",
|
|
186
556
|
gravity,
|
|
187
557
|
...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
|
|
188
558
|
...typeof module.setup === "function" ? { setup: module.setup } : {},
|
|
189
|
-
...
|
|
190
|
-
...
|
|
559
|
+
...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
|
|
560
|
+
...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
|
|
191
561
|
file: entry
|
|
192
562
|
};
|
|
193
563
|
}
|
|
@@ -195,7 +565,7 @@ async function checkWorldDeterminism(world) {
|
|
|
195
565
|
if (!world.setup) return void 0;
|
|
196
566
|
const { initPhysics } = await import("@irtio/runtime");
|
|
197
567
|
const rapier = await initPhysics();
|
|
198
|
-
const
|
|
568
|
+
const build3 = () => {
|
|
199
569
|
const w = new rapier.World({ ...world.gravity });
|
|
200
570
|
try {
|
|
201
571
|
world.setup(w, rapier);
|
|
@@ -204,8 +574,8 @@ async function checkWorldDeterminism(world) {
|
|
|
204
574
|
w.free();
|
|
205
575
|
}
|
|
206
576
|
};
|
|
207
|
-
const first =
|
|
208
|
-
const second =
|
|
577
|
+
const first = build3();
|
|
578
|
+
const second = build3();
|
|
209
579
|
if (first.length === second.length && everyByteEqual(first, second)) return void 0;
|
|
210
580
|
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.`;
|
|
211
581
|
}
|
|
@@ -213,6 +583,48 @@ function everyByteEqual(a, b) {
|
|
|
213
583
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
214
584
|
return true;
|
|
215
585
|
}
|
|
586
|
+
var DETERMINISM_STEPS_2D = 60;
|
|
587
|
+
function canonicalMatterWorld(matter, engine) {
|
|
588
|
+
return matter.Composite.allBodies(engine.world).map(
|
|
589
|
+
(b) => [b.position.x, b.position.y, b.angle, b.velocity.x, b.velocity.y, b.angularVelocity].join(
|
|
590
|
+
","
|
|
591
|
+
)
|
|
592
|
+
).join(";");
|
|
593
|
+
}
|
|
594
|
+
async function checkWorld2dDeterminism(world, schema) {
|
|
595
|
+
const factories = world.bodies ?? {};
|
|
596
|
+
const exercised = schema ? schema.collections.filter(
|
|
597
|
+
(c) => c.physics !== void 0 && factories[c.name] !== void 0
|
|
598
|
+
) : [];
|
|
599
|
+
if (!world.setup && exercised.length === 0) return void 0;
|
|
600
|
+
const { initMatter } = await import("@irtio/runtime");
|
|
601
|
+
const { defaultRecord } = await import("@irtio/schema");
|
|
602
|
+
const matter = await initMatter();
|
|
603
|
+
const stepMs = (world.timestep ?? 1 / 60) * 1e3;
|
|
604
|
+
const build3 = () => {
|
|
605
|
+
const engine = matter.Engine.create();
|
|
606
|
+
engine.gravity.x = world.gravity.x;
|
|
607
|
+
engine.gravity.y = world.gravity.y;
|
|
608
|
+
try {
|
|
609
|
+
world.setup?.(engine, matter);
|
|
610
|
+
for (const desc of exercised) {
|
|
611
|
+
try {
|
|
612
|
+
const spec = factories[desc.name](matter, defaultRecord(desc), "irtio-determinism-check");
|
|
613
|
+
if (spec?.body !== void 0) matter.Composite.add(engine.world, spec.body);
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (let i = 0; i < DETERMINISM_STEPS_2D; i++) matter.Engine.update(engine, stepMs);
|
|
618
|
+
return canonicalMatterWorld(matter, engine);
|
|
619
|
+
} finally {
|
|
620
|
+
matter.Engine.clear(engine);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const first = build3();
|
|
624
|
+
const second = build3();
|
|
625
|
+
if (first === second) return void 0;
|
|
626
|
+
return `the world builder is not deterministic: two builds of ${world.file} disagree after ${DETERMINISM_STEPS_2D} steps. It must be pure over synced inputs \u2014 no Math.random(), no clock, no module state; put seeds and level parameters in synced state instead.`;
|
|
627
|
+
}
|
|
216
628
|
var TICK_HEALTH_TIMEOUT_MS = 2e3;
|
|
217
629
|
function stateUrlFor(wsUrl) {
|
|
218
630
|
let parsed;
|
|
@@ -228,6 +640,51 @@ function stateUrlFor(wsUrl) {
|
|
|
228
640
|
parsed.search = "";
|
|
229
641
|
return parsed.toString();
|
|
230
642
|
}
|
|
643
|
+
async function readBuildIdentity(wsUrl) {
|
|
644
|
+
const stateUrl = stateUrlFor(wsUrl);
|
|
645
|
+
if (stateUrl === void 0) {
|
|
646
|
+
return { unavailable: `cannot derive an inspector URL from ${wsUrl}` };
|
|
647
|
+
}
|
|
648
|
+
let body;
|
|
649
|
+
try {
|
|
650
|
+
const response = await fetch(stateUrl, {
|
|
651
|
+
signal: AbortSignal.timeout(TICK_HEALTH_TIMEOUT_MS)
|
|
652
|
+
});
|
|
653
|
+
if (!response.ok) {
|
|
654
|
+
return {
|
|
655
|
+
unavailable: `${stateUrl} answered ${response.status}` + (response.status === 404 ? ": only `irtio dev` serves the inspector, so a deployed tenant cannot name its build here" : "")
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
body = await response.json();
|
|
659
|
+
} catch (err) {
|
|
660
|
+
return {
|
|
661
|
+
unavailable: `could not reach ${stateUrl} (${err instanceof Error ? err.message : String(err)})`
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
const state = body;
|
|
665
|
+
const bundleHash = typeof state?.bundle?.hash === "string" ? state.bundle.hash : void 0;
|
|
666
|
+
const schemaHash = typeof state?.bundle?.schemaHash === "string" ? state.bundle.schemaHash : void 0;
|
|
667
|
+
const startedAt = typeof state?.startedAt === "number" ? state.startedAt : void 0;
|
|
668
|
+
if (bundleHash === void 0 && startedAt === void 0) {
|
|
669
|
+
return { unavailable: `${stateUrl} named no bundle and no start time`, source: stateUrl };
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
...bundleHash !== void 0 ? { bundleHash } : {},
|
|
673
|
+
...schemaHash !== void 0 ? { schemaHash } : {},
|
|
674
|
+
...startedAt !== void 0 ? { startedAt } : {},
|
|
675
|
+
source: stateUrl
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
function formatBuildIdentity(build3) {
|
|
679
|
+
if (build3.unavailable !== void 0) return `build unknown: ${build3.unavailable}`;
|
|
680
|
+
const parts = [];
|
|
681
|
+
parts.push(`bundle ${build3.bundleHash ?? "unknown"}`);
|
|
682
|
+
if (build3.schemaHash !== void 0) parts.push(`schema ${build3.schemaHash}`);
|
|
683
|
+
parts.push(
|
|
684
|
+
build3.startedAt !== void 0 ? `server up since ${new Date(build3.startedAt).toISOString()}` : "server start time unknown"
|
|
685
|
+
);
|
|
686
|
+
return parts.join(" \xB7 ");
|
|
687
|
+
}
|
|
231
688
|
async function readTickCounters(wsUrl, roomId) {
|
|
232
689
|
const stateUrl = stateUrlFor(wsUrl);
|
|
233
690
|
if (stateUrl === void 0) {
|
|
@@ -280,6 +737,13 @@ function tickHealthFrom(before, after) {
|
|
|
280
737
|
};
|
|
281
738
|
}
|
|
282
739
|
var STOP_GRACE_MS = 5e3;
|
|
740
|
+
var TRUTH_SETTLE_MS = 400;
|
|
741
|
+
function delay(ms) {
|
|
742
|
+
return new Promise((resolve3) => {
|
|
743
|
+
const timer = setTimeout(resolve3, ms);
|
|
744
|
+
timer.unref?.();
|
|
745
|
+
});
|
|
746
|
+
}
|
|
283
747
|
function ceilingFor(seconds) {
|
|
284
748
|
return seconds * 3e3 + 3e4;
|
|
285
749
|
}
|
|
@@ -294,7 +758,18 @@ var RunNotPerformedError = class extends Error {
|
|
|
294
758
|
function rate(bytesPerSecond) {
|
|
295
759
|
return bytesPerSecond >= 1e3 ? `${(bytesPerSecond / 1e3).toFixed(1)} kB/s` : `${Math.round(bytesPerSecond)} B/s`;
|
|
296
760
|
}
|
|
297
|
-
function
|
|
761
|
+
function formatRunProfile(report, top) {
|
|
762
|
+
const profile = report.profile;
|
|
763
|
+
if (!profile) return [];
|
|
764
|
+
const seconds = Math.max(1e-3, report.durationMs / 1e3);
|
|
765
|
+
const bots = Math.max(1, report.bots);
|
|
766
|
+
return [
|
|
767
|
+
`${pc.bold("bandwidth")} ${pc.dim("as seen by the bots, per bot per second")}`,
|
|
768
|
+
...formatProfileTable(profile, { seconds, per: bots, ...top !== void 0 ? { top } : {} }),
|
|
769
|
+
""
|
|
770
|
+
];
|
|
771
|
+
}
|
|
772
|
+
function formatReport(report, schemaLoaded) {
|
|
298
773
|
const lines = [];
|
|
299
774
|
for (const invariant of report.invariants) {
|
|
300
775
|
const mark = invariant.state === "unavailable" ? pc.yellow("n/a ") : invariant.ok ? pc.green("ok ") : pc.red("FAIL");
|
|
@@ -304,11 +779,30 @@ function formatReport(report, schemaLoaded, cheating) {
|
|
|
304
779
|
const convergence = report.convergenceLagMs === void 0 ? "convergence not sampled" : `convergence ${report.convergenceLagMs} ms (p50 of ${report.convergence?.samples ?? 0})`;
|
|
305
780
|
const { mispredictions, mispredictionMagnitude, mispredictionMax, snaps } = report.totals;
|
|
306
781
|
const misprediction = mispredictions === 0 ? "misprediction 0" : `misprediction mean ${(mispredictionMagnitude / mispredictions).toFixed(1)} max ${mispredictionMax.toFixed(1)}`;
|
|
782
|
+
const { messagesSent, messagesReceived, messagesDropped } = report.totals;
|
|
783
|
+
const messages = messagesSent + messagesReceived + messagesDropped === 0 ? "" : ` \xB7 ${messagesSent} msg out / ${messagesReceived} in` + (messagesDropped > 0 ? ` / ${messagesDropped} dropped` : "");
|
|
307
784
|
lines.push(
|
|
308
785
|
pc.dim(
|
|
309
|
-
` ${report.framesPerSecond} frames/s \xB7 in ${rate(report.bytesInPerSecondPerBot)}/bot \xB7 out ${rate(report.bytesOutPerSecondPerBot)}/bot \xB7 ${report.totals.corrections} corrections \xB7 ${misprediction} \xB7 ${snaps} snap(s) \xB7 ${convergence}`
|
|
786
|
+
` ${report.framesPerSecond} frames/s \xB7 in ${rate(report.bytesInPerSecondPerBot)}/bot \xB7 out ${rate(report.bytesOutPerSecondPerBot)}/bot \xB7 ${report.totals.corrections} corrections \xB7 ${misprediction} \xB7 ${snaps} snap(s) \xB7 ${convergence}${messages}`
|
|
310
787
|
)
|
|
311
788
|
);
|
|
789
|
+
const prediction = report.prediction;
|
|
790
|
+
if (prediction !== void 0) {
|
|
791
|
+
const { syncCorrections, proxiedCorrections, suppressedCorrections } = report.totals;
|
|
792
|
+
lines.push(
|
|
793
|
+
pc.dim(
|
|
794
|
+
` prediction: ${prediction.bots}/${report.bots} bot(s) held a local world \xB7 peak ${prediction.proxies} proxied, ${prediction.absent} absent \xB7 ${mispredictions} misprediction(s), ${suppressedCorrections} within-epsilon, ${proxiedCorrections} proxied, ${syncCorrections} body-sync`
|
|
795
|
+
)
|
|
796
|
+
);
|
|
797
|
+
if (prediction.absent > 0) {
|
|
798
|
+
lines.push(
|
|
799
|
+
pc.yellow(
|
|
800
|
+
` ${prediction.absent} instance(s) had no body in a local world at once, so predicted bodies passed through them.
|
|
801
|
+
raise maxProxyBodies, or give the collection a body factory on the client.`
|
|
802
|
+
)
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
312
806
|
if (report.tracePath !== void 0) lines.push(pc.dim(` trace: ${report.tracePath}`));
|
|
313
807
|
lines.push("");
|
|
314
808
|
const failed = report.invariants.filter((i) => !i.ok);
|
|
@@ -324,59 +818,281 @@ function formatReport(report, schemaLoaded, cheating) {
|
|
|
324
818
|
pc.red(` ${failed.length} invariant(s) failed: ${failed.map((i) => i.name).join(", ")}`)
|
|
325
819
|
);
|
|
326
820
|
}
|
|
327
|
-
if (
|
|
821
|
+
if (!schemaLoaded) {
|
|
328
822
|
lines.push(
|
|
329
823
|
pc.yellow(
|
|
330
|
-
"
|
|
824
|
+
" 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."
|
|
331
825
|
)
|
|
332
826
|
);
|
|
333
827
|
}
|
|
334
|
-
|
|
828
|
+
return lines;
|
|
829
|
+
}
|
|
830
|
+
function formatScenario(run, cwd) {
|
|
831
|
+
const lines = [""];
|
|
832
|
+
lines.push(
|
|
833
|
+
` ${pc.bold("scenario")} ${pc.dim(path2.relative(cwd, run.file))} ` + pc.dim(`(${run.ticks} recorded tick(s))`)
|
|
834
|
+
);
|
|
835
|
+
if (run.assertions.length === 0) {
|
|
335
836
|
lines.push(
|
|
336
837
|
pc.yellow(
|
|
337
|
-
" no
|
|
838
|
+
" the scenario ran no assertions, so it proved nothing. Call timeline.check(name, fn), or throw from assert."
|
|
338
839
|
)
|
|
339
840
|
);
|
|
340
841
|
}
|
|
842
|
+
for (const assertion of run.assertions) {
|
|
843
|
+
const mark = assertion.ok ? pc.green("ok ") : pc.red("FAIL");
|
|
844
|
+
const where = assertion.tick !== void 0 ? `tick ${assertion.tick}: ` : "";
|
|
845
|
+
lines.push(` ${mark} ${assertion.name.padEnd(17)} ${pc.dim(`${where}${assertion.detail}`)}`);
|
|
846
|
+
}
|
|
847
|
+
if (run.dropped > 0) {
|
|
848
|
+
lines.push(
|
|
849
|
+
pc.yellow(
|
|
850
|
+
` 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.`
|
|
851
|
+
)
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
lines.push(pc.dim(` timeline: ${run.timelinePath}`));
|
|
855
|
+
const failed = run.assertions.filter((a) => !a.ok);
|
|
856
|
+
lines.push(
|
|
857
|
+
failed.length === 0 && run.assertions.length > 0 ? pc.green(` every scenario assertion held (${run.assertions.length}).`) : pc.red(` ${failed.length} scenario assertion(s) failed.`)
|
|
858
|
+
);
|
|
859
|
+
return lines;
|
|
860
|
+
}
|
|
861
|
+
function formatAdversarial(run) {
|
|
862
|
+
const lines = ["", ` ${pc.bold("adversarial")}`];
|
|
863
|
+
for (const entry of run.conditions) {
|
|
864
|
+
if (entry.conditions === void 0) continue;
|
|
865
|
+
const c = entry.counters;
|
|
866
|
+
const did = `dropped ${c.droppedOut} out / ${c.droppedIn} in, duplicated ${c.duplicatedOut}/${c.duplicatedIn}, reordered ${c.reorderedOut}/${c.reorderedIn}`;
|
|
867
|
+
lines.push(
|
|
868
|
+
` ${pc.dim("bot")} ${String(entry.bot).padEnd(3)} ${describeConditions(entry.conditions)}`
|
|
869
|
+
);
|
|
870
|
+
lines.push(` ${pc.dim(did)}`);
|
|
871
|
+
}
|
|
872
|
+
for (let i = 0; i < run.cheated.length; i++) {
|
|
873
|
+
const bot = run.cheated[i] ?? 0;
|
|
874
|
+
const corrections = run.correctionsPerCheater[i] ?? 0;
|
|
875
|
+
if (corrections > 0) {
|
|
876
|
+
lines.push(
|
|
877
|
+
` ${pc.green("ok ")} bot ${bot} cheated and drew ${corrections} correction(s): the room refused the illegal writes.`
|
|
878
|
+
);
|
|
879
|
+
} else {
|
|
880
|
+
lines.push(
|
|
881
|
+
pc.yellow(
|
|
882
|
+
` HOLE bot ${bot} cheated and drew 0 corrections: the room accepted every illegal write it sent.
|
|
883
|
+
Add rules to \`validate\` in irtio/room.ts: reject by returning \`prev\`, or clamp the value.`
|
|
884
|
+
)
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
341
888
|
return lines;
|
|
342
889
|
}
|
|
890
|
+
function formatHits(rows) {
|
|
891
|
+
const lines = [
|
|
892
|
+
"",
|
|
893
|
+
` ${pc.bold("hit registration")} ${pc.dim(`(${rows.length} shot(s))`)}`
|
|
894
|
+
];
|
|
895
|
+
for (const row of rows) {
|
|
896
|
+
const marks = [row.estimated ? "estimated" : void 0, row.rewound ? "rewound" : void 0].filter((m) => m !== void 0).map((m) => ` (${m})`).join("");
|
|
897
|
+
const at = row.serverTick === void 0 ? "no tick" : `tick ${row.serverTick}${marks}`;
|
|
898
|
+
if (row.missDistance === void 0) {
|
|
899
|
+
lines.push(
|
|
900
|
+
pc.yellow(
|
|
901
|
+
` bot ${row.bot} at ${row.target}: ${at}, no distance: ${row.unresolved ?? "unknown"}`
|
|
902
|
+
)
|
|
903
|
+
);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
lines.push(
|
|
907
|
+
` ${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)}`)}`
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
const measured = rows.filter((r) => r.missDistance !== void 0);
|
|
911
|
+
if (measured.length > 0) {
|
|
912
|
+
const mean = measured.reduce((s, r) => s + (r.missDistance ?? 0), 0) / measured.length;
|
|
913
|
+
const worst = Math.max(...measured.map((r) => r.missDistance ?? 0));
|
|
914
|
+
const rewound = measured.filter((r) => r.rewound === true).length;
|
|
915
|
+
const verdict = rewound === 0 ? "Nothing here compensates for lag; this is what a player at this latency would experience." : `${rewound} of ${measured.length} shot(s) were resolved through room.rewind, against the world the shooter was looking at.`;
|
|
916
|
+
lines.push(
|
|
917
|
+
pc.dim(
|
|
918
|
+
` mean miss ${mean.toFixed(2)}, worst ${worst.toFixed(2)} over ${measured.length} shot(s). ${verdict}`
|
|
919
|
+
)
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
return lines;
|
|
923
|
+
}
|
|
924
|
+
function formatMatchmaking(summary, failures) {
|
|
925
|
+
const lines = ["", ` ${pc.bold("matchmaking")}`];
|
|
926
|
+
lines.push(
|
|
927
|
+
pc.dim(
|
|
928
|
+
` ${summary.tickets} ticket(s), ${summary.rooms} room(s), wait p50 ${summary.waitP50Ms} ms p95 ${summary.waitP95Ms} ms max ${summary.waitMaxMs} ms, ${summary.timeouts} timeout(s)` + (summary.backfills > 0 ? `, ${summary.backfills} backfill(s)` : "") + (summary.parties > 0 ? `, parties intact ${summary.partiesIntact}/${summary.parties}` : "")
|
|
929
|
+
)
|
|
930
|
+
);
|
|
931
|
+
for (const split of summary.splitParties) {
|
|
932
|
+
lines.push(
|
|
933
|
+
pc.red(
|
|
934
|
+
` FAIL party ${split.party} was split across ${split.rooms.length} rooms: ` + split.rooms.join(", ")
|
|
935
|
+
)
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
for (const room of summary.overfullRooms) {
|
|
939
|
+
lines.push(
|
|
940
|
+
pc.red(
|
|
941
|
+
` FAIL room ${room.room} holds ${room.bots} bots on a ticket that seats ${room.size}`
|
|
942
|
+
)
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
for (const failure of failures) {
|
|
946
|
+
lines.push(
|
|
947
|
+
pc.red(` FAIL bot ${failure.requested} got no ticket: ${failure.code} \u2014 ${failure.message}`)
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
if (summary.splitParties.length === 0 && summary.overfullRooms.length === 0 && failures.length === 0) {
|
|
951
|
+
lines.push(pc.green(" every bot was answered, and every party landed in one room."));
|
|
952
|
+
}
|
|
953
|
+
return lines;
|
|
954
|
+
}
|
|
955
|
+
function point(p) {
|
|
956
|
+
const parts = Object.entries(p).map(([k, v]) => `${k} ${Math.round(v * 100) / 100}`);
|
|
957
|
+
return parts.length === 0 ? "(none)" : `(${parts.join(", ")})`;
|
|
958
|
+
}
|
|
959
|
+
function formatTruth(run) {
|
|
960
|
+
const lines = ["", ` ${pc.bold("truth seam")}`];
|
|
961
|
+
if (run.diff === void 0) {
|
|
962
|
+
lines.push(
|
|
963
|
+
pc.yellow(
|
|
964
|
+
` n/a no save could be decoded, so nothing was compared: ${run.error ?? "unknown"}`
|
|
965
|
+
)
|
|
966
|
+
);
|
|
967
|
+
return lines;
|
|
968
|
+
}
|
|
969
|
+
const diff = run.diff;
|
|
970
|
+
lines.push(
|
|
971
|
+
pc.dim(
|
|
972
|
+
` save ${run.saveId ?? "(unnamed)"} at tick ${diff.saveTick}, blob format v${diff.saveVersion}`
|
|
973
|
+
)
|
|
974
|
+
);
|
|
975
|
+
for (const bot of diff.bots) {
|
|
976
|
+
const mark = bot.ok ? pc.green("ok ") : pc.red("FAIL");
|
|
977
|
+
lines.push(
|
|
978
|
+
` ${mark} bot ${String(bot.bot).padEnd(3)} ${pc.dim(
|
|
979
|
+
bot.ok ? `${bot.compared} record(s) held, and every one matched the save` : `${bot.differences.length} difference(s) across ${bot.compared} record(s) held`
|
|
980
|
+
)}`
|
|
981
|
+
);
|
|
982
|
+
for (const d of bot.differences.slice(0, 5)) lines.push(pc.red(` ${d.detail}`));
|
|
983
|
+
if (bot.differences.length > 5) {
|
|
984
|
+
lines.push(pc.dim(` ... and ${bot.differences.length - 5} more`));
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
lines.push(
|
|
988
|
+
diff.ok ? pc.green(
|
|
989
|
+
` every record the ${diff.bots.length} client(s) held matched the decoded save, and none was missing from it.`
|
|
990
|
+
) : pc.red(
|
|
991
|
+
` ${diff.differences.length} difference(s) between what the clients held and what the save says the server held.`
|
|
992
|
+
)
|
|
993
|
+
);
|
|
994
|
+
return lines;
|
|
995
|
+
}
|
|
996
|
+
function conditionsFrom(options) {
|
|
997
|
+
const all = options.conditions;
|
|
998
|
+
const perBot = options.conditionsPerBot;
|
|
999
|
+
if (perBot === void 0 || Object.keys(perBot).length === 0) {
|
|
1000
|
+
return hasConditions(all) ? all : void 0;
|
|
1001
|
+
}
|
|
1002
|
+
return (index) => perBot[index] ?? all;
|
|
1003
|
+
}
|
|
1004
|
+
function cheatFrom(options) {
|
|
1005
|
+
const named = options.cheatBots;
|
|
1006
|
+
if (named !== void 0 && named.length > 0) {
|
|
1007
|
+
const set = new Set(named);
|
|
1008
|
+
return options.cheat === true ? true : (index) => set.has(index);
|
|
1009
|
+
}
|
|
1010
|
+
return options.cheat === true ? true : void 0;
|
|
1011
|
+
}
|
|
343
1012
|
async function runSimulation(options = {}) {
|
|
344
|
-
const cwd =
|
|
1013
|
+
const cwd = path2.resolve(options.cwd ?? process.cwd());
|
|
345
1014
|
const output = [];
|
|
346
1015
|
const log = (line) => {
|
|
347
1016
|
output.push(line);
|
|
348
1017
|
(options.log ?? ((l) => console.log(l)))(line);
|
|
349
1018
|
};
|
|
350
|
-
const bots = options.bots ?? DEFAULT_BOTS;
|
|
351
|
-
const seconds = options.seconds ?? DEFAULT_SECONDS;
|
|
352
1019
|
const url = options.url ?? DEFAULT_URL;
|
|
353
|
-
const outDir =
|
|
1020
|
+
const outDir = path2.join(cwd, ".irtio", "sim");
|
|
1021
|
+
const loadedScenario = options.scenario !== void 0 ? await loadScenario({
|
|
1022
|
+
cwd,
|
|
1023
|
+
outDir,
|
|
1024
|
+
file: options.scenario,
|
|
1025
|
+
irtioPackages: options.irtioPackages
|
|
1026
|
+
}) : void 0;
|
|
1027
|
+
const scenario = loadedScenario?.scenario;
|
|
1028
|
+
const bots = scenario?.bots ?? options.bots ?? DEFAULT_BOTS;
|
|
1029
|
+
const seconds = scenario?.seconds ?? options.seconds ?? DEFAULT_SECONDS;
|
|
354
1030
|
const loaded = await loadProjectSchema({
|
|
355
1031
|
cwd,
|
|
356
1032
|
outDir,
|
|
357
1033
|
irtioPackages: options.irtioPackages
|
|
358
1034
|
});
|
|
1035
|
+
if (scenario !== void 0 && loaded === void 0) {
|
|
1036
|
+
throw new ScenarioNotRunError(
|
|
1037
|
+
`irtio simulate: a scenario needs the project schema, and there is no irtio/schema.ts under ${cwd}.
|
|
1038
|
+
Scenarios assert on the room's own collections, so a relay run has nothing to assert about. Run it from the project directory.`
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
359
1041
|
const hasPhysics = loaded !== void 0 && loaded.schema.collections.some(
|
|
360
1042
|
(c) => c.physics !== void 0
|
|
361
1043
|
);
|
|
362
|
-
const
|
|
1044
|
+
const loadedWorld = hasPhysics ? await loadProjectWorld({ cwd, outDir, irtioPackages: options.irtioPackages }) : void 0;
|
|
1045
|
+
const world2d = is2dWorld(loadedWorld) ? loadedWorld : void 0;
|
|
1046
|
+
const world = is2dWorld(loadedWorld) || isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
|
|
1047
|
+
if (world2d !== void 0) {
|
|
1048
|
+
log(`physics: ${world2d.file} is a matter2d world; bots predict with physics2d`);
|
|
1049
|
+
} else if (isUnpredictedWorld(loadedWorld)) {
|
|
1050
|
+
log(pc.yellow(`physics: ${loadedWorld.why}`));
|
|
1051
|
+
}
|
|
363
1052
|
let worldError;
|
|
364
1053
|
if (world) {
|
|
365
1054
|
worldError = await checkWorldDeterminism(world);
|
|
1055
|
+
} else if (world2d) {
|
|
1056
|
+
worldError = await checkWorld2dDeterminism(world2d, loaded?.schema);
|
|
366
1057
|
}
|
|
1058
|
+
const conditionsSpec = scenario?.conditions ?? conditionsFrom(options);
|
|
1059
|
+
const cheatSpec = scenario?.cheat ?? cheatFrom(options);
|
|
1060
|
+
const cheats = cheatPredicate(cheatSpec);
|
|
1061
|
+
const wantTruth = scenario?.truth === true || options.truth === true;
|
|
367
1062
|
const common = {
|
|
368
1063
|
url,
|
|
369
1064
|
durationMs: seconds * 1e3,
|
|
1065
|
+
...conditionsSpec !== void 0 ? { conditions: conditionsSpec } : {},
|
|
1066
|
+
...scenario?.seed !== void 0 ? { seed: scenario.seed } : {},
|
|
370
1067
|
...options.key !== void 0 ? { key: options.key } : {},
|
|
371
1068
|
...options.room !== void 0 ? { room: options.room } : {},
|
|
372
1069
|
...options.correctionsPerSecMax !== void 0 ? { correctionsPerSecMax: options.correctionsPerSecMax } : options.correctionsMax !== void 0 ? { correctionsPerSecMax: options.correctionsMax } : {},
|
|
373
1070
|
...options.mispredictionMax !== void 0 ? { mispredictionMagnitudeMax: options.mispredictionMax } : {},
|
|
374
1071
|
...options.snapsMax !== void 0 ? { snapsMax: options.snapsMax } : {},
|
|
375
|
-
...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {}
|
|
1072
|
+
...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {},
|
|
1073
|
+
...options.profile === true ? { profile: true } : {}
|
|
376
1074
|
};
|
|
377
|
-
const predicted = world !== void 0 && worldError === void 0;
|
|
1075
|
+
const predicted = (world !== void 0 || world2d !== void 0) && worldError === void 0;
|
|
1076
|
+
const projectKey = options.key ?? loaded?.schema?.project;
|
|
1077
|
+
if (options.queue !== void 0 && (projectKey === void 0 || projectKey === "")) {
|
|
1078
|
+
throw new RunNotPerformedError(
|
|
1079
|
+
"irtio simulate: --queue needs a project key, and this schema carries none.\n build the schema with `irtio` so it names its project, or pass --key."
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
const match = options.queue !== void 0 && options.control !== void 0 ? {
|
|
1083
|
+
controlUrl: options.control,
|
|
1084
|
+
project: projectKey,
|
|
1085
|
+
queue: options.queue,
|
|
1086
|
+
...options.matchTimeoutMs !== void 0 ? { timeoutMs: options.matchTimeoutMs } : {},
|
|
1087
|
+
...options.party !== void 0 ? (
|
|
1088
|
+
// Consecutive groups of `n`: bots 0..n-1 are party 0, n..2n-1 are party 1. A
|
|
1089
|
+
// trailing group smaller than `n` is still a party, of whatever is left.
|
|
1090
|
+
{ party: (index) => Math.floor(index / options.party) }
|
|
1091
|
+
) : {}
|
|
1092
|
+
} : void 0;
|
|
378
1093
|
const spawn = {
|
|
379
1094
|
...common,
|
|
1095
|
+
...match !== void 0 ? { match } : {},
|
|
380
1096
|
...options.joinTimeoutMs !== void 0 ? { joinTimeoutMs: options.joinTimeoutMs } : {},
|
|
381
1097
|
...options.roomGoneGraceMs !== void 0 ? { roomGoneGraceMs: options.roomGoneGraceMs } : {}
|
|
382
1098
|
};
|
|
@@ -385,8 +1101,8 @@ async function runSimulation(options = {}) {
|
|
|
385
1101
|
runner = loaded ? await spawnBots(bots, {
|
|
386
1102
|
...spawn,
|
|
387
1103
|
schema: loaded.schema,
|
|
388
|
-
script: randomScript(loaded.schema, { cheat:
|
|
389
|
-
...predicted ? {
|
|
1104
|
+
script: scenario?.script ?? randomScript(loaded.schema, { cheat: cheatSpec ?? false }),
|
|
1105
|
+
...predicted && world !== void 0 ? {
|
|
390
1106
|
physics: {
|
|
391
1107
|
gravity: world.gravity,
|
|
392
1108
|
...world.timestep !== void 0 ? { timestep: world.timestep } : {},
|
|
@@ -394,6 +1110,20 @@ async function runSimulation(options = {}) {
|
|
|
394
1110
|
...world.bodies !== void 0 ? { bodies: world.bodies } : {},
|
|
395
1111
|
...world.intents !== void 0 ? { intents: world.intents } : {}
|
|
396
1112
|
}
|
|
1113
|
+
} : {},
|
|
1114
|
+
// ---- M6 lane E: testing parity ---- (D73-a)
|
|
1115
|
+
...predicted && world2d !== void 0 ? {
|
|
1116
|
+
physics2d: {
|
|
1117
|
+
gravity: world2d.gravity,
|
|
1118
|
+
...world2d.timestep !== void 0 ? { timestep: world2d.timestep } : {},
|
|
1119
|
+
...world2d.setup !== void 0 ? { setup: world2d.setup } : {},
|
|
1120
|
+
...world2d.bodies !== void 0 ? { bodies: world2d.bodies } : {},
|
|
1121
|
+
...world2d.intents !== void 0 ? { intents: world2d.intents } : {},
|
|
1122
|
+
...world2d.settle !== void 0 ? { settle: world2d.settle } : {},
|
|
1123
|
+
...world2d.epsilon !== void 0 ? { epsilon: world2d.epsilon } : {},
|
|
1124
|
+
...world2d.maxPredictedBodies !== void 0 ? { maxPredictedBodies: world2d.maxPredictedBodies } : {},
|
|
1125
|
+
...world2d.smoothingHalfLifeMs !== void 0 ? { smoothingHalfLifeMs: world2d.smoothingHalfLifeMs } : {}
|
|
1126
|
+
}
|
|
397
1127
|
} : {}
|
|
398
1128
|
}) : await spawnBots(bots, { ...spawn, script: relayEchoScript() });
|
|
399
1129
|
} catch (err) {
|
|
@@ -405,57 +1135,166 @@ async function runSimulation(options = {}) {
|
|
|
405
1135
|
}
|
|
406
1136
|
log("");
|
|
407
1137
|
log(
|
|
408
|
-
`${pc.bold("irtio simulate")}
|
|
1138
|
+
`${pc.bold("irtio simulate")}: ${bots} bot${bots === 1 ? "" : "s"} \xD7 ${seconds}s on ${url} (room ${pc.bold(runner.roomId)})${options.cheat ? pc.yellow(" [cheat]") : ""}`
|
|
409
1139
|
);
|
|
1140
|
+
const build3 = await readBuildIdentity(url);
|
|
1141
|
+
log(pc.dim(` ${formatBuildIdentity(build3)}`));
|
|
410
1142
|
log("");
|
|
1143
|
+
if (scenario !== void 0) {
|
|
1144
|
+
try {
|
|
1145
|
+
await startTimeline(url, runner.roomId);
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
await runner.stop().catch(() => void 0);
|
|
1148
|
+
throw err;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
411
1151
|
const tickBefore = await readTickCounters(url, runner.roomId);
|
|
412
1152
|
const ceilingMs = options.ceilingMs ?? ceilingFor(seconds);
|
|
413
1153
|
let ceilingHit = false;
|
|
414
|
-
const ceiling = new Promise((
|
|
1154
|
+
const ceiling = new Promise((resolve3) => {
|
|
415
1155
|
const timer = setTimeout(() => {
|
|
416
1156
|
ceilingHit = true;
|
|
417
|
-
|
|
1157
|
+
resolve3();
|
|
418
1158
|
}, ceilingMs);
|
|
419
1159
|
void runner.done().catch(() => void 0).finally(() => {
|
|
420
1160
|
clearTimeout(timer);
|
|
421
|
-
|
|
1161
|
+
resolve3();
|
|
422
1162
|
});
|
|
423
1163
|
});
|
|
424
1164
|
await ceiling;
|
|
1165
|
+
let truth;
|
|
1166
|
+
const clientStates = [];
|
|
1167
|
+
if (wantTruth) {
|
|
1168
|
+
if (loaded === void 0) {
|
|
1169
|
+
truth = { error: "a relay run has no schema, so there is nothing to decode a save against" };
|
|
1170
|
+
} else {
|
|
1171
|
+
await delay(TRUTH_SETTLE_MS);
|
|
1172
|
+
const fetched = await fetchSave(url, runner.roomId);
|
|
1173
|
+
if ("error" in fetched) {
|
|
1174
|
+
truth = { error: fetched.error };
|
|
1175
|
+
} else {
|
|
1176
|
+
await delay(TRUTH_SETTLE_MS);
|
|
1177
|
+
for (const bot of runner.bots) {
|
|
1178
|
+
clientStates.push({
|
|
1179
|
+
bot: bot.index,
|
|
1180
|
+
state: captureClientState(
|
|
1181
|
+
loaded.schema,
|
|
1182
|
+
bot.room.state
|
|
1183
|
+
)
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
try {
|
|
1187
|
+
const decoded = decodeSave(fetched.save.bytes, loaded.schema);
|
|
1188
|
+
truth = {
|
|
1189
|
+
saveId: fetched.save.saveId,
|
|
1190
|
+
saveState: decoded.state,
|
|
1191
|
+
diff: diffAgainstSave(clientStates, decoded.state, {
|
|
1192
|
+
saveTick: decoded.tick,
|
|
1193
|
+
saveVersion: decoded.version
|
|
1194
|
+
})
|
|
1195
|
+
};
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
truth = {
|
|
1198
|
+
saveId: fetched.save.saveId,
|
|
1199
|
+
error: `the save could not be decoded: ${err instanceof Error ? err.message : String(err)}`
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
425
1205
|
runner.recordTickHealth(tickHealthFrom(tickBefore, await readTickCounters(url, runner.roomId)));
|
|
1206
|
+
let dump;
|
|
1207
|
+
if (scenario !== void 0) {
|
|
1208
|
+
try {
|
|
1209
|
+
dump = await readTimeline(url, runner.roomId);
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
await runner.stop().catch(() => void 0);
|
|
1212
|
+
throw err;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
426
1215
|
const stopped = await Promise.race([
|
|
427
1216
|
runner.stop(),
|
|
428
|
-
new Promise((
|
|
429
|
-
const timer = setTimeout(() =>
|
|
1217
|
+
new Promise((resolve3) => {
|
|
1218
|
+
const timer = setTimeout(() => resolve3(runner.report()), STOP_GRACE_MS);
|
|
430
1219
|
timer.unref?.();
|
|
431
1220
|
})
|
|
432
1221
|
]);
|
|
433
1222
|
const endedBy = ceilingHit ? "ceiling" : runner.endedBy ?? "scripts";
|
|
434
1223
|
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;
|
|
435
|
-
await
|
|
436
|
-
const tracePath =
|
|
1224
|
+
await mkdir2(outDir, { recursive: true });
|
|
1225
|
+
const tracePath = path2.resolve(
|
|
437
1226
|
cwd,
|
|
438
|
-
options.trace ??
|
|
1227
|
+
options.trace ?? path2.join(outDir, `trace-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`)
|
|
439
1228
|
);
|
|
440
|
-
await
|
|
1229
|
+
await mkdir2(path2.dirname(tracePath), { recursive: true });
|
|
441
1230
|
await runner.trace.save(tracePath);
|
|
442
1231
|
const report = { ...stopped, tracePath };
|
|
443
|
-
|
|
1232
|
+
const hits = runner.shots.length > 0 && dump !== void 0 ? correlateShots(runner.shots, dump) : [];
|
|
1233
|
+
const cheated = report.perBot.map((_, index) => index).filter((index) => cheats(index));
|
|
1234
|
+
const injectedAny = runner.conditions.some((c) => c.conditions !== void 0);
|
|
1235
|
+
const adversarial = injectedAny || cheated.length > 0 ? {
|
|
1236
|
+
conditions: runner.conditions,
|
|
1237
|
+
cheated,
|
|
1238
|
+
correctionsPerCheater: cheated.map((index) => report.perBot[index]?.corrections ?? 0)
|
|
1239
|
+
} : void 0;
|
|
1240
|
+
let scenarioRun;
|
|
1241
|
+
if (scenario !== void 0 && dump !== void 0 && loadedScenario !== void 0) {
|
|
1242
|
+
const timeline = makeTimeline(dump);
|
|
1243
|
+
const assertions = [];
|
|
1244
|
+
let threw;
|
|
1245
|
+
try {
|
|
1246
|
+
await scenario.assert(timeline, { hits, truth: truth?.diff });
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
threw = err instanceof Error ? err.message : String(err);
|
|
1249
|
+
}
|
|
1250
|
+
assertions.push(...timeline.results);
|
|
1251
|
+
if (threw !== void 0) assertions.push({ name: "assert", ok: false, detail: threw });
|
|
1252
|
+
const timelinePath = path2.join(
|
|
1253
|
+
path2.dirname(tracePath),
|
|
1254
|
+
`${path2.basename(tracePath, ".json")}-timeline.json`
|
|
1255
|
+
);
|
|
1256
|
+
await writeFile(timelinePath, `${JSON.stringify(dump, null, 2)}
|
|
1257
|
+
`, "utf8");
|
|
1258
|
+
scenarioRun = {
|
|
1259
|
+
file: loadedScenario.file,
|
|
1260
|
+
assertions,
|
|
1261
|
+
ok: assertions.length > 0 && assertions.every((a) => a.ok),
|
|
1262
|
+
ticks: dump.frames.length,
|
|
1263
|
+
dropped: dump.dropped,
|
|
1264
|
+
timelinePath
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
for (const line of formatReport(report, loaded !== void 0)) {
|
|
444
1268
|
log(line);
|
|
445
1269
|
}
|
|
1270
|
+
if (report.profile !== void 0) {
|
|
1271
|
+
for (const line of formatRunProfile(report, options.profileTop)) log(line);
|
|
1272
|
+
}
|
|
1273
|
+
if (report.matchmaking !== void 0) {
|
|
1274
|
+
for (const line of formatMatchmaking(report.matchmaking, runner.matchFailures)) log(line);
|
|
1275
|
+
}
|
|
1276
|
+
if (adversarial !== void 0) for (const line of formatAdversarial(adversarial)) log(line);
|
|
1277
|
+
if (hits.length > 0) for (const line of formatHits(hits)) log(line);
|
|
1278
|
+
if (truth !== void 0) for (const line of formatTruth(truth)) log(line);
|
|
1279
|
+
if (scenarioRun !== void 0) {
|
|
1280
|
+
for (const line of formatScenario(scenarioRun, cwd)) log(line);
|
|
1281
|
+
}
|
|
446
1282
|
if (worldError !== void 0) {
|
|
447
1283
|
log(pc.red(` FAIL world-builder: ${worldError}`));
|
|
448
1284
|
} else if (predicted) {
|
|
449
1285
|
const suppressed = report.totals.suppressedCorrections;
|
|
1286
|
+
const engine = world2d !== void 0 ? "matter2d" : "rapier3d";
|
|
450
1287
|
log(
|
|
451
1288
|
pc.dim(
|
|
452
|
-
` bots predicted physics from ${
|
|
1289
|
+
` bots predicted ${engine} physics from ${path2.relative(cwd, world?.file ?? world2d?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
|
|
453
1290
|
)
|
|
454
1291
|
);
|
|
1292
|
+
} else if (hasPhysics && isUnpredictedWorld(loadedWorld)) {
|
|
1293
|
+
log(pc.yellow(" bots interpolated instead of predicting; see the physics line above."));
|
|
455
1294
|
} else if (hasPhysics) {
|
|
456
1295
|
log(
|
|
457
1296
|
pc.yellow(
|
|
458
|
-
" this schema has physics but no irtio/world.ts
|
|
1297
|
+
" 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)."
|
|
459
1298
|
)
|
|
460
1299
|
);
|
|
461
1300
|
}
|
|
@@ -463,12 +1302,26 @@ async function runSimulation(options = {}) {
|
|
|
463
1302
|
log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
|
|
464
1303
|
}
|
|
465
1304
|
if (fatal !== void 0) log(pc.red(` run ended early: ${fatal}`));
|
|
466
|
-
const exitCode = endedBy === "ceiling" ? 2 : !report.ok || worldError !== void 0 || runner.scriptErrors.length > 0
|
|
1305
|
+
const exitCode = endedBy === "ceiling" ? 2 : !report.ok || worldError !== void 0 || runner.scriptErrors.length > 0 || // D73-c: a bot that never got a ticket is a measured failure of the front door, so it
|
|
1306
|
+
// is exit 1 like every other one. The run still happened for the bots that got in, and
|
|
1307
|
+
// the matchmaking section names the ones that did not.
|
|
1308
|
+
runner.matchFailures.length > 0 || scenarioRun?.ok === false || // D43: a difference between what the clients held and what the save says the server
|
|
1309
|
+
// held is a measured violation, so it is exit 1 like every other one. A run that could
|
|
1310
|
+
// not take a save at all is not: nothing was measured, and the section says so in
|
|
1311
|
+
// words rather than turning "unread" into a verdict, which is D36's `unavailable` rule
|
|
1312
|
+
// applied to the truth seam.
|
|
1313
|
+
truth?.diff?.ok === false ? 1 : 0;
|
|
467
1314
|
return {
|
|
468
1315
|
report,
|
|
469
1316
|
tracePath,
|
|
1317
|
+
build: build3,
|
|
1318
|
+
...scenarioRun !== void 0 ? { scenario: scenarioRun } : {},
|
|
1319
|
+
...adversarial !== void 0 ? { adversarial } : {},
|
|
1320
|
+
...hits.length > 0 ? { hits } : {},
|
|
1321
|
+
...truth !== void 0 ? { truth } : {},
|
|
470
1322
|
schema: loaded !== void 0,
|
|
471
1323
|
predicted,
|
|
1324
|
+
matchFailures: runner.matchFailures,
|
|
472
1325
|
...worldError !== void 0 ? { worldError } : {},
|
|
473
1326
|
endedBy,
|
|
474
1327
|
...fatal !== void 0 ? { fatal } : {},
|
|
@@ -499,17 +1352,25 @@ async function simulate(args) {
|
|
|
499
1352
|
...err instanceof RunNotPerformedError && localDefault ? [`nothing is answering ${DEFAULT_URL}. Start the room with \`irtio dev\`, or pass --url.`] : []
|
|
500
1353
|
]);
|
|
501
1354
|
if (trailer !== void 0) console.error(pc.dim(trailer));
|
|
502
|
-
process.exitCode = err instanceof RunNotPerformedError ? 2 : 1;
|
|
1355
|
+
process.exitCode = err instanceof RunNotPerformedError || err instanceof ScenarioNotRunError ? 2 : 1;
|
|
503
1356
|
}
|
|
504
1357
|
}
|
|
505
1358
|
export {
|
|
506
1359
|
RunNotPerformedError,
|
|
1360
|
+
ScenarioNotRunError,
|
|
507
1361
|
USAGE,
|
|
508
1362
|
ceilingFor,
|
|
1363
|
+
checkWorld2dDeterminism,
|
|
509
1364
|
checkWorldDeterminism,
|
|
1365
|
+
detectMatter2dRoom,
|
|
1366
|
+
formatBuildIdentity,
|
|
1367
|
+
is2dWorld,
|
|
1368
|
+
isUnpredictedWorld,
|
|
510
1369
|
loadProjectSchema,
|
|
511
1370
|
loadProjectWorld,
|
|
1371
|
+
loadScenario,
|
|
512
1372
|
parseSimulateArgs,
|
|
1373
|
+
readBuildIdentity,
|
|
513
1374
|
readTickCounters,
|
|
514
1375
|
runSimulation,
|
|
515
1376
|
simulate,
|