From 8c6146259ea13fa400f2cfaabc7390d54964bcf7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 17:48:05 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(bridge):=20protocol=2012=20=E2=80=94?= =?UTF-8?q?=20BetterChat=20and=20popups=20(phase=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - titles.set: a whole set of chat titles, swapped in by reference and read by a BetterChat third-party title getter (one lookup, no socket). The getter is registered at load and again whenever BetterChat loads. - perm.sync: a group may carry its BetterChat style. Each field is written through API_SetGroupField only when the game still holds what the site last pushed (or BetterChat's default); anything else is reported as drift. One `chat group set` per touched group saves it, because the API never does (D143). A retired style removes the group with `chat group remove` (D139). The report gains `chat`. - chat.say: delivery chat|popup and an optional voice format. A popup with PopupNotifications absent is refused `popup-unavailable`. Every line said is logged, since the game logs neither a broadcast nor a popup. - server.hello/status carry `integrations`; `rg.titles [steamId]` prints what BetterChat would render for a player. - overlay.toml: protocol 12; PopupNotifications leaves requires_plugins (D141). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 15 +- overlay/oxide/plugins/RunicGateway.cs | 724 +++++++++++++++++++++++++- 2 files changed, 729 insertions(+), 10 deletions(-) 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..d9c8f63 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,10 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); + // Hand BetterChat the title getter, if it is here. When it loads later, or reloads and + // forgets us, OnPluginLoaded does this again (§33.1). + 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 +2879,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 +2970,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 +3118,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 +3175,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 +3243,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 +3528,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 +3783,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 +3810,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 +3895,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 +3913,9 @@ namespace Oxide.Plugins { MarkHook("OnPluginUnloaded"); + if (plugin != null && plugin.Name == "BetterChat") + _titlesRegistered = false; + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -8236,6 +8351,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 +8404,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 +8434,553 @@ 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); nothing here is persisted, because the site sends + /// it again on every boot it has not sent to. + /// + /// + /// 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; + + // 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); + } + + 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) From 5ff88610a5c040ebb4b7f7b9c2f0c5aa19d5df8b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 18:23:28 -0500 Subject: [PATCH 2/2] fix(bridge): keep the chat titles across a reload (D144) The walk found that a hot reload of this plugin emptied every title, and the site never re-sent them: it re-sends on a new boot id or wipe id, and a reload is neither, because the boot id names the server process. The set is now written to titles.json with the wipe it was pushed on, and read at OnServerInitialized only when that wipe is still the current one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 77 +++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index d9c8f63..3ffa50b 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1372,8 +1372,10 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); - // Hand BetterChat the title getter, if it is here. When it loads later, or reloads and - // forgets us, OnPluginLoaded does this again (§33.1). + // 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 @@ -8459,8 +8461,15 @@ namespace Oxide.Plugins /// /// 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); nothing here is persisted, because the site sends - /// it again on every boot it has not sent to. + /// 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 @@ -8598,6 +8607,7 @@ namespace Oxide.Plugins } _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. @@ -8622,6 +8632,65 @@ namespace Oxide.Plugins 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");