@genex-ai/multiplayer 0.17.6 → 0.17.8

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.
Files changed (40) hide show
  1. package/PROTOCOL.md +101 -30
  2. package/README.md +35 -14
  3. package/WORLD-GLTF.md +109 -0
  4. package/WORLD-SCENE.md +135 -0
  5. package/dist/authority/fast-udp.d.ts +4 -2
  6. package/dist/authority/fast-udp.js +8 -46
  7. package/dist/authority/fast-webtransport.d.ts +26 -4
  8. package/dist/authority/fast-webtransport.js +12 -115
  9. package/dist/authority/index.d.ts +379 -41
  10. package/dist/authority/index.js +247 -47
  11. package/dist/authority/module.d.ts +2 -1
  12. package/dist/authority/module.js +1 -1
  13. package/dist/bridge-authority.d.ts +3 -2
  14. package/dist/bridge-authority.global.js +10 -9
  15. package/dist/bridge-authority.js +11 -6
  16. package/dist/bridge.global.js +5 -5
  17. package/dist/bridge.js +3 -3
  18. package/dist/chunk-2NGXXQDS.js +1916 -0
  19. package/dist/{chunk-ZSWF4VCN.js → chunk-CSQEEANB.js} +78 -2
  20. package/dist/{chunk-UX3F6YTV.js → chunk-FPWP22Y7.js} +1 -1
  21. package/dist/{chunk-5JGRFT2N.js → chunk-NFMGIKFZ.js} +879 -1898
  22. package/dist/{chunk-K3SF5VDS.js → chunk-OD6NRSX2.js} +3 -1
  23. package/dist/{chunk-7QVOIHJF.js → chunk-QY3QQNDJ.js} +2 -2
  24. package/dist/{chunk-Y6XHXDIK.js → chunk-QZOCUQJT.js} +820 -537
  25. package/dist/chunk-TSYEWPVO.js +44 -0
  26. package/dist/chunk-TVTVIMIH.js +675 -0
  27. package/dist/chunk-YWQ3ALYY.js +69 -0
  28. package/dist/{fast-client-CZ41v-oB.d.ts → fast-client--A88q0kk.d.ts} +85 -4
  29. package/dist/index.js +3 -3
  30. package/dist/level-build-CbRqrYJq.d.ts +140 -0
  31. package/dist/{module-DqtFb5wA.d.ts → module-CSpXrzFc.d.ts} +151 -400
  32. package/dist/{physics-predict-49udN3a1.d.ts → physics-predict-C7ZB1gDG.d.ts} +18 -3
  33. package/dist/protocol.d.ts +87 -2
  34. package/dist/protocol.js +15 -1
  35. package/dist/shot-world-D22tqOUT.d.ts +485 -0
  36. package/dist/world-fit/index.d.ts +549 -0
  37. package/dist/world-fit/index.js +4763 -0
  38. package/dist/world-fit/three.d.ts +77 -0
  39. package/dist/world-fit/three.js +140 -0
  40. package/package.json +15 -3
package/README.md CHANGED
@@ -213,7 +213,9 @@ self-heal on the next publish, and lost `send()` events / inputs are counted in
213
213
  Render `reconnecting`/`reconnected` as an overlay and keep your loop running. `disconnect` is terminal
214
214
  (server restart, revoked session `4401`, or the link never recovered `4003`): read a fresh token and
215
215
  `connect()` again. A deliberate `leave()` never reconnects. The relay also broadcasts `server:restart` right before
216
- a deploy — flush saves when you see it.
216
+ a deploy — flush saves when you see it. An authority session (`connectAuthority`) does more: a deploy or a dead server
217
+ moves its seat to a fresh room by itself, on the same session object, saying `draining`, `rejoining` and `rejoined`
218
+ (PROTOCOL.md §17.13), and its `disconnect` means it is over for good.
217
219
 
218
220
  **One seat per player:** the relay enforces a single session per identity per room — a
219
221
  second tab / device / reloaded page supersedes the previous session (old one gets
@@ -627,7 +629,7 @@ for (const e of JSON.parse(GenexMultiplayerBridge.drainAuthorityEventsJson(h)))
627
629
  Protocol 4 ([`PROTOCOL.md` §17](./PROTOCOL.md#17-authority-rooms-protocol-4)): the server runs the
628
630
  game's own room module at a fixed tick, clients send typed input and render what the server writes.
629
631
  Use it when cheating matters (competitive play); everything above is the relay and is unchanged by it.
630
- The authority servers are not live yet; only a local development server serves these rooms.
632
+ Genex's authority servers serve these rooms in production (Asia, EU and US); `npx genex-authority-dev start` serves the same module locally.
631
633
 
632
634
  ```ts
633
635
  import { connectAuthority } from '@genex-ai/multiplayer/authority';
@@ -664,7 +666,9 @@ s.on('hit', (data) => flash(data)); // events your room module emits
664
666
  When your entity respawns (its `epoch` changes) it snaps to the server's new pose and keeps the
665
667
  inputs still in flight, which the server applies to the new life, so moving through a death costs no
666
668
  correction. `s.telemetry` counts `inputsSent`, `inputsDropped`, `reconciles`, `visibleCorrections`
667
- (> 0.05 units), `peakCorrection` and `reseeds` (respawns of your entity and reconnects, never counted
669
+ (a move of your place past 5 cm: 0.05 × the room's `lengthUnit`, else the preset's implied unit), `peakCorrection`
670
+ (the largest such move, room units; velocity and angles are never in it), `peakStateCorrection` (the largest
671
+ correction of any other mirrored field, each in its own unit: only zero or not says anything) and `reseeds` (respawns of your entity and reconnects, never counted
668
672
  as corrections). `s.prediction` says whether your entity is predicted right now, and why not
669
673
  (`off`, `no_entity`, `no_preset`, `world_mismatch`).
670
674
  - **`firstperson`** ([§17.7](./PROTOCOL.md#177-movement-presets)) is Source-engine movement through a
@@ -739,8 +743,8 @@ s.on('hit', (data) => flash(data)); // events your room module emits
739
743
  is `physics-draw.ts` (`bodyStateOf`, `extrapolateBody`, `receiveBodyState`, `drawBody`), held to
740
744
  the netcode bench's `physics-draw.json` vectors. A body entity is never `me()`. The module side is
741
745
  `config.physics` and `ctx.physics` (commands, queries answered next tick, events, `body(key)` for
742
- subscribed kinds); the wire and the reference tables are `wire.ts` and `physics.ts`. No Genex server
743
- runs physics until the pinned core ships. `s.setPhysics(hash)` (or `connectAuthority({ …, physics })`)
746
+ subscribed kinds); the wire and the reference tables are `wire.ts` and `physics.ts`. Genex's authority
747
+ servers run these rooms on the pinned core. `s.setPhysics(hash)` (or `connectAuthority({ …, physics })`)
744
748
  names a physics world your client runs a copy of; `s.physics.reason` is `physics_mismatch` while the
745
749
  room's hash differs.
746
750
  - **Physics prediction** ([§17.11](./PROTOCOL.md#1711-physics-prediction)): give the session the core,
@@ -780,8 +784,11 @@ s.on('hit', (data) => flash(data)); // events your room module emits
780
784
  as both, in your prediction, the seat prediction, the physics replay and the drawing guard. The
781
785
  authority host runs the same pass on the server after each tick's preset steps and before the
782
786
  module's `step`, so the module writes no contact code at all (and must not run a pass of its own).
787
+ - **Letting a held body go.** A module that holds a body with `cap` 0 and lifts it unannounced lifts it for inputs on their
788
+ way, so a held stick lurches by about speed × round trip. Announce the tick (a countdown does anyway) and give the session a
789
+ `seatRule` that sets `cap` on it (§17.11 *Seat rules*): every seat starts on one tick, and no correction, measured on UDP.
783
790
  - **Input repeat** is a room setting ([§17.11 *Input pacing*](./PROTOCOL.md#1711-physics-prediction),
784
- *The setting*): `config.inputs: { preset?, repeat? }`, `repeat` (`'on'` or `'off'`) as given, else
791
+ *The setting*): `config.inputs: { preset?, repeat?, settleMs? }` (`settleMs`: §17.11 *A quiet seat*), `repeat` (`'on'` or `'off'`) as given, else
785
792
  the genre preset's (`inputs.preset`, else `config.contacts.preset`), else on; `vehicle-arena` is off,
786
793
  `shooter`, `platformer`, `party` and `physics-sandbox` on (`INPUT_PRESETS`). On, a starved seat's
787
794
  held input is repeated and the session compares your prediction at
@@ -793,17 +800,24 @@ s.on('hit', (data) => flash(data)); // events your room module emits
793
800
  `disconnect` fire as there. An input that came due while the socket was down is dropped, never
794
801
  replayed. `leave()` during an outage still leaves: it stops reconnecting, and a retry already under
795
802
  way leaves the moment it lands.
796
- - **The fast channel** ([§17.12](./PROTOCOL.md#1712-the-fast-channel-fast1)), Node clients for now
797
- (bots, the netcode bench, tests): `connectAuthority({ …, fast: openUdpFastSocket })` with
798
- `openUdpFastSocket` from `@genex-ai/multiplayer/authority/fast-udp` asks for `fast@1`. In a room that
803
+ - **The fast channel** ([§17.12](./PROTOCOL.md#1712-the-fast-channel-fast1)), on by default: over UDP for Node
804
+ clients (bots, the netcode bench, tests) and over WebTransport in a browser, with nothing to pass.
805
+ `connectAuthority({ … })` asks for `fast@1`, `fast: false` turns it off,
806
+ and `openUdpFastSocket` from `@genex-ai/multiplayer/authority/fast-udp` is there to wrap. One line per join
807
+ names the transport and any fallback (`session.fast.line`; stderr, or `fastLog`). In a room that
799
808
  offers it your inputs go by UDP datagram, each one in several (a lost datagram costs nothing), and the
800
809
  room's motion comes back as per-seat ack-delta snapshots written into the same state a patch writes,
801
810
  so prediction, reconcile and interpolation run unchanged; a WebSocket held up by a lost TCP segment
802
- no longer holds them. Join, events and the rest of the state stay on the WebSocket. With no snapshot
803
- 1 s after the first datagram or 2 s after the last, a binding it cannot use, or the server's
804
- `__fast:off`, the seat goes on by WebSocket for the rest of the socket, every input sent stepped
805
- once and every motion field at the WebSocket's latest value. `s.fast` says `live`, `snapshots` and `fallback` (its reason). The browser's WebTransport
806
- opener comes later; a room without the channel plays exactly as before.
811
+ no longer holds them. Join, events and the rest of the state stay on the WebSocket. The seat moves only
812
+ once the path is proven, with the WebSocket carrying everything until then, so a network that blocks UDP
813
+ loses no input. With no snapshot 1 s after the first datagram or 2 s after the last, a binding it cannot
814
+ use, or the server's `__fast:off`, the seat goes on by WebSocket, every input sent stepped once and every
815
+ motion field at the WebSocket's latest value, and comes back by itself when the path does (retrying from
816
+ 1 s up to every minute, with jitter). A seat with nothing to send keeps its channel with an empty datagram
817
+ every 250 ms, and a NAT rebinding or a Wi-Fi to mobile move keeps it on UDP. `s.fast` says `live`,
818
+ `snapshots`, `fallbacks`, `returns` and `fallback`: why the seat is on the WebSocket now (`no-webtransport`,
819
+ `no-relay`, `not-offered`, `unreachable`, `handshake`, `stall`, `server`). A room without the channel plays
820
+ exactly as before.
807
821
  - **Lag compensation.** Every input tells the server what you saw: other entities `renderDelayMs`
808
822
  (default 100) in the past, whether or not your own entity is predicted.
809
823
  - **Every server message** also reaches `onEvent(type, data)` when you pass it, in order and with no
@@ -868,6 +882,13 @@ export default defineRoom({
868
882
  });
869
883
  ```
870
884
 
885
+ ### A level as a world: `@genex-ai/multiplayer/world-fit`
886
+
887
+ A world for `firstperson` or `vehicle` from a real level, at build time: `readGltfLevel(path)` reads a glb or glTF and
888
+ `sceneFromThree(scene)` (`@genex-ai/multiplayer/world-fit/three`, no Node in it) reads a three.js scene, both into the same scene
889
+ file ([WORLD-SCENE.md](WORLD-SCENE.md)) for `genex world fit`. Node names or `extras.genex` say what collides, on which channel and
890
+ where a spawn is; a file is hostile input and a refusal names what to change: [WORLD-GLTF.md](WORLD-GLTF.md).
891
+
871
892
  ## Rules of thumb
872
893
 
873
894
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
package/WORLD-GLTF.md ADDED
@@ -0,0 +1,109 @@
1
+ # A level from a glb, a glTF or a three.js scene
2
+
3
+ Two doors to the same [scene file](WORLD-SCENE.md), so a level from any engine with a glTF exporter reaches `genex world fit`:
4
+
5
+ ```sh
6
+ genex world gltf level.glb level.scene.json # no flags: a level says what it needs in its own extras
7
+ ```
8
+
9
+ ```ts
10
+ import { sceneFromThree } from "@genex-ai/multiplayer/world-fit/three";
11
+ const { json, notes } = sceneFromThree(gltf, { scale: 39.37 }); // a THREE.Scene, an Object3D or what GLTFLoader gave you
12
+ ```
13
+
14
+ One level gives one scene by either door: `packages/netcode-bench/test/authority/world-gltf-three.test.ts` holds a level
15
+ through `GLTFLoader` and `sceneFromThree` to the reader's scene, value for value. Same file, same bytes and hash on Mac, Linux
16
+ and Windows (`world-gltf.yml`). Library: `readGltfLevel(path)` and `runWorldGltf(args)` in `@genex-ai/multiplayer/world-fit`. `genex world gltf` exits 0, 1 on a call
17
+ that is not one and 3 on a refused level, as `genex world fit` does.
18
+
19
+ ## Space and units
20
+ A glTF is metres, y up, right-handed. A three.js scene is y up too. Everything comes out in the room's axes, z up, handedness
21
+ kept: drawing space `(X, Y, Z)` is the room's `(X, −Z, Y)`, the same turn as drawing a room's body at `(x, z, −y)`. Coordinates land
22
+ on a grid of 1/256 of a unit, then float32, so no path to a number shows.
23
+
24
+ - **The reader** scales metres by `unitsPerMeter`, 39.37 (Source units) unless the glTF scene's `extras.genex.unitsPerMeter` says
25
+ otherwise. A game in metres sets it to 1.
26
+ - **The helper** scales by its `scale` option, default 1: a game that draws the server's numbers as they are. A model loaded in
27
+ metres and not scaled in the scene is `{ scale: 39.37 }`; a model with `model.scale.setScalar(39.37)` is already there.
28
+ - **Hull**: `extras.genex.hull` on the scene (`radius`, `height`, `crouchHeight`, `step`, `jump`, `leap`, room units). Left out, the
29
+ fit sizes the Source defaults from `unitsPerMeter`. Make it your room's own parameters if they differ.
30
+
31
+ ## Marks
32
+ A mesh collides, on both channels, unless it is marked. Any exporter can say all of these in a node's name; Blender, three.js and
33
+ anything that writes custom properties can say them in `extras.genex` (`userData.genex` in three.js), and those win. A parent passes
34
+ its mark to what hangs under it; a child's own mark wins.
35
+
36
+ | Name starts with | `extras.genex` | Meaning |
37
+ |---|---|---|
38
+ | | | Collides: stops a hull and a bullet |
39
+ | `nocol_` | `{ "collide": "none" }` | Only drawn: art beside a collision proxy, a sky dome, a banner, a character |
40
+ | `clip_` | `{ "collide": "move" }` | Clip wall: stops a hull, a bullet passes |
41
+ | `shot_` | `{ "collide": "shot" }` | Stops a bullet, a hull passes |
42
+ | `box_` | `{ "shape": "box" }` | Its bounds as one box |
43
+ | `hull_` | `{ "shape": "hull" }` | The convex hull of its points, up to 4,096 |
44
+ | | `{ "shape": "mesh" }` | Triangles, no box looked for |
45
+ | `movable_` | `{ "movable": true }` | Moves or disappears at run time: named in the fit's report, never in the world |
46
+ | `prop_` | `{ "prop": true }` | May be cut when a budget runs out |
47
+ | `spawn_` | `{ "spawn": true }` | Where a player starts: the node's origin, on or just over the floor. Its own mesh does not collide. `spawn_Red_1` is the spawn `Red_1` |
48
+ | | `{ "tags": ["red"] }` | Carried to the actor or spawn for the game |
49
+
50
+ Prefixes stack in any case: `CLIP_box_Fence.001`. A misspelled mark is refused rather than ignored.
51
+ Hidden objects still collide: that is what a collision proxy usually is. Mark what is only for show.
52
+
53
+ What else it works out, so an exporter never has to:
54
+ - **A closed cube is an exact box**, on the world's axes or turned, even inside a merged mesh, and under negative scale. A cube
55
+ sheared by a non-uniform parent is the exact hull of its corners. A cube flattened by a scale of zero is the plane left.
56
+ - **A node an animation moves is movable**, with what hangs under it. In three.js, pass the loaded `gltf`, not `gltf.scene`, so it can see the animations.
57
+ - **An instanced mesh is every instance** (`EXT_mesh_gpu_instancing`, `THREE.InstancedMesh`). **An LOD is its nearest level.**
58
+ - **Quantized positions** (`KHR_mesh_quantization`) read as the numbers they mean. **Strips and fans** are triangles; points and lines are no surface.
59
+
60
+ ## Refusals
61
+ A file is hostile input. Every refusal is a `LevelRefusal` with a `code` and a message naming the node or field and what to do; the
62
+ scene is not written and an existing one stays. The reader's table is `packages/multiplayer/test/world-gltf-hostile.test.ts`.
63
+
64
+ | Code | What it says |
65
+ |---|---|
66
+ | `not_gltf`, `bad_container`, `bad_version` | Not a glb or glTF 2; chunks that do not add up |
67
+ | `bad_reference`, `bad_accessor`, `bad_index`, `bad_value`, `not_finite` | An index naming nothing; an accessor that does not fit or is sparse; an index past its vertices; a bad transform; NaN or infinity |
68
+ | `node_cycle` | A cycle, or a node with two parents |
69
+ | `too_large` | Past a limit below |
70
+ | `compressed` | Draco (`KHR_draco_mesh_compression`) or meshopt (`EXT_meshopt_compression`, `KHR_meshopt_compression`) on something that collides. Not decoded here: re-export without it, or load it in three.js and use `sceneFromThree`, whose loader decodes it. Compressed art marked `nocol_` is no problem |
71
+ | `unknown_extension` | A required extension this cannot honour, by name |
72
+ | `uri_refused` | A buffer URI that leaves the input's folder or is not a plain file: a parent segment, an absolute path, a scheme, a host, a backslash, an encoded trick, a link leading outside (by realpath), a FIFO. Nothing outside the folder is read and nothing touches the network. Image URIs are never read at all |
73
+ | `skinned`, `morphed` | A skinned or morph-posed mesh that collides: a world is static. Mark it `nocol_` beside a static proxy, or `movable_` |
74
+ | `unsupported` | A `THREE.BatchedMesh` that collides |
75
+ | `degenerate` | A mesh with no triangle of any area left. A few among real ones are dropped and counted in `notes` |
76
+ | `non_manifold` | More than 64 triangles on one edge, which no surface has. Ordinary non-manifold edges are kept and counted in `notes`: the scene format reads a surface, open or closed |
77
+ | `no_spawn`, `no_surface`, `bad_mark` | What it says |
78
+
79
+ Limits, each checked before anything is decoded for it: 1 GiB a file or buffer and for all buffers together, 8,000,000 triangles with
80
+ instances counted, 24,000,000 vertices an accessor, 1,000,000 nodes, depth 1,024. Past them, give what no one walks on a `nocol_` mark
81
+ or a collision proxy.
82
+
83
+ ## The whole path
84
+ ```sh
85
+ genex world gltf level.glb level.scene.json # or write sceneFromThree's json to that file
86
+ genex world fit level.scene.json world.json # the §17.7 world, and what it cost and changed
87
+ ```
88
+ Then the world is one file both sides import: `defineRoom({ …, world })` in the module, `connectAuthority({ …, world })` on the
89
+ client. Draw the glb as it is, in the same units: the world is made from it, not drawn. A spawn from the scene is where `onJoin` puts a seat.
90
+
91
+ `packages/netcode-bench/demo/level-walk/` is this path on a real page: `courtyard.glb` (`conformance/authority/world-scene/`,
92
+ written by its `courtyard.reference.mjs`), fitted, in a local `genex-authority-dev` room, two three.js seats and a Rust seat walking it.
93
+ `packages/netcode-bench/test/authority/world-gltf-route.test.ts` walks the same routes with no browser.
94
+
95
+ ### three.js in a game
96
+ Run the helper wherever the level already exists. A Node build script needs no browser: `new GLTFLoader().parse(arrayBuffer, "", …)`
97
+ for a glb with embedded buffers, or a `Scene` built in code. In a page, hand a creator the file:
98
+ ```ts
99
+ const url = URL.createObjectURL(new Blob([sceneFromThree(gltf, { scale: 39.37 }).json], { type: "application/json" }));
100
+ ```
101
+ Do this once, at build time, and import what the fit wrote: never fit or re-export in a player's page, or one float's difference
102
+ turns prediction off (`world_mismatch`).
103
+
104
+ ### Rust, or any engine with a glTF exporter
105
+ 1. Export the level as a glb in metres, with the marks above. Blender: *Include > Custom Properties* writes `extras`; names are always kept.
106
+ 2. `genex world gltf level.glb level.scene.json`, then `genex world fit level.scene.json world.json`.
107
+ 3. Rust: `FirstpersonWorld::from_json_str(include_str!("../assets/world.json"))` and `session.set_world(world)`; the module exports the
108
+ same file. Draw with `engine = (g.x, g.z, −g.y)`. `sdks/rust/genex-multiplayer/examples/level_walk.rs` does this against a room:
109
+ hash equal to the room's, predicted, no correction. A game in metres sets `unitsPerMeter` to 1 and every distance in its params.
package/WORLD-SCENE.md ADDED
@@ -0,0 +1,135 @@
1
+ # The world scene format
2
+
3
+ What an engine's exporter writes and `genex world fit` reads: a level as collision surfaces, in world space, with no
4
+ engine in it. Reader and checks: `src/world-fit/scene.ts` (`parseWorldScene`); a small example every exporter can
5
+ compare with: `../netcode-bench/conformance/authority/world-scene/sample.json`. A glb, a glTF or a three.js scene becomes this
6
+ file with no exporter to write: [WORLD-GLTF.md](WORLD-GLTF.md).
7
+
8
+ Exporters: Unity (`sdks/unity/README.md`, *From a level to a world*).
9
+
10
+ ```json
11
+ {
12
+ "format": "genex-world-scene",
13
+ "version": 1,
14
+ "unitsPerMeter": 39.37,
15
+ "source": { "engine": "unity", "level": "Yard" },
16
+ "hull": { "radius": 16, "height": 72, "crouchHeight": 54, "step": 18, "jump": 57, "leap": 189 },
17
+ "actors": [
18
+ { "name": "Ground", "surfaces": [
19
+ { "kind": "box", "center": [0, 0, -16], "half": [2000, 2000, 16] } ] },
20
+ { "name": "Fence_3", "tags": ["fence"], "surfaces": [
21
+ { "kind": "box", "center": [100, 40, 44], "axes": [[0.8, 0.6, 0], [-0.6, 0.8, 0], [0, 0, 1]], "half": [32, 2, 44],
22
+ "channels": ["move"] } ] },
23
+ { "name": "Rock_1", "tags": ["prop"], "surfaces": [
24
+ { "kind": "mesh", "vertices": [0, 0, 0, 90, 0, 0, 0, 90, 0, 0, 0, 60], "indices": [0, 1, 2, 0, 1, 3, 1, 2, 3, 2, 0, 3] } ] }
25
+ ],
26
+ "spawns": [ { "at": [0, 0, 8], "name": "Red_1", "tags": ["red"] } ]
27
+ }
28
+ ```
29
+
30
+ ## Space
31
+ - World space, the room's units, the same axes the game hands the step: **z up**, x and y as §17.7 moves them. An engine
32
+ with y up converts here, the way it converts everything else it sends the room.
33
+ - `unitsPerMeter` says what a unit is. It sizes the report's areas and, when `hull` is left out, scales the Source
34
+ defaults. It rescales nothing else.
35
+ - `hull` is the room's own hull: `radius`, `height` (stand), `crouchHeight`, `step`, `jump` (how high a standing jump
36
+ lifts the feet, `jumpImpulse² / (2·gravity)`) and `leap` (how far a run jump carries, `maxSpeed · 2·jumpImpulse / gravity`).
37
+ Every tolerance the fit uses comes from these; a scene has no tuning values to set.
38
+
39
+ ## Actors and surfaces
40
+ An actor is what a creator knows by name: a refusal says `Wreck_12`. Write the engine's own name, a path when names repeat.
41
+ Each surface is one of:
42
+
43
+ | `kind` | Fields | Meaning |
44
+ |---|---|---|
45
+ | `box` | `center`, `half`, `axes?` | An oriented box. `axes` are three orthonormal rows; left out means the world's. A box on the axes stays exact in the fit |
46
+ | `hull` | `points` | The convex hull of up to 4,096 points |
47
+ | `sphere` | `center`, `radius` | |
48
+ | `capsule` | `a`, `b`, `radius` | Any direction |
49
+ | `mesh` | `vertices`, `indices` | Triangles: x y z per vertex, three indices per triangle, wound either way, open or closed |
50
+ | `heightfield` | `origin`, `cell`, `cols`, `rows`, `heights` | `heights[row·cols + col]` at `origin + (col·cell[0], row·cell[1])`, solid below. `null` (NaN in binary) is a hole |
51
+
52
+ Big arrays may be `{ "f32le": "<base64>" }` (`vertices`, `heights`) and `{ "u32le": "<base64>" }` (`indices`).
53
+
54
+ Write everything that collides: the fit decides what a hull can reach and drops the rest, so an exporter never prunes.
55
+
56
+ ## Channels
57
+ `channels` on a surface says which traces it answers, and left out means both. `move` stops a hull. `shot` stops a bullet.
58
+
59
+ | Engine says | Write |
60
+ |---|---|
61
+ | Blocks pawns and traces | leave it out |
62
+ | Clip wall: blocks pawns, not traces | `["move"]` |
63
+ | Shot-only mesh: bullets stop, characters pass | `["shot"]` |
64
+ | Neither | leave the surface out |
65
+
66
+ `genex world fit` reads `move`, `genex world shots` reads `shot`, from the same file.
67
+
68
+ ## Where a level ends
69
+ `bound` is optional: `{ "min": [x, y], "max": [x, y], "ceiling": z }`, `ceiling` optional. Write it when the level is meant to stop
70
+ somewhere its geometry alone doesn't stop a hull. The fit adds solid of its own, `move` only, named `Bound` in the report and counted
71
+ in the budget: walls outside those edges, tall enough that nothing in the level lifts a hull over, and a lid whose underside is
72
+ at `ceiling`, so a hull's head stays under it. A spawn outside the bound or over the ceiling is refused at its path. Leave it out and
73
+ the level's reach is whatever its geometry gives; the fit doesn't guess where a creator meant it to end.
74
+
75
+ ## Tags
76
+ Free strings on an actor or a surface, carried for the game. The fit reads two:
77
+ - `movable`: moves or disappears at run time. Never in a static world; the report names it.
78
+ - `prop`: may be cut when a budget runs out, as one box a surface, before anything else that costs reach. An exporter tags
79
+ only what its engine or creator already calls a prop: an actor no wider than eight hull radii counts as one with no tag.
80
+
81
+ ## The shot world
82
+ `genex world shots <scene.json> <shots.json> [--max-boxes <n>] [--max-bytes <n>] [--report <file>] [--json]` writes what a module
83
+ exports as `shotWorld`, `{ boxes, ramps, tolerance }`, or exits 3 and writes none. `fitShotWorld` is the same thing in code. It keeps
84
+ what a muzzle can see from where a hull can be: a surface no hull reaches and a line reaches stays, one no line reaches goes, and that
85
+ is sound on the lattice. A straight line moves one way on each axis, so it stays in the flood of its own octant, which from a yard open
86
+ to the sky does not come back down outside the wall. A source box on the axes stays one box. Anything else gets boxes cut round the
87
+ surface they hold, so a stop is off by less than a cell. The cell is the finest of a ladder (a quarter of the hull's radius, doubling) the
88
+ budget holds. Boxes only: no ramps yet. The report reads rays from where a hull stands, against the source's own triangles: `accuracy`
89
+ (hit or miss, along the ray, and `surface`, how far the fit's stops are from a real surface: along a ray a near miss of an edge
90
+ reads as the whole way to the next wall) and `claims`, what a tolerance is worth by the rule of `ctx.world.claim`: honest claims refused or
91
+ capped, and how far past a real surface the most a claim may say reaches. The world's `tolerance` is `surface` at the 99th percentile.
92
+ It names what moves at run time and what no muzzle sees. It is not fed to any client.
93
+
94
+ ## What `genex world fit` reads and says
95
+ `genex world fit <scene.json> <world.json> [--max-boxes <n>] [--max-ramps <n>] [--report <file>] [--json]` writes the §17.7
96
+ world, or exits 3 and writes none. `@genex-ai/multiplayer/world-fit`'s `fitScene` is the same thing in code. The report gives
97
+ reach lost and extra against the source, how far floors really are from the source's (`floor`: the highest surface under a
98
+ hull's whole footprint, with no cell rounding, at the lattice's points, with the worst ones named), how far walls may recede, how far the reach runs past the level (`reachExtent`, below), the costliest
99
+ actors and, on a refusal, what each would lose as one box. What it models: a square hull walking, stepping, dropping, jumping, crouch jumping and leaping off
100
+ edges. What it does not: a slope a room can't stand on reads as walkable if its steps are, up to a steep one. Steeper, a column a cell
101
+ wide with a face rising a crouching hull's height or more beside it and falling two cells or more on its other side is no floor:
102
+ closed up the face's height and counted in `leanLedges`, so a tall leaning wall is its outer envelope, as wide at its top as at its
103
+ widest, and a true lip under a cell wide goes the same way. Held to leans up to 12 degrees off the vertical; past that a hull can still
104
+ stand on some of a face, because its lips can be a step apart. The floor figures skip the part of that top past the real wall. A door a hull clears by under a
105
+ cell is found on a finer lattice of its own, down to a thirty-second of a cell: open between exact boxes, shut and named in
106
+ `narrowDoorsShut` between walls the fit can't keep exact. The rule for the other way: a gap too narrow for the
107
+ hull in the source never opens, at any rung, and stays shut by `gapMarginUnits`, an eighth of a cell on the radius.
108
+
109
+ Floors win over budget. Where a coarser rung leaves feet past a radius from the source's floor, repair rounds spend boxes there; if
110
+ feet stay past a radius at worst or half a radius at the 99th percentile, the fit refuses with `floor_over`, writes no world, and
111
+ names who costs the most, as `over_budget` does. `open_slits` is the same for a gap left open: a report with either is never `ok`.
112
+
113
+ What the floor numbers say, in the scene's units, all against the same reading of the highest surface under a hull's footprint:
114
+
115
+ | Field | What it is | What can pass it |
116
+ |---|---|---|
117
+ | `floor.p99Units`, `floor.maxUnits` | Source against fit at the lattice's points. These are what the fit holds itself to: half a radius and a radius, or `floor_over` | A real hull rests between lattice points, so a seat's feet can read more than `maxUnits` at an edge, resting on a ledge by a hair |
118
+ | `floor.upperBoundUnits` | The same with the footprint half a cell wider and narrower, which holds every position between the points | Nothing: a seat's feet stay under it. It is a ledge's height wherever a footprint can overlap a ledge by a hair more in one world than the other, so it says little of how a level looks |
119
+ | `floor.edgeFreeUnits` | How far apart the two ranges of height are: nothing if a shift of half a cell can make them read alike | |
120
+
121
+ So compare a seat's worst feet with `upperBoundUnits`, and its 99th percentile with `p99Units`. A seat over `maxUnits` at an edge rest
122
+ is not a broken fit; a seat over `upperBoundUnits` is.
123
+
124
+ `reachExtent` says how far the source's reach runs past what the level holds, and is measured whether or not anything is wrong:
125
+ `coreMin` and `coreMax` are the plan of the spawns and every actor but flat ground as wide as the level; `outsideCoreM2` and
126
+ `outsideCorePct` the reach further than a run jump past that, `farthestFromCoreM` how far it gets; `walkedM2` what a hull gets to from
127
+ a spawn with no jump, `beyondWalkPct` the share of the reach only a jump gets to, and `outsideWalkedPct` the share further than a run
128
+ jump past the plan of what walking gets to. `flagged` is true at 25% of either `outsideCorePct` or `outsideWalkedPct`, with no `bound`.
129
+ It never refuses: whether the reach is what the creator meant is theirs to say, with a `bound`.
130
+
131
+ Spawns are where the hull's reach starts: `at`, with optional `name` and `tags`. A level with no spawn cannot be fitted.
132
+
133
+ ## Compatibility
134
+ `version` is checked and a new meaning is a new version. A reader refuses a bad value with its path (`WorldSceneError`,
135
+ `$.actors[3].surfaces[0].half[1] is below zero`).
@@ -1,5 +1,6 @@
1
- import { F as FastSocketOpener } from '../fast-client-CZ41v-oB.js';
2
- import '../module-DqtFb5wA.js';
1
+ import { F as FastSocketOpener } from '../fast-client--A88q0kk.js';
2
+ import '../module-CSpXrzFc.js';
3
+ import '../shot-world-D22tqOUT.js';
3
4
 
4
5
  /** The host (an IPv6 host unbracketed) and port of a `udp://host:port` url, else null: the url is
5
6
  * server-supplied, so anything more than a host and a port in 1..65535 is refused. */
@@ -7,6 +8,7 @@ declare function udpEndpoint(url: string): {
7
8
  host: string;
8
9
  port: number;
9
10
  } | null;
11
+
10
12
  /** A {@link FastSocketOpener} for `udp://` urls on node:dgram; null for any other url. */
11
13
  declare const openUdpFastSocket: FastSocketOpener;
12
14
 
@@ -1,51 +1,13 @@
1
+ import {
2
+ udpEndpoint,
3
+ udpFastOpener
4
+ } from "../chunk-YWQ3ALYY.js";
5
+
1
6
  // src/authority/fast-udp.ts
2
7
  import { createSocket } from "dgram";
3
- var UDP_SCHEME = "udp:";
4
- var PORT_MAX = 65535;
5
- var CONNECT_QUEUE_MAX = 32;
6
- var BARE_PATHS = ["", "/"];
7
- var onlyHostAndPort = (u) => u.username === "" && u.password === "" && BARE_PATHS.includes(u.pathname) && u.search === "" && u.hash === "";
8
- function udpEndpoint(url) {
9
- let parsed;
10
- try {
11
- parsed = new URL(url);
12
- } catch {
13
- return null;
14
- }
15
- const port = Number(parsed.port);
16
- const portOk = Number.isInteger(port) && port >= 1 && port <= PORT_MAX;
17
- if (parsed.protocol !== UDP_SCHEME || !portOk || parsed.hostname === "" || !onlyHostAndPort(parsed)) return null;
18
- return { host: parsed.hostname.replace(/^\[(.*)\]$/, "$1"), port };
19
- }
20
- var openUdpFastSocket = (url, receive) => {
21
- const to = udpEndpoint(url);
22
- if (to === null) return null;
23
- const socket = createSocket(to.host.includes(":") ? "udp6" : "udp4");
24
- let connected = false;
25
- let closed = false;
26
- let waiting = [];
27
- socket.on("message", (msg) => receive(new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength)));
28
- socket.on("error", () => {
29
- });
30
- socket.connect(to.port, to.host, () => {
31
- connected = true;
32
- if (!closed) for (const bytes of waiting) socket.send(bytes);
33
- waiting = [];
34
- });
35
- const fast = {
36
- send(bytes) {
37
- if (closed) return;
38
- if (connected) socket.send(bytes);
39
- else waiting = [...waiting, bytes].slice(-CONNECT_QUEUE_MAX);
40
- },
41
- close() {
42
- if (closed) return;
43
- closed = true;
44
- socket.close();
45
- }
46
- };
47
- return fast;
48
- };
8
+ import { lookup } from "dns";
9
+ import { isIP } from "net";
10
+ var openUdpFastSocket = udpFastOpener({ createSocket, lookup, isIP });
49
11
  export {
50
12
  openUdpFastSocket,
51
13
  udpEndpoint
@@ -1,5 +1,6 @@
1
- import { a as FastBindMessage, F as FastSocketOpener } from '../fast-client-CZ41v-oB.js';
2
- import '../module-DqtFb5wA.js';
1
+ import { F as FastSocketOpener, a as FastFallbackReason, b as FastBindMessage } from '../fast-client--A88q0kk.js';
2
+ import '../module-CSpXrzFc.js';
3
+ import '../shot-world-D22tqOUT.js';
3
4
 
4
5
  /** Hex digits of one certificate hash (SHA-256). */
5
6
  declare const FAST_WT_HASH_HEX_DIGITS = 64;
@@ -23,7 +24,9 @@ interface WebTransportLike {
23
24
  readonly reliability?: string;
24
25
  readonly datagrams: {
25
26
  readonly readable: ReadableStream<Uint8Array>;
26
- readonly writable: WritableStream<Uint8Array>;
27
+ /** Chromium's way to send; Safari 26 has only `createWritable()`. */
28
+ readonly writable?: WritableStream<Uint8Array>;
29
+ createWritable?(): WritableStream<Uint8Array>;
27
30
  readonly maxDatagramSize?: number;
28
31
  };
29
32
  close(info?: {
@@ -38,8 +41,27 @@ declare function webTransportOptions(target: FastWebTransportTarget): Record<str
38
41
  /** A {@link FastSocketOpener} on `WebTransport` (the browser's, or a stand-in in tests): null when the
39
42
  * binding has no usable `wt` or there is no WebTransport. */
40
43
  declare function webTransportFastOpener(WebTransport: WebTransportConstructor | undefined): FastSocketOpener;
44
+ /** The part of a global scope {@link chooseFastSocket} reads. */
45
+ interface FastSocketScope {
46
+ WebTransport?: unknown;
47
+ /** A page has one; */
48
+ document?: unknown;
49
+ /** a worker has this. Neither: not a browser (Node). */
50
+ importScripts?: unknown;
51
+ }
41
52
  /** The browser's opener: the global `WebTransport` (null where there is none, so the seat stays on the
42
53
  * WebSocket). */
43
54
  declare const openWebTransportFastSocket: FastSocketOpener;
55
+ /** How a seat opens its fast channel, and why it never asks when it does not. */
56
+ interface FastSocketChoice {
57
+ open: FastSocketOpener | undefined;
58
+ /** The reason `s.fast` reads from the start. */
59
+ fallback: FastFallbackReason | null;
60
+ }
61
+ /** The socket for `connectAuthority`'s `fast`: the game's own opener, none when the game said `false`;
62
+ * with no word from the game, WebTransport wherever `scope` has it. A browser without it never asks
63
+ * (a seat that asks and then cannot open the binding loses the WebSocket inputs it sent before the
64
+ * binding, PROTOCOL.md §17.12 *Fallback*), and says so. Node, with neither, is a plain WebSocket seat. */
65
+ declare function chooseFastSocket(given: FastSocketOpener | false | undefined, scope?: FastSocketScope): FastSocketChoice;
44
66
 
45
- export { FAST_WT_HASHES_MAX, FAST_WT_HASH_HEX_DIGITS, type FastWebTransportTarget, type WebTransportConstructor, type WebTransportLike, fastWebTransportOf, openWebTransportFastSocket, webTransportFastOpener, webTransportOptions };
67
+ export { FAST_WT_HASHES_MAX, FAST_WT_HASH_HEX_DIGITS, type FastSocketChoice, type FastSocketScope, type FastWebTransportTarget, type WebTransportConstructor, type WebTransportLike, chooseFastSocket, fastWebTransportOf, openWebTransportFastSocket, webTransportFastOpener, webTransportOptions };
@@ -1,122 +1,19 @@
1
1
  import {
2
- fromHex
3
- } from "../chunk-5JGRFT2N.js";
4
- import "../chunk-K3SF5VDS.js";
5
- import "../chunk-ZSWF4VCN.js";
6
-
7
- // src/authority/fast-webtransport.ts
8
- var FAST_WT_HASH_HEX_DIGITS = 64;
9
- var FAST_WT_HASHES_MAX = 4;
10
- var WT_SCHEME = "https:";
11
- var CONNECT_QUEUE_MAX = 32;
12
- var HASH_PATTERN = /^[0-9a-f]+$/;
13
- var RELIABLE_ONLY = "reliable-only";
14
- function fastWebTransportOf(bind) {
15
- const wt = bind.wt;
16
- if (typeof wt !== "object" || wt === null) return null;
17
- const { url, hashes } = wt;
18
- if (typeof url !== "string" || !Array.isArray(hashes)) return null;
19
- if (hashes.length === 0 || hashes.length > FAST_WT_HASHES_MAX) return null;
20
- const hashOk = (h) => typeof h === "string" && h.length === FAST_WT_HASH_HEX_DIGITS && HASH_PATTERN.test(h);
21
- if (!hashes.every(hashOk)) return null;
22
- let parsed;
23
- try {
24
- parsed = new URL(url);
25
- } catch {
26
- return null;
27
- }
28
- const bare = parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "";
29
- if (parsed.protocol !== WT_SCHEME || parsed.hostname === "" || !bare) return null;
30
- return { url, hashes: hashes.map((h) => fromHex(h)) };
31
- }
32
- function webTransportOptions(target) {
33
- return {
34
- serverCertificateHashes: target.hashes.map((value) => ({ algorithm: "sha-256", value })),
35
- requireUnreliable: true,
36
- allowPooling: false,
37
- congestionControl: "low-latency"
38
- };
39
- }
40
- var carriesFast = (t) => t.reliability !== RELIABLE_ONLY;
41
- var SocketState = { Connecting: "connecting", Open: "open", Failed: "failed", Closed: "closed" };
42
- function webTransportFastOpener(WebTransport) {
43
- return (_url, receive, bind) => {
44
- const target = bind === void 0 ? null : fastWebTransportOf(bind);
45
- if (target === null || WebTransport === void 0) return null;
46
- let t;
47
- try {
48
- t = new WebTransport(target.url, webTransportOptions(target));
49
- } catch {
50
- return null;
51
- }
52
- let state = SocketState.Connecting;
53
- let waiting = [];
54
- let writer = null;
55
- const fail = () => {
56
- if (state === SocketState.Closed || state === SocketState.Failed) return;
57
- state = SocketState.Failed;
58
- writer = null;
59
- waiting = [];
60
- try {
61
- t.close();
62
- } catch {
63
- }
64
- };
65
- const write = (w, bytes) => void w.write(bytes).catch(() => {
66
- });
67
- const readAll = async (reader) => {
68
- for (; ; ) {
69
- const r = await reader.read();
70
- if (r.done) return;
71
- if (state === SocketState.Open) receive(r.value);
72
- }
73
- };
74
- t.ready.then(
75
- () => {
76
- if (state !== SocketState.Connecting) return;
77
- if (!carriesFast(t)) return fail();
78
- state = SocketState.Open;
79
- const w = t.datagrams.writable.getWriter();
80
- writer = w;
81
- for (const bytes of waiting) write(w, bytes);
82
- waiting = [];
83
- readAll(t.datagrams.readable.getReader()).then(fail, fail);
84
- },
85
- () => fail()
86
- );
87
- t.closed.then(fail, fail);
88
- const socket = {
89
- send(bytes) {
90
- if (state === SocketState.Open && writer !== null) write(writer, bytes);
91
- else if (state === SocketState.Connecting) waiting = [...waiting, bytes].slice(-CONNECT_QUEUE_MAX);
92
- },
93
- close() {
94
- if (state === SocketState.Closed) return;
95
- const was = state;
96
- state = SocketState.Closed;
97
- writer = null;
98
- waiting = [];
99
- if (was === SocketState.Failed) return;
100
- try {
101
- t.close();
102
- } catch {
103
- }
104
- },
105
- failed: () => state === SocketState.Failed,
106
- // The browser's outgoing limit, known once the session is open: the browser sends only uplinks,
107
- // so the seat's largest uplink must fit (Chromium reports 1024; a 120 Hz seat's is 889).
108
- maxDatagramBytes() {
109
- const size = state === SocketState.Open ? t.datagrams.maxDatagramSize : void 0;
110
- return typeof size === "number" ? size : void 0;
111
- }
112
- };
113
- return socket;
114
- };
115
- }
116
- var openWebTransportFastSocket = (url, receive, bind) => webTransportFastOpener(globalThis.WebTransport)(url, receive, bind);
2
+ FAST_WT_HASHES_MAX,
3
+ FAST_WT_HASH_HEX_DIGITS,
4
+ chooseFastSocket,
5
+ fastWebTransportOf,
6
+ openWebTransportFastSocket,
7
+ webTransportFastOpener,
8
+ webTransportOptions
9
+ } from "../chunk-NFMGIKFZ.js";
10
+ import "../chunk-OD6NRSX2.js";
11
+ import "../chunk-2NGXXQDS.js";
12
+ import "../chunk-CSQEEANB.js";
117
13
  export {
118
14
  FAST_WT_HASHES_MAX,
119
15
  FAST_WT_HASH_HEX_DIGITS,
16
+ chooseFastSocket,
120
17
  fastWebTransportOf,
121
18
  openWebTransportFastSocket,
122
19
  webTransportFastOpener,