diff --git a/.github/workflows/docs-drift.yml b/.github/workflows/docs-drift.yml index e121de1..c635e0a 100644 --- a/.github/workflows/docs-drift.yml +++ b/.github/workflows/docs-drift.yml @@ -62,3 +62,6 @@ jobs: - name: Telemetry events match the observability guide run: scripts/check-telemetry-drift.sh + + - name: Guide anchors resolve + run: scripts/check-doc-anchors.sh diff --git a/guides/configuration.md b/guides/configuration.md index 2bef718..9d24c1b 100644 --- a/guides/configuration.md +++ b/guides/configuration.md @@ -426,7 +426,7 @@ does not work. Off by default. Turning it on lets a client ask for `world.tick` as a binary frame at `session.connect`, about a quarter of the bytes and several times cheaper to decode - the numbers and the encoding are in -[the protocol guide](websocket-protocol.md#binary-worldtick). +[the protocol guide](websocket-protocol.md#binary-world-tick). ```erlang {binary_wire, true} @@ -450,7 +450,7 @@ poses rather than sending datagrams every client would discard. **A frame carries at most 32 distinct field names**, because the field header indexes the dictionary in five bits. Past that the frame is sent as text, which is correct but costs the entities in it their datagram fast path - see -[the protocol guide](websocket-protocol.md#binary-worldtick) for what to watch +[the protocol guide](websocket-protocol.md#binary-world-tick) for what to watch for. A zone that cannot encode its entities at all drops to the text wire and then diff --git a/guides/datagram-plane.md b/guides/datagram-plane.md index d75a23a..ad5155c 100644 --- a/guides/datagram-plane.md +++ b/guides/datagram-plane.md @@ -200,7 +200,7 @@ what it is. `ASOBI_DGRAM_POSE_PERIOD` (default 20) is the axial refresh in ticks. -Full variable list in [Configuration](configuration.md#the-datagram-gateway-role). +Full variable list in [Configuration](configuration.md#the-datagram-gateway). ## Client setup @@ -295,7 +295,7 @@ reconfigure. A pose record carries a slot and nothing else, and the only frame that binds a slot to an entity is an `add` on the [binary -`world.tick`](websocket-protocol.md#binary-worldtick). So a client that connected +`world.tick`](websocket-protocol.md#binary-world-tick). So a client that connected without `"wire": "binary"` cannot resolve a pose, and the server does not send it any - it would be dropped client-side and the entity would look frozen with nothing in either log to say why. @@ -356,10 +356,10 @@ than left for a reader to discover. ## Further reading -- [Configuration](configuration.md#the-datagram-gateway-role) - every variable. +- [Configuration](configuration.md#the-datagram-gateway) - every variable. - [Self-hosting](self-hosting.md#adding-the-datagram-plane) - the compose file in context. -- [WebSocket protocol](websocket-protocol.md#binary-worldtick) - the binary +- [WebSocket protocol](websocket-protocol.md#binary-world-tick) - the binary `world.tick` encoding, which the plane depends on for its slot bindings. - ADR 0012 and ADR 0013 in `docs/adr/` - the protocol design, why it was rejected, and why it was reopened. diff --git a/guides/getting-started.md b/guides/getting-started.md index fe2c9a8..4c00712 100644 --- a/guides/getting-started.md +++ b/guides/getting-started.md @@ -3,7 +3,7 @@ asobi is one Erlang/OTP node holding the game backend, the Lua runtime and the operator console. There are two front doors onto it: -- **[Lua + Docker](#lua--docker)** - run the image, write game logic in Lua. The default path. +- **[Lua + Docker](#lua-docker)** - run the image, write game logic in Lua. The default path. - **[Erlang OTP](#erlang-otp)** - depend on the Hex package and write game logic in Erlang. Command blocks that differ per OS are shown for both bash (Linux, macOS, Git @@ -175,7 +175,7 @@ A 200 with: That means auth, the database, REST and the Lua runtime are all live. `access_token` is what the WebSocket handshake sends (see -[Connect via WebSocket](#connect-via-websocket)) and what goes in the +[Connect via WebSocket](#5-connect-via-websocket)) and what goes in the `Authorization: Bearer` header on REST calls. It is valid for 60 minutes. `refresh_token` buys a new pair from `POST /api/v1/auth/refresh` and is valid for 30 days. Neither TTL is configurable today. diff --git a/guides/lobbies.md b/guides/lobbies.md index acd148f..fad8183 100644 --- a/guides/lobbies.md +++ b/guides/lobbies.md @@ -120,7 +120,7 @@ To close a match to backfill, call end of round one, once the objective spawns, whenever the game says so. A closed match answers `match.locked`; a full one answers `match.full`. To turn away one specific player rather than everybody, return `nil` from -[`join`](lua-scripting.md#refusing-a-join) instead. +[`join`](lua-scripting.md#join-player_id-state-or-join-player_id-state-ctx) instead. ### The 60-second timeout @@ -162,7 +162,7 @@ Nothing creates the hub at boot. The first `world.find_or_create` instantiates it and it stays up from then on; after a restart the first player recreates it. Worlds are subject to `world_max_per_player` (5) and `world_max` (1000) - see -[World capacity](configuration.md#world-capacity). +[World capacity](configuration.md#instance-capacity). ### Private lobbies @@ -185,7 +185,7 @@ Hide it from the browser with `listed = false` in the script. That is discovery only - it never gates joining, so the join callback above is still the whole gate. `listed` and `quick_play` are properties of the mode, not of one instance, so every world of that mode is equally hidden. See -[Join context](websocket-protocol.md#join-context). +[Join context](websocket-protocol.md#match-join). ### Telling the room someone arrived diff --git a/guides/lua-api.md b/guides/lua-api.md index f38d71e..af96cce 100644 --- a/guides/lua-api.md +++ b/guides/lua-api.md @@ -130,7 +130,7 @@ end Asynchronous, like `broadcast`: a join already in the mailbox ahead of the flag still gets in. Closing a match stops the next joiner, not one already through the door. To refuse a specific player instead, return `nil` from -[`join`](lua-scripting.md#refusing-a-join). +[`join`](lua-scripting.md#join-player_id-state-or-join-player_id-state-ctx). ## Bots diff --git a/guides/matchmaking.md b/guides/matchmaking.md index df5c717..dde1ea0 100644 --- a/guides/matchmaking.md +++ b/guides/matchmaking.md @@ -283,7 +283,7 @@ Erlang caller inside the release, and by nothing else - there is no `match.create` frame and no `POST /api/v1/matches`. Gate entry by implementing `join/3` in your game module and checking the join -context - see [WebSocket protocol](websocket-protocol.md#join-context). To let +context - see [WebSocket protocol](websocket-protocol.md#match-join). To let friends find your session in a browser instead, see [World server](world-server.md). diff --git a/guides/observability.md b/guides/observability.md index c96c260..3baf640 100644 --- a/guides/observability.md +++ b/guides/observability.md @@ -131,7 +131,7 @@ asobi.wire.binary_refused ``` The `asobi.dgram.*` events fire only on a node in the -[`dgram_gw` role](configuration.md#the-datagram-gateway-role). +[`dgram_gw` role](configuration.md#the-datagram-gateway). `asobi.dgram.dropped` is the one to build a dashboard on, and `gate` is why it is one event rather than seven. Nothing is ever sent back to a rejected datagram, so @@ -201,7 +201,7 @@ worth alerting on: a spike in either is either an attack or a client misconfiguration, and both are invisible in game metrics. `asobi.ws.legacy_input_unwrap` counts input frames sent in the deprecated -sole-`data` shape (see [WebSocket protocol](websocket-protocol.md#worldinput)). +sole-`data` shape (see [WebSocket protocol](websocket-protocol.md#world-input)). It is not an error, and not worth an alert: it exists so the carve-out can be retired once the counter reaches zero for a release, instead of guessing which clients still depend on it. diff --git a/guides/phases.md b/guides/phases.md index 2af1f33..157b85e 100644 --- a/guides/phases.md +++ b/guides/phases.md @@ -132,7 +132,7 @@ block on the listing and join reply - `status`, `phase`, `remaining_ms` and the pending `start_condition`. Broadcast anything richer yourself from `on_phase_started`. -See [WebSocket protocol](websocket-protocol.md#worldphase_changed-server-push) +See [WebSocket protocol](websocket-protocol.md#world-phase_changed-server-push) for the frame envelope and [Lobbies](lobbies.md) for `game.broadcast`. ### Erlang games diff --git a/guides/rest-api.md b/guides/rest-api.md index c2c93dc..379a4ec 100644 --- a/guides/rest-api.md +++ b/guides/rest-api.md @@ -196,7 +196,7 @@ are cached for 500ms. `POST /api/v1/worlds` returns **201** with the world info, **429** `world.player_limit_reached` when the player is at their per-player cap, and **503** `world.capacity_reached` when the global cap is reached. See -[World capacity](configuration.md#world-capacity). The equivalent +[World capacity](configuration.md#instance-capacity). The equivalent `world.create` failures over WebSocket carry no code of their own - see the [WebSocket protocol](websocket-protocol.md). diff --git a/guides/websocket-protocol.md b/guides/websocket-protocol.md index 16843cf..0b2db9c 100644 --- a/guides/websocket-protocol.md +++ b/guides/websocket-protocol.md @@ -132,7 +132,7 @@ than the 32 field names the dictionary can index. The uplink is text-only on both wires. A binary frame sent to the server answers `error` with reason `binary_uplink_unsupported`. -See [Binary `world.tick`](#binary-worldtick) for the encoding. +See [Binary `world.tick`](#binary-world-tick) for the encoding. ### `session.heartbeat` @@ -279,7 +279,7 @@ The full match info, including the roster: `join_refused` carries the game's own reason string in `error.details.refused_reason` when the script gave one. It is game vocabulary, never an asobi code - see -[Refusing a join](lua-scripting.md#refusing-a-join). +[Refusing a join](lua-scripting.md#join-player_id-state-or-join-player_id-state-ctx). ```json {"type": "error", "cid": "j-1", "payload": {"reason": "join_refused", "error": {"code": "match.join_refused", "message": "The game refused this join. See `details.refused_reason`.", "details": {"refused_reason": "wrong_code"}}}} @@ -339,7 +339,7 @@ Send game input to the match server. {"type": "match.input", "payload": {"action": "move", "x": 10, "y": 5}} ``` -As with [`world.input`](#worldinput), the `payload` IS the input map. Two +As with [`world.input`](#world-input), the `payload` IS the input map. Two **deprecated** compatibility shapes survive here and will go at the next protocol break: a payload whose only key is `data` mapped to an object is unwrapped to that object, and one whose only key is `data` mapped to a JSON @@ -381,7 +381,7 @@ request that caused it when there was one: several common failures. On this page that covers the world capacity pair (`world_capacity_reached`, `player_world_limit_reached`, which REST answers as `world.capacity_reached` and `world.player_limit_reached`) and every - join-context rejection listed under [Join context](#join-context). Match a + join-context rejection listed under [Join context](#match-join). Match a reason string on `details.reason` for those, not a code. - `error.message` is prose for a human reading a log. Do not parse it. - `error.details` is **always** an object, `{}` when there is nothing to add. @@ -696,7 +696,7 @@ reason `invalid_payload`. It is not silently treated as empty input. For client-side prediction, add an optional `seq` *alongside* `payload` (a sibling, so "the payload IS the input map" stays true). The server echoes the -highest consumed `seq` back as a [`world.ack`](#worldack-server-push); see +highest consumed `seq` back as a [`world.ack`](#world-ack-server-push); see [Client-side prediction](#client-side-prediction). A `seq` that is not a non-negative integer below 2^53 is ignored. @@ -773,13 +773,13 @@ Two frames are applied **ungated**, without the sequence check: - A frame with no `frame_seq` at all, which is the removal list you get for the zone you are leaving. Gating it would leave you holding ghosts forever. -On a gap, send [`world.resync`](#worldresync) for that zone and you get a fresh +On a gap, send [`world.resync`](#world-resync) for that zone and you get a fresh keyframe. ### Binary `world.tick` A client that negotiated `"wire": "binary"` at -[`session.connect`](#choosing-a-wire) receives `world.tick` as a **WebSocket +[`session.connect`](#session-connect) receives `world.tick` as a **WebSocket binary frame** carrying the same information in about a quarter of the bytes, and materially cheaper to decode: measured against native JSON, 2.4x faster in Godot's GDScript and 33x faster than the pure-Lua parser Defold and LOVE ship. @@ -884,7 +884,7 @@ authoritative state already includes - is a first-class primitive: of `payload`) and applies the input locally right away (the prediction). 2. The server records the highest `seq` it consumed for that player - a rejected input still counts, so a dropped input never strands the client - and sends it - back on the next broadcast as a [`world.ack`](#worldack-server-push) + back on the next broadcast as a [`world.ack`](#world-ack-server-push) addressed to that connection alone. 3. The client discards every predicted input up to that `seq` and replays the rest on top of the authoritative `world.tick` state (the reconciliation). @@ -895,7 +895,7 @@ The ack is addressed to one connection and never rides the shared `world.tick`, so one player's input stream is never broadcast to the rest of the zone. It is sent to clients that opted in by stamping a `seq`, and to clients whose game module reports a consumed seq for them - see -[Batched input and the ack](#batched-input-and-the-ack), where numbering the +[Batched input and the ack](#client-side-prediction), where numbering the steps inside the payload replaces stamping the frame. **`seq` never goes backwards on a connection.** The high-water mark is recorded diff --git a/guides/world-server.md b/guides/world-server.md index c3ed9fa..d39d93f 100644 --- a/guides/world-server.md +++ b/guides/world-server.md @@ -205,12 +205,12 @@ end |---|---|---| | `init(config)` | yes | Return the initial global game state | | `join(player_id, state)` | yes | Player joined; return state | -| `join(player_id, state, ctx)` | no | Same, plus the client's join context. Declare the third parameter and it is used instead - see [Join context](websocket-protocol.md#join-context) | +| `join(player_id, state, ctx)` | no | Same, plus the client's join context. Declare the third parameter and it is used instead - see [Join context](websocket-protocol.md#match-join) | | `leave(player_id, state)` | yes | Player left; return state | | `spawn_position(player_id, state)` | yes | Return a `{x=N, y=N}` table | | `post_tick(tick, state)` | yes | Global tick logic. Set `_finished` and `_result` on state to end the world | | `zone_tick(entities, zone_state)` | no | Per-zone simulation; return both | -| `handle_input(player_id, input, entities)` | no | Apply one player's input to that zone's entities. A second return value is the client seq you consumed - see [Batched input and the ack](websocket-protocol.md#batched-input-and-the-ack) | +| `handle_input(player_id, input, entities)` | no | Apply one player's input to that zone's entities. A second return value is the client seq you consumed - see [Batched input and the ack](websocket-protocol.md#client-side-prediction) | | `generate_world(seed, config)` | no | Return a table keyed by `"x,y"` strings | | `get_state(player_id, state)` | no | Player-visible state | | `spawn_templates(config)` | no | See [Spawn templates](#spawn-templates) | @@ -321,7 +321,7 @@ post_tick(_TickN, State) -> | `leave/2` | yes | Player left the world | | `spawn_position/2` | yes | Return `{ok, {X, Y}}` for new player placement | | `zone_tick/2` | yes | Per-zone simulation: `(Entities, ZoneState) -> {Entities, ZoneState}` | -| `handle_input/3` | yes | Process player input within a zone's entities. Return `{ok, Entities, ConsumedSeq}` to ack what you *ran* rather than what arrived - see [Batched input and the ack](websocket-protocol.md#batched-input-and-the-ack) | +| `handle_input/3` | yes | Process player input within a zone's entities. Return `{ok, Entities, ConsumedSeq}` to ack what you *ran* rather than what arrived - see [Batched input and the ack](websocket-protocol.md#client-side-prediction) | | `post_tick/2` | yes | Global post-tick: return `{ok, State}`, `{vote, Config, State}`, or `{finished, Result, State}` | | `generate_world/2` | no | Procedural generation: `(Seed, Config) -> {ok, #{Coords => ZoneState}}` | | `get_state/2` | no | Per-player state view | diff --git a/scripts/check-doc-anchors.sh b/scripts/check-doc-anchors.sh new file mode 100755 index 0000000..be682f6 --- /dev/null +++ b/scripts/check-doc-anchors.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Fails if a guide links to an anchor that ex_doc does not generate. +# +# scripts/check-doc-anchors.sh +# +# Twenty-three of these accumulated unnoticed. Two ways they get in: +# +# 1. A heading is renamed and inbound links are not swept. `## World capacity` +# became `## Instance capacity` and four guides kept pointing at the old id. +# 2. The slug is guessed. Wire frames carry a dot, and ex_doc hyphenates it - +# `world.tick` is `#world-tick`, not `#worldtick`. Six links dropped it. +# +# Neither breaks a build or 404s. The link resolves to the page and silently +# lands the reader at the top of it, which is why these survive for months. +# +# ex_doc only emits ids for h2 and h3 in extras. An h4 heading exists in the +# rendered page but cannot be linked, so a link to one is reported with that +# explanation rather than as a typo - the fix there is to link the parent +# section, not to invent an anchor. +# +# The slugifier below was validated against `rebar3 ex_doc` output: it +# reproduces 703 of the 719 ids in the built HTML, and every one it misses is an +# h4, which has no id to reproduce. Kept in step with ex_doc by that check +# rather than by reading its source. +set -euo pipefail + +cd "$(dirname "$0")/.." + +python3 - <<'PY' +import glob +import os +import re +import sys + +# Same transformation ex_doc applies to extras headings: strip inline markup, +# lowercase, collapse anything that is not a letter, digit or underscore into a +# single hyphen. Underscores survive (`world.phase_changed` is +# `#world-phase_changed-server-push`). +def slug(text): + text = re.sub(r'`([^`]*)`', r'\1', text) + text = re.sub(r'\*\*?([^*]*)\*\*?', r'\1', text) + text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text) + return re.sub(r'[^a-z0-9_]+', '-', text.strip().lower()).strip('-') + + +def page_name(path): + base = os.path.basename(path)[:-3] + return 'readme' if base.upper() == 'README' else base + + +docs = sorted(glob.glob('guides/*.md')) + ['README.md'] + +# id -> heading level, per page. Level matters: h4 is a real heading with no id. +headings = {} +seen = {} +for path in docs: + page = page_name(path) + headings[page] = {} + seen[page] = {} + in_fence = False + for line in open(path, encoding='utf-8'): + if line.startswith('```'): + in_fence = not in_fence + continue + if in_fence: + continue + m = re.match(r'^(#{2,6})\s+(.*?)\s*$', line) + if not m: + continue + # ex_doc suffixes repeats of a slug, counting the repeats rather than + # the occurrences: three `### Configuration` on a page give + # `#configuration`, `#configuration-1`, `#configuration-2`. Dedup is by + # slug across the whole page, so an h2 and an h3 with the same text + # collide too. Model it, or every duplicated heading reads as broken. + base = slug(m.group(2)) + seen[page][base] = seen[page].get(base, 0) + 1 + n = seen[page][base] + headings[page][base if n == 1 else f"{base}-{n - 1}"] = len(m.group(1)) + +LINKABLE = (2, 3) +broken = [] +checked = 0 + +for path in docs: + for m in re.finditer(r'\]\(([^)\s]*\.md)?#([A-Za-z0-9_-]+)\)', open(path, encoding='utf-8').read()): + target, frag = m.group(1), m.group(2) + page = page_name(target) if target else page_name(path) + checked += 1 + if page not in headings: + broken.append((path, f"{target or ''}#{frag}", f"no guide named {page}.md")) + continue + level = headings[page].get(frag) + if level in LINKABLE: + continue + if level is not None: + broken.append((path, f"{target or ''}#{frag}", + f"that heading is h{level}; ex_doc only gives h2/h3 an id - link its parent section")) + continue + near = [h for h, lv in headings[page].items() + if lv in LINKABLE and h.replace('-', '') == frag.replace('-', '')] + broken.append((path, f"{target or ''}#{frag}", + f"did you mean #{near[0]}" if near else "no such heading")) + +for path, link, why in broken: + print(f" {path}: {link}\n {why}", file=sys.stderr) + +if broken: + print(f"\n{len(broken)} broken anchor link(s) of {checked} checked.", file=sys.stderr) + print("A broken anchor does not 404 - it drops the reader at the top of the " + "page - so nothing else will catch this.", file=sys.stderr) + sys.exit(1) + +print(f"doc anchors: {checked} links checked, all resolve") +PY