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