From 3d9998ce64015f498a316d98cc9f4772f28a3124 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 07:00:44 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20protocol=2010=20=E2=80=94=20the?= =?UTF-8?q?=20tally,=20kit=20credits=20and=20chat=20(phase=2013b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tally.open / .snapshot / .close: a run's participation tally, persisted in tallies.json. Presence accrues every 5 s from ZoneManager's own membership (or the whole server); kills are the last hit, gated by killsOf and the killer being inside the area (D98); the score is seconds, kills, or minutes + killWeight x kills (D99). Only a zone this run opened counts (D100). Gated by EventsEnabled (D107); forgotten seven days after opening (D108). - kits.list: the kit source, from Kits' GetKitNames/GetKitInfo. - perm.sync `credits`: one extra use of a kit per reward (D103), held in credits.json and spent LAST from OnKitRedeemed, because Kits' SetPlayerKitUses does nothing for a player with no usage record. Withdrawn when unredeemed; reset when Kits wipes its use counts. - chat.say: one line to the server, remembered by key for 10 minutes. - EventsMaxRecipients / EventsMaxTallies / EventsMaxChat bounds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 8 +- overlay/oxide/plugins/RunicGateway.cs | 1232 ++++++++++++++++++++++++- 2 files changed, 1233 insertions(+), 7 deletions(-) diff --git a/overlay.toml b/overlay.toml index 76fff64..7b6c852 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,11 +26,11 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 9 — the transport, the read path, identity, the permission mirror, +# Current: 10 — 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, and the world verbs events place and give back -# (docs/rust-link/PROTOCOL.md §15). -protocol = 9 +# 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). +protocol = 10 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 70b44af..da708ad 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 = 9; + private const int ProtocolVersion = 10; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -238,7 +238,8 @@ namespace Oxide.Plugins "OnGroupPermissionGranted", "OnGroupPermissionRevoked", "OnPluginLoaded", "OnPluginUnloaded", "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", - "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged" + "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", + "OnKitRedeemed" }; private void Init() @@ -321,6 +322,14 @@ namespace Oxide.Plugins // unloads — and they are NOT forgotten: the next load re-creates them (D92). StopWorldTimer(); + // A tally is kept across a reload — it records who was there, and the next load + // carries on counting — so what it has counted since the last minute's save is written + // now rather than lost. + StopTallyTimer(); + + if (_talliesDirty) + SaveTallies(); + _running = false; _stopping.Set(); Wake.Set(); @@ -386,6 +395,18 @@ namespace Oxide.Plugins /// The most NPCs one event step may place (D95). Lowerable, like the above. public int EventsMaxNpcs = WorldHardMaxNpcs; + + /// + /// The most people one reward step may name (protocol 10, §29.5). The website reads + /// it and refuses a step over it; it is never trimmed to fit. + /// + public int EventsMaxRecipients = RecipientsHardMax; + + /// The most tallies that may count at once on this server (§29.5). + public int EventsMaxTallies = TallyHardMax; + + /// The longest line an event or a news post may say in chat, in characters. + public int EventsMaxChat = ChatHardMax; } protected override void LoadDefaultConfig() @@ -419,8 +440,12 @@ namespace Oxide.Plugins // what the website mirrors (D95). _config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates)); _config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs)); + _config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax)); + _config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax)); + _config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax)); - if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null) + if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null || + Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null) Config.WriteObject(_config, true); } @@ -434,6 +459,7 @@ namespace Oxide.Plugins // the holds already in place rather than have them loaded over it afterwards. LoadLeases(); LoadWorld(); + LoadRewards(); } // ---- the outbound queue ---- @@ -893,6 +919,27 @@ namespace Oxide.Plugins HandleWorldOwned(cmd, reqId); break; + case "tally.open": + // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. + HandleTallyOpen(cmd, reqId); + break; + + case "tally.snapshot": + HandleTallySnapshot(cmd, reqId); + break; + + case "tally.close": + HandleTallyClose(cmd, reqId); + break; + + case "kits.list": + HandleKitsList(reqId); + break; + + case "chat.say": + HandleChatSay(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1328,6 +1375,9 @@ namespace Oxide.Plugins { MarkHook("OnServerShutdown"); FlushTallies(); + + if (_talliesDirty) + SaveTallies(); EmitFrame(Frame("server.shutdown", "event")); } @@ -1361,6 +1411,11 @@ namespace Oxide.Plugins // Nor is anything an event placed: the old map, and everything on it, is gone. DropWorldFromOtherWipes(); + // A run's tally is NOT dropped: it records people, not the map, and a zone it counted + // in simply stops gaining anybody. Kit credits are kept too — unless Kits is about to + // clear its use counts, when the ones moved into them go with it (§29.3). + ResetCreditsForWipe(); + Puts($"new save; wipe is now {(_wipeId ?? "unknown")}"); } @@ -1435,6 +1490,12 @@ namespace Oxide.Plugins if (player == null || player.IsNpc) return; + // A tally's player kill (D98): the last hit, never your own. + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + + if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID) + CreditKill(killer, true); + Dictionary frame = Frame("player.death", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; @@ -1517,14 +1578,20 @@ namespace Oxide.Plugins // A real player's death is OnPlayerDeath's, not ours — emitting here too would // double every kill in the feed. Only the NPC kind is ours to count. if (victimPlayer.IsNpc) + { TallyFor(attacker).NpcKills++; + CreditKill(attacker, false); + } return; } + // An animal is an NPC to a run's tally exactly as it is to the leaderboard (§29.5): + // two meanings of one word would be two answers to "how many NPCs did I kill". if (entity is BaseNpc) { TallyFor(attacker).NpcKills++; + CreditKill(attacker, false); return; } @@ -2865,6 +2932,17 @@ namespace Oxide.Plugins public readonly Dictionary> DesiredGroupPermissions = new Dictionary>(); + + /// + /// The site's kit credits for this server (protocol 10, D103), or null when the sync + /// carried none — an older website, whose silence must not withdraw anybody's credit. + /// + public List Credits; + + public int CreditsApplied; + public int CreditsWithdrawn; + public bool CreditsWaiting; + public readonly List CreditsUnresolved = new List(); } private PermSyncJob _permJob; @@ -3106,6 +3184,27 @@ namespace Oxide.Plugins foreach (string group in job.DesiredMembers.Keys) job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); + // ── The kit credits (protocol 10) ────────────────────────────────── + // + // Not store operations, so not drained with them: they are settled once, after the + // grants have landed, and counted in the same report. + if (cmd.ContainsKey("credits")) + { + job.Credits = new List(); + + foreach (Dictionary row in Rows(cmd, "credits")) + { + string steamId = StringField(row, "steamId"); + string kit = StringField(row, "kit"); + int count = IntField(row, "count"); + + if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(kit) || count <= 0) + continue; + + job.Credits.Add(new KitCredit { SteamId = steamId, Kit = kit, Earned = count }); + } + } + if (job.Ops.Count > PermMaxOps) { EmitPermError(reqId, "too-large"); @@ -3167,6 +3266,21 @@ namespace Oxide.Plugins _permJob = null; StopPermTimer(); + + if (job.Credits != null) + { + try + { + ReconcileCredits(job); + } + catch (Exception ex) + { + // The grants have landed and must still be reported; a credit that could not be + // settled is settled by the next sync, or by the player's next redemption. + Puts($"could not settle kit credits: {Flatten(ex.Message)}"); + } + } + SendPermReport(job); } @@ -3564,6 +3678,16 @@ namespace Oxide.Plugins report["notLanded"] = job.NotLanded; report["operations"] = job.Ops.Count; + if (job.Credits != null) + { + // Counted the way `notLanded` is counted for grants: what actually changed in Kits, + // read back, never what was asked for. + report["creditsApplied"] = job.CreditsApplied; + report["creditsWithdrawn"] = job.CreditsWithdrawn; + report["creditsUnresolved"] = job.CreditsUnresolved; + report["creditsWaiting"] = job.CreditsWaiting; + } + if (!string.IsNullOrEmpty(job.SetId)) report["setId"] = job.SetId; @@ -7061,6 +7185,1108 @@ namespace Oxide.Plugins $"last: {_lastWorldSummary}"); } + // ---- the rewards (protocol 10, PLAN.md §29) ---- + // + // Three things, and the website counts none of them: + // + // * **The tally** — who took part in a run. It is counted here because only the game + // sees a player stand in a zone or land the last hit (D81), and it is persisted, + // because a restart in the middle of an event must not forget who was there. + // * **The credits** — each reward is one more use of a kit (D103). The site authors how + // many a player has earned and pushes them with `perm.sync`; this plugin spends them + // LAST, from `OnKitRedeemed`, because Kits cannot be told in advance: its + // `SetPlayerKitUses` does nothing for a player with no usage record of the kit. + // * **The chat line** — `chat.say`, for a run's `rust.announce` and for the news leg. + // + // The zone a tally counts in is asked of ZoneManager on every tick (`GetPlayersInZone`, + // `IsPlayerInZone`) rather than mirrored from `OnEnterZone`/`OnExitZone`. ZoneManager + // already holds each zone's members, and a mirror would have to be reseeded every time a + // zone is re-created (D92) — the tick asks at most `EventsMaxTallies` questions every five + // seconds, and no chatty hook is subscribed at all (R17). + + /// The most tallies that may count at once. EventsMaxTallies may lower it. + private const int TallyHardMax = 8; + + /// + /// The most people one reward step may name. The plugin publishes it and the website + /// refuses a step over it (§29.5): a reward is priced before its tally is read, so this + /// is the price of every mode that is not a count. + /// + private const int RecipientsHardMax = 100; + + /// The longest chat line, in characters. EventsMaxChat may lower it. + private const int ChatHardMax = 256; + + private const float TallyTickSeconds = 5f; + + /// + /// The longest a tally counts, and the longest it is kept at all: seven days from + /// opening, the lease and zone ceiling (D108). A tally that stopped counting earlier is + /// kept for the steps that read it after — collect and the reward — until then, and a + /// teardown closes it sooner. One ceiling for everything, so a website that went away + /// never leaves a tally on disk for ever. + /// + private const long TallyMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; + + /// Core files at most 5 000 participants per step; a tally holds no more. + private const int TallyMaxPeople = 5000; + + /// + /// The most one tick may credit. A tick that arrives late accrues the time that really + /// passed, so a longer interval never rewrites history — but a clock that jumps is not + /// time anybody spent standing in a zone. + /// + private const long TallyMaxStepMs = 60000L; + + /// Every twelfth tick (a minute) writes the file, when anything changed. + private const int TallySaveEveryTicks = 12; + + private const long ChatKeyMemoryMs = 10L * 60L * 1000L; + + private const string ScoreSeconds = "seconds"; + private const string ScoreKills = "kills"; + private const string ScoreBoth = "both"; + + /// One person in one tally. Persisted as it is. + private sealed class Participant + { + public string SteamId; + public string Name; + public double Seconds; + public int Kills; + public long JoinedMs; + } + + /// One run's tally on this server. There is at most one per run (§29.5). + private sealed class RunTally + { + public string RunId; + + /// The idempotency key of the step that opened it; a repeat is answered, not refused. + public string Key; + + /// The zone step's name, as the step gave it, or null for the whole server. + public string Zone; + + /// What that name resolved to when the tally opened (D100). + public string ZoneId; + + public string Score; + public string KillsOf; + public double KillWeight; + + public long OpenedMs; + + /// When counting stops. What was counted is kept until seven days after opening (D108). + public long DeadlineMs; + + /// People who took part after the tally was full, counted so a result can say so. + public int Overflow; + + public Dictionary People = new Dictionary(); + + /// Not persisted: a load starts accruing from the load, never from before a restart. + [JsonIgnore] + public long LastAccrueMs; + } + + /// + /// One player's credits for one kit (D103). Earned is the site's; the other two + /// are this plugin's own record of what it did with them. + /// + private sealed class KitCredit + { + public string SteamId; + public string Kit; + + /// How many the site says this player has earned. Authored there, pushed here. + public int Earned; + + /// How many have been moved into Kits' counter — each one a use count lowered by one. + public int Applied; + + /// How many of those moved uses have since been redeemed. + public int Consumed; + } + + private Dictionary _runTallies = new Dictionary(); + private Dictionary _credits = new Dictionary(); + + /// Recently said chat keys, and when. Main thread only. + private readonly Dictionary _chatKeys = new Dictionary(); + + private Timer _tallyTimer; + private int _tallyTicks; + private bool _talliesDirty; + private string _lastRewardSummary = "none yet"; + + private int MaxTallies() + { + return _config != null ? _config.EventsMaxTallies : TallyHardMax; + } + + private int MaxRecipients() + { + return _config != null ? _config.EventsMaxRecipients : RecipientsHardMax; + } + + private int MaxChat() + { + return _config != null ? _config.EventsMaxChat : ChatHardMax; + } + + private static bool Counting(RunTally t, long now) + { + return now < t.DeadlineMs; + } + + /// Whether a player is somebody a tally counts: a real, connected, living, awake person. + private static bool CountsAsPresent(BasePlayer p) + { + return p != null && !p.IsNpc && p.IsConnected && !p.IsDead() && !p.IsSleeping(); + } + + private static Participant PersonIn(RunTally t, BasePlayer player, long now) + { + string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture); + Participant person; + + if (t.People.TryGetValue(id, out person)) + { + person.Name = player.displayName; + return person; + } + + if (t.People.Count >= TallyMaxPeople) + { + t.Overflow++; + return null; + } + + person = new Participant { SteamId = id, Name = player.displayName, JoinedMs = now }; + t.People[id] = person; + return person; + } + + /// The D99 score: seconds, kills, or minutes present plus the weight times kills. + private static double ScoreOf(RunTally t, Participant p) + { + switch (t.Score) + { + case ScoreSeconds: + return Math.Floor(p.Seconds); + case ScoreKills: + return p.Kills; + default: + return Math.Round(p.Seconds / 60.0 + t.KillWeight * p.Kills, 2); + } + } + + /// Main thread. Start counting who takes part in a run. + private void HandleTallyOpen(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(runId)) + { + EmitRewardError("tally.error", reqId, "malformed", "a tally is opened for a run"); + return; + } + + RunTally existing; + + if (_runTallies.TryGetValue(runId, out existing)) + { + // A retry whose first answer was lost repeats the key; anything else is a second + // tally for one run, which would split its people across two answers (§29.5). + if (!string.IsNullOrEmpty(key) && existing.Key == key) + EmitTallyOk(reqId, existing, true); + else + EmitRewardError("tally.error", reqId, "already-open", + "this run already has a tally on this server, and a run holds one"); + + return; + } + + // The same consent as the world verbs (D94): a tally is an event doing something on + // this server unattended. Reading and closing one are never behind it. + if (_config == null || !_config.EventsEnabled) + { + EmitRewardError("tally.error", reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return; + } + + string score = Lower(StringField(cmd, "score")); + + if (score != ScoreSeconds && score != ScoreKills && score != ScoreBoth) + { + EmitRewardError("tally.error", reqId, "malformed", "a tally scores seconds, kills or both"); + return; + } + + string killsOf = Lower(StringField(cmd, "killsOf")); + + if (score == ScoreSeconds) + { + killsOf = null; + } + else if (killsOf != "players" && killsOf != "npcs" && killsOf != "both") + { + EmitRewardError("tally.error", reqId, "malformed", "a tally that counts kills says whose: players, npcs or both"); + return; + } + + double weight = score == ScoreBoth ? (DoubleField(cmd, "killWeight") ?? 5.0) : 0.0; + + if (weight < 0 || weight > 1000) + { + EmitRewardError("tally.error", reqId, "out-of-range", $"a kill is worth 0 to 1000 minutes, not {weight}"); + return; + } + + long holdMs = LongField(cmd, "holdMs"); + + if (holdMs == 0) + holdMs = TallyMaxHoldMs; + + if (holdMs < 60000L || holdMs > TallyMaxHoldMs) + { + EmitRewardError("tally.error", reqId, "out-of-range", + $"a tally counts for 1 minute to {TallyMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}"); + return; + } + + long now = NowMs(); + int live = 0; + + foreach (RunTally t in _runTallies.Values) + { + if (Counting(t, now)) + live++; + } + + if (live >= MaxTallies()) + { + EmitRewardError("tally.error", reqId, "too-many", + $"this server counts at most {MaxTallies()} tallies at once, and {live} are counting"); + return; + } + + string zone = StringField(cmd, "zone"); + string zoneId = null; + + if (!string.IsNullOrEmpty(zone)) + { + // Only a zone THIS run opened (D100) — never an operator's arena, never another + // run's. Matched on the name its step gave it, the way the step's author wrote it. + var matches = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindZone && e.RunId == runId && + string.Equals((e.Name ?? "").Trim(), zone.Trim(), StringComparison.OrdinalIgnoreCase)) + matches.Add(e); + } + + if (matches.Count == 0) + { + EmitRewardError("tally.error", reqId, "no-zone", + $"this run has opened no zone named '{zone}' on this server — open it in an earlier step"); + return; + } + + if (matches.Count > 1) + { + EmitRewardError("tally.error", reqId, "ambiguous-zone", + $"this run has opened {matches.Count} zones named '{zone}' — give each its own name"); + return; + } + + zoneId = matches[0].Id; + } + + var tally = new RunTally + { + RunId = runId, + Key = key, + Zone = zoneId != null ? zone.Trim() : null, + ZoneId = zoneId, + Score = score, + KillsOf = killsOf, + KillWeight = weight, + OpenedMs = now, + DeadlineMs = now + holdMs, + LastAccrueMs = now + }; + + _runTallies[runId] = tally; + SaveTallies(); + EnsureTallyTimer(); + + _lastRewardSummary = $"tally opened for run {runId} ({score}, {(zoneId ?? "whole server")}) for {holdMs / 60000L} min"; + Puts($"rewards: {_lastRewardSummary}"); + EmitTallyOk(reqId, tally, false); + } + + /// Main thread. Who has taken part in a run so far. Never behind EventsEnabled. + private void HandleTallySnapshot(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + RunTally t; + + if (string.IsNullOrEmpty(runId) || !_runTallies.TryGetValue(runId, out t)) + { + EmitRewardError("tally.error", reqId, "no-tally", "this run has no tally on this server"); + return; + } + + // Brought up to this moment first, so a reward read at the end of an event includes + // its last few seconds. + Accrue(t, NowMs(), null); + + var people = new List(); + + foreach (Participant p in t.People.Values) + { + people.Add(new Dictionary + { + { "steamId", p.SteamId }, + { "name", p.Name }, + { "seconds", (long)Math.Floor(p.Seconds) }, + { "kills", p.Kills }, + { "score", ScoreOf(t, p) }, + { "joinedAt", p.JoinedMs } + }); + } + + Dictionary reply = Frame("tally.snapshot", "reply"); + reply["runId"] = t.RunId; + reply["score"] = t.Score; + reply["killWeight"] = t.KillWeight; + reply["counting"] = Counting(t, NowMs()); + reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs()); + reply["overflow"] = t.Overflow; + reply["people"] = people; + reply["maxRecipients"] = MaxRecipients(); + + if (t.KillsOf != null) + reply["killsOf"] = t.KillsOf; + + if (t.Zone != null) + { + reply["zone"] = t.Zone; + Plugin zm = ZoneManagerPlugin(); + reply["zoneStanding"] = zm != null && zm.Call("CheckZoneID", t.ZoneId) != null; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// Main thread. Stop counting and forget. A tally already gone is a success. + private void HandleTallyClose(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + + if (string.IsNullOrEmpty(runId)) + { + EmitRewardError("tally.error", reqId, "malformed", "a close names the run whose tally to forget"); + return; + } + + bool closed = _runTallies.Remove(runId); + + if (closed) + SaveTallies(); + + _lastRewardSummary = closed ? $"tally for run {runId} closed" : $"tally for run {runId} was already gone"; + + Dictionary reply = Frame("tally.ok", "reply"); + reply["runId"] = runId; + reply["closed"] = closed; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitTallyOk(string reqId, RunTally t, bool repeat) + { + Dictionary reply = Frame("tally.ok", "reply"); + reply["runId"] = t.RunId; + reply["score"] = t.Score; + reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs()); + reply["repeat"] = repeat; + + if (t.Zone != null) + { + reply["zone"] = t.Zone; + reply["zoneId"] = t.ZoneId; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitRewardError(string kind, string reqId, string reason, string message) + { + Dictionary frame = Frame(kind, "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastRewardSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// + /// Main thread. Credits presence up to — and no further than the + /// tally's deadline. is looked up once per tick by the caller. + /// + private void Accrue(RunTally t, long now, Plugin zm) + { + long until = Math.Min(now, t.DeadlineMs); + long from = t.LastAccrueMs > 0 ? t.LastAccrueMs : until; + + if (until <= from) + { + t.LastAccrueMs = Math.Max(t.LastAccrueMs, until); + return; + } + + t.LastAccrueMs = until; + double seconds = Math.Min(until - from, TallyMaxStepMs) / 1000.0; + IEnumerable present; + + if (t.ZoneId != null) + { + zm = zm ?? ZoneManagerPlugin(); + + // A zone that is not standing — ZoneManager unloaded, or a wipe took it — gains + // nobody, and keeps what it has. + if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null) + return; + + present = zm.Call("GetPlayersInZone", t.ZoneId) as List; + } + else + { + present = BasePlayer.activePlayerList; + } + + if (present == null) + return; + + foreach (BasePlayer player in present) + { + if (!CountsAsPresent(player)) + continue; + + Participant person = PersonIn(t, player, now); + + if (person == null) + continue; + + person.Seconds += seconds; + _talliesDirty = true; + } + } + + /// Main thread, every five seconds while any tally is held. + private void TallyTick() + { + long now = NowMs(); + List forget = null; + Plugin zm = ZoneManagerPlugin(); + + foreach (RunTally t in _runTallies.Values) + { + // Forgotten seven days after it opened, whatever its minutes (D108): a website + // that went away must not leave a tally on disk for ever. + if (now >= t.OpenedMs + TallyMaxHoldMs) + { + if (forget == null) + forget = new List(); + + forget.Add(t.RunId); + continue; + } + + if (Counting(t, now) || t.LastAccrueMs < t.DeadlineMs) + Accrue(t, now, zm); + } + + if (forget != null) + { + foreach (string id in forget) + _runTallies.Remove(id); + + _talliesDirty = true; + _lastRewardSummary = $"{forget.Count} tally(s) seven days old were forgotten"; + Puts($"rewards: {_lastRewardSummary}"); + } + + if (_talliesDirty && (++_tallyTicks % TallySaveEveryTicks == 0 || forget != null)) + SaveTallies(); + + if (_runTallies.Count == 0) + StopTallyTimer(); + } + + /// + /// Main thread. The last hit's kill, for every tally counting kills of that kind whose area + /// the killer is standing in when the victim dies (D98). + /// + private void CreditKill(BasePlayer killer, bool victimIsPlayer) + { + if (_runTallies.Count == 0 || killer == null || killer.IsNpc) + return; + + long now = NowMs(); + Plugin zm = null; + bool zmLooked = false; + + foreach (RunTally t in _runTallies.Values) + { + if (!Counting(t, now) || t.KillsOf == null) + continue; + + if (victimIsPlayer ? t.KillsOf == "npcs" : t.KillsOf == "players") + continue; + + if (t.ZoneId != null) + { + if (!zmLooked) + { + zm = ZoneManagerPlugin(); + zmLooked = true; + } + + object inside = zm != null ? zm.Call("IsPlayerInZone", t.ZoneId, killer) : null; + + if (!(inside is bool) || !(bool)inside) + continue; + } + + Participant person = PersonIn(t, killer, now); + + if (person == null) + continue; + + person.Kills++; + _talliesDirty = true; + } + } + + private void EnsureTallyTimer() + { + if (_tallyTimer == null && _runTallies.Count > 0) + _tallyTimer = timer.Every(TallyTickSeconds, TallyTick); + } + + private void StopTallyTimer() + { + if (_tallyTimer != null) + { + _tallyTimer.Destroy(); + _tallyTimer = null; + } + } + + private string TalliesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "tallies.json"); + } + + private string CreditsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "credits.json"); + } + + /// Main thread. Written at most once a minute while counting, and on every open and close. + private void SaveTallies() + { + WriteDataFile(TalliesPath(), new List(_runTallies.Values), "tallies"); + _talliesDirty = false; + } + + private void SaveCredits() + { + WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits"); + } + + private void WriteDataFile(string path, object rows, string what) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom); + + if (File.Exists(path)) + File.Delete(path); + + File.Move(tmp, path); + } + catch (Exception ex) + { + Puts($"could not save the {what}: {Flatten(ex.Message)}"); + } + } + + private List ReadDataFile(string path, string what) + { + try + { + if (File.Exists(path)) + return JsonConvert.DeserializeObject>(File.ReadAllText(path)); + } + catch (Exception ex) + { + Puts($"could not read the {what}: {Flatten(ex.Message)}"); + } + + return null; + } + + /// + /// Main thread, once per load. A tally survives a restart, a reload and a wipe: it records + /// people, not the map. Presence accrues from now — the time the server was down is not + /// time anybody spent in a zone. + /// + private void LoadRewards() + { + _runTallies = new Dictionary(); + _credits = new Dictionary(); + long now = NowMs(); + + List tallies = ReadDataFile(TalliesPath(), "tallies"); + + if (tallies != null) + { + foreach (RunTally t in tallies) + { + if (t == null || string.IsNullOrEmpty(t.RunId)) + continue; + + if (t.People == null) + t.People = new Dictionary(); + + t.LastAccrueMs = Math.Min(now, t.DeadlineMs); + _runTallies[t.RunId] = t; + } + } + + List credits = ReadDataFile(CreditsPath(), "kit credits"); + + if (credits != null) + { + foreach (KitCredit c in credits) + { + if (c != null && !string.IsNullOrEmpty(c.SteamId) && !string.IsNullOrEmpty(c.Kit)) + _credits[CreditKey(c.SteamId, c.Kit)] = c; + } + } + + Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record"); + EnsureTallyTimer(); + } + + // ── Kits ────────────────────────────────────────────────────────────────── + + private Plugin KitsPlugin() + { + return plugins.Find("Kits"); + } + + /// Main thread. The kits a reward can name, and what gates each (the kit source). + private void HandleKitsList(string reqId) + { + Plugin kits = KitsPlugin(); + + if (kits == null) + { + EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server"); + return; + } + + var names = new List(); + kits.Call("GetKitNames", names); + + var rows = new List(); + + foreach (string name in names) + { + if (string.IsNullOrEmpty(name)) + continue; + + var info = kits.Call("GetKitInfo", name) as Newtonsoft.Json.Linq.JObject; + + if (info == null) + continue; + + rows.Add(new Dictionary + { + { "name", name }, + { "permission", (string)info["permission"] ?? "" }, + { "max", info["max"] != null ? (int)info["max"] : 0 }, + { "cooldown", info["cooldown"] != null ? (int)info["cooldown"] : 0 }, + { "hidden", info["hide"] != null && (bool)info["hide"] }, + { "description", Flatten((string)info["description"] ?? "") } + }); + } + + Dictionary reply = Frame("kits.list", "reply"); + reply["kits"] = rows; + reply["maxRecipients"] = MaxRecipients(); + reply["maxTallies"] = MaxTallies(); + reply["maxChat"] = MaxChat(); + reply["eventsEnabled"] = _config != null && _config.EventsEnabled; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static string CreditKey(string steamId, string kit) + { + return steamId + "|" + Lower(kit); + } + + private static int CallInt(Plugin plugin, string method, params object[] args) + { + object value = plugin.Call(method, args); + return value is int ? (int)value : 0; + } + + /// + /// Main thread. Brings one player's credits for one kit into line with what the site says + /// they earned and what Kits says they have used. Three steps, in this order: + /// + /// + /// + /// Notice a redemption. A moved credit lowered the use count to one below the + /// kit's limit; if the count is back at the limit, the player has redeemed it. This is + /// how OnKitRedeemed is read, and it also heals a redemption made while this + /// plugin was unloaded. + /// + /// + /// Withdraw what the site took back (a revert). Only a moved credit not yet + /// redeemed is put back; a redemption already made stands (R16). A use count is + /// never raised past the kit's limit. + /// + /// + /// Spend the next one, last. Only when the player has used the kit up, and one + /// at a time, so the credit is always the use after the kit's own. + /// + /// + /// + private void SettleCredit(Plugin kits, KitCredit c, ref int applied, ref int withdrawn) + { + if (kits == null) + return; + + int max = CallInt(kits, "GetKitMaxUses", c.Kit); + + // A kit with no use limit has no "one more use" to give; its permission is the reward. + if (max <= 0) + return; + + ulong id; + + if (!ulong.TryParse(c.SteamId, out id)) + return; + + int uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit); + + if (c.Applied > c.Consumed && uses >= max) + c.Consumed = c.Applied; + + if (c.Applied > c.Earned) + { + int withdraw = Math.Min(c.Applied - c.Earned, c.Applied - c.Consumed); + + if (withdraw > 0) + { + kits.Call("SetPlayerKitUses", id, c.Kit, Math.Min(uses + withdraw, max)); + uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit); + c.Applied -= withdraw; + withdrawn += withdraw; + } + + // What is left over was redeemed and stands. It is forgiven rather than held + // against the next credit this player earns: the reward it came from is gone. + c.Applied = Math.Min(c.Applied, c.Earned); + c.Consumed = Math.Min(c.Consumed, c.Applied); + } + + if (c.Applied < c.Earned && c.Applied == c.Consumed && uses >= max) + { + // `SetPlayerKitUses` is a no-op for a player with no usage record of this kit, so + // the read-back is what makes the count true — the same rule as the grants (D85). + kits.Call("SetPlayerKitUses", id, c.Kit, max - 1); + + if (CallInt(kits, "GetPlayerKitUses", id, c.Kit) == max - 1) + { + c.Applied++; + applied++; + } + } + } + + /// + /// Main thread, at the end of a perm.sync that carried credits. The field is + /// the site's whole set for this server: a player and kit it no longer names has earned + /// nothing, and their unredeemed credit is withdrawn. + /// + private void ReconcileCredits(PermSyncJob job) + { + var desired = new Dictionary(); + + foreach (KitCredit row in job.Credits) + { + string key = CreditKey(row.SteamId, row.Kit); + KitCredit have; + + if (desired.TryGetValue(key, out have)) + have.Earned += row.Earned; + else + desired[key] = row; + } + + foreach (KitCredit c in _credits.Values) + { + if (!desired.ContainsKey(CreditKey(c.SteamId, c.Kit))) + c.Earned = 0; + } + + foreach (KeyValuePair kv in desired) + { + KitCredit c; + + if (!_credits.TryGetValue(kv.Key, out c)) + { + c = new KitCredit { SteamId = kv.Value.SteamId, Kit = kv.Value.Kit }; + _credits[kv.Key] = c; + } + + c.Earned = kv.Value.Earned; + } + + Plugin kits = KitsPlugin(); + var unknown = new HashSet(); + + foreach (KitCredit c in _credits.Values) + { + if (kits == null) + continue; + + object isKit = kits.Call("IsKit", c.Kit); + + if (!(isKit is bool) || !(bool)isKit) + { + if (c.Earned > 0 && unknown.Add(c.Kit)) + job.CreditsUnresolved.Add(c.Kit); + + continue; + } + + SettleCredit(kits, c, ref job.CreditsApplied, ref job.CreditsWithdrawn); + } + + var empty = new List(); + + foreach (KeyValuePair kv in _credits) + { + if (kv.Value.Earned == 0 && kv.Value.Applied == 0) + empty.Add(kv.Key); + } + + foreach (string key in empty) + _credits.Remove(key); + + job.CreditsWaiting = kits == null; + SaveCredits(); + } + + /// + /// Void, like every hook here: Kits raises this after a redemption has been counted, which + /// is the only moment a player's usage record of the kit is certain to exist. + /// + private void OnKitRedeemed(BasePlayer player, string kitName) + { + MarkHook("OnKitRedeemed"); + + if (player == null || string.IsNullOrEmpty(kitName) || _credits.Count == 0) + return; + + KitCredit c; + + if (!_credits.TryGetValue(CreditKey(player.UserIDString, kitName), out c)) + return; + + int applied = 0, withdrawn = 0; + SettleCredit(KitsPlugin(), c, ref applied, ref withdrawn); + SaveCredits(); + + if (applied > 0) + _lastRewardSummary = $"{player.displayName} redeemed {kitName}; one more use applied from a reward"; + } + + /// + /// Whether Kits clears its usage data at a wipe (its own Wipe player data when the + /// server is wiped, off by default). Read from its config file, because the switch + /// is not in its API — and on Carbon that file is under carbon/configs, which + /// is why the directory comes from the framework and never from a literal (R19). + /// + private bool KitsWipesPlayerData() + { + try + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Kits.json"); + + if (!File.Exists(path)) + return false; + + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + Newtonsoft.Json.Linq.JToken wipe = config["Wipe player data when the server is wiped"]; + + return wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)wipe; + } + catch (Exception ex) + { + Puts($"could not read Kits' wipe setting: {Flatten(ex.Message)}"); + return false; + } + } + + /// + /// Main thread, from OnNewSave. When Kits has wiped every use count, a credit this + /// plugin moved into one is gone with it — so the record of moving it goes too, or the + /// next redemption would be read as spending a credit that no longer exists. What the + /// site says was earned stays: a wipe does not take a reward away. + /// + private void ResetCreditsForWipe() + { + if (_credits.Count == 0 || !KitsWipesPlayerData()) + return; + + foreach (KitCredit c in _credits.Values) + { + c.Applied = 0; + c.Consumed = 0; + } + + SaveCredits(); + Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over"); + } + + // ── Chat ────────────────────────────────────────────────────────────────── + + /// + /// Main thread. One line to everybody on the server. A key said in the last ten minutes + /// is answered ok and not said again, so a retried step never repeats itself. + /// A run's line is behind EventsEnabled; a news post is behind the site's own + /// switch instead (D104), because news is not an event. + /// + private void HandleChatSay(Dictionary cmd, string reqId) + { + string key = StringField(cmd, "key"); + bool fromEvent = cmd.ContainsKey("event") && string.Equals(StringField(cmd, "event"), "true", StringComparison.OrdinalIgnoreCase); + string message = Flatten(StringField(cmd, "message")); + long now = NowMs(); + + if (fromEvent && (_config == null || !_config.EventsEnabled)) + { + EmitRewardError("chat.error", reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return; + } + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(message)) + { + EmitRewardError("chat.error", reqId, "malformed", "a chat line has a key and a message"); + return; + } + + if (message.Length > MaxChat()) + { + EmitRewardError("chat.error", reqId, "too-long", + $"this server says at most {MaxChat()} characters in one line, not {message.Length}"); + return; + } + + var stale = new List(); + + foreach (KeyValuePair kv in _chatKeys) + { + if (now - kv.Value > ChatKeyMemoryMs) + stale.Add(kv.Key); + } + + foreach (string s in stale) + _chatKeys.Remove(s); + + bool repeat = _chatKeys.ContainsKey(key); + + if (!repeat) + { + Server.Broadcast(message); + _chatKeys[key] = now; + _lastRewardSummary = $"said a line ({message.Length} chars) to {BasePlayer.activePlayerList.Count} players"; + } + + Dictionary reply = Frame("chat.ok", "reply"); + reply["said"] = !repeat; + reply["repeat"] = repeat; + reply["players"] = BasePlayer.activePlayerList.Count; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// What events are counting and what rewards are held, from the server console. + [ConsoleCommand("rg.rewards")] + private void CmdRewards(ConsoleSystem.Arg arg) + { + var lines = new List + { + $"protocol={ProtocolVersion} tallies={_runTallies.Count} credits={_credits.Count} " + + $"kits={(KitsPlugin() != null)} maxTallies={MaxTallies()} maxRecipients={MaxRecipients()}" + }; + + long now = NowMs(); + + foreach (RunTally t in _runTallies.Values) + { + lines.Add( + $" run {t.RunId}: {t.Score}{(t.KillsOf != null ? "/" + t.KillsOf : "")} " + + $"{(t.Zone != null ? "zone '" + t.Zone + "'" : "whole server")} people={t.People.Count} " + + $"{(Counting(t, now) ? "counting, " + (t.DeadlineMs - now) / 1000L + "s left" : "closed")}"); + } + + foreach (KitCredit c in _credits.Values) + lines.Add($" credit {c.SteamId} {c.Kit}: earned={c.Earned} applied={c.Applied} redeemed={c.Consumed}"); + + lines.Add($"last: {_lastRewardSummary}"); + arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); + } + // ---- reading a command's fields ---- // // `JsonConvert` hands back `object`, and every field below arrives as one. These are the -- 2.49.1