From 38f854d5807340c330e79302fef193f215dc73ae Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 21 Oct 2026 13:59:10 -0500 Subject: [PATCH] fix(bridge): the last fixes' bridge half (W3, W9, W12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of docs/modules/rust/PLAN_LAST_FIXES.md. - W3: a Travelling Vendor call while one is on the map answers world.error reason `already-out` instead of "called 1" with nothing spawned. Not counted against the run's budget and not kept under its key, so a retry looks again. Module-Rust#55 reads it as called 0. - W9: a rolled-back RaidableBases profile write carries RaidableBases' own log entry for the file (its path and the exception's message, without the stack), read from the log since the write began, instead of the last 8 KB of the server log. Its other entries since the write stand in when none names the profile, and nothing when it said nothing. - W12: raid.ended carries the base's id, and its runId and key, when a run made it, as PROTOCOL §20.12 says. A teardown takes the base off the record before its despawn raises Ended, so the live base keeps its run and key too. No protocol change: W3's reason is a new value of an existing field, and W12 sends the fields PROTOCOL already lists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 210 +++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 8 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 0be829e..d69bd1a 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -6792,6 +6792,10 @@ namespace Oxide.Plugins ReloadsBefore = helper.Call("ReloadsFinished") is int done ? done : 0 }; + // RaidableBases' own words on a profile it cannot read are logged during the reload, + // so the log is read from here when it is rolled back (W9). + MarkLogPosition(job); + if (!(helper.Call("ReloadProfiles") is bool asked) || !asked) { RestoreConfig(staged); @@ -6847,7 +6851,7 @@ namespace Oxide.Plugins if (failed.Count == 0) SettleConfigJob(job, true, false, null, null, null); else - RollBackRaidProfiles(job, $"RaidableBases did not load profile {string.Join(", ", failed.ToArray())}", LogTail()); + RollBackRaidProfiles(job, $"RaidableBases did not load profile {string.Join(", ", failed.ToArray())}", RaidProfileErrors(job, failed)); } else { @@ -6863,7 +6867,7 @@ namespace Oxide.Plugins return; if (job.Phase == ConfigPhase.Edit) - RollBackRaidProfiles(job, $"RaidableBases' profile reload did not finish within {ConfigReloadCeilingSeconds:0}s", LogTail()); + RollBackRaidProfiles(job, $"RaidableBases' profile reload did not finish within {ConfigReloadCeilingSeconds:0}s", RaidProfileErrors(job, null)); else SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; the restored profiles did not finish reloading either", job.RollbackLog); } @@ -6884,6 +6888,148 @@ namespace Oxide.Plugins SettleConfigJob(job, false, true, false, $"{reason}; the files are back, but RaidableBases would not reload them", log); } + /// The most lines of one of RaidableBases' entries a rollback carries: its file, the exception, its message. + private const int RaidProfileErrorLinesMax = 6; + + /// + /// A log line that begins an entry: Oxide's 06:50 [Info] or Carbon's + /// [2026.10.21 18:45:45] [INFO] . Anything else continues the entry above it. + /// + private static readonly System.Text.RegularExpressions.Regex LogEntryStart = + new System.Text.RegularExpressions.Regex(@"^(\d{2}:\d{2} |\[\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2}\] )\[", System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// + /// Main thread. What RaidableBases itself logged since the profile write began (W9), for + /// a rollback: an unreadable profile is one entry, the file's path and then the exception + /// (Could not convert string to integer: not a number. Path 'Difficulty Level', line 3), + /// and that much is carried, without the stack. With , only the + /// entries naming one of those profiles; with none of those, or none asked for, every + /// entry of its own since the write. Null when it said nothing: the reason line stands + /// alone rather than borrowing the server log's tail, which is other plugins' lines. + /// + private string RaidProfileErrors(ConfigWriteJob job, List failed) + { + string text = LogSinceMark(job); + + if (string.IsNullOrEmpty(text)) + return null; + + Plugin rb = RaidableBasesPlugin(); + var tags = new List { "[RaidableBases]" }; + + if (rb != null && !string.IsNullOrEmpty(rb.Title)) + tags.Add("[" + rb.Title + "]"); + + var named = new StringBuilder(); + var all = new StringBuilder(); + StringBuilder entry = null; + bool namesFailed = false; + int kept = 0; + + Action close = () => + { + if (entry == null) + return; + + all.Append(entry); + + if (namesFailed) + named.Append(entry); + + entry = null; + }; + + foreach (string raw in text.Split('\n')) + { + string line = raw.TrimEnd('\r'); + + if (LogEntryStart.IsMatch(line)) + { + close(); + + if (!tags.Exists(t => line.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0)) + continue; + + entry = new StringBuilder(line).Append('\n'); + kept = 1; + namesFailed = failed != null && failed.Exists(n => line.IndexOf(n + ".json", StringComparison.OrdinalIgnoreCase) >= 0); + continue; + } + + // The stack is the plugin's, not the operator's: the message above it is the error. + if (entry == null || kept >= RaidProfileErrorLinesMax || line.TrimStart().StartsWith("at ", StringComparison.Ordinal)) + { + if (entry != null) + kept = RaidProfileErrorLinesMax; + + continue; + } + + if (line.Trim().Length == 0) + continue; + + entry.Append(line).Append('\n'); + kept++; + } + + close(); + + string found = (named.Length > 0 ? named : all).ToString().TrimEnd('\n'); + + if (found.Length == 0) + return null; + + return found.Length > ConfigLogTailBytes ? found.Substring(0, ConfigLogTailBytes) : found; + } + + /// + /// Main thread. The log written since , at most + /// ConfigLogPollMaxBytes, without moving the job's own reading place. A log that + /// rotated since is read on from its mark and then from the new file's start. + /// + private string LogSinceMark(ConfigWriteJob job) + { + var text = new StringBuilder(); + + try + { + string newest = NewestLogFile(); + + if (!string.IsNullOrEmpty(job.LogPath) && File.Exists(job.LogPath)) + text.Append(ReadLogFrom(job.LogPath, job.LogOffset, ConfigLogPollMaxBytes)); + + if (newest != null && !string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase) && text.Length < ConfigLogPollMaxBytes) + text.Append(ReadLogFrom(newest, 0, ConfigLogPollMaxBytes - text.Length)); + } + catch (Exception) + { + // A log that cannot be read leaves the rollback its reason line. + } + + return text.ToString(); + } + + private static string ReadLogFrom(string path, long offset, int max) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + // A log shorter than the mark was truncated: read it from the top. + if (stream.Length < offset) + offset = 0; + + int take = (int)Math.Min(stream.Length - offset, max); + + if (take <= 0) + return string.Empty; + + stream.Seek(offset, SeekOrigin.Begin); + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + /// /// Main thread. If a write touched this plugin's own config, take the one setting that /// is safe to change live — EventsEnabled — from what was written. Returns @@ -10525,6 +10671,13 @@ namespace Oxide.Plugins /// The run's id for it (a world registry id), or null for a base no run made. public string Id; + + /// + /// The run and step key behind , kept here too: a teardown takes the + /// base off the record before its despawn raises Ended (W12). + /// + public string RunId; + public string Key; } private readonly List _raids = new List(); @@ -10782,6 +10935,8 @@ namespace Oxide.Plugins entry.Started = true; entry.Name = baseName; entry.Difficulty = live.Difficulty; + live.RunId = entry.RunId; + live.Key = entry.Key; SaveWorld(); } @@ -10873,11 +11028,11 @@ namespace Oxide.Plugins _raids.Remove(live); string ours = RaidTagAt(location) ?? live?.Id; - WorldEntry entry; + WorldEntry entry = null; // A run's base the plugin ended first, on its own timer or because it was raided: the // run's resource is marked expired (D170). One the run's teardown despawned is already - // off the record. + // off the record, and its id, run and key come from the live base instead. if (ours != null && _world.TryGetValue(ours, out entry)) { _world.Remove(ours); @@ -10888,8 +11043,9 @@ namespace Oxide.Plugins if (!WorldEventsLive()) return; - LiveRaid told = live ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName, Id = ours }; - EmitFrame(RaidFrame(RaidEndedKind, told, null)); + LiveRaid told = live ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName }; + told.Id = ours; + EmitFrame(RaidFrame(RaidEndedKind, told, entry)); } private void OnRaidableBasePurchased(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, string baseName, @@ -10948,6 +11104,17 @@ namespace Oxide.Plugins frame["runId"] = entry.RunId; frame["key"] = entry.Key; } + else if (!string.IsNullOrEmpty(live.Id)) + { + // A run's base no longer on the record: a teardown's despawn (W12). + frame["id"] = live.Id; + + if (!string.IsNullOrEmpty(live.RunId)) + frame["runId"] = live.RunId; + + if (!string.IsNullOrEmpty(live.Key)) + frame["key"] = live.Key; + } return frame; } @@ -11027,7 +11194,7 @@ namespace Oxide.Plugins continue; int level = TupleItem(row, 3) is int l ? l : 0; - _raids.Add(new LiveRaid + var live = new LiveRaid { At = at, Level = level, @@ -11036,7 +11203,16 @@ namespace Oxide.Plugins Base = TupleItem(row, 14) as string, DespawnAtMs = TupleItem(row, 16) is DateTime d ? RaidMs(d) : 0L, Id = RaidTagAt(at) - }); + }; + WorldEntry entry; + + if (live.Id != null && _world.TryGetValue(live.Id, out entry)) + { + live.RunId = entry.RunId; + live.Key = entry.Key; + } + + _raids.Add(live); } } catch (Exception ex) @@ -19475,6 +19651,14 @@ namespace Oxide.Plugins } } + // The game runs one Travelling Vendor at a time, and its event quietly starts nothing + // while one is out (W3). Said, not counted, and not kept: a retry looks again. + if (ev == "travelling_vendor" && TravellingVendorOut()) + { + EmitWorldError(reqId, "already-out", "a Travelling Vendor is already out, and the game runs one at a time; nothing was called"); + return; + } + int used = 0; foreach (WorldCall c in _worldCalls.Values) @@ -19527,6 +19711,16 @@ namespace Oxide.Plugins EmitFrame(frame); } + /// Main thread. Whether a Travelling Vendor is on the map now. + private static bool TravellingVendorOut() + { + foreach (TravellingVendor v in UnityEngine.Object.FindObjectsOfType()) + if (v != null && !v.IsDestroyed) + return true; + + return false; + } + /// The game's own schedule for an event, enabled or not: an operator who switched one off may still call it. private static EventSchedule ScheduleFor(string key) { -- 2.49.1