The specification the other three repositories are held against, plus the phase record. `PROTOCOL.md` §8 is the new contract. Its centre is one field: every frame now carries `type` — `event`, `snapshot`, `reply`, `control` — and the sidecar files on that and nothing else. That is the dumb-forwarder property made structural rather than intended: ten new event kinds are zero change in Rust-Link, and only a version adding an indexed column touches it at all. Also in §8: the fifteen-kind catalogue and what each frame carries; `wipeId` derived by the plugin, which REVERSES §3.2's "deriving one is the website's job" and says why; boards re-sent on connect and on a cadence; the aggregate rule (a hook that can fire more than once a second per player is a counter, not an event); the void rule that stops a read-path hook vetoing a death or a login; and `GET /feed`, a cursor route separate from `/events` because one route with two orderings serves the wrong one to every caller that forgets the parameter. §8.5 is the part to read twice. The classification of a kind as public or staff is NOT on the wire, deliberately: a boundary declared by the sender is one a compromised or out-of-date game host can widen, so the module holds a default-deny allowlist and this table is what its test holds it against. §8.8 corrects a catalogue rather than a defect: PLAN.md §10 sources `rust.login.denied` from `CanUserLogin`, and that hook fires on every attempt — the only way to learn of a denial from it is to be the denier. A denial is the absence of an approval, and protocol 2 emits both facts so phase 10 can pair them. `PLAYER_WALK.md` is new, and it exists because half this catalogue cannot fire without somebody holding a mouse. Ten steps, what each one should produce, and what counts as a pass — written so the walk can be run without watching the output live, and so the answer afterwards is readable as a transcript. PLAN.md §16 is phase 3 as built: the four decisions, the two defects only a server that BOOTED with the plugin could find (a wipe id that was null for every real session, and a two-second main-thread stall on unload), what was proven and how, and — stated plainly rather than implied — the three measurements still queued on the org lead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
101 lines
5.9 KiB
Markdown
101 lines
5.9 KiB
Markdown
# The player walk — proving the half of the read path a console cannot reach
|
|
|
|
Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about
|
|
whether a hook can fire without somebody holding a mouse.
|
|
|
|
Everything in the first half was proven from a console and a REST client while phase 3 was built —
|
|
the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real
|
|
player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and
|
|
none of those three can be manufactured from a console without becoming a different test.
|
|
|
|
This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on
|
|
Carbon, and it is written so that the answer is readable afterwards rather than watched live.
|
|
|
|
---
|
|
|
|
## Before you start
|
|
|
|
1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic
|
|
Gateway*, and `rg.link` answers `connected=True`.
|
|
2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the
|
|
end readable as a transcript of the walk and nothing else.
|
|
3. The sidecar's token to hand, for the reads at the bottom.
|
|
|
|
Run this once, before you join:
|
|
|
|
```
|
|
rg.hooks
|
|
```
|
|
|
|
Every player hook should read **silent**. That is the baseline: the point of the walk is to move
|
|
them, and starting from a run where some already fired proves less.
|
|
|
|
---
|
|
|
|
## The walk
|
|
|
|
Do these in order. The order matters only in two places, noted where it does.
|
|
|
|
| # | Do this | Fires | The frame should carry |
|
|
|---|---|---|---|
|
|
| 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name |
|
|
| 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure |
|
|
| 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it |
|
|
| 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug |
|
|
| 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry |
|
|
| 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that |
|
|
| 7 | **Kill yourself** — `kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` |
|
|
| 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from |
|
|
| 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it |
|
|
| 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it |
|
|
|
|
Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the
|
|
cadence flush rather than the disconnect flush, and step 1's three frames are the only place the
|
|
order between hooks is itself part of the answer.
|
|
|
|
---
|
|
|
|
## Reading the result
|
|
|
|
From the machine running the sidecar:
|
|
|
|
```bash
|
|
TOKEN=… # [web].auth_token from sidecar.toml, or `--print-config`
|
|
BASE=http://127.0.0.1:8090
|
|
|
|
# The whole walk, oldest first, as a transcript.
|
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \
|
|
| python -m json.tool
|
|
|
|
# Or one kind at a time.
|
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20"
|
|
```
|
|
|
|
And from the game console:
|
|
|
|
```
|
|
rg.hooks
|
|
```
|
|
|
|
Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step
|
|
that should have fired it is the finding — and on Carbon it is the specific question
|
|
[`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names
|
|
and nobody has yet checked whether they are renames or holes.
|
|
|
|
---
|
|
|
|
## What counts as a pass
|
|
|
|
Not "frames arrived". Three things, and the third is the one worth slowing down for:
|
|
|
|
1. **Every hook in the table fired**, on both frameworks, from the same plugin file.
|
|
2. **Every frame carries the envelope** — `type`, `serverId` and `wipeId` on all of them
|
|
([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a
|
|
wipe and its rollup is lost.
|
|
3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes
|
|
of chopping is two frames that sum to the total, not two frames each carrying the total. Getting
|
|
this backwards makes every leaderboard roughly double, and it looks correct until somebody counts.
|
|
|
|
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
|
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|