diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6799076..48ccce1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -30,7 +30,7 @@ # # 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, # `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers -# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the +# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`, `RunicGatewayRaids.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the # manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to # `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each # decide which (§34.2.1). The fixed, unversioned prefix is so a reader never @@ -41,7 +41,8 @@ # committed file keeps its placeholder; the manifest records the commit. # Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168, # D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs` -# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own +# for ZoneDomes, PLAN_REDESIGNS D194, and `RunicGatewayRaids.cs` for RaidableBases, +# D204) keeps its own # version: it does one job, changes rarely, and the bridge reads its version at # hello to know which job it does. # @@ -81,7 +82,7 @@ env: PLUGIN: overlay/oxide/plugins/RunicGateway.cs # The helpers shipped beside the bridge (D168, D182). Optional at runtime — # the bridge loads without them — and installed by default. - HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs + HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs overlay/oxide/plugins/RunicGatewayRaids.cs SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer diff --git a/README.md b/README.md index ef11d55..ba7a598 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ unauthenticated command channel on the network. overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs overlay/oxide/plugins/RunicGatewayZones.cs → /oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs → /oxide/plugins/RunicGatewayDomes.cs +overlay/oxide/plugins/RunicGatewayRaids.cs → /oxide/plugins/RunicGatewayRaids.cs ``` `RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager @@ -52,6 +53,17 @@ helper patches the first with Harmony and tells the bridge when ZoneDomes has st domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is missing. `rgd.status` in the server console says whether it patched. +`RunicGatewayRaids.cs` is the **RaidableBases helper** (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D204–D207, +D387). RaidableBases' API spawns nothing and despawns nothing, so an event's base cannot be the event's from the +outside. The helper reaches its internals by reflection and patches its spawn queue, `OpenEvent` and each base's +`BypassUseOwners()` with Harmony: it tags the base a run asked for at birth, applies that spawn's own PvP, NPCs, +despawn time and lock on a copy of its profile, places one at a spot after checking it, despawns that one base at +the run's end, and reloads RaidableBases' profiles when the site edits them. It serves both editions, free and +paid, and reads every difficulty, profile and base name from the installed plugin. It is optional: without it the +bridge still reports raid bases on the feed and the map, and a step can still ask RaidableBases for a base where it +picks, but that base despawns on its own timer, and the site says so. `rgr.status` in the server console says +whether it patched, which edition's spawn call it found, and whether the plugin's spawn grid is built. + Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load: ```json @@ -134,6 +146,15 @@ kept when the event ends: Guarded Crate has no way to end one event alone. Witho is refused, because the crate would land unguarded; and, as for Dangerous Treasures, a framework with `consolePlugins=False` cannot place one. +nivex's `RaidableBases` is optional, free or paid, and needs `CopyPaste` to paste its bases. Where it is +loaded, a base appearing, being raided, bought or locked to a player reach the site's feed, its map's own +*Raid bases* layer and the *Warlord* title, and an event can spawn one (`raid.spawn`). With +`RunicGatewayRaids.cs` (above) the base is the event's: at a spot or where RaidableBases picks, with its own +settings, despawned when the event ends; and the site can edit RaidableBases' profiles from its configuration +page, reloading them under watch. For up to ten minutes after RaidableBases loads it builds its spawn grid and +refuses every spawn, and a spawn then is answered "not ready". Every difficulty, profile and base name is read +from the installed plugin. + nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is loaded. Its levels are never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an event lends one as it lends any other permission, and it takes effect at once. diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 79fda21..0be829e 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -290,7 +290,10 @@ namespace Oxide.Plugins // Protocol 14, Dangerous Treasures (§20.8). "OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded", // Protocol 14, Guarded Crate (§20.9). - "OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed" + "OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed", + // Protocol 14, RaidableBases (§20.12). + "OnRaidableBaseStarted", "OnRaidableBaseEnded", "OnRaidableBaseCompleted", + "OnRaidableBasePurchased", "OnRaidableBaseLocked" }; private void Init() @@ -586,6 +589,7 @@ namespace Oxide.Plugins LoadWorldCalls(); LoadDtStarts(); LoadGcPlacements(); + LoadRaidStarts(); LoadCommandRecords(); } @@ -1097,6 +1101,17 @@ namespace Oxide.Plugins HandleGcPlace(cmd, reqId); break; + case "raid.spawn": + // Protocol 14 (PROTOCOL.md §20.12, D203-D208, D387). One RaidableBases base for a + // run: the plugin's pick or an admin's spot, despawned at the run's end. + HandleRaidSpawn(cmd, reqId); + break; + + case "raid.check": + // Protocol 14 (PROTOCOL.md §20.12, D208, D388, D389). A spot's checks, spawning nothing. + HandleRaidCheck(cmd, reqId); + break; + case "command.run": // Protocol 14 (PROTOCOL.md §20.11, D382-D384). Console commands a step's author // typed: once, or once per online recipient; at most once per key. @@ -1672,6 +1687,7 @@ namespace Oxide.Plugins SeedTreasures("load"); NoteDtSeen(DangerousTreasuresPlugin() != null); SeedGuardedCrates("load"); + SeedRaids("load"); // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which // carries what the probe found. @@ -2918,6 +2934,9 @@ namespace Oxide.Plugins /// Protocol 14 (D375): guarded crates this player cleared, by killing the last guard (Warden). public int GuardedWon; + + /// Protocol 14 (§11.4, D202): RaidableBases bases this player completed as a raider (Warlord). + public int RaidsCompleted; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . @@ -2986,7 +3005,7 @@ namespace Oxide.Plugins return Gathered.Count == 0 && NpcKills == 0 && Structures == 0 && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 && BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && TreasuresWon == 0 && GuardedWon == 0 - && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 + && RaidsCompleted == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 && !HasBalance; } @@ -3094,6 +3113,8 @@ namespace Oxide.Plugins frame["treasuresWon"] = tally.TreasuresWon; if (tally.GuardedWon > 0) frame["guardedWon"] = tally.GuardedWon; + if (tally.RaidsCompleted > 0) + frame["raidsCompleted"] = tally.RaidsCompleted; if (tally.Crafted.Count > 0) frame["crafted"] = tally.Crafted; if (tally.Built > 0) @@ -5226,7 +5247,7 @@ namespace Oxide.Plugins // zone step's PvP rules are refused without (§14.3.2); and Dynamic PVP (§14.3.3). // Guarded Crate and NpcSpawn too: a placement is refused without either (D364). And // ZLevels, which the servers page names (§14.3.6). - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin) || IsRaidPlugin(plugin)) NextTick(ResendHello); // Dynamic PVP raises its creates as it opens its zones, which at a load are its @@ -5242,6 +5263,11 @@ namespace Oxide.Plugins NextTick(() => SeedTreasures("Dangerous Treasures loaded")); } + // RaidableBases' live bases, found again: a reload of it keeps none, but a hotload of + // this bridge hears no starts (§20.12). + if (plugin != null && plugin != this && plugin.Name == "RaidableBases") + NextTick(() => SeedRaids("RaidableBases loaded")); + // Economics loaded after players connected: what they hold now is not otherwise known // until it moves (D336). if (IsEconomics(plugin)) @@ -5269,7 +5295,7 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin) || IsRaidPlugin(plugin)) NextTick(ResendHello); // Guarded Crate's Unload ended every event WITHOUT a hook. @@ -5287,6 +5313,11 @@ namespace Oxide.Plugins NoteDtSeen(false); } + // Its Unload despawned every base, each with its hooks; a run's base still on its way + // is gone with the queue (§20.12). + if (plugin != null && plugin != this && plugin.Name == "RaidableBases") + RaidableBasesGone(); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -5528,6 +5559,15 @@ namespace Oxide.Plugins /// The log lines that showed the failure, or the tail read when the ceiling passed. public string RollbackLog; + + /// + /// RaidableBases' profiles rather than a plugin's config (D205): reloaded by the raids + /// helper as rb.reloadprofiles does, and judged by whether each profile loaded. + /// + public bool Profiles; + + /// The helper's finished reloads when this phase's reload was asked for; one more is this one. + public int ReloadsBefore; } private ConfigWriteJob _configJob; @@ -5553,6 +5593,7 @@ namespace Oxide.Plugins try { WalkConfig(root, root, 0, files, ref truncated); + ListRaidProfiles(files, ref truncated); } catch (Exception ex) { @@ -5893,6 +5934,34 @@ namespace Oxide.Plugins }); } + // RaidableBases' profiles reload through its own command, not a plugin reload (D205), so + // a write is all profiles or none, and refused before anything lands when the reload + // could not be asked for or watched. + int profileFiles = 0; + + foreach (StagedConfigWrite file in staged) + { + if (IsRaidProfilePath(file.RelPath)) + profileFiles++; + } + + if (profileFiles > 0 && profileFiles < staged.Count) + { + EmitConfigError(reqId, "mixed", "RaidableBases' profiles reload on their own; save them apart from other files"); + return; + } + + if (profileFiles > 0) + { + string refusal = RaidProfileWriteRefusal(); + + if (refusal != null) + { + EmitConfigError(reqId, "profiles-unavailable", refusal); + return; + } + } + foreach (StagedConfigWrite file in staged) { try @@ -5911,6 +5980,12 @@ namespace Oxide.Plugins } } + if (profileFiles > 0) + { + StartRaidProfileJob(reqId, staged); + return; + } + // This plugin never reloads itself (§21.3), so a change to its own settings would // otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first // of them an operator is MEANT to change from the website (protocol 8, D76). It is @@ -6032,6 +6107,12 @@ namespace Oxide.Plugins return; } + if (job.Profiles) + { + RaidProfileWatchTick(job); + return; + } + string failure = ReadConfigFailure(job); if (failure != null && IsCompileStallOf(failure, job.Reload)) @@ -6540,6 +6621,269 @@ namespace Oxide.Plugins } } + // ---- RaidableBases' profiles in the editor (PLAN_REDESIGNS §11.4.3, D205) ---- + // + // The editor never walks a plugin's data directory, which holds live state and the + // permission stores. **One folder is the named exception**: RaidableBases keeps its profiles + // (every base's NPCs, loot rules, timers and protection) in `data/RaidableBases/Profiles/`, + // and an operator tunes them there. They travel under a virtual prefix so no path from the + // config tree can ever name them, and they get the editor's usual guarantees: version + // checked, backed up, written, reloaded under watch, and rolled back if the profile does not + // load. The reload is `rb.reloadprofiles`'s, asked of the raids helper, and "loaded" is the + // helper finding the profile in the plugin's own table after the reload finished. + + private const string RaidProfilePrefix = "@data/RaidableBases/Profiles/"; + + private static bool IsRaidProfilePath(string rel) + { + return rel != null && rel.StartsWith(RaidProfilePrefix, StringComparison.Ordinal); + } + + private static string RaidProfilesDir() + { + try + { + return Path.Combine(Path.Combine(Interface.Oxide.DataDirectory, "RaidableBases"), "Profiles"); + } + catch (Exception) + { + return null; + } + } + + /// A profile file's path on this host: one .json directly in the profiles folder, never a link. + private static bool ResolveRaidProfilePath(string rel, out string full, out string refusal) + { + full = null; + refusal = null; + string name = rel.Substring(RaidProfilePrefix.Length); + + if (name.Length == 0 || name.IndexOf('/') >= 0 || name.IndexOf('\\') >= 0 || name.IndexOf(':') >= 0 + || name.IndexOf('\0') >= 0 || name.IndexOf("..", StringComparison.Ordinal) >= 0 + || !name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + refusal = "a RaidableBases profile is one .json file directly in its profiles folder"; + return false; + } + + string dir = RaidProfilesDir(); + + if (string.IsNullOrEmpty(dir)) + { + refusal = "this framework reports no data directory"; + return false; + } + + try + { + string canonicalDir = Path.GetFullPath(dir); + string candidate = Path.GetFullPath(Path.Combine(canonicalDir, name)); + + if (!string.Equals(Path.GetDirectoryName(candidate), canonicalDir.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + { + refusal = "a path may not step outside RaidableBases' profiles folder"; + return false; + } + + if (File.Exists(candidate) && (new FileInfo(candidate).Attributes & FileAttributes.ReparsePoint) != 0) + { + refusal = "a link is not followed"; + return false; + } + + full = candidate; + return true; + } + catch (Exception ex) + { + refusal = ex.Message; + return false; + } + } + + /// Main thread. The profile files, as config rows under the virtual prefix. Directory entries only, like the walk. + private static void ListRaidProfiles(List into, ref bool truncated) + { + string dir = RaidProfilesDir(); + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return; + + string[] entries = Directory.GetFiles(dir, "*.json"); + Array.Sort(entries, StringComparer.OrdinalIgnoreCase); + + foreach (string path in entries) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + FileInfo info; + + try + { + info = new FileInfo(path); + } + catch (Exception) + { + continue; + } + + // RaidableBases skips its own placeholder, and so does the editor. + if ((info.Attributes & FileAttributes.ReparsePoint) != 0 || info.Name.IndexOf("_empty", StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + var row = new Dictionary + { + { "path", RaidProfilePrefix + info.Name }, + { "bytes", info.Length }, + { "modified", ToUnixMs(info.LastWriteTimeUtc) }, + { "plugin", "RaidableBases" }, + { "profile", true } + }; + + if (info.Length > ConfigMaxFileBytes) + { + row["editable"] = false; + row["reason"] = "larger than this bridge will carry"; + } + else + { + row["editable"] = true; + } + + into.Add(row); + } + } + + /// Why a profile write cannot be reloaded and watched now, or null. + private string RaidProfileWriteRefusal() + { + if (RaidableBasesPlugin() == null) + return "RaidableBases is not loaded, so its profiles cannot be reloaded"; + + if (!RaidsHelperPatched()) + return RaidsHelperState() == "missing" + ? "a profile is saved through RunicGatewayRaids.cs, which reloads it and checks it loaded, and it is not installed" + : $"RunicGatewayRaids.cs could not patch this RaidableBases ({RaidsHelperState()}), so a profile cannot be reloaded and checked"; + + if (!(RaidsHelperPlugin().Call("GridReady") is bool ready) || !ready) + return "RaidableBases is still building its spawn grid (up to ten minutes after it loads) and refuses to reload its profiles until it has"; + + return null; + } + + /// Main thread. The files are on disk: ask for the reload, answer at once, watch. + private void StartRaidProfileJob(string reqId, List staged) + { + Plugin helper = RaidsHelperPlugin(); + + var job = new ConfigWriteJob + { + WriteId = Guid.NewGuid().ToString("N"), + Reload = "RaidableBases", + Staged = staged, + Phase = ConfigPhase.Edit, + Started = DateTime.UtcNow, + PhaseStarted = DateTime.UtcNow, + Profiles = true, + ReloadsBefore = helper.Call("ReloadsFinished") is int done ? done : 0 + }; + + if (!(helper.Call("ReloadProfiles") is bool asked) || !asked) + { + RestoreConfig(staged); + EmitConfigError(reqId, "reload-failed", "RaidableBases would not reload its profiles now (its grid, or a reload already running)"); + return; + } + + _configJob = job; + job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick); + SendConfigReport(reqId, staged, false, false, null, null, job); + } + + /// + /// Main thread, once a second while a profile reload is pending. Settled when the helper + /// says the reload finished, by whether every written profile is in the plugin's table; + /// a profile that is not is rolled back and the old one reloaded. + /// + private void RaidProfileWatchTick(ConfigWriteJob job) + { + Plugin helper = RaidsHelperPlugin(); + + if (helper == null || RaidableBasesPlugin() == null) + { + if (job.Phase == ConfigPhase.Edit) + { + RestoreConfig(job.Staged); + SettleConfigJob(job, false, true, false, "RaidableBases or its helper unloaded before the profiles finished reloading; the files are back", null); + } + else + { + SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; RaidableBases or its helper unloaded before the restored profiles reloaded", job.RollbackLog); + } + + return; + } + + int finished = helper.Call("ReloadsFinished") is int n ? n : 0; + + if (finished > job.ReloadsBefore) + { + var failed = new List(); + + foreach (StagedConfigWrite file in job.Staged) + { + string name = Path.GetFileNameWithoutExtension(file.FullPath); + + if (helper.Call("ProfileState", name) as string != "loaded") + failed.Add(name); + } + + if (job.Phase == ConfigPhase.Edit) + { + if (failed.Count == 0) + SettleConfigJob(job, true, false, null, null, null); + else + RollBackRaidProfiles(job, $"RaidableBases did not load profile {string.Join(", ", failed.ToArray())}", LogTail()); + } + else + { + SettleConfigJob(job, false, true, failed.Count == 0, + failed.Count == 0 ? job.RollbackReason : $"{job.RollbackReason}; the restored profile did not load either", + job.RollbackLog); + } + + return; + } + + if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds) + return; + + if (job.Phase == ConfigPhase.Edit) + RollBackRaidProfiles(job, $"RaidableBases' profile reload did not finish within {ConfigReloadCeilingSeconds:0}s", LogTail()); + else + SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; the restored profiles did not finish reloading either", job.RollbackLog); + } + + private void RollBackRaidProfiles(ConfigWriteJob job, string reason, string log) + { + RestoreConfig(job.Staged); + + job.Phase = ConfigPhase.Restore; + job.PhaseStarted = DateTime.UtcNow; + job.RollbackReason = reason; + job.RollbackLog = log; + + Plugin helper = RaidsHelperPlugin(); + job.ReloadsBefore = helper != null && helper.Call("ReloadsFinished") is int done ? done : 0; + + if (helper == null || !(helper.Call("ReloadProfiles") is bool asked) || !asked) + SettleConfigJob(job, false, true, false, $"{reason}; the files are back, but RaidableBases would not reload them", log); + } + /// /// 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 @@ -6851,6 +7195,10 @@ namespace Oxide.Plugins return false; } + // The one named exception to "never the data directory" (D205): RaidableBases' profiles. + if (IsRaidProfilePath(rel)) + return ResolveRaidProfilePath(rel, out full, out refusal); + if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0) { refusal = "a path may not step outside the configuration directory"; @@ -6949,6 +7297,9 @@ namespace Oxide.Plugins /// private static string PluginGuess(string rel) { + if (IsRaidProfilePath(rel)) + return "RaidableBases"; + int slash = rel.IndexOf('/'); return slash > 0 @@ -8191,6 +8542,12 @@ namespace Oxide.Plugins /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; + /// A raid base's difficulty name, the plugin's own (§20.12, D386). Null for anything else. + public string Difficulty; + + /// Whether a raid base has opened. Until it does it is on its way, and the position is the spot asked for or zero. + public bool Started; + public string BootId; public string WipeId; public long TakenMs; @@ -10109,6 +10466,1214 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- RaidableBases (PLAN_REDESIGNS §11.4, D202-D208, D386-D390) ---- + // + // nivex's RaidableBases pastes a stocked, guarded CopyPaste building for players to raid. The + // bridge reports its bases (the feed, the map's raids layer, the tally's Warlord count) from + // the plugin's own lifecycle hooks, which both editions raise alike, and spawns one for a + // run's step. PROTOCOL.md §20.12 is the wire; what the spikes found is §11.4.1–§11.4.4. + // + // * **Names are the plugin's (D386).** Difficulty names and levels come from its own + // `GetAllDifficulties` (and `GetRaidableModes` on the paid edition), profile and base + // names from the raids helper. None is written here. + // * **A run's base needs the raids helper to be the run's** (D204–D207, D387). The helper + // tags it at birth, applies the step's settings, places it at a spot, and despawns it + // alone. The base is then a world registry entry like a zone, under its own id, and + // `world.revert`, `world.owned` and `world.expired` treat it as they treat the rest. + // * **Without the helper a step still spawns one where the plugin can be asked**: the paid + // edition's own `SpawnRandomBase` call, at a difficulty, or the free edition's console + // command, which says nothing back. Neither base is the run's; it despawns on its own + // timer, and the reply says so. + // * **An unload of RaidableBases raises `Completed` for every live base, with no owner and + // no raiders (§11.4.2).** Such a completion is neither counted nor told. + + private const string WorldKindRaid = "raidbase"; + + /// A run's raid base id is this bridge's own, so a zone's rg- can never be mistaken for one. + private const string RaidIdPrefix = "raid-"; + + private const string RaidStartedKind = "raid.started"; + private const string RaidCompletedKind = "raid.completed"; + private const string RaidEndedKind = "raid.ended"; + private const string RaidPurchasedKind = "raid.purchased"; + private const string RaidLockedKind = "raid.locked"; + + /// The free edition's console command, as its config ships it. + private const string RbConsoleCommand = "rbevent"; + + /// How far a hook's position may be from a base this bridge knows and still be that base. + private const float RaidReach = 2f; + + /// The most raiders a completion names; the rest are counted in raiderCount. + private const int RaidRaidersMax = 32; + + /// A run's base not open this long after it was asked for never will be: its request is dropped and it expires. + private const long RaidPendingMs = 15L * 60L * 1000L; + + /// How long a spawn's key is remembered, as a payment's is. + private const long RaidStartMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One live base, as the hooks told it. Main thread only. + private sealed class LiveRaid + { + public UnityEngine.Vector3 At; + public int Level; + public string Difficulty; + public bool Pvp; + public string Base; + public long DespawnAtMs; + + /// The run's id for it (a world registry id), or null for a base no run made. + public string Id; + } + + private readonly List _raids = new List(); + + /// One spawn a step asked for, kept for its key so a retry spawns nothing. Persisted as it is. + private sealed class RaidStart + { + public string Key; + public string Id; + public string Route; + public string Difficulty; + public int Level; + public string Profile; + public string Base; + public float X; + public float Z; + public bool Spot; + public long AtMs; + } + + private Dictionary _raidStarts = new Dictionary(StringComparer.Ordinal); + + private int _raidSerial; + + private Timer _raidTimer; + + private Plugin RaidableBasesPlugin() + { + return plugins.Find("RaidableBases"); + } + + private Plugin RaidsHelperPlugin() + { + return plugins.Find("RunicGatewayRaids"); + } + + /// RaidableBases, its helper and CopyPaste are all in hello, so a load or unload of any says it again. + private bool IsRaidPlugin(Plugin plugin) + { + if (plugin == null || plugin == this) + return false; + + string name = plugin.Name; + return name == "RaidableBases" || name == "RunicGatewayRaids" || name == "CopyPaste"; + } + + /// missing, or the helper's own state: patched, unsupported or no-raidablebases. + private string RaidsHelperState() + { + Plugin helper = RaidsHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + private bool RaidsHelperPatched() + { + return RaidableBasesPlugin() != null && RaidsHelperState() == "patched"; + } + + /// + /// The installed copy's difficulties, each name once with its level, by level (D386). From + /// its public GetAllDifficulties, which lists one per profile; the free edition's is + /// Normal at 512. + /// + private List> RaidDifficulties() + { + var rows = new List>(); + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + return rows; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + if (rb.Call("GetAllDifficulties") is System.Collections.IEnumerable all) + { + foreach (object pair in all) + { + string name = TupleItem(pair, 1) as string; + object level = TupleItem(pair, 2); + + if (string.IsNullOrEmpty(name) || !(level is int) || !seen.Add(name)) + continue; + + rows.Add(new KeyValuePair(name, (int)level)); + } + } + } + catch (Exception ex) + { + Puts($"could not read RaidableBases' difficulties: {Flatten(ex.Message)}"); + } + + rows.Sort((a, b) => a.Value != b.Value ? a.Value.CompareTo(b.Value) : string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase)); + return rows; + } + + /// The name the installed copy gives a level, or null. + private string RaidDifficultyName(int level) + { + foreach (KeyValuePair d in RaidDifficulties()) + { + if (d.Value == level) + return d.Key; + } + + return null; + } + + /// + /// Item (from 1) of a value tuple, following Rest past the + /// seventh. Reflection over System.ValueTuple only: nothing of RaidableBases' own. + /// + private static object TupleItem(object tuple, int n) + { + object at = tuple; + + while (at != null && n > 7) + { + at = at.GetType().GetField("Rest")?.GetValue(at); + n -= 7; + } + + return at?.GetType().GetField("Item" + n)?.GetValue(at); + } + + /// Whether the paid edition's own SpawnRandomBase(string, Vector3, int, int, bool) is there to call (D348: looked for by name, never assumed from the edition). + private bool RaidSpawnCall(Plugin rb) + { + if (rb == null) + return false; + + try + { + foreach (System.Reflection.MethodInfo m in rb.GetType().GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)) + { + if (m.Name != "SpawnRandomBase") + continue; + + System.Reflection.ParameterInfo[] ps = m.GetParameters(); + + if (ps.Length == 5 && ps[0].ParameterType == typeof(string) && ps[1].ParameterType == typeof(UnityEngine.Vector3) + && ps[2].ParameterType == typeof(int) && ps[3].ParameterType == typeof(int) && ps[4].ParameterType == typeof(bool)) + return true; + } + } + catch (Exception) + { + } + + return false; + } + + /// The plugin's RaidableType.Manual as an int, bound by name (the paid edition moved the ints, §11.4.2). + private static int RaidManualType(Plugin rb) + { + try + { + Type type = rb.GetType().GetNestedType("RaidableType", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (type != null && type.IsEnum) + return Convert.ToInt32(Enum.Parse(type, "Manual")); + } + catch (Exception) + { + } + + return 1; + } + + /// + /// Hello's integrations.raidableBases: the edition, the difficulties, whether the + /// paid call is there, CopyPaste, and the raids helper with its grid and profiles. + /// + private Dictionary RaidableBasesBody() + { + Plugin rb = RaidableBasesPlugin(); + Dictionary body = IntegrationBody(rb); + Plugin helperPlugin = RaidsHelperPlugin(); + var helper = new Dictionary { { "state", RaidsHelperState() } }; + + if (helperPlugin != null) + { + helper["version"] = helperPlugin.Version.ToString(); + + if (helperPlugin.Call("StateReason") is string reason) + helper["reason"] = reason; + } + + body["helper"] = helper; + + if (rb == null) + return body; + + body["premium"] = rb.Call("IsPremium") is bool premium && premium; + body["spawnCall"] = RaidSpawnCall(rb); + body["copyPaste"] = IntegrationBody(plugins.Find("CopyPaste")); + + var difficulties = new List(); + + foreach (KeyValuePair d in RaidDifficulties()) + difficulties.Add(new Dictionary { { "name", d.Key }, { "level", d.Value } }); + + body["difficulties"] = difficulties; + + if (RaidsHelperPatched()) + { + body["gridReady"] = helperPlugin.Call("GridReady") is bool ready && ready; + + if (helperPlugin.Call("Profiles") is List profiles) + body["profiles"] = profiles; + } + + return body; + } + + // ---- the hooks ---- + + private void OnRaidableBaseStarted(UnityEngine.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) + { + MarkHook("OnRaidableBaseStarted"); + + // Its one-argument overload carries the plugin's own class; a framework that padded it + // into this signature hands over no position and no name. + if (location == UnityEngine.Vector3.zero && baseName == null) + return; + + string ours = RaidTagAt(location); + LiveRaid live = NearestRaid(location); + + if (live == null) + { + live = new LiveRaid(); + _raids.Add(live); + } + + live.At = location; + live.Level = level; + live.Difficulty = RaidDifficultyName(level); + live.Pvp = allowPvp; + live.Base = baseName; + live.DespawnAtMs = RaidMs(despawn); + live.Id = ours; + + WorldEntry entry = null; + + if (ours != null && _world.TryGetValue(ours, out entry)) + { + entry.X = location.x; + entry.Y = location.y; + entry.Z = location.z; + entry.Started = true; + entry.Name = baseName; + entry.Difficulty = live.Difficulty; + SaveWorld(); + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = RaidFrame(RaidStartedKind, live, entry); + frame["radius"] = Math.Round(protectionRadius, 1); + + if (live.DespawnAtMs > 0) + frame["despawnAt"] = live.DespawnAtMs; + + EmitFrame(frame); + } + + private void OnRaidableBaseCompleted(UnityEngine.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) + { + MarkHook("OnRaidableBaseCompleted"); + + var credited = new List(); + + if (raiders != null) + { + foreach (BasePlayer p in raiders) + { + if (p != null && !p.IsNpc && !credited.Contains(p)) + credited.Add(p); + } + } + + // The unload trap (§11.4.2): every live base "completes" with no owner and no raiders. + if (credited.Count == 0 && ownerId == 0UL) + return; + + // The plugin's own participants are its credit; a locked base completed by its owner + // alone, with nobody else listed, credits the owner. + if (credited.Count == 0 && owner != null && !owner.IsNpc) + credited.Add(owner); + + foreach (BasePlayer p in credited) + TallyFor(p).RaidsCompleted++; + + if (!WorldEventsLive()) + return; + + string ours = RaidTagAt(location); + LiveRaid live = NearestRaid(location) ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName, Id = ours }; + WorldEntry entry = null; + + if (ours != null) + _world.TryGetValue(ours, out entry); + + Dictionary frame = RaidFrame(RaidCompletedKind, live, entry); + var named = new List(); + + foreach (BasePlayer p in credited) + { + if (named.Count >= RaidRaidersMax) + break; + + named.Add(new Dictionary { { "steamId", SteamIdOf(p) }, { "name", p.displayName } }); + } + + frame["raiders"] = named; + frame["raiderCount"] = credited.Count; + + if (ownerId != 0UL) + { + frame["ownerId"] = ownerId.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (owner != null && !string.IsNullOrEmpty(owner.displayName)) + frame["ownerName"] = owner.displayName; + } + + EmitFrame(frame); + } + + private void OnRaidableBaseEnded(UnityEngine.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) + { + MarkHook("OnRaidableBaseEnded"); + + LiveRaid live = NearestRaid(location); + + if (live != null) + _raids.Remove(live); + + string ours = RaidTagAt(location) ?? live?.Id; + WorldEntry entry; + + // 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. + if (ours != null && _world.TryGetValue(ours, out entry)) + { + _world.Remove(ours); + SaveWorld(); + EmitRaidExpired(entry, null); + } + + 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)); + } + + private void OnRaidableBasePurchased(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, string baseName, + DateTime spawn, DateTime despawn) + { + MarkHook("OnRaidableBasePurchased"); + EmitRaidOwner(RaidPurchasedKind, userId, location, level, allowPvp, baseName); + } + + private void OnRaidableBaseLocked(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, float loadTime, + string baseName, DateTime spawn, DateTime despawn) + { + MarkHook("OnRaidableBaseLocked"); + EmitRaidOwner(RaidLockedKind, userId, location, level, allowPvp, baseName); + } + + /// A player bought a base, or an admin locked one to a player (paid edition, D390). + private void EmitRaidOwner(string kind, string userId, UnityEngine.Vector3 location, int level, bool allowPvp, string baseName) + { + if (!WorldEventsLive()) + return; + + var live = new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName }; + Dictionary frame = RaidFrame(kind, live, null); + ulong steam; + + if (TryParseSteamId(userId, out steam)) + { + frame["steamId"] = steam.ToString(System.Globalization.CultureInfo.InvariantCulture); + BasePlayer who = BasePlayer.FindByID(steam) ?? BasePlayer.FindSleeping(steam); + + if (who != null && !string.IsNullOrEmpty(who.displayName)) + frame["name"] = who.displayName; + } + + EmitFrame(frame); + } + + /// A raid.* event's common fields, and the run's when the base is one. + private Dictionary RaidFrame(string kind, LiveRaid live, WorldEntry entry) + { + Dictionary frame = Frame(kind, "event"); + PutPosition(frame, live.At); + frame["level"] = live.Level; + frame["pvp"] = live.Pvp; + + if (!string.IsNullOrEmpty(live.Difficulty)) + frame["difficulty"] = live.Difficulty; + + if (!string.IsNullOrEmpty(live.Base)) + frame["base"] = live.Base; + + if (entry != null) + { + frame["id"] = entry.Id; + frame["runId"] = entry.RunId; + frame["key"] = entry.Key; + } + + return frame; + } + + private void EmitRaidExpired(WorldEntry e, string reason) + { + Dictionary frame = Frame("world.expired", "event"); + frame["id"] = e.Id; + frame["what"] = WorldKindRaid; + + if (!string.IsNullOrEmpty(e.RunId)) + frame["runId"] = e.RunId; + + if (!string.IsNullOrEmpty(reason)) + frame["reason"] = reason; + + EmitFrame(frame); + } + + /// A hook's DateTime as unix milliseconds; the plugin keeps local times. + private static long RaidMs(DateTime at) + { + if (at == default(DateTime)) + return 0L; + + return ToUnixMs(at.Kind == DateTimeKind.Utc ? at : at.ToUniversalTime()); + } + + private LiveRaid NearestRaid(UnityEngine.Vector3 at) + { + LiveRaid best = null; + float bestDistance = RaidReach; + + foreach (LiveRaid r in _raids) + { + float d = Distance2D(r.At, at); + + if (d <= bestDistance) + { + bestDistance = d; + best = r; + } + } + + return best; + } + + /// The run's id for the base at a spot, from the helper, or null. + private string RaidTagAt(UnityEngine.Vector3 at) + { + Plugin helper = RaidsHelperPlugin(); + return helper != null ? helper.Call("TagAt", at) as string : null; + } + + /// + /// Main thread. The live bases, found again from the plugin's public GetAllEvents: + /// a hotload of the bridge hears no starts. Its tuple's first four items and its base + /// name are read (the plugin warns that the shape may change; a shape this cannot read + /// leaves the set to the hooks). + /// + private void SeedRaids(string why) + { + _raids.Clear(); + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + return; + + try + { + if (!(rb.Call("GetAllEvents") is System.Collections.IEnumerable all)) + return; + + foreach (object row in all) + { + if (!(TupleItem(row, 1) is UnityEngine.Vector3 at)) + continue; + + int level = TupleItem(row, 3) is int l ? l : 0; + _raids.Add(new LiveRaid + { + At = at, + Level = level, + Difficulty = RaidDifficultyName(level), + Pvp = TupleItem(row, 4) is bool pvp && pvp, + Base = TupleItem(row, 14) as string, + DespawnAtMs = TupleItem(row, 16) is DateTime d ? RaidMs(d) : 0L, + Id = RaidTagAt(at) + }); + } + } + catch (Exception ex) + { + Puts($"could not read RaidableBases' live bases ({why}): {Flatten(ex.Message)}"); + } + + if (_raids.Count > 0) + Puts($"map: {_raids.Count} raid base(s) after {why}"); + } + + /// The map's raids rows (§20.12): every live base, and the run's own among them. + private List RaidRows() + { + var rows = new List(); + + foreach (LiveRaid r in _raids) + { + Dictionary row = At(r.At); + row["level"] = r.Level; + row["pvp"] = r.Pvp; + + if (!string.IsNullOrEmpty(r.Difficulty)) + row["difficulty"] = r.Difficulty; + + if (!string.IsNullOrEmpty(r.Base)) + row["base"] = r.Base; + + if (r.DespawnAtMs > 0) + row["despawnAt"] = r.DespawnAtMs; + + WorldEntry entry; + + if (r.Id != null && _world.TryGetValue(r.Id, out entry)) + { + row["runId"] = entry.RunId; + row["key"] = entry.Key; + } + + rows.Add(row); + } + + return rows; + } + + /// Main thread. RaidableBases unloaded: every base went with it, and every run's base still on its way. + private void RaidableBasesGone() + { + _raids.Clear(); + var gone = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindRaid) + gone.Add(e); + } + + if (gone.Count == 0) + return; + + foreach (WorldEntry e in gone) + { + _world.Remove(e.Id); + EmitRaidExpired(e, "raidablebases-unloaded"); + } + + SaveWorld(); + _lastWorldSummary = $"RaidableBases unloaded: {gone.Count} run base(s) went with it"; + Puts($"world: {_lastWorldSummary}"); + } + + // ---- a run's base in the world registry ---- + + /// Main thread. Despawns a run's base through the helper. Whether it was there to take. + private bool DespawnRaid(WorldEntry e) + { + Plugin helper = RaidsHelperPlugin(); + + if (helper == null) + return false; + + string done = helper.Call("Despawn", e.Id) as string; + return done == "removed" || done == "cancelled"; + } + + /// + /// Main thread. Whether a run's base is still out there. A base does not survive a + /// restart (RaidableBases despawns every one as it unloads), so one from another boot is + /// gone. Without the helper the bridge cannot ask, and "I cannot ask" is not "it is gone". + /// + private bool RaidHeld(WorldEntry e) + { + if (!string.IsNullOrEmpty(e.BootId) && e.BootId != _bootId) + return false; + + Plugin helper = RaidsHelperPlugin(); + + if (helper == null || RaidableBasesPlugin() == null) + return RaidableBasesPlugin() != null; + + string live = helper.Call("LiveState", e.Id) as string; + return live == "open" || live == "queued"; + } + + private void EnsureRaidTimer() + { + if (_raidTimer == null) + _raidTimer = timer.Every(30f, CheckRaidsPending); + } + + /// + /// Main thread, every 30 s while a run's base is on its way. One that has not opened + /// fifteen minutes after it was asked for never will (its paste failed, or it found no + /// room): the request is dropped and the run's resource expires, with the reason. + /// + private void CheckRaidsPending() + { + List due = null; + bool waiting = false; + long now = NowMs(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindRaid || e.Started) + continue; + + waiting = true; + + if (now - e.TakenMs < RaidPendingMs) + continue; + + if (due == null) + due = new List(); + + due.Add(e); + } + + if (!waiting && _raidTimer != null) + { + _raidTimer.Destroy(); + _raidTimer = null; + return; + } + + if (due == null) + return; + + foreach (WorldEntry e in due) + { + _world.Remove(e.Id); + DespawnRaid(e); + EmitRaidExpired(e, "never-started"); + } + + SaveWorld(); + _lastWorldSummary = $"{due.Count} run raid base(s) never opened and were dropped"; + Puts($"world: {_lastWorldSummary}"); + } + + // ---- the spawn's keys ---- + + private string RaidStartsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "raid-starts.json"); + } + + private void SaveRaidStarts() + { + WriteDataFile(RaidStartsPath(), new List(_raidStarts.Values), "RaidableBases spawns"); + } + + private void LoadRaidStarts() + { + _raidStarts = new Dictionary(StringComparer.Ordinal); + List starts = ReadDataFile(RaidStartsPath(), "RaidableBases spawns"); + + if (starts != null) + foreach (RaidStart s in starts) + if (s != null && !string.IsNullOrEmpty(s.Key)) + _raidStarts[s.Key] = s; + + ForgetOldRaidStarts(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindRaid && !e.Started) + { + EnsureRaidTimer(); + break; + } + } + } + + private void ForgetOldRaidStarts() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _raidStarts) + if (now >= kv.Value.AtMs + RaidStartMemoryMs) + stale.Add(kv.Key); + + foreach (string key in stale) + _raidStarts.Remove(key); + + if (stale.Count > 0) + SaveRaidStarts(); + } + + // ---- raid.spawn and raid.check ---- + + /// + /// Main thread. One base for a run's step (§20.12). With the raids helper it is the run's: + /// a world registry entry, despawned at teardown. Without it, the plugin's own call or + /// console command spawns one that despawns on its own timer. A key already answered is + /// answered again with its first spawn and spawns nothing. + /// + private void HandleRaidSpawn(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax || string.IsNullOrEmpty(runId)) + { + EmitRaidError(reqId, "malformed", "a spawn is made for a run, under a key of at most 128 characters", null); + return; + } + + ForgetOldRaidStarts(); + + RaidStart seen; + + if (_raidStarts.TryGetValue(key, out seen)) + { + EmitRaidQueued(reqId, seen, true, null); + return; + } + + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + { + EmitRaidError(reqId, "rb-missing", "RaidableBases is not loaded on this server", null); + return; + } + + if (plugins.Find("CopyPaste") == null) + { + EmitRaidError(reqId, "copypaste-missing", "CopyPaste is not loaded on this server, and RaidableBases pastes every base with it", null); + return; + } + + string difficulty = StringField(cmd, "difficulty"); + string profile = StringField(cmd, "profile"); + string baseName = StringField(cmd, "base"); + bool spot = !string.IsNullOrEmpty(StringField(cmd, "monument")) || DoubleField(cmd, "x").HasValue || DoubleField(cmd, "z").HasValue; + bool settings = cmd.ContainsKey("pvp") || cmd.ContainsKey("npcs") || cmd.ContainsKey("npcCount") || cmd.ContainsKey("despawnMinutes") || cmd.ContainsKey("lock"); + bool needsHelper = spot || settings || !string.IsNullOrEmpty(profile) || !string.IsNullOrEmpty(baseName); + + // The difficulty, by the plugin's own names (D386). + int? level = null; + + if (!string.IsNullOrEmpty(difficulty)) + { + foreach (KeyValuePair d in RaidDifficulties()) + { + if (string.Equals(d.Key, difficulty, StringComparison.OrdinalIgnoreCase)) + { + difficulty = d.Key; + level = d.Value; + break; + } + } + + if (!level.HasValue) + { + EmitRaidError(reqId, "bad-difficulty", $"RaidableBases has no difficulty '{difficulty}' on this server", null); + return; + } + } + + if (RaidsHelperPatched()) + { + SpawnWithHelper(cmd, reqId, key, runId, difficulty, level, profile, baseName, spot); + return; + } + + if (needsHelper) + { + string state = RaidsHelperState(); + EmitRaidError(reqId, state == "missing" ? "helper-needed" : "helper-unsupported", + state == "missing" + ? "a spot, a profile, a base or settings for one spawn need RunicGatewayRaids.cs beside the bridge, and it is not installed" + : $"RunicGatewayRaids.cs could not patch this RaidableBases ({state}), so a spot, a profile, a base or settings cannot be used", + null); + return; + } + + SpawnWithoutHelper(rb, reqId, key, difficulty, level); + } + + private void SpawnWithHelper(Dictionary cmd, string reqId, string key, string runId, string difficulty, int? level, + string profile, string baseName, bool spot) + { + Plugin helper = RaidsHelperPlugin(); + string id = $"{RaidIdPrefix}{NowMs()}-{++_raidSerial}"; + var req = new Dictionary { { "id", id } }; + + if (difficulty != null) + req["difficulty"] = difficulty; + + if (level.HasValue) + req["level"] = level.Value; + + if (!string.IsNullOrEmpty(profile)) + req["profile"] = profile; + + if (!string.IsNullOrEmpty(baseName)) + req["base"] = baseName; + + if (spot) + { + UnityEngine.Vector3 at; + string reason, refusal; + + // A spot is a world call's location; its height is the step's own (below), not `y`. + var where = new Dictionary(cmd); + where.Remove("y"); + + if (!ResolveWorldPoint(where, out at, out reason, out refusal)) + { + EmitRaidError(reqId, reason, refusal, null); + return; + } + + req["x"] = at.x; + req["z"] = at.z; + + double? adjust = DoubleField(cmd, "heightAdjust"); + double? height = DoubleField(cmd, "height"); + + if (adjust.HasValue && height.HasValue) + { + EmitRaidError(reqId, "malformed", "a spot takes a height adjustment or an exact height, not both", null); + return; + } + + if (height.HasValue) + req["height"] = height.Value; + else if (adjust.HasValue) + req["heightAdjust"] = adjust.Value; + } + + foreach (string flag in new[] { "pvp", "npcs", "lock" }) + { + bool value; + + if (cmd.ContainsKey(flag) && TryParseFlag(StringField(cmd, flag), out value)) + req[flag] = value; + } + + int npcCount = IntField(cmd, "npcCount"); + int despawn = IntField(cmd, "despawnMinutes"); + + if (cmd.ContainsKey("npcCount")) + { + if (npcCount < 0 || npcCount > WorldHardMaxNpcs) + { + EmitRaidError(reqId, "out-of-range", $"a raid base takes 0 to {WorldHardMaxNpcs} NPCs, not {npcCount}", null); + return; + } + + req["npcCount"] = npcCount; + } + + if (cmd.ContainsKey("despawnMinutes")) + { + if (despawn < 1 || despawn > 24 * 60) + { + EmitRaidError(reqId, "out-of-range", $"a raid base despawns after 1 to 1440 minutes, not {despawn}", null); + return; + } + + req["despawnMinutes"] = despawn; + } + + var answer = helper.Call("Spawn", req) as Dictionary; + + if (answer == null) + { + EmitRaidError(reqId, "refused", "the raids helper did not answer", null); + return; + } + + List warnings = answer.TryGetValue("warnings", out object w) ? w as List : null; + + if (!(answer.TryGetValue("ok", out object ok) && ok is bool done && done)) + { + EmitRaidError(reqId, StringField(answer, "reason") ?? "refused", StringField(answer, "message") ?? "the raids helper refused", warnings); + return; + } + + double? x = DoubleField(answer, "x"); + double? z = DoubleField(answer, "z"); + + var start = new RaidStart + { + Key = key, + Id = id, + Route = StringField(answer, "route") == "spot" ? "spot" : "pick", + Difficulty = StringField(answer, "difficulty"), + Level = IntField(answer, "level"), + Profile = StringField(answer, "profile"), + Base = StringField(answer, "base"), + X = x.HasValue ? (float)x.Value : 0f, + Z = z.HasValue ? (float)z.Value : 0f, + Spot = x.HasValue, + AtMs = NowMs() + }; + + _world[id] = new WorldEntry + { + Id = id, + Kind = WorldKindRaid, + RunId = runId, + Key = key, + X = start.X, + Y = 0f, + Z = start.Z, + Name = start.Base, + Difficulty = start.Difficulty, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }; + + _raidStarts[key] = start; + SaveWorld(); + SaveRaidStarts(); + EnsureRaidTimer(); + + _lastWorldSummary = $"asked RaidableBases for a {start.Difficulty ?? "?"} base ({start.Route}) for run {runId} as {id}"; + Puts($"world: {_lastWorldSummary}"); + EmitRaidQueued(reqId, start, false, warnings); + } + + /// No helper: the paid edition's own call at a difficulty, else the free edition's console command. Neither base is the run's. + private void SpawnWithoutHelper(Plugin rb, string reqId, string key, string difficulty, int? level) + { + var start = new RaidStart { Key = key, Difficulty = difficulty, Level = level ?? 0, AtMs = NowMs() }; + + if (RaidSpawnCall(rb)) + { + if (!level.HasValue) + { + List> all = RaidDifficulties(); + + if (all.Count == 0) + { + EmitRaidError(reqId, "bad-difficulty", "RaidableBases lists no difficulty on this server", null); + return; + } + + KeyValuePair picked = all[UnityEngine.Random.Range(0, all.Count)]; + start.Difficulty = picked.Key; + start.Level = picked.Value; + } + + object result = rb.Call("SpawnRandomBase", null, UnityEngine.Vector3.zero, start.Level, RaidManualType(rb), true); + + if (!(result is bool queued) || !queued) + { + EmitRaidError(reqId, "refused", $"RaidableBases did not queue a base: {(result as string ?? "no reason given")}", null); + return; + } + + start.Route = "plugin"; + } + else + { + if (!_consolePlugins) + { + EmitRaidError(reqId, "console-unavailable", + "this framework does not run another plugin's console command from code, so this edition of RaidableBases cannot be asked for a base without RunicGatewayRaids.cs (D363, Rust-Plugins#34)", + null); + return; + } + + try + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), RbConsoleCommand); + } + catch (Exception ex) + { + EmitRaidError(reqId, "refused", $"RaidableBases' console command threw: {Flatten(ex.Message)}", null); + return; + } + + start.Route = "console"; + start.Difficulty = null; + } + + _raidStarts[key] = start; + SaveRaidStarts(); + + _lastWorldSummary = $"asked RaidableBases for a base by its {start.Route} (not the run's: it despawns on its own timer)"; + Puts($"world: {_lastWorldSummary}"); + EmitRaidQueued(reqId, start, false, null); + } + + /// Main thread. A spot's checks, spawning nothing (D208): the editor's advice. + private void HandleRaidCheck(Dictionary cmd, string reqId) + { + if (!WorldReady(reqId)) + return; + + if (RaidableBasesPlugin() == null) + { + EmitRaidError(reqId, "rb-missing", "RaidableBases is not loaded on this server", null); + return; + } + + if (!RaidsHelperPatched()) + { + EmitRaidError(reqId, RaidsHelperState() == "missing" ? "helper-needed" : "helper-unsupported", + "checking a spot needs RunicGatewayRaids.cs beside the bridge, patched", null); + return; + } + + UnityEngine.Vector3 at; + string reason, refusal; + var where = new Dictionary(cmd); + where.Remove("y"); + + if (!ResolveWorldPoint(where, out at, out reason, out refusal)) + { + EmitRaidError(reqId, reason, refusal, null); + return; + } + + var req = new Dictionary { { "x", at.x }, { "z", at.z } }; + + foreach (string field in new[] { "difficulty", "profile", "base" }) + { + string value = StringField(cmd, field); + + if (!string.IsNullOrEmpty(value)) + req[field] = value; + } + + var answer = RaidsHelperPlugin().Call("Check", req) as Dictionary; + Dictionary reply = Frame("raid.checked", "reply"); + + if (answer == null) + { + reply["ok"] = false; + reply["reason"] = "refused"; + reply["message"] = "the raids helper did not answer"; + } + else + { + reply["ok"] = answer.TryGetValue("ok", out object ok) && ok is bool fine && fine; + reply["warnings"] = answer.TryGetValue("warnings", out object w) && w is List list ? list : new List(); + + if (StringField(answer, "reason") != null) + reply["reason"] = StringField(answer, "reason"); + + if (StringField(answer, "message") != null) + reply["message"] = StringField(answer, "message"); + + if (StringField(answer, "base") != null) + reply["base"] = StringField(answer, "base"); + } + + PutPosition(reply, at); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitRaidQueued(string reqId, RaidStart start, bool repeat, List warnings) + { + Dictionary frame = Frame("raid.queued", "reply"); + frame["key"] = start.Key; + frame["route"] = start.Route; + frame["repeat"] = repeat; + + // The run's base, despawned at teardown, only by the helper's routes (D204). + frame["despawns"] = start.Id != null ? "teardown" : "own-timer"; + + if (start.Id != null) + frame["id"] = start.Id; + + if (!string.IsNullOrEmpty(start.Difficulty)) + { + frame["difficulty"] = start.Difficulty; + frame["level"] = start.Level; + } + + if (!string.IsNullOrEmpty(start.Profile)) + frame["profile"] = start.Profile; + + if (!string.IsNullOrEmpty(start.Base)) + frame["base"] = start.Base; + + if (start.Spot) + PutPosition(frame, new UnityEngine.Vector3(start.X, 0f, start.Z)); + + frame["warnings"] = warnings ?? new List(); + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + private void EmitRaidError(string reqId, string reason, string message, List warnings) + { + Dictionary frame = Frame("raid.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (warnings != null && warnings.Count > 0) + frame["warnings"] = warnings; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastWorldSummary = $"RaidableBases spawn refused ({reason}): {message}"; + EmitFrame(frame); + } + // ---- ZLevels Remastered (PLAN_REDESIGNS §14.3.6, D369, D374) ---- // // Hello says whether it is loaded, and that is all. No level is read or sent: the @@ -11024,9 +12589,12 @@ namespace Oxide.Plugins // Not on record. That is either something a wipe or a prune already took — // gone, which is a success — or something this bridge never made. Only a // thing that is actually THERE can be the second, and only that is refused. - bool exists = id.StartsWith("rg-", StringComparison.Ordinal) - ? zm != null && zm.Call("CheckZoneID", id) != null - : FindEntity(id) != null; + // A raid base's id is this bridge's own (§20.12), so one off the record is gone. + bool exists = id.StartsWith(RaidIdPrefix, StringComparison.Ordinal) + ? false + : id.StartsWith("rg-", StringComparison.Ordinal) + ? zm != null && zm.Call("CheckZoneID", id) != null + : FindEntity(id) != null; (exists ? refused : gone).Add(id); } @@ -11064,6 +12632,13 @@ namespace Oxide.Plugins _zoneQuietUntil.Remove(e.Id); } + else if (e.Kind == WorldKindRaid) + { + // Off the record BEFORE the despawn: its `Despawn()` raises the plugin's hooks, + // and an ending still on the record would be told as an expiry (§20.12). + _world.Remove(e.Id); + wasThere = DespawnRaid(e); + } else { wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : KillEntity(e.Id); @@ -11118,6 +12693,8 @@ namespace Oxide.Plugins if (e.Kind == WorldKindZone) held = zm == null || zm.Call("CheckZoneID", e.Id) != null; + else if (e.Kind == WorldKindRaid) + held = RaidHeld(e); else held = FindEntity(e.Id) != null; @@ -11177,6 +12754,18 @@ namespace Oxide.Plugins row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs()); } + if (e.Kind == WorldKindRaid) + { + // The base's own name and its difficulty, both the plugin's (D386). + if (!string.IsNullOrEmpty(e.Name)) + row["name"] = e.Name; + + if (!string.IsNullOrEmpty(e.Difficulty)) + row["difficulty"] = e.Difficulty; + + row["started"] = e.Started; + } + return row; } @@ -11412,7 +13001,7 @@ namespace Oxide.Plugins [ConsoleCommand("rg.world")] private void CmdWorld(ConsoleSystem.Arg arg) { - int zones = 0, crates = 0, npcs = 0; + int zones = 0, crates = 0, npcs = 0, raidBases = 0; // Guarded Crate's `stop all` ends events without a hook; count what is really running. SeedGuardedCrates(null); @@ -11423,10 +13012,14 @@ namespace Oxide.Plugins zones++; else if (e.Kind == WorldKindNpc) npcs++; + else if (e.Kind == WorldKindRaid) + raidBases++; else crates++; } + Plugin raidHelper = RaidsHelperPlugin(); + arg.ReplyWith( $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + @@ -11436,6 +13029,9 @@ namespace Oxide.Plugins $"guardedCrate={(GuardedCratePlugin() != null)} npcSpawn={(NpcSpawnPlugin() != null)} guarded={_guardedCrates.Count} gcPlaces={_gcPlacements.Count} " + $"gcEvents={(GuardedCrateEventNames() != null ? string.Join("|", GuardedCrateEventNames().ToArray()) : "none")} gcCommand={GuardedCrateCommand()} gcListReadable={_gcListReadable} " + $"zLevels={(ZLevelsPlugin() != null ? ZLevelsPlugin().Version.ToString() : "absent")} " + + $"raidableBases={(RaidableBasesPlugin() != null ? RaidableBasesPlugin().Version.ToString() : "absent")} raidsHelper={RaidsHelperState()} " + + $"raidGrid={(raidHelper != null && raidHelper.Call("GridReady") is bool gridReady ? (gridReady ? "ready" : "building") : "unknown")} " + + $"raids={_raids.Count} raidBases={raidBases} " + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + $"commandKeys={_commandRecords.Count} commandsRunning={_commandJobs.Count} " + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + @@ -15053,7 +16649,9 @@ namespace Oxide.Plugins // Protocol 14 (§14.3.5): whether a step may place a guarded crate here, and which. { "guardedCrate", GuardedCrateBody() }, // Protocol 14 (§14.3.6): whether its XP boosts are here for a step to lend. - { "zLevels", IntegrationBody(ZLevelsPlugin()) } + { "zLevels", IntegrationBody(ZLevelsPlugin()) }, + // Protocol 14 (§20.12): its edition, its difficulties, the raids helper and its profiles. + { "raidableBases", RaidableBasesBody() } }; } @@ -16314,6 +17912,10 @@ namespace Oxide.Plugins { Dictionary row; + // A run's raid base is drawn on the raids layer, with its run (§20.12). + if (w.Kind == WorldKindRaid) + continue; + if (w.Kind == WorldKindZone) { row = At(new UnityEngine.Vector3(w.X, 0f, w.Z)); @@ -16465,6 +18067,9 @@ namespace Oxide.Plugins reply["players"] = players; reply["bases"] = bases; reply["pvp"] = PvpZoneRows(); + + // RaidableBases' live bases (§20.12): a layer of their own with its own audience (D114). + reply["raids"] = RaidRows(); reply["tookMs"] = clock.ElapsedMilliseconds; if (sleepersTruncated) diff --git a/overlay/oxide/plugins/RunicGatewayRaids.cs b/overlay/oxide/plugins/RunicGatewayRaids.cs new file mode 100644 index 0000000..998eaa5 --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayRaids.cs @@ -0,0 +1,1697 @@ +// 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}" : "")); + } + } +}