docs: merge duplicate networking namespace dirs; guard against recurrence
Documentation namespace cleanup (issue #38). Audit outcome: - `date` vs `datetime` are NOT duplicates — `Date` is calendar days since the epoch, `DateTime` is instants (seconds); distinct runtime namespaces. Kept both. - `network` vs `networking` WAS a real duplicate. Every other stdlib area documents only its namespace (`World.*`, `Screen.*`, …), never the bare builtins it lowers to. Networking alone also documented the low-level `net_*`/builtin forms under `networking/`, duplicating the `Network.*` pages under `network/`. Removed `networking/`; `network/` (the `Network` namespace, which the compiler and LSP both expose) is canonical. Folded the `@Sync`/`@Owned` framing into `network/_section.md` so no context is lost. - Dropped the `networking` key from docgen inventory.json. Guard (AC3): `tools/docgen/check.py` now fails if any `ns:` is documented from more than one directory, or if two sections share an id or (case-folded) title — so a duplicate-namespace split cannot silently reappear. `gen.py` + `check.py` pass (34 sections, 365 symbols). Closes #38 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1c9235a948
commit
3bab2d2d4c
13 changed files with 34 additions and 312 deletions
|
|
@ -4,4 +4,4 @@ title: Network
|
|||
order: 6
|
||||
---
|
||||
|
||||
The low-level networking seam — send and poll datagrams, serialize and apply entity state, and read ownership and role. Offline these collapse to single-player defaults.
|
||||
The low-level networking seam — send and poll datagrams, serialize and apply entity state, and read ownership and role. Multiplayer is normally a compile-time property of the ECS, not glue you thread by hand: mark fields <code>@Sync</code> and models <code>@Owned</code> and the compiler generates the replication. These <code>Network.*</code> primitives are what that sugar lowers to, for when you drive the transport yourself. Offline they collapse to single-player defaults.
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
id: networking
|
||||
title: Networking
|
||||
order: 9
|
||||
---
|
||||
|
||||
Multiplayer is a compile-time property of the ECS, not glue you thread by hand. Mark fields <code>@Sync</code> and models <code>@Owned</code>; the compiler generates the replication. These are the low-level primitives beneath that sugar, for when you drive the transport yourself.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
id: fn-apply
|
||||
name: apply
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: apply
|
||||
sig: apply(target, buf, len)
|
||||
tip: Fold received @Sync bytes back onto a model instance.
|
||||
order: 50
|
||||
---
|
||||
|
||||
<code>apply</code> is the exact inverse of <code>serialize</code>: it reads <code>len</code> bytes from <code>buf</code> and copies each value back into the replicable-and-participating fields of the target model instance, in the same member-then-field order the serializer packed them. It is compiler-generated and dispatches on the instance's model, so it only touches synced fields — an unsynced field keeps whatever value it already held. Use it after <code>net_poll</code> to reconcile a client to the authority's state, or after a rollback to restore a snapshot you took with <code>serialize</code>. The buffer must have come from a serializer for a matching model, or the layout will not line up.
|
||||
|
||||
Parameters:
|
||||
- `target` — the model instance (an `entity`) to write the fields onto
|
||||
- `buf` — the source buffer holding serialized bytes
|
||||
- `len` — the number of bytes in `buf` to read
|
||||
|
||||
```ludic
|
||||
program ReconcilePlayer {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
@Owned model Player { @Sync Position }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 3, row: 4 } }
|
||||
for (Position) in query [Position, {Player}] {
|
||||
let player_id = self()
|
||||
let snapshot_buffer = bytes(64)
|
||||
let byte_count = serialize(player_id, snapshot_buffer) # save authoritative state
|
||||
Position.column = 999 # a mispredicted divergence
|
||||
apply(player_id, snapshot_buffer, byte_count) # reconcile back to the snapshot
|
||||
print(Position.column) # 3 — restored
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
id: fn-is_owner
|
||||
name: is_owner
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: is_owner
|
||||
sig: is_owner(target) -> bool
|
||||
tip: Whether the local peer owns a model instance.
|
||||
order: 50
|
||||
---
|
||||
|
||||
<code>is_owner</code> reports whether the local peer owns a given model instance — it compares the instance's stored owner (see <code>owner</code>) against <code>local_id</code> and returns the result. It only makes sense for instances of an <code>@Owned</code> model, and it is what <code>@Predicted</code> handler dispatch consults to decide whether the owning client should run a control handler speculatively. Prefer this over reading <code>owner</code> and comparing ids yourself. A freshly spawned, unowned instance returns false everywhere until the authority assigns it with <code>set_owner</code>.
|
||||
|
||||
Parameters:
|
||||
- `target` — the model instance (an `entity`) to test
|
||||
|
||||
```ludic
|
||||
program CheckOwnership {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
@Owned model Player { @Sync Position }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 5, row: 6 } }
|
||||
for (Position) in query [Position, {Player}] {
|
||||
let player_id = self()
|
||||
set_owner(player_id, 7)
|
||||
print(is_owner(player_id)) # 0 — local id 0 does not own peer 7's instance
|
||||
set_owner(player_id, 0)
|
||||
print(is_owner(player_id)) # 1 — now the local peer owns it
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
id: fn-is_server
|
||||
name: is_server
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: is_server
|
||||
sig: is_server() -> bool
|
||||
tip: Whether this peer is the authority.
|
||||
order: 50
|
||||
---
|
||||
|
||||
<code>is_server</code> returns whether the local peer is currently acting as the authority. The networking runtime sets the peer's role register (via <code>set_role</code>); offline it defaults to server, so an un-networked build reports <code>true</code> and every role guard collapses to "run here." This is the low-level read behind the <code>@Server</code> handler annotation — and in ordinary gameplay code you should reach for the annotation instead, since scattering <code>is_server()</code> branches through your simulation is exactly the readability footgun the role annotations exist to remove. Use the raw check only when you are driving the loop yourself.
|
||||
|
||||
```ludic
|
||||
program AuthorityGate {
|
||||
property Score { total: int = 0 }
|
||||
model Scoreboard { Score }
|
||||
|
||||
@Server handler AwardPoints phase Update {
|
||||
for (Score) in query [Score] { Score.total = Score.total + 10 }
|
||||
}
|
||||
|
||||
entry {
|
||||
spawn Scoreboard { Score { total: 0 } }
|
||||
if is_server() { print(1) } else { print(0) } # 1 offline — defaults to authority
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
id: fn-local_id
|
||||
name: local_id
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: local_id
|
||||
sig: local_id() -> int
|
||||
tip: This peer's own network id.
|
||||
order: 50
|
||||
---
|
||||
|
||||
<code>local_id</code> returns the network id assigned to the local peer by the runtime. It is the value <code>is_owner</code> compares an instance's <code>owner</code> against, so it answers "which of these owned instances are mine?" when you drive ownership logic by hand. Offline, with no networking runtime spliced in, it defaults to <code>0</code>. You rarely need it directly — <code>is_owner</code> already folds the comparison — but it is useful when tagging spawned instances or addressing a specific peer.
|
||||
|
||||
```ludic
|
||||
program IdentifyPeer {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
@Owned model Player { @Sync Position }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 0, row: 0 } }
|
||||
for (Position) in query [Position, {Player}] {
|
||||
let player_id = self()
|
||||
set_owner(player_id, local_id()) # claim this instance for the local peer
|
||||
print(is_owner(player_id)) # 1 — owner now equals local_id()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
---
|
||||
id: fn-net_poll
|
||||
name: net_poll
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: net_poll
|
||||
sig: net_poll(buf, cap) -> int
|
||||
tip: Receive queued bytes from the transport; returns the byte count.
|
||||
order: 1
|
||||
---
|
||||
|
||||
<code>net_poll</code> copies up to <code>cap</code> waiting bytes into <code>buf</code> and returns how many actually arrived, or <code>0</code> when nothing is queued. It is the read half of the transport seam and the counterpart to <code>net_send</code>; each poll yields one datagram, so drain in a loop until it returns <code>0</code>. Once you have the bytes you fold them back into a model instance with the generated <code>apply</code> — for a self-describing frame, write the entity id into the first word on send and read it back here before applying. Like <code>net_send</code>, this is the freedom layer beneath <code>@Sync</code>; most games never call it directly.
|
||||
|
||||
Parameters:
|
||||
- `buf` — the destination buffer to copy received bytes into
|
||||
- `cap` — the maximum number of bytes to accept this call
|
||||
|
||||
```ludic
|
||||
program ReceivePlayer {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
@Owned model Player { @Sync Position }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 0, row: 0 } }
|
||||
let incoming_bytes = words(512)
|
||||
var received_count = net_poll(incoming_bytes, 2048)
|
||||
while received_count > 0 {
|
||||
let target_id = incoming_bytes[0] # entity id led the frame
|
||||
apply(target_id, offset(incoming_bytes, 4), received_count - 4)
|
||||
received_count = net_poll(incoming_bytes, 2048) # next datagram, if any
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
id: fn-net_send
|
||||
name: net_send
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: net_send
|
||||
sig: net_send(peer, buf, len)
|
||||
tip: Send raw bytes to a peer over the host's transport seam.
|
||||
order: 0
|
||||
---
|
||||
|
||||
<code>net_send</code> hands <code>len</code> bytes from <code>buf</code> to the numbered <code>peer</code>, and it is the write half of Ludic's transport seam. The transport itself is a host-provided seam rather than a fixed protocol — real UDP natively, WebRTC or WebSocket on the web, or a built-in loopback in tests — so the same game runs unchanged over any of them. This is the low-level freedom layer: you normally let <code>@Sync</code> and <code>@Owned</code> generate replication for you, and reach for <code>net_send</code> only when you drive the wire yourself, typically to ship a model instance's serialized <code>@Sync</code> fields. Pair it with <code>serialize</code> to fill the buffer and with <code>net_poll</code> on the far side to receive.
|
||||
|
||||
Parameters:
|
||||
- `peer` — the destination peer id (a network id, e.g. `0` for the authority)
|
||||
- `buf` — the source buffer holding the bytes to send
|
||||
- `len` — how many bytes of `buf` to send
|
||||
|
||||
```ludic
|
||||
program ReplicatePlayer {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
@Owned model Player { @Sync Position }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 3, row: 4 } }
|
||||
for (Position) in query [Position, {Player}] {
|
||||
let player_id = self()
|
||||
let outgoing_bytes = words(512)
|
||||
outgoing_bytes[0] = player_id # entity id in the first word
|
||||
let field_count = serialize(player_id, offset(outgoing_bytes, 4))
|
||||
net_send(0, outgoing_bytes, 4 + field_count) # ship id + synced fields
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
id: fn-owner
|
||||
name: owner
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: owner
|
||||
sig: owner(target) -> int
|
||||
tip: Read a model instance's network owner id.
|
||||
order: 50
|
||||
---
|
||||
|
||||
<code>owner</code> returns the network peer id that owns a model instance, or <code>-1</code> when the instance is unowned. It only means anything for instances of an <code>@Owned</code> model, which is what gives the model its owner slot (the runtime's <code>@L_owner</code> array); a fresh instance starts unowned until the authority assigns it with <code>set_owner</code>. Ownership is part of the world snapshot, so it round-trips through replication and rollback. Read it when you need the raw id; use <code>is_owner</code> when you only need to know whether the local peer owns the instance.
|
||||
|
||||
Parameters:
|
||||
- `target` — the model instance (an `entity`) whose owner is read
|
||||
|
||||
```ludic
|
||||
program InspectOwner {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
@Owned model Player { @Sync Position }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 5, row: 6 } }
|
||||
for (Position) in query [Position, {Player}] {
|
||||
let player_id = self()
|
||||
print(owner(player_id)) # -1 — a fresh instance is unowned
|
||||
set_owner(player_id, 7)
|
||||
print(owner(player_id)) # 7 — the authority assigned peer 7
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
---
|
||||
id: fn-serialize
|
||||
name: serialize
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: serialize
|
||||
sig: serialize(target, buf) -> int
|
||||
tip: Pack a model instance's @Sync fields into a buffer; returns bytes written.
|
||||
order: 50
|
||||
---
|
||||
|
||||
<code>serialize</code> writes the replicated state of a model instance into <code>buf</code> and returns how many bytes it wrote. It is compiler-generated from the schema: it copies exactly the fields that are both <code>@Sync</code>-marked and participating in that instance's model, tightly packed in member-then-field order, and nothing else. This is the dispatcher over the per-model codecs, so it works for any spawned model whose fields sync. Use it to snapshot state before shipping it with <code>net_send</code> or before a rollback; the inverse is <code>apply</code>, which reads back the identical layout, and <code>sync_size</code> gives the byte count up front so you can size a buffer.
|
||||
|
||||
Parameters:
|
||||
- `target` — the model instance (an `entity`) whose synced fields are read
|
||||
- `buf` — the destination buffer the packed bytes are written into
|
||||
|
||||
```ludic
|
||||
program SnapshotPlayer {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
property Health { @Sync current: int = 0, maximum: int = 0 }
|
||||
@Owned model Player { @Sync Position, @Sync Health }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 3, row: 4 }, Health { current: 50, maximum: 100 } }
|
||||
for (Position, Health) in query [Position, Health, {Player}] {
|
||||
let player_id = self()
|
||||
let snapshot_buffer = bytes(64)
|
||||
let byte_count = serialize(player_id, snapshot_buffer) # pack column, row, current
|
||||
print(byte_count) # 12 = Position(8) + Health.current(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
id: fn-set_owner
|
||||
name: set_owner
|
||||
category: networking
|
||||
kind: builtin
|
||||
tokens: set_owner
|
||||
sig: set_owner(target, id)
|
||||
tip: Assign a model instance's network owner.
|
||||
order: 50
|
||||
---
|
||||
|
||||
<code>set_owner</code> writes the owning peer id into a model instance's owner slot; it is how the authority hands an <code>@Owned</code> model instance to a client. Because ownership gates who may write <code>@Sync(to: owner)</code> fields and who runs <code>@Predicted</code> handlers, assignment is normally the server's job — a client assigning ownership to itself would defeat that. The value is stored in the world (the <code>@L_owner</code> array), so it survives snapshot, replication, and rollback. Pass the local peer id to make the local peer the owner, or any other peer's id to hand it off.
|
||||
|
||||
Parameters:
|
||||
- `target` — the model instance (an `entity`) to assign
|
||||
- `id` — the owning peer id to store
|
||||
|
||||
```ludic
|
||||
program AssignOwnership {
|
||||
@Sync property Position { column: int = 0, row: int = 0 }
|
||||
@Owned model Player { @Sync Position }
|
||||
|
||||
entry {
|
||||
spawn Player { Position { column: 5, row: 6 } }
|
||||
for (Position) in query [Position, {Player}] {
|
||||
let player_id = self()
|
||||
set_owner(player_id, 0) # hand this instance to the local peer (id 0)
|
||||
print(is_owner(player_id)) # 1 — the local peer now owns it
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue