@irtio/cli 0.6.0 → 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.js +1 -1
- package/dist/{chunk-3HQMVCYA.js → chunk-BQBOBFBO.js} +4 -4
- package/dist/{chunk-RNAH5T4W.js → chunk-IDF46P7R.js} +2 -0
- package/dist/{chunk-DKWG7MGO.js → chunk-JL235KIE.js} +1 -1
- package/dist/{chunk-ZD4ND6X6.js → chunk-OCVALOGK.js} +1 -1
- package/dist/{chunk-RQSJZWQC.js → chunk-WFMRNGO5.js} +134 -105
- package/dist/{delete-project-VENS2B44.js → delete-project-MXUYNGAO.js} +2 -2
- package/dist/deploy.d.ts +3 -1
- package/dist/deploy.js +163 -5
- package/dist/{dev-QM26ONKS.js → dev-AUZ4OLA3.js} +139 -10
- package/dist/index.js +39 -15
- package/dist/init.js +2 -2
- package/dist/{keys-JHLMEGRA.js → keys-NXRIBJZP.js} +3 -3
- package/dist/{leaderboard-SYPSBPS3.js → leaderboard-ZBJBKETM.js} +65 -11
- package/dist/{login-2M73HBZT.js → login-3RVN5PPN.js} +2 -2
- package/dist/{logs-2W7CPZO5.js → logs-AT7G6YRH.js} +3 -3
- package/dist/{migrate-T3DZJREY.js → migrate-FMXTRVUV.js} +3 -3
- package/dist/{ratings-VG32WFDG.js → ratings-XBLX2MUW.js} +3 -3
- package/dist/{rollback-SO74MVZV.js → rollback-LI4TDLQA.js} +3 -3
- package/dist/{rooms-VI33P4RA.js → rooms-52Q5KBUS.js} +179 -20
- package/dist/simulate.d.ts +115 -10
- package/dist/simulate.js +270 -36
- package/dist/{static-deploy-KOWFKWZA.js → static-deploy-7UCYINJB.js} +5 -5
- package/dist/{status-HF3ZEKB7.js → status-JZGKH2P6.js} +3 -3
- package/dist/{usage-4G23QXCH.js → usage-7S447INI.js} +3 -3
- package/dist/{whoami-KTMTQNHM.js → whoami-UFSWPWK6.js} +2 -2
- package/package.json +7 -7
package/dist/simulate.d.ts
CHANGED
|
@@ -80,8 +80,14 @@ interface SimulateArgs {
|
|
|
80
80
|
profile?: boolean;
|
|
81
81
|
/** D65: rows in that table. Implies `profile`. */
|
|
82
82
|
profileTop?: number;
|
|
83
|
+
/** Queue for rooms instead of building one. Needs `control`. */
|
|
84
|
+
queue?: string;
|
|
85
|
+
/** The control plane the queue lives on. `irtio dev` runs none. */
|
|
86
|
+
control?: string;
|
|
87
|
+
/** Group this many consecutive bots into one party each. */
|
|
88
|
+
party?: number;
|
|
83
89
|
}
|
|
84
|
-
declare const USAGE = "usage: irtio simulate [options]\n\nDrives N real clients at a running room and checks the built-in invariants. Run it from a project\ndirectory so it can load irtio/schema.ts; without one it falls back to a relay run.\n\noptions:\n --bots <n> how many clients to spawn (default 5)\n --seconds <n> how long to play for (default 10)\n --room <code> join this room instead of creating one\n --url <ws://...> where to connect (default ws://localhost:7070)\n --key <projectKey> the project key to present\n --trace <path> write the frame trace here\n --scenario <file> run a scenario module and assert against the recorded timeline\n --cheat send illegal writes and expect corrections, from every bot\n --cheat-bot <index> only this bot cheats (repeatable)\n --conditions <json> inject network conditions into every bot, e.g.\n '{\"rttMs\":200,\"jitterMs\":20,\"loss\":0.02}'. Keys: rttMs, jitterMs,\n loss, duplicate, reorder, reorderMs. Loss, duplicate and reorder\n touch state frames only, so a join always completes\n --conditions-bot <i>:<json> conditions for one bot, overriding --conditions (repeatable)\n --truth save the room at the end and diff it against what each client\n received, within that client's visibility\n --misprediction-max <units> fail if one correction snaps a prediction further than this\n --snaps-max <n> fail above this many cap-exceeded reconciliations\n --corrections-max <perSec> fail above this correction rate per bot\n --overruns-max <n> fail above this many server tick overruns in the run window\n (default 0; the count is read from the server, and the run says\n so when it could not be read)\n --profile print where the run's bytes went, by collection and field, as the\n bots saw them\n --profile-top <n> rows in that table (default 12; implies --profile)\n -h, --help print this\n\nexit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be\nperformed (nobody joined, or it passed its wall-clock ceiling).\n";
|
|
90
|
+
declare const USAGE = "usage: irtio simulate [options]\n\nDrives N real clients at a running room and checks the built-in invariants. Run it from a project\ndirectory so it can load irtio/schema.ts; without one it falls back to a relay run.\n\noptions:\n --bots <n> how many clients to spawn (default 5)\n --seconds <n> how long to play for (default 10)\n --room <code> join this room instead of creating one\n --url <ws://...> where to connect (default ws://localhost:7070)\n --key <projectKey> the project key to present\n --trace <path> write the frame trace here\n --scenario <file> run a scenario module and assert against the recorded timeline\n --cheat send illegal writes and expect corrections, from every bot\n --cheat-bot <index> only this bot cheats (repeatable)\n --conditions <json> inject network conditions into every bot, e.g.\n '{\"rttMs\":200,\"jitterMs\":20,\"loss\":0.02}'. Keys: rttMs, jitterMs,\n loss, duplicate, reorder, reorderMs. Loss, duplicate and reorder\n touch state frames only, so a join always completes\n --conditions-bot <i>:<json> conditions for one bot, overriding --conditions (repeatable)\n --truth save the room at the end and diff it against what each client\n received, within that client's visibility\n --misprediction-max <units> fail if one correction snaps a prediction further than this\n --snaps-max <n> fail above this many cap-exceeded reconciliations\n --corrections-max <perSec> fail above this correction rate per bot\n --overruns-max <n> fail above this many server tick overruns in the run window\n (default 0; the count is read from the server, and the run says\n so when it could not be read)\n --queue <name> queue for rooms through the matchmaker instead of creating one:\n every bot calls the real match API and joins the room its ticket\n names. Needs --control\n --control <https://...> the control plane the queue lives on. `irtio dev` runs none, so a\n queued run points at a real one (staging, or a test plane)\n --party <n> group each n consecutive bots into one party, so they queue\n together and land in one room. Needs --queue\n --profile print where the run's bytes went, by collection and field, as the\n bots saw them\n --profile-top <n> rows in that table (default 12; implies --profile)\n -h, --help print this\n\nexit codes: 0 every invariant held, 1 something was measured and failed, 2 the run could not be\nperformed (nobody joined, or it passed its wall-clock ceiling).\n";
|
|
85
91
|
/** Hand-rolled, like `parseDevArgs`: the CLI has no argument-parsing dependency. */
|
|
86
92
|
declare function parseSimulateArgs(args: readonly string[]): SimulateArgs;
|
|
87
93
|
interface LoadSchemaOptions {
|
|
@@ -100,6 +106,7 @@ declare function loadProjectSchema(options: LoadSchemaOptions): Promise<{
|
|
|
100
106
|
} | undefined>;
|
|
101
107
|
/** The shared world-builder's exports, as `joinRoom({ physics })` wants them. */
|
|
102
108
|
interface LoadedWorld {
|
|
109
|
+
readonly engine: 'rapier3d';
|
|
103
110
|
readonly gravity: {
|
|
104
111
|
x: number;
|
|
105
112
|
y: number;
|
|
@@ -112,18 +119,87 @@ interface LoadedWorld {
|
|
|
112
119
|
readonly file: string;
|
|
113
120
|
}
|
|
114
121
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
122
|
+
* D73-a: a matter2d world, as `joinRoom({ physics2d })` wants it.
|
|
123
|
+
*
|
|
124
|
+
* `bugs.md` #21 taught this loader to *recognise* a 2D world rather than refuse it, and stopped
|
|
125
|
+
* there, on a comment saying matter2d had no client prediction. That stopped being true when
|
|
126
|
+
* `joinRoom({ physics2d })` shipped (M5 part 4.5), so a 2D world is now loaded to be predicted
|
|
127
|
+
* with, exactly as the Rapier one is.
|
|
128
|
+
*
|
|
129
|
+
* The source is the shared world module, which exports `gravity` (2D) and whichever of `timestep`,
|
|
130
|
+
* `setup`, `bodies`, `intents` and `settle` it has — the same object a browser hands
|
|
131
|
+
* `joinRoom({ physics2d })`. The room's own `physics` declaration is deliberately **not** a
|
|
132
|
+
* source: it is the server's half, and the client half (`settle`, the per-step force over the
|
|
133
|
+
* bodies nobody steers, and the exact timestep) is written on the client. A world built from the
|
|
134
|
+
* room alone would leave every unsteered body hanging in the air locally and be corrected on
|
|
135
|
+
* every tick, which is a worse answer than not predicting.
|
|
136
|
+
*/
|
|
137
|
+
interface LoadedWorld2d {
|
|
138
|
+
readonly engine: 'matter2d';
|
|
139
|
+
readonly gravity: {
|
|
140
|
+
x: number;
|
|
141
|
+
y: number;
|
|
142
|
+
};
|
|
143
|
+
readonly timestep?: number | undefined;
|
|
144
|
+
readonly setup?: ((engine: unknown, matter: unknown) => void) | undefined;
|
|
145
|
+
readonly bodies?: Record<string, (...args: never[]) => unknown> | undefined;
|
|
146
|
+
readonly intents?: Record<string, (...args: never[]) => void> | undefined;
|
|
147
|
+
readonly settle?: Record<string, (...args: never[]) => void> | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* The three tuning numbers `ClientPhysics2dOptions` takes, when the world module states them.
|
|
150
|
+
*
|
|
151
|
+
* `epsilon` is the one that matters and it is the one nobody expects: it is a distance in the
|
|
152
|
+
* world's own units, and its default (0.05) is sized for a metre-scale world. A matter.js world
|
|
153
|
+
* in pixels — matter's own convention, and what every matter.js tutorial writes — is two orders
|
|
154
|
+
* of magnitude coarser, so the default calls every ordinary contact resolution a misprediction.
|
|
155
|
+
* A world that states its scale here gets the same suppression a metre-scale world gets for free.
|
|
156
|
+
*/
|
|
157
|
+
readonly epsilon?: number | undefined;
|
|
158
|
+
readonly maxPredictedBodies?: number | undefined;
|
|
159
|
+
readonly smoothingHalfLifeMs?: number | undefined;
|
|
160
|
+
readonly file: string;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A matter2d project this loader recognises and cannot predict from: run it, say why in one
|
|
164
|
+
* sentence, and never pretend to a predictor that does not exist.
|
|
165
|
+
*
|
|
166
|
+
* `bugs.md` #21 introduced this for every matter2d project, because there was no client
|
|
167
|
+
* prediction at all. D73-a narrows it to the two cases that are still true: a world module with
|
|
168
|
+
* no `bodies` (there is nothing to build), and an **inert** one — no `intents`, no `settle`, and
|
|
169
|
+
* zero gravity, so nothing in a local world built from it could ever move a body.
|
|
170
|
+
* `examples/matter-chase` is the second case by design: its room applies thrust by hand inside
|
|
171
|
+
* `tick()`, so there is no steering hook to share and its own client does not predict either.
|
|
118
172
|
*/
|
|
119
|
-
/** `bugs.md` #21: a 2D world module, recognised rather than refused. There is nothing to predict
|
|
120
|
-
* from it in this release, so the caller drops the predicted-world path and runs anyway. */
|
|
121
173
|
interface UnpredictedWorld {
|
|
122
|
-
readonly
|
|
174
|
+
readonly engine: 'matter2d';
|
|
175
|
+
/** One sentence, printed by the run, naming what would have to change. */
|
|
176
|
+
readonly why: string;
|
|
123
177
|
readonly file: string;
|
|
124
178
|
}
|
|
125
|
-
|
|
126
|
-
|
|
179
|
+
type LoadedAnyWorld = LoadedWorld | LoadedWorld2d | UnpredictedWorld;
|
|
180
|
+
/** Narrows a loaded world to a matter2d one bots can predict with. */
|
|
181
|
+
declare function is2dWorld(world: LoadedAnyWorld | undefined): world is LoadedWorld2d;
|
|
182
|
+
declare function isUnpredictedWorld(world: LoadedAnyWorld | undefined): world is UnpredictedWorld;
|
|
183
|
+
/**
|
|
184
|
+
* D73-a: does this project's room declare matter2d?
|
|
185
|
+
*
|
|
186
|
+
* The one question the room module is asked, and the reason it is asked at all: a matter2d project
|
|
187
|
+
* whose world module is not a client world — `games/dive`, whose engine gravity is zero and
|
|
188
|
+
* applied per body, so there is no `gravity` to export and its `physics2d` block is composed in
|
|
189
|
+
* `main.ts` — used to be refused at the door with "does not export a { x, y, z } gravity", which
|
|
190
|
+
* is why `irtio simulate` had never been run against dive at all. Recognising it here turns that
|
|
191
|
+
* refusal into a run with a sentence.
|
|
192
|
+
*
|
|
193
|
+
* `undefined` for every other answer: no room module, one that will not build or import, or one
|
|
194
|
+
* declaring another engine. None of those is an error here — the world module's own error is the
|
|
195
|
+
* right one to raise.
|
|
196
|
+
*/
|
|
197
|
+
declare function detectMatter2dRoom(options: LoadSchemaOptions): Promise<string | undefined>;
|
|
198
|
+
/**
|
|
199
|
+
* Bundles and imports the project's shared world-builder module, so the bots predict physics the
|
|
200
|
+
* way a browser client would.
|
|
201
|
+
*/
|
|
202
|
+
declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedAnyWorld | undefined>;
|
|
127
203
|
/**
|
|
128
204
|
* The build-twice determinism check: builds two worlds from the shared `setup` and compares
|
|
129
205
|
* Rapier's own snapshots byte for byte. A builder that is not pure over synced inputs
|
|
@@ -132,6 +208,23 @@ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedWor
|
|
|
132
208
|
* description, or `undefined` when the worlds agree.
|
|
133
209
|
*/
|
|
134
210
|
declare function checkWorldDeterminism(world: LoadedWorld): Promise<string | undefined>;
|
|
211
|
+
/**
|
|
212
|
+
* D73-a: the matter2d determinism check.
|
|
213
|
+
*
|
|
214
|
+
* The Rapier check compares two `takeSnapshot()` byte strings, and matter.js has no snapshot at
|
|
215
|
+
* all (`packages/runtime/src/core/matter.ts`'s header says so), so this one is built the only way
|
|
216
|
+
* that is left: build the world twice through the engine the runtime loads, step both the same
|
|
217
|
+
* number of times with the same (empty) intent stream, and compare every body's pose and velocity
|
|
218
|
+
* through a canonical encoding. Two builds that disagree came from a builder that is not pure over
|
|
219
|
+
* synced inputs, which is the same fault the Rapier check catches and the same reason it matters —
|
|
220
|
+
* every "misprediction" it causes is blamed on netcode.
|
|
221
|
+
*
|
|
222
|
+
* Stepping, rather than comparing the two worlds as built, is what makes it catch randomness that
|
|
223
|
+
* is small at t=0: a crate spawned a thousandth of a unit off lands in a different place. The body
|
|
224
|
+
* factories are exercised too, against each collection's zero record, so a `Math.random()` in a
|
|
225
|
+
* factory is caught and not only one in `setup`.
|
|
226
|
+
*/
|
|
227
|
+
declare function checkWorld2dDeterminism(world: LoadedWorld2d, schema?: AnySchema): Promise<string | undefined>;
|
|
135
228
|
/**
|
|
136
229
|
* The dev server's JSON view, which is where a local run reads the server's own tick counters.
|
|
137
230
|
* `irtio dev` serves it on the socket's own port; nothing else does, and that is deliberate — a
|
|
@@ -221,6 +314,12 @@ interface RunSimulationOptions extends Partial<SimulateArgs> {
|
|
|
221
314
|
* is a fatal run condition, not a clean end.
|
|
222
315
|
*/
|
|
223
316
|
readonly ceilingMs?: number;
|
|
317
|
+
/**
|
|
318
|
+
* D73-c: how long a queued bot waits for its ticket. The control plane caps this at two minutes
|
|
319
|
+
* and applies its own default (30 s) when it is omitted; a run that wants to see a queue *not*
|
|
320
|
+
* fill needs a bound shorter than its own patience.
|
|
321
|
+
*/
|
|
322
|
+
readonly matchTimeoutMs?: number;
|
|
224
323
|
/** @internal Test seam, same as `startDev`'s. */
|
|
225
324
|
readonly irtioPackages?: Record<string, string> | undefined;
|
|
226
325
|
}
|
|
@@ -296,6 +395,12 @@ interface SimulationRun {
|
|
|
296
395
|
readonly schema: boolean;
|
|
297
396
|
/** `true` when the bots predicted physics from a shared world-builder module (D22 part 2). */
|
|
298
397
|
readonly predicted: boolean;
|
|
398
|
+
/** D73-c: bots that never got a ticket. Empty on a run that did not queue. */
|
|
399
|
+
readonly matchFailures: readonly {
|
|
400
|
+
requested: number;
|
|
401
|
+
code: string;
|
|
402
|
+
message: string;
|
|
403
|
+
}[];
|
|
299
404
|
/** Result of the build-twice world-builder check: the failure text, or `undefined` if it held
|
|
300
405
|
* (or did not apply). A failure also fails the run. */
|
|
301
406
|
readonly worldError?: string;
|
|
@@ -312,4 +417,4 @@ interface SimulationRun {
|
|
|
312
417
|
declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
|
|
313
418
|
declare function simulate(args: readonly string[]): Promise<void>;
|
|
314
419
|
|
|
315
|
-
export { type AdversarialRun, type BuildIdentity, type LoadSchemaOptions, RunNotPerformedError, type RunSimulationOptions, ScenarioNotRunError, type ScenarioRun, type SimulateArgs, type SimulationEnd, type SimulationRun, type TruthRun, USAGE, type UnpredictedWorld, ceilingFor, checkWorldDeterminism, formatBuildIdentity, isUnpredictedWorld, loadProjectSchema, loadProjectWorld, loadScenario, parseSimulateArgs, readBuildIdentity, readTickCounters, runSimulation, simulate, stateUrlFor, tickHealthFrom };
|
|
420
|
+
export { type AdversarialRun, type BuildIdentity, type LoadSchemaOptions, type LoadedAnyWorld, type LoadedWorld2d, RunNotPerformedError, type RunSimulationOptions, ScenarioNotRunError, type ScenarioRun, type SimulateArgs, type SimulationEnd, type SimulationRun, type TruthRun, USAGE, type UnpredictedWorld, ceilingFor, checkWorld2dDeterminism, checkWorldDeterminism, detectMatter2dRoom, formatBuildIdentity, is2dWorld, isUnpredictedWorld, loadProjectSchema, loadProjectWorld, loadScenario, parseSimulateArgs, readBuildIdentity, readTickCounters, runSimulation, simulate, stateUrlFor, tickHealthFrom };
|
package/dist/simulate.js
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
helpFor,
|
|
7
7
|
helpRequested,
|
|
8
8
|
trailerFor
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import "./chunk-
|
|
9
|
+
} from "./chunk-OCVALOGK.js";
|
|
10
|
+
import "./chunk-IDF46P7R.js";
|
|
11
11
|
import "./chunk-UPHQM6NZ.js";
|
|
12
12
|
|
|
13
13
|
// src/simulate.ts
|
|
@@ -276,6 +276,13 @@ options:
|
|
|
276
276
|
--overruns-max <n> fail above this many server tick overruns in the run window
|
|
277
277
|
(default 0; the count is read from the server, and the run says
|
|
278
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
|
|
279
286
|
--profile print where the run's bytes went, by collection and field, as the
|
|
280
287
|
bots saw them
|
|
281
288
|
--profile-top <n> rows in that table (default 12; implies --profile)
|
|
@@ -350,6 +357,21 @@ function parseSimulateArgs(args) {
|
|
|
350
357
|
case "--truth":
|
|
351
358
|
parsed.truth = true;
|
|
352
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
|
+
}
|
|
353
375
|
case "--profile":
|
|
354
376
|
parsed.profile = true;
|
|
355
377
|
break;
|
|
@@ -378,6 +400,16 @@ function parseSimulateArgs(args) {
|
|
|
378
400
|
throw new Error(`irtio simulate: unknown option ${JSON.stringify(arg)}`);
|
|
379
401
|
}
|
|
380
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
|
+
}
|
|
381
413
|
return parsed;
|
|
382
414
|
}
|
|
383
415
|
function monorepoPackages() {
|
|
@@ -423,16 +455,15 @@ async function loadProjectSchema(options) {
|
|
|
423
455
|
}
|
|
424
456
|
return { schema, file: entry };
|
|
425
457
|
}
|
|
458
|
+
function is2dWorld(world) {
|
|
459
|
+
return world !== void 0 && world.engine === "matter2d" && !("why" in world);
|
|
460
|
+
}
|
|
426
461
|
function isUnpredictedWorld(world) {
|
|
427
|
-
return world !== void 0 && "
|
|
462
|
+
return world !== void 0 && "why" in world;
|
|
428
463
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
(f) => existsSync2(f)
|
|
432
|
-
);
|
|
433
|
-
if (entry === void 0) return void 0;
|
|
464
|
+
var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
|
|
465
|
+
async function bundleAndImport(entry, outfile, options) {
|
|
434
466
|
await mkdir2(options.outDir, { recursive: true });
|
|
435
|
-
const outfile = path2.join(options.outDir, "world.mjs");
|
|
436
467
|
const alias = options.irtioPackages ?? monorepoPackages();
|
|
437
468
|
await esbuild2.build({
|
|
438
469
|
entryPoints: [entry],
|
|
@@ -440,34 +471,93 @@ async function loadProjectWorld(options) {
|
|
|
440
471
|
format: "esm",
|
|
441
472
|
platform: "node",
|
|
442
473
|
outfile,
|
|
443
|
-
external: ["@dimforge/rapier3d-compat"],
|
|
474
|
+
external: ["@dimforge/rapier3d-compat", "matter-js"],
|
|
444
475
|
...alias !== void 0 ? { alias } : {}
|
|
445
476
|
});
|
|
446
|
-
|
|
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);
|
|
447
521
|
const gravity = module.gravity;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
`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.
|
|
451
524
|
the shared world-builder must export the same gravity the room config uses
|
|
452
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.`
|
|
453
531
|
);
|
|
532
|
+
if (recognised !== void 0) return recognised;
|
|
533
|
+
throw notAWorld;
|
|
454
534
|
}
|
|
455
535
|
if (typeof gravity.z !== "number") {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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 };
|
|
464
552
|
}
|
|
553
|
+
if (typeof gravity.y !== "number") throw notAWorld;
|
|
465
554
|
return {
|
|
555
|
+
engine: "rapier3d",
|
|
466
556
|
gravity,
|
|
467
557
|
...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
|
|
468
558
|
...typeof module.setup === "function" ? { setup: module.setup } : {},
|
|
469
|
-
...
|
|
470
|
-
...
|
|
559
|
+
...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
|
|
560
|
+
...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
|
|
471
561
|
file: entry
|
|
472
562
|
};
|
|
473
563
|
}
|
|
@@ -493,6 +583,48 @@ function everyByteEqual(a, b) {
|
|
|
493
583
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
494
584
|
return true;
|
|
495
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
|
+
}
|
|
496
628
|
var TICK_HEALTH_TIMEOUT_MS = 2e3;
|
|
497
629
|
function stateUrlFor(wsUrl) {
|
|
498
630
|
let parsed;
|
|
@@ -647,11 +779,30 @@ function formatReport(report, schemaLoaded) {
|
|
|
647
779
|
const convergence = report.convergenceLagMs === void 0 ? "convergence not sampled" : `convergence ${report.convergenceLagMs} ms (p50 of ${report.convergence?.samples ?? 0})`;
|
|
648
780
|
const { mispredictions, mispredictionMagnitude, mispredictionMax, snaps } = report.totals;
|
|
649
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` : "");
|
|
650
784
|
lines.push(
|
|
651
785
|
pc.dim(
|
|
652
|
-
` ${report.framesPerSecond} frames/s \xB7 in ${rate(report.bytesInPerSecondPerBot)}/bot \xB7 out ${rate(report.bytesOutPerSecondPerBot)}/bot \xB7 ${report.totals.corrections} corrections \xB7 ${misprediction} \xB7 ${snaps} snap(s) \xB7 ${convergence}`
|
|
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}`
|
|
653
787
|
)
|
|
654
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
|
+
}
|
|
655
806
|
if (report.tracePath !== void 0) lines.push(pc.dim(` trace: ${report.tracePath}`));
|
|
656
807
|
lines.push("");
|
|
657
808
|
const failed = report.invariants.filter((i) => !i.ok);
|
|
@@ -742,7 +893,8 @@ function formatHits(rows) {
|
|
|
742
893
|
` ${pc.bold("hit registration")} ${pc.dim(`(${rows.length} shot(s))`)}`
|
|
743
894
|
];
|
|
744
895
|
for (const row of rows) {
|
|
745
|
-
const
|
|
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}`;
|
|
746
898
|
if (row.missDistance === void 0) {
|
|
747
899
|
lines.push(
|
|
748
900
|
pc.yellow(
|
|
@@ -758,12 +910,46 @@ function formatHits(rows) {
|
|
|
758
910
|
const measured = rows.filter((r) => r.missDistance !== void 0);
|
|
759
911
|
if (measured.length > 0) {
|
|
760
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.`;
|
|
761
916
|
lines.push(
|
|
762
917
|
pc.dim(
|
|
763
|
-
` mean miss ${mean.toFixed(2)} over ${measured.length} shot(s).
|
|
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}`
|
|
764
942
|
)
|
|
765
943
|
);
|
|
766
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
|
+
}
|
|
767
953
|
return lines;
|
|
768
954
|
}
|
|
769
955
|
function point(p) {
|
|
@@ -856,15 +1042,18 @@ async function runSimulation(options = {}) {
|
|
|
856
1042
|
(c) => c.physics !== void 0
|
|
857
1043
|
);
|
|
858
1044
|
const loadedWorld = hasPhysics ? await loadProjectWorld({ cwd, outDir, irtioPackages: options.irtioPackages }) : void 0;
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
);
|
|
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}`));
|
|
863
1051
|
}
|
|
864
|
-
const world = isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
|
|
865
1052
|
let worldError;
|
|
866
1053
|
if (world) {
|
|
867
1054
|
worldError = await checkWorldDeterminism(world);
|
|
1055
|
+
} else if (world2d) {
|
|
1056
|
+
worldError = await checkWorld2dDeterminism(world2d, loaded?.schema);
|
|
868
1057
|
}
|
|
869
1058
|
const conditionsSpec = scenario?.conditions ?? conditionsFrom(options);
|
|
870
1059
|
const cheatSpec = scenario?.cheat ?? cheatFrom(options);
|
|
@@ -883,9 +1072,27 @@ async function runSimulation(options = {}) {
|
|
|
883
1072
|
...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {},
|
|
884
1073
|
...options.profile === true ? { profile: true } : {}
|
|
885
1074
|
};
|
|
886
|
-
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;
|
|
887
1093
|
const spawn = {
|
|
888
1094
|
...common,
|
|
1095
|
+
...match !== void 0 ? { match } : {},
|
|
889
1096
|
...options.joinTimeoutMs !== void 0 ? { joinTimeoutMs: options.joinTimeoutMs } : {},
|
|
890
1097
|
...options.roomGoneGraceMs !== void 0 ? { roomGoneGraceMs: options.roomGoneGraceMs } : {}
|
|
891
1098
|
};
|
|
@@ -895,7 +1102,7 @@ async function runSimulation(options = {}) {
|
|
|
895
1102
|
...spawn,
|
|
896
1103
|
schema: loaded.schema,
|
|
897
1104
|
script: scenario?.script ?? randomScript(loaded.schema, { cheat: cheatSpec ?? false }),
|
|
898
|
-
...predicted ? {
|
|
1105
|
+
...predicted && world !== void 0 ? {
|
|
899
1106
|
physics: {
|
|
900
1107
|
gravity: world.gravity,
|
|
901
1108
|
...world.timestep !== void 0 ? { timestep: world.timestep } : {},
|
|
@@ -903,6 +1110,20 @@ async function runSimulation(options = {}) {
|
|
|
903
1110
|
...world.bodies !== void 0 ? { bodies: world.bodies } : {},
|
|
904
1111
|
...world.intents !== void 0 ? { intents: world.intents } : {}
|
|
905
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
|
+
}
|
|
906
1127
|
} : {}
|
|
907
1128
|
}) : await spawnBots(bots, { ...spawn, script: relayEchoScript() });
|
|
908
1129
|
} catch (err) {
|
|
@@ -1049,6 +1270,9 @@ async function runSimulation(options = {}) {
|
|
|
1049
1270
|
if (report.profile !== void 0) {
|
|
1050
1271
|
for (const line of formatRunProfile(report, options.profileTop)) log(line);
|
|
1051
1272
|
}
|
|
1273
|
+
if (report.matchmaking !== void 0) {
|
|
1274
|
+
for (const line of formatMatchmaking(report.matchmaking, runner.matchFailures)) log(line);
|
|
1275
|
+
}
|
|
1052
1276
|
if (adversarial !== void 0) for (const line of formatAdversarial(adversarial)) log(line);
|
|
1053
1277
|
if (hits.length > 0) for (const line of formatHits(hits)) log(line);
|
|
1054
1278
|
if (truth !== void 0) for (const line of formatTruth(truth)) log(line);
|
|
@@ -1059,11 +1283,14 @@ async function runSimulation(options = {}) {
|
|
|
1059
1283
|
log(pc.red(` FAIL world-builder: ${worldError}`));
|
|
1060
1284
|
} else if (predicted) {
|
|
1061
1285
|
const suppressed = report.totals.suppressedCorrections;
|
|
1286
|
+
const engine = world2d !== void 0 ? "matter2d" : "rapier3d";
|
|
1062
1287
|
log(
|
|
1063
1288
|
pc.dim(
|
|
1064
|
-
` bots predicted physics from ${path2.relative(cwd, world?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
|
|
1289
|
+
` bots predicted ${engine} physics from ${path2.relative(cwd, world?.file ?? world2d?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
|
|
1065
1290
|
)
|
|
1066
1291
|
);
|
|
1292
|
+
} else if (hasPhysics && isUnpredictedWorld(loadedWorld)) {
|
|
1293
|
+
log(pc.yellow(" bots interpolated instead of predicting; see the physics line above."));
|
|
1067
1294
|
} else if (hasPhysics) {
|
|
1068
1295
|
log(
|
|
1069
1296
|
pc.yellow(
|
|
@@ -1075,7 +1302,10 @@ async function runSimulation(options = {}) {
|
|
|
1075
1302
|
log(pc.red(` bot ${failure.bot} script failed: ${String(failure.error)}`));
|
|
1076
1303
|
}
|
|
1077
1304
|
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 ||
|
|
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
|
|
1079
1309
|
// held is a measured violation, so it is exit 1 like every other one. A run that could
|
|
1080
1310
|
// not take a save at all is not: nothing was measured, and the section says so in
|
|
1081
1311
|
// words rather than turning "unread" into a verdict, which is D36's `unavailable` rule
|
|
@@ -1091,6 +1321,7 @@ async function runSimulation(options = {}) {
|
|
|
1091
1321
|
...truth !== void 0 ? { truth } : {},
|
|
1092
1322
|
schema: loaded !== void 0,
|
|
1093
1323
|
predicted,
|
|
1324
|
+
matchFailures: runner.matchFailures,
|
|
1094
1325
|
...worldError !== void 0 ? { worldError } : {},
|
|
1095
1326
|
endedBy,
|
|
1096
1327
|
...fatal !== void 0 ? { fatal } : {},
|
|
@@ -1129,8 +1360,11 @@ export {
|
|
|
1129
1360
|
ScenarioNotRunError,
|
|
1130
1361
|
USAGE,
|
|
1131
1362
|
ceilingFor,
|
|
1363
|
+
checkWorld2dDeterminism,
|
|
1132
1364
|
checkWorldDeterminism,
|
|
1365
|
+
detectMatter2dRoom,
|
|
1133
1366
|
formatBuildIdentity,
|
|
1367
|
+
is2dWorld,
|
|
1134
1368
|
isUnpredictedWorld,
|
|
1135
1369
|
loadProjectSchema,
|
|
1136
1370
|
loadProjectWorld,
|