From 042727e5fe61f8978dc5c07bed8fb989a5f1e3fb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 17:04:41 -0500 Subject: [PATCH 01/18] =?UTF-8?q?fix(bridge):=20protocol=2013=20opens=20?= =?UTF-8?q?=E2=80=94=20UTF-8=20from=20the=20site,=20a=20reload=20that=20fa?= =?UTF-8?q?ils=20on=20evidence=20(F15,=20F9,=20F10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F15: the read loop cast each byte to a char, decoding the site's UTF-8 as Latin-1, so every non-ASCII announcement, title, kit name and saved config file arrived mangled. Lines are now split as bytes and decoded whole, which keeps a character that straddles two reads intact. F9/F10 (D177, D178): config.write answers at once with pending, writeId and ceilingMs, and the outcome follows as a config.outcome event. A pending reload reads the framework log each second and rolls back the moment the target fails to compile or initialise; a slow compile is waited for, under a 30 s ceiling per phase. At the ceiling the plugin looks at what is loaded before restoring anything, and a rollback waits for the restore's own reload so the outcome says whether the plugin is back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 8 +- overlay/oxide/plugins/RunicGateway.cs | 629 ++++++++++++++++++++++---- 2 files changed, 551 insertions(+), 86 deletions(-) diff --git a/overlay.toml b/overlay.toml index 2ba4501..33a24ba 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,13 +26,15 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 12 — the transport, the read path, identity, the permission mirror, +# Current: 13 — 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). -protocol = 12 +# 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 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index b8595ae..23082ac 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 = 12; + private const int ProtocolVersion = 13; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -321,7 +321,8 @@ namespace Oxide.Plugins // A config write waiting on a reload dies here too, and its files stay as written. // That is the honest outcome: the rollback exists to undo an edit that took a plugin // down, and this plugin being unloaded is not evidence of either — it is usually THIS - // plugin being reloaded by somebody. The caller times out and re-reads. + // plugin being reloaded by somebody. No `config.outcome` follows; the website calls a + // write it never heard about lost once twice `ceilingMs` has passed, and re-reads. StopConfigWatch(); _configJob = null; @@ -768,10 +769,25 @@ namespace Oxide.Plugins } } + /// + /// Reader thread. Splits the stream into lines as bytes and decodes each finished + /// line as UTF-8. + /// + /// + /// Bytes, not characters, because a character can straddle two reads: the first half of + /// an em dash at the end of one buffer and the rest at the start of the next. Splitting + /// on the byte is safe where splitting the text is not — UTF-8 never uses 0x0A + /// inside a multi-byte sequence, so a newline byte is always a newline — and decoding + /// the whole line at once is what keeps the halves together. The old loop cast each byte + /// to a char, which is Latin-1, and turned every non-ASCII character the website + /// sent into two or three wrong ones: announcements, titles, kit names, and the text of + /// every configuration file saved from the site (F15). + /// + /// private void ReadLoop(NetworkStream stream, int epoch) { byte[] buffer = new byte[8192]; - StringBuilder line = new StringBuilder(512); + var line = new MemoryStream(512); bool discarding = false; try @@ -785,9 +801,9 @@ namespace Oxide.Plugins for (int i = 0; i < read; i++) { - char c = (char)buffer[i]; + byte b = buffer[i]; - if (c == '\n') + if (b == (byte)'\n') { if (discarding) { @@ -797,22 +813,22 @@ namespace Oxide.Plugins } else { - Dispatch(line.ToString()); + Dispatch(Encoding.UTF8.GetString(line.GetBuffer(), 0, (int)line.Length)); } - line.Length = 0; + line.SetLength(0); } - else if (c != '\r') + else if (b != (byte)'\r') { if (discarding) continue; - line.Append(c); + line.WriteByte(b); if (line.Length > MaxInboundLineBytes) { Puts("inbound line over the cap; discarding to the next newline"); - line.Length = 0; + line.SetLength(0); discarding = true; } } @@ -4072,23 +4088,66 @@ namespace Oxide.Plugins private const int ConfigMaxWriteFiles = 10; /// - /// How long a reload has to produce OnPluginLoaded before the write is undone. + /// The longest a reload may take before this plugin stops waiting for it (D178). /// /// - /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than - /// taste. The worst path through one write is two of these windows — wait for the - /// edit's reload, give up, restore, wait for the restore's reload — with a caller - /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link - /// sidecar/src/rpc.rs). A window that does not leave room for both makes the - /// rollback report arrive after the only thing waiting to hear it has given up, which - /// is the one outcome worse than no rollback: the plugin is fine and nobody knows. + /// A ceiling, not the test. Protocol 12 rolled back any reload that had not + /// announced itself within four seconds, because the caller held a socket for the whole + /// wait. That window lost to work whose length nothing here controls: the org lead's + /// first walk saw Oxide's compiler idle out, restart, and load Kits 2.85 s later — just + /// past the window — and the plugin put a valid edit back (F9). Protocol 13 answers the + /// save at once and sends the outcome as a frame, so the wait no longer has to fit + /// inside an RPC, and a reload fails on evidence: the framework's log saying the + /// plugin did not compile or initialise (). This number + /// only bounds a reload that says nothing at all. + /// + /// + /// + /// Each phase gets it once — the edit's reload, and the restore's if there is one — and + /// the pending reply carries it as ceilingMs, so the website knows when a write it + /// never heard back about is lost rather than slow. /// /// - private const float ConfigReloadWindowSeconds = 4f; + private const float ConfigReloadCeilingSeconds = 30f; - /// How much of the newest log file is read back when a reload does not arrive. + /// How often a pending reload reads the log for its failure. + private const float ConfigLogPollSeconds = 1f; + + /// The most one poll reads from the log. A server spamming it cannot make a tick expensive. + private const int ConfigLogPollMaxBytes = 64 * 1024; + + /// How much of the newest log file is read back when a reload fails. private const int ConfigLogTailBytes = 8 * 1024; + /// + /// What a failed load says in the log, beside the plugin's name. + /// + /// + /// Lower case, matched case-insensitively. Oxide's are its compiler's and its loader's + /// own words ("Error while compiling", "Failed to initialize plugin", seen on the rig in + /// configuration step 4). Carbon's differ, and the rest of the list is there so that the + /// same file works on both (R19); the Carbon pass of the re-walk is what pins them. + /// + /// + private static readonly string[] ConfigFailureWords = + { + "error while compiling", + "failed to initialize", + "failed initializing", + "failed to compile", + "failed compiling", + "failed to load", + "failed loading", + "unable to load", + }; + + /// Which reload a job is waiting on: the edit's, or the restore's after a rollback. + private enum ConfigPhase + { + Edit, + Restore + } + /// One file, resolved and checked, on its way to disk. private sealed class StagedConfigWrite { @@ -4113,13 +4172,44 @@ namespace Oxide.Plugins /// private sealed class ConfigWriteJob { - public string ReqId; + /// + /// Ours, not the sidecar's reqId. The reply and the later + /// config.outcome both carry it, and the website files the outcome by it — + /// the sidecar's request ids restart with the sidecar, so they cannot name a write + /// across one. + /// + public string WriteId; + public string Reload; public List Staged; + + /// The one-second poll that reads the log and enforces the ceiling. public Timer Watch; - /// Whether the target was seen leaving. It separates "never started" from "did not come back". + public ConfigPhase Phase; + + /// When the current phase's reload was asked for. Each phase gets the whole ceiling. + public DateTime PhaseStarted; + + /// When the write landed, for the outcome's elapsedMs. + public DateTime Started; + + /// Whether the target was seen leaving in this phase. It separates "never started" from "did not come back". public bool Unloaded; + + /// The log file being read, and how far into it this job has read. + public string LogPath; + + public long LogOffset; + + /// A line cut off at the end of the last read, kept until the rest arrives. + public string LogPartial = ""; + + /// Why the edit was rolled back, carried into the outcome once the restore settles. + public string RollbackReason; + + /// The log lines that showed the failure, or the tail read when the ceiling passed. + public string RollbackLog; } private ConfigWriteJob _configJob; @@ -4530,25 +4620,41 @@ namespace Oxide.Plugins return; } - _configJob = new ConfigWriteJob + var job = new ConfigWriteJob { - ReqId = reqId, + WriteId = Guid.NewGuid().ToString("N"), Reload = reload, - Staged = staged + Staged = staged, + Phase = ConfigPhase.Edit, + Started = DateTime.UtcNow, + PhaseStarted = DateTime.UtcNow }; - _configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut); + // Where the log is NOW, before the reload writes anything to it. Only lines after this + // point can be about this reload. + MarkLogPosition(job); + + _configJob = job; if (!ReloadPluginByName(reload)) { - StopConfigWatch(); _configJob = null; RestoreConfig(staged); EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'"); + return; } + + // The reload was asked for and has not finished; it may be seconds away behind a cold + // compile. Answer now — the caller is holding a socket with a ten-second budget — and + // send the outcome as a frame when there is one (F9, D177). + job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick); + SendConfigReport(reqId, staged, false, false, null, null, job); } - /// Main thread. The reload arrived: the plugin is back, so the write stands. + /// + /// Main thread. OnPluginLoaded named the target: in the edit phase the write + /// stands; in the restore phase the plugin is back on its old file. + /// private void ConfigReloadArrived() { ConfigWriteJob job = _configJob; @@ -4556,42 +4662,144 @@ namespace Oxide.Plugins if (job == null) return; - StopConfigWatch(); - _configJob = null; - - SendConfigReport(job.ReqId, job.Staged, true, false, null, null); + if (job.Phase == ConfigPhase.Edit) + SettleConfigJob(job, true, false, null, null, null); + else + SettleConfigJob(job, false, true, true, job.RollbackReason, job.RollbackLog); } /// - /// Main thread. The window closed with no OnPluginLoaded, which means the plugin - /// is down and the edit is why. Put every file back, reload again, and say so. + /// Main thread, once a second while a reload is pending. Reads what the framework has + /// logged since the last tick, and enforces the ceiling. /// /// - /// This is the feature's real content. Without it, R18 is a web form that takes a - /// required plugin off a production server one typo at a time. + /// A failure is a line, not a silence (D178). Both frameworks log the moment a + /// plugin fails to compile or to initialise, so a broken edit is rolled back about a + /// second after it breaks, and a slow compile — which logs nothing until it succeeds — + /// is simply waited for. The ceiling is for a reload that says nothing either way. + /// + /// + /// + /// What the ceiling does depends on what is loaded, not on the clock (F10). + /// Protocol 12 restored the files whenever the window closed, even over a plugin that + /// was about to finish loading the edit, and then reported "did not come back" about a + /// plugin that did. Now: a target that is loaded again was reloaded and its announcement + /// missed, so the edit stands; one that is loaded and was never seen unloading was never + /// reloaded at all, so the file stands and applies at its next load; only a target that + /// is not loaded has its files put back. /// /// - private void ConfigReloadTimedOut() + private void ConfigWatchTick() { ConfigWriteJob job = _configJob; if (job == null) + { + return; + } + + string failure = ReadConfigFailure(job); + + if (failure != null) + { + if (job.Phase == ConfigPhase.Edit) + { + RollBackConfig(job, $"'{job.Reload}' failed to load: {ShortLine(failure)}", failure); + } + else + { + SettleConfigJob(job, false, true, false, + $"{job.RollbackReason}; the restored file did not load either: {ShortLine(failure)}", + job.RollbackLog + "\n" + failure); + } + + return; + } + + if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds) return; - _configJob = null; - StopConfigWatch(); + bool loaded = IsPluginLoaded(job.Reload); + if (job.Phase == ConfigPhase.Restore) + { + SettleConfigJob(job, false, true, loaded, + loaded + ? job.RollbackReason + : $"{job.RollbackReason}; '{job.Reload}' did not come back on the restored file " + + $"within {ConfigReloadCeilingSeconds:0}s either", + job.RollbackLog); + return; + } + + if (loaded && job.Unloaded) + { + SettleConfigJob(job, true, false, null, + $"'{job.Reload}' is loaded again, but its load was not announced", null); + } + else if (loaded) + { + SettleConfigJob(job, false, false, null, + $"the framework did not reload '{job.Reload}' within {ConfigReloadCeilingSeconds:0}s; " + + "the file stands and applies at its next load", + null); + } + else + { + RollBackConfig(job, + job.Unloaded + ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadCeilingSeconds:0}s" + : $"'{job.Reload}' did not load within {ConfigReloadCeilingSeconds:0}s", + LogTail()); + } + } + + /// + /// Main thread. The edit took the plugin down: put every file back and reload again. + /// + /// + /// This is the feature's real content. Without it, R18 is a web form that takes a + /// required plugin off a production server one typo at a time. The outcome waits for the + /// restore's own reload, so it can say whether the plugin is back, rather than only that + /// the files are. + /// + /// + private void RollBackConfig(ConfigWriteJob job, string reason, string log) + { RestoreConfig(job.Staged); - string reason = job.Unloaded - ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s" - : $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s"; + job.Phase = ConfigPhase.Restore; + job.PhaseStarted = DateTime.UtcNow; + job.Unloaded = false; + job.RollbackReason = reason; + job.RollbackLog = log; - // Fire and forget: the restore is on disk either way, and waiting for a second window - // would spend the caller's whole RPC budget to learn something the operator can see. - ReloadPluginByName(job.Reload); + // The restore's failure, if it has one, is logged after this point. + MarkLogPosition(job); - SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail()); + if (!ReloadPluginByName(job.Reload)) + { + SettleConfigJob(job, false, true, false, + $"{reason}; the files are back, but this framework refused to reload '{job.Reload}'", log); + } + } + + /// Main thread. The job is over: stop watching and say how it ended. + private void SettleConfigJob( + ConfigWriteJob job, + bool reloaded, + bool rolledBack, + bool? restored, + string reason, + string log) + { + if (_configJob != job) + return; + + StopConfigWatch(); + _configJob = null; + + EmitConfigOutcome(job, reloaded, rolledBack, restored, reason, log); } private void StopConfigWatch() @@ -4605,8 +4813,172 @@ namespace Oxide.Plugins job.Watch = null; } + private bool IsPluginLoaded(string name) + { + Plugin found = plugins.Find(name); + return found != null; + } + + /// Main thread. Points the job at the end of the newest log file. + private void MarkLogPosition(ConfigWriteJob job) + { + job.LogPath = NewestLogFile(); + job.LogPartial = ""; + + try + { + job.LogOffset = job.LogPath != null ? new FileInfo(job.LogPath).Length : 0; + } + catch (Exception) + { + job.LogOffset = 0; + } + } + /// - /// Main thread. What happened, and what is on disk now. + /// Main thread. The first line logged since the last read that says the target failed to + /// load, or null. + /// + /// + /// Bounded per call. A log that rotates mid-job (midnight) is followed to the new file + /// from its start; one that shrank was truncated and is read again from the top. Lines + /// this plugin wrote itself are skipped, so a message of ours that names the target + /// beside the word "failed" cannot roll back a reload that is going fine. + /// + /// + private string ReadConfigFailure(ConfigWriteJob job) + { + try + { + string newest = NewestLogFile(); + + if (newest == null) + return null; + + if (!string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase)) + { + job.LogPath = newest; + job.LogOffset = 0; + job.LogPartial = ""; + } + + string text; + + using (var stream = new FileStream(job.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + if (stream.Length < job.LogOffset) + { + job.LogOffset = 0; + job.LogPartial = ""; + } + + long available = stream.Length - job.LogOffset; + + if (available <= 0) + return null; + + int take = (int)Math.Min(available, ConfigLogPollMaxBytes); + stream.Seek(job.LogOffset, SeekOrigin.Begin); + + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + job.LogOffset += read; + + text = job.LogPartial + Encoding.UTF8.GetString(buffer, 0, read); + } + + int lastBreak = text.LastIndexOf('\n'); + + if (lastBreak < 0) + { + job.LogPartial = text.Length > ConfigLogPollMaxBytes ? "" : text; + return null; + } + + job.LogPartial = text.Substring(lastBreak + 1); + + if (job.LogPartial.Length > ConfigLogPollMaxBytes) + job.LogPartial = ""; + + string own = "[" + Title + "]"; + + foreach (string raw in text.Substring(0, lastBreak).Split('\n')) + { + string line = raw.TrimEnd('\r'); + + if (line.IndexOf(own, StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + if (IsLoadFailureOf(line, job.Reload)) + return line; + } + } + catch (Exception) + { + // A log that cannot be read costs the fast path and nothing else: the ceiling + // still settles the job. + } + + return null; + } + + /// Whether a log line says failed to compile or to initialise. + private static bool IsLoadFailureOf(string line, string plugin) + { + if (string.IsNullOrEmpty(line) || string.IsNullOrEmpty(plugin)) + return false; + + string lower = line.ToLowerInvariant(); + bool failed = false; + + foreach (string word in ConfigFailureWords) + { + if (lower.IndexOf(word, StringComparison.Ordinal) >= 0) + { + failed = true; + break; + } + } + + return failed && MentionsName(lower, plugin.ToLowerInvariant()); + } + + /// + /// Whether appears as a whole name — so a failure of + /// KitsExtended is not read as one of Kits. A trailing .cs or a + /// version still counts: "Kits.cs", "'Kits v4.4.9'". + /// + private static bool MentionsName(string lower, string name) + { + int from = 0; + + while (true) + { + int at = lower.IndexOf(name, from, StringComparison.Ordinal); + + if (at < 0) + return false; + + int end = at + name.Length; + bool startOk = at == 0 || !char.IsLetterOrDigit(lower[at - 1]); + bool endOk = end == lower.Length || !char.IsLetterOrDigit(lower[end]); + + if (startOk && endOk) + return true; + + from = at + 1; + } + } + + /// A log line short enough for a reason sentence. + private static string ShortLine(string line) + { + string trimmed = (line ?? "").Trim(); + return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…"; + } + + /// + /// Main thread. Each file as it is on disk now. /// /// /// The versions are re-read rather than assumed, because both frameworks merge missing @@ -4615,13 +4987,7 @@ namespace Oxide.Plugins /// conflict with itself on the next save. /// /// - private void SendConfigReport( - string reqId, - List staged, - bool reloaded, - bool rolledBack, - string reason, - string log) + private List ConfigFilesNow(List staged, bool rolledBack) { var files = new List(); @@ -4644,11 +5010,40 @@ namespace Oxide.Plugins files.Add(row); } + return files; + } + + /// + /// Main thread. The reply to config.write. + /// + /// + /// With a job it says "written, reloading": pending, + /// the writeId the outcome will carry, and ceilingMs. Without one it is the + /// whole answer — nothing was asked to reload, or the target was not loaded. + /// + /// + private void SendConfigReport( + string reqId, + List staged, + bool reloaded, + bool rolledBack, + string reason, + string log, + ConfigWriteJob pending = null) + { Dictionary report = Frame("config.report", "reply"); report["ok"] = !rolledBack; report["reloaded"] = reloaded; report["rolledBack"] = rolledBack; - report["files"] = files; + report["pending"] = pending != null; + report["files"] = ConfigFilesNow(staged, rolledBack); + + if (pending != null) + { + report["writeId"] = pending.WriteId; + report["reload"] = pending.Reload; + report["ceilingMs"] = (long)(ConfigReloadCeilingSeconds * 1000); + } if (!string.IsNullOrEmpty(reason)) report["reason"] = reason; @@ -4659,14 +5054,59 @@ namespace Oxide.Plugins if (reqId != null) report["reqId"] = reqId; - _lastConfigSummary = - $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + - $"at={DateTime.UtcNow:HH:mm:ss}Z" + - (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + _lastConfigSummary = pending != null + ? $"files={staged.Count} reloading={pending.Reload} at={DateTime.UtcNow:HH:mm:ss}Z" + : $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + + $"at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); EmitFrame(report); } + /// + /// Main thread. How a pending reload ended, as an event (protocol 13, F9). + /// + /// + /// An event, not a reply, because nothing is waiting for it: the sidecar files it in its + /// feed and the website's ingest settles the write it names. Staff class on the site — + /// the log tail is an operator's console. + /// + /// + private void EmitConfigOutcome( + ConfigWriteJob job, + bool reloaded, + bool rolledBack, + bool? restored, + string reason, + string log) + { + Dictionary frame = Frame("config.outcome", "event"); + frame["writeId"] = job.WriteId; + frame["reload"] = job.Reload; + frame["ok"] = !rolledBack; + frame["reloaded"] = reloaded; + frame["rolledBack"] = rolledBack; + frame["elapsedMs"] = (long)(DateTime.UtcNow - job.Started).TotalMilliseconds; + frame["files"] = ConfigFilesNow(job.Staged, rolledBack); + + if (restored.HasValue) + frame["restored"] = restored.Value; + + if (!string.IsNullOrEmpty(reason)) + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(log)) + frame["log"] = log; + + _lastConfigSummary = + $"files={job.Staged.Count} reloaded={reloaded} rolledBack={rolledBack}" + + (restored.HasValue ? $" restored={restored.Value}" : "") + + $" at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + + EmitFrame(frame); + } + private void EmitConfigError(string reqId, string reason, string detail) { Dictionary frame = Frame("config.error", "reply"); @@ -4811,6 +5251,42 @@ namespace Oxide.Plugins } } + /// + /// The framework's newest log file, or null. The framework's own log is the newest + /// *.txt directly in LogDirectory; plugins that log to files write into + /// subdirectories of it, which this does not look in. + /// + private static string NewestLogFile() + { + try + { + string dir = Interface.Oxide.LogDirectory; + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return null; + + string newest = null; + DateTime newestAt = DateTime.MinValue; + + foreach (string candidate in Directory.GetFiles(dir, "*.txt")) + { + DateTime at = File.GetLastWriteTimeUtc(candidate); + + if (at <= newestAt) + continue; + + newest = candidate; + newestAt = at; + } + + return newest; + } + catch (Exception) + { + return null; + } + } + /// /// The tail of the newest log file, read only when a reload failed. /// @@ -4824,29 +5300,7 @@ namespace Oxide.Plugins { try { - string dir = Interface.Oxide.LogDirectory; - - if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) - return null; - - string[] logs = Directory.GetFiles(dir, "*.txt"); - - if (logs.Length == 0) - return null; - - string newest = null; - DateTime newestAt = DateTime.MinValue; - - foreach (string candidate in logs) - { - DateTime at = File.GetLastWriteTimeUtc(candidate); - - if (at <= newestAt) - continue; - - newest = candidate; - newestAt = at; - } + string newest = NewestLogFile(); if (newest == null) return null; @@ -10279,11 +10733,20 @@ namespace Oxide.Plugins private void CmdConfig(ConsoleSystem.Arg arg) { string root = ConfigRoot(); + ConfigWriteJob job = _configJob; + string waiting = "no"; + + if (job != null) + { + double seconds = (DateTime.UtcNow - job.PhaseStarted).TotalSeconds; + waiting = $"{job.Reload} ({job.Phase.ToString().ToLowerInvariant()}, " + + $"{seconds:0}s of {ConfigReloadCeilingSeconds:0}s)"; + } arg.ReplyWith( $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + - $"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine + + $"waiting={waiting}" + Environment.NewLine + $"last write: {_lastConfigSummary}"); } From 616f4fbca864f3f8852b75477925421f4489954d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 20:21:06 -0500 Subject: [PATCH 02/18] =?UTF-8?q?fix(bridge):=20protocol=2013=20=E2=80=94?= =?UTF-8?q?=20a=20configuration=20reload=20that=20works=20on=20Carbon=20(W?= =?UTF-8?q?1-W5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-1 rig walk (PLAYER_WALK configuration 2, 2b, 4 on both rigs) found: - W3: Carbon's Oxide-compatible ReloadPlugin is not a reload. One call unloaded Kits and never compiled it again; the next did nothing. Carbon now reloads with `c.reload` on the server console (ConsoleSystem.Run), as an operator would at the panel. Oxide keeps ReloadPlugin, falling back to `oxide.reload`. - W4: Carbon's log is carbon/logs/Carbon.Core.log, with no *.txt in the directory at all, so the log evidence never fired on Carbon. Chosen by name. - W5: Carbon loads a plugin whose LoadConfig threw and raises Loaded in the same instant. OnPluginLoaded is now noted, and the job settles on the first log read a poll after it. Carbon's "Failed ILoadConfig" is on the word list. - W2: Oxide's "Could not initialize plugin 'Kits v4.4.9' (JsonReaderException: ...)" is on the word list, so the outcome quotes the exception. - W1: Oxide's idle compiler shutdown drops a queued reload with "Timed out waiting for plugin to be compiled: X". The reload is asked for once more; a second give-up settles at once by what is loaded. rg.config prints the log file it follows (log=). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 176 +++++++++++++++++++++++--- 1 file changed, 156 insertions(+), 20 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 23082ac..144aba3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -4124,23 +4124,47 @@ namespace Oxide.Plugins /// /// /// Lower case, matched case-insensitively. Oxide's are its compiler's and its loader's - /// own words ("Error while compiling", "Failed to initialize plugin", seen on the rig in - /// configuration step 4). Carbon's differ, and the rest of the list is there so that the - /// same file works on both (R19); the Carbon pass of the re-walk is what pins them. + /// own words ("Error while compiling", "Failed to initialize plugin", and "Could not + /// initialize plugin 'Kits v4.4.9' (JsonReaderException: …)" when a plugin's own + /// LoadConfig throws, seen on the rig in configuration step 4). The last one + /// carries the exception, so it is the line an operator needs; Oxide follows it with + /// "Previous version of plugin failed to load", which says nothing about why. Carbon's + /// differ, and the rest of the list is there so that the same file works on both (R19); + /// the Carbon pass of the re-walk is what pins them. The first pinned is Carbon + /// 2.0.259's "[Kits] Failed ILoadConfig (Could not convert string to double: abc. …)" — + /// after which Carbon loads the plugin anyway, broken, and raises Loaded. /// /// private static readonly string[] ConfigFailureWords = { "error while compiling", + "could not initialize", "failed to initialize", "failed initializing", "failed to compile", "failed compiling", "failed to load", "failed loading", + "failed iloadconfig", "unable to load", }; + /// + /// What the framework says when it gave up on a compile, beside the plugin's name. + /// + /// + /// Not a failure of the file: Oxide shuts its compiler down after a minute idle, and a + /// reload asked for in that moment is dropped with "Timed out waiting for plugin to be + /// compiled: ZoneManager" while the plugin carries on, loaded, on its old file (seen on + /// the rig, configuration step 2b). The compiler starts again on demand, so the reload + /// is asked for once more; only a second give-up settles the job. + /// + /// + private static readonly string[] ConfigStallWords = + { + "timed out waiting for plugin to be compiled", + }; + /// Which reload a job is waiting on: the edit's, or the restore's after a rollback. private enum ConfigPhase { @@ -4197,6 +4221,12 @@ namespace Oxide.Plugins /// Whether the target was seen leaving in this phase. It separates "never started" from "did not come back". public bool Unloaded; + /// Whether this phase's reload was already asked for again after the framework gave up compiling. + public bool Retried; + + /// When OnPluginLoaded named the target in this phase, or null. The next log read settles it. + public DateTime? LoadedAt; + /// The log file being read, and how far into it this job has read. public string LogPath; @@ -4652,16 +4682,32 @@ namespace Oxide.Plugins } /// - /// Main thread. OnPluginLoaded named the target: in the edit phase the write - /// stands; in the restore phase the plugin is back on its old file. + /// Main thread. OnPluginLoaded named the target. It is noted, not settled: the + /// next log read settles it (W5). + /// + /// + /// "Loaded" is not "loaded well" on Carbon. A config its LoadConfig cannot read + /// is logged as a failure and the plugin is loaded anyway, broken, in the same instant + /// (seen on the rig, configuration step 4). Settling on the hook reported that edit as + /// applied; reading the log first rolls it back, for about a second's wait. + /// /// private void ConfigReloadArrived() { ConfigWriteJob job = _configJob; - if (job == null) + if (job == null || job.LoadedAt.HasValue) return; + job.LoadedAt = DateTime.UtcNow; + } + + /// + /// Main thread. A load that a full log read after it found no failure: in the edit + /// phase the write stands; in the restore phase the plugin is back on its old file. + /// + private void ConfigLoadConfirmed(ConfigWriteJob job) + { if (job.Phase == ConfigPhase.Edit) SettleConfigJob(job, true, false, null, null, null); else @@ -4700,6 +4746,12 @@ namespace Oxide.Plugins string failure = ReadConfigFailure(job); + if (failure != null && IsCompileStallOf(failure, job.Reload)) + { + ConfigCompileStalled(job, failure); + return; + } + if (failure != null) { if (job.Phase == ConfigPhase.Edit) @@ -4716,9 +4768,53 @@ namespace Oxide.Plugins return; } + // A load is trusted once a read that began at least a poll after it found nothing: + // the failure it would have to beat is logged before the load, never after. + if (job.LoadedAt.HasValue && + (DateTime.UtcNow - job.LoadedAt.Value).TotalSeconds >= ConfigLogPollSeconds) + { + ConfigLoadConfirmed(job); + return; + } + if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds) return; + SettleByWhatIsLoaded(job, $"within {ConfigReloadCeilingSeconds:0}s"); + } + + /// + /// Main thread. The framework gave up compiling the target (W1). The first time, the + /// reload is asked for again with a fresh ceiling; the second time, the job is settled + /// at once by what is loaded — the same judgement the ceiling makes, without waiting + /// for it, because the framework has already said it is not going to finish. + /// + private void ConfigCompileStalled(ConfigWriteJob job, string line) + { + if (!job.Retried) + { + job.Retried = true; + job.PhaseStarted = DateTime.UtcNow; + job.Unloaded = false; + job.LoadedAt = null; + MarkLogPosition(job); + + Puts($"the framework gave up compiling '{job.Reload}'; asking for the reload once more"); + + if (ReloadPluginByName(job.Reload)) + return; + } + + SettleByWhatIsLoaded(job, $"before the framework gave up compiling it ({ShortLine(line)})"); + } + + /// + /// Main thread. Settles a reload that said nothing conclusive, by what is loaded — not + /// by the clock (F10). finishes the sentence: "within 30s", or + /// why it stopped sooner. + /// + private void SettleByWhatIsLoaded(ConfigWriteJob job, string when) + { bool loaded = IsPluginLoaded(job.Reload); if (job.Phase == ConfigPhase.Restore) @@ -4727,7 +4823,7 @@ namespace Oxide.Plugins loaded ? job.RollbackReason : $"{job.RollbackReason}; '{job.Reload}' did not come back on the restored file " + - $"within {ConfigReloadCeilingSeconds:0}s either", + $"{when} either", job.RollbackLog); return; } @@ -4740,7 +4836,7 @@ namespace Oxide.Plugins else if (loaded) { SettleConfigJob(job, false, false, null, - $"the framework did not reload '{job.Reload}' within {ConfigReloadCeilingSeconds:0}s; " + + $"the framework did not reload '{job.Reload}' {when}; " + "the file stands and applies at its next load", null); } @@ -4748,8 +4844,8 @@ namespace Oxide.Plugins { RollBackConfig(job, job.Unloaded - ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadCeilingSeconds:0}s" - : $"'{job.Reload}' did not load within {ConfigReloadCeilingSeconds:0}s", + ? $"'{job.Reload}' unloaded and did not come back {when}" + : $"'{job.Reload}' did not load {when}", LogTail()); } } @@ -4771,6 +4867,8 @@ namespace Oxide.Plugins job.Phase = ConfigPhase.Restore; job.PhaseStarted = DateTime.UtcNow; job.Unloaded = false; + job.Retried = false; + job.LoadedAt = null; job.RollbackReason = reason; job.RollbackLog = log; @@ -4909,7 +5007,7 @@ namespace Oxide.Plugins if (line.IndexOf(own, StringComparison.OrdinalIgnoreCase) >= 0) continue; - if (IsLoadFailureOf(line, job.Reload)) + if (IsLoadFailureOf(line, job.Reload) || IsCompileStallOf(line, job.Reload)) return line; } } @@ -4924,23 +5022,34 @@ namespace Oxide.Plugins /// Whether a log line says failed to compile or to initialise. private static bool IsLoadFailureOf(string line, string plugin) + { + return SaysAbout(line, plugin, ConfigFailureWords); + } + + /// Whether a log line says the framework gave up compiling (W1). + private static bool IsCompileStallOf(string line, string plugin) + { + return SaysAbout(line, plugin, ConfigStallWords); + } + + private static bool SaysAbout(string line, string plugin, string[] words) { if (string.IsNullOrEmpty(line) || string.IsNullOrEmpty(plugin)) return false; string lower = line.ToLowerInvariant(); - bool failed = false; + bool said = false; - foreach (string word in ConfigFailureWords) + foreach (string word in words) { if (lower.IndexOf(word, StringComparison.Ordinal) >= 0) { - failed = true; + said = true; break; } } - return failed && MentionsName(lower, plugin.ToLowerInvariant()); + return said && MentionsName(lower, plugin.ToLowerInvariant()); } /// @@ -5252,9 +5361,16 @@ namespace Oxide.Plugins } /// - /// The framework's newest log file, or null. The framework's own log is the newest - /// *.txt directly in LogDirectory; plugins that log to files write into - /// subdirectories of it, which this does not look in. + /// The framework's newest log file, or null. Oxide's own log is the newest *.txt + /// directly in LogDirectory; plugins that log to files write into subdirectories + /// of it, which this does not look in. + /// + /// + /// Carbon's is Carbon.Core.log in carbon/logs, beside four other + /// *.log files and no *.txt at all (read on the rig, Carbon 2.0.259). Its + /// Harmony log is the one written most often, so "newest" would pick the wrong file; + /// the name is the only reliable way to choose. + /// /// private static string NewestLogFile() { @@ -5265,6 +5381,12 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return null; + if (IsCarbon()) + { + string core = Path.Combine(dir, "Carbon.Core.log"); + return File.Exists(core) ? core : null; + } + string newest = null; DateTime newestAt = DateTime.MinValue; @@ -5336,6 +5458,13 @@ namespace Oxide.Plugins /// private bool ReloadPluginByName(string name) { + // Carbon's Oxide-compatible ReloadPlugin is not a reload (W3, Carbon 2.0.259 on the + // rig): one call unloaded Kits and never compiled it again, the next did nothing at + // all. Its own console command, typed at the panel, reloads the same plugin in about a + // second — so on Carbon the reload IS that command, run on the server console. + if (IsCarbon()) + return RunReloadCommand("c.reload", name); + try { System.Reflection.MethodInfo reload = Interface.Oxide.GetType() @@ -5352,14 +5481,20 @@ namespace Oxide.Plugins Puts($"framework reload of '{name}' threw: {ex.Message}"); } + return RunReloadCommand("oxide.reload", name); + } + + /// Runs a reload command on the server console, as an operator at the panel would. + private bool RunReloadCommand(string command, string name) + { try { - covalence.Server.Command(IsCarbon() ? "c.reload" : "oxide.reload", name); + ConsoleSystem.Run(ConsoleSystem.Option.Server, command, name); return true; } catch (Exception ex) { - Puts($"console reload of '{name}' threw: {ex.Message}"); + Puts($"console {command} of '{name}' threw: {ex.Message}"); return false; } } @@ -10746,6 +10881,7 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + + $"log={NewestLogFile() ?? "(none: a failed reload waits out the ceiling)"}" + Environment.NewLine + $"waiting={waiting}" + Environment.NewLine + $"last write: {_lastConfigSummary}"); } From 586ad7ceba6c5cd707b53277793b60a6f0ecadcd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 21:36:04 -0500 Subject: [PATCH 03/18] =?UTF-8?q?fix(bridge):=20protocol=2013=20step=202?= =?UTF-8?q?=20=E2=80=94=20expiry,=20gathering,=20raids,=20plugin=20loads,?= =?UTF-8?q?=20and=20a=20ZoneManager=20helper=20(F1=20F3=20F4=20F8=20F12=20?= =?UTF-8?q?F13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288). - F13: `world.expired` carries the entity's kind as `what`. It used to write it over the frame's own `kind`, so every expiry left filed as "zone". - F1 (D159): `gathered` also counts OnDispenserBonus (the final-hit bonus), OnCollectiblePickup (hemp, mushrooms, stones off the ground) and OnGrowableGathered (farmed plants); all three are void and in ExpectedHooks. - F3: taking down a building block you own, or one on a cupboard you are authorised on, no longer counts toward `structures`. The frame still goes. - F4: every remaining UserIDString is SteamIdOf(userID), so a player another plugin spawned is never `steamId: null`. - F8 (D184): `plugin.loaded` / `plugin.unloaded`, once the world is ready, with the permissions that plugin added or removed (a diff of the registered list, taken a tick after the hook). - F12 (D181, D182): RunicGatewayZones.cs, a D168 helper shipped beside the bridge, Harmony-postfixes ZoneManager's Zone.InitializeZone and, a tick later, enters every connected player ZoneManager's own IsPositionInZone places inside, through ZoneManager's own OnPlayerEnterZone. Without it (or when it cannot find what it patches) the bridge scores its zones by IsPositionInZone. Hello reports `zoneHelper: { state, version?, reason? }`. - release.yml stages every helper beside the bridge and lists each .cs with its sha256 in manifest.files; the installer and the egg place them all. Not yet compiled: this is walked on both rigs before it merges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 32 +- README.md | 12 +- overlay.toml | 9 +- overlay/oxide/plugins/RunicGateway.cs | 288 ++++++++++++++++-- overlay/oxide/plugins/RunicGatewayZones.cs | 332 +++++++++++++++++++++ 5 files changed, 644 insertions(+), 29 deletions(-) create mode 100644 overlay/oxide/plugins/RunicGatewayZones.cs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 14c8eb9..5f66dba 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -29,8 +29,9 @@ # exact commit being released. # # 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, -# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at -# its top. It does NOT mirror `oxide/plugins/`: the same file goes to +# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, `RunicGatewayZones.cs` +# and `manifest.json` at its top. Every `.cs` in it is listed in the +# manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to # `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each # decide which (§34.2.1). The fixed, unversioned prefix is so a reader never # has to parse the version out of a path to find the manifest that states it. @@ -38,6 +39,10 @@ # 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so # `oxide.plugins` / `c.plugins` on a server names the release it runs. The # committed file keeps its placeholder; the manifest records the commit. +# Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168, +# D182 — today `RunicGatewayZones.cs`, the ZoneManager helper) keeps its own +# version: it does one job, changes rarely, and the bridge reads its version at +# hello to know which job it does. # # The version is never the protocol. The protocol lives in overlay.toml and # moves only when a message shape does; the version moves on every releasable @@ -73,6 +78,9 @@ env: REPO: RunicGateway/Rust-Plugins ARTIFACT: runicgateway-rust-plugin PLUGIN: overlay/oxide/plugins/RunicGateway.cs + # The helpers shipped beside the bridge (D168, D182). Optional at runtime — + # the bridge loads without them — and installed by default. + HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer @@ -279,6 +287,9 @@ jobs: run: | set -euo pipefail [ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; } + for helper in $HELPERS; do + [ -f "$helper" ] || { echo "::error::${helper} is missing"; exit 1; } + done [ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; } node scripts/checkPlugin.js # The stamp below rewrites exactly one [Info(...)] line; zero or two @@ -304,6 +315,19 @@ jobs: grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \ || { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; } + # The helpers go in as committed, each keeping its own [Info] version. + for helper in $HELPERS; do + cp "$helper" "${STAGE}/$(basename "$helper")" + done + + # `files` names every .cs staged, with its sha256 — the installer places + # exactly this set, and checks each file against it. + PAIRS=() + for f in "${STAGE}"/*.cs; do + PAIRS+=("$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)") + done + FILES="$(jq -n '[$ARGS.positional | _nwise(2) | {(.[0]): .[1]}] | add' --args "${PAIRS[@]}")" + # Declarations from overlay.toml. Read, don't hardcode — the point of # that file is that the protocol number lives in one place. toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; } @@ -329,7 +353,7 @@ jobs: --arg min_oxide "${MIN_OXIDE}" \ --arg min_carbon "${MIN_CARBON}" \ --argjson requires "${REQUIRES}" \ - --arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \ + --argjson files "${FILES}" \ '{ component: $component, version: $version, @@ -339,7 +363,7 @@ jobs: min_oxide_version: $min_oxide, min_carbon_version: $min_carbon, requires_plugins: $requires, - files: { "RunicGateway.cs": $sha } + files: $files }' > "${STAGE}/manifest.json" echo "----- manifest.json -----" cat "${STAGE}/manifest.json" diff --git a/README.md b/README.md index bdb5b7f..71e82a6 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,16 @@ unauthenticated command channel on the network. ## Installing it ``` -overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs +overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs +overlay/oxide/plugins/RunicGatewayZones.cs → /oxide/plugins/RunicGatewayZones.cs ``` +`RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager +counts a player in a zone only when its trigger fires on entry, so a zone created, updated or re-created around +somebody already standing there holds nobody. The helper patches that with Harmony. It is optional: without it the +bridge still loads, scores its own zones by position, and reports the helper missing at hello. `rgz.status` in the +server console says whether it patched. + Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load: ```json @@ -69,7 +76,8 @@ egg, both of which deploy a **released** plugin and install the sidecar beside i Every merge to `main` that carries a `feat`, `fix` or `perf` commit cuts a release (`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-.tar.gz` and `SHA256SUMS`. The tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]` -version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the +version stamped to the release), the helpers beside it (each keeping its own version; every `.cs` is listed with +its sha256 in the manifest's `files`, and the installer and the egg place them all) and a `manifest.json` folding in `overlay.toml`: the protocol, the Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and the egg decide where the file goes. A release then asks the installer repo to recompose its Rust bundle. diff --git a/overlay.toml b/overlay.toml index 33a24ba..3093524 100644 --- a/overlay.toml +++ b/overlay.toml @@ -38,9 +38,12 @@ protocol = 13 # ── Oxide compatibility ────────────────────────────────────────────────────── # -# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches -# nothing, so it is expected to work on any reasonably current Oxide. This is the -# oldest build it is known good on. +# The overlay only ADDS files — the bridge, and beside it the ZoneManager helper +# (`RunicGatewayZones.cs`, docs/modules/rust/PLAN_FIXES.md D181, D182), both into +# `oxide/plugins/` — and patches nothing on disk, so it is expected to work on any +# reasonably current Oxide. The helper patches ZoneManager in memory with Harmony, +# which both frameworks load; without it the bridge still loads and falls back. +# This is the oldest build it is known good on. # # There is no `patches_verified_against` key, and there is no `patches/` tier: # Rust's server is a binary and Oxide's hook API is the supported way in, so diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 144aba3..3b5d6ab 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -234,6 +234,7 @@ namespace Oxide.Plugins "OnServerInitialized", "OnServerShutdown", "OnNewSave", "OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned", "OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath", + "OnDispenserBonus", "OnCollectiblePickup", "OnGrowableGathered", "OnPlayerReported", "OnUserBanned", "OnUserUnbanned", "CanUserLogin", "OnUserApproved", "OnUserPermissionGranted", "OnUserPermissionRevoked", @@ -1143,6 +1144,25 @@ namespace Oxide.Plugins // must not do it until the world it would be asking about exists (§28.6). hello["worldReady"] = _worldReady; + // The ZoneManager helper (D182), only where there is a ZoneManager for it to help. + // `patched` is the whole fix; anything else means this plugin scores its zones by + // position and ZoneManager's flags miss somebody already inside a new zone. + if (ZoneManagerPlugin() != null) + { + var helper = new Dictionary { { "state", ZoneHelperState() } }; + Plugin zones = ZoneHelperPlugin(); + + if (zones != null) + { + helper["version"] = zones.Version.ToString(); + + if (zones.Call("StateReason") is string reason) + helper["reason"] = reason; + } + + hello["zoneHelper"] = helper; + } + EmitFrame(hello); } @@ -1163,7 +1183,7 @@ namespace Oxide.Plugins var row = new Dictionary { - { "steamId", player.UserIDString }, + { "steamId", SteamIdOf(player) }, { "name", player.displayName }, { "sleeping", player.IsSleeping() } }; @@ -1298,7 +1318,7 @@ namespace Oxide.Plugins if (attacker != null && !attacker.IsNpc) { frame["attackerType"] = "player"; - frame["attackerId"] = attacker.UserIDString; + frame["attackerId"] = SteamIdOf(attacker); frame["attackerName"] = attacker.displayName; } else @@ -1447,6 +1467,7 @@ namespace Oxide.Plugins // and this hook also fires on a hotload, which is exactly when ZoneManager has just // erased every zone the previous load of this plugin owned (D92). _worldReady = true; + SeedRegisteredPerms(); DropWorldFromOtherWipes(); RecreateZones("load"); @@ -1533,7 +1554,7 @@ namespace Oxide.Plugins _connectedAt[player.userID] = NowMs(); Dictionary frame = Frame("player.connected", "event"); - frame["steamId"] = player.UserIDString; + frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; EmitFrame(frame); } @@ -1550,7 +1571,7 @@ namespace Oxide.Plugins FlushTally(player.userID); Dictionary frame = Frame("player.disconnected", "event"); - frame["steamId"] = player.UserIDString; + frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; if (!string.IsNullOrEmpty(reason)) @@ -1578,7 +1599,7 @@ namespace Oxide.Plugins return; Dictionary frame = Frame("player.respawned", "event"); - frame["steamId"] = player.UserIDString; + frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; EmitFrame(frame); } @@ -1601,7 +1622,7 @@ namespace Oxide.Plugins CreditKill(killer, true); Dictionary frame = Frame("player.death", "event"); - frame["steamId"] = player.UserIDString; + frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; frame["sleeping"] = player.IsSleeping(); @@ -1625,7 +1646,7 @@ namespace Oxide.Plugins return; Dictionary frame = Frame("player.chat", "event"); - frame["steamId"] = player.UserIDString; + frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; frame["channel"] = channel.ToString(); @@ -1650,6 +1671,60 @@ namespace Oxide.Plugins TallyFor(player).Gather(item.info.shortname, item.amount); } + // ---- the rest of what a player gathers (F1, D159) ---- + // + // `OnDispenserGather` alone is the swings. The first walk had 825 wood in the inventory + // and 587 on the tally, and 20 cloth from hemp and none on it: the bonus on a tree's final + // hit is `OnDispenserBonus`, and anything picked up off the ground — hemp, mushrooms, + // stones, a wild pumpkin — is `OnCollectiblePickup`. A farmed plant is + // `OnGrowableGathered`. D159: "gathered" is everything a player harvests, so the + // leaderboard agrees with the inventory. All three are counters (rule 2) and void (rule 1). + + /// The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus. + private void OnDispenserBonus(ResourceDispenser dispenser, BasePlayer player, Item item) + { + MarkHook("OnDispenserBonus"); + + if (player == null || player.IsNpc || item == null || item.info == null) + return; + + TallyFor(player).Gather(item.info.shortname, item.amount); + } + + /// + /// A pick-up off the ground. Void: a non-null return cancels it. It fires BEFORE the + /// items move, so what is counted is the collectible's list — and a pick-up another + /// plugin vetoes is counted anyway, which is a rarity worth less than a second hook. + /// + private void OnCollectiblePickup(CollectibleEntity collectible, BasePlayer player) + { + MarkHook("OnCollectiblePickup"); + + if (player == null || player.IsNpc || collectible == null || collectible.itemList == null) + return; + + Tally tally = TallyFor(player); + + foreach (ItemAmount amount in collectible.itemList) + { + if (amount == null || amount.itemDef == null) + continue; + + tally.Gather(amount.itemDef.shortname, (int)amount.amount); + } + } + + /// A farmed plant, harvested. No return behaviour on either framework. + private void OnGrowableGathered(GrowableEntity plant, Item item, BasePlayer player) + { + MarkHook("OnGrowableGathered"); + + if (player == null || player.IsNpc || item == null || item.info == null) + return; + + TallyFor(player).Gather(item.info.shortname, item.amount); + } + /// /// Everything that dies which is not a player death: NPC kills, which are a counter, /// and raided structures, which are an event with a `staff` class because a @@ -1712,7 +1787,13 @@ namespace Oxide.Plugins // The tally's meaning is unchanged since protocol 2 — building blocks only — so a // leaderboard column does not quietly change what it counts in the middle of a wipe. - if (structure == "block") + // + // **But not your own** (F3). The first walk's org lead broke their own wall and the + // next tally carried `structures: 1`. Taking down a piece you placed, or one on a + // cupboard you are authorised on, is building, not raiding. The frame still goes: the + // site already skips the raid ALERT for an authorised attacker (D59), and a staff feed + // that loses a line because the player happened to own it is a feed with a hole. + if (structure == "block" && !OwnsOrAuthed(attacker, entity)) TallyFor(attacker).Structures++; Dictionary frame = Frame("entity.destroyed", "event"); @@ -1834,7 +1915,7 @@ namespace Oxide.Plugins if (reporter != null) { - frame["reporterId"] = reporter.UserIDString; + frame["reporterId"] = SteamIdOf(reporter); frame["reporterName"] = reporter.displayName; } @@ -2620,7 +2701,7 @@ namespace Oxide.Plugins if (player == null) return; - string steamId = player.UserIDString; + string steamId = SteamIdOf(player); if (string.IsNullOrEmpty(steamId)) return; @@ -2683,7 +2764,7 @@ namespace Oxide.Plugins if (player == null) return; - string steamId = player.UserIDString; + string steamId = SteamIdOf(player); if (string.IsNullOrEmpty(steamId)) return; @@ -3965,13 +4046,90 @@ namespace Oxide.Plugins // produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin // that is DOWN because of an edit the website just made. // - // They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from - // either: a busy server loads and unloads plugins for reasons that have nothing to do with - // us, and a frame per event would be noise on the wire and a trigger nobody asked for. + // Protocol 13 made them a feed as well, and a narrow one (F8, D184). A grant the site made + // for a plugin that was not loaded is `unresolved` until the plugin comes back — and the + // first walk watched it land thirteen minutes after the plugin did, on the fifteen-minute + // audit, under a page promising "it will land by itself". So a load or an unload is now + // announced with the permissions that plugin added or removed, and the site re-syncs when + // that list is not empty. Only once the world is ready: a boot loads every plugin, and a + // boot is the restart sync's job. + + /// The registered permission list as the last load or unload left it. Main thread. + private HashSet _registeredPerms; + + /// + /// Main thread. The list as it is now; the first call is the baseline. From + /// OnServerInitialized, which is also the moment _worldReady turns true. + /// + private void SeedRegisteredPerms() + { + string[] now = permission.GetPermissions(); + _registeredPerms = new HashSet(now ?? new string[0], StringComparer.OrdinalIgnoreCase); + } + + /// + /// Main thread, one tick after the hook. The diff is taken then rather than inside the + /// hook because the order of "the framework drops an unloaded plugin's permissions" and + /// "the framework calls OnPluginUnloaded" is the framework's, and a tick later + /// both have happened on either. Two plugins loaded in the same tick share one diff and + /// the first name gets it — the site's answer (re-sync) is the same either way, and + /// §4.1's owner is taken from the inventory, not from this. + /// + private void EmitPluginChange(string name, string version, bool loaded) + { + if (_registeredPerms == null) + return; + + string[] now = permission.GetPermissions() ?? new string[0]; + HashSet current = new HashSet(now, StringComparer.OrdinalIgnoreCase); + + List changed = new List(); + + if (loaded) + { + foreach (string perm in current) + { + if (!_registeredPerms.Contains(perm)) + changed.Add(perm); + } + } + else + { + foreach (string perm in _registeredPerms) + { + if (!current.Contains(perm)) + changed.Add(perm); + } + } + + _registeredPerms = current; + changed.Sort(StringComparer.Ordinal); + + Dictionary frame = Frame(loaded ? "plugin.loaded" : "plugin.unloaded", "event"); + frame["name"] = name; + + if (!string.IsNullOrEmpty(version)) + frame["version"] = version; + + frame["permissions"] = changed; + EmitFrame(frame); + } + + /// Main thread. Queues for somebody else's plugin, once the world is up. + private void NotePluginChange(Plugin plugin, bool loaded) + { + if (plugin == null || plugin == this || !_worldReady) + return; + + string name = plugin.Name; + string version = plugin.Version.ToString(); + NextTick(() => EmitPluginChange(name, version, loaded)); + } private void OnPluginLoaded(Plugin plugin) { MarkHook("OnPluginLoaded"); + NotePluginChange(plugin, true); // ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an // event opened. They are put back from this plugin's own record (D92). @@ -3995,6 +4153,7 @@ namespace Oxide.Plugins private void OnPluginUnloaded(Plugin plugin) { MarkHook("OnPluginUnloaded"); + NotePluginChange(plugin, false); if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; @@ -6925,6 +7084,52 @@ namespace Oxide.Plugins return plugins.Find("ZoneManager"); } + // ---- the ZoneManager helper (F12, D181, D182) ---- + // + // ZoneManager counts a player in a zone only when its trigger fires on entry, so a zone + // created, updated or re-created around somebody standing there holds nobody. The helper, + // `RunicGatewayZones.cs`, ships beside this file and patches that. It is optional: without it, + // or when it loaded and could not find what it patches, the tally and the kill credit ask + // ZoneManager's public `IsPositionInZone` instead of its membership — so a missing helper + // costs ZoneManager's own flags for somebody already inside, never their score. + + private Plugin ZoneHelperPlugin() + { + return plugins.Find("RunicGatewayZones"); + } + + /// Main thread. patched, unsupported, no-zonemanager or missing. + private string ZoneHelperState() + { + Plugin helper = ZoneHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + /// + /// Main thread. Whether ZoneManager's own membership can be trusted for a zone that may + /// have been made around somebody. Asked once per tally tick or kill, not per player. + /// + private bool ZoneMembershipTrusted() + { + return ZoneHelperState() == "patched"; + } + + /// + /// Main thread. Is this player in the zone: ZoneManager's membership when the helper has + /// made it right, and the zone's shape otherwise (D182). + /// + private static bool InZone(Plugin zm, string zoneId, BasePlayer player, bool trusted) + { + if (zm == null || player == null) + return false; + + object inside = trusted + ? zm.Call("IsPlayerInZone", zoneId, player) + : zm.Call("IsPositionInZone", zoneId, player.transform.position); + + return inside is bool && (bool)inside; + } + /// /// The map's monuments in the one order every caller agrees on: grouped by prefab short /// name, then by position. #n in a value counts within its group, so it names the @@ -7898,7 +8103,12 @@ namespace Oxide.Plugins Dictionary frame = Frame("world.expired", "event"); frame["id"] = e.Id; - frame["kind"] = e.Kind; + + // `what`, never `kind` (F13, protocol 13). Until 13 this line wrote the entity's + // kind over the FRAME's, so every expiry left the plugin filed as `kind: "zone"` + // and nothing listening for `world.expired` ever heard one. `what` is the name the + // site's resource rows already use for the same thing. + frame["what"] = e.Kind; if (!string.IsNullOrEmpty(e.RunId)) frame["runId"] = e.RunId; @@ -8456,7 +8666,24 @@ namespace Oxide.Plugins if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null) return; - present = zm.Call("GetPlayersInZone", t.ZoneId) as List; + if (ZoneMembershipTrusted()) + { + present = zm.Call("GetPlayersInZone", t.ZoneId) as List; + } + else + { + // No helper (D182): ZoneManager's membership misses whoever was standing here + // when the zone was made, so the zone's own shape decides. + var inside = new List(); + + foreach (BasePlayer player in BasePlayer.activePlayerList) + { + if (InZone(zm, t.ZoneId, player, false)) + inside.Add(player); + } + + present = inside; + } } else { @@ -8534,6 +8761,7 @@ namespace Oxide.Plugins long now = NowMs(); Plugin zm = null; bool zmLooked = false; + bool trusted = false; foreach (RunTally t in _runTallies.Values) { @@ -8548,12 +8776,12 @@ namespace Oxide.Plugins if (!zmLooked) { zm = ZoneManagerPlugin(); + trusted = ZoneMembershipTrusted(); zmLooked = true; } - object inside = zm != null ? zm.Call("IsPlayerInZone", t.ZoneId, killer) : null; - - if (!(inside is bool) || !(bool)inside) + // Membership with the helper, the zone's shape without it (D182). + if (!InZone(zm, t.ZoneId, killer, trusted)) continue; } @@ -8913,7 +9141,7 @@ namespace Oxide.Plugins KitCredit c; - if (!_credits.TryGetValue(CreditKey(player.UserIDString, kitName), out c)) + if (!_credits.TryGetValue(CreditKey(SteamIdOf(player), kitName), out c)) return; int applied = 0, withdrawn = 0; @@ -10470,6 +10698,26 @@ namespace Oxide.Plugins /// read it back empty from a sleeper made on the server: an id the map could not name /// is an own dot and a clan mate the site can never find. /// + /// + /// Whether the player placed this piece or is authorised on the cupboard that covers + /// it — the test for "their own base" (F3). Main thread: it reads the building's + /// privilege. A piece with no cupboard is judged on its owner alone. + /// + private static bool OwnsOrAuthed(BasePlayer player, BaseEntity entity) + { + if (player == null || entity == null) + return false; + + if (entity.OwnerID == (ulong)player.userID) + return true; + + // `authorizedPlayers` rather than `IsAuthed`, for AddAuthorization's reason: it is the + // set this plugin already reads for the raid frame, proven on both frameworks. + BuildingPrivlidge privilege = entity.GetBuildingPrivilege(); + return privilege != null && privilege.authorizedPlayers != null + && privilege.authorizedPlayers.Contains(player.userID); + } + private static string SteamIdOf(BasePlayer p) { ulong id = p.userID; diff --git a/overlay/oxide/plugins/RunicGatewayZones.cs b/overlay/oxide/plugins/RunicGatewayZones.cs new file mode 100644 index 0000000..de90edd --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayZones.cs @@ -0,0 +1,332 @@ +// Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge. +// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. +// +// ONE JOB (docs/modules/rust/PLAN_FIXES.md F12, D168, D181, D182): +// +// ZoneManager counts a player as inside a zone only when the zone's trigger fires on ENTRY. A +// zone created, updated or loaded around somebody who is already standing there never fires it, +// so ZoneManager says nobody is inside — and its flags, its enter message and `OnEnterZone` never +// happen for them. The first player walk found it twice: after a restart the org lead woke up +// eighteen metres inside an event zone the bridge had just re-created, and ZoneManager's +// `IsPlayerInZone` said no; and a zone opened around a player did the same. Every participant of +// an event that survives a restart scores nothing until they walk out and back in. +// +// ZoneManager has no public way to ask it to look again: the routine that does (`UpdatePlayerZones`) +// is private and runs only for a player who is connecting. So this helper patches ZoneManager with +// Harmony, which is the org lead's decision (D181): +// +// * **The patch point is `Zone.InitializeZone`**, the one method a zone passes through when it is +// created (`CreateOrUpdateZone`), updated (`Zone.Reset`) or loaded from ZoneManager's own data +// file at startup. A postfix there sees every one of them. +// * **It does not decide who is inside.** One tick later — when ZoneManager has finished +// registering the zone — it asks ZoneManager's own public `IsPositionInZone` for each connected +// player, and enters those it names through ZoneManager's own `OnPlayerEnterZone`. So ZoneManager's +// flags, eject and kill rules, enter message and `OnEnterZone` all apply exactly as if the +// player had walked in. A player ZoneManager already counts is skipped, and ZoneManager refuses +// a second entry anyway, so a trigger that does fire after all changes nothing. +// * **It fixes every zone on the server**, not only the bridge's. That is deliberate: the fault is +// ZoneManager's, and it is the same fault for an arena another plugin made. +// +// It is optional. The bridge never depends on it to load: without it — deleted, or loaded but +// unable to find what it patches after a ZoneManager update — the bridge scores the zones it made +// by `IsPositionInZone` instead (D182), and reports this helper's state at hello so the site can +// say what is missing. `rgz.status` in the server console prints the same state. + +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Oxide.Core.Plugins; +using UnityEngine; + +namespace Oxide.Plugins +{ + [Info("Runic Gateway Zones", "Runic Gateway", "0.1.0")] + [Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded.")] + public class RunicGatewayZones : RustPlugin + { + private const string HarmonyId = "runicgateway.zones"; + + /// The instance the static postfix reports to. Null while unloaded. + private static RunicGatewayZones _instance; + + private Harmony _harmony; + private MethodInfo _patched; + + // Reflected once per ZoneManager load. A ZoneManager reload is a new assembly, so all of it + // is looked up again then. + private Plugin _zoneManager; + private MethodInfo _enter; + private MethodInfo _getZone; + private FieldInfo _definition; + private PropertyInfo _id; + + /// + /// What the bridge reads through : patched, unsupported + /// (ZoneManager is here but not the shape this helper patches), or no-zonemanager. + /// + private string _state = "no-zonemanager"; + private string _reason; + + /// Zones initialised this tick, swept once on the next. Main thread. + private readonly Dictionary _pending = new Dictionary(); + private bool _sweepQueued; + + private void OnServerInitialized() + { + _instance = this; + Patch(); + } + + private void Unload() + { + Unpatch(); + _instance = null; + } + + private void OnPluginLoaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "ZoneManager") + { + Unpatch(); + Patch(); + } + } + + private void OnPluginUnloaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "ZoneManager") + { + Unpatch(); + _state = "no-zonemanager"; + _reason = null; + } + } + + // ── The API the bridge calls ───────────────────────────────────────────── + + /// patched, unsupported or no-zonemanager. + private string State() => _state; + + /// Why the state is not patched, or null. + private string StateReason() => _reason; + + /// The helper's own version, for hello. + private string HelperVersion() => Version.ToString(); + + // ── Patching ───────────────────────────────────────────────────────────── + + private void Patch() + { + _zoneManager = plugins.Find("ZoneManager"); + + if (_zoneManager == null) + { + _state = "no-zonemanager"; + _reason = null; + return; + } + + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + Type zmType = _zoneManager.GetType(); + Type zoneType = zmType.GetNestedType("Zone", BindingFlags.Public | BindingFlags.NonPublic); + MethodInfo initialize = zoneType?.GetMethod("InitializeZone", any); + + _getZone = zmType.GetMethod("GetZoneByID", any, null, new[] { typeof(string) }, null); + _definition = zoneType?.GetField("definition", any); + _id = _definition?.FieldType.GetProperty("Id", any); + _enter = zoneType == null + ? null + : zmType.GetMethod("OnPlayerEnterZone", any, null, new[] { typeof(BasePlayer), zoneType }, null); + + string missing = + zoneType == null ? "ZoneManager.Zone" : + initialize == null ? "Zone.InitializeZone" : + _definition == null || _id == null ? "Zone.definition.Id" : + _enter == null ? "ZoneManager.OnPlayerEnterZone(BasePlayer, Zone)" : + _getZone == null ? "ZoneManager.GetZoneByID(string)" : + null; + + if (missing != null) + { + Fail($"this ZoneManager ({_zoneManager.Version}) has no {missing}"); + return; + } + + try + { + _harmony = new Harmony(HarmonyId); + _harmony.Patch(initialize, postfix: new HarmonyMethod( + typeof(RunicGatewayZones).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic))); + _patched = initialize; + } + catch (Exception ex) + { + Fail($"Harmony could not patch Zone.InitializeZone: {ex.Message}"); + return; + } + + _state = "patched"; + _reason = null; + Puts($"patched ZoneManager {_zoneManager.Version}: a zone takes in the players already inside it"); + + // The zones ZoneManager loaded before this helper did were initialised unwatched — + // which, at a server start, is all of them. Sweep them once now. + SweepAll(); + } + + private void Unpatch() + { + if (_harmony != null && _patched != null) + { + try + { + _harmony.Unpatch(_patched, HarmonyPatchType.Postfix, HarmonyId); + } + catch (Exception ex) + { + PrintWarning($"could not remove the ZoneManager patch: {ex.Message}"); + } + } + + _harmony = null; + _patched = null; + _pending.Clear(); + } + + private void Fail(string reason) + { + Unpatch(); + _state = "unsupported"; + _reason = reason; + PrintWarning($"{reason}. Runic Gateway scores its zones by position instead; ZoneManager's own flags will not apply to somebody already inside a new zone."); + } + + // ── The patch ──────────────────────────────────────────────────────────── + + /// + /// Harmony postfix on ZoneManager.Zone.InitializeZone. Static, as Harmony needs; + /// it only records the zone. Anything thrown here would surface inside ZoneManager, so + /// nothing is allowed to. + /// + private static void AfterInitialize(object __instance) + { + try + { + _instance?.Queue(__instance); + } + catch (Exception) + { + // Never ZoneManager's problem. + } + } + + private void Queue(object zone) + { + object definition = _definition.GetValue(zone); + string id = definition == null ? null : _id.GetValue(definition) as string; + + if (string.IsNullOrEmpty(id)) + return; + + // The newest instance wins: a zone re-created in the same tick is one sweep. + _pending[id] = zone; + + if (_sweepQueued) + return; + + _sweepQueued = true; + + // A tick later, not now. `CreateOrUpdateZone` initialises a zone once with an empty + // definition and again after the arguments are applied, and ZoneManager's startup load + // registers the zone in its table only AFTER initialising it — so `IsPositionInZone` + // answers correctly on the next tick and not before. + NextTick(SweepPending); + } + + private void SweepPending() + { + _sweepQueued = false; + + if (_state != "patched" || _pending.Count == 0) + { + _pending.Clear(); + return; + } + + var batch = new List>(_pending); + _pending.Clear(); + + foreach (KeyValuePair entry in batch) + Sweep(entry.Key, entry.Value); + } + + private void SweepAll() + { + var ids = _zoneManager.Call("GetZoneIDs") as string[]; + + if (ids == null) + return; + + foreach (string id in ids) + Sweep(id, null); + } + + /// + /// Main thread. Enters every connected player ZoneManager says is inside the zone and + /// does not yet count. is the instance to enter them into, or null + /// to look it up (the startup sweep). + /// + private void Sweep(string id, object zone) + { + Plugin zm = _zoneManager; + + if (zm == null || zm.Call("CheckZoneID", id) == null) + return; + + zone = zone ?? _getZone.Invoke(zm, new object[] { id }); + + if (zone == null || (zone is UnityEngine.Object unity && unity == null)) + return; + + int entered = 0; + + foreach (BasePlayer player in BasePlayer.activePlayerList.ToArray()) + { + if (player == null || player.IsNpc || player.transform == null) + continue; + + if (zm.Call("IsPlayerInZone", id, player) is bool already && already) + continue; + + if (!(zm.Call("IsPositionInZone", id, player.transform.position) is bool inside) || !inside) + continue; + + try + { + _enter.Invoke(zm, new[] { (object)player, zone }); + entered++; + } + catch (Exception ex) + { + PrintWarning($"could not enter {player.displayName} into zone {id}: {(ex.InnerException ?? ex).Message}"); + } + } + + if (entered > 0) + Puts($"zone {id}: {entered} player(s) already inside were counted in"); + } + + // ── Console ────────────────────────────────────────────────────────────── + + [ConsoleCommand("rgz.status")] + private void StatusCommand(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}"); + } + } +} From d1d4a00d1dd7f851c5566fc560bcc2181bc9051c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 21:43:22 -0500 Subject: [PATCH 04/18] fix(bridge): the ZoneManager helper copies the player list without ToArray Found compiling on the rust-oxide rig: BasePlayer.activePlayerList is Facepunch's ListHashSet, which has no ToArray, so the helper did not compile. It now copies into a List, which it needs anyway: entering a player can eject or kill them and change the live list mid-loop. HarmonyLib itself resolved on Oxide with no reference directive, and the helper patched ZoneManager 3.1.14 on both frameworks once this compiled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGatewayZones.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGatewayZones.cs b/overlay/oxide/plugins/RunicGatewayZones.cs index de90edd..33c2ecb 100644 --- a/overlay/oxide/plugins/RunicGatewayZones.cs +++ b/overlay/oxide/plugins/RunicGatewayZones.cs @@ -292,7 +292,9 @@ namespace Oxide.Plugins int entered = 0; - foreach (BasePlayer player in BasePlayer.activePlayerList.ToArray()) + // A copy: entering a player can eject or kill them, which changes the live list. + // `activePlayerList` is Facepunch's ListHashSet, which has no ToArray. + foreach (BasePlayer player in new List(BasePlayer.activePlayerList)) { if (player == null || player.IsNpc || player.transform == null) continue; From 8b4b65249fb79f85adda199ad1355b288d2fd659 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 28 Sep 2026 06:58:39 -0500 Subject: [PATCH 05/18] feat(bridge): read the whole permission store (perm.inventory), and sync group parents The permission manager (PLAN_REDESIGNS section 1, D160): the site owns every permission and group on the server, so the bridge now reads all of it. - perm.inventory: every registered permission with the plugin that registered it (PermissionExists(name, plugin), public on both frameworks), every group with title, rank, parent and permissions, every holder's direct permissions and groups (not default), and the pairs event leases hold. Built a slice per tick, sent in pages under the 1 MiB line cap, paged from one snapshot. rg.inventory logs it. - perm.sync sets an existing group's title and rank and every group's parent; an absent title or rank means "leave it" (the adopt policy's hold). The managed list and the foreign scan are gone: the site reads the store and decides. Walked on both rigs: Oxide 85 permissions all owned, Carbon 104 with its 30 adminmodule.* unowned; ZoneManager owns ignoreflag.nokits. Refs RunicGateway/Module-Rust#21 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 762 ++++++++++++++++++++------ 1 file changed, 581 insertions(+), 181 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 3b5d6ab..23202db 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -319,6 +319,12 @@ namespace Oxide.Plugins _permJob = null; StopPermTimer(); + // The same for an inventory being built: the site's request times out and it asks + // again from page 0, which is the only way it ever reads one. + _inventoryJob = null; + _inventory = null; + StopInventoryTimer(); + // A config write waiting on a reload dies here too, and its files stay as written. // That is the honest outcome: the rollback exists to undo an edit that took a plugin // down, and this plugin being unloaded is not evidence of either — it is usually THIS @@ -955,6 +961,12 @@ namespace Oxide.Plugins HandlePermSync(cmd, reqId); break; + case "perm.inventory": + // Protocol 13 (D160). The whole store, owners included, in pages — what the + // site reads before every sync now that it owns all of it. + HandlePermInventory(cmd, reqId); + break; + case "config.list": // Protocol 5 (R18). What settings files exist on this host, and which plugins // are loaded to reload them. A description of the tree, never its contents. @@ -2996,9 +3008,11 @@ namespace Oxide.Plugins // log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing // one plugin is a silent partial. Registering the name here instead would fabricate a // permission the operator never installed (D33). - // • A holder the site does not author is reported `foreign` and never touched (D31). - // Somebody typing `oxide.grant` during an incident is drift, not an error, and the - // website offers an operator two answers to it. + // • A holder the site does not author is never touched by a sync. Until protocol 13 the + // sync scanned the names the site claimed and reported such holders `foreign` (D31). + // Now the site owns the whole store (D160) and reads it with `perm.inventory`, so it + // finds a hand edit itself and answers it with the server's policy (D161): the sync + // only ever carries what the site decided. // • A group member who has never connected is reported `pending`. The store has no user // record to put in a group yet (§12.2 rule 4), and the membership lands on their first // connection — a direct grant would have reached them immediately, and pretending the @@ -3042,8 +3056,12 @@ namespace Oxide.Plugins RetireMember, RetireGrant, RetireGroup, - ScanPermission, - ScanGroup, + + /// + /// Protocol 13: a group's parent (PLAN_REDESIGNS §1.4). Compiled after every group, + /// so the parent it names already exists when it is applied. + /// + EnsureGroupParent, /// Protocol 12: a group's BetterChat style (D138). EnsureChatStyle, @@ -3066,6 +3084,12 @@ namespace Oxide.Plugins public string Title; public int Rank; + + /// + /// Whether the site sent a rank. A group whose frame has none keeps the rank it has, + /// rather than being set to 0 by an absent field. + /// + public bool HasRank; } /// @@ -3083,6 +3107,10 @@ namespace Oxide.Plugins public int Grants; public int Revokes; public int GroupsCreated; + + /// Protocol 13: an existing group whose title, rank or parent the sync changed. + public int GroupsUpdated; + public int GroupsRemoved; public int GroupPermissions; public int GroupPermissionsRemoved; @@ -3092,7 +3120,6 @@ namespace Oxide.Plugins public int Absent; public readonly List Unresolved = new List(); - public readonly List Foreign = new List(); public readonly List Pending = new List(); /// @@ -3103,8 +3130,8 @@ namespace Oxide.Plugins public readonly List NotLanded = new List(); /// - /// Group permissions an event lease holds (protocol 8). Neither applied to nor - /// reported foreign; what the site asked for is kept on the hold instead. + /// Group permissions an event lease holds (protocol 8). Not applied; what the site + /// asked for is kept on the hold instead. /// public readonly List Leased = new List(); @@ -3113,17 +3140,6 @@ namespace Oxide.Plugins /// Permission names already reported unresolved, so each is reported once. public readonly HashSet UnresolvedSeen = new HashSet(); - /// Who the site says should hold each managed permission, for the foreign scan. - public readonly Dictionary> DesiredHolders = - new Dictionary>(); - - /// Who the site says is in each managed group, and what it should carry. - public readonly Dictionary> DesiredMembers = - new Dictionary>(); - - 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. @@ -3259,6 +3275,471 @@ namespace Oxide.Plugins EmitFrame(reply); } + // ---- protocol 13: the whole store (PLAN_REDESIGNS §1.2, D160) ---- + // + // The site owns every permission and group on this server, so it has to be able to read + // all of them — not just the names it once authored, which is all the foreign scan ever + // looked at. One verb, `perm.inventory`, answers with: + // + // • every registered permission and the plugin that REGISTERED it. The owner comes from + // `PermissionExists(name, plugin)` asked of each loaded plugin — public API on both + // frameworks, proven on both rigs — never from the name's prefix, which is how + // `zonemanager.ignoreflag.nokits` ends up filed under Kits in other tools. + // • every group with its title, rank, parent and the permissions it carries itself. + // • every Steam id that holds anything: its own permissions and its groups, except + // `default`, which every connected player is in by the framework's own rule. + // • the group permissions an event lease is holding, which are the lease's, not a hand + // edit's. + // + // Neither framework can list its users, so the holders come from asking each permission + // and each group who has it, and each of those walks every user. On a real store that is + // permissions × users, so it is built a slice per tick, like the sync drain, and then + // sent in pages that each fit the game link's 1 MiB line cap. The snapshot is taken once + // and paged from memory, so a grant made between two pages cannot tear it. + + /// How many permissions or groups one build step asks about. + private const int InventorySourcesPerStep = 25; + + /// How many bytes of rows one page may carry, well inside the 1 MiB line cap. + private const int InventoryPageBudgetBytes = 700 * 1024; + + /// + /// How long a build may run before it is abandoned. The sidecar waits ten seconds for a + /// reply; a build past this has already lost that wait, and a store that large needs a + /// person to hear about it rather than a hitch every sync. + /// + private const long InventoryMaxBuildMs = 8000; + + /// How long a finished snapshot is kept for the pages after the first. + private const long InventoryKeepMs = 2 * 60 * 1000; + + private sealed class InventoryUser + { + public string SteamId; + public string Name; + public readonly List Permissions = new List(); + public readonly List Groups = new List(); + } + + /// A build in flight. There is at most one; a second request is answered `busy`. + private sealed class InventoryJob + { + public string ReqId; + public long StartedMs; + + /// Started from the server console (rg.inventory): logged, never sent. + public bool FromConsole; + + /// Permissions first, then groups prefixed with a NUL, one store question each. + public readonly List Sources = new List(); + + public int Next; + public readonly Dictionary Users = new Dictionary(); + public List Permissions; + public List Groups; + public List Leased; + } + + /// A finished snapshot, paged from memory. + private sealed class Inventory + { + public string Id; + public long BuiltMs; + public long BuildMs; + public List Permissions; + public List Groups; + public List Leased; + public int UserCount; + public readonly List> Pages = new List>(); + } + + private InventoryJob _inventoryJob; + private Timer _inventoryTimer; + private Inventory _inventory; + + /// The last inventory built, for rg.perms. Diagnostics only. + private string _lastInventorySummary = "none yet"; + + /// + /// The same build from the server console, logged rather than sent: how a rig proves the + /// read — and times it on a large store — without the website or the sidecar's token. + /// + [ConsoleCommand("rg.inventory")] + private void CmdInventory(ConsoleSystem.Arg arg) + { + if (arg.Connection != null) + return; + + if (_inventoryJob != null) + { + arg.ReplyWith("an inventory is already being built"); + return; + } + + StartInventory(null, true); + arg.ReplyWith("inventory started; the summary is logged"); + } + + /// Main thread. Page 0 starts a build; any other page is read from the snapshot it names. + private void HandlePermInventory(Dictionary cmd, string reqId) + { + string snapshotId = StringField(cmd, "snapshotId"); + int page = IntField(cmd, "page"); + + if (!string.IsNullOrEmpty(snapshotId)) + { + Inventory held = _inventory; + + if (held == null || held.Id != snapshotId || NowMs() - held.BuiltMs > InventoryKeepMs || + page < 0 || page >= held.Pages.Count) + { + // The site starts again from page 0; a stale page is never a partial answer. + EmitPermError(reqId, "stale"); + return; + } + + EmitInventoryPage(held, page, reqId); + return; + } + + if (_inventoryJob != null) + { + EmitPermError(reqId, "busy"); + return; + } + + StartInventory(reqId, false); + } + + /// Main thread. Reads the small parts at once and starts the sliced holder walk. + private void StartInventory(string reqId, bool fromConsole) + { + var job = new InventoryJob { ReqId = reqId, StartedMs = NowMs(), FromConsole = fromConsole }; + + // Owners, groups and leases are small and read at once; the holder walk is sliced. + var owner = new Dictionary(StringComparer.OrdinalIgnoreCase); + string[] registered = permission.GetPermissions() ?? new string[0]; + + foreach (Plugin plugin in plugins.GetAll()) + { + if (plugin == null) + continue; + + foreach (string perm in registered) + { + if (!string.IsNullOrEmpty(perm) && !owner.ContainsKey(perm) && permission.PermissionExists(perm, plugin)) + owner[perm] = plugin.Name; + } + } + + job.Permissions = new List(); + + foreach (string perm in registered) + { + if (string.IsNullOrEmpty(perm)) + continue; + + string lowered = Lower(perm); + var row = new Dictionary { { "name", lowered } }; + string by; + + // A name no plugin owns — Carbon's built-in modules register theirs that way — is + // sent without one, and the site groups it by prefix. + if (owner.TryGetValue(perm, out by)) + row["owner"] = by; + + job.Permissions.Add(row); + job.Sources.Add(lowered); + } + + job.Groups = new List(); + + foreach (string name in permission.GetGroups() ?? new string[0]) + { + if (string.IsNullOrEmpty(name)) + continue; + + string lowered = Lower(name); + var carried = new List(); + + foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0]) + { + if (!string.IsNullOrEmpty(perm)) + carried.Add(Lower(perm)); + } + + job.Groups.Add(new Dictionary + { + { "name", lowered }, + { "title", permission.GetGroupTitle(name) ?? "" }, + { "rank", permission.GetGroupRank(name) }, + { "parent", Lower(permission.GetGroupParent(name)) ?? "" }, + { "permissions", carried } + }); + + // `default` holds every player who ever connected, by the framework's rule rather + // than anybody's decision, so its members are not the site's to own. + if (!string.Equals(lowered, "default", StringComparison.Ordinal)) + job.Sources.Add("\0" + lowered); + } + + job.Leased = new List(); + + foreach (LeaseHold hold in _leases.Values) + { + if (hold.Key != GroupPermissionKey || string.IsNullOrEmpty(hold.Target)) + continue; + + int slash = hold.Target.IndexOf('/'); + + if (slash <= 0) + continue; + + job.Leased.Add(new Dictionary + { + { "kind", "group-permission" }, + { "subject", Lower(hold.Target.Substring(0, slash)) }, + { "object", Lower(hold.Target.Substring(slash + 1)) } + }); + } + + _inventoryJob = job; + _inventoryTimer = timer.Every(PermStepSeconds, StepInventory); + + // A small store — every rig, and most real servers — finishes inside this call. + StepInventory(); + } + + /// Main thread. Asks up to permissions or groups who holds them. + private void StepInventory() + { + InventoryJob job = _inventoryJob; + + if (job == null) + { + StopInventoryTimer(); + return; + } + + if (NowMs() - job.StartedMs > InventoryMaxBuildMs) + { + _inventoryJob = null; + StopInventoryTimer(); + Puts($"permission inventory abandoned after {InventoryMaxBuildMs} ms at {job.Next} of {job.Sources.Count} sources, {job.Users.Count} users"); + + if (!job.FromConsole) + EmitPermError(job.ReqId, "too-large"); + + return; + } + + int budget = InventorySourcesPerStep; + + while (budget-- > 0 && job.Next < job.Sources.Count) + { + string source = job.Sources[job.Next++]; + + try + { + bool isGroup = source.Length > 0 && source[0] == '\0'; + string name = isGroup ? source.Substring(1) : source; + string[] entries = isGroup ? permission.GetUsersInGroup(name) : permission.GetPermissionUsers(name); + + if (entries == null) + continue; + + foreach (string entry in entries) + { + string id = UserId(entry); + + if (string.IsNullOrEmpty(id)) + continue; + + InventoryUser user; + + if (!job.Users.TryGetValue(id, out user)) + { + user = new InventoryUser { SteamId = id, Name = StoredName(entry) }; + job.Users[id] = user; + } + + if (isGroup) + user.Groups.Add(name); + else + user.Permissions.Add(name); + } + } + catch (Exception ex) + { + // One odd name must not lose the whole read; the next sync reads it again. + Puts($"permission inventory: '{source.TrimStart('\0')}' threw: {Flatten(ex.Message)}"); + } + } + + if (job.Next < job.Sources.Count) + return; + + _inventoryJob = null; + StopInventoryTimer(); + + Inventory done = Paginate(job); + _inventory = done; + _lastInventorySummary = $"{done.Id}: {done.Permissions.Count} permissions, {done.Groups.Count} groups, " + + $"{done.UserCount} users, {done.Pages.Count} pages, {done.BuildMs} ms at={DateTime.UtcNow:HH:mm:ss}Z"; + + if (job.FromConsole) + { + // To the log, where a rig walk can read it: the console's own reply never reaches + // Oxide's log file. + Puts($"inventory {_lastInventorySummary}"); + + var owners = new Dictionary(); + + foreach (Dictionary row in done.Permissions) + { + object by; + string key = row.TryGetValue("owner", out by) ? (string)by : "(none)"; + int n; + owners.TryGetValue(key, out n); + owners[key] = n + 1; + } + + var ownerText = new List(); + + foreach (KeyValuePair kv in owners) + ownerText.Add(kv.Key + "=" + kv.Value); + + List sample = done.Pages[0].GetRange(0, Math.Min(10, done.Pages[0].Count)); + + Puts("inventory owners: " + string.Join(", ", ownerText.ToArray())); + Puts("inventory groups: " + JsonConvert.SerializeObject(done.Groups, Formatting.None)); + Puts("inventory leased: " + JsonConvert.SerializeObject(done.Leased, Formatting.None)); + Puts("inventory users: " + JsonConvert.SerializeObject(sample, Formatting.None)); + return; + } + + EmitInventoryPage(done, 0, job.ReqId); + } + + private void StopInventoryTimer() + { + if (_inventoryTimer == null) + return; + + _inventoryTimer.Destroy(); + _inventoryTimer = null; + } + + /// + /// Splits the users into pages by their serialised size. Page 0 also carries the + /// permissions, groups and leases, so its budget is what they leave. + /// + private Inventory Paginate(InventoryJob job) + { + var inv = new Inventory + { + Id = Guid.NewGuid().ToString("N").Substring(0, 12), + BuiltMs = NowMs(), + BuildMs = NowMs() - job.StartedMs, + Permissions = job.Permissions, + Groups = job.Groups, + Leased = job.Leased, + UserCount = job.Users.Count + }; + + int header = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(new Dictionary + { + { "permissions", job.Permissions }, + { "groups", job.Groups }, + { "leased", job.Leased } + }, Formatting.None)); + + var ids = new List(job.Users.Keys); + ids.Sort(StringComparer.Ordinal); + + var current = new List(); + int bytes = header; + + foreach (string id in ids) + { + InventoryUser u = job.Users[id]; + var row = new Dictionary + { + { "steamId", u.SteamId }, + { "permissions", u.Permissions }, + { "groups", u.Groups } + }; + + if (!string.IsNullOrEmpty(u.Name)) + row["name"] = u.Name; + + int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)) + 1; + + if (current.Count > 0 && bytes + size > InventoryPageBudgetBytes) + { + inv.Pages.Add(current); + current = new List(); + bytes = 0; + } + + current.Add(row); + bytes += size; + } + + // Always at least one page, so an empty store still answers page 0. + inv.Pages.Add(current); + return inv; + } + + private void EmitInventoryPage(Inventory inv, int page, string reqId) + { + Dictionary reply = Frame("perm.inventory", "reply"); + reply["snapshotId"] = inv.Id; + reply["page"] = page; + reply["pages"] = inv.Pages.Count; + reply["more"] = page + 1 < inv.Pages.Count; + reply["users"] = inv.Pages[page]; + + if (page == 0) + { + reply["permissions"] = inv.Permissions; + reply["groups"] = inv.Groups; + reply["leased"] = inv.Leased; + reply["stats"] = new Dictionary + { + { "permissions", inv.Permissions.Count }, + { "groups", inv.Groups.Count }, + { "users", inv.UserCount }, + { "buildMs", inv.BuildMs } + }; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// The name out of a store listing entry — 76561198…(Wanderer) or + /// 76561198… (Wanderer) — or null. Between the FIRST bracket and the LAST, so a + /// name with brackets of its own survives. + /// + private static string StoredName(string entry) + { + if (string.IsNullOrEmpty(entry)) + return null; + + int open = entry.IndexOf('('); + int close = entry.LastIndexOf(')'); + + if (open < 0 || close <= open + 1) + return null; + + string name = entry.Substring(open + 1, close - open - 1).Trim(); + return name.Length > 0 && name != "Unnamed" ? name : null; + } + /// /// Main thread. Compiles a desired set into operations and starts draining it. /// @@ -3291,21 +3772,39 @@ namespace Oxide.Plugins // Order matters and it is not cosmetic: a group has to exist before anything can be // put in it, and a permission has to be on the group before a member inherits it. + // Protocol 13: parents are applied after every group exists, because a group may name + // a parent that comes later in the frame. + var parentOps = new List(); + foreach (Dictionary group in Rows(cmd, "groups")) { string name = Lower(StringField(group, "name")); if (string.IsNullOrEmpty(name)) continue; + // Protocol 13: an absent title or rank means "leave it" — the site holds an + // in-game change to them while a person decides (D161's `adopt`) — and an + // empty title is a title, sent verbatim, not a reason to use the name. job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroup, Subject = name, - Title = StringField(group, "title") ?? name, - Rank = IntField(group, "rank") + Title = group.ContainsKey("title") ? (StringField(group, "title") ?? "") : null, + Rank = IntField(group, "rank"), + HasRank = group.ContainsKey("rank") }); - HashSet wanted = Bucket(job.DesiredGroupPermissions, name); + // Only a frame that says something about the parent changes it: "" clears it, and + // an absent key leaves whatever the group has. + if (group.ContainsKey("parent")) + { + parentOps.Add(new PermOp + { + Kind = PermOpKind.EnsureGroupParent, + Subject = name, + Object = Lower(StringField(group, "parent")) ?? "" + }); + } foreach (string perm in Strings(group, "permissions")) { @@ -3313,7 +3812,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(lowered)) continue; - wanted.Add(lowered); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroupPermission, @@ -3327,7 +3825,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(member)) continue; - Bucket(job.DesiredMembers, name).Add(member); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureMember, @@ -3336,10 +3833,6 @@ 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). @@ -3367,7 +3860,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(lowered)) continue; - Bucket(job.DesiredHolders, lowered).Add(steamId); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGrant, @@ -3377,6 +3869,10 @@ namespace Oxide.Plugins } } + // Parents after every group exists and before any retirement, so a group the site is + // about to remove is no longer anybody's parent when it goes. + job.Ops.AddRange(parentOps); + // ── What the site has withdrawn ──────────────────────────────────── // // The site remembers what it put here, so a grant it has since removed arrives as an @@ -3416,24 +3912,8 @@ namespace Oxide.Plugins } } - // ── The namespace the site claims ────────────────────────────────── - // - // Foreign holders can only be found for names the site says it manages. A permission it - // does not claim is somebody else's business entirely and is never enumerated — which - // also bounds this scan by the site's own set rather than by the size of the store. - - foreach (string perm in Strings(cmd, "managed")) - { - string lowered = Lower(perm); - if (string.IsNullOrEmpty(lowered)) - continue; - - Bucket(job.DesiredHolders, lowered); - job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered }); - } - - foreach (string group in job.DesiredMembers.Keys) - job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); + // Protocol 13 dropped the `managed` list and the foreign scan it drove: the site reads + // the whole store with `perm.inventory` and decides for itself (D160, D161). // ── The kit credits (protocol 10) ────────────────────────────────── // @@ -3550,15 +4030,54 @@ namespace Oxide.Plugins switch (op.Kind) { case PermOpKind.EnsureGroup: - if (permission.GroupExists(op.Subject)) + if (!permission.GroupExists(op.Subject)) + { + permission.CreateGroup(op.Subject, op.Title ?? op.Subject, op.Rank); + job.GroupsCreated++; + break; + } + + // Protocol 13: the site owns an existing group's title and rank too (D160), so + // a difference is written rather than left. Until now a group's title was only + // ever set when the sync created it. + bool updated = false; + + if (op.Title != null && !string.Equals(permission.GetGroupTitle(op.Subject) ?? "", op.Title, StringComparison.Ordinal)) + { + permission.SetGroupTitle(op.Subject, op.Title); + updated = true; + } + + if (op.HasRank && permission.GetGroupRank(op.Subject) != op.Rank) + { + permission.SetGroupRank(op.Subject, op.Rank); + updated = true; + } + + if (updated) + job.GroupsUpdated++; + else + job.AlreadyCorrect++; + + break; + + case PermOpKind.EnsureGroupParent: + string currentParent = Lower(permission.GetGroupParent(op.Subject)) ?? ""; + + if (string.Equals(currentParent, op.Object, StringComparison.Ordinal)) { job.AlreadyCorrect++; + break; } + + // Both frameworks refuse a parent that does not exist or that would make a + // loop, and say so only by returning false. The read-back is what counts. + permission.SetGroupParent(op.Subject, string.IsNullOrEmpty(op.Object) ? null : op.Object); + + if (string.Equals(Lower(permission.GetGroupParent(op.Subject)) ?? "", op.Object, StringComparison.Ordinal)) + job.GroupsUpdated++; else - { - permission.CreateGroup(op.Subject, op.Title, op.Rank); - job.GroupsCreated++; - } + job.NotLanded.Add(op.Subject + ":parent:" + op.Object); break; @@ -3685,14 +4204,6 @@ namespace Oxide.Plugins break; - case PermOpKind.ScanPermission: - ScanPermission(job, op.Subject); - break; - - case PermOpKind.ScanGroup: - ScanGroup(job, op.Subject); - break; - case PermOpKind.EnsureChatStyle: ApplyChatStyle(job, op.Subject); break; @@ -3703,105 +4214,6 @@ namespace Oxide.Plugins } } - /// - /// Everybody holding a managed permission DIRECTLY, minus everybody the site says - /// should — the rest is a hand edit, and it is reported rather than undone. - /// - private void ScanPermission(PermSyncJob job, string perm) - { - if (!permission.PermissionExists(perm)) - return; - - HashSet desired; - if (!job.DesiredHolders.TryGetValue(perm, out desired)) - desired = new HashSet(); - - string[] holders = permission.GetPermissionUsers(perm); - - if (holders == null) - return; - - foreach (string entry in holders) - { - string id = UserId(entry); - - if (string.IsNullOrEmpty(id) || desired.Contains(id)) - continue; - - job.Foreign.Add(new Dictionary - { - { "kind", "grant" }, - { "subject", id }, - { "object", perm } - }); - } - } - - /// - /// The same question about a managed group, asked twice: who is in it that the site did - /// not put there, and what does it carry that the site did not put on it. - /// - private void ScanGroup(PermSyncJob job, string group) - { - if (!permission.GroupExists(group)) - return; - - HashSet members; - if (!job.DesiredMembers.TryGetValue(group, out members)) - members = new HashSet(); - - string[] present = permission.GetUsersInGroup(group); - - if (present != null) - { - foreach (string entry in present) - { - string id = UserId(entry); - - if (string.IsNullOrEmpty(id) || members.Contains(id)) - continue; - - job.Foreign.Add(new Dictionary - { - { "kind", "member" }, - { "subject", id }, - { "object", group } - }); - } - } - - HashSet wanted; - if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted)) - wanted = new HashSet(); - - string[] carried = permission.GetGroupPermissions(group, false); - - if (carried == null) - return; - - foreach (string perm in carried) - { - string lowered = Lower(perm); - - if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered)) - continue; - - // A pair an event has borrowed is not somebody's console grant (§27.3). - if (LeasedGroupPermission(group, lowered) != null) - { - NoteLeased(job, group, lowered); - continue; - } - - job.Foreign.Add(new Dictionary - { - { "kind", "group-permission" }, - { "subject", group }, - { "object", lowered } - }); - } - } - /// /// A sync op about a pair an event lease holds. The store is left to the lease; what /// the site wants is recorded on the hold and becomes the value the lease gives back @@ -3919,6 +4331,7 @@ namespace Oxide.Plugins { "grants", job.Grants }, { "revokes", job.Revokes }, { "groupsCreated", job.GroupsCreated }, + { "groupsUpdated", job.GroupsUpdated }, { "groupsRemoved", job.GroupsRemoved }, { "groupPermissions", job.GroupPermissions }, { "groupPermissionsRemoved", job.GroupPermissionsRemoved }, @@ -3931,7 +4344,6 @@ namespace Oxide.Plugins report["alreadyCorrect"] = job.AlreadyCorrect; report["absent"] = job.Absent; report["unresolved"] = job.Unresolved; - report["foreign"] = job.Foreign; report["leased"] = job.Leased; report["pending"] = job.Pending; report["notLanded"] = job.NotLanded; @@ -3972,8 +4384,8 @@ namespace Oxide.Plugins _lastPermSummary = $"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} " + + $"groups={job.GroupsCreated}+{job.GroupsUpdated} members={job.Members} same={job.AlreadyCorrect} " + + $"unresolved={job.Unresolved.Count} " + $"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"; @@ -3996,8 +4408,8 @@ namespace Oxide.Plugins // ---- drift, as it happens ---- // // Both frameworks raise a hook for every permission write, ours included. They are NOT the - // drift record — the sync report is, because only the site's desired set can say whether a - // grant is foreign. What they are is a reason to reconcile SOON: an operator typing + // drift record — the inventory is (protocol 13), because only the site's own record can say + // whether a grant is a hand edit. What they are is a reason to reconcile SOON: an operator typing // `oxide.grant` at the console shows up on the website in seconds instead of at the next // scheduled audit, and the authoritative answer still comes from a sync. // @@ -6893,8 +7305,8 @@ namespace Oxide.Plugins /// /// Whether a lease holds this group permission, for the permission mirror. A pair a - /// lease has borrowed is the lease's until it ends: a sync neither applies to it nor - /// reports it foreign. + /// lease has borrowed is the lease's until it ends: a sync does not apply to it, and the + /// inventory lists it under leased so the site never takes it for a hand edit. /// private LeaseHold LeasedGroupPermission(string group, string perm) { @@ -11045,19 +11457,6 @@ namespace Oxide.Plugins return value == null ? null : value.Trim().ToLowerInvariant(); } - private static HashSet Bucket(Dictionary> map, string key) - { - HashSet bucket; - - if (!map.TryGetValue(key, out bucket)) - { - bucket = new HashSet(); - map[key] = bucket; - } - - return bucket; - } - // ---- diagnostics ---- /// @@ -11097,8 +11496,9 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " + $"groups={(groups != null ? groups.Length : 0)} " + - $"running={(_permJob != null)}" + Environment.NewLine + - $"last sync: {_lastPermSummary}"); + $"running={(_permJob != null)} inventoryBuilding={(_inventoryJob != null)}" + Environment.NewLine + + $"last sync: {_lastPermSummary}" + Environment.NewLine + + $"last inventory: {_lastInventorySummary}"); } /// From ff49f8f6edd3805902a4669f850fdcf4456ccfe6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 28 Sep 2026 23:32:58 -0500 Subject: [PATCH 06/18] =?UTF-8?q?feat(bridge):=20count=20the=20twenty-thre?= =?UTF-8?q?e=20chat-title=20conditions=20in=20player.tally=20(PLAN=5FREDES?= =?UTF-8?q?IGNS=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit player.tally gains animalKills, weaponKills (by the killfeed's weapon prefab name), headshots, bestPvpM/bestPveM (this interval's maximum), apcKills, heliKills, crafted (by item category), built, repaired, healed, rockets, explosives and missions. Every field is absent when nothing moved. Ten new void hooks, all in ExpectedHooks: OnEntityTakeDamage (the last player to hurt an APC or helicopter, for a crash that names nobody), OnItemCraftFinished, OnEntityBuilt, OnStructureRepair (counted only when the health rose by the next tick), OnHealingItemUse and OnPlayerRevive (another player only; a syringe revive counts once), OnRocketLaunched, OnExplosiveThrown/Dropped (a damaging TimedExplosive only) and OnMissionSucceeded. npcKills keeps counting animals too, so the public column does not change meaning mid-wipe; the site takes human NPCs as the difference (D209). WeaponOf and DistanceOf are split out of DescribeAttacker so the killfeed and the tally read one name and one distance. Not yet compiled on a rig: the rigs' storage is unmounted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 413 ++++++++++++++++++++++++-- 1 file changed, 392 insertions(+), 21 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 23202db..d519035 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -244,7 +244,10 @@ namespace Oxide.Plugins "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", "OnKitRedeemed", - "OnEntitySpawned", "OnEntityKill" + "OnEntitySpawned", "OnEntityKill", + "OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair", + "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched", + "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded" }; private void Init() @@ -1302,15 +1305,7 @@ namespace Oxide.Plugins BaseEntity initiator = info != null ? info.Initiator : null; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; - string weapon = null; - - if (info != null) - { - if (info.WeaponPrefab != null) - weapon = info.WeaponPrefab.ShortPrefabName; - else if (info.Weapon != null) - weapon = info.Weapon.ShortPrefabName; - } + string weapon = WeaponOf(info); if (weapon != null) frame["weapon"] = weapon; @@ -1339,16 +1334,49 @@ namespace Oxide.Plugins frame["attackerName"] = initiator.ShortPrefabName; } - float distance = info != null ? info.ProjectileDistance : 0f; - - if (distance <= 0f && initiator.transform != null && victim.transform != null) - distance = UnityEngine.Vector3.Distance( - initiator.transform.position, victim.transform.position); + float distance = DistanceOf(info, victim); if (distance > 0f) frame["distance"] = (float)Math.Round(distance, 1); } + /// + /// The killing blow's weapon as the killfeed names it: the held entity's short prefab + /// name (bow_hunting.entity), or what was thrown or placed. Null when the hit + /// carries neither. The tally's weapon kills (§5.3) read this same name, so the lists + /// the site keeps are lists of what the killfeed already shows. + /// + private static string WeaponOf(HitInfo info) + { + if (info == null) + return null; + + if (info.WeaponPrefab != null) + return info.WeaponPrefab.ShortPrefabName; + + return info.Weapon != null ? info.Weapon.ShortPrefabName : null; + } + + /// + /// How far the killing blow travelled, in metres: the projectile's own distance, or the + /// gap between the two when there was no projectile. 0 when neither can be read. + /// + private static float DistanceOf(HitInfo info, BaseEntity victim) + { + if (info == null) + return 0f; + + float distance = info.ProjectileDistance; + BaseEntity initiator = info.Initiator; + + if (distance <= 0f && initiator != null && initiator.transform != null + && victim != null && victim.transform != null) + distance = UnityEngine.Vector3.Distance( + initiator.transform.position, victim.transform.position); + + return distance; + } + // ---- the map grid ---- /// @@ -1631,8 +1659,14 @@ namespace Oxide.Plugins BasePlayer killer = info != null ? info.InitiatorPlayer : null; if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID) + { CreditKill(killer, true); + // The kill itself is `player.death`'s, counted by the site from this frame; the + // tally adds only what a title needs about it (§5.1). + TallyFor(killer).Killed(info, player, true); + } + Dictionary frame = Frame("player.death", "event"); frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; @@ -1755,6 +1789,15 @@ namespace Oxide.Plugins return; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; + + // Before the early return below: a patrol helicopter usually dies by crashing, and a + // crash's HitInfo names no player (§5.4). + if (IsVehicleKill(entity)) + { + CreditVehicle(entity, attacker); + return; + } + bool byPlayer = attacker != null && !attacker.IsNpc; // Nothing below is interesting unless a real player did it. That single test discards @@ -1770,7 +1813,9 @@ namespace Oxide.Plugins // double every kill in the feed. Only the NPC kind is ours to count. if (victimPlayer.IsNpc) { - TallyFor(attacker).NpcKills++; + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.Killed(info, entity, false); CreditKill(attacker, false); } @@ -1779,9 +1824,14 @@ namespace Oxide.Plugins // 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". + // `animalKills` counts it AGAIN, separately (§5.2): the public NPC column keeps its + // meaning mid-wipe, and the site takes the human NPCs as the difference. if (entity is BaseNpc) { - TallyFor(attacker).NpcKills++; + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.AnimalKills++; + tally.Killed(info, entity, false); CreditKill(attacker, false); return; } @@ -1829,6 +1879,246 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- what a chat title counts (PLAN_REDESIGNS §5, D172–D174) ---- + // + // Every one of these is a counter (rule 2) and void (rule 1). Three of them can veto — + // `OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive` — and `OnEntityTakeDamage` + // can cancel the damage outright, which is exactly why none of them returns anything. + + /// How long a player's hit on a vehicle still earns its kill when the blow that ends it names nobody (§5.4). + private const float VehicleCreditSeconds = 60f; + + /// The last player to hurt each APC or helicopter, by net id. A handful at most. + private readonly Dictionary> _vehicleHits = + new Dictionary>(); + + /// The APC and the two helicopters: the three things a title credits a kill of (D174). + private static bool IsVehicleKill(BaseEntity entity) + { + return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter; + } + + /// + /// Void: a non-null return cancels the damage. It fires for every hit on everything, + /// so the type test is the first thing it does; only a hit on one of the three + /// vehicles is remembered, and only who dealt it and when. + /// + private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("OnEntityTakeDamage"); + + if (entity == null || info == null || !IsVehicleKill(entity) || entity.net == null) + return; + + BasePlayer player = info.InitiatorPlayer; + if (player == null || player.IsNpc) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + + // A helicopter that flies off the map is never killed, so its row is never removed + // at death; a stale one is dropped here instead. + if (_vehicleHits.Count > 16) + { + var stale = new List(); + foreach (KeyValuePair> hit in _vehicleHits) + if (now - hit.Value.Value > VehicleCreditSeconds) + stale.Add(hit.Key); + foreach (ulong id in stale) + _vehicleHits.Remove(id); + } + + _vehicleHits[entity.net.ID.Value] = new KeyValuePair(player.userID, now); + } + + /// + /// The killing blow's player (D174), or, when the blow names nobody — a helicopter + /// crashing — the last player who hurt it within . + /// + private void CreditVehicle(BaseCombatEntity entity, BasePlayer attacker) + { + KeyValuePair last = default(KeyValuePair); + bool hit = entity.net != null && _vehicleHits.TryGetValue(entity.net.ID.Value, out last); + + if (entity.net != null) + _vehicleHits.Remove(entity.net.ID.Value); + + BasePlayer credited = attacker != null && !attacker.IsNpc ? attacker : null; + + if (credited == null && hit && UnityEngine.Time.realtimeSinceStartup - last.Value <= VehicleCreditSeconds) + credited = BasePlayer.FindByID(last.Key) ?? BasePlayer.FindSleeping(last.Key); + + if (credited == null) + return; + + Tally tally = TallyFor(credited); + + if (entity is BradleyAPC) + tally.ApcKills++; + else + tally.HeliKills++; + } + + /// No return behaviour. A crafted shirt, rifle or anything else, by its item category. + private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter) + { + MarkHook("OnItemCraftFinished"); + + BasePlayer player = task != null ? task.owner : null; + + if (player == null || player.IsNpc || item == null || item.info == null) + return; + + TallyFor(player).Craft(item.info.category.ToString().ToLowerInvariant(), item.amount); + } + + /// No return behaviour. Only a building block is a structure built; a deployable is not. + private void OnEntityBuilt(Planner planner, UnityEngine.GameObject go) + { + MarkHook("OnEntityBuilt"); + + BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null; + + if (player == null || player.IsNpc || go == null || !(go.ToBaseEntity() is BuildingBlock)) + return; + + TallyFor(player).Built++; + } + + /// + /// Void: a non-null return refuses the repair. It fires on every hammer hit, BEFORE the + /// game decides whether there is anything to repair, the resources to pay for it, or a + /// recent attack that forbids it. So the health is read now and again next tick, and + /// only a hit that actually raised it is a repair. + /// + private void OnStructureRepair(BaseCombatEntity entity, BasePlayer player) + { + MarkHook("OnStructureRepair"); + + if (entity == null || player == null || player.IsNpc) + return; + + float before = entity.health; + ulong id = player.userID; + + NextTick(() => + { + if (entity == null || entity.IsDestroyed || entity.health <= before) + return; + + BasePlayer still = BasePlayer.FindByID(id); + if (still != null) + TallyFor(still).Repaired++; + }); + } + + // A syringe on a downed player raises `OnPlayerRevive` and then `OnHealingItemUse` for the + // same use; one act of help is one heal. The revive is remembered for the frame it happened in. + private ulong _reviveHealer; + private ulong _reviveTarget; + private int _reviveFrame = -1; + + /// + /// Void: a non-null return refuses the heal. is who is healed; + /// the tool's owner is who heals. Healing yourself is not a heal (D174). + /// + private void OnHealingItemUse(MedicalTool tool, BasePlayer player) + { + MarkHook("OnHealingItemUse"); + + BasePlayer healer = tool != null ? tool.GetOwnerPlayer() : null; + + if (healer == null || healer.IsNpc || player == null || player.IsNpc + || (ulong)healer.userID == (ulong)player.userID) + return; + + if (_reviveFrame == UnityEngine.Time.frameCount + && _reviveHealer == (ulong)healer.userID && _reviveTarget == (ulong)player.userID) + return; + + TallyFor(healer).Healed++; + } + + /// Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted. + private void OnPlayerRevive(BasePlayer reviver, BasePlayer player) + { + MarkHook("OnPlayerRevive"); + + if (reviver == null || reviver.IsNpc || player == null || player.IsNpc + || (ulong)reviver.userID == (ulong)player.userID) + return; + + _reviveHealer = reviver.userID; + _reviveTarget = player.userID; + _reviveFrame = UnityEngine.Time.frameCount; + + TallyFor(reviver).Healed++; + } + + /// No return behaviour. One rocket leaving a launcher. + private void OnRocketLaunched(BasePlayer player, BaseEntity entity) + { + MarkHook("OnRocketLaunched"); + + if (player == null || player.IsNpc) + return; + + TallyFor(player).Rockets++; + } + + /// No return behaviour. A grenade, beancan, satchel or C4 thrown. + private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item) + { + MarkHook("OnExplosiveThrown"); + CountExplosive(player, entity); + } + + /// No return behaviour. The same, dropped at your feet rather than thrown. + private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item) + { + MarkHook("OnExplosiveDropped"); + CountExplosive(player, entity); + } + + /// + /// A supply signal and a smoke grenade are thrown through the same hooks, and neither + /// is an explosive. What is one is a timed explosive that does damage. + /// + private void CountExplosive(BasePlayer player, BaseEntity entity) + { + if (player == null || player.IsNpc) + return; + + TimedExplosive explosive = entity as TimedExplosive; + if (explosive == null || explosive is SupplySignal || explosive.damageTypes == null) + return; + + foreach (Rust.DamageTypeEntry damage in explosive.damageTypes) + { + if (damage != null && damage.amount > 0f) + { + TallyFor(player).Explosives++; + return; + } + } + } + + /// + /// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A + /// failed or abandoned one raises a different hook, and is never counted. + /// + private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance) + { + MarkHook("OnMissionSucceeded"); + + BasePlayer player = instance != null ? instance.GetAssignee() : null; + + if (player == null || player.IsNpc) + return; + + TallyFor(player).Missions++; + } + /// /// The most people one entity.destroyed frame names as authorised. A cupboard /// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game @@ -2450,19 +2740,70 @@ namespace Oxide.Plugins public int Structures; public string Name; + // Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Sums unless named a best. + public int AnimalKills; + public readonly Dictionary WeaponKills = new Dictionary(); + public int Headshots; + /// The longest player kill this interval, in metres: a best, not a sum. + public float BestPvpM; + /// The longest NPC or animal kill this interval: a best, not a sum. + public float BestPveM; + public int ApcKills; + public int HeliKills; + public readonly Dictionary Crafted = new Dictionary(); + public int Built; + public int Repaired; + public int Healed; + public int Rockets; + public int Explosives; + public int Missions; + public void Gather(string shortname, int amount) { - if (string.IsNullOrEmpty(shortname) || amount <= 0) + Add(Gathered, shortname, amount); + } + + public void Craft(string category, int amount) + { + Add(Crafted, category, amount); + } + + /// + /// What a credited kill adds for the titles: its weapon (any victim, D174), a + /// headshot, and its distance as this interval's best of its kind. + /// + public void Killed(HitInfo info, BaseEntity victim, bool victimIsPlayer) + { + Add(WeaponKills, WeaponOf(info), 1); + + if (info != null && info.isHeadshot) + Headshots++; + + float distance = DistanceOf(info, victim); + + if (victimIsPlayer) + BestPvpM = Math.Max(BestPvpM, distance); + else + BestPveM = Math.Max(BestPveM, distance); + } + + private static void Add(Dictionary into, string key, int amount) + { + if (string.IsNullOrEmpty(key) || amount <= 0) return; int have; - Gathered.TryGetValue(shortname, out have); - Gathered[shortname] = have + amount; + into.TryGetValue(key, out have); + into[key] = have + amount; } public bool IsEmpty() { - return Gathered.Count == 0 && NpcKills == 0 && Structures == 0; + return Gathered.Count == 0 && NpcKills == 0 && Structures == 0 + && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 + && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 + && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 + && Rockets == 0 && Explosives == 0 && Missions == 0; } } @@ -2532,6 +2873,36 @@ namespace Oxide.Plugins if (tally.Structures > 0) frame["structures"] = tally.Structures; + // The title conditions (§5.1), each absent when nothing moved, as the three above. + if (tally.AnimalKills > 0) + frame["animalKills"] = tally.AnimalKills; + if (tally.WeaponKills.Count > 0) + frame["weaponKills"] = tally.WeaponKills; + if (tally.Headshots > 0) + frame["headshots"] = tally.Headshots; + if (tally.BestPvpM > 0f) + frame["bestPvpM"] = (float)Math.Round(tally.BestPvpM, 1); + if (tally.BestPveM > 0f) + frame["bestPveM"] = (float)Math.Round(tally.BestPveM, 1); + if (tally.ApcKills > 0) + frame["apcKills"] = tally.ApcKills; + if (tally.HeliKills > 0) + frame["heliKills"] = tally.HeliKills; + if (tally.Crafted.Count > 0) + frame["crafted"] = tally.Crafted; + if (tally.Built > 0) + frame["built"] = tally.Built; + if (tally.Repaired > 0) + frame["repaired"] = tally.Repaired; + if (tally.Healed > 0) + frame["healed"] = tally.Healed; + if (tally.Rockets > 0) + frame["rockets"] = tally.Rockets; + if (tally.Explosives > 0) + frame["explosives"] = tally.Explosives; + if (tally.Missions > 0) + frame["missions"] = tally.Missions; + EmitFrame(frame); } From f67f9e3b229b1253402a4bc6a0ef2db4a2be3628 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 02:07:29 -0500 Subject: [PATCH 07/18] =?UTF-8?q?fix(bridge):=20the=20three=20members=20th?= =?UTF-8?q?e=20rig=20corrected=20=E2=80=94=20crafter=20owner,=20mission=20?= =?UTF-8?q?assignee,=20heal=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiled on both rigs (2026-09-29): ItemCraftTask has no owner, so the crafter is ItemCrafter.owner; OnMissionSucceeded credits its third argument, the assignee, since MissionInstance has no accessor for it; OnHealingItemUse takes its target as object, because GiveEffectsTo now takes an IMedicalToolTarget, and counts only a player. GameObject.ToBaseEntity is not in scope for a plugin, so OnEntityBuilt asks GetComponent(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 32 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index d519035..e6641c3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1959,12 +1959,16 @@ namespace Oxide.Plugins tally.HeliKills++; } - /// No return behaviour. A crafted shirt, rifle or anything else, by its item category. + /// + /// No return behaviour. A crafted shirt, rifle or anything else, by its item category. + /// Who crafted it is the crafter's owner: the task no longer carries one (read off the + /// rig, 2026-09-29). + /// private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter) { MarkHook("OnItemCraftFinished"); - BasePlayer player = task != null ? task.owner : null; + BasePlayer player = crafter != null ? crafter.owner : null; if (player == null || player.IsNpc || item == null || item.info == null) return; @@ -1979,7 +1983,7 @@ namespace Oxide.Plugins BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null; - if (player == null || player.IsNpc || go == null || !(go.ToBaseEntity() is BuildingBlock)) + if (player == null || player.IsNpc || go == null || go.GetComponent() == null) return; TallyFor(player).Built++; @@ -2019,13 +2023,21 @@ namespace Oxide.Plugins private int _reviveFrame = -1; /// - /// Void: a non-null return refuses the heal. is who is healed; + /// Void: a non-null return refuses the heal. is what is healed; /// the tool's owner is who heals. Healing yourself is not a heal (D174). + /// + /// + /// The target is taken as object: the game's `GiveEffectsTo` now takes an + /// `IMedicalToolTarget`, which is not always a player, and a parameter typed + /// `BasePlayer` would simply never be called for the hook's current shape. Only a + /// player healed counts. + /// /// - private void OnHealingItemUse(MedicalTool tool, BasePlayer player) + private void OnHealingItemUse(MedicalTool tool, object target) { MarkHook("OnHealingItemUse"); + BasePlayer player = target as BasePlayer; BasePlayer healer = tool != null ? tool.GetOwnerPlayer() : null; if (healer == null || healer.IsNpc || player == null || player.IsNpc @@ -2107,16 +2119,16 @@ namespace Oxide.Plugins /// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A /// failed or abandoned one raises a different hook, and is never counted. /// - private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance) + private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance, BasePlayer assignee) { MarkHook("OnMissionSucceeded"); - BasePlayer player = instance != null ? instance.GetAssignee() : null; - - if (player == null || player.IsNpc) + // The game hands the assignee over: `MissionSuccess(instance, assignee)`. The instance + // itself has no accessor for it (read off the rig, 2026-09-29). + if (assignee == null || assignee.IsNpc) return; - TallyFor(player).Missions++; + TallyFor(assignee).Missions++; } /// From 746523387e18d1348501040de59f959733a24a4c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 05:25:43 -0500 Subject: [PATCH 08/18] =?UTF-8?q?feat(bridge):=20a=20zone's=20ZoneManager?= =?UTF-8?q?=20options,=20its=20messages,=20and=20a=20ZoneDomes=20dome=20(P?= =?UTF-8?q?LAN=5FREDESIGNS=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit world.zone gains flags, settings, enterMessage, leaveMessage, delivery, format and dome (PROTOCOL §19.11). Flags are checked against the live ZoneManager's own list, read through its API call ZoneFieldListRaw() (D211), and refused bad-option rather than dropped; settings are an allowlist of five with bounds. Both are kept on the registry entry and re-applied when a zone is re-created. The bridge says a zone's enter and leave messages itself (D193), as chat or a popup (chat when PopupNotifications is missing), and stays quiet for 5 s after re-creating a zone. RunicGatewayDomes.cs, a new helper, patches ZoneDomes so its API can be called without a player, and raises OnRgDomesReady after ZoneDomes' own start; the bridge puts its domes back only after it, so none is drawn twice. Domes are removed before their zone at expiry and revert. hello carries zoneManager { version, flags }, integrations.zoneDomes the ZoneDomes and helper state, and hello is sent again when ZoneManager, ZoneDomes or either helper loads or unloads. The release ships the new helper beside the zone helper. Not compiled or walked on a rig: the panel's file API was down on both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 9 +- README.md | 8 + overlay/oxide/plugins/RunicGateway.cs | 619 ++++++++++++++++++++- overlay/oxide/plugins/RunicGatewayDomes.cs | 261 +++++++++ 4 files changed, 888 insertions(+), 9 deletions(-) create mode 100644 overlay/oxide/plugins/RunicGatewayDomes.cs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5f66dba..4b1b5ab 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -29,8 +29,8 @@ # exact commit being released. # # 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, -# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, `RunicGatewayZones.cs` -# and `manifest.json` at its top. Every `.cs` in it is listed in the +# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers +# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the # manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to # `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each # decide which (§34.2.1). The fixed, unversioned prefix is so a reader never @@ -40,7 +40,8 @@ # `oxide.plugins` / `c.plugins` on a server names the release it runs. The # committed file keeps its placeholder; the manifest records the commit. # Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168, -# D182 — today `RunicGatewayZones.cs`, the ZoneManager helper) keeps its own +# D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs` +# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own # version: it does one job, changes rarely, and the bridge reads its version at # hello to know which job it does. # @@ -80,7 +81,7 @@ env: PLUGIN: overlay/oxide/plugins/RunicGateway.cs # The helpers shipped beside the bridge (D168, D182). Optional at runtime — # the bridge loads without them — and installed by default. - HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs + HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer diff --git a/README.md b/README.md index 71e82a6..5ba7364 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ unauthenticated command channel on the network. ``` overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs overlay/oxide/plugins/RunicGatewayZones.cs → /oxide/plugins/RunicGatewayZones.cs +overlay/oxide/plugins/RunicGatewayDomes.cs → /oxide/plugins/RunicGatewayDomes.cs ``` `RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager @@ -44,6 +45,13 @@ somebody already standing there holds nobody. The helper patches that with Harmo bridge still loads, scores its own zones by position, and reports the helper missing at hello. `rgz.status` in the server console says whether it patched. +`RunicGatewayDomes.cs` is the **ZoneDomes helper** (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D194). ZoneDomes' +API ends by telling the player who asked how it went, and throws when a plugin calls it with no player; and at its +own start it drops the dome of every zone that does not exist yet, which is every zone the bridge re-creates. The +helper patches the first with Harmony and tells the bridge when ZoneDomes has started, so the bridge can put its +domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is +missing. `rgd.status` in the server console says whether it patched. + Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load: ```json diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 23202db..19a5634 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -244,7 +244,8 @@ namespace Oxide.Plugins "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", "OnKitRedeemed", - "OnEntitySpawned", "OnEntityKill" + "OnEntitySpawned", "OnEntityKill", + "OnEnterZone", "OnExitZone", "OnRgDomesReady" }; private void Init() @@ -1173,6 +1174,17 @@ namespace Oxide.Plugins } hello["zoneHelper"] = helper; + + // D211: ZoneManager's own flag list, for the site's zone presets (§3.1). In hello + // rather than every status frame, because it changes only when ZoneManager does, + // and a ZoneManager load sends hello again. + var zoneManager = new Dictionary { { "version", ZoneManagerPlugin().Version.ToString() } }; + List flags = ZoneFlagNames(ZoneManagerPlugin()); + + if (flags != null) + zoneManager["flags"] = flags; + + hello["zoneManager"] = zoneManager; } EmitFrame(hello); @@ -4527,6 +4539,22 @@ namespace Oxide.Plugins EmitFrame(frame); } + private bool IsZonePlugin(Plugin plugin) + { + if (plugin == null || plugin == this) + return false; + + string name = plugin.Name; + return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes"; + } + + /// Main thread. Hello again, once the world is up and the link is. + private void ResendHello() + { + if (_worldReady && _connected) + SendHello(); + } + /// Main thread. Queues for somebody else's plugin, once the world is up. private void NotePluginChange(Plugin plugin, bool loaded) { @@ -4548,6 +4576,11 @@ namespace Oxide.Plugins if (plugin != null && plugin != this && plugin.Name == "ZoneManager") NextTick(() => RecreateZones("ZoneManager reloaded")); + // Hello carries ZoneManager's flag list and both helpers' states, so a change to any + // of the four is said again rather than waiting for the next connect (§3.1, §3.2). + if (IsZonePlugin(plugin)) + NextTick(ResendHello); + // 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") @@ -4570,6 +4603,9 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; + if (IsZonePlugin(plugin)) + NextTick(ResendHello); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -7446,6 +7482,24 @@ namespace Oxide.Plugins public float Radius; public string Name; + /// + /// A zone's ZoneManager flags and settings (PLAN_REDESIGNS §3.1), kept so a re-created + /// zone gets them back. Null on a zone from before protocol 13's options. + /// + public List Flags; + public Dictionary Settings; + + /// What a player is told on the way in and out, and how (D193). Said by this plugin, not ZoneManager. + public string EnterMessage; + public string LeaveMessage; + public string Delivery; + public string Format; + + /// A ZoneDomes dome over the zone (D194): its type (0–4) and how many spheres are stacked. + public bool Dome; + public int DomeType; + public int DomeStack; + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; @@ -7542,6 +7596,401 @@ namespace Oxide.Plugins return inside is bool && (bool)inside; } + // ---- a zone's options (PLAN_REDESIGNS §3.1, D166, D211) ---- + // + // ZoneManager's flags are whatever the installed version declares, so the list is read from + // it, never kept here. `ZoneFieldListRaw` is in ZoneManager's own API region and answers + // the zone fields followed by every flag name; the fields this plugin controls or does not + // offer are dropped from the front. Plan §0.6 believed only a plugin that references + // ZoneManager's types could read the list, so it put the read in the zone helper. That was + // wrong for 3.1.14 (D211), and a server without the helper still gets every flag. + + /// The fields `ZoneFieldListRaw` answers that are not flags, lower-cased. + private static readonly HashSet ZoneNonFlagFields = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "name", "id", "radius", "rotation", "size", "location", "enter_message", "leave_message", + "radiation", "comfort", "temperature", "safezone", "permission", "ejectspawns", "parentid", "enabled", "enable" + }; + + /// + /// The settings a step may give a zone, and their bounds. Every other ZoneManager field + /// (rotation, parent, eject spawns, size) changes what this plugin believes the zone is, + /// so it stays out (§3.1). The messages are this plugin's own (D193). + /// + private static readonly string[] ZoneSettingKeys = { "radiation", "comfort", "temperature", "safezone", "permission" }; + + private const float ZoneRadiationMax = 500f; + private const float ZoneComfortMax = 1f; + private const float ZoneTemperatureLimit = 100f; + private const int ZoneMessageMax = 256; + private const int ZoneFlagsMax = 80; + + /// ZoneDomes' own sphere types: Standard, Red, Blue, Green, Purple. + private const int DomeTypeMax = 4; + + /// The most spheres one dome stacks. Each is an entity; ZoneDomes itself has no limit. + private const int DomeStackMax = 10; + + /// A zone this plugin just put back says nothing to whoever is inside it for this long. + private const long ZoneQuietMs = 5000L; + + /// Zone id → the moment its enter and leave messages start again. Main thread. + private readonly Dictionary _zoneQuietUntil = new Dictionary(); + + /// + /// Main thread. ZoneManager's flag names, as it spells them, or null when it is not loaded + /// or does not answer. Asked when needed: an operator can update ZoneManager at any time. + /// + private static List ZoneFlagNames(Plugin zm) + { + var fields = zm == null ? null : zm.Call("ZoneFieldListRaw") as List; + + if (fields == null) + return null; + + var flags = new List(); + + foreach (string field in fields) + { + if (!string.IsNullOrEmpty(field) && !ZoneNonFlagFields.Contains(field)) + flags.Add(field); + } + + return flags; + } + + /// + /// Main thread. Reads a step's flags and settings against the live ZoneManager. Each flag + /// is returned as ZoneManager spells it; each setting is checked and written as + /// ZoneManager parses it. False with a refusal on anything unknown or out of range. + /// + private static bool ReadZoneOptions(Plugin zm, Dictionary cmd, + out List flags, out Dictionary settings, out string refusal) + { + flags = new List(); + settings = new Dictionary(StringComparer.Ordinal); + refusal = null; + + object raw; + + if (cmd.TryGetValue("flags", out raw) && raw != null) + { + var list = raw as Newtonsoft.Json.Linq.JArray; + + if (list == null) + { + refusal = "a zone's flags are a list of names"; + return false; + } + + if (list.Count > ZoneFlagsMax) + { + refusal = $"a zone takes at most {ZoneFlagsMax} flags, not {list.Count}"; + return false; + } + + List known = ZoneFlagNames(zm); + + if (known == null) + { + refusal = "this server's ZoneManager did not say which flags it has"; + return false; + } + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string k in known) + byName[k] = k; + + foreach (Newtonsoft.Json.Linq.JToken token in list) + { + string name = token.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string)token : null; + string spelled; + + if (string.IsNullOrEmpty(name) || !byName.TryGetValue(name.Trim(), out spelled)) + { + refusal = $"'{name}' is not a flag this server's ZoneManager has"; + return false; + } + + if (!flags.Contains(spelled)) + flags.Add(spelled); + } + } + + if (cmd.TryGetValue("settings", out raw) && raw != null) + { + var obj = raw as Newtonsoft.Json.Linq.JObject; + + if (obj == null) + { + refusal = "a zone's settings are an object of name and value"; + return false; + } + + foreach (Newtonsoft.Json.Linq.JProperty p in obj.Properties()) + { + string key = p.Name.ToLowerInvariant(); + + if (Array.IndexOf(ZoneSettingKeys, key) < 0) + { + refusal = $"'{p.Name}' is not a zone setting this server offers (radiation, comfort, temperature, safezone, permission)"; + return false; + } + + // Invariant, because a JSON 0.5 read under the host's culture can come back "0,5". + var jv = p.Value as Newtonsoft.Json.Linq.JValue; + string value = jv == null ? null : Convert.ToString(jv.Value, System.Globalization.CultureInfo.InvariantCulture); + string written; + + if (!ZoneSettingValue(key, value, out written, out refusal)) + return false; + + settings[key] = written; + } + } + + return true; + } + + private static bool ZoneSettingValue(string key, string value, out string written, out string refusal) + { + written = null; + refusal = null; + var inv = System.Globalization.CultureInfo.InvariantCulture; + + if (key == "safezone") + { + bool on; + + if (!bool.TryParse(value, out on)) + { + refusal = $"safezone is true or false, not '{value}'"; + return false; + } + + written = on ? "true" : "false"; + return true; + } + + if (key == "permission") + { + string perm = (value ?? string.Empty).Trim().ToLowerInvariant(); + + if (perm.StartsWith("zonemanager.", StringComparison.Ordinal)) + perm = perm.Substring("zonemanager.".Length); + + if (perm.Length == 0 || perm.Length > 32) + { + refusal = "a zone's permission is 1 to 32 characters"; + return false; + } + + foreach (char c in perm) + { + if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_') + { + refusal = $"a zone's permission is letters, digits and _, not '{value}'"; + return false; + } + } + + written = perm; + return true; + } + + float number; + + if (!float.TryParse(value, System.Globalization.NumberStyles.Float, inv, out number) || float.IsNaN(number) || float.IsInfinity(number)) + { + refusal = $"{key} is a number, not '{value}'"; + return false; + } + + float low = key == "temperature" ? -ZoneTemperatureLimit : 0f; + float high = key == "radiation" ? ZoneRadiationMax : key == "comfort" ? ZoneComfortMax : ZoneTemperatureLimit; + + if (number < low || number > high) + { + refusal = $"{key} is {low} to {high}, not {number.ToString(inv)}"; + return false; + } + + written = number.ToString(inv); + return true; + } + + // ---- the dome (PLAN_REDESIGNS §3.2, D167, D194) ---- + // + // ZoneDomes draws the dome. Two things it lacks are made up here and in the domes helper, + // `RunicGatewayDomes.cs`: its API ends in a chat message to the player who asked, which + // throws for a call with none (the helper patches that), and at its own start it drops + // every dome whose zone does not exist yet, which is every zone this plugin re-creates. + // So this plugin puts its domes back after it puts its zones back, and only once ZoneDomes + // has finished its own start: a dome added before that is drawn twice, and the first set + // of spheres is left in the world where nothing can remove it. The helper says when. + + private Plugin ZoneDomesPlugin() + { + return plugins.Find("ZoneDomes"); + } + + private Plugin DomesHelperPlugin() + { + return plugins.Find("RunicGatewayDomes"); + } + + /// Main thread. patched, unsupported, no-zonedomes or missing. + private string DomesHelperState() + { + Plugin helper = DomesHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + /// Main thread. Whether a dome may be added now: ZoneDomes loaded, patched, and past its own start. + private bool DomesReady() + { + Plugin helper = DomesHelperPlugin(); + return ZoneDomesPlugin() != null && helper != null + && helper.Call("State") as string == "patched" && helper.Call("Ready") is bool ready && ready; + } + + /// Main thread. Why a dome cannot be added now, or null when it can. + private string DomeRefusal() + { + if (ZoneDomesPlugin() == null) + return "ZoneDomes is not loaded on this server, so a zone cannot have a dome"; + + string state = DomesHelperState(); + + if (state == "missing") + return "RunicGatewayDomes.cs is not installed beside the bridge, and ZoneDomes cannot be called without it"; + + if (state != "patched") + return $"RunicGatewayDomes.cs could not patch this ZoneDomes ({state})"; + + return DomesReady() ? null : "ZoneDomes has not finished starting"; + } + + /// Main thread. Adds a zone's dome. True when ZoneDomes made it, or already had it. + private bool AddDome(WorldEntry e) + { + Plugin zd = ZoneDomesPlugin(); + + if (zd == null || !e.Dome) + return false; + + object made = zd.Call("AddNewDome", null, e.Id, e.DomeType, e.DomeStack); + return made is bool && (bool)made; + } + + /// + /// Main thread. Takes a zone's dome out of the world and out of ZoneDomes' data file. + /// Called BEFORE the zone is erased, so a dome never outlives its zone. + /// + private void RemoveDome(WorldEntry e) + { + Plugin zd = ZoneDomesPlugin(); + + if (zd == null || !e.Dome) + return; + + // Without the helper the call still removes the dome and then throws on its message; + // the framework logs that, and the dome is gone either way. + zd.Call("RemoveExistingDome", null, e.Id); + } + + /// + /// Main thread. Puts back the dome of every standing zone that has one. ZoneDomes answers + /// false for a dome it already has, so this is safe to repeat. + /// + private void RestoreDomes(string why) + { + if (!_worldReady || !DomesReady()) + return; + + Plugin zm = ZoneManagerPlugin(); + + if (zm == null) + return; + + int added = 0; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindZone || !e.Dome || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) == null) + continue; + + if (AddDome(e)) + added++; + } + + if (added > 0) + { + _lastWorldSummary = $"domes after {why}: {added} put back"; + Puts($"world: {_lastWorldSummary}"); + } + } + + /// Raised by the domes helper when ZoneDomes has finished its own start. + private void OnRgDomesReady() + { + MarkHook("OnRgDomesReady"); + NextTick(() => RestoreDomes("ZoneDomes started")); + } + + // ---- the zone's messages (D193) ---- + // + // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its + // own config turns popups on for every zone on the server. So this plugin never gives it + // the messages for its own zones, and says them here, as each zone's step chose. + + private void OnEnterZone(string zoneId, BasePlayer player) + { + MarkHook("OnEnterZone"); + SayZoneMessage(zoneId, player, true); + } + + private void OnExitZone(string zoneId, BasePlayer player) + { + MarkHook("OnExitZone"); + SayZoneMessage(zoneId, player, false); + } + + private void SayZoneMessage(string zoneId, BasePlayer player, bool entering) + { + WorldEntry e; + + if (zoneId == null || player == null || !player.IsConnected || !_world.TryGetValue(zoneId, out e) || e.Kind != WorldKindZone) + return; + + string message = entering ? e.EnterMessage : e.LeaveMessage; + + if (string.IsNullOrEmpty(message)) + return; + + // A zone this plugin just put back (a restart, a ZoneManager reload) re-enters whoever + // is standing in it. They were told when they walked in. + long quiet; + + if (_zoneQuietUntil.TryGetValue(zoneId, out quiet)) + { + if (NowMs() < quiet) + return; + + _zoneQuietUntil.Remove(zoneId); + } + + // A popup where PopupNotifications is loaded, and chat where it is not (§2.4): the + // step was accepted long before this moment, and a player is told either way. + Plugin popup = e.Delivery == DeliveryPopup ? PopupPlugin() : null; + + if (popup != null) + popup.Call("CreatePopupNotification", message, player, 0f); + else + player.ChatMessage(e.Format == null ? message : Voice(e.Format, message)); + } + /// /// The map's monuments in the one order every caller agrees on: grouped by prefab short /// name, then by position. #n in a value counts within its group, so it names the @@ -7904,6 +8353,84 @@ namespace Oxide.Plugins if (name.Length > 64) name = name.Substring(0, 64); + // PLAN_REDESIGNS §3.1: the flags and settings, each checked against this server's own + // ZoneManager. An unknown one is refused, never dropped: a zone that quietly lacks the + // "no building" its author chose is worse than a step that fails and says why. + List flags; + Dictionary settings; + string optionRefusal; + + if (!ReadZoneOptions(zm, cmd, out flags, out settings, out optionRefusal)) + { + EmitWorldError(reqId, "bad-option", optionRefusal); + return; + } + + // D193: what a player is told on the way in and out, and whether as chat or a popup. + string enterMessage = StringField(cmd, "enterMessage"); + string leaveMessage = StringField(cmd, "leaveMessage"); + + if ((enterMessage != null && enterMessage.Length > ZoneMessageMax) || (leaveMessage != null && leaveMessage.Length > ZoneMessageMax)) + { + EmitWorldError(reqId, "out-of-range", $"a zone's message is at most {ZoneMessageMax} characters"); + return; + } + + string delivery = StringField(cmd, "delivery"); + + if (string.IsNullOrEmpty(delivery)) + delivery = DeliveryChat; + + if (delivery != DeliveryChat && delivery != DeliveryPopup) + { + EmitWorldError(reqId, "malformed", $"a zone's messages go 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)) + { + EmitWorldError(reqId, "malformed", $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters"); + return; + } + + // D194: a dome, where ZoneDomes and the domes helper are both here. + bool dome = false; + int domeType = 0, domeStack = 1; + object domeRaw; + + if (cmd.TryGetValue("dome", out domeRaw) && domeRaw != null) + { + var d = domeRaw as Newtonsoft.Json.Linq.JObject; + + if (d == null) + { + EmitWorldError(reqId, "malformed", "a zone's dome is an object of type and stack"); + return; + } + + Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"]; + domeType = t != null && t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1; + domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1; + + if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax) + { + EmitWorldError(reqId, "out-of-range", $"a dome's type is 0 to {DomeTypeMax} and its stack 1 to {DomeStackMax}"); + return; + } + + string noDome = DomeRefusal(); + + if (noDome != null) + { + EmitWorldError(reqId, "dome-unavailable", noDome); + return; + } + + dome = true; + } + var entry = new WorldEntry { Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", @@ -7915,6 +8442,15 @@ namespace Oxide.Plugins Z = point.z, Radius = (float)radius, Name = name, + Flags = flags.Count > 0 ? flags : null, + Settings = settings.Count > 0 ? settings : null, + EnterMessage = string.IsNullOrEmpty(enterMessage) ? null : enterMessage, + LeaveMessage = string.IsNullOrEmpty(leaveMessage) ? null : leaveMessage, + Delivery = delivery, + Format = delivery == DeliveryChat ? format : null, + Dome = dome, + DomeType = domeType, + DomeStack = domeStack, DeadlineMs = NowMs() + holdMs, BootId = _bootId, WipeId = _wipeId, @@ -7927,11 +8463,20 @@ namespace Oxide.Plugins return; } + // The dome after the zone, because ZoneDomes reads the zone's place and size from + // ZoneManager. A dome that is not made takes its zone with it: the step asked for both. + if (dome && !AddDome(entry)) + { + zm.Call("EraseTemporaryZone", this, entry.Id); + EmitWorldError(reqId, "dome-unavailable", "ZoneDomes did not make the dome, so the zone was not opened"); + return; + } + _world[entry.Id] = entry; SaveWorld(); EnsureWorldTimer(); - _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}"; + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}"; Puts($"world: {_lastWorldSummary}"); EmitWorldPlaced(reqId, new List { entry }, false); } @@ -7959,13 +8504,35 @@ namespace Oxide.Plugins /// private bool CreateZone(Plugin zm, WorldEntry entry) { - string[] args = + var args = new List { "name", entry.Name ?? "Event zone", "radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture) }; - object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args, new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); + // ZoneManager's own key/value arguments (§3.1): each setting by its key, and each flag + // as "", "true". Kept on the record, so a zone put back after a restart or a + // ZoneManager reload gets them back. A flag a newer ZoneManager no longer has is + // ignored by ZoneManager rather than failing the zone. + if (entry.Settings != null) + { + foreach (KeyValuePair kv in entry.Settings) + { + args.Add(kv.Key); + args.Add(kv.Value); + } + } + + if (entry.Flags != null) + { + foreach (string flag in entry.Flags) + { + args.Add(flag); + args.Add("true"); + } + } + + object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args.ToArray(), new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null; } @@ -7995,9 +8562,14 @@ namespace Oxide.Plugins continue; if (CreateZone(zm, e)) + { restored++; + _zoneQuietUntil[e.Id] = NowMs() + ZoneQuietMs; + } else + { failed++; + } } if (restored > 0 || failed > 0) @@ -8005,6 +8577,10 @@ namespace Oxide.Plugins _lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed"; Puts($"world: {_lastWorldSummary}"); } + + // The domes after the zones (D194). At a real start ZoneDomes has not finished its own + // start yet, and the helper's ready hook brings this back when it has. + RestoreDomes(why); } /// Main thread. Place crates or NPCs an event owns, one registry entry per net id. @@ -8227,8 +8803,14 @@ namespace Oxide.Plugins // it lives in memory only — and its record is all there is left to remove. wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null; + // The dome first: ZoneDomes keeps it in its own data file, and a dome + // left there outlives the zone it was drawn over (D194). + RemoveDome(e); + if (wasThere) zm.Call("EraseTemporaryZone", this, e.Id); + + _zoneQuietUntil.Remove(e.Id); } else { @@ -8508,10 +9090,13 @@ namespace Oxide.Plugins foreach (WorldEntry e in due) { + RemoveDome(e); + if (zm != null && zm.Call("CheckZoneID", e.Id) != null) zm.Call("EraseTemporaryZone", this, e.Id); _world.Remove(e.Id); + _zoneQuietUntil.Remove(e.Id); Dictionary frame = Frame("world.expired", "event"); frame["id"] = e.Id; @@ -10047,10 +10632,34 @@ namespace Oxide.Plugins return new Dictionary { { "betterChat", IntegrationBody(BetterChatPlugin()) }, - { "popupNotifications", IntegrationBody(PopupPlugin()) } + { "popupNotifications", IntegrationBody(PopupPlugin()) }, + { "zoneDomes", ZoneDomesBody() } }; } + /// + /// ZoneDomes and the domes helper (D194), live on every status frame like the other + /// optional mods: the site offers a dome only where both are here and patched. + /// + private Dictionary ZoneDomesBody() + { + Dictionary body = IntegrationBody(ZoneDomesPlugin()); + var helper = new Dictionary { { "state", ZoneDomesPlugin() == null && DomesHelperPlugin() != null ? "no-zonedomes" : DomesHelperState() } }; + Plugin domes = DomesHelperPlugin(); + + if (domes != null) + { + helper["version"] = domes.Version.ToString(); + + if (domes.Call("StateReason") is string reason) + helper["reason"] = reason; + } + + body["helper"] = helper; + body["ready"] = DomesReady(); + return body; + } + /// A version only for a plugin that is loaded. Absent, not empty. private static Dictionary IntegrationBody(Plugin plugin) { diff --git a/overlay/oxide/plugins/RunicGatewayDomes.cs b/overlay/oxide/plugins/RunicGatewayDomes.cs new file mode 100644 index 0000000..5bba137 --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayDomes.cs @@ -0,0 +1,261 @@ +// Runic Gateway Domes — a helper for ZoneDomes, shipped beside the Runic Gateway bridge. +// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. +// +// ONE JOB (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D168, D194): +// +// ZoneDomes draws a dome over a ZoneManager zone, and the bridge asks it to for an event zone +// whose step wants one. Its API was written for a person at a keyboard: `AddNewDome` and +// `RemoveExistingDome` both end by telling the player who asked how it went, with no check that +// there was one. Called by a plugin with no player, the dome is made and saved, and THEN the call +// throws — so the caller cannot tell a made dome from a failed one (plan §0.5, found on the rig). +// +// So this helper patches ZoneDomes with Harmony, the same way the zone helper patches ZoneManager +// (D181): +// +// * **A message with no player is not sent.** A prefix on ZoneDomes' `TranslateMessage(BasePlayer, +// string, string)` skips it when the player is null. Nothing else about the call changes, so +// `AddNewDome` returns its true or false normally. +// * **It says when ZoneDomes has finished starting.** ZoneDomes' own start (`InitializeDomes`) +// drops every dome whose zone does not exist yet and draws every one that does. A dome added +// BEFORE that is drawn a second time, and the first set of spheres stays in the world with +// nothing holding it. A postfix there raises `OnRgDomesReady`, and the bridge puts its domes +// back only after it. +// +// It is optional. Without it the bridge offers no dome and says why; the zones themselves are +// unaffected. `rgd.status` in the server console prints its state. + +using System; +using System.Reflection; +using HarmonyLib; +using Oxide.Core; +using Oxide.Core.Plugins; + +namespace Oxide.Plugins +{ + [Info("Runic Gateway Domes", "Runic Gateway", "0.1.0")] + [Description("Helper for Runic Gateway: ZoneDomes can be called without a player, and says when it has started.")] + public class RunicGatewayDomes : RustPlugin + { + private const string HarmonyId = "runicgateway.domes"; + + /// The instance the static patches report to. Null while unloaded. + private static RunicGatewayDomes _instance; + + private Harmony _harmony; + private MethodInfo _message; + private MethodInfo _initialize; + + /// patched, unsupported (ZoneDomes is here but not the shape patched), or no-zonedomes. + private string _state = "no-zonedomes"; + private string _reason; + + /// Whether ZoneDomes has finished its own start since it was patched. Main thread. + private bool _ready; + + /// Set once the server is up, at this plugin's own OnServerInitialized. + private bool _serverUp; + + // Patched as EARLY as possible — at load, or the moment ZoneDomes loads — so that at a real + // server start the patch is in before ZoneDomes' `OnServerInitialized` runs its start. + + private void Loaded() + { + _instance = this; + Patch(); + } + + private void OnServerInitialized(bool initial) + { + _serverUp = true; + + // Loaded into a running server: ZoneDomes, if it is here, started long ago. + if (!initial) + MarkReadyNextTick(); + } + + private void Unload() + { + Unpatch(); + _instance = null; + } + + private void OnPluginLoaded(Plugin plugin) + { + if (plugin == null || plugin.Name != "ZoneDomes") + return; + + Unpatch(); + Patch(); + + // A ZoneDomes loaded into a running server has run its start by now: the framework + // calls a hot-loaded plugin's `OnServerInitialized` before it tells anybody else it + // loaded. The tick is margin, and costs nothing. + if (_serverUp) + MarkReadyNextTick(); + } + + private void OnPluginUnloaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "ZoneDomes") + { + Unpatch(); + _state = "no-zonedomes"; + _reason = null; + } + } + + // ── The API the bridge calls ───────────────────────────────────────────── + + /// patched, unsupported or no-zonedomes. + private string State() => _state; + + /// Why the state is not patched, or null. + private string StateReason() => _reason; + + /// Whether ZoneDomes is patched and past its own start, so a dome may be added. + private bool Ready() => _state == "patched" && _ready; + + /// The helper's own version, for hello. + private string HelperVersion() => Version.ToString(); + + // ── Patching ───────────────────────────────────────────────────────────── + + private void Patch() + { + _ready = false; + Plugin zoneDomes = plugins.Find("ZoneDomes"); + + if (zoneDomes == null) + { + _state = "no-zonedomes"; + _reason = null; + return; + } + + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + Type type = zoneDomes.GetType(); + + _message = type.GetMethod("TranslateMessage", any, null, new[] { typeof(BasePlayer), typeof(string), typeof(string) }, null); + _initialize = type.GetMethod("InitializeDomes", any, null, Type.EmptyTypes, null); + + string missing = + _message == null ? "TranslateMessage(BasePlayer, string, string)" : + _initialize == null ? "InitializeDomes()" : + type.GetMethod("AddNewDome", any) == null ? "AddNewDome" : + type.GetMethod("RemoveExistingDome", any) == null ? "RemoveExistingDome" : + null; + + if (missing != null) + { + Fail($"this ZoneDomes ({zoneDomes.Version}) has no {missing}"); + return; + } + + try + { + _harmony = new Harmony(HarmonyId); + _harmony.Patch(_message, prefix: new HarmonyMethod( + typeof(RunicGatewayDomes).GetMethod(nameof(BeforeMessage), BindingFlags.Static | BindingFlags.NonPublic))); + _harmony.Patch(_initialize, postfix: new HarmonyMethod( + typeof(RunicGatewayDomes).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic))); + } + catch (Exception ex) + { + Fail($"Harmony could not patch ZoneDomes: {ex.Message}"); + return; + } + + _state = "patched"; + _reason = null; + Puts($"patched ZoneDomes {zoneDomes.Version}: its API can be called without a player"); + } + + private void Unpatch() + { + if (_harmony != null) + { + try + { + if (_message != null) + _harmony.Unpatch(_message, HarmonyPatchType.Prefix, HarmonyId); + + if (_initialize != null) + _harmony.Unpatch(_initialize, HarmonyPatchType.Postfix, HarmonyId); + } + catch (Exception ex) + { + PrintWarning($"could not remove the ZoneDomes patches: {ex.Message}"); + } + } + + _harmony = null; + _message = null; + _initialize = null; + _ready = false; + } + + private void Fail(string reason) + { + Unpatch(); + _state = "unsupported"; + _reason = reason; + PrintWarning($"{reason}. Runic Gateway will not offer a dome over its zones on this server."); + } + + private void MarkReadyNextTick() + { + NextTick(() => + { + if (_state != "patched" || _ready) + return; + + _ready = true; + Interface.CallHook("OnRgDomesReady"); + }); + } + + // ── The patches ────────────────────────────────────────────────────────── + + /// + /// Harmony prefix on ZoneDomes.TranslateMessage(BasePlayer, string, string). + /// Returning false skips the message; a real player still gets it. + /// + private static bool BeforeMessage(BasePlayer player) + { + return player != null; + } + + /// + /// Harmony postfix on ZoneDomes.InitializeDomes. Anything thrown here would surface + /// inside ZoneDomes, so nothing is allowed to. + /// + private static void AfterInitialize() + { + try + { + RunicGatewayDomes self = _instance; + + if (self == null || self._state != "patched") + return; + + self._ready = true; + Interface.CallHook("OnRgDomesReady"); + } + catch (Exception) + { + // Never ZoneDomes' problem. + } + } + + // ── Console ────────────────────────────────────────────────────────────── + + [ConsoleCommand("rgd.status")] + private void StatusCommand(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + arg.ReplyWith($"Runic Gateway Domes {Version}: {_state}{(_state == "patched" ? (_ready ? ", ready" : ", waiting for ZoneDomes to start") : "")}{(_reason != null ? $" ({_reason})" : "")}"); + } + } +} From b30c6edd5991d0551efd1d8c26d6027a8806d03b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 09:42:17 -0500 Subject: [PATCH 09/18] feat(bridge): a dome without a type is the full dome (D212) ZoneDomes' Standard type is its one whole dome; the four colours show only where they meet the ground or a building. Walked on both rigs 2026-09-29. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 19a5634..f748189 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -8411,7 +8411,9 @@ namespace Oxide.Plugins } Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"]; - domeType = t != null && t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1; + // No type is Standard, the one full dome; the colours show only where they meet + // the ground (D212). + domeType = t == null ? 0 : t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1; domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1; if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax) From 742e18ba0af676f8706f589b9c57821cfd29d83e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:21:50 -0500 Subject: [PATCH 10/18] feat(bridge): RunicNPC, the site's NPC profiles and placements (runicnpc stage 4) Protocol 13 gains npc.profiles, npc.profiles.set, npc.placements and npc.placement (add, set, remove, rename, respawn), each calling RunicNPC's API 3; the frames npc.died, npc.health and npc.placement.changed from its hooks; runicNpc in the integrations body; world.place with a profile; the killfeed's attackerNpc and attackerProfile; and the tally's npcProfileKills. rg.npc reports it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 712 +++++++++++++++++++++++++- 1 file changed, 705 insertions(+), 7 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 5001b9b..63b70cf 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -248,7 +248,8 @@ namespace Oxide.Plugins "OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair", "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched", "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", - "OnEnterZone", "OnExitZone", "OnRgDomesReady" + "OnEnterZone", "OnExitZone", "OnRgDomesReady", + "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged" }; private void Init() @@ -1068,6 +1069,25 @@ namespace Oxide.Plugins HandleMapLive(reqId); break; + case "npc.profiles": + // Protocol 13, RunicNPC (docs/runicnpc/PLAN.md stage 4). The server's profiles, + // read before the site's first push so it can adopt them (D244). + HandleNpcProfiles(reqId); + break; + + case "npc.profiles.set": + HandleNpcProfilesSet(cmd, reqId); + break; + + case "npc.placements": + HandleNpcPlacements(reqId); + break; + + case "npc.placement": + // One change to one placement, by `op` (D245, D246). + HandleNpcPlacement(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1311,7 +1331,7 @@ namespace Oxide.Plugins /// the environment case and not an error. /// /// - private static void DescribeAttacker( + private void DescribeAttacker( Dictionary frame, BasePlayer victim, HitInfo info) { BaseEntity initiator = info != null ? info.Initiator : null; @@ -1344,6 +1364,17 @@ namespace Oxide.Plugins { frame["attackerType"] = "npc"; frame["attackerName"] = initiator.ShortPrefabName; + + // One of RunicNPC's: its own name, as the victim's death screen shows it, and its + // profile. Beside `attackerName`, not over it, so a reader that knows only prefab + // names still reads a scientist (runicnpc PLAN.md §1.4, stage 4). + string profile = RunicNpcProfileOf(initiator); + + if (profile != null && attacker != null) + { + frame["attackerNpc"] = attacker.displayName; + frame["attackerProfile"] = profile; + } } float distance = DistanceOf(info, victim); @@ -1828,6 +1859,11 @@ namespace Oxide.Plugins Tally tally = TallyFor(attacker); tally.NpcKills++; tally.Killed(info, entity, false); + + string profile = RunicNpcProfileOf(victimPlayer); + + if (profile != null) + tally.KilledProfile(profile); CreditKill(attacker, false); } @@ -2775,6 +2811,9 @@ namespace Oxide.Plugins public int ApcKills; public int HeliKills; public readonly Dictionary Crafted = new Dictionary(); + + /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . + public readonly Dictionary NpcProfileKills = new Dictionary(); public int Built; public int Repaired; public int Healed; @@ -2792,6 +2831,11 @@ namespace Oxide.Plugins Add(Crafted, category, amount); } + public void KilledProfile(string profile) + { + Add(NpcProfileKills, profile, 1); + } + /// /// What a credited kill adds for the titles: its weapon (any victim, D174), a /// headshot, and its distance as this interval's best of its kind. @@ -2827,7 +2871,7 @@ namespace Oxide.Plugins && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 - && Rockets == 0 && Explosives == 0 && Missions == 0; + && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0; } } @@ -2926,6 +2970,8 @@ namespace Oxide.Plugins frame["explosives"] = tally.Explosives; if (tally.Missions > 0) frame["missions"] = tally.Missions; + if (tally.NpcProfileKills.Count > 0) + frame["npcProfileKills"] = tally.NpcProfileKills; EmitFrame(frame); } @@ -4931,6 +4977,12 @@ namespace Oxide.Plugins return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes"; } + /// RunicNPC's presence and API are in hello, so its load and unload say it again (stage 4). + private bool IsRunicNpc(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "RunicNPC"; + } + /// Main thread. Hello again, once the world is up and the link is. private void ResendHello() { @@ -4961,7 +5013,7 @@ namespace Oxide.Plugins // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). - if (IsZonePlugin(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) NextTick(ResendHello); // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has @@ -4986,7 +5038,7 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) NextTick(ResendHello); ConfigWriteJob job = _configJob; @@ -8990,6 +9042,21 @@ namespace Oxide.Plugins return; } + // Stage 4 (D243): a RunicNPC profile instead of one of Rust's own prefabs. + string profile = StringField(cmd, "profile"); + + if (!string.IsNullOrEmpty(profile)) + { + if (!string.IsNullOrEmpty(StringField(cmd, "prefab"))) + { + EmitWorldError(reqId, "malformed", "a placement names a prefab or a profile, not both"); + return; + } + + HandleWorldPlaceProfile(cmd, reqId, key, runId, profile); + return; + } + WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab")); if (prefab == null) @@ -9199,7 +9266,7 @@ namespace Oxide.Plugins } else { - wasThere = KillEntity(e.Id); + wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : KillEntity(e.Id); } _world.Remove(e.Id); @@ -10292,6 +10359,636 @@ namespace Oxide.Plugins EnsureTallyTimer(); } + // ── RunicNPC (docs/runicnpc/PLAN.md stage 4, PROTOCOL.md §19.12) ───────────────────── + // + // RunicNPC is Runic Gateway's own NPC plugin. The bridge is its only link to the site + // (runicnpc PLAN.md §4): it calls RunicNPC's API for the site's commands and turns its hooks + // into frames. RunicNPC never talks to the sidecar, so "the sidecar is a dumb forwarder" + // stays true. It is optional until RunicNPC's stage 9; without it every `npc.*` command + // answers `runicnpc-missing`, and events keep Rust's own scientists (D243). + + /// The RunicNPC API these commands call: 3 added placements from a website (D249). + private const int RunicNpcApiNeeded = 3; + + private Plugin RunicNpcPlugin() + { + return plugins.Find("RunicNPC"); + } + + /// RunicNPC's API version, or 0 when it is not loaded. + private int RunicNpcApi() + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + return 0; + + object api = npc.Call("RunicNpc_ApiVersion"); + return api is int ? (int)api : 0; + } + + /// For the integrations body: loaded, its version, and the API it answers. + private Dictionary RunicNpcBody() + { + Dictionary body = IntegrationBody(RunicNpcPlugin()); + + if (RunicNpcPlugin() != null) + body["api"] = RunicNpcApi(); + + return body; + } + + /// + /// Main thread. The profile name of one of RunicNPC's NPCs, or null for anything else — + /// a Rust scientist, an animal, or any NPC while RunicNPC is not loaded. + /// + private string RunicNpcProfileOf(BaseEntity entity) + { + if (entity == null) + return null; + + Plugin npc = RunicNpcPlugin(); + return npc != null ? npc.Call("RunicNpc_ProfileOf", entity) as string : null; + } + + /// + /// Main thread. The plugin to call, or null after answering npc.error — missing, or + /// older than the API these commands need. + /// + private Plugin RunicNpcFor(string reqId) + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + { + EmitNpcError(reqId, "runicnpc-missing", "RunicNPC is not loaded on this server"); + return null; + } + + int api = RunicNpcApi(); + + if (api < RunicNpcApiNeeded) + { + EmitNpcError(reqId, "runicnpc-old", + $"RunicNPC {npc.Version} answers API {api}, and this bridge needs {RunicNpcApiNeeded}"); + return null; + } + + return npc; + } + + /// + /// Main thread. npc.profiles: the server's profile set as RunicNPC holds it — whether + /// a site manages it, every profile, and those refused and why. The site reads this before + /// its first push to a server, to adopt the server's own (D244). + /// + private void HandleNpcProfiles(string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + var all = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject; + + if (all == null) + { + EmitNpcError(reqId, "refused", "RunicNPC did not answer with its profiles"); + return; + } + + Dictionary reply = Frame("npc.profiles", "reply"); + reply["managed"] = all["managed"] != null && (bool)all["managed"]; + reply["profiles"] = all["profiles"] ?? new Newtonsoft.Json.Linq.JObject(); + reply["refused"] = all["refused"] ?? new Newtonsoft.Json.Linq.JObject(); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.profiles.set: replaces the whole profile set, as the site does on + /// every push, and so marks the server managed (D221). RunicNPC checks each profile — a kit + /// this server lacks, for one — and the answer lists those it refused. + /// + private void HandleNpcProfilesSet(Dictionary cmd, string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + object value; + var profiles = cmd.TryGetValue("profiles", out value) ? value as Newtonsoft.Json.Linq.JObject : null; + + if (profiles == null) + { + EmitNpcError(reqId, "malformed", "a push carries `profiles`, an object of profiles by name"); + return; + } + + var refused = npc.Call("RunicNpc_SetProfiles", profiles) as Dictionary; + _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}"; + Puts($"runicnpc: {_lastNpcSummary}"); + + Dictionary reply = Frame("npc.ok", "reply"); + reply["refused"] = refused ?? new Dictionary(); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.placements: every placement with its values, how many of its NPCs + /// are alive, what it waits for and its note (D237, D239); the routes a placement may walk; + /// and the cost warning for what the server plans now (D227). + /// + private void HandleNpcPlacements(string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + var routes = npc.Call("RunicNpc_Routes") as Newtonsoft.Json.Linq.JObject; + var names = new List(); + + if (routes != null) + { + foreach (KeyValuePair route in routes) + names.Add(route.Key); + } + + names.Sort(StringComparer.Ordinal); + + Dictionary reply = Frame("npc.placements", "reply"); + reply["placements"] = npc.Call("RunicNpc_Placements") ?? new List>(); + reply["routes"] = names; + reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.placement: one change to one placement, by op — add + /// (a new one, named by RunicNPC as in game, from a map point it puts on the ground and + /// checks against the navmesh: D245, D246), set (new values for an existing one), + /// remove, rename and respawn. Every refusal is RunicNPC's own sentence. + /// + private void HandleNpcPlacement(Dictionary cmd, string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + string op = StringField(cmd, "op"); + string id = StringField(cmd, "id"); + object value; + var placement = cmd.TryGetValue("placement", out value) ? value as Newtonsoft.Json.Linq.JObject : null; + Dictionary reply = Frame("npc.ok", "reply"); + + switch (op) + { + case "add": + { + if (placement == null) + { + EmitNpcError(reqId, "malformed", "add carries `placement`"); + return; + } + + var answer = npc.Call("RunicNpc_AddPlacement", placement) as Dictionary; + object error = null; + + if (answer == null || answer.TryGetValue("error", out error)) + { + EmitNpcError(reqId, "refused", answer != null ? error as string : "RunicNPC did not answer"); + return; + } + + Merge(reply, answer); + id = answer["id"] as string; + break; + } + + case "set": + { + if (string.IsNullOrEmpty(id) || placement == null) + { + EmitNpcError(reqId, "malformed", "set carries `id` and `placement`"); + return; + } + + if (!PlacementExists(npc, id)) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + string error = npc.Call("RunicNpc_SetPlacement", id, placement) as string; + + if (error != null) + { + EmitNpcError(reqId, "refused", error); + return; + } + + reply["id"] = id; + reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string; + break; + } + + case "remove": + { + object removed = npc.Call("RunicNpc_RemovePlacement", id); + + if (!(removed is bool) || !(bool)removed) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + reply["id"] = id; + break; + } + + case "rename": + { + string to = StringField(cmd, "to"); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) + { + EmitNpcError(reqId, "malformed", "rename carries `id` and `to`"); + return; + } + + if (!PlacementExists(npc, id)) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + string error = npc.Call("RunicNpc_RenamePlacement", id, to) as string; + + if (error != null) + { + EmitNpcError(reqId, "refused", error); + return; + } + + reply["id"] = to; + reply["previous"] = id; + break; + } + + case "respawn": + { + object count = npc.Call("RunicNpc_RespawnPlacement", id); + int n = count is int ? (int)count : -1; + + if (n < 0) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + reply["id"] = id; + reply["respawned"] = n; + break; + } + + default: + EmitNpcError(reqId, "malformed", "op is add, set, remove, rename or respawn"); + return; + } + + _lastNpcSummary = $"placement {op} {id}"; + Puts($"runicnpc: {_lastNpcSummary}"); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static bool PlacementExists(Plugin npc, string id) + { + var all = npc.Call("RunicNpc_Placements") as List>; + + if (all == null) + return false; + + foreach (Dictionary row in all) + { + object rowId; + + if (row.TryGetValue("id", out rowId) && rowId as string == id) + return true; + } + + return false; + } + + private void EmitNpcError(string reqId, string reason, string message) + { + Dictionary frame = Frame("npc.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastNpcSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What the last RunicNPC command did, for rg.status. + private string _lastNpcSummary; + + // ---- RunicNPC's hooks, as frames ---- + + /// + /// One of RunicNPC's NPCs died (stage 4). npc.died carries its profile and name, who + /// landed the killing blow, and every player who took health from it, most first — what a + /// phase waiting on "8 guards died" counts, and what a boss's reward goes to (stage 6). + /// The per-player kill count is the tally's (npcProfileKills), not this frame's. + /// + private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + MarkHook("OnRunicNpcDied"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.died", "event"); + PutNpc(frame, npc, profile, owner); + + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + + if (killer != null && !killer.IsNpc) + { + frame["killerId"] = ((ulong)killer.userID).ToString(); + frame["killerName"] = killer.displayName; + } + + string weapon = WeaponOf(info); + + if (weapon != null) + frame["weapon"] = weapon; + + var rows = new List(); + + if (contributors != null) + { + var sorted = new List>(contributors); + sorted.Sort((a, b) => b.Value.CompareTo(a.Value)); + + foreach (KeyValuePair kv in sorted) + { + BasePlayer who = BasePlayer.FindByID(kv.Key) ?? BasePlayer.FindSleeping(kv.Key); + var row = new Dictionary + { + { "steamId", kv.Key.ToString() }, + { "damage", (float)Math.Round(kv.Value, 1) } + }; + + if (who != null) + row["name"] = who.displayName; + + rows.Add(row); + } + } + + frame["contributors"] = rows; + EmitFrame(frame); + } + + /// An NPC fell to one of its profile's healthThresholds (a boss below 50%). + private void OnRunicNpcHealth(BasePlayer npc, string profile, float threshold) + { + MarkHook("OnRunicNpcHealth"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.health", "event"); + PutNpc(frame, npc, profile, null); + frame["threshold"] = threshold; + frame["health"] = (float)Math.Round(npc.health, 1); + frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); + EmitFrame(frame); + } + + /// + /// A placement was set, removed or renamed, from the site or in game (D249). The frame says + /// which; the site reads the list again rather than trusting a diff. + /// + private void OnRunicNpcPlacementChanged(string id, string change, string previous) + { + MarkHook("OnRunicNpcPlacementChanged"); + + Dictionary frame = Frame("npc.placement.changed", "event"); + frame["id"] = id; + frame["change"] = change; + + if (!string.IsNullOrEmpty(previous)) + frame["previous"] = previous; + + EmitFrame(frame); + } + + /// The fields every NPC frame shares. owner null reads it from the NPC's placement, if any. + private void PutNpc(Dictionary frame, BasePlayer npc, string profile, string owner) + { + frame["netId"] = npc.net != null ? npc.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + frame["profile"] = profile; + frame["name"] = npc.displayName; + + if (!string.IsNullOrEmpty(owner)) + { + frame["owner"] = owner; + + // An event run's NPC names its run, for the site's phase gates; a placement's names + // the placement. `plugin:` owners are another plugin's business. + if (owner.StartsWith("run:", StringComparison.Ordinal)) + frame["runId"] = owner.Substring(4); + else if (owner.StartsWith("placement:", StringComparison.Ordinal)) + frame["placement"] = owner.Substring(10); + } + + UnityEngine.Vector3 at = npc.transform.position; + frame["x"] = (float)Math.Round(at.x, 1); + frame["z"] = (float)Math.Round(at.z, 1); + } + + // ---- events: an NPC of a profile (D243) ---- + + /// + /// Main thread. world.place with a profile: the NPCs are RunicNPC's, owned by + /// the run (run:<runId>), so its restart and teardown rules are the bridge's as + /// for any placed NPC (§15.3). All or nothing, like a prefab: if RunicNPC refuses one, the + /// ones already spawned are removed. + /// + private void HandleWorldPlaceProfile(Dictionary cmd, string reqId, string key, string runId, string profile) + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + { + EmitWorldError(reqId, "runicnpc-missing", $"RunicNPC is not loaded, so there is no profile '{profile}' here"); + return; + } + + var profiles = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken known = profiles != null ? profiles["profiles"]?[profile] : null; + + if (known == null) + { + EmitWorldError(reqId, "unknown-profile", $"this server has no RunicNPC profile '{profile}'"); + return; + } + + Newtonsoft.Json.Linq.JToken refusal = profiles["refused"]?[profile]; + + if (refusal != null) + { + EmitWorldError(reqId, "unknown-profile", $"RunicNPC refuses the profile '{profile}': {refusal}"); + return; + } + + int count = IntField(cmd, "count"); + int max = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs; + + if (count < 1 || count > max) + { + EmitWorldError(reqId, "out-of-range", $"this server places 1 to {max} NPCs at a time, not {count}"); + return; + } + + double spread = DoubleField(cmd, "spread") ?? (count > 1 ? 10.0 : 0.0); + + if (spread < 0 || spread > WorldMaxSpread) + { + EmitWorldError(reqId, "out-of-range", $"a scatter is 0 to {WorldMaxSpread} m, not {spread}"); + return; + } + + UnityEngine.Vector3 center; + string reason, message; + + if (!ResolveWorldPoint(cmd, out center, out reason, out message)) + { + EmitWorldError(reqId, reason, message); + return; + } + + string owner = "run:" + runId; + var made = new List(); + + for (int i = 0; i < count; i++) + { + BasePlayer spawned = null; + + // A scattered point may land off the navmesh (a rock, a slope); a few tries, then the + // centre itself, before the step is refused. + for (int attempt = 0; attempt < 4 && spawned == null; attempt++) + { + UnityEngine.Vector3 at = center; + + if (count > 1 && attempt < 3) + { + UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread; + at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y); + at.y = TerrainMeta.HeightMap.GetHeight(at); + } + + spawned = npc.Call("RunicNpc_Spawn", at, profile, owner, null) as BasePlayer; + } + + if (spawned == null) + { + foreach (WorldEntry e in made) + npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture)); + + EmitWorldError(reqId, "refused", + $"RunicNPC would not spawn '{profile}' there (off the navmesh, a cap, or the navmesh still building; the server log says which)"); + return; + } + + UnityEngine.Vector3 put = spawned.transform.position; + made.Add(new WorldEntry + { + Id = spawned.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind = WorldKindNpc, + Prefab = RunicNpcPrefix + profile, + RunId = runId, + Key = key, + X = put.x, + Y = put.y, + Z = put.z, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }); + } + + foreach (WorldEntry e in made) + _world[e.Id] = e; + + SaveWorld(); + + _lastWorldSummary = $"placed {count} of RunicNPC profile {profile} for run {runId}"; + Puts($"world: {_lastWorldSummary}"); + EmitWorldPlaced(reqId, made, false); + } + + /// A registry entry's prefab for a RunicNPC profile: runicnpc:<profile>. + private const string RunicNpcPrefix = "runicnpc:"; + + /// + /// Main thread. Removes a run's RunicNPC NPC through RunicNPC, so it hears of it (and raises + /// its own despawn hook); anything else, or RunicNPC gone, is killed as any entity is. + /// + private bool KillWorldNpc(WorldEntry e) + { + Plugin npc = RunicNpcPlugin(); + ulong netId; + + if (npc != null && e.Prefab != null && e.Prefab.StartsWith(RunicNpcPrefix, StringComparison.Ordinal) + && ulong.TryParse(e.Id, out netId)) + { + object removed = npc.Call("RunicNpc_Despawn", netId); + + if (removed is int && (int)removed > 0) + return true; + } + + return KillEntity(e.Id); + } + + /// RunicNPC as the bridge sees it: loaded, its API, and the last command. + [ConsoleCommand("rg.npc")] + private void CmdNpc(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + Plugin npc = RunicNpcPlugin(); + arg.ReplyWith(npc == null + ? "RunicNPC: not loaded. npc.* commands answer runicnpc-missing; events place Rust's own scientists." + : $"RunicNPC {npc.Version}, API {RunicNpcApi()} (this bridge needs {RunicNpcApiNeeded}). last: {_lastNpcSummary ?? "none yet"}"); + } + // ── Kits ────────────────────────────────────────────────────────────────── private Plugin KitsPlugin() @@ -11018,7 +11715,8 @@ namespace Oxide.Plugins { { "betterChat", IntegrationBody(BetterChatPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) }, - { "zoneDomes", ZoneDomesBody() } + { "zoneDomes", ZoneDomesBody() }, + { "runicNpc", RunicNpcBody() } }; } From 6347c25d57b839bac7f7d567abde0226abb3e1bb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:48:51 -0500 Subject: [PATCH 11/18] feat(release): declare the RunicNPC API the bridge needs (runicnpc stage 4, D224) overlay.toml gains runicnpc_api = 3, the release folds it into manifest.json, and checkPlugin.js holds it equal to RunicNpcApiNeeded in the code. The installer's bundle carries the latest RunicNPC release that answers it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 6 ++++++ overlay.toml | 12 ++++++++++++ scripts/checkPlugin.js | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4b1b5ab..6799076 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -338,6 +338,10 @@ jobs: # requires_plugins = ["Kits", "ZoneManager"] is already a JSON array. # One line, quoted strings only; anything else fails the check below. REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')" + # The RunicNPC API the bridge's npc.* commands need (runicnpc stage 4). 0 when + # absent, which a bundle reads as "carries no RunicNPC". + RUNICNPC_API="$(grep -m1 -E '^runicnpc_api[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')" + RUNICNPC_API="${RUNICNPC_API:-0}" [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } [ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; } [ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; } @@ -354,6 +358,7 @@ jobs: --arg min_oxide "${MIN_OXIDE}" \ --arg min_carbon "${MIN_CARBON}" \ --argjson requires "${REQUIRES}" \ + --argjson runicnpc "${RUNICNPC_API}" \ --argjson files "${FILES}" \ '{ component: $component, @@ -364,6 +369,7 @@ jobs: min_oxide_version: $min_oxide, min_carbon_version: $min_carbon, requires_plugins: $requires, + runicnpc_api: $runicnpc, files: $files }' > "${STAGE}/manifest.json" echo "----- manifest.json -----" diff --git a/overlay.toml b/overlay.toml index 3093524..5f22132 100644 --- a/overlay.toml +++ b/overlay.toml @@ -77,3 +77,15 @@ min_carbon_version = "2.0.259" # 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"] + +# ── The RunicNPC API the bridge needs ─────────────────────────────────────── +# +# RunicNPC (docs/runicnpc/PLAN.md stage 4): the bridge's npc.* commands call +# RunicNPC's API and need at least this version of it. It MUST equal +# `RunicNpcApiNeeded` in RunicGateway.cs; `node scripts/checkPlugin.js` holds the +# two equal. The release copies it into manifest.json, and the installer's +# bundle carries the latest RunicNPC release that answers at least it (D224). +# +# RunicNPC is optional to the bridge until its stage 9: without it every npc.* +# command answers `runicnpc-missing`, and events place Rust's own scientists. +runicnpc_api = 3 diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index d7ac002..bf4e078 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -198,6 +198,14 @@ function check(source, toml) { // 5. The two declaration sites this repository owns must agree. const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) + // runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol. + const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml) + const npcCode = /const\s+int\s+RunicNpcApiNeeded\s*=\s*(\d+)\s*;/.exec(source) + if (npcCode && !npcToml) problems.push('the plugin needs a RunicNPC API (RunicNpcApiNeeded) and overlay.toml declares no runicnpc_api') + if (npcCode && npcToml && npcCode[1] !== npcToml[1]) { + problems.push(`the plugin needs RunicNPC API ${npcCode[1]} and overlay.toml declares runicnpc_api = ${npcToml[1]}. The bundle picks a RunicNPC by the second, so they must agree.`) + } + const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml) if (!inCode) problems.push('could not read ProtocolVersion from the plugin source') From cf26b05abc6e3ef90a42112d7b543d7b4b133492 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 07:58:16 -0500 Subject: [PATCH 12/18] feat(bridge): the faction table and an event's escort, ally and tether (runicnpc stage 5) - npc.profiles answers with RunicNPC's faction table, and npc.profiles.set pushes one beside the profiles (RunicNpc_SetFactions), set first so the profiles are checked against it; a refused table comes back as factionsRefused and the old one is kept (D254); - world.place with a profile takes escort (a Steam id; the player must be on, else escort-offline), ally ({kind: clan|player, id}) and tether (the run's own zone that holds the point, else no-zone), and gives each NPC its orders through RunicNpc_Escort, _Ally and _Tether. All or nothing: an order RunicNPC refuses removes every NPC the step placed (D269, D270, D272); - the RunicNPC API the bridge needs is 4 (overlay.toml runicnpc_api = 4). Compiled on the Oxide rig. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 4 +- overlay/oxide/plugins/RunicGateway.cs | 181 +++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 5 deletions(-) diff --git a/overlay.toml b/overlay.toml index 5f22132..07d111a 100644 --- a/overlay.toml +++ b/overlay.toml @@ -80,7 +80,7 @@ requires_plugins = ["Kits", "ZoneManager"] # ── The RunicNPC API the bridge needs ─────────────────────────────────────── # -# RunicNPC (docs/runicnpc/PLAN.md stage 4): the bridge's npc.* commands call +# RunicNPC (docs/runicnpc/PLAN.md stages 4 and 5): the bridge's npc.* commands call # RunicNPC's API and need at least this version of it. It MUST equal # `RunicNpcApiNeeded` in RunicGateway.cs; `node scripts/checkPlugin.js` holds the # two equal. The release copies it into manifest.json, and the installer's @@ -88,4 +88,4 @@ requires_plugins = ["Kits", "ZoneManager"] # # RunicNPC is optional to the bridge until its stage 9: without it every npc.* # command answers `runicnpc-missing`, and events place Rust's own scientists. -runicnpc_api = 3 +runicnpc_api = 4 diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 63b70cf..26ef994 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -10367,8 +10367,11 @@ namespace Oxide.Plugins // stays true. It is optional until RunicNPC's stage 9; without it every `npc.*` command // answers `runicnpc-missing`, and events keep Rust's own scientists (D243). - /// The RunicNPC API these commands call: 3 added placements from a website (D249). - private const int RunicNpcApiNeeded = 3; + /// + /// The RunicNPC API these commands call: 3 added placements from a website (D249), 4 the + /// faction table and an event's escort, ally and tether (runicnpc stage 5). + /// + private const int RunicNpcApiNeeded = 4; private Plugin RunicNpcPlugin() { @@ -10462,6 +10465,9 @@ namespace Oxide.Plugins reply["profiles"] = all["profiles"] ?? new Newtonsoft.Json.Linq.JObject(); reply["refused"] = all["refused"] ?? new Newtonsoft.Json.Linq.JObject(); + // Runicnpc stage 5 (D254): the faction table, read with the profiles so the site can adopt it. + reply["factions"] = all["factions"] ?? new Newtonsoft.Json.Linq.JArray(); + if (reqId != null) reply["reqId"] = reqId; @@ -10489,13 +10495,23 @@ namespace Oxide.Plugins return; } + // Runicnpc stage 5 (D254): the faction table travels with the profiles. It is set first, so the + // profiles are checked against the table they will be used with; a refused table is reported + // and the old one kept, and the profiles are pushed either way. + var factions = cmd.TryGetValue("factions", out value) ? value as Newtonsoft.Json.Linq.JArray : null; + string factionsRefused = factions != null ? npc.Call("RunicNpc_SetFactions", factions) as string : null; + var refused = npc.Call("RunicNpc_SetProfiles", profiles) as Dictionary; - _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}"; + _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}" + + (factions != null ? $", factions {factions.Count}{(factionsRefused != null ? " REFUSED" : "")}" : ""); Puts($"runicnpc: {_lastNpcSummary}"); Dictionary reply = Frame("npc.ok", "reply"); reply["refused"] = refused ?? new Dictionary(); + if (factionsRefused != null) + reply["factionsRefused"] = factionsRefused; + if (reqId != null) reply["reqId"] = reqId; @@ -10892,6 +10908,14 @@ namespace Oxide.Plugins return; } + NpcOrders orders; + + if (!ReadNpcOrders(cmd, runId, center, out orders, out reason, out message)) + { + EmitWorldError(reqId, reason, message); + return; + } + string owner = "run:" + runId; var made = new List(); @@ -10925,6 +10949,19 @@ namespace Oxide.Plugins return; } + string orderRefusal = ApplyNpcOrders(npc, spawned, orders); + + if (orderRefusal != null) + { + npc.Call("RunicNpc_Despawn", spawned.net.ID.Value); + + foreach (WorldEntry e in made) + npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture)); + + EmitWorldError(reqId, "refused", $"RunicNPC would not give '{profile}' its orders: {orderRefusal}"); + return; + } + UnityEngine.Vector3 put = spawned.transform.position; made.Add(new WorldEntry { @@ -10955,6 +10992,144 @@ namespace Oxide.Plugins /// A registry entry's prefab for a RunicNPC profile: runicnpc:<profile>. private const string RunicNpcPrefix = "runicnpc:"; + // ---- an event's NPCs: escort, ally and tether (runicnpc stage 5, D269, D270, D272) ---- + + /// What a placing step tells its NPCs to do besides their profile. Every part is optional. + private sealed class NpcOrders + { + /// D269: the player they escort. + public BasePlayer Escort; + + /// D270: clan (the game's clan id) or player (a Steam id: that player and their team). + public string AllyKind; + public string AllyId; + + /// D272: the zone of this run that holds the step's point. + public string Tether; + + public bool Any => Escort != null || AllyKind != null || Tether != null; + } + + /// + /// Main thread. Reads escort (a Steam id), ally ({kind: clan|player, id}) and + /// tether (true: the run's own zone around the point) from a world.place. False with + /// the reason when one cannot be honoured, so the step is refused before anything spawns. + /// + private bool ReadNpcOrders(Dictionary cmd, string runId, UnityEngine.Vector3 center, + out NpcOrders orders, out string reason, out string message) + { + orders = new NpcOrders(); + reason = null; + message = null; + + string escort = StringField(cmd, "escort"); + + if (!string.IsNullOrEmpty(escort)) + { + ulong steamId; + + if (!ulong.TryParse(escort, out steamId)) + { + reason = "malformed"; + message = $"an escort is a player's Steam id, not '{escort}'"; + return false; + } + + BasePlayer player = BasePlayer.FindByID(steamId); + + if (player == null || !player.IsConnected || player.IsDead()) + { + reason = "escort-offline"; + message = $"the player {steamId} is not on the server, so there is nobody to escort"; + return false; + } + + orders.Escort = player; + } + + object allyValue; + var ally = cmd.TryGetValue("ally", out allyValue) ? allyValue as Newtonsoft.Json.Linq.JObject : null; + + if (ally != null) + { + orders.AllyKind = (string)ally["kind"]; + orders.AllyId = (string)ally["id"]; + + if ((orders.AllyKind != "clan" && orders.AllyKind != "player") || string.IsNullOrEmpty(orders.AllyId)) + { + reason = "malformed"; + message = "an ally is {kind: clan|player, id}"; + return false; + } + } + + object tetherValue; + + if (cmd.TryGetValue("tether", out tetherValue) && tetherValue is bool && (bool)tetherValue) + { + orders.Tether = RunZoneAt(runId, center); + + if (orders.Tether == null) + { + reason = "no-zone"; + message = "no zone this event made holds that point, so there is nothing to keep the NPCs inside"; + return false; + } + } + + if (orders.Any && RunicNpcApi() < 4) + { + reason = "runicnpc-old"; + message = $"escort, ally and tether need RunicNPC's API 4, and it answers {RunicNpcApi()}"; + return false; + } + + return true; + } + + /// Main thread. The zone a run made that holds a point, or null. + private string RunZoneAt(string runId, UnityEngine.Vector3 at) + { + Plugin zm = plugins.Find("ZoneManager"); + + if (zm == null) + return null; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindZone || e.RunId != runId || ZoneExpired(e)) + continue; + + object inside = zm.Call("IsPositionInZone", e.Id, at); + + if (inside is bool && (bool)inside) + return e.Id; + } + + return null; + } + + /// Main thread. Gives one spawned NPC its orders; null, or RunicNPC's reason for refusing one. + private static string ApplyNpcOrders(Plugin npc, BasePlayer spawned, NpcOrders orders) + { + if (!orders.Any) + return null; + + ulong netId = spawned.net.ID.Value; + string refusal = null; + + if (orders.Escort != null) + refusal = npc.Call("RunicNpc_Escort", netId, (BaseEntity)orders.Escort) as string; + + if (refusal == null && orders.AllyKind != null) + refusal = npc.Call("RunicNpc_Ally", netId, orders.AllyKind, orders.AllyId) as string; + + if (refusal == null && orders.Tether != null) + refusal = npc.Call("RunicNpc_Tether", netId, orders.Tether) as string; + + return refusal; + } + /// /// Main thread. Removes a run's RunicNPC NPC through RunicNPC, so it hears of it (and raises /// its own despawn hook); anything else, or RunicNPC gone, is killed as any entity is. From 90f403422a5465de91c0892038a147e3cdbb4688 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 10:48:28 -0500 Subject: [PATCH 13/18] feat(bridge): boss frames for the site's public triggers (runicnpc stage 6) npc.boss.spawned, npc.boss.phase and npc.boss.died, from RunicNPC API 5's boss hooks, for the site's spawned, phase and killed triggers (D277). The death frame names the killer and counts the players who hurt the boss; npc.died still carries every contributor. A profile's `boss` and `use` blocks travel in the push unchanged. runicnpc_api and RunicNpcApiNeeded are 5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 4 +- overlay/oxide/plugins/RunicGateway.cs | 67 +++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/overlay.toml b/overlay.toml index 07d111a..18c7490 100644 --- a/overlay.toml +++ b/overlay.toml @@ -80,7 +80,7 @@ requires_plugins = ["Kits", "ZoneManager"] # ── The RunicNPC API the bridge needs ─────────────────────────────────────── # -# RunicNPC (docs/runicnpc/PLAN.md stages 4 and 5): the bridge's npc.* commands call +# RunicNPC (docs/runicnpc/PLAN.md stages 4 to 6): the bridge's npc.* commands call # RunicNPC's API and need at least this version of it. It MUST equal # `RunicNpcApiNeeded` in RunicGateway.cs; `node scripts/checkPlugin.js` holds the # two equal. The release copies it into manifest.json, and the installer's @@ -88,4 +88,4 @@ requires_plugins = ["Kits", "ZoneManager"] # # RunicNPC is optional to the bridge until its stage 9: without it every npc.* # command answers `runicnpc-missing`, and events place Rust's own scientists. -runicnpc_api = 4 +runicnpc_api = 5 diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 26ef994..870a237 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -249,7 +249,8 @@ namespace Oxide.Plugins "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched", "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", "OnEnterZone", "OnExitZone", "OnRgDomesReady", - "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged" + "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", + "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied" }; private void Init() @@ -10369,9 +10370,10 @@ namespace Oxide.Plugins /// /// The RunicNPC API these commands call: 3 added placements from a website (D249), 4 the - /// faction table and an event's escort, ally and tether (runicnpc stage 5). + /// faction table and an event's escort, ally and tether (runicnpc stage 5), 5 bosses and + /// passive NPCs (runicnpc stage 6): a profile's `boss` and `use`, and the boss hooks. /// - private const int RunicNpcApiNeeded = 4; + private const int RunicNpcApiNeeded = 5; private Plugin RunicNpcPlugin() { @@ -10824,6 +10826,65 @@ namespace Oxide.Plugins } /// The fields every NPC frame shares. owner null reads it from the NPC's placement, if any. + // ---- bosses (runicnpc stage 6: D277) ---- + // + // Three frames for the site's public boss triggers: spawned, a phase, killed. A boss's death + // also raises npc.died as every NPC's does; npc.boss.died adds the boss's own facts, and names + // no Steam id but the killer's, because the triggers it feeds are public. + + /// A boss spawned: an admin's placement, an event's step or another plugin. + private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) + { + MarkHook("OnRunicNpcBossSpawned"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.boss.spawned", "event"); + PutNpc(frame, npc, profile, owner); + frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); + EmitFrame(frame); + } + + /// A boss entered a phase: phase counts from 1, at is its health fraction. + private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) + { + MarkHook("OnRunicNpcBossPhase"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.boss.phase", "event"); + PutNpc(frame, npc, profile, owner); + frame["phase"] = phase; + frame["at"] = at; + frame["health"] = (float)Math.Round(npc.health, 1); + frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); + EmitFrame(frame); + } + + /// A boss died: the killing blow's player, and how many players hurt it. + private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + MarkHook("OnRunicNpcBossDied"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.boss.died", "event"); + PutNpc(frame, npc, profile, owner); + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + + if (killer != null && !killer.IsNpc) + { + frame["killerId"] = ((ulong)killer.userID).ToString(); + frame["killerName"] = killer.displayName; + } + + frame["damagers"] = contributors != null ? contributors.Count : 0; + EmitFrame(frame); + } + private void PutNpc(Dictionary frame, BasePlayer npc, string profile, string owner) { frame["netId"] = npc.net != null ? npc.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; From 1df3db57d325fbe799d85d8738089f745e7e46d5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 22:36:29 -0500 Subject: [PATCH 14/18] feat(bridge): a Place NPCs step's "drop loot" switch (runicnpc stage 7) world.place with a profile takes dropLoot (D296): false spawns the NPCs with RunicNPC's {"loot":{"dropTable":false}} override, so they keep their corpse's start but roll no table. Absent or true, as before. runicnpc_api and RunicNpcApiNeeded are 6. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 4 ++-- overlay/oxide/plugins/RunicGateway.cs | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/overlay.toml b/overlay.toml index 18c7490..570b602 100644 --- a/overlay.toml +++ b/overlay.toml @@ -80,7 +80,7 @@ requires_plugins = ["Kits", "ZoneManager"] # ── The RunicNPC API the bridge needs ─────────────────────────────────────── # -# RunicNPC (docs/runicnpc/PLAN.md stages 4 to 6): the bridge's npc.* commands call +# RunicNPC (docs/runicnpc/PLAN.md stages 4 to 7): the bridge's npc.* commands call # RunicNPC's API and need at least this version of it. It MUST equal # `RunicNpcApiNeeded` in RunicGateway.cs; `node scripts/checkPlugin.js` holds the # two equal. The release copies it into manifest.json, and the installer's @@ -88,4 +88,4 @@ requires_plugins = ["Kits", "ZoneManager"] # # RunicNPC is optional to the bridge until its stage 9: without it every npc.* # command answers `runicnpc-missing`, and events place Rust's own scientists. -runicnpc_api = 5 +runicnpc_api = 6 diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 870a237..e83d082 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -10371,9 +10371,10 @@ namespace Oxide.Plugins /// /// The RunicNPC API these commands call: 3 added placements from a website (D249), 4 the /// faction table and an event's escort, ally and tether (runicnpc stage 5), 5 bosses and - /// passive NPCs (runicnpc stage 6): a profile's `boss` and `use`, and the boss hooks. + /// passive NPCs (runicnpc stage 6): a profile's `boss` and `use`, and the boss hooks, 6 loot (runicnpc + /// stage 7): a profile's `loot`, and the `dropTable` override a Place NPCs step's switch sends. /// - private const int RunicNpcApiNeeded = 5; + private const int RunicNpcApiNeeded = 6; private Plugin RunicNpcPlugin() { @@ -10980,6 +10981,12 @@ namespace Oxide.Plugins string owner = "run:" + runId; var made = new List(); + // Runicnpc stage 7 (D296): the step's "drop loot" switch. Off, the NPCs keep their corpse's start + // but roll no loot table; on (or absent, a step older than the switch) they drop it as anywhere. + object dropLootValue; + bool dropLoot = !(cmd.TryGetValue("dropLoot", out dropLootValue) && dropLootValue is bool && !(bool)dropLootValue); + Newtonsoft.Json.Linq.JObject spawnOverrides = dropLoot ? null : Newtonsoft.Json.Linq.JObject.Parse("{\"loot\":{\"dropTable\":false}}"); + for (int i = 0; i < count; i++) { BasePlayer spawned = null; @@ -10997,7 +11004,7 @@ namespace Oxide.Plugins at.y = TerrainMeta.HeightMap.GetHeight(at); } - spawned = npc.Call("RunicNpc_Spawn", at, profile, owner, null) as BasePlayer; + spawned = npc.Call("RunicNpc_Spawn", at, profile, owner, spawnOverrides) as BasePlayer; } if (spawned == null) From 74bcb0f89539b645a79c007296298ab1ddf8bf51 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 08:23:10 -0500 Subject: [PATCH 15/18] feat(bridge): map.live names RunicNPC's NPCs, marks bosses and adds (runicnpc stage 8) One RunicNpc_List per map.live ask, joined to the ledger by net id (D302, D303): - A ledgered NPC's row gains its name and the step key that placed it. A boss's row also gains `boss` and its `health` (0-1); the health is for the event page's progress line. - A RunicNPC NPC owned by a run that no step ledgered is a boss's add. It joins the events layer under that run with `add: true`. - Any other boss joins the world layer as `kind: "boss"` with its name. - A placement's ordinary NPCs are not sent. Protocol 13, still unreleased; the fields are additive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 113 ++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index e83d082..2e60acf 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -13076,6 +13076,73 @@ namespace Oxide.Plugins return e == null || e.IsDestroyed || e.transform == null; } + /// + /// Main thread. RunicNPC's NPCs keyed by net id as text (a ledger entry's id), from + /// `RunicNpc_List`; empty when RunicNPC is not loaded or answers anything else. + /// + private Dictionary> RunicNpcsForMap() + { + var byId = new Dictionary>(); + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + return byId; + + var listed = npc.Call("RunicNpc_List", (string)null) as List>; + + if (listed == null) + return byId; + + foreach (Dictionary d in listed) + { + object id; + + if (d != null && d.TryGetValue("netId", out id) && id != null) + byId[id.ToString()] = d; + } + + return byId; + } + + /// + /// A RunicNPC NPC's name, and for a boss `boss` and its health as a fraction. The health is + /// for the event page's progress line; the site's map does not show it (D303). + /// + private static void MapNpcFields(Dictionary row, Dictionary npc) + { + object value; + + if (npc.TryGetValue("name", out value) && value is string) + row["name"] = value; + + if (!npc.TryGetValue("boss", out value) || value == null) + return; + + row["boss"] = true; + + object health, max; + + if (npc.TryGetValue("health", out health) && npc.TryGetValue("maxHealth", out max)) + { + double h = Convert.ToDouble(health), m = Convert.ToDouble(max); + + if (m > 0) + row["health"] = Math.Round(Math.Max(0, Math.Min(1, h / m)), 3); + } + } + + /// Where one of RunicNPC's NPCs stands, from its `RunicNpc_List` row. + private static Dictionary MapNpcAt(Dictionary npc) + { + object value; + var p = npc.TryGetValue("position", out value) ? value as Dictionary : null; + + if (p == null) + return At(UnityEngine.Vector3.zero); + + return At(new UnityEngine.Vector3(Convert.ToSingle(p["x"]), 0f, Convert.ToSingle(p["z"]))); + } + private static Dictionary At(UnityEngine.Vector3 p) { return new Dictionary @@ -13140,6 +13207,10 @@ namespace Oxide.Plugins var events = new List(); + // RunicNPC's own NPCs by net id, for their names, bosses and a boss's adds (runicnpc + // stage 8, D302, D303). Empty without RunicNPC. + Dictionary> runic = RunicNpcsForMap(); + foreach (WorldEntry w in _world.Values) { Dictionary row; @@ -13162,6 +13233,17 @@ namespace Oxide.Plugins row = At(found.transform.position); row["prefab"] = w.Prefab; + + // The step that placed it, so a step's NPCs still standing can be counted. + row["key"] = w.Key; + + Dictionary npc; + + if (runic.TryGetValue(w.Id, out npc)) + { + MapNpcFields(row, npc); + runic.Remove(w.Id); + } } row["kind"] = w.Kind; @@ -13169,6 +13251,37 @@ namespace Oxide.Plugins events.Add(row); } + // What is left of RunicNPC's list was placed by no step. A boss's adds are owned by its + // run (`run:`) and join the events layer under it; any other boss is in the open + // world, as cargo or a helicopter is. A placement's ordinary NPCs are not on the map. + foreach (Dictionary npc in runic.Values) + { + object value; + string owner = npc.TryGetValue("owner", out value) ? value as string : null; + bool boss = npc.TryGetValue("boss", out value) && value != null; + Dictionary row; + + if (owner != null && owner.StartsWith("run:", StringComparison.Ordinal)) + { + row = MapNpcAt(npc); + MapNpcFields(row, npc); + row["kind"] = WorldKindNpc; + row["runId"] = owner.Substring(4); + row["add"] = true; + events.Add(row); + } + else if (boss) + { + row = MapNpcAt(npc); + row["kind"] = "boss"; + + if (npc.TryGetValue("name", out value) && value is string) + row["name"] = value; + + world.Add(row); + } + } + var players = new List(); foreach (BasePlayer p in BasePlayer.activePlayerList) From 3117b0c349fee49ef8f40864cf8556f031587311 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 16:58:30 -0500 Subject: [PATCH 16/18] feat(bridge): every NPC an event places needs RunicNPC (runicnpc stage 9, D310) world.place of one of Rust's own NPC prefabs now needs RunicNPC loaded and answering RunicNpcApiNeeded, as a profile placement does, so a module that missed the hello cannot place NPCs on a server the site calls incomplete. The refusal comes before anything spawns, with the existing reasons runicnpc-missing and runicnpc-old; a profile placement now also refuses an old RunicNPC up front (it was refused only when it carried orders). Crates are unaffected. No message changes shape; protocol stays 13. overlay.toml's runicnpc_api comment and rg.npc's line say RunicNPC is required. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 5 +-- overlay/oxide/plugins/RunicGateway.cs | 46 +++++++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/overlay.toml b/overlay.toml index 570b602..55d57c2 100644 --- a/overlay.toml +++ b/overlay.toml @@ -86,6 +86,7 @@ requires_plugins = ["Kits", "ZoneManager"] # two equal. The release copies it into manifest.json, and the installer's # bundle carries the latest RunicNPC release that answers at least it (D224). # -# RunicNPC is optional to the bridge until its stage 9: without it every npc.* -# command answers `runicnpc-missing`, and events place Rust's own scientists. +# RunicNPC is required since its stage 9 (D310): without it, or with one older +# than this, every npc.* command and every NPC an event places, Rust's own +# scientists included, is refused as `runicnpc-missing` or `runicnpc-old`. runicnpc_api = 6 diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 2e60acf..32b679c 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -9067,6 +9067,11 @@ namespace Oxide.Plugins return; } + // RunicNPC stage 9 (D310): Rust's own scientists need RunicNPC too, so a module that + // missed the hello cannot place NPCs on a server the site would call incomplete. + if (prefab.Kind == WorldKindNpc && !RunicNpcForWorld(reqId)) + return; + int count = IntField(cmd, "count"); int max = prefab.Kind == WorldKindNpc ? (_config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs) @@ -10365,8 +10370,8 @@ namespace Oxide.Plugins // RunicNPC is Runic Gateway's own NPC plugin. The bridge is its only link to the site // (runicnpc PLAN.md §4): it calls RunicNPC's API for the site's commands and turns its hooks // into frames. RunicNPC never talks to the sidecar, so "the sidecar is a dumb forwarder" - // stays true. It is optional until RunicNPC's stage 9; without it every `npc.*` command - // answers `runicnpc-missing`, and events keep Rust's own scientists (D243). + // stays true. It is required since RunicNPC's stage 9 (D310): without it every `npc.*` command + // answers `runicnpc-missing`, and so does every NPC an event places, Rust's own included. /// /// The RunicNPC API these commands call: 3 added placements from a website (D249), 4 the @@ -10443,6 +10448,33 @@ namespace Oxide.Plugins return npc; } + /// + /// Main thread. Whether an event may place NPCs here (stage 9, D310): RunicNPC loaded and + /// answering the API this bridge needs, for a profile and for Rust's own scientists alike. + /// Answers world.error with the reason when not. + /// + private bool RunicNpcForWorld(string reqId) + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + { + EmitWorldError(reqId, "runicnpc-missing", "this server needs RunicNPC to place NPCs, and it is not loaded"); + return false; + } + + int api = RunicNpcApi(); + + if (api < RunicNpcApiNeeded) + { + EmitWorldError(reqId, "runicnpc-old", + $"this server needs RunicNPC to place NPCs: RunicNPC {npc.Version} answers API {api}, and this bridge needs {RunicNpcApiNeeded}"); + return false; + } + + return true; + } + /// /// Main thread. npc.profiles: the server's profile set as RunicNPC holds it — whether /// a site manages it, every profile, and those refused and why. The site reads this before @@ -10919,14 +10951,10 @@ namespace Oxide.Plugins /// private void HandleWorldPlaceProfile(Dictionary cmd, string reqId, string key, string runId, string profile) { - Plugin npc = RunicNpcPlugin(); - - if (npc == null) - { - EmitWorldError(reqId, "runicnpc-missing", $"RunicNPC is not loaded, so there is no profile '{profile}' here"); + if (!RunicNpcForWorld(reqId)) return; - } + Plugin npc = RunicNpcPlugin(); var profiles = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject; Newtonsoft.Json.Linq.JToken known = profiles != null ? profiles["profiles"]?[profile] : null; @@ -11228,7 +11256,7 @@ namespace Oxide.Plugins Plugin npc = RunicNpcPlugin(); arg.ReplyWith(npc == null - ? "RunicNPC: not loaded. npc.* commands answer runicnpc-missing; events place Rust's own scientists." + ? "RunicNPC: not loaded. npc.* commands and every NPC an event places answer runicnpc-missing (it is required)." : $"RunicNPC {npc.Version}, API {RunicNpcApi()} (this bridge needs {RunicNpcApiNeeded}). last: {_lastNpcSummary ?? "none yet"}"); } From e5f04e0c0ce9d6b5ec2ec605cac63069bcb2a980 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 7 Oct 2026 13:41:57 -0500 Subject: [PATCH 17/18] fix(bridge): Rust's animals and heals count toward titles again (runicnpc 9d) - Animal kills: Rust's animals are now Rust.Ai.Gen2.BaseNPC2 (stag, boar, wolf, bears, big cats, crocodile, farm animals, critters, sea life), not BaseNpc, so no animal kill counted as an animal or an NPC kill. They count as both again; ScientistNPC2, the one person among them, counts as a human NPC kill. - Heals: OnHealingItemUse is (tool, fromPlayer, toTarget). The bridge took two parameters, received the healer as the target, and dropped every heal as healing yourself. It now reads the target, and one act of help (a syringe revive raises both hooks, the heal first) counts once whichever hook comes first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 84 ++++++++++++++++----------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 32b679c..4217de3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1875,7 +1875,22 @@ namespace Oxide.Plugins // two meanings of one word would be two answers to "how many NPCs did I kill". // `animalKills` counts it AGAIN, separately (§5.2): the public NPC column keeps its // meaning mid-wipe, and the site takes the human NPCs as the difference. - if (entity is BaseNpc) + // + // Rust's own animals are now `Rust.Ai.Gen2.BaseNPC2` — stag, boar, wolf, bears, big cats, + // crocodile, farm animals, critters, sea life — and not `BaseNpc`, which is all this + // tested until the RunicNPC 9d walk found no animal kill had counted at all. `BaseNpc` + // stays for whatever still derives from it. `ScientistNPC2` is the one person among + // them, so it is a human NPC kill, not an animal. + if (entity is Rust.Ai.Gen2.ScientistNPC2) + { + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.Killed(info, entity, false); + CreditKill(attacker, false); + return; + } + + if (entity is BaseNpc || entity is Rust.Ai.Gen2.BaseNPC2) { Tally tally = TallyFor(attacker); tally.NpcKills++; @@ -2065,55 +2080,56 @@ namespace Oxide.Plugins }); } - // A syringe on a downed player raises `OnPlayerRevive` and then `OnHealingItemUse` for the - // same use; one act of help is one heal. The revive is remembered for the frame it happened in. - private ulong _reviveHealer; - private ulong _reviveTarget; - private int _reviveFrame = -1; + // A syringe on a downed player raises `OnHealingItemUse` and then, from inside the same + // `GiveEffectsTo`, `OnPlayerRevive`; one act of help is one heal. The last one counted is + // remembered for the frame it happened in, and the other hook skips the same pair. + private ulong _helpHealer; + private ulong _helpTarget; + private int _helpFrame = -1; - /// - /// Void: a non-null return refuses the heal. is what is healed; - /// the tool's owner is who heals. Healing yourself is not a heal (D174). - /// - /// - /// The target is taken as object: the game's `GiveEffectsTo` now takes an - /// `IMedicalToolTarget`, which is not always a player, and a parameter typed - /// `BasePlayer` would simply never be called for the hook's current shape. Only a - /// player healed counts. - /// - /// - private void OnHealingItemUse(MedicalTool tool, object target) + /// Counts one act of help unless it is yourself, an NPC, or the other hook already did this frame. + private void CountHelp(BasePlayer healer, BasePlayer player) { - MarkHook("OnHealingItemUse"); - - BasePlayer player = target as BasePlayer; - BasePlayer healer = tool != null ? tool.GetOwnerPlayer() : null; - if (healer == null || healer.IsNpc || player == null || player.IsNpc || (ulong)healer.userID == (ulong)player.userID) return; - if (_reviveFrame == UnityEngine.Time.frameCount - && _reviveHealer == (ulong)healer.userID && _reviveTarget == (ulong)player.userID) + int frame = UnityEngine.Time.frameCount; + + if (_helpFrame == frame && _helpHealer == (ulong)healer.userID && _helpTarget == (ulong)player.userID) return; + _helpHealer = healer.userID; + _helpTarget = player.userID; + _helpFrame = frame; + TallyFor(healer).Healed++; } + /// + /// Void: a non-null return refuses the heal. Healing yourself is not a heal (D174). + /// + /// + /// Rust's `MedicalTool.GiveEffectsTo(fromPlayer, toTarget)` raises it as + /// (tool, fromPlayer, toTarget). Until the RunicNPC 9d walk this took two + /// parameters, so the second — the healer — arrived as the target, every heal looked like + /// healing yourself, and none counted. The target is object: `IMedicalToolTarget` + /// is not always a player, and only a player healed counts. + /// + /// + private void OnHealingItemUse(MedicalTool tool, BasePlayer fromPlayer, object target) + { + MarkHook("OnHealingItemUse"); + + CountHelp(fromPlayer ?? (tool != null ? tool.GetOwnerPlayer() : null), target as BasePlayer); + } + /// Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted. private void OnPlayerRevive(BasePlayer reviver, BasePlayer player) { MarkHook("OnPlayerRevive"); - if (reviver == null || reviver.IsNpc || player == null || player.IsNpc - || (ulong)reviver.userID == (ulong)player.userID) - return; - - _reviveHealer = reviver.userID; - _reviveTarget = player.userID; - _reviveFrame = UnityEngine.Time.frameCount; - - TallyFor(reviver).Healed++; + CountHelp(reviver, player); } /// No return behaviour. One rocket leaving a launcher. From a785ad91efed55d549d986971a3e9c4edd8c4c86 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 7 Oct 2026 13:41:57 -0500 Subject: [PATCH 18/18] fix(zones): an event's zone enforces its flags (RunicGatewayZones 0.2.0, runicnpc 9d) ZoneManager subscribes the hooks that enforce its flags only for the zones it had when it loaded; a zone made later through its API, which is every zone an event opens, never re-subscribes them, so an event's PvpGod or NoBuild zone enforced nothing. The helper's postfix, which already sees every zone created, updated or loaded, now has ZoneManager run its own UpdateHookSubscriptions on the next tick, before entering the players already inside. A ZoneManager without that method is warned about and reported by rgz.status; the first job still works. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGatewayZones.cs | 52 +++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/overlay/oxide/plugins/RunicGatewayZones.cs b/overlay/oxide/plugins/RunicGatewayZones.cs index 33c2ecb..ed28130 100644 --- a/overlay/oxide/plugins/RunicGatewayZones.cs +++ b/overlay/oxide/plugins/RunicGatewayZones.cs @@ -1,7 +1,7 @@ // Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge. // Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. // -// ONE JOB (docs/modules/rust/PLAN_FIXES.md F12, D168, D181, D182): +// ITS FIRST JOB (docs/modules/rust/PLAN_FIXES.md F12, D168, D181, D182): // // ZoneManager counts a player as inside a zone only when the zone's trigger fires on ENTRY. A // zone created, updated or loaded around somebody who is already standing there never fires it, @@ -27,6 +27,17 @@ // * **It fixes every zone on the server**, not only the bridge's. That is deliberate: the fault is // ZoneManager's, and it is the same fault for an arena another plugin made. // +// A SECOND JOB (RunicNPC stage 9d walk, the org lead's call, 2026-10-07): +// +// ZoneManager subscribes the hooks that enforce its flags (`OnEntityTakeDamage` for PvpGod and the +// other damage flags, `OnEntityBuilt` for NoBuild, and so on) only for the flags some zone had when +// ZoneManager loaded, or when a flag is toggled by command. A zone made later through its API — +// which is every zone an event opens — never re-subscribes them, so on a server with no such zone +// already, an event's PvpGod or NoBuild zone enforces nothing: the 9d walk shot a player dead and +// built a foundation inside one. The same postfix that sees every zone made therefore also has +// ZoneManager run its own `UpdateHookSubscriptions` on the next tick. ZoneManager decides what to +// subscribe; this only asks it to look. +// // It is optional. The bridge never depends on it to load: without it — deleted, or loaded but // unable to find what it patches after a ZoneManager update — the bridge scores the zones it made // by `IsPositionInZone` instead (D182), and reports this helper's state at hello so the site can @@ -41,8 +52,8 @@ using UnityEngine; namespace Oxide.Plugins { - [Info("Runic Gateway Zones", "Runic Gateway", "0.1.0")] - [Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded.")] + [Info("Runic Gateway Zones", "Runic Gateway", "0.2.0")] + [Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded, and enforces a new zone's flags.")] public class RunicGatewayZones : RustPlugin { private const string HarmonyId = "runicgateway.zones"; @@ -61,6 +72,9 @@ namespace Oxide.Plugins private FieldInfo _definition; private PropertyInfo _id; + /// ZoneManager's private UpdateHookSubscriptions, or null when this ZoneManager has none (the second job is then off). + private MethodInfo _updateHooks; + /// /// What the bridge reads through : patched, unsupported /// (ZoneManager is here but not the shape this helper patches), or no-zonemanager. @@ -134,6 +148,7 @@ namespace Oxide.Plugins MethodInfo initialize = zoneType?.GetMethod("InitializeZone", any); _getZone = zmType.GetMethod("GetZoneByID", any, null, new[] { typeof(string) }, null); + _updateHooks = zmType.GetMethod("UpdateHookSubscriptions", any, null, Type.EmptyTypes, null); _definition = zoneType?.GetField("definition", any); _id = _definition?.FieldType.GetProperty("Id", any); _enter = zoneType == null @@ -169,10 +184,14 @@ namespace Oxide.Plugins _state = "patched"; _reason = null; - Puts($"patched ZoneManager {_zoneManager.Version}: a zone takes in the players already inside it"); + Puts($"patched ZoneManager {_zoneManager.Version}: a zone takes in the players already inside it" + + (_updateHooks != null ? ", and a new zone's flags are enforced" : "")); + if (_updateHooks == null) + PrintWarning($"this ZoneManager ({_zoneManager.Version}) has no UpdateHookSubscriptions: a zone made after it loaded may not enforce its flags until ZoneManager reloads"); // The zones ZoneManager loaded before this helper did were initialised unwatched — // which, at a server start, is all of them. Sweep them once now. + RefreshHooks(); SweepAll(); } @@ -258,10 +277,32 @@ namespace Oxide.Plugins var batch = new List>(_pending); _pending.Clear(); + // The second job first: the hooks that enforce the new zones' flags, then the players. + RefreshHooks(); + foreach (KeyValuePair entry in batch) Sweep(entry.Key, entry.Value); } + /// + /// Main thread. Has ZoneManager subscribe the hooks its zones' flags now need. ZoneManager + /// only adds subscriptions here, and subscribing a hook twice is a no-op. + /// + private void RefreshHooks() + { + if (_updateHooks == null || _zoneManager == null) + return; + + try + { + _updateHooks.Invoke(_zoneManager, null); + } + catch (Exception ex) + { + PrintWarning($"could not have ZoneManager refresh its hooks: {(ex.InnerException ?? ex).Message}"); + } + } + private void SweepAll() { var ids = _zoneManager.Call("GetZoneIDs") as string[]; @@ -328,7 +369,8 @@ namespace Oxide.Plugins if (arg.Connection != null && !arg.IsAdmin) return; - arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}"); + arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}" + + $"; new zones' flags {(_updateHooks != null ? "enforced" : "NOT refreshed (no UpdateHookSubscriptions)")}"); } } }