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) {