docs(rust): phase 10 — notifications and engagement (PLAN.md §25, PROTOCOL.md §13) #265

Merged
whitlocktech merged 2 commits from docs/rust-phase-10 into main 2026-09-23 18:43:16 +00:00
2 changed files with 347 additions and 3 deletions

View File

@@ -326,6 +326,10 @@ that this phase lives or dies on:
And the emit discipline: **emit on the transition, not on the poll.** Core's cooldown would hide a
module that emitted "the server is still up" as news.
> **Amended 2026-09-23 by D62 (§25.0).** The announce leg moves to phase 13 beside `rust.announce`,
> which needs the same in-game chat verb; the post hook is dropped until something in game can hold a
> post; PopupNotifications moves to phase 17. The other four registrations ship in phase 10.
### R8 — multi-server from the start
**Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs
@@ -992,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 `<Mod>/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). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + 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 |
@@ -1257,6 +1261,10 @@ which is exactly why R16 could change what it grants without touching anything o
## 10. The engagement catalogue — what Rust can expose
> **Corrected by §25 (2026-09-23).** The table below is the catalogue as first planned. The one that
> shipped is §25.2: `self` is not a ceiling core has, `rust.clan.member.added` duplicates core's own
> Team trigger, `rust.kit.entitled` waits for phase 13, and the two player audiences were one set.
Added 2026-09-15, answering "check the default alerts Rust can expose". R7 settled that the set
ships; this is what goes in it.
@@ -3604,6 +3612,285 @@ alone.
- `ListTopClans` returns a pooled list that the plugin does not return to the pool. The GC collects
it; it is noted here so nobody "fixes" it into a double free.
## 25. Phase 10 — notifications and engagement (plan of record, 2026-09-23)
R7's phase: the module says what happened in a Rust server in terms core's engagement engine can
act on — **triggers** (what an event is and the widest audience it may ever have), **audiences**
(named sets of people over this module's data), **seeds** (the bodies and the disabled rules that
use them) and **push streams** (the facet that lets a rule reach a phone). Nothing here decides who
is told: the module says what happened and who it is *about*, and core applies the rule, the
ceiling, the preference, the suppression list and the verification gate.
Three repositories and this document. The criterion — *the offline raid alert reaches the player
whose base it was, and nobody else* — could not be met on the wire as it stood, which is why
[Rust-Plugins][rp] and [Rust-Link][rl] are in a phase the table listed as `Module-Rust + docs`
(§25.1). All three into `edge`.
### 25.0 The decisions this phase needed
All from the org lead, 2026-09-23.
| # | Decision |
|---|---|
| **D59** | **"The player whose base it was" means everyone authorised on the building's tool cupboard.** Each linked one is sent their own alert — one emit per person, `ownerUserId` set, so the ceiling stays `owner` and "nobody else" holds per emit. The block's placer (`OwnerID`) is not the base's owner in any sense a Rust player would recognise: a base several clan members built alerts only whoever placed that wall, and a block placed by someone since kicked alerts *them*. An attacker who is authorised on the cupboard (a self-demolish, a teammate) raises no alert. |
| **D60** | **A raid is the destruction of a building block, a door, an external wall or gate, or the tool cupboard itself.** Today's emitter sees building blocks only, and a door is not one (§25.1), so the most common raid — two charges on a door — alerted nobody. |
| **D61** | **The alert fires whether or not the owner is online, and carries `ownerOnline`.** The seeded rule narrows with the condition `ownerOnline is false`, so "offline raid alert" is a rule an operator can widen by editing a condition rather than a code change. `ownerOnline` is the *recipient's* own state, since each emit has one recipient (D59). |
| **D62** | **Nothing reaches the game in this phase — which amends R7.** No announce leg and no post hook: R7 put both in v1, but the leg needs a chat verb the plugin does not have, and the hook would have nothing on Rust to mirror a post *into* (`module-uo` registers none either). The announce leg moves to phase 13, beside `rust.announce`, which needs the same verb; the post hook is dropped until something in game can hold a post; PopupNotifications moves to phase 17's optional tier. |
| **D63** | **A replayed event notifies only while it is still news, and the bound is per family.** After an outage the cursor (D9) replays hours of frames. A **broadcast** (server online/offline, wipe, leaderboard) older than **15 minutes** notifies nobody — "the server is up" is false three hours later. A **personal or staff** event (raid, clan, moderation) is kept for **24 hours** — "your base was raided at 03:10" is still true and still wanted. Every emit carries its true `occurredAt`, and a `dedupeKey` built from the server and the sidecar's own event id, so a replay of the same batch is a no-op in core's outbox. |
| **D64** | **Four changes to §10's catalogue.** `rust.clan.member.added` is **dropped** — core already fires `team.member.joined` (and `team.leadership.changed`) for our clans through phase 9's Team sync, so a second trigger double-notifies. `rust.kit.entitled` is **deferred to phase 13**, where the grant that emits it lands. `rust.login.denied` is **a deferred read** (§16.5): an attempt with no approval within 60 seconds, staff ceiling, **no address in the payload**. `rust.leaderboard.topped` is **the kills leader of the current wipe changing to somebody new**, never on first sight, once per server per leader. |
| **D65** | **Three families get a push-stream facet: the raid alert, server online/offline, and wipe started.** Core delivers engagement push only to devices subscribed to a stream whose id *is* the trigger id (§25.1), so a trigger without the facet can never reach a phone however its rule is set. Clan and moderation stay email and in-app. |
| **D66** | **The raid alert never names the raider.** The payload carries what was destroyed, the grid and the time. A raider's identity is gameplay intelligence the game itself does not hand the victim, and the org lead's lowest-blast-area rule (§23) applies: a variable that is not declared cannot be interpolated by any template an operator writes. |
| **D67** | **A structure with no tool cupboard alerts nobody.** No placer fallback: a cupboard-less structure is usually decaying or abandoned, and D59's whole point is that the placer is not the owner. |
| **D68** | **"Online" means the plugin is connected now, and that fixes the public page too.** Folded into this phase (§25.3). |
### 25.1 Facts the phase rests on
**From the game** (`Assembly-CSharp.dll` off the rig, decompiled — the build phase 9 pulled):
- **A door is not a `BuildingBlock`.** `Door : AnimatedBuildingBlock : StabilityEntity`, a sibling of
`BuildingBlock : StabilityEntity`, so `entity as BuildingBlock` — the protocol-2 emitter's filter —
has never seen a door. External walls are `SimpleBuildingBlock : StabilityEntity`; external gates
are `Door`s. The cupboard is `BuildingPrivlidge : StorageContainer`.
- **`authorizedPlayers` is a `HashSet<ulong>`** on current Rust (it was a `List<PlayerNameID>` in
older builds, which is what most public raid-alert plugins still read). **`recentGroupMembers` is
not authorisation**: it counts code-lock users toward group upkeep and prunes on a timer, and it
must not be read as "lives here".
- **`DecayEntity.GetBuildingPrivilege()`** resolves through the entity's building
(`GetDominatingBuildingPrivilege`). An entity with no building falls back to `BaseEntity`'s, which
is a `Vis.Entities` sphere query on the main thread. That is acceptable at the rate structures are
destroyed — tens per raid, not per frame — and it is the reason the lookup happens only after the
cheap filters (a real player did it; the entity is one of D60's four).
- **`OnEntityDeath` runs before the entity is destroyed**, so a cupboard being destroyed can still
report its own authorisation list.
**From core** (`website`, `main`):
- **There is no `self` ceiling.** The lattice is `everyone`, `authenticated`, `subscribers`,
`members`, `staff`, `admin`, `owner`. §10 gave `rust.player.linked` and `rust.kit.entitled` a
`self` ceiling core would refuse at boot; `owner` with the user as `ownerUserId` is the value that
exists and means the same thing here.
- **Engagement push reaches a device only through a stream of the same id.** `pushChannel.deliver`
calls `publishToUsers(row.trigger_id, …)`, which selects endpoints subscribed to *that stream*.
Hence D65.
- **The cooldown is per (rule, user, subject, channel).** The raid alert's subject is therefore the
building (its cupboard's id): one alert per base per cooldown, however many walls fall.
- **`dedupeKey` is UNIQUE in the outbox**, so an emit replayed with the same key is a no-op. Core does
**not** bound a stale `occurredAt`; that is D63, on this side.
- **Core already notifies Team members on `team.member.joined` and `team.leadership.changed`.** Hence
D64's first change.
- **`MODULE_API.md` says a module ships its own `engagement-triggers.json`**; core never reads it.
It is a freeze whose diff is the review signal, like `routes.manifest.json`. `module-uo` ships
none. This module ships one, gated in CI the way `routeManifest` is (line endings normalised —
see the engagement workstream's CRLF lesson), and phase 19 carries the gap back to the kit.
**From this module:**
- **The sidecar keeps its last `server.hello` board after the game disconnects**, and the refresh
reads only `/boards`. So a game that hangs, or whose bridge plugin is unloaded, while the sidecar
stays up has read as *online* on the public page since phase 4. In the in-container deployment
(§22, R20) a crash takes the sidecar with it and the page is right; a hang or an unload is not.
`/health` already answers "is the plugin connected" and nothing asked it. D68.
### 25.2 The catalogue as built
**Triggers.** Every one declares `subjectKey`, and every variable an `example`.
| Trigger | Source | `ceiling` / default audience | Subject | Push facet |
|---|---|---|---|---|
| `rust.base.destroyed` | `entity.destroyed` (protocol 7), one emit per linked authorised user | `owner` / `owner` | `building` | yes — personal, needs a linked account |
| `rust.wipe.started` | `server.wipe` | `everyone` / `subscribers` | `serverId` | yes |
| `rust.server.online` | the refresh, transition to connected (D68) | `everyone` / `subscribers` | `serverId` | yes |
| `rust.server.offline` | the refresh, transition away from connected | `everyone` / `subscribers` | `serverId` | yes |
| `rust.leaderboard.topped` | the wipe's kills leader changes (D64) | `everyone` / `subscribers` | `serverId` | — |
| `rust.player.linked` | the link confirm route (R1) | `owner` / `owner` | `steamId` | — |
| `rust.clan.member.left` / `.kicked` | `clan.member.left` / `.kicked` | `members` / `members` | `clan` | — |
| `rust.clan.disbanded` | `clan.disbanded` | `members` / `members` | `clan` | — |
| `rust.player.reported` | `player.reported` | `staff` / `staff` | `steamId` (the target) | — |
| `rust.player.banned` / `.unbanned` | `player.banned` / `.unbanned` | `staff` / `staff` | `steamId` | — |
| `rust.login.denied` | a `player.login.attempt` with no `player.approved` within 60 s (D64) | `staff` / `staff` | `steamId` | — |
A clan trigger's recipients are the clan's linked members at the moment of the event, carried as
`recipientUserIds` (the mechanism `module-uo` and core's Team fan-out use), because "the clan this
event was about" differs every firing and cannot be a saved audience. For a disband that is the
roster *before* the disband is applied.
**No variable carries an IP address, a Steam id other than the subject's own, or — for the raid
alert — the attacker (D66).** A ban's `reason` and a report's text are staff-ceilinged.
**Audiences** — user ids and nothing else; one that fails resolves to nobody (§10, §11.5).
| Audience | Resolves to | `ceiling` |
|---|---|---|
| `rust.clan.members` | a clan's linked members (param: the clan's `externalId`) | `members` |
| `rust.server.players` | linked accounts ever seen on a server (param: `serverId`) | `authenticated` |
| `rust.wipe.participants` | linked accounts seen on a server's **current** wipe (param: `serverId`) | `authenticated` |
§10 described the second and third as the same set ("seen this wipe" and "played the current wipe");
they are separated as all-time against this wipe, which is the distinction an operator can use.
**Seeds.** Every rule disabled (core enforces it). One **group key per family** — `raid-v1`,
`wipe-v1`, `server-v1`, `leaderboard-v1`, `account-v1`, `clans-v1`, `moderation-v1` — so a rule
added to a family later can take a new key without the others. Bespoke bodies only where the message
has something to say that core's structural projection cannot: **the raid alert** and **the wipe**,
email and in-app each. Everything else points at core's `notify.event` / `inapp.event`.
The raid rule: audience `owner`, `ownerOnline is false`, email + in-app + push, cooldown 30 minutes
per building. The offline rule: `delay_seconds` 300 with `cancel_on: ['rust.server.online']`, so a
plugin reload or a quick restart mails nobody.
### 25.3 What the plan builds
**The plugin (protocol 7).** `entity.destroyed` widens to D60's four entity kinds and gains
`buildingId` (the cupboard's net id) and `authorized` — each `{ steamId, online }`, **bounded at 64**
with `authorizedTruncated` when cut — both absent when there is no cupboard (D67). `ownerId`, `prefab`,
`grid` and the attacker fields stay as they were: the staff feed still reads them, and the module
decides what an alert may carry. `overlay.toml`'s protocol moves to 7.
**The sidecar.** `PROTOCOL_VERSION` becomes 7. The frame is stored and served as it arrives (§8.1),
so nothing else changes.
**The module.**
- **`server/engagement/`** — `triggers.js`, `audiences.js`, `streams.js`, `seeds.js` (the
declarations, one file each) and `emit.js`, the fan-out: one function per trigger, each wrapped so
a bad frame cannot stop ingest, each applying D63's bound before it emits. Registered from
`index.js`, which drops the comment calling these "deliberately absent".
- **Ingest** calls the fan-out after an event is written, inside the same per-item guard.
- **The refresh** asks `/health` beside `/boards` and records `connected`; `online` becomes
"the plugin is connected now" (D68). Transitions are tracked in memory, and a first sight is never
a transition, so a website restart announces nothing.
- **The leaderboard** leader is read after each ingest tick from the wipe's stats, with the same
first-sight rule.
- **The login-denied sweep** runs on the prune cadence as a query over `rust_events` — attempts
older than 60 s and younger than D63's 24 h with no approval for the same Steam id after them —
keyed on the attempt's event id. A query rather than an in-memory timer, so a restart loses
nothing and re-running it is a no-op.
- **`engagement-triggers.json`**, generated from the registrations and checked in CI.
**The docs.** This section as built, `rust-link/PROTOCOL.md` §13 (protocol 7), R7's amendment, §10's
corrections, and the phase row.
### 25.4 Done when
The row's criterion: **the offline raid alert reaches the player whose base it was, and nobody
else.** Walked on the Oxide rig with a cupboard authorising two linked accounts, a third linked
account that is not authorised, and a raid made by a fourth. Both authorised accounts get one alert
each. The third gets nothing, and so does the raider. Then the same plugin file runs on Carbon.
The rig has one Steam account, not four (§12.5), so the walk uses phase 9's method: a rig-only
helper that authorises synthetic Steam ids on a real cupboard and destroys a real door with a
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

View File

@@ -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.