fix(bridge): the last fixes' bridge half (W3, W9, W12) #43

Merged
whitlocktech merged 1 commits from fix/last-fixes-bridge into edge 2026-10-21 19:35:51 -05:00

View File

@@ -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);
}
/// <summary>The most lines of one of RaidableBases' entries a rollback carries: its file, the exception, its message.</summary>
private const int RaidProfileErrorLinesMax = 6;
/// <summary>
/// A log line that begins an entry: Oxide's <c>06:50 [Info] </c> or Carbon's
/// <c>[2026.10.21 18:45:45] [INFO] </c>. Anything else continues the entry above it.
/// </summary>
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);
/// <summary>
/// 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
/// (<c>Could not convert string to integer: not a number. Path 'Difficulty Level', line 3</c>),
/// and that much is carried, without the stack. With <paramref name="failed"/>, 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.
/// </summary>
private string RaidProfileErrors(ConfigWriteJob job, List<string> failed)
{
string text = LogSinceMark(job);
if (string.IsNullOrEmpty(text))
return null;
Plugin rb = RaidableBasesPlugin();
var tags = new List<string> { "[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;
}
/// <summary>
/// Main thread. The log written since <see cref="MarkLogPosition"/>, at most
/// <c>ConfigLogPollMaxBytes</c>, 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.
/// </summary>
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);
}
}
/// <summary>
/// Main thread. If a write touched this plugin's own config, take the one setting that
/// is safe to change live — <c>EventsEnabled</c> — from what was written. Returns
@@ -10525,6 +10671,13 @@ namespace Oxide.Plugins
/// <summary>The run's id for it (a world registry id), or null for a base no run made.</summary>
public string Id;
/// <summary>
/// The run and step key behind <see cref="Id"/>, kept here too: a teardown takes the
/// base off the record before its despawn raises <c>Ended</c> (W12).
/// </summary>
public string RunId;
public string Key;
}
private readonly List<LiveRaid> _raids = new List<LiveRaid>();
@@ -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);
}
/// <summary>Main thread. Whether a Travelling Vendor is on the map now.</summary>
private static bool TravellingVendorOut()
{
foreach (TravellingVendor v in UnityEngine.Object.FindObjectsOfType<TravellingVendor>())
if (v != null && !v.IsDestroyed)
return true;
return false;
}
/// <summary>The game's own schedule for an event, enabled or not: an operator who switched one off may still call it.</summary>
private static EventSchedule ScheduleFor(string key)
{