// Runic Gateway Raids — a helper for RaidableBases, shipped beside the Runic Gateway bridge.
// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later.
//
// WHAT IT IS FOR (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D168, D204–D208, D387–D389):
//
// nivex's RaidableBases pastes a CopyPaste building, stocks it and guards it, and its public API
// spawns nothing and despawns nothing (§11.4.1). An event's step wants more than the plugin offers
// another plugin: the base the run made and no other, despawned at the run's end; settings for that
// one spawn; the lock to the first attacker for that one base; and a spot an admin chose. So this
// helper reaches RaidableBases' internals, by reflection and three Harmony patches, which is the
// org lead's decision (D204–D207) and the reason it lives here and not in the bridge (D168):
//
// * **A run's base is tagged at birth.** A prefix on the spawn queue's `Add(RandomBase)` sees the
// request this helper's own call made (the plugin picks the spot), and a postfix on
// `OpenEvent(RandomBase)` sees the base that request became. So the base is the run's by
// identity, never by matching a name or a position against the operator's own bases (§11.4.4).
// * **Settings for one spawn go on a copy of its profile** (see `CopyProfile`): PvP, NPCs and
// their count, despawn minutes, and at a spot its height. The profile every other base shares
// is never touched — the plugin's own `Clone()` is shallow and leaked a height into it on the
// rig (§11.4.4). **PvP is set on the base itself after `OpenEvent`** too, because the plugin's
// config switches ("Convert PVE To PVP") are applied inside it and win over a profile (§11.4.2).
// * **The lock is per event (D206).** A prefix on each base's `BypassUseOwners()` answers from the
// run's own setting for a tagged base; every other base follows the plugin's config.
// * **A spot is checked, then pasted (D207, D208, D388, D389).** This helper's own look for players,
// buildings, deployables and raid bases, then the plugin's own area check with its reason, then
// the ocean level against the ground. Rocks and cliffs are warnings; the rest refuse. The base is
// built the way the plugin's "spawn where I look" builds one, from numbers instead of a view.
// * **One base is despawned through its own `Despawn()`**, which raises the plugin's usual hooks.
// * **Profiles reload under watch.** The site edits RaidableBases' profile files (D205), and the
// bridge reloads them through here and asks afterwards whether each loaded.
//
// Names are the operator's: every difficulty, profile and base name is read from the installed
// plugin, never written here (D386). The plugin's own enum is bound by name, since the paid edition
// inserted `Purchased` and moved the ints (§11.4.2). Both editions are served, free and paid, by
// what each has (D348): the paid edition's own `SpawnRandomBase(string, Vector3, int, int, bool)`
// for the plugin's pick, the free edition's private typed one otherwise.
//
// It is optional. Without it the bridge still reports bases on the feed and the map, and a step
// still spawns one where the plugin can be asked to (the paid call, or the free edition's console
// command), but nothing despawns it early and there are no settings, no lock and no spot; the site
// says so. A RaidableBases reload drops the patches with the old assembly, and this re-patches.
// `rgr.status` in the server console prints the state.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Runic Gateway Raids", "Runic Gateway", "0.1.0")]
[Description("Helper for Runic Gateway: RaidableBases spawns, overrides, locks and despawns one event's base, and reloads its profiles under watch.")]
public class RunicGatewayRaids : RustPlugin
{
private const string HarmonyId = "runicgateway.raids";
private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
/// The radius a spot is checked at when the profile gives none: the plugin's own `rbe` uses 50 m.
private const float DefaultSpotRadius = 50f;
/// A spot this far under the ocean surface is "in water" (§11.4.2: the water map reads −500 on the rigs).
private const float WaterMargin = 0.5f;
/// How long an ended base's tag is still answered by : its last hooks follow its despawn.
private const float EndedTagSeconds = 120f;
/// The instance the static patches report to. Null while unloaded.
private static RunicGatewayRaids _instance;
private Harmony _harmony;
private readonly List _patched = new List();
/// patched , unsupported (RaidableBases is here but not the shape this reaches), or no-raidablebases .
private string _state = "no-raidablebases";
private string _reason;
// ── What one RaidableBases load is made of. A reload is a new assembly, so all of it is
// looked up again then. ─────────────────────────────────────────────────────────────
private Plugin _rb;
private Type _rbType;
private Type _raidableBase;
private Type _randomBase;
private Type _spawnsType;
private Type _raidableType;
private object _manual;
private int _manualMask;
private MethodInfo _openEvent;
private MethodInfo _queueAdd;
private MethodInfo _bypass;
private MethodInfo _despawn;
private MethodInfo _gridLoading;
private MethodInfo _pasteBuilding;
private MethodInfo _getBuilding;
private MethodInfo _getPasteData;
private MethodInfo _parseListed;
private MethodInfo _fileExists;
private MethodInfo _reloadProfiles;
private MethodInfo _getDistance;
private MethodInfo _areaSafe;
/// Paid: SpawnRandomBase(string, Vector3, int, int, bool) . Null on the free edition.
private MethodInfo _spawnApi;
/// Free: SpawnRandomBase(RaidableType, string, bool, BasePlayer, IPlayer, bool) . Null on the paid edition.
private MethodInfo _spawnTyped;
private ConstructorInfo _spawnsCtor;
// ── The run's bases ───────────────────────────────────────────────────
/// One base a run asked for: its settings, and what it became.
private sealed class Tag
{
public string Id;
public bool? Pvp;
public bool? Npcs;
public int? NpcCount;
public int? DespawnMinutes;
public bool? Lock;
public string BaseName;
public string Profile;
/// The plugin's request object, until OpenEvent turns it into a base.
public object Request;
/// The base, once open.
public object Raid;
public Vector3 Location;
public bool Cancelled;
public float EndedAt = -1f;
}
/// Requests this helper made, by the plugin's request object. Main thread.
private static readonly Dictionary Requested = new Dictionary();
/// Open bases a run owns, by the base. Read by the lock patch.
private static readonly Dictionary Tagged = new Dictionary();
private readonly Dictionary _byId = new Dictionary(StringComparer.Ordinal);
/// Set for the length of this helper's own call into the plugin's pick, for the queue patch.
private static Tag _capturing;
/// Profile reloads this helper finished, for the bridge's watch.
private int _reloadsFinished;
private bool _reloading;
// ── Lifecycle ─────────────────────────────────────────────────────────
private void OnServerInitialized()
{
_instance = this;
Patch();
}
private void Unload()
{
Unpatch();
ForgetAll();
_instance = null;
}
private void OnPluginLoaded(Plugin plugin)
{
if (plugin != null && plugin.Name == "RaidableBases")
{
Unpatch();
ForgetAll();
Patch();
}
}
private void OnPluginUnloaded(Plugin plugin)
{
if (plugin != null && plugin.Name == "RaidableBases")
{
// Its unload despawned every base; nothing tagged is left in the world.
Unpatch();
ForgetAll();
_rb = null;
_state = "no-raidablebases";
_reason = null;
}
}
private void ForgetAll()
{
Requested.Clear();
Tagged.Clear();
_byId.Clear();
_capturing = null;
_reloading = false;
}
// ── The API the bridge calls ─────────────────────────────────────────
/// patched , unsupported or no-raidablebases .
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();
/// Whether the plugin's spawn grid is built. Until it is, the plugin refuses every spawn and every profile reload.
private bool GridReady()
{
if (_state != "patched")
return false;
try
{
return !(bool)_gridLoading.Invoke(_rb, null);
}
catch (Exception)
{
return false;
}
}
///
/// Every profile the plugin loaded, as hello carries them: name , difficulty ,
/// level , enabled , pvp , bases (only those whose CopyPaste file
/// exists), and seabed and float (whether it is built for water, D389).
///
private List Profiles()
{
var rows = new List();
if (_state != "patched")
return rows;
foreach (KeyValuePair kv in ProfileTable())
{
object options = F(kv.Value, "Options");
if (options == null)
continue;
var row = new Dictionary
{
{ "name", kv.Key },
{ "difficulty", ModeOf(options) },
{ "level", LevelOf(options) },
{ "enabled", F(options, "Enabled") is bool on && on },
{ "pvp", F(options, "AllowPVP") is bool pvp && pvp },
{ "bases", BasesOf(kv.Key, options) }
};
object water = F(options, "Water");
row["seabed"] = F(water, "Seabed") is float seabed && seabed > 0f;
row["float"] = F(water, "AllowSubmerged") is bool floats && floats;
rows.Add(row);
}
return rows;
}
///
/// Main thread. Asks the plugin for one base for a run. carries
/// id (the bridge's), difficulty (a name) and level , profile ,
/// base , and for a spot x , z with heightAdjust or height ;
/// and the settings pvp , npcs , npcCount , despawnMinutes ,
/// lock . Answers ok with what was asked for, or reason and message .
///
private Dictionary Spawn(Dictionary req)
{
if (_state != "patched")
return Refuse("helper-unsupported", $"the RaidableBases helper is {_state}{(_reason != null ? $" ({_reason})" : "")}");
string id = Text(req, "id");
if (string.IsNullOrEmpty(id) || _byId.ContainsKey(id))
return Refuse("malformed", "a spawn needs an id this helper has not seen");
if (!GridReady())
return Refuse("rb-not-ready", "RaidableBases is still building its spawn grid, and refuses every spawn until it has");
if (Flag(_rb, "IsSpawnerBusy") || Flag(_rb, "IsLoaderBusy"))
return Refuse("rb-busy", "RaidableBases is pasting another base; it takes one at a time");
var tag = new Tag
{
Id = id,
Pvp = BoolOf(req, "pvp"),
Npcs = BoolOf(req, "npcs"),
NpcCount = IntOf(req, "npcCount"),
DespawnMinutes = IntOf(req, "despawnMinutes"),
Lock = BoolOf(req, "lock")
};
string refusal;
string reason;
Choice choice = Choose(req, Number(req, "x").HasValue, out reason, out refusal);
if (choice == null)
return Refuse(reason, refusal);
tag.BaseName = choice.BaseName;
tag.Profile = choice.ProfileName;
Dictionary answer = Number(req, "x").HasValue
? SpawnAt(req, tag, choice)
: SpawnPicked(tag, choice);
if (answer.TryGetValue("ok", out object ok) && ok is bool done && done)
{
_byId[id] = tag;
answer["difficulty"] = choice.Difficulty;
answer["level"] = choice.Level;
if (choice.ProfileName != null)
answer["profile"] = choice.ProfileName;
if (choice.BaseName != null)
answer["base"] = choice.BaseName;
}
return answer;
}
///
/// Main thread. The checks a spot must pass (D208, D388, D389), without spawning:
/// x , z , and the difficulty , profile or base whose
/// radius and water settings apply. Answers ok , the refusal's reason and
/// message , and warnings .
///
private Dictionary Check(Dictionary req)
{
if (_state != "patched")
return Refuse("helper-unsupported", $"the RaidableBases helper is {_state}");
string reason;
string refusal;
Choice choice = Choose(req, true, out reason, out refusal);
if (choice == null)
return Refuse(reason, refusal);
var warnings = new List();
Vector3 at;
if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal))
{
Dictionary no = Refuse(reason, refusal);
no["warnings"] = warnings;
return no;
}
return new Dictionary { { "ok", true }, { "warnings", warnings }, { "base", choice.BaseName } };
}
///
/// Main thread. Takes one base away: removed (it was open and its own Despawn()
/// ran), cancelled (it was still on its way and will be taken away the moment it
/// opens), or gone .
///
private string Despawn(string id)
{
Tag tag;
if (string.IsNullOrEmpty(id) || !_byId.TryGetValue(id, out tag))
return "gone";
if (tag.Raid != null && IsOpen(tag.Raid))
{
try
{
_despawn.Invoke(tag.Raid, null);
return "removed";
}
catch (Exception ex)
{
PrintWarning($"could not despawn base {id}: {(ex.InnerException ?? ex).Message}");
return "gone";
}
}
if (tag.Raid == null && !tag.Cancelled && tag.EndedAt < 0f)
{
tag.Cancelled = true;
Dequeue(tag.Request);
return "cancelled";
}
return "gone";
}
/// open , queued (asked for and not open yet) or gone .
private string LiveState(string id)
{
Tag tag;
if (string.IsNullOrEmpty(id) || !_byId.TryGetValue(id, out tag) || tag.Cancelled)
return "gone";
if (tag.Raid != null)
return IsOpen(tag.Raid) ? "open" : "gone";
return tag.EndedAt < 0f ? "queued" : "gone";
}
/// The id of the run's base at this spot, or null: how the bridge joins a lifecycle hook to a run.
private string TagAt(Vector3 at)
{
foreach (Tag tag in _byId.Values)
{
if (tag.Raid == null)
continue;
if (tag.EndedAt >= 0f && Time.realtimeSinceStartup - tag.EndedAt > EndedTagSeconds)
continue;
Vector3 where = tag.Raid is UnityEngine.Object unity && unity == null ? tag.Location : (F(tag.Raid, "Location") is Vector3 now ? now : tag.Location);
if (Distance2D(where, at) <= 2f)
return tag.Id;
}
return null;
}
///
/// Main thread. Has the plugin reload its profiles, as rb.reloadprofiles does.
/// False when it would refuse: its grid is still building, or a reload is running.
///
private bool ReloadProfiles()
{
if (_state != "patched" || _reloading || !GridReady())
return false;
IEnumerator inner;
try
{
// No player. Typed in the console, `rb.reloadprofiles` also reloads the per-base loot
// tables and logs a line; with no player the plugin skips both, and a profile edit
// touches neither (they are their own files, `Base_Loot/`).
inner = _reloadProfiles.Invoke(_rb, new object[] { null }) as IEnumerator;
}
catch (Exception ex)
{
PrintWarning($"could not start RaidableBases' profile reload: {(ex.InnerException ?? ex).Message}");
return false;
}
if (inner == null)
return false;
_reloading = true;
ServerMgr.Instance.StartCoroutine(Watch(inner));
return true;
}
/// How many profile reloads have finished since this helper loaded.
private int ReloadsFinished() => _reloadsFinished;
/// loaded , error (the plugin could not read it) or absent .
private string ProfileState(string name)
{
if (_state != "patched" || string.IsNullOrEmpty(name))
return "absent";
foreach (KeyValuePair kv in ProfileTable())
{
if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase))
return "loaded";
}
if (F(_rb, "profileErrors") is IEnumerable errors)
{
foreach (object file in errors)
{
string text = file as string;
if (text != null && System.IO.Path.GetFileNameWithoutExtension(text).Equals(name, StringComparison.OrdinalIgnoreCase))
return "error";
}
}
return "absent";
}
private IEnumerator Watch(IEnumerator inner)
{
try
{
yield return inner;
}
finally
{
_reloading = false;
_reloadsFinished++;
}
}
// ── Choosing a base ──────────────────────────────────────────────────
private sealed class Choice
{
public string BaseName;
public string ProfileName;
public object Profile;
public string Difficulty;
public int Level;
}
///
/// Main thread. Which base, profile and difficulty a request means, from the plugin's own
/// table. A base names its profile; a profile picks one of its bases at random; a
/// difficulty alone picks at random among its enabled profiles when a concrete base is
/// needed (a spot), and is left to the plugin otherwise. Null with a reason when the
/// names do not fit together or do not exist.
///
private Choice Choose(Dictionary req, bool needBase, out string reason, out string refusal)
{
reason = null;
refusal = null;
string wantBase = Text(req, "base");
string wantProfile = Text(req, "profile");
string wantDifficulty = Text(req, "difficulty");
int? wantLevel = IntOf(req, "level");
List> table = ProfileTable();
if (!string.IsNullOrEmpty(wantBase))
{
foreach (KeyValuePair kv in table)
{
object options = F(kv.Value, "Options");
foreach (string b in BasesOf(kv.Key, options))
{
if (!b.Equals(wantBase, StringComparison.OrdinalIgnoreCase))
continue;
if (!string.IsNullOrEmpty(wantProfile) && !kv.Key.Equals(wantProfile, StringComparison.OrdinalIgnoreCase))
{
reason = "bad-base";
refusal = $"base '{wantBase}' belongs to profile '{kv.Key}', not '{wantProfile}'";
return null;
}
Choice found = ChoiceOf(kv.Key, kv.Value, options, b);
if (!FitsDifficulty(found, wantDifficulty, wantLevel, out refusal))
{
reason = "bad-difficulty";
return null;
}
return found;
}
}
reason = "bad-base";
refusal = $"RaidableBases has no base '{wantBase}' with a CopyPaste file";
return null;
}
if (!string.IsNullOrEmpty(wantProfile))
{
foreach (KeyValuePair kv in table)
{
if (!kv.Key.Equals(wantProfile, StringComparison.OrdinalIgnoreCase))
continue;
object options = F(kv.Value, "Options");
List bases = BasesOf(kv.Key, options);
if (bases.Count == 0)
{
reason = "bad-profile";
refusal = $"profile '{kv.Key}' has no base with a CopyPaste file";
return null;
}
Choice found = ChoiceOf(kv.Key, kv.Value, options, bases[UnityEngine.Random.Range(0, bases.Count)]);
if (!FitsDifficulty(found, wantDifficulty, wantLevel, out refusal))
{
reason = "bad-difficulty";
return null;
}
return found;
}
reason = "bad-profile";
refusal = $"RaidableBases has no profile '{wantProfile}'";
return null;
}
// A difficulty, or nothing at all.
var candidates = new List();
foreach (KeyValuePair kv in table)
{
object options = F(kv.Value, "Options");
if (!(F(options, "Enabled") is bool on) || !on)
continue;
Choice shape = ChoiceOf(kv.Key, kv.Value, options, null);
string ignored;
if (!FitsDifficulty(shape, wantDifficulty, wantLevel, out ignored))
continue;
foreach (string b in BasesOf(kv.Key, options))
candidates.Add(ChoiceOf(kv.Key, kv.Value, options, b));
}
if (candidates.Count == 0)
{
reason = string.IsNullOrEmpty(wantDifficulty) && !wantLevel.HasValue ? "bad-profile" : "bad-difficulty";
refusal = string.IsNullOrEmpty(wantDifficulty) && !wantLevel.HasValue
? "RaidableBases has no enabled profile with a base"
: $"RaidableBases has no enabled profile with a base at difficulty '{wantDifficulty ?? wantLevel.ToString()}'";
return null;
}
if (needBase)
return candidates[UnityEngine.Random.Range(0, candidates.Count)];
// The plugin picks the base among the difficulty's own (its cycle, its rules). With no
// difficulty asked for, one is picked here at random from those that have a base.
Choice pick = candidates[UnityEngine.Random.Range(0, candidates.Count)];
return new Choice { Difficulty = pick.Difficulty, Level = pick.Level };
}
private Choice ChoiceOf(string name, object profile, object options, string baseName)
{
return new Choice
{
BaseName = baseName,
ProfileName = name,
Profile = profile,
Difficulty = ModeOf(options),
Level = LevelOf(options)
};
}
private static bool FitsDifficulty(Choice choice, string difficulty, int? level, out string refusal)
{
refusal = null;
if (!string.IsNullOrEmpty(difficulty) && !string.Equals(choice.Difficulty, difficulty, StringComparison.OrdinalIgnoreCase))
{
refusal = $"profile '{choice.ProfileName}' is difficulty '{choice.Difficulty}', not '{difficulty}'";
return false;
}
if (level.HasValue && string.IsNullOrEmpty(difficulty) && choice.Level != level.Value)
{
refusal = $"profile '{choice.ProfileName}' is level {choice.Level}, not {level.Value}";
return false;
}
return true;
}
/// The plugin's profile table, name first. Empty when it cannot be read.
private List> ProfileTable()
{
var rows = new List>();
if (!(F(F(_rb, "Buildings"), "Profiles") is IDictionary profiles))
return rows;
foreach (DictionaryEntry kv in profiles)
{
if (kv.Key is string name && kv.Value != null)
rows.Add(new KeyValuePair(name, kv.Value));
}
rows.Sort((a, b) => string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase));
return rows;
}
/// A profile's bases that have a CopyPaste file: the profile's own name, then its additional bases.
private List BasesOf(string profileName, object options)
{
var bases = new List();
if (Exists(profileName))
bases.Add(profileName);
if (F(options, "AdditionalBases") is IDictionary extra)
{
foreach (DictionaryEntry kv in extra)
{
if (kv.Key is string name && !bases.Contains(name) && Exists(name))
bases.Add(name);
}
}
return bases;
}
private bool Exists(string file)
{
try
{
return _fileExists.Invoke(_rb, new object[] { file }) is bool yes && yes;
}
catch (Exception)
{
return false;
}
}
/// The paid edition's difficulty name; the free edition has one, "Normal".
private static string ModeOf(object options)
{
return F(options, "Mode") is string mode && !string.IsNullOrEmpty(mode) ? mode : "Normal";
}
/// The paid edition's difficulty level; the free edition's hooks always carry 512.
private static int LevelOf(object options)
{
return F(options, "Level") is int level ? level : 512;
}
// ── Spawning ─────────────────────────────────────────────────────────
///
/// The plugin picks the spot (D203). The queue patch sees the request our call makes and
/// tags it, with the settings on a copy of its profile.
///
private Dictionary SpawnPicked(Tag tag, Choice choice)
{
object result = null;
_capturing = tag;
try
{
if (_spawnApi != null)
{
result = _spawnApi.Invoke(_rb, new object[] { choice.BaseName, Vector3.zero, choice.Level, Convert.ToInt32(_manual), true });
}
else
{
result = _spawnTyped.Invoke(_rb, new object[] { _manual, choice.BaseName, true, null, null, true });
}
}
catch (Exception ex)
{
_capturing = null;
return Refuse("refused", $"RaidableBases threw: {(ex.InnerException ?? ex).Message}");
}
finally
{
_capturing = null;
}
if (tag.Request == null || !(result is bool queued) || !queued)
{
if (tag.Request != null)
{
Requested.Remove(tag.Request);
Dequeue(tag.Request);
}
string said = result as string;
return Refuse("refused", $"RaidableBases did not queue a base: {(said ?? LastMessage() ?? "no reason given")}");
}
return new Dictionary { { "ok", true }, { "route", "pick" } };
}
///
/// A spot an admin gave (D207): checked (D208), then built the way the plugin's own
/// "spawn where I look" builds one and pasted at once.
///
private Dictionary SpawnAt(Dictionary req, Tag tag, Choice choice)
{
var warnings = new List();
Vector3 at;
string reason;
string refusal;
if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal))
{
Dictionary no = Refuse(reason, refusal);
no["warnings"] = warnings;
return no;
}
// The base as the plugin builds it: an additional base comes back as a clone of its
// profile carrying that base's own paste options and name (§11.4.2: the paid edition
// finds no profile without the difficulty's name).
object built;
try
{
object answer = _getBuilding.GetParameters().Length >= 4
? _getBuilding.Invoke(_rb, new object[] { _manual, choice.Difficulty, choice.BaseName, null })
: _getBuilding.Invoke(_rb, new object[] { _manual, choice.BaseName, null });
built = answer?.GetType().GetField("Item2")?.GetValue(answer);
}
catch (Exception ex)
{
return Refuse("refused", $"RaidableBases could not find base '{choice.BaseName}': {(ex.InnerException ?? ex).Message}");
}
if (built == null)
return Refuse("bad-base", $"RaidableBases will not build base '{choice.BaseName}' for an event: {LastMessage() ?? "no reason given"}");
object copy;
try
{
copy = CopyProfile(built);
object options = F(copy, "Options");
object setup = F(options, "Setup");
double? adjust = Number(req, "heightAdjust");
double? height = Number(req, "height");
if (height.HasValue)
{
S(setup, "EnableForcedHeight", true);
S(setup, "ForcedHeightValue", (float)height.Value);
}
else if (adjust.HasValue)
{
S(setup, "PasteHeightAdjustment", (F(setup, "PasteHeightAdjustment") is float now ? now : 0f) + (float)adjust.Value);
}
ApplySettings(options, tag);
}
catch (Exception ex)
{
return Refuse("refused", $"could not copy profile '{choice.ProfileName}': {(ex.InnerException ?? ex).Message}");
}
object rb;
try
{
rb = Activator.CreateInstance(_randomBase);
S(rb, "Instance", _rb);
S(rb, "BaseName", choice.BaseName);
S(rb, "Profile", copy);
S(rb, "Position", at);
S(rb, "type", _manual);
S(rb, "spawns", _spawnsCtor.Invoke(new object[] { _rb }));
S(rb, "pasteData", _getPasteData.Invoke(_rb, new object[] { choice.BaseName }));
Radii(rb);
_parseListed?.Invoke(_rb, new[] { rb });
}
catch (Exception ex)
{
return Refuse("refused", $"could not build the spawn request: {(ex.InnerException ?? ex).Message}");
}
tag.Request = rb;
Requested[rb] = tag;
bool pasting;
try
{
pasting = _pasteBuilding.Invoke(_rb, new[] { rb }) is bool yes && yes;
}
catch (Exception ex)
{
Requested.Remove(rb);
return Refuse("refused", $"RaidableBases threw while pasting: {(ex.InnerException ?? ex).Message}");
}
if (!pasting)
{
Requested.Remove(rb);
return Refuse("refused", "RaidableBases would not paste (is CopyPaste loaded?)");
}
// As the plugin's own queue does after a paste starts, so its queue waits for this one.
S(_rb, "IsSpawnerBusy", true);
S(rb, "IsPasting", true);
var ok = new Dictionary { { "ok", true }, { "route", "spot" }, { "warnings", warnings } };
ok["x"] = Math.Round(at.x, 1);
ok["z"] = Math.Round(at.z, 1);
return ok;
}
/// The radii the plugin's own queue sets on a request, from the copied profile.
private void Radii(object rb)
{
try
{
object options = F(F(rb, "Profile"), "Options");
if (_getDistance != null)
S(rb, "typeDistance", _getDistance.Invoke(_rb, new[] { _manual }));
float protection = ProtectionRadius(options);
S(rb, "protectionRadius", protection);
float arena = F(F(options, "ArenaWalls"), "Radius") is float walls ? walls : 0f;
S(rb, "safeRadius", Mathf.Max(arena, protection));
float cupboard = F(F(F(F(_rb, "config"), "Settings"), "Management"), "CupboardDetectionRadius") is float c ? c : 0f;
S(rb, "buildRadius", Mathf.Max(105f, Mathf.Max(cupboard, Mathf.Max(arena, protection)) + 5f));
}
catch (Exception ex)
{
PrintWarning($"could not set a spot's radii; the plugin's defaults stand: {(ex.InnerException ?? ex).Message}");
}
}
private float ProtectionRadius(object options)
{
try
{
MethodInfo radius = options?.GetType().GetMethod("ProtectionRadius", Any, null, new[] { _raidableType }, null);
if (radius != null && radius.Invoke(options, new[] { _manual }) is float r && r > 0f)
return r;
}
catch (Exception)
{
}
return DefaultSpotRadius;
}
/// A spawn's settings, on its own copy of the profile's options.
private static void ApplySettings(object options, Tag tag)
{
if (tag.Pvp.HasValue)
S(options, "AllowPVP", tag.Pvp.Value);
object npc = F(options, "NPC");
if (npc != null && (tag.Npcs.HasValue || tag.NpcCount.HasValue))
{
if (tag.Npcs == false || tag.NpcCount == 0)
{
S(npc, "Enabled", false);
}
else
{
S(npc, "Enabled", true);
if (tag.NpcCount.HasValue)
{
// The profile's own mix of the two kinds, at the count asked for, and not a
// random amount: the event said how many.
int murderers = Math.Max(0, F(npc, "SpawnAmountMurderers") is int m ? m : 0);
int scientists = Math.Max(0, F(npc, "SpawnAmountScientists") is int s ? s : 0);
int count = tag.NpcCount.Value;
int sci = murderers + scientists == 0 ? count : (int)Math.Round(count * (double)scientists / (murderers + scientists));
S(npc, "SpawnAmountScientists", sci);
S(npc, "SpawnAmountMurderers", count - sci);
S(npc, "SpawnRandomAmountScientists", false);
S(npc, "SpawnRandomAmountMurderers", false);
}
}
}
if (tag.DespawnMinutes.HasValue)
{
object despawn = F(options, "DespawnOptions");
if (despawn != null)
{
S(despawn, "OverrideConfig", true);
S(despawn, "DespawnMinutes", tag.DespawnMinutes.Value);
S(despawn, "DespawnMinutesInactive", tag.DespawnMinutes.Value);
}
}
}
/// Takes a request off the plugin's queue, when it is still on it.
private void Dequeue(object request)
{
if (request == null)
return;
try
{
object queue = F(F(_rb, "Queues"), "queue");
if (queue == null)
return;
Type type = queue.GetType();
if (!(type.GetMethod("ToArray")?.Invoke(queue, null) is Array items))
return;
type.GetMethod("Clear")?.Invoke(queue, null);
MethodInfo enqueue = type.GetMethod("Enqueue");
foreach (object item in items)
{
if (!ReferenceEquals(item, request))
enqueue.Invoke(queue, new[] { item });
}
}
catch (Exception ex)
{
PrintWarning($"could not take a request off RaidableBases' queue; it is cancelled when it opens: {(ex.InnerException ?? ex).Message}");
}
}
// ── A spot's checks (D208, D388, D389) ───────────────────────────────
///
/// Main thread. Whether a base may go at the request's x , z . Refuses for a
/// player, a building, a deployable, a raid base or water; rocks and cliffs are warnings.
///
private bool CheckSpot(Dictionary req, Choice choice, List warnings, out Vector3 at, out string reason, out string refusal)
{
at = Vector3.zero;
reason = null;
refusal = null;
double? x = Number(req, "x");
double? z = Number(req, "z");
if (!x.HasValue || !z.HasValue)
{
reason = "malformed";
refusal = "a spot needs x and z";
return false;
}
float half = World.Size / 2f;
if (Math.Abs(x.Value) > half || Math.Abs(z.Value) > half)
{
reason = "off-map";
refusal = $"({x.Value:0}, {z.Value:0}) is off this map, which runs from -{half:0} to {half:0}";
return false;
}
at = new Vector3((float)x.Value, 0f, (float)z.Value);
float ground = TerrainMeta.HeightMap.GetHeight(at);
at.y = ground;
object options = choice.Profile != null ? F(choice.Profile, "Options") : null;
float radius = options != null ? ProtectionRadius(options) : DefaultSpotRadius;
// Ours first: the plugin's check stops at the first thing it finds, and a boulder found
// first would hide a player's hut behind a warning.
if (!OwnChecks(at, radius, out reason, out refusal))
return false;
string said;
if (!PluginAreaSafe(at, radius, out said))
{
if (IsTerrainWarning(said))
{
warnings.Add(said);
}
else
{
reason = "spot-blocked";
refusal = $"RaidableBases' area check refused the spot: {said}";
return false;
}
}
// The ocean level against the ground. The water map read −500 everywhere on the rigs'
// map, so it cannot be used for this (§11.4.2).
float water = WaterSystem.OceanLevel;
if (water > ground + WaterMargin)
{
object waterOptions = F(options, "Water");
bool seabed = F(waterOptions, "Seabed") is float chance && chance > 0f;
bool floats = F(waterOptions, "AllowSubmerged") is bool yes && yes;
if (!seabed && !floats)
{
reason = "in-water";
refusal = $"the spot is {water - ground:0.0} m under water, and profile '{choice.ProfileName ?? "?"}' is not built for water";
return false;
}
warnings.Add($"the spot is {water - ground:0.0} m under water; profile '{choice.ProfileName}' allows it");
}
return true;
}
/// Players, buildings, deployables and raid bases within the radius, by this helper's own look.
private bool OwnChecks(Vector3 at, float radius, out string reason, out string refusal)
{
reason = null;
refusal = null;
foreach (BasePlayer player in BasePlayer.allPlayerList)
{
if (player == null || player.IsNpc || player.transform == null)
continue;
ulong uid = player.userID;
if (!uid.IsSteamId())
continue;
if (Distance2D(player.transform.position, at) <= radius)
{
reason = "spot-blocked";
refusal = $"a player is within {radius:0} m ({player.displayName})";
return false;
}
}
if (F(_rb, "Raids") is IList raids)
{
foreach (object raid in raids)
{
if (raid == null || raid is UnityEngine.Object unity && unity == null)
continue;
if (F(raid, "Location") is Vector3 where && Distance2D(where, at) <= radius * 2f)
{
reason = "spot-blocked";
refusal = $"a raid base is within {radius * 2f:0} m";
return false;
}
}
}
var nearby = new List();
Vis.Entities(at, radius, nearby);
foreach (BaseEntity entity in nearby)
{
if (entity == null || entity.IsDestroyed)
continue;
if (entity is BuildingPrivlidge || entity is BuildingBlock && entity.OwnerID.IsSteamId())
{
reason = "spot-blocked";
refusal = $"a player's building is within {radius:0} m";
return false;
}
if (entity.OwnerID.IsSteamId() && !(entity is BasePlayer))
{
reason = "spot-blocked";
refusal = $"a player's deployable ({entity.ShortPrefabName}) is within {radius:0} m";
return false;
}
}
return true;
}
/// The plugin's own area check, bound by parameter count (§11.4.2), with its reason.
private bool PluginAreaSafe(Vector3 at, float radius, out string said)
{
said = null;
object controller = F(_rb, "SpawnsController");
object messages = F(F(_rb, "Queues"), "Messages");
if (controller == null || _areaSafe == null)
return true;
try
{
(F(messages, "_elements") as IDictionary)?.Clear();
ParameterInfo[] ps = _areaSafe.GetParameters();
var args = new object[ps.Length];
args[0] = at;
args[1] = 0f;
args[2] = radius;
args[3] = radius;
args[4] = radius;
args[5] = _manualMask;
args[6] = false;
args[7] = null;
for (int i = 8; i < ps.Length; i++)
args[i] = ps[i].ParameterType == _raidableType ? _manual : ps[i].HasDefaultValue ? ps[i].DefaultValue : null;
if (_areaSafe.Invoke(controller, args) is bool safe && safe)
return true;
said = messages?.GetType().GetMethod("GetLast", Any)?.Invoke(messages, new object[] { null }) as string;
said = Clean(said) ?? "no reason given";
return false;
}
catch (Exception ex)
{
// The check itself broke: advice lost, not a refusal.
PrintWarning($"RaidableBases' area check threw: {(ex.InnerException ?? ex).Message}");
return true;
}
}
///
/// Whether the plugin's reason is the terrain, which D388 makes a warning. Its messages
/// are English literals in its source (not its lang file), so the words are its own.
///
private static bool IsTerrainWarning(string said)
{
return said != null && (said.IndexOf("Rock is too large", StringComparison.OrdinalIgnoreCase) >= 0
|| said.IndexOf("Cliff formation is too large", StringComparison.OrdinalIgnoreCase) >= 0);
}
/// The plugin's debug line ("1x - Rock is too large: x y z") down to its words.
private static string Clean(string said)
{
if (string.IsNullOrEmpty(said))
return null;
int dash = said.IndexOf(" - ", StringComparison.Ordinal);
string text = dash >= 0 && dash < 8 ? said.Substring(dash + 3) : said;
return text.Trim();
}
private string LastMessage()
{
try
{
object messages = F(F(_rb, "Queues"), "Messages");
if (!(F(messages, "_elements") is IDictionary elements) || elements.Count == 0)
return null;
return Clean(messages.GetType().GetMethod("GetLast", Any)?.Invoke(messages, new object[] { null }) as string);
}
catch (Exception)
{
return null;
}
}
// ── Patching ─────────────────────────────────────────────────────────
private void Patch()
{
_rb = plugins.Find("RaidableBases");
if (_rb == null)
{
_state = "no-raidablebases";
_reason = null;
return;
}
_rbType = _rb.GetType();
_raidableBase = Nested("RaidableBase");
_randomBase = Nested("RandomBase");
_spawnsType = Nested("RaidableSpawns");
_raidableType = Nested("RaidableType");
Type queueType = Nested("QueueController");
Type spawnsController = Nested("SpawnsControllerManager");
_manual = null;
try
{
if (_raidableType != null && _raidableType.IsEnum)
_manual = Enum.Parse(_raidableType, "Manual");
}
catch (Exception)
{
_manual = null;
}
_openEvent = _randomBase == null ? null : _rbType.GetMethod("OpenEvent", Any, null, new[] { _randomBase }, null);
_pasteBuilding = _randomBase == null ? null : _rbType.GetMethod("PasteBuilding", Any, null, new[] { _randomBase }, null);
_parseListed = _randomBase == null ? null : _rbType.GetMethod("ParseListedOptions", Any, null, new[] { _randomBase }, null);
_queueAdd = queueType == null || _randomBase == null ? null : queueType.GetMethod("Add", Any, null, new[] { _randomBase }, null);
_bypass = _raidableBase?.GetMethod("BypassUseOwners", Any, null, Type.EmptyTypes, null);
_despawn = _raidableBase?.GetMethod("Despawn", Any, null, Type.EmptyTypes, null);
_gridLoading = _rbType.GetMethod("IsGridLoading", Any, null, Type.EmptyTypes, null);
_getPasteData = _rbType.GetMethod("GetPasteData", Any, null, new[] { typeof(string) }, null);
_fileExists = _rbType.GetMethod("FileExists", Any, null, new[] { typeof(string) }, null);
_reloadProfiles = FindMethod(_rbType, "ReloadProfiles", m => m.GetParameters().Length == 1 && typeof(IEnumerator).IsAssignableFrom(m.ReturnType));
_getDistance = _raidableType == null ? null : _rbType.GetMethod("GetDistance", Any, null, new[] { _raidableType }, null);
_getBuilding = FindMethod(_rbType, "GetBuilding", m => m.GetParameters().Length >= 3);
_areaSafe = spawnsController == null ? null : FindMethod(spawnsController, "IsAreaSafe", m => m.GetParameters().Length >= 9 && m.GetParameters()[0].ParameterType == typeof(Vector3));
_spawnApi = FindMethod(_rbType, "SpawnRandomBase", m => Shape(m, typeof(string), typeof(Vector3), typeof(int), typeof(int), typeof(bool)));
_spawnTyped = _raidableType == null ? null : FindMethod(_rbType, "SpawnRandomBase", m =>
{
ParameterInfo[] ps = m.GetParameters();
return ps.Length == 6 && ps[0].ParameterType == _raidableType && ps[1].ParameterType == typeof(string);
});
_spawnsCtor = _spawnsType == null ? null : Array.Find(_spawnsType.GetConstructors(Any), c => c.GetParameters().Length == 1);
try
{
FieldInfo mask = _rbType.GetField("manualMask", Any);
_manualMask = mask != null ? Convert.ToInt32(mask.GetValue(null)) : 0;
}
catch (Exception)
{
_manualMask = 0;
}
string missing =
_raidableBase == null ? "RaidableBase" :
_randomBase == null ? "RandomBase" :
_manual == null ? "RaidableType.Manual" :
_openEvent == null ? "OpenEvent(RandomBase)" :
_queueAdd == null ? "QueueController.Add(RandomBase)" :
_bypass == null ? "RaidableBase.BypassUseOwners()" :
_despawn == null ? "RaidableBase.Despawn()" :
_gridLoading == null ? "IsGridLoading()" :
_pasteBuilding == null ? "PasteBuilding(RandomBase)" :
_getBuilding == null ? "GetBuilding" :
_getPasteData == null ? "GetPasteData(string)" :
_fileExists == null ? "FileExists(string)" :
_reloadProfiles == null ? "ReloadProfiles(IPlayer)" :
_spawnsCtor == null ? "RaidableSpawns(RaidableBases)" :
_spawnApi == null && _spawnTyped == null ? "SpawnRandomBase" :
_manualMask == 0 ? "manualMask" :
null;
if (missing != null)
{
Fail($"this RaidableBases ({_rb.Version}) has no {missing}");
return;
}
try
{
_harmony = new Harmony(HarmonyId);
PatchOne(_queueAdd, prefix: nameof(BeforeQueueAdd));
PatchOne(_openEvent, postfix: nameof(AfterOpenEvent));
PatchOne(_bypass, prefix: nameof(BeforeBypassUseOwners));
}
catch (Exception ex)
{
Fail($"Harmony could not patch RaidableBases: {ex.Message}");
return;
}
_state = "patched";
_reason = null;
// Not a refusal: the spot check loses only the plugin's half and says why in the log.
if (_areaSafe == null)
PrintWarning($"this RaidableBases ({_rb.Version}) has no SpawnsController.IsAreaSafe: a spot is checked by this helper's own look and the water test only");
Puts($"patched RaidableBases {_rb.Version} ({(_spawnApi != null ? "paid" : "free")} edition's spawn call): " +
"an event's base can be tagged, set, locked, placed and despawned");
}
private void PatchOne(MethodBase target, string prefix = null, string postfix = null)
{
_harmony.Patch(target,
prefix: prefix == null ? null : new HarmonyMethod(typeof(RunicGatewayRaids).GetMethod(prefix, BindingFlags.Static | BindingFlags.NonPublic)),
postfix: postfix == null ? null : new HarmonyMethod(typeof(RunicGatewayRaids).GetMethod(postfix, BindingFlags.Static | BindingFlags.NonPublic)));
_patched.Add(target);
}
private void Unpatch()
{
if (_harmony != null)
{
foreach (MethodBase target in _patched)
{
try
{
_harmony.Unpatch(target, HarmonyPatchType.All, HarmonyId);
}
catch (Exception ex)
{
PrintWarning($"could not remove a RaidableBases patch: {ex.Message}");
}
}
}
_patched.Clear();
_harmony = null;
}
private void Fail(string reason)
{
Unpatch();
_state = "unsupported";
_reason = reason;
PrintWarning($"{reason}. Runic Gateway still reports raid bases, but an event cannot despawn, set, lock or place its own.");
}
private Type Nested(string name) => _rbType?.GetNestedType(name, Any);
private static MethodInfo FindMethod(Type type, string name, Predicate fits)
{
if (type == null)
return null;
foreach (MethodInfo m in type.GetMethods(Any))
{
if (m.Name == name && fits(m))
return m;
}
return null;
}
private static bool Shape(MethodInfo m, params Type[] types)
{
ParameterInfo[] ps = m.GetParameters();
if (ps.Length != types.Length)
return false;
for (int i = 0; i < ps.Length; i++)
{
if (ps[i].ParameterType != types[i])
return false;
}
return true;
}
// ── The patches. Static, as Harmony needs; nothing thrown here may reach RaidableBases. ──
/// Prefix on QueueController.Add(RandomBase) : tags the request our own pick made.
private static void BeforeQueueAdd(object __0)
{
try
{
Tag tag = _capturing;
if (tag == null || __0 == null || _instance == null)
return;
_capturing = null;
tag.Request = __0;
Requested[__0] = tag;
_instance.CopySettingsOnto(__0, tag);
}
catch (Exception)
{
// Never RaidableBases' problem.
}
}
/// Postfix on OpenEvent(RandomBase) : the request has become a base.
private static void AfterOpenEvent(object __result, object __0)
{
try
{
Tag tag;
if (__result == null || __0 == null || !Requested.TryGetValue(__0, out tag))
return;
Requested.Remove(__0);
tag.Raid = __result;
tag.Request = null;
Tagged[__result] = tag;
if (F(__result, "Location") is Vector3 where)
tag.Location = where;
// The config's Convert switches were applied inside OpenEvent, after the profile.
if (tag.Pvp.HasValue)
S(__result, "AllowPVP", tag.Pvp.Value);
if (tag.Cancelled && _instance != null)
{
RunicGatewayRaids self = _instance;
self.NextTick(() => self.Despawn(tag.Id));
}
}
catch (Exception)
{
// Never RaidableBases' problem.
}
}
/// Prefix on RaidableBase.BypassUseOwners() : a run's base answers from its own lock (D206).
private static bool BeforeBypassUseOwners(object __instance, ref bool __result)
{
try
{
Tag tag;
if (__instance != null && Tagged.TryGetValue(__instance, out tag) && tag.Lock.HasValue)
{
// Locked to the first attacker means the owner rules are NOT bypassed.
__result = !tag.Lock.Value;
return false;
}
}
catch (Exception)
{
}
return true;
}
/// The pick route's settings, on a copy of the request's profile.
private void CopySettingsOnto(object request, Tag tag)
{
if (!tag.Pvp.HasValue && !tag.Npcs.HasValue && !tag.NpcCount.HasValue && !tag.DespawnMinutes.HasValue)
return;
object copy = CopyProfile(F(request, "Profile"));
ApplySettings(F(copy, "Options"), tag);
S(request, "Profile", copy);
}
// ── The plugin's own hooks, for the tags' bookkeeping ────────────────
private void OnRaidableBaseEnded(Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, ulong ownerId,
BasePlayer owner, List raiders, List intruders, List entities, string baseName,
DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining)
{
foreach (Tag tag in _byId.Values)
{
if (tag.Raid != null && tag.EndedAt < 0f && Distance2D(tag.Location, location) <= 2f)
tag.EndedAt = Time.realtimeSinceStartup;
}
// Forget what ended long ago, so the table stays the size of what is live.
var stale = new List();
foreach (Tag tag in _byId.Values)
{
if (tag.EndedAt >= 0f && Time.realtimeSinceStartup - tag.EndedAt > EndedTagSeconds)
stale.Add(tag.Id);
}
foreach (string gone in stale)
{
if (_byId.TryGetValue(gone, out Tag tag) && tag.Raid != null)
Tagged.Remove(tag.Raid);
_byId.Remove(gone);
}
}
// ── Small things ─────────────────────────────────────────────────────
private static bool IsOpen(object raid)
{
if (raid == null || raid is UnityEngine.Object unity && unity == null)
return false;
return !(F(raid, "IsDespawning") is bool despawning && despawning);
}
private static bool Flag(object o, string name) => F(o, name) is bool on && on;
private static object F(object o, string name)
{
if (o == null)
return null;
Type type = o.GetType();
FieldInfo field = type.GetField(name, Any);
if (field != null)
return field.GetValue(o);
PropertyInfo property = type.GetProperty(name, Any);
return property != null && property.GetIndexParameters().Length == 0 ? property.GetValue(o) : null;
}
private static void S(object o, string name, object value)
{
if (o == null)
return;
Type type = o.GetType();
FieldInfo field = type.GetField(name, Any);
if (field != null)
{
field.SetValue(o, value);
return;
}
PropertyInfo property = type.GetProperty(name, Any);
if (property != null && property.CanWrite)
property.SetValue(o, value);
}
private static readonly MethodInfo MemberwiseCloneMethod = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic);
private static object ShallowCopy(object o) => o == null ? null : MemberwiseCloneMethod.Invoke(o, null);
///
/// A copy of a profile one spawn may change without touching the profile every other
/// base shares (§11.4.4). The profile and its options are copied member by member, so the
/// loot list, the spawn table and every value the plugin computes after it reads a
/// profile are carried over as they are — a JSON round trip lost those, and the base it
/// opened broke the plugin's damage hooks on the rig (build, 2026-10-16). The three
/// objects a spawn's settings write into (Setup , NPC , DespawnOptions )
/// are copied again, which is what the plugin's own shallow Clone() does not do and
/// why its height leaked into the shared profile (§11.4.4). Returns the profile's copy;
/// its Options is the options' copy.
///
private static object CopyProfile(object profile)
{
object copy = ShallowCopy(profile);
object options = ShallowCopy(F(profile, "Options"));
foreach (string nested in new[] { "Setup", "NPC", "DespawnOptions" })
{
object inner = F(options, nested);
if (inner != null)
S(options, nested, ShallowCopy(inner));
}
S(copy, "Options", options);
return copy;
}
private static Dictionary Refuse(string reason, string message)
{
return new Dictionary { { "ok", false }, { "reason", reason }, { "message", message } };
}
private static string Text(Dictionary req, string key)
{
return req != null && req.TryGetValue(key, out object value) && value != null ? value.ToString() : null;
}
private static double? Number(Dictionary req, string key)
{
string text = Text(req, key);
double value;
return text != null && double.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out value)
? value
: (double?)null;
}
private static int? IntOf(Dictionary req, string key)
{
double? value = Number(req, key);
return value.HasValue ? (int)Math.Round(value.Value) : (int?)null;
}
private static bool? BoolOf(Dictionary req, string key)
{
if (req == null || !req.TryGetValue(key, out object value) || value == null)
return null;
if (value is bool b)
return b;
string text = value.ToString().Trim().ToLowerInvariant();
return text == "true" || text == "1" ? true : text == "false" || text == "0" ? false : (bool?)null;
}
private static float Distance2D(Vector3 a, Vector3 b)
{
float dx = a.x - b.x;
float dz = a.z - b.z;
return Mathf.Sqrt(dx * dx + dz * dz);
}
// ── Console ──────────────────────────────────────────────────────────
[ConsoleCommand("rgr.status")]
private void StatusCommand(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin)
return;
int open = 0, queued = 0;
foreach (Tag tag in _byId.Values)
{
string live = LiveState(tag.Id);
if (live == "open")
open++;
else if (live == "queued")
queued++;
}
arg.ReplyWith($"Runic Gateway Raids {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}" +
(_state == "patched" ? $"; {(_spawnApi != null ? "paid" : "free")} spawn call; grid {(GridReady() ? "ready" : "building")}; " +
$"{ProfileTable().Count} profiles; event bases open {open}, on their way {queued}" : ""));
}
}
}