From 6952fbf12370adde8eed9fd4fb3570a0bc146c15 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 02:15:23 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20protocol=2014=20=E2=80=94=20kit?= =?UTF-8?q?s.uses=20and=20reward=20notices=20(PLAN=5FREDESIGNS=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens protocol 14 (D332): - kits.uses {kit, steamIds} -> {kit, max, cooldown, asked, exhausted, onCooldown}: counts only, bounded by the sync's own bound (§2.6). - perm.sync notices [{key, steamId, message, delivery}] (D333): the site's whole untold set. Told at once to a player who is awake, else on OnPlayerSleepEnded; popup where PopupNotifications is loaded and chosen, chat otherwise. Told keys persist in notices.json until the site stops sending them; the report lists noticesTold. ProtocolVersion and overlay.toml 13 -> 14; OnPlayerSleepEnded joins ExpectedHooks (checkPlugin: 55 hooks, all void). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 8 +- overlay/oxide/plugins/RunicGateway.cs | 311 +++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 6 deletions(-) diff --git a/overlay.toml b/overlay.toml index 55d57c2..daa699b 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,15 +26,17 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 13 — the transport, the read path, identity, the permission mirror, +# Current: 14 — 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), # 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), # and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration -# save that answers at once and reports its reload as a `config.outcome` frame. -protocol = 13 +# save that answers at once and reports its reload as a `config.outcome` frame, +# and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward +# messages a `perm.sync` carries as `notices`. +protocol = 14 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 4217de3..1f61bd3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -56,8 +56,13 @@ namespace Oxide.Plugins /// PROTOCOL_VERSION in the sidecar, and module.json in Module-Rust. The /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. + /// + /// + /// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses, and the + /// one-time reward messages perm.sync carries as notices (D333). + /// /// - private const int ProtocolVersion = 13; + private const int ProtocolVersion = 14; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -250,7 +255,8 @@ namespace Oxide.Plugins "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", "OnEnterZone", "OnExitZone", "OnRgDomesReady", "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", - "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied" + "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied", + "OnPlayerSleepEnded" }; private void Init() @@ -1043,6 +1049,12 @@ namespace Oxide.Plugins HandleKitsList(reqId); break; + case "kits.uses": + // Protocol 14 (the module's PLAN_REDESIGNS.md §2.6). How many of the given + // players have used a kit up, for the warning beside a kit weekend. + HandleKitsUses(cmd, reqId); + break; + case "chat.say": HandleChatSay(cmd, reqId); break; @@ -3609,6 +3621,15 @@ namespace Oxide.Plugins public bool CreditsWaiting; public readonly List CreditsUnresolved = new List(); + /// + /// The site's untold reward messages for this server (protocol 14, D333), or null when + /// the sync carried none — an older website, whose silence must not cancel anything. + /// + public List Notices; + + /// The keys of already told, for the report. + public readonly List NoticesTold = 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 @@ -4394,6 +4415,38 @@ namespace Oxide.Plugins } } + // ── The reward messages (protocol 14, D333) ──────────────────────── + // + // "You earned kit vip", said once to each player, now or when they next wake up. The + // site's WHOLE untold set, like the credits: a notice it stops sending is one it has + // been told about, or one whose reward was withdrawn before anybody heard of it. + if (cmd.ContainsKey("notices")) + { + job.Notices = new List(); + + foreach (Dictionary row in Rows(cmd, "notices")) + { + string key = StringField(row, "key"); + string steamId = StringField(row, "steamId"); + string message = Flatten(StringField(row, "message") ?? ""); + ulong id; + + if (string.IsNullOrEmpty(key) || key.Length > NoticeKeyMax || !ulong.TryParse(steamId, out id) || message.Length == 0) + continue; + + if (job.Notices.Count >= NoticesMax) + break; + + job.Notices.Add(new RewardNotice + { + Key = key, + SteamId = steamId, + Message = message.Length > MaxChat() ? message.Substring(0, MaxChat()) : message, + Delivery = StringField(row, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat + }); + } + } + if (job.Ops.Count > PermMaxOps) { EmitPermError(reqId, "too-large"); @@ -4470,6 +4523,20 @@ namespace Oxide.Plugins } } + if (job.Notices != null) + { + try + { + ApplyNotices(job); + } + catch (Exception ex) + { + // The grants have landed and must still be reported; an untold notice is told + // by the next sync, or when its player wakes. + Puts($"could not tell reward notices: {Flatten(ex.Message)}"); + } + } + SendPermReport(job); } @@ -4817,6 +4884,12 @@ namespace Oxide.Plugins report["creditsWaiting"] = job.CreditsWaiting; } + // Protocol 14 (D333). Every notice of this sync's set that its player has been told — + // at this sync or before it — so the site marks it told and stops sending it. Present + // only when the sync carried `notices`, like the credit counts. + if (job.Notices != null) + report["noticesTold"] = job.NoticesTold; + // 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 } }; @@ -9768,9 +9841,41 @@ namespace Oxide.Plugins public int Consumed; } + /// + /// One "you earned a reward" message for one player (protocol 14, D333). The key names + /// the reward — a run and its step — so a message is told once however many syncs carry + /// it. + /// + private sealed class RewardNotice + { + public string Key; + public string SteamId; + public string Message; + public string Delivery; + } + + /// The most notices one sync carries; the rest wait for the next. + private const int NoticesMax = 2000; + + /// The longest notice key. The site's are short (run:N:step:N). + private const int NoticeKeyMax = 128; + private Dictionary _runTallies = new Dictionary(); private Dictionary _credits = new Dictionary(); + /// + /// The site's untold notices, by key, as the last sync carried them. In memory only: the + /// site sends its whole untold set on every sync, so a reload loses nothing it will not + /// hear again. + /// + private Dictionary _notices = new Dictionary(); + + /// + /// The keys already told, kept on disk until the site stops sending them. A reload + /// between telling a player and the report that says so would otherwise tell them twice. + /// + private HashSet _noticesTold = new HashSet(StringComparer.Ordinal); + /// Recently said chat keys, and when. Main thread only. private readonly Dictionary _chatKeys = new Dictionary(); @@ -10303,6 +10408,128 @@ namespace Oxide.Plugins WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits"); } + // ── Reward notices (protocol 14, PROTOCOL.md §20.2, D333) ────────────────────────────── + // + // "You earned kit vip — type /kit vip", said once to the player a reward step picked, in + // chat or as a popup. The site decides what to say and to whom; this plugin decides only + // WHEN: at once if the player is up and about, otherwise the next time they wake on this + // server. A sleeping or loading player sees no popup and reads no chat, so "online" is not + // enough — awake is. + + private string NoticesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "notices.json"); + } + + private void SaveNoticesTold() + { + WriteDataFile(NoticesPath(), new List(_noticesTold), "told reward notices"); + } + + private void LoadNoticesTold() + { + List told = ReadDataFile(NoticesPath(), "told reward notices"); + + if (told == null) + return; + + foreach (string key in told) + { + if (!string.IsNullOrEmpty(key)) + _noticesTold.Add(key); + } + } + + /// + /// Main thread, at the end of a sync that carried notices. Takes the site's set as + /// the whole truth, forgets told keys the site no longer sends (it has heard, or the + /// reward was withdrawn), tells everybody awake now, and lists every told key of the set + /// for the report. + /// + private void ApplyNotices(PermSyncJob job) + { + var next = new Dictionary(StringComparer.Ordinal); + + foreach (RewardNotice n in job.Notices) + next[n.Key] = n; + + _notices = next; + + int before = _noticesTold.Count; + _noticesTold.RemoveWhere(k => !next.ContainsKey(k)); + bool changed = _noticesTold.Count != before; + + foreach (RewardNotice n in next.Values) + { + if (_noticesTold.Contains(n.Key)) + continue; + + BasePlayer player = AwakePlayer(n.SteamId); + + if (player != null && TellNotice(player, n)) + changed = true; + } + + if (changed) + SaveNoticesTold(); + + foreach (string key in _noticesTold) + job.NoticesTold.Add(key); + } + + /// The player with this Steam id if they are connected and awake, else null. + private static BasePlayer AwakePlayer(string steamId) + { + ulong id; + + if (!ulong.TryParse(steamId, out id)) + return null; + + BasePlayer player = BasePlayer.FindByID(id); + return player != null && player.IsConnected && !player.IsSleeping() ? player : null; + } + + /// + /// Main thread. Says one notice to one player and records it told. A popup where + /// PopupNotifications is loaded and the step chose one, chat otherwise (§18) — the reward + /// was granted long before this moment, and the player is told either way. + /// + private bool TellNotice(BasePlayer player, RewardNotice n) + { + Plugin popup = n.Delivery == DeliveryPopup ? PopupPlugin() : null; + + if (popup != null) + popup.Call("CreatePopupNotification", n.Message, player, 0f); + else + player.ChatMessage(n.Message); + + return _noticesTold.Add(n.Key); + } + + /// + /// A player woke — after connecting, or after sleeping in a bag. Their untold notices are + /// said now. The site hears at the next sync's report. + /// + private void OnPlayerSleepEnded(BasePlayer player) + { + MarkHook("OnPlayerSleepEnded"); + + if (player == null || _notices.Count == 0) + return; + + string steamId = SteamIdOf(player); + bool changed = false; + + foreach (RewardNotice n in _notices.Values) + { + if (n.SteamId == steamId && !_noticesTold.Contains(n.Key) && TellNotice(player, n)) + changed = true; + } + + if (changed) + SaveNoticesTold(); + } + private void WriteDataFile(string path, object rows, string what) { try @@ -10377,7 +10604,9 @@ namespace Oxide.Plugins } } - Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record"); + LoadNoticesTold(); + + Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits and {_noticesTold.Count} told notices on record"); EnsureTallyTimer(); } @@ -11333,6 +11562,82 @@ namespace Oxide.Plugins EmitFrame(reply); } + /// + /// Main thread. Protocol 14 (PROTOCOL.md §20.1; the module's PLAN_REDESIGNS.md §2.6). Of + /// the given players, how many have used kit up and how many are waiting out its + /// cooldown — the warning beside a kit weekend ("9 of 41 linked players have used it + /// up"). Counts, never a list: the website asked about players it already knows, + /// and the answer it needs is how many, not who. + /// + /// + /// Two Kits reads per player, so the list is bounded by the sync's own bound: the site + /// sends its linked players for this server, and a sync carries no more than that. + /// + /// + private void HandleKitsUses(Dictionary cmd, string reqId) + { + Plugin kits = KitsPlugin(); + + if (kits == null) + { + EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server"); + return; + } + + string kit = StringField(cmd, "kit"); + + if (string.IsNullOrEmpty(kit) || !(kits.Call("GetKitInfo", kit) is Newtonsoft.Json.Linq.JObject)) + { + EmitRewardError("kits.error", reqId, "unknown-kit", $"this server has no kit named '{Flatten(kit ?? "")}'"); + return; + } + + var ids = new List(); + + foreach (string raw in Strings(cmd, "steamIds")) + { + ulong id; + + if (ulong.TryParse(raw, out id)) + ids.Add(id); + } + + if (ids.Count > PermMaxOps) + { + EmitRewardError("kits.error", reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}"); + return; + } + + int max = CallInt(kits, "GetKitMaxUses", kit); + int exhausted = 0; + int onCooldown = 0; + + foreach (ulong id in ids) + { + // A kit with no use limit is never used up. + if (max > 0 && CallInt(kits, "GetPlayerKitUses", id, kit) >= max) + exhausted++; + + object cooldown = kits.Call("GetPlayerKitCooldown", id, kit); + + if (cooldown is double && (double)cooldown > 0) + onCooldown++; + } + + Dictionary reply = Frame("kits.uses", "reply"); + reply["kit"] = kit; + reply["max"] = max; + reply["cooldown"] = CallInt(kits, "GetKitCooldown", kit); + reply["asked"] = ids.Count; + reply["exhausted"] = exhausted; + reply["onCooldown"] = onCooldown; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + private static string CreditKey(string steamId, string kit) { return steamId + "|" + Lower(kit); -- 2.49.1