@irtio/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle.js +1 -1
- package/dist/{chunk-OTSFRVJN.js → chunk-NOYGY3HA.js} +10 -3
- package/dist/deploy.js +1 -1
- package/dist/{dev-AUZ4OLA3.js → dev-VJ2ATJTF.js} +81 -4
- package/dist/index.js +2 -2
- package/dist/init.js +1 -1
- package/dist/{migrate-FMXTRVUV.js → migrate-5YCFT63L.js} +1 -1
- package/dist/simulate.d.ts +60 -12
- package/dist/simulate.js +101 -28
- package/package.json +8 -7
package/dist/bundle.js
CHANGED
|
@@ -10,9 +10,16 @@ var ALLOWED_IMPORTS = [
|
|
|
10
10
|
"@irtio/schema",
|
|
11
11
|
"@dimforge/rapier3d-compat",
|
|
12
12
|
// D45: the second blessed engine. Exactly one import added, as the decision says.
|
|
13
|
-
"matter-js"
|
|
13
|
+
"matter-js",
|
|
14
|
+
// The third: Rapier held in a plane. A separate package from the 3D build, so a separate
|
|
15
|
+
// import — a room picks one and pays for one.
|
|
16
|
+
"@dimforge/rapier2d-compat"
|
|
17
|
+
];
|
|
18
|
+
var EXTERNAL_IMPORTS = [
|
|
19
|
+
"@dimforge/rapier3d-compat",
|
|
20
|
+
"matter-js",
|
|
21
|
+
"@dimforge/rapier2d-compat"
|
|
14
22
|
];
|
|
15
|
-
var EXTERNAL_IMPORTS = ["@dimforge/rapier3d-compat", "matter-js"];
|
|
16
23
|
var BundleError = class extends Error {
|
|
17
24
|
name = "BundleError";
|
|
18
25
|
};
|
|
@@ -92,7 +99,7 @@ async function bundleRoom(options) {
|
|
|
92
99
|
if (!out) throw new BundleError("esbuild produced no output");
|
|
93
100
|
const warnings = result.warnings.map((w) => w.text);
|
|
94
101
|
const importsEngine = Object.values(result.metafile?.outputs ?? {}).some(
|
|
95
|
-
(o) => o.imports.some((i) =>
|
|
102
|
+
(o) => o.imports.some((i) => EXTERNAL_IMPORTS.includes(i.path))
|
|
96
103
|
);
|
|
97
104
|
const hash = createHash("sha256").update(out.contents).digest("hex");
|
|
98
105
|
const file = path.join(options.outDir, `room.${hash.slice(0, 16)}.mjs`);
|
package/dist/deploy.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
BundleError,
|
|
32
32
|
bundleRoom
|
|
33
|
-
} from "./chunk-
|
|
33
|
+
} from "./chunk-NOYGY3HA.js";
|
|
34
34
|
import {
|
|
35
35
|
HelpRequested,
|
|
36
36
|
helpFor,
|
|
@@ -1746,6 +1746,33 @@ var RoomRecord = class {
|
|
|
1746
1746
|
* which is the arena case and the common one.
|
|
1747
1747
|
*/
|
|
1748
1748
|
backfillOpen = true;
|
|
1749
|
+
// ---- M6 lane H: lobby front door ----
|
|
1750
|
+
/**
|
|
1751
|
+
* D75: whether this room is currently in control's public registry, and which queue under.
|
|
1752
|
+
*
|
|
1753
|
+
* On the record for `backfillOpen`'s reasons — out of the hibernation blob, and the supervisor's
|
|
1754
|
+
* own bookkeeping — but NOT reset on a worker ready, and that difference is the whole point.
|
|
1755
|
+
* `lobbyStarted` is a one-way door: a room whose game has started must never be advertised
|
|
1756
|
+
* again, and a reset would re-offer it to a stranger on the first poll after a wake.
|
|
1757
|
+
*/
|
|
1758
|
+
lobbyPublic = false;
|
|
1759
|
+
lobbyQueue;
|
|
1760
|
+
lobbyStarted = false;
|
|
1761
|
+
/**
|
|
1762
|
+
* Has this room said anything about its lobby yet?
|
|
1763
|
+
*
|
|
1764
|
+
* This is the difference between "private" and "not in the registry conversation", and getting it
|
|
1765
|
+
* wrong was a real bug rather than a nicety. The first version reported the lobby fields only
|
|
1766
|
+
* when `lobbyPublic` was true, so a `false` never left the box: control's deregister branch was
|
|
1767
|
+
* unreachable, and a host who made a lobby public and then private kept receiving strangers while
|
|
1768
|
+
* their own panel said private.
|
|
1769
|
+
*
|
|
1770
|
+
* The two states still have to stay apart, because absence is what an agent older than this lane
|
|
1771
|
+
* sends for every room on its box and what every room without a lobby sends forever — control
|
|
1772
|
+
* reads it as "leave this room's registry row alone". So the latch: once a room has spoken, every
|
|
1773
|
+
* report carries its current answer, `false` included.
|
|
1774
|
+
*/
|
|
1775
|
+
lobbySpoken = false;
|
|
1749
1776
|
/**
|
|
1750
1777
|
* The deployment version this room was created under. New rooms take the newest; rooms created
|
|
1751
1778
|
* before a deploy keep theirs until they idle (per-room drain, plan §3.3).
|
|
@@ -1909,7 +1936,18 @@ var RoomRecord = class {
|
|
|
1909
1936
|
// only when the room type declared it, so "absent" and "not a candidate" stay the same
|
|
1910
1937
|
// thing all the way to control's nullable column.
|
|
1911
1938
|
maxClients: this.config.maxClients,
|
|
1912
|
-
...this.config.backfill === true ? { backfill: this.backfillNow } : {}
|
|
1939
|
+
...this.config.backfill === true ? { backfill: this.backfillNow } : {},
|
|
1940
|
+
// ---- M6 lane H: lobby front door ----
|
|
1941
|
+
// Reported by a room that has said something about its lobby, and by no other room, so
|
|
1942
|
+
// "absent" stays "not in the registry conversation at all" — the same shape `backfill` uses,
|
|
1943
|
+
// and what keeps a mixed fleet degrading to "no public rooms from the old boxes".
|
|
1944
|
+
//
|
|
1945
|
+
// Once a room HAS spoken, its current answer goes out on every report including `false`. See
|
|
1946
|
+
// `lobbySpoken` for the bug that came of only ever reporting `true`.
|
|
1947
|
+
...this.lobbyStarted ? { lobbyStarted: true } : this.lobbySpoken ? {
|
|
1948
|
+
public: this.lobbyPublic,
|
|
1949
|
+
...this.lobbyQueue !== void 0 ? { queue: this.lobbyQueue } : {}
|
|
1950
|
+
} : {}
|
|
1913
1951
|
};
|
|
1914
1952
|
}
|
|
1915
1953
|
};
|
|
@@ -2449,6 +2487,7 @@ var IDENTITY_KEY_WAIT_MS = 2e3;
|
|
|
2449
2487
|
var MAX_IDENTITY_KEY_WAITERS = 64;
|
|
2450
2488
|
var RESUME_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2451
2489
|
var BUS_SEEN_MAX = 256;
|
|
2490
|
+
var LOBBY_QUEUE_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
|
|
2452
2491
|
var SHARD_ASSIGNMENT_RETRY_MS = [1e3, 3e4];
|
|
2453
2492
|
var KvUnavailable = class extends Error {
|
|
2454
2493
|
code = KV_ERRORS.unavailable;
|
|
@@ -5679,6 +5718,38 @@ var SupervisorImpl = class {
|
|
|
5679
5718
|
case "setBackfill":
|
|
5680
5719
|
room.backfillOpen = msg.open;
|
|
5681
5720
|
return;
|
|
5721
|
+
// ---- M6 lane H: lobby front door ----
|
|
5722
|
+
case "setLobby": {
|
|
5723
|
+
if (room.config.lobby !== true) {
|
|
5724
|
+
this.roomLog(
|
|
5725
|
+
room,
|
|
5726
|
+
"warn",
|
|
5727
|
+
"refusing setLobby: this room type declared no lobby, so it cannot enter the public registry"
|
|
5728
|
+
);
|
|
5729
|
+
return;
|
|
5730
|
+
}
|
|
5731
|
+
if (msg.started === true) {
|
|
5732
|
+
room.lobbySpoken = true;
|
|
5733
|
+
room.lobbyStarted = true;
|
|
5734
|
+
room.lobbyPublic = false;
|
|
5735
|
+
return;
|
|
5736
|
+
}
|
|
5737
|
+
if (room.lobbyStarted) return;
|
|
5738
|
+
if (msg.queue !== void 0) {
|
|
5739
|
+
if (!LOBBY_QUEUE_RE.test(msg.queue)) {
|
|
5740
|
+
this.roomLog(
|
|
5741
|
+
room,
|
|
5742
|
+
"warn",
|
|
5743
|
+
`refusing setLobby: queue ${JSON.stringify(msg.queue)} must be 1-32 of a-z 0-9 . _ - and start with a letter or digit`
|
|
5744
|
+
);
|
|
5745
|
+
return;
|
|
5746
|
+
}
|
|
5747
|
+
room.lobbyQueue = msg.queue;
|
|
5748
|
+
}
|
|
5749
|
+
room.lobbySpoken = true;
|
|
5750
|
+
if (msg.public !== void 0) room.lobbyPublic = msg.public;
|
|
5751
|
+
return;
|
|
5752
|
+
}
|
|
5682
5753
|
case "setAlarm":
|
|
5683
5754
|
if (isMailboxAlarm(msg.name)) {
|
|
5684
5755
|
this.roomLog(
|
|
@@ -6271,7 +6342,7 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
6271
6342
|
}
|
|
6272
6343
|
const outfile = path.join(outDir, "worker.mjs");
|
|
6273
6344
|
const runtimeDir = path.join(packagesDir, "runtime");
|
|
6274
|
-
const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
|
|
6345
|
+
const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js", "@dimforge/rapier2d-compat"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
|
|
6275
6346
|
await esbuild.build({
|
|
6276
6347
|
entryPoints: [source],
|
|
6277
6348
|
bundle: true,
|
|
@@ -6290,7 +6361,13 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
6290
6361
|
// bundle resolves via node_modules. `rapierPlugin` (above) already externalizes it at an
|
|
6291
6362
|
// absolute path when `@irtio/runtime`'s own copy can be found; this string entry is the
|
|
6292
6363
|
// fallback for a published install where that resolution comes for free from node_modules.
|
|
6293
|
-
external: [
|
|
6364
|
+
external: [
|
|
6365
|
+
"node:worker_threads",
|
|
6366
|
+
"node:url",
|
|
6367
|
+
"@dimforge/rapier3d-compat",
|
|
6368
|
+
"matter-js",
|
|
6369
|
+
"@dimforge/rapier2d-compat"
|
|
6370
|
+
]
|
|
6294
6371
|
});
|
|
6295
6372
|
return outfile;
|
|
6296
6373
|
}
|
package/dist/index.js
CHANGED
|
@@ -135,7 +135,7 @@ switch (command) {
|
|
|
135
135
|
break;
|
|
136
136
|
}
|
|
137
137
|
case "dev": {
|
|
138
|
-
const { dev } = await import("./dev-
|
|
138
|
+
const { dev } = await import("./dev-VJ2ATJTF.js");
|
|
139
139
|
await dev(args);
|
|
140
140
|
break;
|
|
141
141
|
}
|
|
@@ -165,7 +165,7 @@ switch (command) {
|
|
|
165
165
|
break;
|
|
166
166
|
}
|
|
167
167
|
case "migrate": {
|
|
168
|
-
const { migrate } = await import("./migrate-
|
|
168
|
+
const { migrate } = await import("./migrate-5YCFT63L.js");
|
|
169
169
|
await migrate(args);
|
|
170
170
|
break;
|
|
171
171
|
}
|
package/dist/init.js
CHANGED
|
@@ -15,7 +15,7 @@ import { createInterface } from "readline/promises";
|
|
|
15
15
|
import pc from "picocolors";
|
|
16
16
|
|
|
17
17
|
// src/agent-guide.generated.ts
|
|
18
|
-
var AGENT_GUIDE = "# irt.io integration guide\n\nThe whole product in one page, written for a context window. Everything below is enough to take a\nsingle-player web game to a deployed, verified multiplayer room without opening a dashboard.\n\n## The mental model\n\nA **room** is a server-side TypeScript file. It owns a small typed **schema**: the state two\nplayers have to agree on, and nothing else about your game. Each client owns its own instances and\nwrites them like local objects. The room's `validate` decides what a write may be. Everything else\nin your game stays where it was. You verify a room the way you verify code. Bots drive real\nsockets, a **scenario** asserts against the server's own recorded timeline, and the same seed gives\nthe same verdict.\n\n## The skeleton\n\nThe smallest project that deploys. Four files, and `irtio init` writes all of them.\n\n```ts\n// irtio/schema.ts\nimport { defineSchema, entity, f32, str, u8 } from '@irtio/schema';\n\nimport { rpc } from './rpc.js';\n\nexport const schema = defineSchema(\n {\n // One instance per connected client, owned by that client. Owned means writable on that\n // client and read-only everywhere else.\n players: entity({ x: f32, y: f32, name: str(24), color: u8 }),\n },\n {\n project: 'p_c0ffee1234abcd56', // written by `irtio init`; public, domain-locked, not a secret\n roles: ['player'] as const,\n rpc,\n },\n);\n```\n\n```ts\n// irtio/rpc.ts\nimport { server, u8 } from '@irtio/schema';\n\n// Typed calls in both directions. `server(...)` is client to server. Names and signatures are\n// part of the schema hash, so skew is caught as a version error instead of at runtime.\nexport const rpc = {\n cheer: server({ params: { volume: u8 } }),\n};\n```\n\n```ts\n// irtio/room.ts\nimport { defineRoom } from '@irtio/server';\n\nimport { schema } from './schema.js';\n\nconst WIDTH = 800;\nconst HEIGHT = 500;\n\nexport default defineRoom(schema, {\n mode: 'tick', // 'event' if nothing moves without a player doing something\n tickRate: 20,\n\n onJoin(state, ctx) {\n if (ctx.reconnecting) return;\n state.players.add(\n ctx.clientId,\n { x: 0, y: 0, name: ctx.name || 'anon', color: (ctx.tick * 37) % 256 },\n { owner: ctx.clientId }, // the line the whole client-side write model rests on\n );\n },\n\n onLeave(state, ctx) {\n state.players.remove(ctx.clientId);\n },\n\n // Owner writes pass through here before they are accepted. Return `next` to accept, `prev` to\n // reject, or a clamped object. This is where cheating stops. Delete it and anything that fits\n // the declared types is accepted.\n validate: {\n players(prev, next) {\n // Refuse what could never be a real value, lock the fields the room assigned at join,\n // and clamp the rest into the world.\n if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;\n if (next.name !== prev.name || next.color !== prev.color) return prev;\n return {\n ...next,\n x: Math.min(WIDTH, Math.max(0, next.x)),\n y: Math.min(HEIGHT, Math.max(0, next.y)),\n };\n },\n },\n\n rpc: {\n cheer(state, params, ctx) {\n if (params.volume > 10) throw new Error('too loud'); // the normal way a room says no\n state.players.get(ctx.clientId)!.color = params.volume;\n },\n },\n\n tick() {}, // tick mode needs one, even empty\n});\n```\n\n```ts\n// your game, three lines added\nimport { joinRoom } from '@irtio/client';\n\nimport { schema } from './irtio/schema.js';\n\nconst room = await joinRoom(schema, { name: 'you' }); // reads ?room=, or creates one\nconst me = room.state.players[room.me]; // yours: write it like a local object\nif (me) me.x = 10;\n\nfor (const [, p] of room.render.players) draw(p); // `render` interpolates everyone else\n```\n\n`room.state` is the authoritative read path for game logic and tests. `room.render` has the same\nshapes and reads non-owned entities a beat behind arrival, interpolated, which is what you draw\nfrom. Writes are batched once per animation frame, and `room.flush()` forces one out now.\n`room.leave()` closes the session. A page rarely needs it; a test with several sessions always\ndoes. `joinRoom`'s options are `{ name, role, url, key, token, physics, transport }`; see\nthe client reference.\n\n`@irtio/client` is a module, so the page that imports it needs whatever bundler your game already\nuses. There is no `irtio build`.\n\n### Write the validate rule your game actually has\n\n`validate` is the one part of the skeleton you cannot copy without thinking. The wrong rule here\nis worse than no rule, because it refuses honest play silently. The server sends a correction, and\nthe thing does not go where the player put it.\n\nA **speed limit** (`if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;`) is\nright when the thing being moved has a movement speed the game defines: a character, a vehicle, a\ndragged token. It is wrong for a **mouse cursor**. A cursor legitimately teleports when the pointer moves fast,\nor when it leaves the window and comes back. A player gains nothing by putting their cursor\nsomewhere.\n\nAsk what a cheat would actually win. Position, for a cursor, wins nothing, so clamp it and stop.\nPosition for a character wins map knowledge and reach, so limit the step. Score, turn order, hit\ndetection and inventory win the game outright, so they should not be client-owned at all: make\nthem `serverOwned` and change them through an RPC.\n\n## The four commands\n\n```bash\nnpx irtio init # scaffold the four files above plus irtio.json\nnpx irtio dev # bundle irtio/room.ts and run it locally\nnpx irtio simulate --scenario irtio/scenario.ts # real bots, your assertions, exit code\nnpx irtio deploy # create the project if new, classify, upload\n```\n\n`simulate` drives real clients at a room that is **already running**, so `dev` stays up in another\nterminal while you run it. The four are a loop, not a pipeline.\n\nEvery subcommand takes `--help`, and the per-command help is the complete list; the top-level\nsummary is a map.\n\n`dev` is the whole server, locally: it bundles the room file and runs it the way the hosted\nserver does, so it is what you develop and test against. It does not build or serve your\n**client**. That stays your own bundler's job, the same one your game already uses.\n\n`deploy` is the hosted path and needs `irtio login`. There is no offline `deploy`. Without an\naccount, `dev` plus your own bundler is as far as you get, and that runs everything below.\n\n`deploy` creates the project on first use and classifies the schema change against the last\ndeployment. It refuses breaking changes unless a migration covers them, then prints where the room\nis playable. `simulate` exits non-zero on a violation, so both belong in CI.\n\nWhen the question is \"why is this room using so much bandwidth\", both commands take `--profile`.\n`dev --profile` prints a per-room breakdown by collection and field once a second.\n`simulate --profile` prints one for the whole run as the bots saw it. The answer is usually a\nsingle wide field, or `overhead` telling you the updates are too small and too frequent. See\n[the profiler](/docs/guides/profiler).\n\n**Restart `dev` after editing the room.** With `--no-watch`, a `dev` you forgot to kill keeps\nserving the old bundle. `simulate` connects to it and reports a clean run against code you have\nreplaced. The report header names the bundle hash and how long the server has been up. Compare\nthose two lines between runs: an edit that changed nothing in the header changed nothing in the\ncode under test.\n\nAn agent driving this through MCP calls `project_create`, `origin_add`, `deploy`, `scenario_run`,\nthen `logs` or `metrics`. Sign in once with `npx irtio login`; the MCP config snippet is a command\nand carries no secret.\n\n## Verify a room with a scenario\n\nA scenario is a TypeScript module next to the room file. Assertions run against the recorded\nauthoritative timeline, one frame per server tick. A scenario can prove something about a race no\nclient could observe, and it replays: same seed, same verdict.\n\n```ts\n// irtio/scenario.ts\nimport { defineScenario } from '@irtio/bots';\n\nimport type { schema } from './schema.js';\n\nexport default defineScenario<typeof schema>({\n bots: 2,\n seconds: 4,\n seed: 41,\n\n // Adversarial in two words: bot 0 lags, and every bot tries illegal writes. `validate` above\n // is what has to refuse them.\n conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),\n cheat: true,\n\n script: async (bot) => {\n await bot.wait(200);\n await bot.room.call.cheer({ volume: 3 });\n await bot.wait(500);\n },\n\n assert: (timeline) => {\n timeline.check('nobody left the arena', () => {\n for (const tick of timeline.ticks) {\n for (const id of timeline.at(tick).players!.ids()) {\n const p = timeline.at(tick).players!.get(id)!;\n if (p.x < 0 || p.x > 800) throw new Error(`player ${id} at x=${p.x} on tick ${tick}`);\n }\n }\n });\n },\n});\n```\n\n`cheat: true` makes the bots write play-illegal values. If the run reports\n`HOLE bot <n> cheated and drew 0 corrections`, your `validate` accepted them, and that is the\nfinding. `truth: true` additionally saves the room at the end and diffs it against what each client\nactually received.\n\nTwo things to know before you read a result.\n\n**A cheat run that works fails the run by default.** Every refused write is a correction, and the\nbuilt-in `correction-storm` invariant fails above 5 corrections per second per bot. A deliberate\ncheater passes that immediately. Raise it for the run with `--corrections-max 25`, and read the\n`HOLE` line rather than the exit code as the verdict on `validate`.\n\n**The built-in invariants alone prove nothing about `validate`.** They are protocol invariants:\nframes decoded, nobody saw what they should not, bandwidth inside budget, no tick overruns. A room\nthat accepts every illegal write there is passes all of them. Only `cheat` and your own scenario\nassertions test server authority, so a run with neither says nothing about whether your game can be\ncheated.\n\n## One retrofit diff\n\nThe canvas case, the shortest one. The cursors canvas example is this diff as running code; see\n[the retrofit guide](/docs/guides/retrofit).\n\n```diff\n+import { joinRoom } from '@irtio/client';\n+\n+import { schema } from './irtio/schema.js';\n+\n+const room = await joinRoom(schema, { name: 'you' });\n-const player = { x: canvas.width / 2, y: canvas.height / 2, name: 'you', color: 200 };\n-\n canvas.addEventListener('pointermove', (event) => {\n const bounds = canvas.getBoundingClientRect();\n+ const player = room.state.players[room.me];\n+ if (!player) return;\n player.x = event.clientX - bounds.left;\n player.y = event.clientY - bounds.top;\n });\n\n function frame() {\n- const entries = [['me', player]];\n+ const entries = [...room.render.players];\n for (const [, p] of entries) draw(p);\n }\n```\n\nThe whole shape is an import, a join, an entity lookup instead of a local object, and one changed\niteration. The camera, particles, input handling, HUD, draw code and level geometry do not\nmove. If your retrofit touches those, it is doing more than a retrofit. Three.js and Phaser follow\nthe same four steps against their own loops; see the retrofit guide.\n\nYour entity does not exist for the first frame or two after the page loads, which is what\n`if (!player) return;` is for. Writing an instance you do not own is a compile error and a\nwarn-once no-op at runtime.\n\n## When it goes wrong\n\nThe codes you will actually meet, with the fix. The full catalogue is at `/docs/reference/errors`.\n\n| Code | Fix |\n|---|---|\n| `E_AUTH` | The project key was rejected. Check `project` in `irtio/schema.ts` matches the project you are deploying to. On localhost with no `irtio init` the client uses the key `dev`, which only `irtio dev` accepts |\n| `E_SCHEMA_MISMATCH` | The client was built with a different schema than the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client, or deploy the schema the client has |\n| `E_ORIGIN` | The page's origin is not in the project's origin list. Register it. `localhost` is always allowed, so this only bites on a deployed page |\n| `E_ROOM_FULL` | The room is at `maxClients`, 64 by default. Raise it in `irtio/room.ts`, or join with no `?room=` to get a new room |\n| `E_ROOM_NOT_FOUND` | No such room, or an id that cannot be one. Do not construct ids yourself: let `joinRoom` create one and read `room.link` |\n| `E_WRITE_REJECTED` | Your `validate` refused the write, or the value did not fit its declared type. If this is a legitimate move, `validate` is too strict or the field too narrow |\n| `E_NOT_OWNER` | Something wrote an instance it does not own. `await room.requestOwnership(entity, id)` first, and re-read the instance after a grant |\n| `E_RPC_BAD_PARAMS` | Parameters did not match the declared shape. Types normally prevent this, so it means client and server were built from different `rpc.ts` files |\n| `E_RATE_LIMITED` | Read the message. `rate limited` is too many frames: batch instead of calling `room.flush()` in a loop. `too many connections from this address` is the per-IP cap (`connectionsPerIpPerMin`, default 120), which a load run from one machine has to raise |\n| `E_STARTING` | Not an error. The server was asleep and is waking. `room.status === 'starting'`; show a spinner and keep waiting |\n| `E_SLOW_CONSUMER` | The client was not draining its stream and was dropped; it reconnects itself. If it recurs, the room produces more per tick than the connection carries: lower `tickRate`, narrow field types, or use role visibility |\n| `E_CONNECT_FAILED` | Not a protocol code. The socket failed before the join. Read the URL in the message: `ws://localhost:7070` means `irtio dev` is not running |\n| `deploy refused: N breaking changes` | Snapshots cannot be read under the new schema. `irtio migrate create <name>`, write the transform, then `irtio deploy --allow-breaking`. Additive changes (a new field with `.default(...)` or `.opt`) need none of this |\n| `not logged in` | `deploy`, `logs`, `rooms`, `whoami` and `migrate create` need credentials. Run `irtio login` |\n| `HOLE bot <n> cheated and drew 0 corrections` | Not a failure, and the most important warning here. Illegal writes were accepted. Add rules to `validate` |\n\n## Past the skeleton\n\nThe skeleton above is the smallest thing that deploys. Each of these is one docs page\nand none of them changes the model.\n\n- Server-authoritative physics with client prediction: `irtio init --physics`, a shared\n `irtio/world.ts` both sides import, Rapier in 3D (`/docs/physics/overview`) or matter.js in 2D\n (`/docs/physics/matter2d`, `tickRate: 60`). Both predict, and `room.prediction` reads the same\n either way (`/docs/physics/prediction`).\n- Scripted NPCs that are ordinary client sessions at the protocol level: `room.spawnNPC(...)`\n (`/docs/guides/npcs`).\n- Player storage that outlives a room (`room.kv`), room saves and restores, hibernation, and the\n `retention` option that sets how long a room's state survives\n (`/docs/persistence/player-storage`, `/docs/persistence/saves`, `/docs/concepts/hibernation`).\n- Signed player tokens, roles, and per-role visibility (`/docs/concepts/auth`,\n `/docs/concepts/visibility`).\n- A player identity that survives reconnects, hibernation and a closed tab\n (`/docs/concepts/identity`).\n- Ranked boards per project, written only by your room code\n (`/docs/persistence/leaderboards`).\n- Two strangers matched into a room without either knowing a room code\n (`/docs/concepts/quick-match`).\n- Voice chat in a room, on its own meter (`/docs/concepts/voice`).\n- One of your rooms sending a message to another (`/docs/concepts/room-bus`).\n- A project split across more than one server when a single one is full\n (`/docs/deploy/sharding`).\n\n**Starting from an engine.** Build against the engine's own loop from the first line: React and\nreact-three-fiber (`/docs/integrations/react`), Three.js (`/docs/integrations/threejs`), Phaser\n(`/docs/integrations/phaser`), PixiJS (`/docs/integrations/pixijs`), Babylon.js\n(`/docs/integrations/babylonjs`).\n\n**Moving a game that already exists.** Single-player, which is the diff above at full length:\ncanvas (`/docs/guides/retrofit`), Three.js (`/docs/guides/retrofit-threejs`), Phaser\n(`/docs/guides/retrofit-phaser`). Already multiplayer on something else: Colyseus\n(`/docs/migrate/colyseus`), Playroom (`/docs/migrate/playroom`), Socket.IO\n(`/docs/migrate/socket-io`).\n\nEvery term these pages use is defined once in the glossary (`/docs/reference/glossary`).\n\n## Rules of thumb\n\n- Sync the smallest set of facts two players must agree on. Everything else stays local.\n- A player reporting a fact about themselves is an owned write. Anything a player could gain by\n lying about (scores, deals, turn order, hit detection) is `serverOwned` plus an RPC.\n- Types are budgets, not hints. `f32`, `u8`, `str(24)` are what make an update a handful of bytes.\n- Never import `room.ts` from client code. Shared geometry and constants go in their own module\n that both sides import.\n- Do not paste a credential into a config file. `irtio login` writes one; MCP and the CLI find it.\n- Believe a room when a scenario with an adversarial pass says so, not when it looks right in two\n tabs.\n";
|
|
18
|
+
var AGENT_GUIDE = "# irt.io integration guide\n\nThe whole product in one page, written for a context window. Everything below is enough to take a\nsingle-player web game to a deployed, verified multiplayer room without opening a dashboard.\n\n## The mental model\n\nA **room** is a server-side TypeScript file. It owns a small typed **schema**: the state two\nplayers have to agree on, and nothing else about your game. Each client owns its own instances and\nwrites them like local objects. The room's `validate` decides what a write may be. Everything else\nin your game stays where it was. You verify a room the way you verify code. Bots drive real\nsockets, a **scenario** asserts against the server's own recorded timeline, and the same seed gives\nthe same verdict.\n\n## The skeleton\n\nThe smallest project that deploys. Four files, and `irtio init` writes all of them.\n\n```ts\n// irtio/schema.ts\nimport { defineSchema, entity, f32, str, u8 } from '@irtio/schema';\n\nimport { rpc } from './rpc.js';\n\nexport const schema = defineSchema(\n {\n // One instance per connected client, owned by that client. Owned means writable on that\n // client and read-only everywhere else.\n players: entity({ x: f32, y: f32, name: str(24), color: u8 }),\n },\n {\n project: 'p_c0ffee1234abcd56', // written by `irtio init`; public, domain-locked, not a secret\n roles: ['player'] as const,\n rpc,\n },\n);\n```\n\n```ts\n// irtio/rpc.ts\nimport { server, u8 } from '@irtio/schema';\n\n// Typed calls in both directions. `server(...)` is client to server. Names and signatures are\n// part of the schema hash, so skew is caught as a version error instead of at runtime.\nexport const rpc = {\n cheer: server({ params: { volume: u8 } }),\n};\n```\n\n```ts\n// irtio/room.ts\nimport { defineRoom } from '@irtio/server';\n\nimport { schema } from './schema.js';\n\nconst WIDTH = 800;\nconst HEIGHT = 500;\n\nexport default defineRoom(schema, {\n mode: 'tick', // 'event' if nothing moves without a player doing something\n tickRate: 20,\n\n onJoin(state, ctx) {\n if (ctx.reconnecting) return;\n state.players.add(\n ctx.clientId,\n { x: 0, y: 0, name: ctx.name || 'anon', color: (ctx.tick * 37) % 256 },\n { owner: ctx.clientId }, // the line the whole client-side write model rests on\n );\n },\n\n onLeave(state, ctx) {\n state.players.remove(ctx.clientId);\n },\n\n // Owner writes pass through here before they are accepted. Return `next` to accept, `prev` to\n // reject, or a clamped object. This is where cheating stops. Delete it and anything that fits\n // the declared types is accepted.\n validate: {\n players(prev, next) {\n // Refuse what could never be a real value, lock the fields the room assigned at join,\n // and clamp the rest into the world.\n if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;\n if (next.name !== prev.name || next.color !== prev.color) return prev;\n return {\n ...next,\n x: Math.min(WIDTH, Math.max(0, next.x)),\n y: Math.min(HEIGHT, Math.max(0, next.y)),\n };\n },\n },\n\n rpc: {\n cheer(state, params, ctx) {\n if (params.volume > 10) throw new Error('too loud'); // the normal way a room says no\n state.players.get(ctx.clientId)!.color = params.volume;\n },\n },\n\n tick() {}, // tick mode needs one, even empty\n});\n```\n\n```ts\n// your game, three lines added\nimport { joinRoom } from '@irtio/client';\n\nimport { schema } from './irtio/schema.js';\n\nconst room = await joinRoom(schema, { name: 'you' }); // reads ?room=, or creates one\nconst me = room.state.players[room.me]; // yours: write it like a local object\nif (me) me.x = 10;\n\nfor (const [, p] of room.render.players) draw(p); // `render` interpolates everyone else\n```\n\n`room.state` is the authoritative read path for game logic and tests. `room.render` has the same\nshapes and reads non-owned entities a beat behind arrival, interpolated, which is what you draw\nfrom. Writes are batched once per animation frame, and `room.flush()` forces one out now.\n`room.leave()` closes the session. A page rarely needs it; a test with several sessions always\ndoes. `joinRoom`'s options are `{ name, role, url, key, token, physics, transport }`; see\nthe client reference.\n\n`@irtio/client` is a module, so the page that imports it needs whatever bundler your game already\nuses. There is no `irtio build`.\n\n### Write the validate rule your game actually has\n\n`validate` is the one part of the skeleton you cannot copy without thinking. The wrong rule here\nis worse than no rule, because it refuses honest play silently. The server sends a correction, and\nthe thing does not go where the player put it.\n\nA **speed limit** (`if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;`) is\nright when the thing being moved has a movement speed the game defines: a character, a vehicle, a\ndragged token. It is wrong for a **mouse cursor**. A cursor legitimately teleports when the pointer moves fast,\nor when it leaves the window and comes back. A player gains nothing by putting their cursor\nsomewhere.\n\nAsk what a cheat would actually win. Position, for a cursor, wins nothing, so clamp it and stop.\nPosition for a character wins map knowledge and reach, so limit the step. Score, turn order, hit\ndetection and inventory win the game outright, so they should not be client-owned at all: make\nthem `serverOwned` and change them through an RPC.\n\n## The four commands\n\n```bash\nnpx irtio init # scaffold the four files above plus irtio.json\nnpx irtio dev # bundle irtio/room.ts and run it locally\nnpx irtio simulate --scenario irtio/scenario.ts # real bots, your assertions, exit code\nnpx irtio deploy # create the project if new, classify, upload\n```\n\n`simulate` drives real clients at a room that is **already running**, so `dev` stays up in another\nterminal while you run it. The four are a loop, not a pipeline.\n\nEvery subcommand takes `--help`, and the per-command help is the complete list; the top-level\nsummary is a map.\n\n`dev` is the whole server, locally: it bundles the room file and runs it the way the hosted\nserver does, so it is what you develop and test against. It does not build or serve your\n**client**. That stays your own bundler's job, the same one your game already uses.\n\n`deploy` is the hosted path and needs `irtio login`. There is no offline `deploy`. Without an\naccount, `dev` plus your own bundler is as far as you get, and that runs everything below.\n\n`deploy` creates the project on first use and classifies the schema change against the last\ndeployment. It refuses breaking changes unless a migration covers them, then prints where the room\nis playable. `simulate` exits non-zero on a violation, so both belong in CI.\n\nWhen the question is \"why is this room using so much bandwidth\", both commands take `--profile`.\n`dev --profile` prints a per-room breakdown by collection and field once a second.\n`simulate --profile` prints one for the whole run as the bots saw it. The answer is usually a\nsingle wide field, or `overhead` telling you the updates are too small and too frequent. See\n[the profiler](/docs/guides/profiler).\n\n**Restart `dev` after editing the room.** With `--no-watch`, a `dev` you forgot to kill keeps\nserving the old bundle. `simulate` connects to it and reports a clean run against code you have\nreplaced. The report header names the bundle hash and how long the server has been up. Compare\nthose two lines between runs: an edit that changed nothing in the header changed nothing in the\ncode under test.\n\nAn agent driving this through MCP calls `project_create`, `origin_add`, `deploy`, `scenario_run`,\nthen `logs` or `metrics`. Sign in once with `npx irtio login`; the MCP config snippet is a command\nand carries no secret.\n\n## Verify a room with a scenario\n\nA scenario is a TypeScript module next to the room file. Assertions run against the recorded\nauthoritative timeline, one frame per server tick. A scenario can prove something about a race no\nclient could observe, and it replays: same seed, same verdict.\n\n```ts\n// irtio/scenario.ts\nimport { defineScenario } from '@irtio/bots';\n\nimport type { schema } from './schema.js';\n\nexport default defineScenario<typeof schema>({\n bots: 2,\n seconds: 4,\n seed: 41,\n\n // Adversarial in two words: bot 0 lags, and every bot tries illegal writes. `validate` above\n // is what has to refuse them.\n conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),\n cheat: true,\n\n script: async (bot) => {\n await bot.wait(200);\n await bot.room.call.cheer({ volume: 3 });\n await bot.wait(500);\n },\n\n assert: (timeline) => {\n timeline.check('nobody left the arena', () => {\n for (const tick of timeline.ticks) {\n for (const id of timeline.at(tick).players!.ids()) {\n const p = timeline.at(tick).players!.get(id)!;\n if (p.x < 0 || p.x > 800) throw new Error(`player ${id} at x=${p.x} on tick ${tick}`);\n }\n }\n });\n },\n});\n```\n\n`cheat: true` makes the bots write play-illegal values. If the run reports\n`HOLE bot <n> cheated and drew 0 corrections`, your `validate` accepted them, and that is the\nfinding. `truth: true` additionally saves the room at the end and diffs it against what each client\nactually received.\n\nTwo things to know before you read a result.\n\n**A cheat run that works fails the run by default.** Every refused write is a correction, and the\nbuilt-in `correction-storm` invariant fails above 5 corrections per second per bot. A deliberate\ncheater passes that immediately. Raise it for the run with `--corrections-max 25`, and read the\n`HOLE` line rather than the exit code as the verdict on `validate`.\n\n**The built-in invariants alone prove nothing about `validate`.** They are protocol invariants:\nframes decoded, nobody saw what they should not, bandwidth inside budget, no tick overruns. A room\nthat accepts every illegal write there is passes all of them. Only `cheat` and your own scenario\nassertions test server authority, so a run with neither says nothing about whether your game can be\ncheated.\n\n## One retrofit diff\n\nThe canvas case, the shortest one. The cursors canvas example is this diff as running code; see\n[the retrofit guide](/docs/guides/retrofit).\n\n```diff\n+import { joinRoom } from '@irtio/client';\n+\n+import { schema } from './irtio/schema.js';\n+\n+const room = await joinRoom(schema, { name: 'you' });\n-const player = { x: canvas.width / 2, y: canvas.height / 2, name: 'you', color: 200 };\n-\n canvas.addEventListener('pointermove', (event) => {\n const bounds = canvas.getBoundingClientRect();\n+ const player = room.state.players[room.me];\n+ if (!player) return;\n player.x = event.clientX - bounds.left;\n player.y = event.clientY - bounds.top;\n });\n\n function frame() {\n- const entries = [['me', player]];\n+ const entries = [...room.render.players];\n for (const [, p] of entries) draw(p);\n }\n```\n\nThe whole shape is an import, a join, an entity lookup instead of a local object, and one changed\niteration. The camera, particles, input handling, HUD, draw code and level geometry do not\nmove. If your retrofit touches those, it is doing more than a retrofit. Three.js and Phaser follow\nthe same four steps against their own loops; see the retrofit guide.\n\nYour entity does not exist for the first frame or two after the page loads, which is what\n`if (!player) return;` is for. Writing an instance you do not own is a compile error and a\nwarn-once no-op at runtime.\n\n## When it goes wrong\n\nThe codes you will actually meet, with the fix. The full catalogue is at `/docs/reference/errors`.\n\n| Code | Fix |\n|---|---|\n| `E_AUTH` | The project key was rejected. Check `project` in `irtio/schema.ts` matches the project you are deploying to. On localhost with no `irtio init` the client uses the key `dev`, which only `irtio dev` accepts |\n| `E_SCHEMA_MISMATCH` | The client was built with a different schema than the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client, or deploy the schema the client has |\n| `E_ORIGIN` | The page's origin is not in the project's origin list. Register it. `localhost` is always allowed, so this only bites on a deployed page |\n| `E_ROOM_FULL` | The room is at `maxClients`, 64 by default. Raise it in `irtio/room.ts`, or join with no `?room=` to get a new room |\n| `E_ROOM_NOT_FOUND` | No such room, or an id that cannot be one. Do not construct ids yourself: let `joinRoom` create one and read `room.link` |\n| `E_WRITE_REJECTED` | Your `validate` refused the write, or the value did not fit its declared type. If this is a legitimate move, `validate` is too strict or the field too narrow |\n| `E_NOT_OWNER` | Something wrote an instance it does not own. `await room.requestOwnership(entity, id)` first, and re-read the instance after a grant |\n| `E_RPC_BAD_PARAMS` | Parameters did not match the declared shape. Types normally prevent this, so it means client and server were built from different `rpc.ts` files |\n| `E_RATE_LIMITED` | Read the message. `rate limited` is too many frames: batch instead of calling `room.flush()` in a loop. `too many connections from this address` is the per-IP cap (`connectionsPerIpPerMin`, default 120), which a load run from one machine has to raise |\n| `E_STARTING` | Not an error. The server was asleep and is waking. `room.status === 'starting'`; show a spinner and keep waiting |\n| `E_SLOW_CONSUMER` | The client was not draining its stream and was dropped; it reconnects itself. If it recurs, the room produces more per tick than the connection carries: lower `tickRate`, narrow field types, or use role visibility |\n| `E_CONNECT_FAILED` | Not a protocol code. The socket failed before the join. Read the URL in the message: `ws://localhost:7070` means `irtio dev` is not running |\n| `deploy refused: N breaking changes` | Snapshots cannot be read under the new schema. `irtio migrate create <name>`, write the transform, then `irtio deploy --allow-breaking`. Additive changes (a new field with `.default(...)` or `.opt`) need none of this |\n| `not logged in` | `deploy`, `logs`, `rooms`, `whoami` and `migrate create` need credentials. Run `irtio login` |\n| `HOLE bot <n> cheated and drew 0 corrections` | Not a failure, and the most important warning here. Illegal writes were accepted. Add rules to `validate` |\n\n## Past the skeleton\n\nThe skeleton above is the smallest thing that deploys. Each of these is one docs page\nand none of them changes the model.\n\n- Server-authoritative physics with client prediction: `irtio init --physics`, a shared\n `irtio/world.ts` both sides import. Three engines: Rapier in 3D (`/docs/physics/overview`),\n Rapier in 2D (`/docs/physics/rapier2d`), or matter.js in 2D (`/docs/physics/matter2d`,\n `tickRate: 60`). Recommend Rapier for a new game and reach for matter2d only for a room already\n on it. All three predict, and `room.prediction` reads the same either way\n (`/docs/physics/prediction`).\n- Scripted NPCs that are ordinary client sessions at the protocol level: `room.spawnNPC(...)`\n (`/docs/guides/npcs`).\n- Player storage that outlives a room (`room.kv`), room saves and restores, hibernation, and the\n `retention` option that sets how long a room's state survives\n (`/docs/persistence/player-storage`, `/docs/persistence/saves`, `/docs/concepts/hibernation`).\n- Signed player tokens, roles, and per-role visibility (`/docs/concepts/auth`,\n `/docs/concepts/visibility`).\n- A player identity that survives reconnects, hibernation and a closed tab\n (`/docs/concepts/identity`).\n- Ranked boards per project, written only by your room code\n (`/docs/persistence/leaderboards`).\n- Two strangers matched into a room without either knowing a room code\n (`/docs/concepts/quick-match`).\n- Voice chat in a room, on its own meter (`/docs/concepts/voice`).\n- One of your rooms sending a message to another (`/docs/concepts/room-bus`).\n- A project split across more than one server when a single one is full\n (`/docs/deploy/sharding`).\n\n**Starting from an engine.** Build against the engine's own loop from the first line: React and\nreact-three-fiber (`/docs/integrations/react`), Three.js (`/docs/integrations/threejs`), Phaser\n(`/docs/integrations/phaser`), PixiJS (`/docs/integrations/pixijs`), Babylon.js\n(`/docs/integrations/babylonjs`).\n\n**Moving a game that already exists.** Single-player, which is the diff above at full length:\ncanvas (`/docs/guides/retrofit`), Three.js (`/docs/guides/retrofit-threejs`), Phaser\n(`/docs/guides/retrofit-phaser`). Already multiplayer on something else: Colyseus\n(`/docs/migrate/colyseus`), Playroom (`/docs/migrate/playroom`), Socket.IO\n(`/docs/migrate/socket-io`).\n\nEvery term these pages use is defined once in the glossary (`/docs/reference/glossary`).\n\n## Rules of thumb\n\n- Sync the smallest set of facts two players must agree on. Everything else stays local.\n- A player reporting a fact about themselves is an owned write. Anything a player could gain by\n lying about (scores, deals, turn order, hit detection) is `serverOwned` plus an RPC.\n- Types are budgets, not hints. `f32`, `u8`, `str(24)` are what make an update a handful of bytes.\n- Never import `room.ts` from client code. Shared geometry and constants go in their own module\n that both sides import.\n- Do not paste a credential into a config file. `irtio login` writes one; MCP and the CLI find it.\n- Believe a room when a scenario with an adversarial pass says so, not when it looks right in two\n tabs.\n";
|
|
19
19
|
|
|
20
20
|
// src/init.ts
|
|
21
21
|
var INIT_DEPENDENCIES = [
|
package/dist/simulate.d.ts
CHANGED
|
@@ -159,6 +159,29 @@ interface LoadedWorld2d {
|
|
|
159
159
|
readonly smoothingHalfLifeMs?: number | undefined;
|
|
160
160
|
readonly file: string;
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* A rapier2d world, as `joinRoom({ physics2d: { engine: 'rapier2d', … } })` wants it.
|
|
164
|
+
*
|
|
165
|
+
* The 2D sibling of {@link LoadedWorld2d}, and it differs in exactly the ways the engine does:
|
|
166
|
+
* there is no `settle` (the world applies gravity, so a crate nobody steers falls on its own —
|
|
167
|
+
* a rapier2d world that had one would fall twice as fast locally), and the default `epsilon` is
|
|
168
|
+
* already right, because a Rapier world is metre-scale by construction rather than by convention.
|
|
169
|
+
*/
|
|
170
|
+
interface LoadedWorldRapier2d {
|
|
171
|
+
readonly engine: 'rapier2d';
|
|
172
|
+
readonly gravity: {
|
|
173
|
+
x: number;
|
|
174
|
+
y: number;
|
|
175
|
+
};
|
|
176
|
+
readonly timestep?: number | undefined;
|
|
177
|
+
readonly setup?: ((world: unknown, rapier: unknown) => void) | undefined;
|
|
178
|
+
readonly bodies?: Record<string, (...args: never[]) => unknown> | undefined;
|
|
179
|
+
readonly intents?: Record<string, (...args: never[]) => void> | undefined;
|
|
180
|
+
readonly epsilon?: number | undefined;
|
|
181
|
+
readonly maxPredictedBodies?: number | undefined;
|
|
182
|
+
readonly smoothingHalfLifeMs?: number | undefined;
|
|
183
|
+
readonly file: string;
|
|
184
|
+
}
|
|
162
185
|
/**
|
|
163
186
|
* A matter2d project this loader recognises and cannot predict from: run it, say why in one
|
|
164
187
|
* sentence, and never pretend to a predictor that does not exist.
|
|
@@ -171,29 +194,41 @@ interface LoadedWorld2d {
|
|
|
171
194
|
* `tick()`, so there is no steering hook to share and its own client does not predict either.
|
|
172
195
|
*/
|
|
173
196
|
interface UnpredictedWorld {
|
|
174
|
-
|
|
197
|
+
/** Which planar engine the room declares. Both can be recognised and refused. */
|
|
198
|
+
readonly engine: 'matter2d' | 'rapier2d';
|
|
175
199
|
/** One sentence, printed by the run, naming what would have to change. */
|
|
176
200
|
readonly why: string;
|
|
177
201
|
readonly file: string;
|
|
178
202
|
}
|
|
179
|
-
type LoadedAnyWorld = LoadedWorld | LoadedWorld2d | UnpredictedWorld;
|
|
203
|
+
type LoadedAnyWorld = LoadedWorld | LoadedWorld2d | LoadedWorldRapier2d | UnpredictedWorld;
|
|
180
204
|
/** Narrows a loaded world to a matter2d one bots can predict with. */
|
|
181
205
|
declare function is2dWorld(world: LoadedAnyWorld | undefined): world is LoadedWorld2d;
|
|
206
|
+
/** Narrows a loaded world to a rapier2d one bots can predict with. */
|
|
207
|
+
declare function isRapier2dWorld(world: LoadedAnyWorld | undefined): world is LoadedWorldRapier2d;
|
|
182
208
|
declare function isUnpredictedWorld(world: LoadedAnyWorld | undefined): world is UnpredictedWorld;
|
|
183
209
|
/**
|
|
184
|
-
*
|
|
210
|
+
* Which engine does this project's room declare, and in which file?
|
|
211
|
+
*
|
|
212
|
+
* D73-a asked this question of matter2d only, and for one reason: a matter2d project whose world
|
|
213
|
+
* module is not a client world — `games/dive`, whose engine gravity is zero and applied per body,
|
|
214
|
+
* so there is no `gravity` to export — used to be refused at the door with "does not export a
|
|
215
|
+
* { x, y, z } gravity", which is why `irtio simulate` had never been run against dive at all.
|
|
185
216
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* refusal into a run with a sentence.
|
|
217
|
+
* rapier2d widens it from a yes/no to a name, because the old fallback cannot tell the two planar
|
|
218
|
+
* engines apart: both export a `{ x, y }` gravity, and guessing from that shape alone would run a
|
|
219
|
+
* rapier2d project's bots on matter.js and blame the mispredictions on netcode. The room's own
|
|
220
|
+
* declaration is the only thing that actually knows, so it is asked first and the gravity shape is
|
|
221
|
+
* kept only as the fallback for a project whose room this loader could not read.
|
|
192
222
|
*
|
|
193
223
|
* `undefined` for every other answer: no room module, one that will not build or import, or one
|
|
194
|
-
*
|
|
195
|
-
* right one to raise.
|
|
224
|
+
* whose config declares no physics. None of those is an error here — the world module's own error
|
|
225
|
+
* is the right one to raise.
|
|
196
226
|
*/
|
|
227
|
+
declare function detectRoomEngine(options: LoadSchemaOptions): Promise<{
|
|
228
|
+
engine: string;
|
|
229
|
+
file: string;
|
|
230
|
+
} | undefined>;
|
|
231
|
+
/** D73-a's original question, kept as the narrow form of {@link detectRoomEngine}. */
|
|
197
232
|
declare function detectMatter2dRoom(options: LoadSchemaOptions): Promise<string | undefined>;
|
|
198
233
|
/**
|
|
199
234
|
* Bundles and imports the project's shared world-builder module, so the bots predict physics the
|
|
@@ -208,6 +243,19 @@ declare function loadProjectWorld(options: LoadSchemaOptions): Promise<LoadedAny
|
|
|
208
243
|
* description, or `undefined` when the worlds agree.
|
|
209
244
|
*/
|
|
210
245
|
declare function checkWorldDeterminism(world: LoadedWorld): Promise<string | undefined>;
|
|
246
|
+
/**
|
|
247
|
+
* The rapier2d determinism check: the Rapier one, in a plane.
|
|
248
|
+
*
|
|
249
|
+
* It is the 3D check rather than the matter2d one, because the 2D build has the same
|
|
250
|
+
* `takeSnapshot()` the 3D build has — two builds of the same `setup` either produce the same bytes
|
|
251
|
+
* or the builder is not pure over synced inputs. No stepping is needed to make small randomness
|
|
252
|
+
* visible: it is already in the snapshot.
|
|
253
|
+
*
|
|
254
|
+
* The body factories are exercised too, against each collection's zero record, the way the
|
|
255
|
+
* matter2d check does it — a `Math.random()` in a factory is exactly as damaging as one in
|
|
256
|
+
* `setup`, and the snapshot sees it for free.
|
|
257
|
+
*/
|
|
258
|
+
declare function checkWorldRapier2dDeterminism(world: LoadedWorldRapier2d, schema?: AnySchema): Promise<string | undefined>;
|
|
211
259
|
/**
|
|
212
260
|
* D73-a: the matter2d determinism check.
|
|
213
261
|
*
|
|
@@ -417,4 +465,4 @@ interface SimulationRun {
|
|
|
417
465
|
declare function runSimulation(options?: RunSimulationOptions): Promise<SimulationRun>;
|
|
418
466
|
declare function simulate(args: readonly string[]): Promise<void>;
|
|
419
467
|
|
|
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 };
|
|
468
|
+
export { type AdversarialRun, type BuildIdentity, type LoadSchemaOptions, type LoadedAnyWorld, type LoadedWorld2d, type LoadedWorldRapier2d, RunNotPerformedError, type RunSimulationOptions, ScenarioNotRunError, type ScenarioRun, type SimulateArgs, type SimulationEnd, type SimulationRun, type TruthRun, USAGE, type UnpredictedWorld, ceilingFor, checkWorld2dDeterminism, checkWorldDeterminism, checkWorldRapier2dDeterminism, detectMatter2dRoom, detectRoomEngine, formatBuildIdentity, is2dWorld, isRapier2dWorld, isUnpredictedWorld, loadProjectSchema, loadProjectWorld, loadScenario, parseSimulateArgs, readBuildIdentity, readTickCounters, runSimulation, simulate, stateUrlFor, tickHealthFrom };
|
package/dist/simulate.js
CHANGED
|
@@ -76,7 +76,9 @@ async function loadScenario(options) {
|
|
|
76
76
|
platform: "node",
|
|
77
77
|
outfile,
|
|
78
78
|
// The physics engine is resolved at runtime by whoever needs it, never bundled twice.
|
|
79
|
-
|
|
79
|
+
// All three, not just the 3D one: a 2D scenario's engine is loaded by the runtime the same
|
|
80
|
+
// way, and bundling a copy here would be a second instance beside it.
|
|
81
|
+
external: ["@dimforge/rapier3d-compat", "matter-js", "@dimforge/rapier2d-compat"],
|
|
80
82
|
...Object.keys(alias).length > 0 ? { alias } : {}
|
|
81
83
|
});
|
|
82
84
|
} catch (err) {
|
|
@@ -458,6 +460,9 @@ async function loadProjectSchema(options) {
|
|
|
458
460
|
function is2dWorld(world) {
|
|
459
461
|
return world !== void 0 && world.engine === "matter2d" && !("why" in world);
|
|
460
462
|
}
|
|
463
|
+
function isRapier2dWorld(world) {
|
|
464
|
+
return world !== void 0 && world.engine === "rapier2d" && !("why" in world);
|
|
465
|
+
}
|
|
461
466
|
function isUnpredictedWorld(world) {
|
|
462
467
|
return world !== void 0 && "why" in world;
|
|
463
468
|
}
|
|
@@ -471,7 +476,7 @@ async function bundleAndImport(entry, outfile, options) {
|
|
|
471
476
|
format: "esm",
|
|
472
477
|
platform: "node",
|
|
473
478
|
outfile,
|
|
474
|
-
external: ["@dimforge/rapier3d-compat", "matter-js"],
|
|
479
|
+
external: ["@dimforge/rapier3d-compat", "@dimforge/rapier2d-compat", "matter-js"],
|
|
475
480
|
...alias !== void 0 ? { alias } : {}
|
|
476
481
|
});
|
|
477
482
|
return await import(`${pathToFileURL2(outfile).href}?v=${Date.now()}-${importSeq2++}`);
|
|
@@ -479,7 +484,7 @@ async function bundleAndImport(entry, outfile, options) {
|
|
|
479
484
|
function hookMap(value) {
|
|
480
485
|
return typeof value === "object" && value !== null ? value : void 0;
|
|
481
486
|
}
|
|
482
|
-
async function
|
|
487
|
+
async function detectRoomEngine(options) {
|
|
483
488
|
const entry = ROOM_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find((f) => existsSync2(f));
|
|
484
489
|
if (entry === void 0) return void 0;
|
|
485
490
|
let module;
|
|
@@ -489,16 +494,23 @@ async function detectMatter2dRoom(options) {
|
|
|
489
494
|
return void 0;
|
|
490
495
|
}
|
|
491
496
|
const definition = module.default ?? module.room;
|
|
492
|
-
|
|
497
|
+
const engine = definition?.config?.physics?.engine;
|
|
498
|
+
return typeof engine === "string" ? { engine, file: entry } : void 0;
|
|
499
|
+
}
|
|
500
|
+
async function detectMatter2dRoom(options) {
|
|
501
|
+
const declared = await detectRoomEngine(options);
|
|
502
|
+
return declared?.engine === "matter2d" ? declared.file : void 0;
|
|
493
503
|
}
|
|
494
504
|
function whyNotPredictable(world) {
|
|
495
505
|
if (world.bodies === void 0 || Object.keys(world.bodies).length === 0) {
|
|
496
506
|
return `${world.file} exports no \`bodies\`, so there are no shapes to build a local world from; bots run without client prediction.
|
|
497
507
|
export the body factories your client passes to \`joinRoom({ physics2d })\`.`;
|
|
498
508
|
}
|
|
499
|
-
const
|
|
509
|
+
const settle = world.engine === "matter2d" ? world.settle : void 0;
|
|
510
|
+
const steered = world.intents !== void 0 || settle !== void 0;
|
|
500
511
|
if (!steered && world.gravity.x === 0 && world.gravity.y === 0) {
|
|
501
|
-
|
|
512
|
+
const hooks = world.engine === "matter2d" ? "no `intents` and no `settle`" : "no `intents`";
|
|
513
|
+
return `${world.file} exports ${hooks}, 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
514
|
export the steering hooks your client passes to \`joinRoom({ physics2d })\` (a room that applies its forces inside \`tick()\` has none to share).`;
|
|
503
515
|
}
|
|
504
516
|
return void 0;
|
|
@@ -507,14 +519,12 @@ async function loadProjectWorld(options) {
|
|
|
507
519
|
const entry = WORLD_CANDIDATES.map((c) => path2.resolve(options.cwd, c)).find(
|
|
508
520
|
(f) => existsSync2(f)
|
|
509
521
|
);
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
return room === void 0 ? void 0 : { engine: "matter2d", why: why(room), file: room };
|
|
513
|
-
};
|
|
522
|
+
const declared = await detectRoomEngine(options);
|
|
523
|
+
const planarRoom = (why) => declared === void 0 || declared.engine !== "matter2d" && declared.engine !== "rapier2d" ? void 0 : { engine: declared.engine, why: why(declared.file), file: declared.file };
|
|
514
524
|
if (entry === void 0) {
|
|
515
|
-
return
|
|
516
|
-
(room) => `${room} declares a
|
|
517
|
-
export the \`physics2d\` block your client passes to \`joinRoom\` from \`irtio/world.ts\`
|
|
525
|
+
return planarRoom(
|
|
526
|
+
(room) => `${room} declares a ${declared?.engine ?? "2D"} room and there is no shared world module beside it, so bots run without client prediction.
|
|
527
|
+
export the \`physics2d\` block your client passes to \`joinRoom\` from \`irtio/world.ts\` to predict.`
|
|
518
528
|
);
|
|
519
529
|
}
|
|
520
530
|
const module = await bundleAndImport(entry, path2.join(options.outDir, "world.mjs"), options);
|
|
@@ -525,32 +535,40 @@ async function loadProjectWorld(options) {
|
|
|
525
535
|
(this is what \`irtio init --physics\` writes)`
|
|
526
536
|
);
|
|
527
537
|
if (typeof gravity !== "object" || gravity === null || typeof gravity.x !== "number") {
|
|
528
|
-
const recognised =
|
|
529
|
-
() => `${entry} exports no gravity, so it is not the client world a
|
|
530
|
-
export the \`physics2d\` block your client passes to \`joinRoom\`
|
|
538
|
+
const recognised = planarRoom(
|
|
539
|
+
() => `${entry} exports no gravity, so it is not the client world a ${declared?.engine ?? "2D"} room predicts from; bots run without client prediction.
|
|
540
|
+
export the \`physics2d\` block your client passes to \`joinRoom\` from this module to predict.`
|
|
531
541
|
);
|
|
532
542
|
if (recognised !== void 0) return recognised;
|
|
533
543
|
throw notAWorld;
|
|
534
544
|
}
|
|
535
|
-
|
|
545
|
+
const planarEngine = declared?.engine === "rapier2d" ? "rapier2d" : declared?.engine === "matter2d" ? "matter2d" : declared === void 0 && typeof gravity.z !== "number" ? "matter2d" : void 0;
|
|
546
|
+
if (planarEngine !== void 0) {
|
|
536
547
|
if (typeof gravity.y !== "number") throw notAWorld;
|
|
537
|
-
const
|
|
538
|
-
engine: "matter2d",
|
|
548
|
+
const common = {
|
|
539
549
|
gravity: { x: gravity.x, y: gravity.y },
|
|
540
550
|
...typeof module.timestep === "number" ? { timestep: module.timestep } : {},
|
|
541
|
-
...typeof module.setup === "function" ? { setup: module.setup } : {},
|
|
542
551
|
...hookMap(module.bodies) !== void 0 ? { bodies: module.bodies } : {},
|
|
543
552
|
...hookMap(module.intents) !== void 0 ? { intents: module.intents } : {},
|
|
544
|
-
...hookMap(module.settle) !== void 0 ? { settle: module.settle } : {},
|
|
545
553
|
...typeof module.epsilon === "number" ? { epsilon: module.epsilon } : {},
|
|
546
554
|
...typeof module.maxPredictedBodies === "number" ? { maxPredictedBodies: module.maxPredictedBodies } : {},
|
|
547
555
|
...typeof module.smoothingHalfLifeMs === "number" ? { smoothingHalfLifeMs: module.smoothingHalfLifeMs } : {},
|
|
548
556
|
file: entry
|
|
549
557
|
};
|
|
550
|
-
const
|
|
551
|
-
|
|
558
|
+
const planar = planarEngine === "rapier2d" ? {
|
|
559
|
+
engine: "rapier2d",
|
|
560
|
+
...common,
|
|
561
|
+
...typeof module.setup === "function" ? { setup: module.setup } : {}
|
|
562
|
+
} : {
|
|
563
|
+
engine: "matter2d",
|
|
564
|
+
...common,
|
|
565
|
+
...typeof module.setup === "function" ? { setup: module.setup } : {},
|
|
566
|
+
...hookMap(module.settle) !== void 0 ? { settle: module.settle } : {}
|
|
567
|
+
};
|
|
568
|
+
const why = whyNotPredictable(planar);
|
|
569
|
+
return why === void 0 ? planar : { engine: planarEngine, why, file: entry };
|
|
552
570
|
}
|
|
553
|
-
if (typeof gravity.y !== "number") throw notAWorld;
|
|
571
|
+
if (typeof gravity.y !== "number" || typeof gravity.z !== "number") throw notAWorld;
|
|
554
572
|
return {
|
|
555
573
|
engine: "rapier3d",
|
|
556
574
|
gravity,
|
|
@@ -591,6 +609,38 @@ function canonicalMatterWorld(matter, engine) {
|
|
|
591
609
|
)
|
|
592
610
|
).join(";");
|
|
593
611
|
}
|
|
612
|
+
async function checkWorldRapier2dDeterminism(world, schema) {
|
|
613
|
+
const factories = world.bodies ?? {};
|
|
614
|
+
const exercised = schema ? schema.collections.filter(
|
|
615
|
+
(c) => c.physics !== void 0 && factories[c.name] !== void 0
|
|
616
|
+
) : [];
|
|
617
|
+
if (!world.setup && exercised.length === 0) return void 0;
|
|
618
|
+
const { initRapier2d } = await import("@irtio/runtime");
|
|
619
|
+
const { defaultRecord } = await import("@irtio/schema");
|
|
620
|
+
const rapier = await initRapier2d();
|
|
621
|
+
const build3 = () => {
|
|
622
|
+
const w = new rapier.World({ x: world.gravity.x, y: world.gravity.y });
|
|
623
|
+
try {
|
|
624
|
+
world.setup?.(w, rapier);
|
|
625
|
+
for (const desc of exercised) {
|
|
626
|
+
try {
|
|
627
|
+
const spec = factories[desc.name](rapier, defaultRecord(desc), "irtio-determinism-check");
|
|
628
|
+
if (spec?.body === void 0) continue;
|
|
629
|
+
const body = w.createRigidBody(spec.body);
|
|
630
|
+
for (const collider of spec.colliders ?? []) w.createCollider(collider, body);
|
|
631
|
+
} catch {
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return w.takeSnapshot();
|
|
635
|
+
} finally {
|
|
636
|
+
w.free();
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
const first = build3();
|
|
640
|
+
const second = build3();
|
|
641
|
+
if (first.length === second.length && everyByteEqual(first, second)) return void 0;
|
|
642
|
+
return `the world builder is not deterministic: two builds of ${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.`;
|
|
643
|
+
}
|
|
594
644
|
async function checkWorld2dDeterminism(world, schema) {
|
|
595
645
|
const factories = world.bodies ?? {};
|
|
596
646
|
const exercised = schema ? schema.collections.filter(
|
|
@@ -1043,9 +1093,12 @@ async function runSimulation(options = {}) {
|
|
|
1043
1093
|
);
|
|
1044
1094
|
const loadedWorld = hasPhysics ? await loadProjectWorld({ cwd, outDir, irtioPackages: options.irtioPackages }) : void 0;
|
|
1045
1095
|
const world2d = is2dWorld(loadedWorld) ? loadedWorld : void 0;
|
|
1046
|
-
const
|
|
1096
|
+
const worldR2d = isRapier2dWorld(loadedWorld) ? loadedWorld : void 0;
|
|
1097
|
+
const world = is2dWorld(loadedWorld) || isRapier2dWorld(loadedWorld) || isUnpredictedWorld(loadedWorld) ? void 0 : loadedWorld;
|
|
1047
1098
|
if (world2d !== void 0) {
|
|
1048
1099
|
log(`physics: ${world2d.file} is a matter2d world; bots predict with physics2d`);
|
|
1100
|
+
} else if (worldR2d !== void 0) {
|
|
1101
|
+
log(`physics: ${worldR2d.file} is a rapier2d world; bots predict with physics2d`);
|
|
1049
1102
|
} else if (isUnpredictedWorld(loadedWorld)) {
|
|
1050
1103
|
log(pc.yellow(`physics: ${loadedWorld.why}`));
|
|
1051
1104
|
}
|
|
@@ -1054,6 +1107,8 @@ async function runSimulation(options = {}) {
|
|
|
1054
1107
|
worldError = await checkWorldDeterminism(world);
|
|
1055
1108
|
} else if (world2d) {
|
|
1056
1109
|
worldError = await checkWorld2dDeterminism(world2d, loaded?.schema);
|
|
1110
|
+
} else if (worldR2d) {
|
|
1111
|
+
worldError = await checkWorldRapier2dDeterminism(worldR2d, loaded?.schema);
|
|
1057
1112
|
}
|
|
1058
1113
|
const conditionsSpec = scenario?.conditions ?? conditionsFrom(options);
|
|
1059
1114
|
const cheatSpec = scenario?.cheat ?? cheatFrom(options);
|
|
@@ -1072,7 +1127,7 @@ async function runSimulation(options = {}) {
|
|
|
1072
1127
|
...options.overrunsMax !== void 0 ? { overrunsMax: options.overrunsMax } : {},
|
|
1073
1128
|
...options.profile === true ? { profile: true } : {}
|
|
1074
1129
|
};
|
|
1075
|
-
const predicted = (world !== void 0 || world2d !== void 0) && worldError === void 0;
|
|
1130
|
+
const predicted = (world !== void 0 || world2d !== void 0 || worldR2d !== void 0) && worldError === void 0;
|
|
1076
1131
|
const projectKey = options.key ?? loaded?.schema?.project;
|
|
1077
1132
|
if (options.queue !== void 0 && (projectKey === void 0 || projectKey === "")) {
|
|
1078
1133
|
throw new RunNotPerformedError(
|
|
@@ -1124,6 +1179,21 @@ async function runSimulation(options = {}) {
|
|
|
1124
1179
|
...world2d.maxPredictedBodies !== void 0 ? { maxPredictedBodies: world2d.maxPredictedBodies } : {},
|
|
1125
1180
|
...world2d.smoothingHalfLifeMs !== void 0 ? { smoothingHalfLifeMs: world2d.smoothingHalfLifeMs } : {}
|
|
1126
1181
|
}
|
|
1182
|
+
} : {},
|
|
1183
|
+
// The same `physics2d` option, under the engine discriminant: one planar option, two
|
|
1184
|
+
// engines, and the client dispatches on the name rather than on the gravity's shape.
|
|
1185
|
+
...predicted && worldR2d !== void 0 ? {
|
|
1186
|
+
physics2d: {
|
|
1187
|
+
engine: "rapier2d",
|
|
1188
|
+
gravity: worldR2d.gravity,
|
|
1189
|
+
...worldR2d.timestep !== void 0 ? { timestep: worldR2d.timestep } : {},
|
|
1190
|
+
...worldR2d.setup !== void 0 ? { setup: worldR2d.setup } : {},
|
|
1191
|
+
...worldR2d.bodies !== void 0 ? { bodies: worldR2d.bodies } : {},
|
|
1192
|
+
...worldR2d.intents !== void 0 ? { intents: worldR2d.intents } : {},
|
|
1193
|
+
...worldR2d.epsilon !== void 0 ? { epsilon: worldR2d.epsilon } : {},
|
|
1194
|
+
...worldR2d.maxPredictedBodies !== void 0 ? { maxPredictedBodies: worldR2d.maxPredictedBodies } : {},
|
|
1195
|
+
...worldR2d.smoothingHalfLifeMs !== void 0 ? { smoothingHalfLifeMs: worldR2d.smoothingHalfLifeMs } : {}
|
|
1196
|
+
}
|
|
1127
1197
|
} : {}
|
|
1128
1198
|
}) : await spawnBots(bots, { ...spawn, script: relayEchoScript() });
|
|
1129
1199
|
} catch (err) {
|
|
@@ -1283,10 +1353,10 @@ async function runSimulation(options = {}) {
|
|
|
1283
1353
|
log(pc.red(` FAIL world-builder: ${worldError}`));
|
|
1284
1354
|
} else if (predicted) {
|
|
1285
1355
|
const suppressed = report.totals.suppressedCorrections;
|
|
1286
|
-
const engine = world2d !== void 0 ? "matter2d" : "rapier3d";
|
|
1356
|
+
const engine = world2d !== void 0 ? "matter2d" : worldR2d !== void 0 ? "rapier2d" : "rapier3d";
|
|
1287
1357
|
log(
|
|
1288
1358
|
pc.dim(
|
|
1289
|
-
` bots predicted ${engine} physics from ${path2.relative(cwd, world?.file ?? world2d?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
|
|
1359
|
+
` bots predicted ${engine} physics from ${path2.relative(cwd, world?.file ?? world2d?.file ?? worldR2d?.file ?? "")} (build-twice check held; ${suppressed} within-epsilon correction(s) suppressed)`
|
|
1290
1360
|
)
|
|
1291
1361
|
);
|
|
1292
1362
|
} else if (hasPhysics && isUnpredictedWorld(loadedWorld)) {
|
|
@@ -1362,9 +1432,12 @@ export {
|
|
|
1362
1432
|
ceilingFor,
|
|
1363
1433
|
checkWorld2dDeterminism,
|
|
1364
1434
|
checkWorldDeterminism,
|
|
1435
|
+
checkWorldRapier2dDeterminism,
|
|
1365
1436
|
detectMatter2dRoom,
|
|
1437
|
+
detectRoomEngine,
|
|
1366
1438
|
formatBuildIdentity,
|
|
1367
1439
|
is2dWorld,
|
|
1440
|
+
isRapier2dWorld,
|
|
1368
1441
|
isUnpredictedWorld,
|
|
1369
1442
|
loadProjectSchema,
|
|
1370
1443
|
loadProjectWorld,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "irtio CLI: local dev server, room scaffolding, bot simulation, deploy, logs and rooms",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,16 +45,17 @@
|
|
|
45
45
|
"esbuild": "^0.27.3",
|
|
46
46
|
"picocolors": "^1.1.1",
|
|
47
47
|
"ws": "^8.18.0",
|
|
48
|
-
"@irtio/bots": "0.
|
|
49
|
-
"@irtio/client": "0.
|
|
50
|
-
"@irtio/
|
|
51
|
-
"@irtio/
|
|
52
|
-
"@irtio/
|
|
53
|
-
"@irtio/
|
|
48
|
+
"@irtio/bots": "0.8.0",
|
|
49
|
+
"@irtio/client": "0.8.0",
|
|
50
|
+
"@irtio/runtime": "0.8.0",
|
|
51
|
+
"@irtio/schema": "0.8.0",
|
|
52
|
+
"@irtio/server": "0.8.0",
|
|
53
|
+
"@irtio/protocol": "0.8.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/ws": "^8.5.13",
|
|
57
57
|
"@dimforge/rapier3d-compat": "0.20.0",
|
|
58
|
+
"@dimforge/rapier2d-compat": "0.20.0",
|
|
58
59
|
"@irtio/store": "0.0.0",
|
|
59
60
|
"@irtio/supervisor": "0.0.0"
|
|
60
61
|
},
|