feat(bridge): protocol 6 — first-party clans, one board and five events
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 12s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 12s
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<ulong>), 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
15
overlay.toml
15
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"]
|
||||
|
||||
@@ -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<ulong>)</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>
|
||||
|
||||
Reference in New Issue
Block a user