diff --git a/docs/language/network/_section.md b/docs/language/network/_section.md index be23e48d..4d31100d 100644 --- a/docs/language/network/_section.md +++ b/docs/language/network/_section.md @@ -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 @Sync and models @Owned and the compiler generates the replication. These Network.* primitives are what that sugar lowers to, for when you drive the transport yourself. Offline they collapse to single-player defaults. diff --git a/docs/language/networking/_section.md b/docs/language/networking/_section.md deleted file mode 100644 index c04e5a6f..00000000 --- a/docs/language/networking/_section.md +++ /dev/null @@ -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 @Sync and models @Owned; the compiler generates the replication. These are the low-level primitives beneath that sugar, for when you drive the transport yourself. diff --git a/docs/language/networking/fn-apply.md b/docs/language/networking/fn-apply.md deleted file mode 100644 index 08728c6e..00000000 --- a/docs/language/networking/fn-apply.md +++ /dev/null @@ -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 ---- - -apply is the exact inverse of serialize: it reads len bytes from buf 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 net_poll to reconcile a client to the authority's state, or after a rollback to restore a snapshot you took with serialize. 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 - } - } -} -``` diff --git a/docs/language/networking/fn-is_owner.md b/docs/language/networking/fn-is_owner.md deleted file mode 100644 index 8b410563..00000000 --- a/docs/language/networking/fn-is_owner.md +++ /dev/null @@ -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 ---- - -is_owner reports whether the local peer owns a given model instance — it compares the instance's stored owner (see owner) against local_id and returns the result. It only makes sense for instances of an @Owned model, and it is what @Predicted handler dispatch consults to decide whether the owning client should run a control handler speculatively. Prefer this over reading owner and comparing ids yourself. A freshly spawned, unowned instance returns false everywhere until the authority assigns it with set_owner. - -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 - } - } -} -``` diff --git a/docs/language/networking/fn-is_server.md b/docs/language/networking/fn-is_server.md deleted file mode 100644 index eece8079..00000000 --- a/docs/language/networking/fn-is_server.md +++ /dev/null @@ -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 ---- - -is_server returns whether the local peer is currently acting as the authority. The networking runtime sets the peer's role register (via set_role); offline it defaults to server, so an un-networked build reports true and every role guard collapses to "run here." This is the low-level read behind the @Server handler annotation — and in ordinary gameplay code you should reach for the annotation instead, since scattering is_server() 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 - } -} -``` diff --git a/docs/language/networking/fn-local_id.md b/docs/language/networking/fn-local_id.md deleted file mode 100644 index af6ada7a..00000000 --- a/docs/language/networking/fn-local_id.md +++ /dev/null @@ -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 ---- - -local_id returns the network id assigned to the local peer by the runtime. It is the value is_owner compares an instance's owner 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 0. You rarely need it directly — is_owner 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() - } - } -} -``` diff --git a/docs/language/networking/fn-net_poll.md b/docs/language/networking/fn-net_poll.md deleted file mode 100644 index a7e5ad64..00000000 --- a/docs/language/networking/fn-net_poll.md +++ /dev/null @@ -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 ---- - -net_poll copies up to cap waiting bytes into buf and returns how many actually arrived, or 0 when nothing is queued. It is the read half of the transport seam and the counterpart to net_send; each poll yields one datagram, so drain in a loop until it returns 0. Once you have the bytes you fold them back into a model instance with the generated apply — for a self-describing frame, write the entity id into the first word on send and read it back here before applying. Like net_send, this is the freedom layer beneath @Sync; 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 - } - } -} -``` diff --git a/docs/language/networking/fn-net_send.md b/docs/language/networking/fn-net_send.md deleted file mode 100644 index a03582d3..00000000 --- a/docs/language/networking/fn-net_send.md +++ /dev/null @@ -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 ---- - -net_send hands len bytes from buf to the numbered peer, 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 @Sync and @Owned generate replication for you, and reach for net_send only when you drive the wire yourself, typically to ship a model instance's serialized @Sync fields. Pair it with serialize to fill the buffer and with net_poll 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 - } - } -} -``` diff --git a/docs/language/networking/fn-owner.md b/docs/language/networking/fn-owner.md deleted file mode 100644 index 14aa3d37..00000000 --- a/docs/language/networking/fn-owner.md +++ /dev/null @@ -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 ---- - -owner returns the network peer id that owns a model instance, or -1 when the instance is unowned. It only means anything for instances of an @Owned model, which is what gives the model its owner slot (the runtime's @L_owner array); a fresh instance starts unowned until the authority assigns it with set_owner. Ownership is part of the world snapshot, so it round-trips through replication and rollback. Read it when you need the raw id; use is_owner 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 - } - } -} -``` diff --git a/docs/language/networking/fn-serialize.md b/docs/language/networking/fn-serialize.md deleted file mode 100644 index 76c4e4a7..00000000 --- a/docs/language/networking/fn-serialize.md +++ /dev/null @@ -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 ---- - -serialize writes the replicated state of a model instance into buf and returns how many bytes it wrote. It is compiler-generated from the schema: it copies exactly the fields that are both @Sync-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 net_send or before a rollback; the inverse is apply, which reads back the identical layout, and sync_size 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) - } - } -} -``` diff --git a/docs/language/networking/fn-set_owner.md b/docs/language/networking/fn-set_owner.md deleted file mode 100644 index 952b04d6..00000000 --- a/docs/language/networking/fn-set_owner.md +++ /dev/null @@ -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 ---- - -set_owner writes the owning peer id into a model instance's owner slot; it is how the authority hands an @Owned model instance to a client. Because ownership gates who may write @Sync(to: owner) fields and who runs @Predicted 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 @L_owner 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 - } - } -} -``` diff --git a/tools/docgen/check.py b/tools/docgen/check.py index 3983baca..687fb5ac 100644 --- a/tools/docgen/check.py +++ b/tools/docgen/check.py @@ -83,6 +83,38 @@ def main(site): if len(ids) > 1: problems.append("token %r (%s) documented on multiple pages: %s" % (tok, kind, ", ".join(sorted(ids)))) + # 2c) one docs directory per namespace / section. Two dirs covering the same + # runtime namespace (e.g. a `network`/`networking` or `date`/`datetime` + # split) invite drift — a symbol documented in one and not the other — so + # fail if any `ns:` is declared from more than one directory, or if two + # sections share a section id or (case-folded) title. + ns2dirs, id2dirs, title2dirs = {}, {}, {} + for cat in sorted(os.listdir(LANG)): + cdir = os.path.join(LANG, cat) + if not os.path.isdir(cdir): + continue + sp = os.path.join(cdir, "_section.md") + if os.path.exists(sp): + smeta, _ = parse_front(sp) + id2dirs.setdefault(smeta.get("id", cat), set()).add(cat) + title2dirs.setdefault(smeta.get("title", "").strip().lower(), set()).add(cat) + for fn in os.listdir(cdir): + if not fn.endswith(".md") or fn == "_section.md": + continue + meta, _ = parse_front(os.path.join(cdir, fn)) + ns = meta.get("ns", "") + if ns: + ns2dirs.setdefault(ns, set()).add(cat) + for ns, dirs in sorted(ns2dirs.items()): + if len(dirs) > 1: + problems.append("namespace %r documented from multiple dirs: %s" % (ns, ", ".join(sorted(dirs)))) + for sid, dirs in sorted(id2dirs.items()): + if len(dirs) > 1: + problems.append("section id %r declared by multiple dirs: %s" % (sid, ", ".join(sorted(dirs)))) + for title, dirs in sorted(title2dirs.items()): + if title and len(dirs) > 1: + problems.append("section title %r shared by multiple dirs: %s" % (title, ", ".join(sorted(dirs)))) + # 3) thin (un-expanded) symbols — warn, not fail (lets infra land before prose) if thin: warnings.append("%d symbols still have only seed text: %s%s" diff --git a/tools/docgen/inventory.json b/tools/docgen/inventory.json index 50c9cd77..a4227b37 100644 --- a/tools/docgen/inventory.json +++ b/tools/docgen/inventory.json @@ -187,17 +187,6 @@ "network-is_owner", "network-local_id" ], - "networking": [ - "fn-net_send", - "fn-net_poll", - "fn-owner", - "fn-set_owner", - "fn-is_server", - "fn-is_owner", - "fn-local_id", - "fn-serialize", - "fn-apply" - ], "operators": [ "op-arith", "op-compare", @@ -417,4 +406,4 @@ "crypto-hex", "crypto-ct_equal" ] -} \ No newline at end of file +}