From 568fc873b31051c2f7e6ac756d554b8be1906a98 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:14:18 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20protocol=206=20=E2=80=94=20firs?= =?UTF-8?q?t-party=20clans,=20one=20board=20and=20five=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `clans` board carries every clan the game will list, with its roster and roles, plus the facts the website needs to trust it: enabled, backend, supported, truncated, and whether the optional uMod Clans plugin is loaded. It is re-sent on connect and on the 60-second cadence, and a few seconds after any membership change. The five events are clan.created, clan.disbanded, clan.member.added, clan.member.left and clan.member.kicked. What the game's assemblies dictated: - The game has no enumerate-all call. The clan leaderboard is the only listing, and the game clamps it to 100 clans. A board at that ceiling says `truncated`, as does one that would not fit in 768 KiB (the sidecar drops lines over 1 MiB). - The local backend is synchronous behind ValueTask. A task that has not completed means another backend (Nexus), which is refused with a reason rather than waited on from the main thread. - A clan id is not an identity, so every frame carries createdMs. - OnClanMemberAdded hands over a bare id before the game's cache refreshes, so the clan is read back, with a fallback to the id alone. - uMod Clans raises OnClanDisbanded(string, List), which has the same name and arity as the game's hook, so the bridge types it exactly. A member's LastSeen is never sent: it is presence (PLAN.md §23). overlay.toml moves to protocol 6 and drops Clans from requires_plugins (D47): Teams come from the game's own clans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 8 +- overlay.toml | 15 +- overlay/oxide/plugins/RunicGateway.cs | 446 +++++++++++++++++++++++++- 3 files changed, 460 insertions(+), 9 deletions(-) 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(); + + frame["enabled"] = ConVar.Clan.enabled; + frame["umodClans"] = plugins.Find("Clans") != null; + frame["clans"] = clans; + frame["count"] = 0; + frame["truncated"] = false; + + // Switched off by the operator. That is an authoritative answer — this server has no + // clans — and different from "could not read them", so it is supported and empty. + if (!ConVar.Clan.enabled) + { + frame["supported"] = true; + EmitFrame(frame); + return; + } + + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + + if (backend == null) + { + frame["supported"] = false; + frame["reason"] = "the game's clan backend has not started yet"; + EmitFrame(frame); + return; + } + + frame["backend"] = backend.GetType().Name; + + // A Nexus server keeps its clans on Facepunch's side of the Nexus, behind a backend + // whose calls do not complete on the spot. Refused with a reason rather than guessed + // at (D55): half-reading it would be a board that is wrong in a way nobody can see. + if (!(backend is LocalClanBackend)) + { + frame["supported"] = false; + frame["reason"] = $"this server's clans are held by a {backend.GetType().Name}, which the bridge does not read"; + EmitFrame(frame); + return; + } + + System.Threading.Tasks.ValueTask>> listing = backend.GetLeaderboard(MaxClansOnBoard); + + if (!listing.IsCompleted || !listing.Result.IsSuccess) + { + frame["supported"] = false; + frame["reason"] = listing.IsCompleted + ? $"the game's clan list could not be read ({listing.Result.Result})" + : "the game's clan list did not answer at once"; + EmitFrame(frame); + return; + } + + List entries = listing.Result.Value; + + // At the clamp there may be more clans than the game will list. It cannot be told + // apart from exactly 100, so it is reported as possibly more — the honest direction, + // since the website then only ADDS Teams and never removes one on this answer. + bool truncated = entries.Count >= MaxClansOnBoard; + int bytes = 0; + + foreach (ClanLeaderboardEntry entry in entries) + { + IClan clan = ReadClan(backend, entry.ClanId); + + if (clan == null) + continue; + + Dictionary row = DescribeClan(clan); + int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)); + + if (bytes + size > ClanBoardBudgetBytes) + { + truncated = true; + break; + } + + bytes += size; + clans.Add(row); + } + + frame["supported"] = true; + frame["truncated"] = truncated; + frame["count"] = clans.Count; + + EmitFrame(frame); + } + + /// + /// Main thread. One clan by id, from the game's cache if it is there and from its + /// database if not, or null. Never waits: see fact 3 at the top of this region. + /// + private static IClan ReadClan(IClanBackend backend, long clanId) + { + IClan clan; + + if (backend.TryGet(clanId, out clan) && clan != null) + return clan; + + System.Threading.Tasks.ValueTask> read = backend.Get(clanId); + + if (!read.IsCompleted || !read.Result.IsSuccess) + return null; + + return read.Result.Value; + } + + /// + /// Main thread. A clan as the board carries it. + /// + /// + /// A member's LastSeen is left off on purpose. It says when somebody was last on + /// the server, which is presence, and the website's rule is that nothing names who is on + /// by default (PLAN.md §23). Not sending it is simpler than trusting every reader to + /// withhold it. + /// + /// + private Dictionary DescribeClan(IClan clan) + { + var roles = new Dictionary(); + + if (clan.Roles != null) + { + foreach (ClanRole role in clan.Roles) + roles[role.RoleId] = role; + } + + var members = new List(); + + if (clan.Members != null) + { + foreach (ClanMember member in clan.Members) + { + var row = new Dictionary + { + { "steamId", member.SteamId.ToString() }, + { "joinedMs", member.Joined } + }; + + ClanRole role; + + // A member whose role id matches no role is left without a rank rather than + // given one: "not known" must never read as "leads this clan", and rank 1 is + // exactly what a default of zero-plus-one would produce. + if (roles.TryGetValue(member.RoleId, out role)) + { + row["rank"] = role.Rank; + row["role"] = role.Name; + } + + string name = PlayerName(member.SteamId); + if (!string.IsNullOrEmpty(name)) + row["name"] = name; + + members.Add(row); + } + } + + UnityEngine.Color32 color = clan.Color; + + return new Dictionary + { + { "clanId", clan.ClanId }, + { "createdMs", clan.Created }, + { "name", clan.Name }, + { "color", $"#{color.r:x2}{color.g:x2}{color.b:x2}" }, + { "score", clan.Score }, + { "maxMembers", clan.MaxMemberCount }, + { "members", members } + }; + } + + /// + /// Main thread. A player's name, whether or not they are on, or null. The framework + /// remembers everybody who has ever joined, which is what lets an offline clan member + /// have a name on a roster. + /// + private string PlayerName(ulong steamId) + { + BasePlayer player = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId); + + if (player != null) + return player.displayName; + + Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId.ToString()); + return known != null ? known.Name : null; + } + + /// + /// Main thread. The envelope every clan event shares: which clan, identified the way + /// the website keys it (D52), and its name so a feed line can say something without a + /// lookup. + /// + private Dictionary ClanFrame(string kind, IClan clan) + { + Dictionary frame = Frame(kind, "event"); + frame["clanId"] = clan.ClanId; + frame["createdMs"] = clan.Created; + frame["clanName"] = clan.Name; + return frame; + } + + /// + /// Main thread. Puts a player onto a clan event as steamId and name — the + /// same two fields every player event carries, so the website's player table learns the + /// name the same way it does from a connect. + /// + private void PutPlayer(Dictionary frame, string idKey, string nameKey, ulong steamId) + { + if (steamId == 0UL) + return; + + frame[idKey] = steamId.ToString(); + + string name = PlayerName(steamId); + if (!string.IsNullOrEmpty(name)) + frame[nameKey] = name; + } + + /// + /// Main thread. Re-sends the clan board shortly, once, however many changes arrive in + /// the meantime. The cadence would get there within a minute anyway; this is what makes + /// a roster follow the event that changed it. + /// + private void ScheduleClansBoard() + { + if (_clansBoardSoon != null) + return; + + _clansBoardSoon = timer.Once(ClanBoardSettleSeconds, () => + { + _clansBoardSoon = null; + + if (!_connected) + return; + + try + { + SendClansBoard(); + } + catch (Exception ex) + { + Puts($"could not build the clans board: {Flatten(ex.Message)}"); + } + }); + } + + /// + /// The founder's own membership raises no OnClanMemberAdded — the game adds them + /// inside the creation — so this event carries them, and the website counts them in. + /// + private void OnClanCreated(LocalClan clan, ulong steamId) + { + MarkHook("OnClanCreated"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.created", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises + /// a hook with this SAME NAME and arity — OnClanDisbanded(string, List<ulong>) + /// — about its own, unrelated clans. Both frameworks match a call to a method by its + /// argument types, so a loosely typed signature here would file the plugin's clans as + /// the game's. + /// + private void OnClanDisbanded(LocalClan clan, ulong steamId) + { + MarkHook("OnClanDisbanded"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.disbanded", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// The one clan hook that hands over a bare id rather than a clan: the game raises it + /// from inside its database layer, before the cached clan has been refreshed. The id is + /// enough to find the clan and its creation time; if even that fails, the frame goes + /// without createdMs and the website matches it on the id alone. + /// + private void OnClanMemberAdded(long clanId, ulong steamId) + { + MarkHook("OnClanMemberAdded"); + + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + IClan clan = backend != null ? ReadClan(backend, clanId) : null; + + Dictionary frame; + + if (clan != null) + { + frame = ClanFrame("clan.member.added", clan); + } + else + { + frame = Frame("clan.member.added", "event"); + frame["clanId"] = clanId; + } + + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + private void OnClanMemberLeft(LocalClan clan, ulong steamId) + { + MarkHook("OnClanMemberLeft"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.member.left", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + private void OnClanMemberKicked(LocalClan clan, ulong steamId, ulong bySteamId) + { + MarkHook("OnClanMemberKicked"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.member.kicked", clan); + PutPlayer(frame, "steamId", "name", steamId); + PutPlayer(frame, "bySteamId", "byName", bySteamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// Not an event: a colour is a property of the clan, so it travels on the board. The + /// hook only brings the next board forward. + /// + private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId) + { + MarkHook("OnClanColorChanged"); + ScheduleClansBoard(); + } + // ---- tallies ---- /// -- 2.49.1