Merge pull request 'feat(bridge): protocol 6 — first-party clans, one board and five events' (#6) from feat/phase-9-clans into edge

Reviewed-on: #6
This commit is contained in:
2026-09-23 10:33:27 +00:00
3 changed files with 460 additions and 9 deletions

View File

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

View File

@@ -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"]

View File

@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
/// </summary>
private const int ProtocolVersion = 5;
private const int ProtocolVersion = 6;
/// <summary>
/// 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)}");
}
}
/// <summary>Main thread.</summary>
@@ -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.
/// <summary>
/// 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.
/// </summary>
private const int MaxClansOnBoard = 100;
/// <summary>
/// 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.
/// </summary>
private const int ClanBoardBudgetBytes = 768 * 1024;
/// <summary>
/// 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.
/// </summary>
private const float ClanBoardSettleSeconds = 3f;
/// <summary>A pending early re-send of the clan board, so a burst of changes sends one.</summary>
private Timer _clansBoardSoon;
/// <summary>
/// Main thread. Every clan on this server, with its roster and roles, as the board the
/// website reconciles Teams against.
///
/// <para>
/// The board is ALWAYS sent, including when there is nothing to read — with
/// <c>supported: false</c> 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.
/// </para>
/// </summary>
private void SendClansBoard()
{
Dictionary<string, object> frame = Frame("clans", "snapshot");
var clans = new List<object>();
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<ClanValueResult<List<ClanLeaderboardEntry>>> 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<ClanLeaderboardEntry> 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<string, object> 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);
}
/// <summary>
/// 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.
/// </summary>
private static IClan ReadClan(IClanBackend backend, long clanId)
{
IClan clan;
if (backend.TryGet(clanId, out clan) && clan != null)
return clan;
System.Threading.Tasks.ValueTask<ClanValueResult<IClan>> read = backend.Get(clanId);
if (!read.IsCompleted || !read.Result.IsSuccess)
return null;
return read.Result.Value;
}
/// <summary>
/// Main thread. A clan as the board carries it.
///
/// <para>
/// A member's <c>LastSeen</c> 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.
/// </para>
/// </summary>
private Dictionary<string, object> DescribeClan(IClan clan)
{
var roles = new Dictionary<int, ClanRole>();
if (clan.Roles != null)
{
foreach (ClanRole role in clan.Roles)
roles[role.RoleId] = role;
}
var members = new List<object>();
if (clan.Members != null)
{
foreach (ClanMember member in clan.Members)
{
var row = new Dictionary<string, object>
{
{ "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<string, object>
{
{ "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 }
};
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private Dictionary<string, object> ClanFrame(string kind, IClan clan)
{
Dictionary<string, object> frame = Frame(kind, "event");
frame["clanId"] = clan.ClanId;
frame["createdMs"] = clan.Created;
frame["clanName"] = clan.Name;
return frame;
}
/// <summary>
/// Main thread. Puts a player onto a clan event as <c>steamId</c> and <c>name</c> — 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.
/// </summary>
private void PutPlayer(Dictionary<string, object> 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;
}
/// <summary>
/// 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.
/// </summary>
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)}");
}
});
}
/// <summary>
/// The founder's own membership raises no <c>OnClanMemberAdded</c> — the game adds them
/// inside the creation — so this event carries them, and the website counts them in.
/// </summary>
private void OnClanCreated(LocalClan clan, ulong steamId)
{
MarkHook("OnClanCreated");
if (clan == null)
return;
Dictionary<string, object> frame = ClanFrame("clan.created", clan);
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
/// <summary>
/// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises
/// a hook with this SAME NAME and arity — <c>OnClanDisbanded(string, List&lt;ulong&gt;)</c>
/// — 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.
/// </summary>
private void OnClanDisbanded(LocalClan clan, ulong steamId)
{
MarkHook("OnClanDisbanded");
if (clan == null)
return;
Dictionary<string, object> frame = ClanFrame("clan.disbanded", clan);
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
/// <summary>
/// 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 <c>createdMs</c> and the website matches it on the id alone.
/// </summary>
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<string, object> 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<string, object> 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<string, object> frame = ClanFrame("clan.member.kicked", clan);
PutPlayer(frame, "steamId", "name", steamId);
PutPlayer(frame, "bySteamId", "byName", bySteamId);
EmitFrame(frame);
ScheduleClansBoard();
}
/// <summary>
/// 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.
/// </summary>
private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId)
{
MarkHook("OnClanColorChanged");
ScheduleClansBoard();
}
// ---- tallies ----
/// <summary>