All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 11s
PLAN_REDESIGNS §11.4 (D202-D208, D386-D390), protocol 14. Spec: docs rust-link/PROTOCOL.md §20.12. - RunicGatewayRaids.cs, the raids helper (D168, D204-D207, D387), shipped beside the bridge and released with it. Harmony patches on RaidableBases' spawn queue Add, OpenEvent and each base's BypassUseOwners(): a run's base is tagged at birth, its PvP/NPCs/despawn/lock set on a member-by-member copy of its profile (Setup, NPC and DespawnOptions copied again, so the shared profile is never touched), PvP also set on the base after OpenEvent; a spot checked (own look, then the plugin's area check with its reason, rocks and cliffs a warning; then the ocean level, D388, D389) and pasted; one base despawned; profiles reloaded under watch. Both editions, every member found by name. rgr.status. - Hello's integrations.raidableBases: edition, the paid spawn call (looked for by name, D348), CopyPaste, the plugin's own difficulties (D386), the helper, and with it the grid and the profiles. - raid.started / raid.completed / raid.ended / raid.purchased / raid.locked from the lifecycle hooks; the unload trap's completions (no owner, no raiders) are neither sent nor counted. The tally's raidsCompleted (Warlord). - map.live's raids layer, kept by the hooks and seeded from GetAllEvents(). - raid.spawn: with the helper the base is a world registry entry, so world.revert / world.owned / world.expired serve it (never-started after 15 min, raidablebases-unloaded); without it the paid call or rbevent, not the run's. raid.check for the editor's advice (D208, D391). - RaidableBases' profiles in the configuration editor (D205): a virtual @data/RaidableBases/Profiles/ path, all-profiles-or-none writes, reloaded by the helper and rolled back when a profile does not load. Compiled on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262) and driven on both rigs with the paid 3.2.2 and the tier-3 pack through a throwaway plugin: the pick and spot routes with every setting, a repeat key, revert, expiry, and a good and a broken profile edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
1698 lines
68 KiB
C#
1698 lines
68 KiB
C#
// 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;
|
||
|
||
/// <summary>The radius a spot is checked at when the profile gives none: the plugin's own `rbe` uses 50 m.</summary>
|
||
private const float DefaultSpotRadius = 50f;
|
||
|
||
/// <summary>A spot this far under the ocean surface is "in water" (§11.4.2: the water map reads −500 on the rigs).</summary>
|
||
private const float WaterMargin = 0.5f;
|
||
|
||
/// <summary>How long an ended base's tag is still answered by <see cref="TagAt"/>: its last hooks follow its despawn.</summary>
|
||
private const float EndedTagSeconds = 120f;
|
||
|
||
/// <summary>The instance the static patches report to. Null while unloaded.</summary>
|
||
private static RunicGatewayRaids _instance;
|
||
|
||
private Harmony _harmony;
|
||
private readonly List<MethodBase> _patched = new List<MethodBase>();
|
||
|
||
/// <summary><c>patched</c>, <c>unsupported</c> (RaidableBases is here but not the shape this reaches), or <c>no-raidablebases</c>.</summary>
|
||
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;
|
||
|
||
/// <summary>Paid: <c>SpawnRandomBase(string, Vector3, int, int, bool)</c>. Null on the free edition.</summary>
|
||
private MethodInfo _spawnApi;
|
||
|
||
/// <summary>Free: <c>SpawnRandomBase(RaidableType, string, bool, BasePlayer, IPlayer, bool)</c>. Null on the paid edition.</summary>
|
||
private MethodInfo _spawnTyped;
|
||
|
||
private ConstructorInfo _spawnsCtor;
|
||
|
||
// ── The run's bases ───────────────────────────────────────────────────
|
||
|
||
/// <summary>One base a run asked for: its settings, and what it became.</summary>
|
||
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;
|
||
|
||
/// <summary>The plugin's request object, until <c>OpenEvent</c> turns it into a base.</summary>
|
||
public object Request;
|
||
|
||
/// <summary>The base, once open.</summary>
|
||
public object Raid;
|
||
|
||
public Vector3 Location;
|
||
public bool Cancelled;
|
||
public float EndedAt = -1f;
|
||
}
|
||
|
||
/// <summary>Requests this helper made, by the plugin's request object. Main thread.</summary>
|
||
private static readonly Dictionary<object, Tag> Requested = new Dictionary<object, Tag>();
|
||
|
||
/// <summary>Open bases a run owns, by the base. Read by the lock patch.</summary>
|
||
private static readonly Dictionary<object, Tag> Tagged = new Dictionary<object, Tag>();
|
||
|
||
private readonly Dictionary<string, Tag> _byId = new Dictionary<string, Tag>(StringComparer.Ordinal);
|
||
|
||
/// <summary>Set for the length of this helper's own call into the plugin's pick, for the queue patch.</summary>
|
||
private static Tag _capturing;
|
||
|
||
/// <summary>Profile reloads this helper finished, for the bridge's watch.</summary>
|
||
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 ─────────────────────────────────────────
|
||
|
||
/// <summary><c>patched</c>, <c>unsupported</c> or <c>no-raidablebases</c>.</summary>
|
||
private string State() => _state;
|
||
|
||
/// <summary>Why the state is not <c>patched</c>, or null.</summary>
|
||
private string StateReason() => _reason;
|
||
|
||
/// <summary>The helper's own version, for hello.</summary>
|
||
private string HelperVersion() => Version.ToString();
|
||
|
||
/// <summary>Whether the plugin's spawn grid is built. Until it is, the plugin refuses every spawn and every profile reload.</summary>
|
||
private bool GridReady()
|
||
{
|
||
if (_state != "patched")
|
||
return false;
|
||
|
||
try
|
||
{
|
||
return !(bool)_gridLoading.Invoke(_rb, null);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Every profile the plugin loaded, as hello carries them: <c>name</c>, <c>difficulty</c>,
|
||
/// <c>level</c>, <c>enabled</c>, <c>pvp</c>, <c>bases</c> (only those whose CopyPaste file
|
||
/// exists), and <c>seabed</c> and <c>float</c> (whether it is built for water, D389).
|
||
/// </summary>
|
||
private List<object> Profiles()
|
||
{
|
||
var rows = new List<object>();
|
||
|
||
if (_state != "patched")
|
||
return rows;
|
||
|
||
foreach (KeyValuePair<string, object> kv in ProfileTable())
|
||
{
|
||
object options = F(kv.Value, "Options");
|
||
|
||
if (options == null)
|
||
continue;
|
||
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Asks the plugin for one base for a run. <paramref name="req"/> carries
|
||
/// <c>id</c> (the bridge's), <c>difficulty</c> (a name) and <c>level</c>, <c>profile</c>,
|
||
/// <c>base</c>, and for a spot <c>x</c>, <c>z</c> with <c>heightAdjust</c> or <c>height</c>;
|
||
/// and the settings <c>pvp</c>, <c>npcs</c>, <c>npcCount</c>, <c>despawnMinutes</c>,
|
||
/// <c>lock</c>. Answers <c>ok</c> with what was asked for, or <c>reason</c> and <c>message</c>.
|
||
/// </summary>
|
||
private Dictionary<string, object> Spawn(Dictionary<string, object> 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<string, object> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The checks a spot must pass (D208, D388, D389), without spawning:
|
||
/// <c>x</c>, <c>z</c>, and the <c>difficulty</c>, <c>profile</c> or <c>base</c> whose
|
||
/// radius and water settings apply. Answers <c>ok</c>, the refusal's <c>reason</c> and
|
||
/// <c>message</c>, and <c>warnings</c>.
|
||
/// </summary>
|
||
private Dictionary<string, object> Check(Dictionary<string, object> 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<object>();
|
||
Vector3 at;
|
||
|
||
if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal))
|
||
{
|
||
Dictionary<string, object> no = Refuse(reason, refusal);
|
||
no["warnings"] = warnings;
|
||
return no;
|
||
}
|
||
|
||
return new Dictionary<string, object> { { "ok", true }, { "warnings", warnings }, { "base", choice.BaseName } };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Takes one base away: <c>removed</c> (it was open and its own <c>Despawn()</c>
|
||
/// ran), <c>cancelled</c> (it was still on its way and will be taken away the moment it
|
||
/// opens), or <c>gone</c>.
|
||
/// </summary>
|
||
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";
|
||
}
|
||
|
||
/// <summary><c>open</c>, <c>queued</c> (asked for and not open yet) or <c>gone</c>.</summary>
|
||
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";
|
||
}
|
||
|
||
/// <summary>The id of the run's base at this spot, or null: how the bridge joins a lifecycle hook to a run.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Has the plugin reload its profiles, as <c>rb.reloadprofiles</c> does.
|
||
/// False when it would refuse: its grid is still building, or a reload is running.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>How many profile reloads have finished since this helper loaded.</summary>
|
||
private int ReloadsFinished() => _reloadsFinished;
|
||
|
||
/// <summary><c>loaded</c>, <c>error</c> (the plugin could not read it) or <c>absent</c>.</summary>
|
||
private string ProfileState(string name)
|
||
{
|
||
if (_state != "patched" || string.IsNullOrEmpty(name))
|
||
return "absent";
|
||
|
||
foreach (KeyValuePair<string, object> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private Choice Choose(Dictionary<string, object> 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<KeyValuePair<string, object>> table = ProfileTable();
|
||
|
||
if (!string.IsNullOrEmpty(wantBase))
|
||
{
|
||
foreach (KeyValuePair<string, object> 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<string, object> kv in table)
|
||
{
|
||
if (!kv.Key.Equals(wantProfile, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
object options = F(kv.Value, "Options");
|
||
List<string> 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<Choice>();
|
||
|
||
foreach (KeyValuePair<string, object> 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;
|
||
}
|
||
|
||
/// <summary>The plugin's profile table, name first. Empty when it cannot be read.</summary>
|
||
private List<KeyValuePair<string, object>> ProfileTable()
|
||
{
|
||
var rows = new List<KeyValuePair<string, object>>();
|
||
|
||
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<string, object>(name, kv.Value));
|
||
}
|
||
|
||
rows.Sort((a, b) => string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase));
|
||
return rows;
|
||
}
|
||
|
||
/// <summary>A profile's bases that have a CopyPaste file: the profile's own name, then its additional bases.</summary>
|
||
private List<string> BasesOf(string profileName, object options)
|
||
{
|
||
var bases = new List<string>();
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>The paid edition's difficulty name; the free edition has one, "Normal".</summary>
|
||
private static string ModeOf(object options)
|
||
{
|
||
return F(options, "Mode") is string mode && !string.IsNullOrEmpty(mode) ? mode : "Normal";
|
||
}
|
||
|
||
/// <summary>The paid edition's difficulty level; the free edition's hooks always carry 512.</summary>
|
||
private static int LevelOf(object options)
|
||
{
|
||
return F(options, "Level") is int level ? level : 512;
|
||
}
|
||
|
||
// ── Spawning ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private Dictionary<string, object> 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<string, object> { { "ok", true }, { "route", "pick" } };
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private Dictionary<string, object> SpawnAt(Dictionary<string, object> req, Tag tag, Choice choice)
|
||
{
|
||
var warnings = new List<object>();
|
||
Vector3 at;
|
||
string reason;
|
||
string refusal;
|
||
|
||
if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal))
|
||
{
|
||
Dictionary<string, object> 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<string, object> { { "ok", true }, { "route", "spot" }, { "warnings", warnings } };
|
||
ok["x"] = Math.Round(at.x, 1);
|
||
ok["z"] = Math.Round(at.z, 1);
|
||
return ok;
|
||
}
|
||
|
||
/// <summary>The radii the plugin's own queue sets on a request, from the copied profile.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>A spawn's settings, on its own copy of the profile's options.</summary>
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Takes a request off the plugin's queue, when it is still on it.</summary>
|
||
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) ───────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Main thread. Whether a base may go at the request's <c>x</c>, <c>z</c>. Refuses for a
|
||
/// player, a building, a deployable, a raid base or water; rocks and cliffs are warnings.
|
||
/// </summary>
|
||
private bool CheckSpot(Dictionary<string, object> req, Choice choice, List<object> 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;
|
||
}
|
||
|
||
/// <summary>Players, buildings, deployables and raid bases within the radius, by this helper's own look.</summary>
|
||
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<BaseEntity>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>The plugin's own area check, bound by parameter count (§11.4.2), with its reason.</summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>The plugin's debug line ("1x - Rock is too large: x y z") down to its words.</summary>
|
||
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<MethodInfo> 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. ──
|
||
|
||
/// <summary>Prefix on <c>QueueController.Add(RandomBase)</c>: tags the request our own pick made.</summary>
|
||
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.
|
||
}
|
||
}
|
||
|
||
/// <summary>Postfix on <c>OpenEvent(RandomBase)</c>: the request has become a base.</summary>
|
||
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.
|
||
}
|
||
}
|
||
|
||
/// <summary>Prefix on <c>RaidableBase.BypassUseOwners()</c>: a run's base answers from its own lock (D206).</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>The pick route's settings, on a copy of the request's profile.</summary>
|
||
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<BasePlayer> raiders, List<BasePlayer> intruders, List<BaseEntity> 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<string>();
|
||
|
||
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);
|
||
|
||
/// <summary>
|
||
/// 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 (<c>Setup</c>, <c>NPC</c>, <c>DespawnOptions</c>)
|
||
/// are copied again, which is what the plugin's own shallow <c>Clone()</c> does not do and
|
||
/// why its height leaked into the shared profile (§11.4.4). Returns the profile's copy;
|
||
/// its <c>Options</c> is the options' copy.
|
||
/// </summary>
|
||
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<string, object> Refuse(string reason, string message)
|
||
{
|
||
return new Dictionary<string, object> { { "ok", false }, { "reason", reason }, { "message", message } };
|
||
}
|
||
|
||
private static string Text(Dictionary<string, object> req, string key)
|
||
{
|
||
return req != null && req.TryGetValue(key, out object value) && value != null ? value.ToString() : null;
|
||
}
|
||
|
||
private static double? Number(Dictionary<string, object> 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<string, object> req, string key)
|
||
{
|
||
double? value = Number(req, key);
|
||
return value.HasValue ? (int)Math.Round(value.Value) : (int?)null;
|
||
}
|
||
|
||
private static bool? BoolOf(Dictionary<string, object> 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}" : ""));
|
||
}
|
||
}
|
||
}
|