fix(bridge): keep the chat titles across a reload (D144)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 22s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 18:23:28 -05:00
parent 8c6146259e
commit 5ff88610a5

View File

@@ -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
/// <summary>
/// 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 (<c>titles.set</c>); nothing here is persisted, because the site sends
/// it again on every boot it has not sent to.
/// the whole set (<c>titles.set</c>).
///
/// <para>
/// <b>Kept on disk as well (D144).</b> The site re-sends on a new boot id or wipe id, and
/// a hot reload of this plugin — an update, an <c>oxide.reload</c> — 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.
/// </para>
///
/// <para>
/// <b>Swapped, never edited.</b> BetterChat reads this through <see cref="TitleFor"/> 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);
}
/// <summary>What <c>titles.json</c> holds: the set, and the wipe it was pushed on.</summary>
private sealed class TitleFile
{
public string WipeId;
public Dictionary<string, string> Titles = new Dictionary<string, string>();
}
private string TitlesPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json");
}
/// <summary>Main thread. Written on every set the site pushes — a set arrives only when it changed.</summary>
private void SaveTitles()
{
var file = new TitleFile { WipeId = _wipeId };
foreach (KeyValuePair<ulong, string> kv in _titles)
file.Titles[kv.Key.ToString()] = kv.Value;
WriteDataFile(TitlesPath(), file, "titles");
}
/// <summary>
/// Main thread, from <c>OnServerInitialized</c> — 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.
/// </summary>
private void LoadTitles()
{
try
{
string path = TitlesPath();
if (!File.Exists(path))
return;
var file = JsonConvert.DeserializeObject<TitleFile>(File.ReadAllText(path));
if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId)
return;
var loaded = new Dictionary<ulong, string>();
foreach (KeyValuePair<string, string> 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<string, object> frame = Frame("titles.error", "reply");