diff --git a/README.md b/README.md
index 88eecf3..16da40f 100644
--- a/README.md
+++ b/README.md
@@ -58,11 +58,15 @@ tarball and installs the sidecar alongside it.
### Prerequisites
-The bridge itself needs nothing but Oxide. The features that follow it read four third-party plugins
-an operator installs from uMod — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`. They are
+The bridge itself needs nothing but Oxide. The features that follow it read three third-party
+plugins an operator installs from uMod — `Kits`, `PopupNotifications` and `ZoneManager`. They are
listed in `overlay.toml` so the installer's `doctor` can report a missing one by name rather than
leaving the site quietly short of a feature.
+Clans on the website come from Rust's **own** clan system, not from the uMod `Clans` plugin, which
+is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
+only the game's own clans become Teams on the site.
+
## Diagnosing it
```
diff --git a/overlay.toml b/overlay.toml
index 31b2da0..b62e4ca 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,9 +26,9 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 5 — the transport, the read path, identity, the permission mirror, and
-# configuration from the site (docs/rust-link/PROTOCOL.md §11).
-protocol = 5
+# Current: 6 — the transport, the read path, identity, the permission mirror,
+# configuration from the site, and first-party clans (docs/rust-link/PROTOCOL.md §12).
+protocol = 6
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
@@ -45,6 +45,11 @@ min_oxide_version = "2.0.7585"
# The `oxide/plugins/` files this overlay expects to find already installed. They
# are not shipped here — they are third-party plugins an operator installs from
# uMod — and the installer's `doctor` reports a missing one rather than
-# installing it. Listing them is what turns "the site shows no clans" into a
+# installing it. Listing them is what turns a missing reward or zone into a
# named prerequisite.
-requires_plugins = ["Clans", "Kits", "PopupNotifications", "ZoneManager"]
+#
+# The uMod **Clans** plugin is deliberately NOT listed (docs/modules/rust/PLAN.md
+# D47). Teams come from Rust's own clan system, which that plugin never touches,
+# so requiring it would split a server's players across two unrelated clan
+# systems. It belongs to phase 17's optional tier.
+requires_plugins = ["Kits", "PopupNotifications", "ZoneManager"]
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 55732b4..1de3153 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 5;
+ private const int ProtocolVersion = 6;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -236,7 +236,9 @@ namespace Oxide.Plugins
"OnUserPermissionGranted", "OnUserPermissionRevoked",
"OnUserGroupAdded", "OnUserGroupRemoved",
"OnGroupPermissionGranted", "OnGroupPermissionRevoked",
- "OnPluginLoaded", "OnPluginUnloaded"
+ "OnPluginLoaded", "OnPluginUnloaded",
+ "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
+ "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged"
};
private void Init()
@@ -291,6 +293,12 @@ namespace Oxide.Plugins
_cadence = null;
}
+ if (_clansBoardSoon != null)
+ {
+ _clansBoardSoon.Destroy();
+ _clansBoardSoon = null;
+ }
+
// A half-drained permission sync is abandoned rather than finished, and the site is
// told nothing — which is right: the report would describe a set that was only partly
// applied, and the next sync re-derives the whole answer from the store anyway. What
@@ -865,6 +873,18 @@ namespace Oxide.Plugins
{
SendHello();
SendPlayersBoard();
+
+ // Last, and fenced: the clan board reads the game's clan database, which is the one
+ // board here built from something other than a list in memory. A failure there must
+ // cost the clans and nothing else — the two boards above are what every page renders.
+ try
+ {
+ SendClansBoard();
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not build the clans board: {Flatten(ex.Message)}");
+ }
}
/// Main thread.
@@ -1508,6 +1528,428 @@ namespace Oxide.Plugins
EmitFrame(frame);
}
+ // ---- clans (protocol 6) ----
+ //
+ // Rust's FIRST-PARTY clan system, which the website turns into core's Teams (R5,
+ // PROTOCOL.md §12). Not the uMod Clans plugin: that is a separate system that never
+ // touches `ClanManager` (D47), and this region reads nothing of it except whether it is
+ // loaded, so the website can warn that its clans are not Teams.
+ //
+ // One board and five events, the same split as presence. The events carry the story —
+ // founded, joined, left, removed, disbanded — and the board is the reconciliation point
+ // that corrects anything missed within a minute. Leadership travels ONLY on the board,
+ // because the game raises no hook when somebody is promoted or demoted (§12.3).
+ //
+ // Three facts about the game shape everything below, and all three were read from its
+ // assemblies rather than from a catalogue:
+ //
+ // 1. **There is no "list every clan" call.** The backend offers get-by-id and
+ // get-by-member; the only listing is the clan leaderboard, and the game clamps that
+ // to 100 clans by score. That clamp is the board's ceiling (D55), and a board at it
+ // says `truncated` rather than pretending to be the whole answer.
+ //
+ // 2. **A clan id is not an identity.** The database file's version number is
+ // hard-coded, so a game update that bumps it starts a fresh file whose ids restart at
+ // 1. Every frame therefore carries the clan's creation time beside its id, and the
+ // pair is what the website keys a Team on (D52).
+ //
+ // 3. **The local backend is synchronous behind an async signature.** Every `ValueTask`
+ // it returns has already completed, which is what makes reading it from a hook
+ // legal. A task that has NOT completed means some other backend, and it is refused
+ // rather than waited on — waiting would be the main thread blocking on a database.
+
+ ///
+ /// The most clans one board can carry, and it is the GAME's number: the clan leaderboard
+ /// clamps its limit to 10..100. Asking for more returns 100.
+ ///
+ private const int MaxClansOnBoard = 100;
+
+ ///
+ /// How many bytes of clan rows one board may carry. Well inside the sidecar's 1 MiB line
+ /// cap, which discards an over-long line outright: a board that silently never arrived
+ /// would look like a server with no clans. 100 clans of 100 members each is about a
+ /// megabyte, so the cap is reachable, and a board that meets it says so.
+ ///
+ private const int ClanBoardBudgetBytes = 768 * 1024;
+
+ ///
+ /// How long after a membership change the clan board is re-sent. The event reaches the
+ /// sidecar at once; the roster that follows it waits for the game to refresh its own
+ /// cached clan, which `OnClanMemberAdded` fires before.
+ ///
+ private const float ClanBoardSettleSeconds = 3f;
+
+ /// A pending early re-send of the clan board, so a burst of changes sends one.
+ private Timer _clansBoardSoon;
+
+ ///
+ /// Main thread. Every clan on this server, with its roster and roles, as the board the
+ /// website reconciles Teams against.
+ ///
+ ///
+ /// The board is ALWAYS sent, including when there is nothing to read — with
+ /// supported: false and a reason. Saying nothing would leave the website holding
+ /// the last board it saw and no way to know it had stopped being true.
+ ///
+ ///
+ private void SendClansBoard()
+ {
+ Dictionary frame = Frame("clans", "snapshot");
+ var clans = new List