From 5ff88610a5c040ebb4b7f7b9c2f0c5aa19d5df8b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 18:23:28 -0500 Subject: [PATCH] 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");