diff --git a/overlay.toml b/overlay.toml
index 89d7c24..6193f2f 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,12 +26,13 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 10 — the transport, the read path, identity, the permission mirror,
+# Current: 12 — the transport, the read path, identity, the permission mirror,
# configuration from the site, first-party clans, a raid frame that names who
# lives there, the event leases, the world verbs events place and give back, and
# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16),
-# and the map: its picture and everything that moves on it (PROTOCOL.md §17).
-protocol = 11
+# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the
+# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18).
+protocol = 12
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
@@ -54,5 +55,9 @@ min_oxide_version = "2.0.7585"
# 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"]
+# systems. Phase 17 dropped the adapter it was meant to get (D134).
+#
+# **PopupNotifications is not listed either** (D141). It is optional: a popup
+# asked of a server without it is refused with a reason, and chat is the default.
+# BetterChat is optional in the same way — titles and group styles wait for it.
+requires_plugins = ["Kits", "ZoneManager"]
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index a55577e..3ffa50b 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 = 11;
+ private const int ProtocolVersion = 12;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -947,6 +947,12 @@ namespace Oxide.Plugins
HandleChatSay(cmd, reqId);
break;
+ case "titles.set":
+ // Protocol 12. The chat titles players have earned, as one whole set that
+ // replaces the last (PLAN.md §33).
+ HandleTitlesSet(cmd, reqId);
+ break;
+
case "map.info":
// Protocol 11 (PLAN.md §30). What this map is and where its picture comes from.
HandleMapInfo(reqId);
@@ -1130,6 +1136,10 @@ namespace Oxide.Plugins
// A server that has never saved has no created time. Absent, not zero.
}
+ // Protocol 12: which of the optional mods this server has, so Admin → Rust can say why
+ // a title or a popup is not showing before anybody goes looking (§33.2).
+ status["integrations"] = IntegrationsBody();
+
return status;
}
@@ -1362,6 +1372,12 @@ namespace Oxide.Plugins
// The map's sets are seeded here for the same reason: a hotload hears no spawns.
SeedMapSets();
+ // The titles this plugin last held, if they are this wipe's (D144), and then the getter
+ // handed to BetterChat, if it is here. When it loads later, or reloads and forgets us,
+ // OnPluginLoaded does this again (§33.1).
+ LoadTitles();
+ RegisterTitles("load");
+
// Started here rather than in Init: this hook fires both at the end of server startup
// and immediately on a hotload, so it is the one place that runs exactly once per
// load with the world available either way.
@@ -2865,7 +2881,13 @@ namespace Oxide.Plugins
RetireGrant,
RetireGroup,
ScanPermission,
- ScanGroup
+ ScanGroup,
+
+ /// Protocol 12: a group's BetterChat style (D138).
+ EnsureChatStyle,
+
+ /// Protocol 12: a styled group's BetterChat group, gone from the site (D139).
+ RetireChatGroup
}
private struct PermOp
@@ -2950,6 +2972,42 @@ namespace Oxide.Plugins
public int CreditsWithdrawn;
public bool CreditsWaiting;
public readonly List CreditsUnresolved = new List();
+
+ ///
+ /// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
+ /// was not, no style op is compiled and the report says loaded: false and
+ /// nothing else: the permissions sync exactly as they did before.
+ ///
+ public bool ChatLoaded;
+
+ public string ChatVersion;
+
+ /// The fields the site wants on each styled group.
+ public readonly Dictionary> ChatStyles =
+ new Dictionary>();
+
+ public int ChatApplied;
+ public int ChatSaved;
+ public int ChatSame;
+ public readonly List ChatDrift = new List();
+ public readonly List ChatRemoved = new List();
+ public readonly List ChatFailed = new List();
+ }
+
+ ///
+ /// One BetterChat field the site wants on a group (protocol 12): the value, and the
+ /// value this site last pushed there.
+ ///
+ private sealed class ChatFieldWant
+ {
+ public string Field;
+ public string Value;
+
+ ///
+ /// What the site last pushed, or null for a field it has never pushed. A game value
+ /// that is neither this nor the one wanted is somebody's hand edit (D138).
+ ///
+ public string Expect;
}
private PermSyncJob _permJob;
@@ -3062,6 +3120,10 @@ namespace Oxide.Plugins
SetId = StringField(cmd, "setId")
};
+ Plugin betterChat = BetterChatPlugin();
+ job.ChatLoaded = betterChat != null;
+ job.ChatVersion = betterChat != null ? betterChat.Version.ToString() : null;
+
// ── The desired set ────────────────────────────────────────────────
//
// Order matters and it is not cosmetic: a group has to exist before anything can be
@@ -3115,6 +3177,20 @@ namespace Oxide.Plugins
// A group with no members still has to be scanned: the interesting case is a
// member the site did not put there.
Bucket(job.DesiredMembers, name);
+
+ // Protocol 12: the group's BetterChat style. After the group's own ops, so the
+ // Oxide group BetterChat's members are read from exists first. With BetterChat
+ // absent nothing is compiled, and the report says why (§33.2).
+ if (job.ChatLoaded)
+ {
+ List style = ChatStyle(job, name, group);
+
+ if (style.Count > 0)
+ {
+ job.ChatStyles[name] = style;
+ job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureChatStyle, Subject = name });
+ }
+ }
}
foreach (Dictionary grant in Rows(cmd, "grants"))
@@ -3169,6 +3245,12 @@ namespace Oxide.Plugins
case "group":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) });
break;
+ case "chat-group":
+ // Only with BetterChat here to ask. Otherwise the report says `loaded:
+ // false`, and the site keeps the retirement for a sync that can act on it.
+ if (job.ChatLoaded)
+ job.Ops.Add(new PermOp { Kind = PermOpKind.RetireChatGroup, Subject = Lower(subject) });
+ break;
}
}
@@ -3448,6 +3530,14 @@ namespace Oxide.Plugins
case PermOpKind.ScanGroup:
ScanGroup(job, op.Subject);
break;
+
+ case PermOpKind.EnsureChatStyle:
+ ApplyChatStyle(job, op.Subject);
+ break;
+
+ case PermOpKind.RetireChatGroup:
+ RemoveChatGroup(job, op.Subject);
+ break;
}
}
@@ -3695,6 +3785,23 @@ namespace Oxide.Plugins
report["creditsWaiting"] = job.CreditsWaiting;
}
+ // Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this
+ // plugin predates styles" — the second has no `chat` at all.
+ var chat = new Dictionary { { "loaded", job.ChatLoaded } };
+
+ if (job.ChatLoaded)
+ {
+ chat["version"] = job.ChatVersion;
+ chat["applied"] = job.ChatApplied;
+ chat["saved"] = job.ChatSaved;
+ chat["same"] = job.ChatSame;
+ chat["drift"] = job.ChatDrift;
+ chat["removed"] = job.ChatRemoved;
+ chat["failed"] = job.ChatFailed;
+ }
+
+ report["chat"] = chat;
+
if (!string.IsNullOrEmpty(job.SetId))
report["setId"] = job.SetId;
@@ -3705,7 +3812,9 @@ namespace Oxide.Plugins
$"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " +
$"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " +
$"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " +
- $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} at={DateTime.UtcNow:HH:mm:ss}Z";
+ $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " +
+ $"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " +
+ $"at={DateTime.UtcNow:HH:mm:ss}Z";
EmitFrame(report);
}
@@ -3788,6 +3897,11 @@ namespace Oxide.Plugins
if (plugin != null && plugin != this && plugin.Name == "ZoneManager")
NextTick(() => RecreateZones("ZoneManager reloaded"));
+ // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
+ // forgotten our getter. Registering again is the only way back (§33.1).
+ if (plugin != null && plugin != this && plugin.Name == "BetterChat")
+ NextTick(() => RegisterTitles("BetterChat loaded"));
+
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
@@ -3801,6 +3915,9 @@ namespace Oxide.Plugins
{
MarkHook("OnPluginUnloaded");
+ if (plugin != null && plugin.Name == "BetterChat")
+ _titlesRegistered = false;
+
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
@@ -8236,6 +8353,44 @@ namespace Oxide.Plugins
return;
}
+ // Protocol 12: where the line goes, and the voice it is said in (D140, D141). A caller
+ // from protocol 11 sends neither and is answered exactly as before.
+ string delivery = StringField(cmd, "delivery");
+
+ if (string.IsNullOrEmpty(delivery))
+ delivery = DeliveryChat;
+
+ if (delivery != DeliveryChat && delivery != DeliveryPopup)
+ {
+ EmitRewardError("chat.error", reqId, "malformed",
+ $"a line is delivered to chat or to a popup, not to '{delivery}'");
+ return;
+ }
+
+ string format = StringField(cmd, "format");
+
+ if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1))
+ {
+ EmitRewardError("chat.error", reqId, "malformed",
+ $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters");
+ return;
+ }
+
+ Plugin popup = null;
+
+ if (delivery == DeliveryPopup)
+ {
+ popup = PopupPlugin();
+
+ // R3: a refusal with a reason, never a line silently said somewhere else.
+ if (popup == null)
+ {
+ EmitRewardError("chat.error", reqId, "popup-unavailable",
+ "PopupNotifications is not loaded on this server, so a popup cannot be shown — install it, or deliver this line to chat");
+ return;
+ }
+ }
+
var stale = new List();
foreach (KeyValuePair kv in _chatKeys)
@@ -8251,15 +8406,29 @@ namespace Oxide.Plugins
if (!repeat)
{
- Server.Broadcast(message);
+ // A popup ignores the format: its panel is not a chat line. A null player is
+ // everybody connected, and a duration of 0 is the plugin's own default. Nothing
+ // queues for a player who is offline (§33.1).
+ string line = popup != null || format == null ? message : Voice(format, message);
+
+ if (popup != null)
+ popup.Call("CreatePopupNotification", line, null, 0f);
+ else
+ Server.Broadcast(line);
+
+ // Logged, because the game logs neither a broadcast nor a popup, and a line the
+ // website put in front of every player deserves a record on the host (§33.3).
+ Puts($"said ({delivery}): {line}");
+
_chatKeys[key] = now;
- _lastRewardSummary = $"said a line ({message.Length} chars) to {BasePlayer.activePlayerList.Count} players";
+ _lastRewardSummary = $"said a line ({message.Length} chars, {delivery}{(format != null ? ", voiced" : "")}) to {BasePlayer.activePlayerList.Count} players";
}
Dictionary reply = Frame("chat.ok", "reply");
reply["said"] = !repeat;
reply["repeat"] = repeat;
reply["players"] = BasePlayer.activePlayerList.Count;
+ reply["delivery"] = delivery;
if (reqId != null)
reply["reqId"] = reqId;
@@ -8267,6 +8436,620 @@ namespace Oxide.Plugins
EmitFrame(reply);
}
+ // ── The optional mods (protocol 12) ───────────────────────────────────────
+ //
+ // BetterChat and PopupNotifications are optional (PLAN.md §33, D141). Each is looked up at
+ // the moment it is needed, never held from our load, because an operator can load or
+ // unload either at any time; and every call into one that is missing answers with a
+ // reason instead of doing nothing quietly (R3).
+
+ private const string DeliveryChat = "chat";
+ private const string DeliveryPopup = "popup";
+
+ /// The most titles one set carries. The site has its own, lower, limit.
+ private const int TitlesMax = 500;
+
+ ///
+ /// The longest one player's title string may be. The site caps a title's text at 24
+ /// characters (§33.4); this bounds what a player with every title, in markup, can hold.
+ ///
+ private const int TitleTextMax = 1024;
+
+ /// The longest voice format a chat line may carry (§33.2).
+ private const int ChatFormatMax = 512;
+
+ ///
+ /// The chat titles players have earned on this server, by Steam id, already in
+ /// BetterChat's markup. The website composes them from the wipe's standings and pushes
+ /// the whole set (titles.set ).
+ ///
+ ///
+ /// Kept on disk as well (D144). The site re-sends on a new boot id or wipe id, and
+ /// a hot reload of this plugin — an update, an oxide.reload — is neither: the boot
+ /// id names the server process. Without the file a reload empties every title until the
+ /// standings next move. A file from another wipe is not loaded, because titles rank the
+ /// wipe they were earned on.
+ ///
+ ///
+ ///
+ /// Swapped, never edited. BetterChat reads this through on
+ /// every chat line, and another plugin can make it do so from its own thread through
+ /// BetterChat's API. A dictionary written while it is read can throw or answer wrong; a
+ /// reference replaced whole is read either before the swap or after it.
+ ///
+ ///
+ private volatile Dictionary _titles = new Dictionary();
+
+ /// The getter BetterChat holds, kept so every registration hands it the same delegate.
+ private Func _titleGetter;
+
+ ///
+ /// Whether the BetterChat loaded right now holds our getter. Cleared when BetterChat
+ /// unloads: its registrations live on the instance, so a reloaded BetterChat has
+ /// forgotten us until registers again (§33.1).
+ ///
+ private bool _titlesRegistered;
+
+ /// The last title set, for rg.titles . Diagnostics only.
+ private string _lastTitlesSummary = "none yet";
+
+ ///
+ /// The twelve fields of a BetterChat group (D138), by the name its API takes, with
+ /// BetterChat 5.2.15's default for each. Title is absent: its default is the
+ /// group's own name in brackets ( ).
+ ///
+ private static readonly Dictionary ChatFieldDefaults =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "Priority", "0" },
+ { "Title", null },
+ { "TitleColor", "#55aaff" },
+ { "TitleSize", "15" },
+ { "TitleHidden", "false" },
+ { "TitleHiddenIfNotPrimary", "false" },
+ { "UsernameColor", "#55aaff" },
+ { "UsernameSize", "15" },
+ { "MessageColor", "white" },
+ { "MessageSize", "15" },
+ { "ChatFormat", "{Title} {Username}: {Message}" },
+ { "ConsoleFormat", "{Title} {Username}: {Message}" }
+ };
+
+ private Plugin BetterChatPlugin()
+ {
+ return plugins.Find("BetterChat");
+ }
+
+ private Plugin PopupPlugin()
+ {
+ return plugins.Find("PopupNotifications");
+ }
+
+ ///
+ /// BetterChat's third-party title getter. It runs on the chat path , once per line
+ /// and again whenever another plugin asks BetterChat to format one, so it does one
+ /// lookup and nothing else: no socket, no file, no allocation (R15). A player with no
+ /// title gets null, which BetterChat skips.
+ ///
+ private string TitleFor(Oxide.Core.Libraries.Covalence.IPlayer player)
+ {
+ if (player == null)
+ return null;
+
+ ulong id;
+ string title;
+
+ return ulong.TryParse(player.Id, out id) && _titles.TryGetValue(id, out title) ? title : null;
+ }
+
+ /// Main thread. Hands BetterChat our title getter, if BetterChat is here.
+ private void RegisterTitles(string why)
+ {
+ Plugin betterChat = BetterChatPlugin();
+
+ if (betterChat == null)
+ {
+ _titlesRegistered = false;
+ return;
+ }
+
+ if (_titleGetter == null)
+ _titleGetter = TitleFor;
+
+ // Void, so the answer is null either way. `rg.titles ` is how a rig proves
+ // BetterChat renders what this handed it.
+ betterChat.Call("API_RegisterThirdPartyTitle", this, _titleGetter);
+ _titlesRegistered = true;
+
+ if (why != "load")
+ Puts($"titles: registered with BetterChat again ({why}), holding {_titles.Count}");
+ }
+
+ ///
+ /// Main thread. Replaces the titles this server holds with the set the site sent. A
+ /// malformed entry refuses the whole set rather than being skipped: the set is whole by
+ /// contract, and half of one is a set nobody sent.
+ ///
+ private void HandleTitlesSet(Dictionary cmd, string reqId)
+ {
+ object raw;
+ var list = cmd.TryGetValue("titles", out raw) ? raw as Newtonsoft.Json.Linq.JArray : null;
+
+ if (list == null)
+ {
+ EmitTitlesError(reqId, "malformed", "a title set carries a titles array");
+ return;
+ }
+
+ if (list.Count > TitlesMax)
+ {
+ EmitTitlesError(reqId, "too-large", $"this server holds at most {TitlesMax} titles, not {list.Count}");
+ return;
+ }
+
+ var next = new Dictionary();
+
+ foreach (Newtonsoft.Json.Linq.JToken item in list)
+ {
+ var row = item as Newtonsoft.Json.Linq.JObject;
+ string steamId = row != null ? JsonText(row["steamId"]) : null;
+ string text = row != null ? JsonText(row["text"]) : null;
+ ulong id;
+
+ if (!ulong.TryParse(steamId, out id) || string.IsNullOrEmpty(text) || text.Length > TitleTextMax)
+ {
+ EmitTitlesError(reqId, "malformed",
+ $"every title names a Steam id and carries text of at most {TitleTextMax} characters");
+ return;
+ }
+
+ next[id] = text;
+ }
+
+ _titles = next;
+ SaveTitles();
+
+ // A BetterChat that arrived without us hearing it load is picked up here, which also
+ // makes a restart of either plugin heal on the site's next push.
+ if (!_titlesRegistered && BetterChatPlugin() != null)
+ RegisterTitles("title set");
+
+ bool shown = _titlesRegistered && BetterChatPlugin() != null;
+ _lastTitlesSummary = $"{next.Count} titles, betterChat={shown}, at={DateTime.UtcNow:HH:mm:ss}Z";
+
+ Dictionary reply = Frame("titles.ok", "reply");
+ reply["count"] = next.Count;
+ reply["betterChat"] = shown;
+
+ string setId = StringField(cmd, "setId");
+
+ if (!string.IsNullOrEmpty(setId))
+ reply["setId"] = setId;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
+ /// What titles.json holds: the set, and the wipe it was pushed on.
+ private sealed class TitleFile
+ {
+ public string WipeId;
+ public Dictionary Titles = new Dictionary();
+ }
+
+ private string TitlesPath()
+ {
+ return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json");
+ }
+
+ /// Main thread. Written on every set the site pushes — a set arrives only when it changed.
+ private void SaveTitles()
+ {
+ var file = new TitleFile { WipeId = _wipeId };
+
+ foreach (KeyValuePair kv in _titles)
+ file.Titles[kv.Key.ToString()] = kv.Value;
+
+ WriteDataFile(TitlesPath(), file, "titles");
+ }
+
+ ///
+ /// Main thread, from OnServerInitialized — the first moment the wipe id is real.
+ /// A file from another wipe, or with no wipe, is left unread: the site sends this wipe's.
+ ///
+ private void LoadTitles()
+ {
+ try
+ {
+ string path = TitlesPath();
+
+ if (!File.Exists(path))
+ return;
+
+ var file = JsonConvert.DeserializeObject(File.ReadAllText(path));
+
+ if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId)
+ return;
+
+ var loaded = new Dictionary();
+
+ foreach (KeyValuePair kv in file.Titles)
+ {
+ ulong id;
+ if (ulong.TryParse(kv.Key, out id) && !string.IsNullOrEmpty(kv.Value))
+ loaded[id] = kv.Value;
+ }
+
+ _titles = loaded;
+ _lastTitlesSummary = $"{loaded.Count} titles from disk, at={DateTime.UtcNow:HH:mm:ss}Z";
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not read the titles: {Flatten(ex.Message)}");
+ }
+ }
+
+ private void EmitTitlesError(string reqId, string reason, string message)
+ {
+ Dictionary frame = Frame("titles.error", "reply");
+ frame["reason"] = reason;
+ frame["message"] = message;
+
+ if (reqId != null)
+ frame["reqId"] = reqId;
+
+ _lastTitlesSummary = $"refused ({reason}): {message}";
+ EmitFrame(frame);
+ }
+
+ /// A JSON string or number as text, or null for anything else.
+ private static string JsonText(Newtonsoft.Json.Linq.JToken token)
+ {
+ if (token == null)
+ return null;
+
+ switch (token.Type)
+ {
+ case Newtonsoft.Json.Linq.JTokenType.String:
+ return (string)token;
+ case Newtonsoft.Json.Linq.JTokenType.Integer:
+ return token.ToString();
+ case Newtonsoft.Json.Linq.JTokenType.Boolean:
+ return (bool)token ? "true" : "false";
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// A chat line in a voice (D140): the site's format with the words put into it, turned
+ /// into the game's rich text. Said by us, with no player as its sender, so it works
+ /// whether or not BetterChat is loaded.
+ ///
+ private string Voice(string format, string message)
+ {
+ // BetterChat's own rule for a player's words (its GetOutput), for the same reason: the
+ // line is ours to style and the words inside it are not.
+ if (message.Contains("[#") || message.Contains("[+"))
+ message = message.Replace("[", string.Empty).Replace("]", string.Empty);
+
+ return covalence.FormatText(format.Replace("{message}", message));
+ }
+
+ private static int Occurrences(string text, string needle)
+ {
+ int count = 0;
+
+ for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal))
+ count++;
+
+ return count;
+ }
+
+ private Dictionary IntegrationsBody()
+ {
+ return new Dictionary
+ {
+ { "betterChat", IntegrationBody(BetterChatPlugin()) },
+ { "popupNotifications", IntegrationBody(PopupPlugin()) }
+ };
+ }
+
+ /// A version only for a plugin that is loaded. Absent, not empty.
+ private static Dictionary IntegrationBody(Plugin plugin)
+ {
+ var body = new Dictionary { { "loaded", plugin != null } };
+
+ if (plugin != null)
+ body["version"] = plugin.Version.ToString();
+
+ return body;
+ }
+
+ // ---- group styles (D138, D139, D143) ----
+
+ ///
+ /// The BetterChat fields a sync asks for on one group. A field this build does not
+ /// know, or a value that is not a string, number or boolean, is reported failed and
+ /// left out rather than guessed at.
+ ///
+ private List ChatStyle(PermSyncJob job, string group, Dictionary row)
+ {
+ var style = new List();
+ object raw;
+
+ if (!row.TryGetValue("chat", out raw))
+ return style;
+
+ var fields = raw as Newtonsoft.Json.Linq.JObject;
+
+ if (fields == null)
+ return style;
+
+ foreach (Newtonsoft.Json.Linq.JProperty field in fields.Properties())
+ {
+ var want = field.Value as Newtonsoft.Json.Linq.JObject;
+ string value = want != null ? JsonText(want["value"]) : null;
+
+ if (!ChatFieldDefaults.ContainsKey(field.Name) || value == null)
+ {
+ job.ChatFailed.Add(new Dictionary
+ {
+ { "group", group },
+ { "field", field.Name },
+ { "reason", "malformed" }
+ });
+ continue;
+ }
+
+ style.Add(new ChatFieldWant
+ {
+ Field = field.Name,
+ Value = value,
+ Expect = JsonText(want["expect"])
+ });
+ }
+
+ return style;
+ }
+
+ ///
+ /// Main thread. Puts the site's style on one BetterChat group, a field at a time, and
+ /// leaves alone any field somebody changed by hand.
+ ///
+ ///
+ /// A field is written when the game still holds what the site last pushed there, or
+ /// holds BetterChat's default and the site has never pushed it. Anything else is a hand
+ /// edit: it is not written, and it is reported with the game's value (D138, R2). A group
+ /// this sync had to create is written whole, because nobody can have edited it yet.
+ ///
+ ///
+ ///
+ /// The save is the part the API does not do. API_SetGroupField changes a
+ /// field in memory and never writes BetterChat's file, so on its next reload the style
+ /// would revert and read as drift. BetterChat's own chat group set does write
+ /// it, and writes every group, so it is run once per group touched with a value that
+ /// has not changed (D143).
+ ///
+ ///
+ private void ApplyChatStyle(PermSyncJob job, string group)
+ {
+ Plugin betterChat = BetterChatPlugin();
+
+ if (betterChat == null)
+ {
+ ChatFailure(job, group, null, "betterchat-unloaded");
+ return;
+ }
+
+ bool created = false;
+
+ if (!ChatGroupExists(betterChat, group))
+ {
+ // Saves on its own.
+ betterChat.Call("API_AddGroup", group);
+
+ if (!ChatGroupExists(betterChat, group))
+ {
+ ChatFailure(job, group, null, "not-created");
+ return;
+ }
+
+ created = true;
+ }
+
+ Dictionary game = ChatGroupFields(betterChat, group);
+ bool touched = false;
+
+ foreach (ChatFieldWant want in job.ChatStyles[group])
+ {
+ string now = ChatFieldValue(game, want.Field);
+
+ if (now == want.Value)
+ {
+ job.ChatSame++;
+ continue;
+ }
+
+ bool ours = created || now == want.Expect || (want.Expect == null && now == ChatDefault(group, want.Field));
+
+ if (!ours)
+ {
+ job.ChatDrift.Add(new Dictionary
+ {
+ { "group", group },
+ { "field", want.Field },
+ { "game", now }
+ });
+ continue;
+ }
+
+ // The typed answer is why each field goes through the API (D143): `Success`,
+ // `InvalidField` or `InvalidValue`, as a BetterChat enum this plugin cannot name —
+ // so read by its name. Null is a group that is not there, or a value BetterChat's
+ // parser threw on without catching (a number too big for an int).
+ object result = betterChat.Call("API_SetGroupField", group, want.Field, want.Value);
+ string answer = result != null ? result.ToString() : null;
+
+ if (answer != "Success")
+ {
+ ChatFailure(job, group, want.Field, answer ?? "no-answer");
+ continue;
+ }
+
+ job.ChatApplied++;
+ touched = true;
+ }
+
+ if (!touched)
+ return;
+
+ game = ChatGroupFields(betterChat, group);
+ string priority = ChatFieldValue(game, "Priority");
+
+ ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "set", group, "Priority", priority);
+
+ // Read back rather than trusted: the command has no answer a plugin can read, and a
+ // console path that did something else would otherwise be counted as a save.
+ if (ChatFieldValue(ChatGroupFields(betterChat, group), "Priority") == priority)
+ job.ChatSaved++;
+ else
+ ChatFailure(job, group, "Priority", "not-saved");
+ }
+
+ ///
+ /// Main thread. Removes a BetterChat group the site no longer styles (D139). There is
+ /// no API for it, only BetterChat's own chat group remove , run as the server
+ /// console. A group already gone is removed: the retirement is idempotent.
+ ///
+ private void RemoveChatGroup(PermSyncJob job, string group)
+ {
+ Plugin betterChat = BetterChatPlugin();
+
+ if (betterChat == null)
+ {
+ ChatFailure(job, group, null, "betterchat-unloaded");
+ return;
+ }
+
+ if (ChatGroupExists(betterChat, group))
+ ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "remove", group);
+
+ if (ChatGroupExists(betterChat, group))
+ ChatFailure(job, group, null, "still-present");
+ else
+ job.ChatRemoved.Add(group);
+ }
+
+ private static void ChatFailure(PermSyncJob job, string group, string field, string reason)
+ {
+ var row = new Dictionary { { "group", group }, { "reason", reason } };
+
+ if (field != null)
+ row["field"] = field;
+
+ job.ChatFailed.Add(row);
+ }
+
+ private static bool ChatGroupExists(Plugin betterChat, string group)
+ {
+ object exists = betterChat.Call("API_GroupExists", group);
+ return exists is bool && (bool)exists;
+ }
+
+ private static Dictionary ChatGroupFields(Plugin betterChat, string group)
+ {
+ return betterChat.Call("API_GetGroupFields", group) as Dictionary
+ ?? new Dictionary();
+ }
+
+ ///
+ /// A field's value as the text the site compares: true /false for a
+ /// boolean, never C#'s True , and an invariant number.
+ ///
+ private static string ChatFieldValue(Dictionary fields, string field)
+ {
+ object value = null;
+
+ foreach (KeyValuePair kv in fields)
+ {
+ if (string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase))
+ {
+ value = kv.Value;
+ break;
+ }
+ }
+
+ if (value == null)
+ return null;
+
+ if (value is bool)
+ return (bool)value ? "true" : "false";
+
+ if (value is IFormattable)
+ return ((IFormattable)value).ToString(null, System.Globalization.CultureInfo.InvariantCulture);
+
+ return value.ToString();
+ }
+
+ ///
+ /// BetterChat's default for a field on a new group. A group's title defaults to its own
+ /// name in brackets, except default 's, which is [Player] .
+ ///
+ private static string ChatDefault(string group, string field)
+ {
+ if (string.Equals(field, "Title", StringComparison.OrdinalIgnoreCase))
+ return group == "default" ? "[Player]" : "[" + group + "]";
+
+ string value;
+ return ChatFieldDefaults.TryGetValue(field, out value) ? value : null;
+ }
+
+ ///
+ /// The titles this server holds, from the server console. Given a Steam id it also
+ /// prints what BetterChat would render for that player — which is how a rig proves a
+ /// title without a person typing in chat (§33.3).
+ ///
+ [ConsoleCommand("rg.titles")]
+ private void CmdTitles(ConsoleSystem.Arg arg)
+ {
+ Plugin betterChat = BetterChatPlugin();
+
+ var lines = new List
+ {
+ $"protocol={ProtocolVersion} titles={_titles.Count} " +
+ $"betterChat={(betterChat != null ? betterChat.Version.ToString() : "absent")} " +
+ $"registered={(_titlesRegistered && betterChat != null)}",
+ $"last set: {_lastTitlesSummary}"
+ };
+
+ // `GetString`, not `Args[0]`: current Rust hands a console command StringViews.
+ string steamId = arg.GetString(0, null);
+
+ if (!string.IsNullOrEmpty(steamId))
+ {
+ ulong id;
+ string held;
+
+ lines.Add(ulong.TryParse(steamId, out id) && _titles.TryGetValue(id, out held)
+ ? $"held: {held}"
+ : "held: (none)");
+
+ Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId);
+
+ if (betterChat == null)
+ lines.Add("rendered: (BetterChat is not loaded)");
+ else if (known == null)
+ lines.Add("rendered: (this server has never seen that player)");
+ else
+ lines.Add("rendered: " + (betterChat.Call("API_GetFormattedMessage", known, "(rg.titles)", false) as string ?? "(no answer)"));
+ }
+
+ arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
+ }
+
/// What events are counting and what rewards are held, from the server console.
[ConsoleCommand("rg.rewards")]
private void CmdRewards(ConsoleSystem.Arg arg)