From cb8e8853f6ef4b7795d95e47616b31192af604da Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 13:41:01 -0500 Subject: [PATCH] =?UTF-8?q?docs(rust):=20phase=2010=20as=20built=20?= =?UTF-8?q?=E2=80=94=20protocol=207=20and=20the=20raid=20alert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md §25.5-25.8: what shipped, the walk that met the criterion on the Oxide rig, the four things the rig found (every generic notice never said which server; attackerId null for a spawned player; a disband roster gone before the disband was read; in-app `subscribers` being everyone), and what is not proven. PROTOCOL.md §13 specifies protocol 7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- modules/rust/PLAN.md | 112 +++++++++++++++++++++++++++++++++++++++++- rust-link/PROTOCOL.md | 61 ++++++++++++++++++++++- 2 files changed, 170 insertions(+), 3 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index f08ec22..1de93e5 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -996,7 +996,7 @@ Each phase ends with its findings written down, as every workstream here does. | 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `/x.json` is found and reloads the right plugin | | 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app | | 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* ✅ **Built 2026-09-23 — plan, as built and findings in §24** (D47–D58). Walked on the Oxide rig end to end and on Carbon hook by hook; the game's 100-clan ceiling accepted (D55); **one module per site** became a core rule on the way (§24.5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly | -| 10 | **Notifications and engagement** (R7). 🚧 **Plan of record in §25 (D59–D68), 2026-09-23.** Streams, triggers with `ceiling` and `subjectKey`, audiences and engagement seeds — **the catalogue is §10, as corrected by §25.2**. The announce leg and the post hook are **not** in it (D62 amends R7), and protocol 7 widens the raid frame, which brings the two bridge repositories in | all 3 + docs | The offline raid alert reaches the player whose base it was, and nobody else | +| 10 | **Notifications and engagement** (R7). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §25 (D59–D68).** The criterion walked on the Oxide rig (two authorised owners alerted once each; bystander, raider and an unlinked owner told nothing), and the same plugin loaded on Carbon; a live walk found that every generic notice never said which server (§25.6). Streams, triggers with `ceiling` and `subjectKey`, audiences and engagement seeds — **the catalogue is §10, as corrected by §25.2**. The announce leg and the post hook are **not** in it (D62 amends R7), and protocol 7 widens the raid frame, which brings the two bridge repositories in | all 3 + docs | The offline raid alert reaches the player whose base it was, and nobody else | | 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there | | 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking | | 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it; a wipe reconciles the ledger instead of stranding it | @@ -3781,6 +3781,116 @@ helper that authorises synthetic Steam ids on a real cupboard and destroys a rea spawned, non-NPC player as the initiator, so the real `OnEntityDeath` path runs. If the helper cannot make the hook see a real player, that is written down as a walk still to run, not claimed. +### 25.5 As built, 2026-09-23 + +**Rust-Plugins (protocol 7).** `entity.destroyed` covers the four structure kinds and carries +`structure`, `buildingId` and `authorized` (PROTOCOL.md §13). `clan.disbanded` carries `members` +(§25.6). `attackerId` comes from `userID` (§25.6). The `structures` tally still counts building +blocks only. + +**Rust-Link.** `PROTOCOL_VERSION` 7, and no other change. + +**Module-Rust.** `server/engagement/` holds five files: + +- **The declarations:** `triggers.js`, `streams.js`, `audiences.js` and `seeds.js`. That is 13 + triggers, 4 streams, 3 audiences, 4 bodies (raid and wipe, email and in-app) and 13 disabled rules + in 7 groups. +- **The fan-out:** `emit.js`. + +It is wired in three places: + +- ingest calls `onEvent` for each stored frame, **before** it is applied, and `checkLeader` after a + batch; +- the refresh calls `serverObserved`; +- a new one-minute `sweep` timer runs the login-denied query. + +The link confirm route calls `linked` on a **new** link only. + +The refresh asks `/health` beside `/boards` (D68). `putState` gained a `seen` flag, so a stale board +no longer moves `last_seen_at`. `engagement-triggers.json` is the committed freeze of all of it, +checked in CI by `check:engagement` with line endings normalised. The check was verified by breaking +it both ways: a changed label fails it; a CRLF-only change passes. 234 server tests and 41 client +tests are green. + +**The walk.** It ran against real core on `main` (MODULE_API 1.10.0) with a fresh database, the +protocol-7 sidecar inside the Oxide rig's container, and a rig-only helper (`RgRaidRig.cs`, never +shipped). The helper builds a real cupboard and door sharing a building id, authorises synthetic +Steam ids on the cupboard, and kills the target with a spawned, non-NPC player as the initiator, so +the real `OnEntityDeath` path runs. + +- **Core accepted the whole set at boot:** 13 triggers, 4 streams, 3 audiences, 4 bodies and 13 + rules in 7 groups, every rule disabled. +- **The criterion.** A door was raided in a base whose cupboard authorised owner1 and owner2 + (both linked) and one unlinked id. The bystander was linked and not authorised; the raider was + linked. owner1 and owner2 each got **one** in-app alert: "Your base is being raided — A door was + destroyed in S16 on Oxide rig." The bystander, the raider and the unlinked id got nothing. Email + and push stayed quiet, because both default off until a person opts in. +- **Self-demolish** (the attacker authorised on the cupboard): no alert. +- **The cupboard itself destroyed:** owner1 was alerted, "A tool cupboard was destroyed in R3". +- **A door with no cupboard (D67):** the frame arrived without `buildingId`/`authorized`; no alert. +- **D68.** `oxide.unload RunicGateway` with the sidecar still up: within one refresh the module + read the server offline, with 0 players and `last_seen` frozen. Loading the plugin back raised + `rust.server.online`. The pending `rust.server.offline` notices, delayed five minutes, were + **cancelled** by it (`cancel_on`), twice. +- **Carbon 2.0.259:** the same plugin file compiled and loaded at protocol 7. The helper's door and + cupboard raids dispatched `OnEntityDeath` to the bridge (`rg.hooks`), and the new cupboard + lookup ran without an exception. + +### 25.6 What the rig found that the plan did not say + +- **Every generic notice said "A server came online", never which one.** Core's structural + projection fills `title` and `intro` from a trigger's label and description when the payload has + none. On a multi-server site that is a notice nobody can act on. Core's rule is "the payload wins, + the projection fills gaps", so every trigger now declares `title` and `intro`, and the emitter + writes the sentence. The walk's next transition read "Oxide rig is online — Oxide rig is back up + and talking to the website." No test could have seen this: every test asserted the payload, and + the defect was in what core did with a payload that was right. +- **`attackerId` arrived `null`.** `UserIDString` is set only in `PlayerInit` (a connection), `Load` + (a sleeper) and for engine bots, so a player another plugin spawns has none. Every real raider + has one, so this is the rig's artefact. But D59's self-demolish exclusion rests on the field, so + the plugin now derives it from `userID`, which is right for every player. +- **A disband's roster can be gone before the disband is read.** `markGone` deletes the members, + and the `clans` board is re-sent seconds after the event, so the refresh can apply it before + ingest reaches the frame. After an outage it always does. `clan.disbanded` now carries the roster + (PROTOCOL.md §13.2): the game walks `Members` to drop each membership and never empties the list. + The fan-out runs before a frame is applied as well, for the left and kicked cases. +- **`subscribers` is everyone, for in-app.** In-app defaults to `instant`, and core's `subscribers` + counts every active user with no in-app preference row as subscribed. So the walk's + "server online" reached all five users, not only walkadmin, who had opted in. That is core's + semantics, and `module-uo`'s broadcasts inherit it too. It is recorded rather than worked around: + an operator enabling a seeded broadcast rule reaches every inbox. +- **The dedupe key is made from the event, not the sidecar's row id**, which refines D63's + wording. A sidecar whose database is replaced restarts its ids, and a key built on them would + swallow every new alert as a repeat of an old one. + +### 25.7 What is not proven here + +- **A real raid by a real player.** The rig has one Steam account (§12.5). The helper runs the real + hook with a spawned initiator, which is as close as a player-less rig gets. +- **The frame's contents on Carbon.** The Carbon rig has no sidecar in its container (phase 8 wired + only the Oxide one), so on Carbon the hook dispatch and the absence of an exception are proven, + but the frame was not read back. The code after the hook is game API, the same assembly on both + frameworks. +- **Email and push delivery.** Both default off. The walk proved the rule reaches the right people + in-app. Push needs a subscribed device, and phase 11 (Android leg C) is where a phone subscribes + to a Rust stream. +- **The clan, moderation, wipe, leaderboard and login-denied triggers on the rig.** The suite covers + each one's audience and the bounds on it. The walk exercised the raid alert (the criterion) and + online/offline (D68). + +### 25.8 Smaller things worth keeping + +- **A running sidecar binary cannot be overwritten** (the node answers `500`, "text file busy"). A + rename can replace it: upload as `.new`, move the old one aside, then move the new one in. +- **Every panel client call needs `Accept: application/json`.** Without it the panel answers `302` to + its login page, and a binary upload "succeeds" with the old file still in place. +- **The walk found a latent schema defect outside this phase** and did not fix it. It is raised with + the org lead separately: tables since phase 6 declare `DEFAULT CHARSET=utf8mb4` with no + collation, while the older ones inherit the database default, so the VARCHAR foreign keys only + form when the database's default collation happens to be `utf8mb4_uca1400_ai_ci`. +- A Python heredoc halved a backslash in a JavaScript string again; the Edit tool is the safe path + for anything with an apostrophe in it. + --- [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index bbb0b85..7bc40e3 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -50,7 +50,7 @@ it is listening without one. ## 2. Versioning -The wire version is a single integer — **6** as of first-party clans (§12) — declared in +The wire version is a single integer — **7** as of the raid frame (§13) — declared in **four** places that must agree: | Where | Repo | @@ -337,6 +337,7 @@ arrives with the phase that needs it, and each is a version bump: - ~~the permission mirror (phase 7)~~ — **protocol 4, §10** - ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11** - ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12** +- ~~who lives in a raided base, for the raid alert (phase 10)~~ — **protocol 7, §13** - leases, budgets and the event actions (phases 12-13) - the map image over the asset-bridge shape (phase 14) @@ -451,7 +452,7 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field | `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid | | `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message | | `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures | -| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker | +| `entity.destroyed` | `OnEntityDeath` on owned building blocks — **and doors, external walls and the cupboard from protocol 7 (§13)** | **staff** | ownerId, prefab, grid, attacker; from protocol 7 also `structure`, `buildingId`, `authorized` | | `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type | | `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason | | `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** | @@ -1196,3 +1197,59 @@ matches a call to a method by its argument types, so neither call reaches it. the bridge's own `rg.hooks` count for `OnClanDisbanded` stayed at the one real disband, with nothing logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the game's. + +## 13. Protocol 7 — the raid frame names who lives there + +Added in phase 10 ([`PLAN.md`](../modules/rust/PLAN.md) §25). The raid alert goes to the people whose +base it was (D59), and protocol 2's `entity.destroyed` could not say who that is: it named the +block's **placer** (`ownerId`), which is not the base's owner in any sense a Rust player recognises, +and it fired only for `BuildingBlock` — which a door is not. **No new kind and no new route;** one +frame widens, and `clan.disbanded` gains its roster. + +### 13.1 `entity.destroyed`, widened + +It now fires, still only when a real (non-NPC) player did it and `OwnerID` is non-zero, for four +kinds of entity. The kind travels as `structure`: + +| `structure` | Game type | Notes | +|---|---|---| +| `block` | `BuildingBlock` | as before; the only kind the `structures` tally counts, so that column keeps its meaning | +| `door` | `Door` (an `AnimatedBuildingBlock`, a *sibling* of `BuildingBlock`) | external gates are doors too | +| `wall` | `SimpleBuildingBlock` | external walls | +| `cupboard` | `BuildingPrivlidge` | the tool cupboard itself; `OnEntityDeath` runs before the kill, so it still reports its own list | + +Two fields are added when the entity resolves to a cupboard (`DecayEntity.GetBuildingPrivilege()`, +which goes through the building, or the cupboard itself): + +| Field | Meaning | +|---|---| +| `buildingId` | the cupboard's network id, as a string — the base's identity, and the raid alert's cooldown subject | +| `authorized` | `[{ steamId, online }]` from the cupboard's `authorizedPlayers`, **bounded at 64**; `authorizedTruncated: true` when cut | + +**Both are ABSENT when there is no cupboard**, which is a different answer from an empty list, and +the website alerts nobody in that case (D67). `recentGroupMembers` — which also sits on the cupboard +— is **not** authorisation: it counts code-lock users toward group upkeep, and it is not sent. + +`attackerId` is now derived from the player's `userID` rather than `UserIDString`, which the game +fills in only for a connected player, a loaded sleeper or an engine bot. The website skips the alert +when the attacker is on the cupboard (a self-demolish, a teammate), and a null would defeat that. + +The class is unchanged: **staff**. A structure's grid is where somebody lives, and the frame now also +names who. It reaches a player only through the raid alert, which is ceilinged `owner` and sent one +person at a time. + +### 13.2 `clan.disbanded` carries `members` + +The Steam ids of the clan it ended. The website tells a disbanded clan's members, and by the time it +reads the frame the next `clans` board may already have removed the roster from its store — the board +is re-sent seconds after the event, and after an outage it is applied before the backlog. The game +deletes the clan and walks `Members` to drop each membership, but never empties the list, so it is +whole when the hook fires. Bounded by the clan's own member limit. + +### 13.3 The sidecar + +`PROTOCOL_VERSION` becomes 7 and nothing else changes: both frames are `event`s, stored and served as +they arrive (§8.1). The bump exists because a website that alerts on `authorized` must not pair with +a protocol-6 plugin that never sends it — against one it would read every raid as a base with no +cupboard and alert nobody while looking healthy. +