feat(bridge): protocol 12 — BetterChat titles and styles, popups (phase 17) #12
15
overlay.toml
15
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"]
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace Oxide.Plugins
|
||||
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
|
||||
/// to pair a sidecar and an overlay that disagree.
|
||||
/// </summary>
|
||||
private const int ProtocolVersion = 11;
|
||||
private const int ProtocolVersion = 12;
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
|
||||
/// <summary>Protocol 12: a group's BetterChat style (D138).</summary>
|
||||
EnsureChatStyle,
|
||||
|
||||
/// <summary>Protocol 12: a styled group's BetterChat group, gone from the site (D139).</summary>
|
||||
RetireChatGroup
|
||||
}
|
||||
|
||||
private struct PermOp
|
||||
@@ -2950,6 +2972,42 @@ namespace Oxide.Plugins
|
||||
public int CreditsWithdrawn;
|
||||
public bool CreditsWaiting;
|
||||
public readonly List<string> CreditsUnresolved = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
|
||||
/// was not, no style op is compiled and the report says <c>loaded: false</c> and
|
||||
/// nothing else: the permissions sync exactly as they did before.
|
||||
/// </summary>
|
||||
public bool ChatLoaded;
|
||||
|
||||
public string ChatVersion;
|
||||
|
||||
/// <summary>The fields the site wants on each styled group.</summary>
|
||||
public readonly Dictionary<string, List<ChatFieldWant>> ChatStyles =
|
||||
new Dictionary<string, List<ChatFieldWant>>();
|
||||
|
||||
public int ChatApplied;
|
||||
public int ChatSaved;
|
||||
public int ChatSame;
|
||||
public readonly List<object> ChatDrift = new List<object>();
|
||||
public readonly List<object> ChatRemoved = new List<object>();
|
||||
public readonly List<object> ChatFailed = new List<object>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One BetterChat field the site wants on a group (protocol 12): the value, and the
|
||||
/// value this site last pushed there.
|
||||
/// </summary>
|
||||
private sealed class ChatFieldWant
|
||||
{
|
||||
public string Field;
|
||||
public string Value;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<ChatFieldWant> 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<string, object> 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<string, object> { { "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<string>();
|
||||
|
||||
foreach (KeyValuePair<string, long> 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<string, object> 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";
|
||||
|
||||
/// <summary>The most titles one set carries. The site has its own, lower, limit.</summary>
|
||||
private const int TitlesMax = 500;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const int TitleTextMax = 1024;
|
||||
|
||||
/// <summary>The longest voice format a chat line may carry (§33.2).</summary>
|
||||
private const int ChatFormatMax = 512;
|
||||
|
||||
/// <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>).
|
||||
///
|
||||
/// <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
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private volatile Dictionary<ulong, string> _titles = new Dictionary<ulong, string>();
|
||||
|
||||
/// <summary>The getter BetterChat holds, kept so every registration hands it the same delegate.</summary>
|
||||
private Func<Oxide.Core.Libraries.Covalence.IPlayer, string> _titleGetter;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="OnPluginLoaded"/> registers again (§33.1).
|
||||
/// </summary>
|
||||
private bool _titlesRegistered;
|
||||
|
||||
/// <summary>The last title set, for <c>rg.titles</c>. Diagnostics only.</summary>
|
||||
private string _lastTitlesSummary = "none yet";
|
||||
|
||||
/// <summary>
|
||||
/// The twelve fields of a BetterChat group (D138), by the name its API takes, with
|
||||
/// BetterChat 5.2.15's default for each. <c>Title</c> is absent: its default is the
|
||||
/// group's own name in brackets (<see cref="ChatDefault"/>).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> ChatFieldDefaults =
|
||||
new Dictionary<string, string>(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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BetterChat's third-party title getter. It runs <b>on the chat path</b>, 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Main thread. Hands BetterChat our title getter, if BetterChat is here.</summary>
|
||||
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 <steamId>` 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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void HandleTitlesSet(Dictionary<string, object> 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<ulong, string>();
|
||||
|
||||
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<string, object> 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);
|
||||
}
|
||||
|
||||
/// <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");
|
||||
frame["reason"] = reason;
|
||||
frame["message"] = message;
|
||||
|
||||
if (reqId != null)
|
||||
frame["reqId"] = reqId;
|
||||
|
||||
_lastTitlesSummary = $"refused ({reason}): {message}";
|
||||
EmitFrame(frame);
|
||||
}
|
||||
|
||||
/// <summary>A JSON string or number as text, or null for anything else.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, object> IntegrationsBody()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
|
||||
{ "popupNotifications", IntegrationBody(PopupPlugin()) }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A version only for a plugin that is loaded. Absent, not empty.</summary>
|
||||
private static Dictionary<string, object> IntegrationBody(Plugin plugin)
|
||||
{
|
||||
var body = new Dictionary<string, object> { { "loaded", plugin != null } };
|
||||
|
||||
if (plugin != null)
|
||||
body["version"] = plugin.Version.ToString();
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// ---- group styles (D138, D139, D143) ----
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private List<ChatFieldWant> ChatStyle(PermSyncJob job, string group, Dictionary<string, object> row)
|
||||
{
|
||||
var style = new List<ChatFieldWant>();
|
||||
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<string, object>
|
||||
{
|
||||
{ "group", group },
|
||||
{ "field", field.Name },
|
||||
{ "reason", "malformed" }
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
style.Add(new ChatFieldWant
|
||||
{
|
||||
Field = field.Name,
|
||||
Value = value,
|
||||
Expect = JsonText(want["expect"])
|
||||
});
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The save is the part the API does not do.</b> <c>API_SetGroupField</c> 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 <c>chat group set</c> does write
|
||||
/// it, and writes every group, so it is run once per group touched with a value that
|
||||
/// has not changed (D143).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<string, object> 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<string, object>
|
||||
{
|
||||
{ "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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. Removes a BetterChat group the site no longer styles (D139). There is
|
||||
/// no API for it, only BetterChat's own <c>chat group remove</c>, run as the server
|
||||
/// console. A group already gone is removed: the retirement is idempotent.
|
||||
/// </summary>
|
||||
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<string, object> { { "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<string, object> ChatGroupFields(Plugin betterChat, string group)
|
||||
{
|
||||
return betterChat.Call("API_GetGroupFields", group) as Dictionary<string, object>
|
||||
?? new Dictionary<string, object>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A field's value as the text the site compares: <c>true</c>/<c>false</c> for a
|
||||
/// boolean, never C#'s <c>True</c>, and an invariant number.
|
||||
/// </summary>
|
||||
private static string ChatFieldValue(Dictionary<string, object> fields, string field)
|
||||
{
|
||||
object value = null;
|
||||
|
||||
foreach (KeyValuePair<string, object> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BetterChat's default for a field on a new group. A group's title defaults to its own
|
||||
/// name in brackets, except <c>default</c>'s, which is <c>[Player]</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[ConsoleCommand("rg.titles")]
|
||||
private void CmdTitles(ConsoleSystem.Arg arg)
|
||||
{
|
||||
Plugin betterChat = BetterChatPlugin();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"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()));
|
||||
}
|
||||
|
||||
/// <summary>What events are counting and what rewards are held, from the server console.</summary>
|
||||
[ConsoleCommand("rg.rewards")]
|
||||
private void CmdRewards(ConsoleSystem.Arg arg)
|
||||
|
||||
Reference in New Issue
Block a user