docs(rust): phase 10 — notifications and engagement (PLAN.md §25, PROTOCOL.md §13) #265
@@ -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
|
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.
|
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
|
### R8 — multi-server from the start
|
||||||
|
|
||||||
**Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs
|
**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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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
|
Added 2026-09-15, answering "check the default alerts Rust can expose". R7 settled that the set
|
||||||
ships; this is what goes in it.
|
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
|
- `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.
|
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
|
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ it is listening without one.
|
|||||||
|
|
||||||
## 2. Versioning
|
## 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:
|
**four** places that must agree:
|
||||||
|
|
||||||
| Where | Repo |
|
| 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**
|
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||||||
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
|
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
|
||||||
- ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
|
- ~~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)
|
- leases, budgets and the event actions (phases 12-13)
|
||||||
- the map image over the asset-bridge shape (phase 14)
|
- 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.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
|
||||||
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
|
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
|
||||||
| `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
|
| `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.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
||||||
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
||||||
| `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** |
|
| `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
|
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
|
logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the
|
||||||
game's.
|
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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user