docs(rust): phase 9 — Teams from first-party clans (PLAN.md §24, PROTOCOL.md §12) #264
@@ -221,6 +221,10 @@ upstream pull request adding a read method, not a file reader.
|
|||||||
|
|
||||||
### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager
|
### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager
|
||||||
|
|
||||||
|
> **Amended 2026-09-23 by D47 (§24):** the uMod **Clans** plugin is no longer required. It moved to
|
||||||
|
> phase 17's optional tier, because it shares no data with the game's own clans, which are what
|
||||||
|
> Teams are built from. The required set is Kits, PopupNotifications and ZoneManager.
|
||||||
|
|
||||||
**Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's
|
**Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's
|
||||||
and BetterChat's author differs only in the optional tier. All fetched at plan time:
|
and BetterChat's author differs only in the optional tier. All fetched at plan time:
|
||||||
|
|
||||||
@@ -987,7 +991,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
|||||||
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
||||||
| 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.* 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). 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 |
|
||||||
| 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 |
|
||||||
@@ -3407,6 +3411,201 @@ own Integration-kit PR alongside this one.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 24. Phase 9 — Teams from first-party clans (plan of record, 2026-09-23)
|
||||||
|
|
||||||
|
R5's phase. Rust's **own** clan system becomes core's Team provider: a clan is a Team, its page is
|
||||||
|
this module's, and core's three contributions (the notification control, the activity feed and the
|
||||||
|
forum) land in places the module names. It was preceded by the presence fix (§23), which this
|
||||||
|
phase's roster question produced, and it builds on that fix's settings table.
|
||||||
|
|
||||||
|
Three repositories and this document: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr],
|
||||||
|
all into `edge`. Android is not in this phase; R10's legs trail by one, and the Team surface reaches
|
||||||
|
the app with core's own Team screens rather than a Rust-specific one.
|
||||||
|
|
||||||
|
### 24.0 The decisions this phase needed
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| **D47** | **The uMod Clans plugin becomes OPTIONAL.** It moves out of `overlay.toml`'s `requires_plugins` and into phase 17's optional tier, which **amends R6**. The two systems share no data (the plugin never touches `ClanManager`), so a server running both splits its players across two unrelated clan systems. First-party clans are the only Teams source, and the admin surface warns when the plugin is loaded that its clans are not Teams. |
|
||||||
|
| **D48** | **A clan roster is shown to clan members and staff by default.** One fleet-wide operator setting with three rungs: `members` (the clan's own linked members, plus staff) · `signed_in` · `public`. It is §23's rule applied to a roster: a roster says who is in a clan and, inside its audience, who of them is on. **Widening it warns that it publishes online status too**, because core's `projectRoster` can withhold rows but not fields. |
|
||||||
|
| **D49** | **The clan activity feed carries membership only (founded, joined, left, removed), and every item is members-only.** Written with `visibility: 'members'` on every item. |
|
||||||
|
| **D50** | **Clan score and colour are in.** Score sorts the Clans tab and colour is shown as a swatch. **Deferred:** clan logos (images over the bridge into `ctx.uploads` is asset-bridge-shaped work that would roughly double the phase) and a Discord `/clan` command (stays in phase 16). |
|
||||||
|
| **D51** | **Protocol 6 is one board and five events.** A `clans` snapshot, re-sent on connect and on the 60-second cadence, plus `clan.created`, `clan.disbanded`, `clan.member.added`, `clan.member.left` and `clan.member.kicked`. The sidecar files by `type` (§8.1), so it only bumps its version. |
|
||||||
|
| **D52** | **`externalId` is `<serverId>:<clanId>:<createdMs>`.** The clan id alone is not an identity: the game's clan database is `clans.<version>.db` with the version hard-coded, so a Facepunch bump starts a fresh file whose ids restart at 1. Without the creation time, the new clan #1 would inherit the old clan #1's Team, its forum and its members-only history, and core would read the swap as a rename. |
|
||||||
|
| **D53** | **`getTeams` is `complete: true` only when every server's board is fresh**, supported and untruncated. Otherwise it answers `complete: false`, which core reads as "add and update, remove nothing". One server being offline must never archive its clans. When no server's board is fresh it refuses. |
|
||||||
|
| **D54** | **Leadership comes from the board, diffed one snapshot against the next**, and is published as `team.leader.added` / `team.leader.removed`. The game raises no promote or demote hook, so this is correct to within one cadence (about a minute). A leader is any member whose role has rank 1, and there may be several. The snapshot is shaped so phase 17 can sharpen it with the uMod plugin's `OnClanUpdate` rather than replace it. |
|
||||||
|
| **D55** | **The board is bounded, and its ceiling is the game's.** The game offers no "list every clan" call; the only listing is the clan leaderboard, which **the game clamps to 100 clans by score**. The org lead accepted that ceiling (2026-09-23) over reading the game's private SQLite schema directly. A server at 100 clans or more, or whose board would not fit on one line of the game link, reports `truncated: true` and the answer becomes partial: Teams there are added and updated but never removed. A **non-local clan backend** (Nexus) is refused with a reason rather than guessed at. |
|
||||||
|
| **D56** | **The pages are `/rust/clans/:externalId`, with a Clans tab on each server's page.** `pageUrlTemplate` substitutes only `{externalId}`, which rules out a route nested under the server. Three module slots are declared: `rust.clan.header` (core's `team.notify`), `rust.clan.detail` (`team.activity`) and `rust.clan.forum` (`team.forum`), one per place, as `module-uo` does. |
|
||||||
|
| **D57** | **Linking or unlinking an account calls `ctx.teams.reconcile`**, so a member's `userId` appears straight away instead of on core's next sweep. |
|
||||||
|
| **D58** | **The clan LIST is public; the roster is not.** Each clan's name, colour, score and member count names no player, so the Clans tab shows them to anyone (org lead, 2026-09-23). Rosters, leaders and who is online stay behind D48's audience. |
|
||||||
|
|
||||||
|
### 24.1 Facts the phase rests on
|
||||||
|
|
||||||
|
Read from the game's own assemblies (`Rust.Clans.Local.dll`, `Rust.Clans.dll` and `ClanManager`
|
||||||
|
in `Assembly-CSharp.dll`, decompiled off the Oxide rig), not from a catalogue:
|
||||||
|
|
||||||
|
- **`clan.enabled` defaults to `true`**, so every server has first-party clans unless an operator
|
||||||
|
turns them off. `ClanManager.ServerInstance.Backend` is null until the backend initialises.
|
||||||
|
- **The backend API has no enumerate-all call.** `IClanBackend` offers `Get`, `TryGet`,
|
||||||
|
`GetByMember`, `Create`, `ListInvitations` and `GetLeaderboard`. `GetLeaderboard(limit)` runs
|
||||||
|
`ListTopClans`, which is `SELECT clan_id, name, score FROM clans WHERE deleted IS NULL ORDER BY
|
||||||
|
score DESC LIMIT ?` with the limit clamped to **10..100**. That clamp is D55's ceiling.
|
||||||
|
- **The local backend is synchronous under an async signature.** Every `ValueTask` it returns has
|
||||||
|
already completed, so the plugin can read results on the main thread without blocking. A
|
||||||
|
backend whose task has not completed is treated as unsupported rather than waited on.
|
||||||
|
- **A leader is any member whose role has `Rank == 1`, and several are allowed.** The game only
|
||||||
|
stops you removing the last one. **Clans cannot be renamed**: `IClan` has no `SetName`.
|
||||||
|
- **Hook sites.** `OnClanCreated(LocalClan, ulong)` fires in `LocalClanBackend.Create` after the
|
||||||
|
commit. `OnClanMemberAdded(long clanId, ulong steamId)` fires inside
|
||||||
|
`LocalClanDatabase.AcceptInvite` with a **bare id**, before the cached `LocalClan` has been
|
||||||
|
refreshed. `OnClanMemberLeft`, `OnClanMemberKicked` and `OnClanDisbanded` fire from `LocalClan`.
|
||||||
|
**The founder's own membership fires no `OnClanMemberAdded`**, so `clan.created` implies it.
|
||||||
|
- **The uMod Clans plugin raises a hook with the SAME name.** It calls `OnClanDisbanded(string tag,
|
||||||
|
List<ulong> members)`, the same name and arity as the game's `OnClanDisbanded(LocalClan, ulong)`.
|
||||||
|
The bridge declares the game's types precisely, so a call with the plugin's types does not match
|
||||||
|
it. This is checked on the rig, which runs the plugin.
|
||||||
|
|
||||||
|
### 24.2 What the plan builds
|
||||||
|
|
||||||
|
**The plugin (protocol 6).** The `clans` board carries, per clan: `clanId`, `createdMs`, `name`,
|
||||||
|
`color` (`#rrggbb`), `score`, `maxMembers`, and `members` (each `steamId`, `rank`, `role`,
|
||||||
|
`joinedMs`, and `name` when the framework knows one). It also carries the board-level facts D53
|
||||||
|
and D55 need: `enabled`, `backend`, `supported`, `truncated`, and `umodClans`, which is whether the
|
||||||
|
optional plugin is loaded (D47's warning). A member's `LastSeen` is deliberately **not** sent: it
|
||||||
|
is presence (§23). The five events carry `clanId`, `createdMs`, the clan's `name` and the Steam
|
||||||
|
ids involved. A membership event also schedules a board re-send a couple of seconds later, so the
|
||||||
|
roster that follows it does not wait a minute.
|
||||||
|
|
||||||
|
**The sidecar.** `PROTOCOL_VERSION` becomes 6. Nothing else changes.
|
||||||
|
|
||||||
|
**The module.**
|
||||||
|
- **The store:** `rust_clans` and `rust_clan_members`, replaced per server from each board, plus
|
||||||
|
`rust_clan_boards`, which records per server when the board's `t` last advanced by the
|
||||||
|
**website's** clock. Freshness is judged by that, so clock skew between the game host and the
|
||||||
|
website cannot make a stale board look fresh.
|
||||||
|
- **The provider:** `getTeams`, `getTeamMembers`, `getTeamLeaders` and `projectRoster` (D48), plus
|
||||||
|
`pageUrlTemplate: '/rust/clans/{externalId}'`. `userId` comes from the module's own link table,
|
||||||
|
`online` from its own presence board, and `rankLabel` from the clan's role name.
|
||||||
|
- **The push:** each `clan.*` event is published to core (`team.created`, `team.disbanded`,
|
||||||
|
`team.member.added`, `team.member.removed`) and written to the Team feed as a members-only item
|
||||||
|
(D49), with a `dedupeKey` from the event's own id so a replay is a no-op. A board that changes
|
||||||
|
the set of clans asks core to reconcile, and a board that changes a leader publishes it (D54).
|
||||||
|
- **The pages:** the Clans tab (public list, D58) and `/rust/clans/:externalId`, whose roster is
|
||||||
|
projected by the same function core calls. The roster setting is a second section on the
|
||||||
|
existing **Rust visibility** admin page, which also lists any server running the uMod plugin.
|
||||||
|
- **The feed:** the five kinds are `staff` class in the public feed's allowlist. They reach the
|
||||||
|
public only through core's members-only Team feed.
|
||||||
|
|
||||||
|
**The docs.** This section as built, `rust-link/PROTOCOL.md` §12 (protocol 6), the R6 amendment
|
||||||
|
and `overlay.toml`'s requirement list.
|
||||||
|
|
||||||
|
### 24.3 Done when
|
||||||
|
|
||||||
|
The phase row's criterion: *the clan page is ours, core's contributions land in places we named,
|
||||||
|
and every slot empty still reads correctly.* Walked against the Oxide rig with real clans, plus a
|
||||||
|
Carbon load of the same plugin file.
|
||||||
|
|
||||||
|
|
||||||
|
### 24.4 As built, 2026-09-23
|
||||||
|
|
||||||
|
Rust-Plugins, Rust-Link and Module-Rust on `feat/phase-9-clans` (into `edge`), and this document.
|
||||||
|
**Core needed one change, and it is not a Teams change:** the org lead's rule that a site runs one
|
||||||
|
module (below), shipped as its own website PR with its own docs PR.
|
||||||
|
|
||||||
|
**The criterion is met on a live rig.** Against the Oxide rig with the protocol-6 sidecar inside the
|
||||||
|
container, and a core at the pinned sha running only this module, a rig-only helper plugin drove
|
||||||
|
the game's own clan backend with synthetic Steam ids:
|
||||||
|
|
||||||
|
- Two clans founded, two members added, one promoted, one leaving, one kicked, one disbanded.
|
||||||
|
Every hook fired (`rg.hooks`: created 2, added 3, left 1, kicked 1, colour 1, disbanded 1).
|
||||||
|
- Core created a Team per clan, synced the rosters with the right leaders, marked the leaver and
|
||||||
|
the kicked member departed, published the promotion from the board diff, and **archived the
|
||||||
|
disbanded clan's Team** on the next complete answer.
|
||||||
|
- The clan page renders signed out with the roster withheld and a sentence saying who may see it.
|
||||||
|
Core's activity contribution is in `rust.clan.detail`; the notify and forum slots are empty for a
|
||||||
|
visitor, and the page reads correctly without them.
|
||||||
|
- The roster gate agrees on both surfaces, the module's page and core's
|
||||||
|
`/public/teams/:slug/members`: hidden from an anonymous visitor and from a signed-in player
|
||||||
|
outside the clan, shown to that player once their link is to a member, and shown to a
|
||||||
|
moderator. Neither leaks a Steam id or an account id.
|
||||||
|
- Admin: a moderator's `PUT` is 403, an unknown rung is 400, and `public` opens the roster to an
|
||||||
|
anonymous visitor.
|
||||||
|
|
||||||
|
**On Carbon** (the other rig, Carbon 2.x): the byte-identical plugin compiled and loaded, and the
|
||||||
|
same helper fired every clan hook with the right counts.
|
||||||
|
|
||||||
|
### 24.5 What the rig found that the plan did not say
|
||||||
|
|
||||||
|
**1. Core's first answer is a refusal, and that is correct.** The `clan.created` event reached core
|
||||||
|
through the ingest cursor (every 5 s) before the first `clans` board reached it through the refresh
|
||||||
|
(every 30 s). Core's reconcile asked the provider and was **refused**: no current board. Twenty
|
||||||
|
seconds later the board arrived and the Teams were created. The alternative, answering from events
|
||||||
|
alone, is exactly what the envelope contract forbids.
|
||||||
|
|
||||||
|
**2. A Team's first feed line would always have been dropped.** Core writes an activity item only
|
||||||
|
for a Team it already holds, and a new clan's Team does not exist until core's next reconcile,
|
||||||
|
debounced by up to 30 seconds. "Founded the clan", the first line of every clan's feed, was
|
||||||
|
dropped on the live run (`teams activity push: dropped items`), as predicted. The module now
|
||||||
|
re-offers the last ten minutes of clan events on every board refresh. On the rig, all four
|
||||||
|
dropped lines landed on the next refresh, each once.
|
||||||
|
|
||||||
|
**3. Core clamps a `dedupeKey` to 40 characters, silently.** `MAX_DEDUPE = 40` in core's
|
||||||
|
activity model. A readable key long enough to be unique (server, clan, creation time, kind, player,
|
||||||
|
instant) is 80 or more characters, so it would be cut short into collisions, and a
|
||||||
|
re-offer would then have deduplicated *different* events into one. The key is a sha1, exactly 40.
|
||||||
|
`MODULE_API.md` does not state the limit; a kit reader would find it the same way.
|
||||||
|
|
||||||
|
**4. `occurredAt` is epoch milliseconds, not a `Date`.** Core reads `Number.isFinite(item.occurredAt)`
|
||||||
|
and replaces anything else with now. A `Date` would have stamped every re-offered line with the time
|
||||||
|
of the re-offer.
|
||||||
|
|
||||||
|
**5. Game ids restart with the database version, as §24.1 said, and the rig showed ids from 1.** The
|
||||||
|
fresh rig numbered its first clan `1`, which is why `createdMs` is in the identity.
|
||||||
|
|
||||||
|
**6. The ceiling is the game's, and it is 100.** `ListTopClans` clamps its limit to 10..100 (D55).
|
||||||
|
|
||||||
|
**7. One Team provider per site, and a load order that made it worse.** Core holds a single provider
|
||||||
|
and a second registration fails that module's **whole load**. Modules load alphabetically, so
|
||||||
|
installing this module beside `module-uo` would have taken `uo` down, not `rust`. The org lead's
|
||||||
|
rule, 2026-09-23: **a site runs one module.** Core's installer now refuses a module whose id
|
||||||
|
differs from the installed one (409) and accepts only upgrades (`MODULE_SYSTEM.md` §2.5, decision
|
||||||
|
35). This is its own website PR, not part of this phase.
|
||||||
|
|
||||||
|
**8. The uMod plugin's same-named hook does not reach the bridge, on either framework.** Raised in
|
||||||
|
both of the plugin's shapes, the bridge's own count for `OnClanDisbanded` stayed at the one real
|
||||||
|
disband, with nothing logged (PROTOCOL.md §12.4).
|
||||||
|
|
||||||
|
**9. A clan event must not move `last_seen`.** The ingest's `touchPlayer` moves it, which is right
|
||||||
|
for a frame that says a player was on. A kick is done *to* somebody who may be offline, and
|
||||||
|
`last_seen` is presence (§23). Clan events note names with an upsert that leaves `last_seen`
|
||||||
|
alone.
|
||||||
|
|
||||||
|
### 24.6 What is not proven here
|
||||||
|
|
||||||
|
- **A real player's name on a feed line.** The synthetic Steam ids have none, so the rig's lines read
|
||||||
|
"A player founded the clan". Named lines need a real player's account on the rig, which is a walk
|
||||||
|
to run beside the §12.5 identity walk.
|
||||||
|
- **Staff do not see the members-only feed.** Core's feed access is membership and grants; a
|
||||||
|
moderator reads the roster (D48 includes staff) but not the members-only lines. That is core's
|
||||||
|
rule rather than this module's, and it is written down rather than changed.
|
||||||
|
- **The 100-clan ceiling was not reached on a rig.** It is the game's own clamp, read from its
|
||||||
|
source, and the truncation path is covered by the suite.
|
||||||
|
- **A Nexus server was not available.** Its refusal is read from the game's backend selection
|
||||||
|
(`NexusServer.Started` chooses `nexus`), not walked.
|
||||||
|
|
||||||
|
### 24.7 Smaller things worth keeping
|
||||||
|
|
||||||
|
- **The rig helper** (`RgClanRig.cs`, rig-only, never shipped) is ninety lines against
|
||||||
|
`ClanManager.ServerInstance.Backend`: create, add (invite plus accept), role, kick, colour,
|
||||||
|
disband, list, and a fake uMod disband. It is the cheapest way to fire every clan hook without
|
||||||
|
players, on either framework.
|
||||||
|
- **swagger-autogen drops a backtick inside a parameter description** and reports success; the
|
||||||
|
module's fragment check caught it (`2 annotation(s) swagger-autogen could not parse`).
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
[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 — **5** as of configuration from the site (§11) — declared in
|
The wire version is a single integer — **6** as of first-party clans (§12) — declared in
|
||||||
**four** places that must agree:
|
**four** places that must agree:
|
||||||
|
|
||||||
| Where | Repo |
|
| Where | Repo |
|
||||||
@@ -335,8 +335,8 @@ arrives with the phase that needs it, and each is a version bump:
|
|||||||
|
|
||||||
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
||||||
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||||||
- plugin configuration edited from the site (phase 7b)
|
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
|
||||||
- clans, for core's Team provider (phase 9)
|
- ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
|
||||||
- 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)
|
||||||
|
|
||||||
@@ -1095,3 +1095,104 @@ re-read rather than to guess, and names the reload window that is probably still
|
|||||||
running.
|
running.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Protocol 6 — first-party clans
|
||||||
|
|
||||||
|
Phase 9, R5. Rust's **own** clan system becomes core's Teams: the plugin reports it, the module
|
||||||
|
answers core's Team provider from it, and the website owns the clan page. Design of record:
|
||||||
|
[`PLAN.md`](../modules/rust/PLAN.md) §24 (D47–D58).
|
||||||
|
|
||||||
|
It is not the uMod **Clans** plugin. That is a separate system that never touches the game's
|
||||||
|
`ClanManager` (D47), so a server running it has two unrelated clan systems, and only the game's
|
||||||
|
becomes Teams. The plugin reads nothing of it except whether it is loaded.
|
||||||
|
|
||||||
|
**The sidecar changed nothing but its version.** One board and five events, filed by `type` (§8.1).
|
||||||
|
|
||||||
|
### 12.1 The `clans` board
|
||||||
|
|
||||||
|
A snapshot, re-sent on connect, on the 60-second cadence, and about three seconds after any clan
|
||||||
|
hook fires, so a roster follows the change that caused it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "clans", "type": "snapshot", "t": 1790158748054, "serverId": "rust-oxide",
|
||||||
|
"enabled": true, "backend": "LocalClanBackend", "supported": true, "truncated": false,
|
||||||
|
"umodClans": false, "count": 1,
|
||||||
|
"clans": [{
|
||||||
|
"clanId": 1, "createdMs": 1790158729260, "name": "Northwatch", "color": "#3fa9f5",
|
||||||
|
"score": 0, "maxMembers": 100,
|
||||||
|
"members": [{ "steamId": "76561190000000001", "rank": 1, "role": "Leader", "joinedMs": 1790158729264, "name": "…" }]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `enabled` | The game's `clan.enabled` convar. `false` is an authoritative answer — no clans — and is `supported` |
|
||||||
|
| `supported` / `reason` | Could the plugin read the clans at all. `false` when the backend has not started, or is not the local one (a **Nexus** server keeps its clans elsewhere) — refused with a reason rather than guessed at |
|
||||||
|
| `truncated` | There may be clans the board does not list. See §12.3 |
|
||||||
|
| `umodClans` | The uMod Clans plugin is loaded. The website warns that its clans are not Teams |
|
||||||
|
| `rank` | The member's role rank. **Rank 1 is leader**, and several members may hold it. Absent when the member's role id matched no role — never defaulted |
|
||||||
|
| `name` | Present when the framework knows the player. Absent, not empty, otherwise |
|
||||||
|
|
||||||
|
A member's `LastSeen` is **not sent**. It is presence, and nothing names who is online by default
|
||||||
|
(PLAN.md §23).
|
||||||
|
|
||||||
|
### 12.2 The five events
|
||||||
|
|
||||||
|
| `kind` | Hook | Carries |
|
||||||
|
|---|---|---|
|
||||||
|
| `clan.created` | `OnClanCreated(LocalClan, ulong)` | clan, founder (`steamId`, `name`) |
|
||||||
|
| `clan.disbanded` | `OnClanDisbanded(LocalClan, ulong)` | clan, who disbanded it |
|
||||||
|
| `clan.member.added` | `OnClanMemberAdded(long, ulong)` | clan, the new member |
|
||||||
|
| `clan.member.left` | `OnClanMemberLeft(LocalClan, ulong)` | clan, the member |
|
||||||
|
| `clan.member.kicked` | `OnClanMemberKicked(LocalClan, ulong, ulong)` | clan, the member, and who kicked them (`bySteamId`, `byName`) |
|
||||||
|
|
||||||
|
"Clan" is always `clanId`, `createdMs` and `clanName`. Every one is **`staff` class** in §8.5's
|
||||||
|
terms: clan membership is members-only (D49), so the public feed never carries it. It reaches a
|
||||||
|
clan's members through core's Team feed, where core decides who is a member.
|
||||||
|
|
||||||
|
`OnClanColorChanged` is hooked too, but produces no event: a colour is a property of the clan, so it
|
||||||
|
travels on the board, and the hook only brings the next board forward.
|
||||||
|
|
||||||
|
Three facts the hook sites impose, all read from the game's assemblies:
|
||||||
|
|
||||||
|
- **The founder's membership fires no `OnClanMemberAdded`.** The game adds them inside the
|
||||||
|
creation, so `clan.created` implies it.
|
||||||
|
- **`OnClanMemberAdded` hands over a bare id**, fired from inside the database layer before the
|
||||||
|
game's cached clan is refreshed. The plugin reads the clan back for `createdMs` and `clanName`; if
|
||||||
|
even that fails the frame carries `clanId` alone and the website matches on it.
|
||||||
|
- **The game raises no promote or demote hook.** Leadership travels on the board only, and the
|
||||||
|
website diffs one board against the next (D54).
|
||||||
|
|
||||||
|
### 12.3 Identity, and the ceiling
|
||||||
|
|
||||||
|
**A clan's identity is `clanId` AND `createdMs`.** The game keeps clans in `clans.<version>.db` with
|
||||||
|
the version hard-coded, so a game update that bumps it starts a fresh file whose ids restart at 1.
|
||||||
|
The website keys a Team on `<serverId>:<clanId>:<createdMs>` (D52), and every clan frame carries
|
||||||
|
both halves for that reason.
|
||||||
|
|
||||||
|
**The game has no "list every clan" call.** Its backend offers get-by-id and get-by-member; the only
|
||||||
|
listing is the clan leaderboard, which runs `SELECT … ORDER BY score DESC LIMIT ?` with the limit
|
||||||
|
**clamped to 100**. So the board lists at most the top 100 clans by score. A board at that ceiling
|
||||||
|
cannot be told apart from one with exactly 100 clans, and says `truncated: true` either way. It also
|
||||||
|
says so if its rows would pass **768 KiB**, well inside the sidecar's 1 MiB line cap, which drops a
|
||||||
|
longer line outright — a board that never arrived would read as a server with no clans.
|
||||||
|
|
||||||
|
The org lead accepted the ceiling (D55) over reading the game's private SQLite schema directly. A
|
||||||
|
truncated board is answered to core as partial, so core adds and updates Teams there and never
|
||||||
|
removes one on its word.
|
||||||
|
|
||||||
|
### 12.4 A hook name another plugin also raises
|
||||||
|
|
||||||
|
The uMod Clans plugin raises `OnClanDisbanded(string tag, List<ulong> members)`, and a Universal
|
||||||
|
form with `List<string>`. Both have **the same name and arity** as the game's
|
||||||
|
`OnClanDisbanded(LocalClan, ulong)`. The bridge declares the game's types exactly, and the framework
|
||||||
|
matches a call to a method by its argument types, so neither call reaches it.
|
||||||
|
|
||||||
|
**Walked on the Oxide rig (2026-09-23):** both uMod-shaped calls were raised from a rig plugin, and
|
||||||
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user