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)