feat(bridge): a zone's ZoneManager options, its messages, and a ZoneDomes dome (PLAN_REDESIGNS §3) #21

Merged
whitlocktech merged 2 commits from feat/zones-domes into edge 2026-09-29 11:28:52 -05:00
4 changed files with 890 additions and 9 deletions

View File

@@ -29,8 +29,8 @@
# exact commit being released.
#
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, `RunicGatewayZones.cs`
# and `manifest.json` at its top. Every `.cs` in it is listed in the
# `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
# 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
@@ -40,7 +40,8 @@
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The
# 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`, the ZoneManager helper) keeps its own
# D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs`
# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own
# version: it does one job, changes rarely, and the bridge reads its version at
# hello to know which job it does.
#
@@ -80,7 +81,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
HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs
SEED_VERSION: "0.1.0"
INSTALLER_REPO: RunicGateway/installer

View File

@@ -36,6 +36,7 @@ unauthenticated command channel on the network.
```
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
overlay/oxide/plugins/RunicGatewayZones.cs → <server>/oxide/plugins/RunicGatewayZones.cs
overlay/oxide/plugins/RunicGatewayDomes.cs → <server>/oxide/plugins/RunicGatewayDomes.cs
```
`RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager
@@ -44,6 +45,13 @@ somebody already standing there holds nobody. The helper patches that with Harmo
bridge still loads, scores its own zones by position, and reports the helper missing at hello. `rgz.status` in the
server console says whether it patched.
`RunicGatewayDomes.cs` is the **ZoneDomes helper** (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D194). ZoneDomes'
API ends by telling the player who asked how it went, and throws when a plugin calls it with no player; and at its
own start it drops the dome of every zone that does not exist yet, which is every zone the bridge re-creates. The
helper patches the first with Harmony and tells the bridge when ZoneDomes has started, so the bridge can put its
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.
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
```json

View File

@@ -244,7 +244,8 @@ namespace Oxide.Plugins
"OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
"OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged",
"OnKitRedeemed",
"OnEntitySpawned", "OnEntityKill"
"OnEntitySpawned", "OnEntityKill",
"OnEnterZone", "OnExitZone", "OnRgDomesReady"
};
private void Init()
@@ -1173,6 +1174,17 @@ namespace Oxide.Plugins
}
hello["zoneHelper"] = helper;
// D211: ZoneManager's own flag list, for the site's zone presets (§3.1). In hello
// rather than every status frame, because it changes only when ZoneManager does,
// and a ZoneManager load sends hello again.
var zoneManager = new Dictionary<string, object> { { "version", ZoneManagerPlugin().Version.ToString() } };
List<string> flags = ZoneFlagNames(ZoneManagerPlugin());
if (flags != null)
zoneManager["flags"] = flags;
hello["zoneManager"] = zoneManager;
}
EmitFrame(hello);
@@ -4527,6 +4539,22 @@ namespace Oxide.Plugins
EmitFrame(frame);
}
private bool IsZonePlugin(Plugin plugin)
{
if (plugin == null || plugin == this)
return false;
string name = plugin.Name;
return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes";
}
/// <summary>Main thread. Hello again, once the world is up and the link is.</summary>
private void ResendHello()
{
if (_worldReady && _connected)
SendHello();
}
/// <summary>Main thread. Queues <see cref="EmitPluginChange"/> for somebody else's plugin, once the world is up.</summary>
private void NotePluginChange(Plugin plugin, bool loaded)
{
@@ -4548,6 +4576,11 @@ namespace Oxide.Plugins
if (plugin != null && plugin != this && plugin.Name == "ZoneManager")
NextTick(() => RecreateZones("ZoneManager reloaded"));
// Hello carries ZoneManager's flag list and both helpers' states, so a change to any
// of the four is said again rather than waiting for the next connect (§3.1, §3.2).
if (IsZonePlugin(plugin))
NextTick(ResendHello);
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
// forgotten our getter. Registering again is the only way back (§33.1).
if (plugin != null && plugin != this && plugin.Name == "BetterChat")
@@ -4570,6 +4603,9 @@ namespace Oxide.Plugins
if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false;
if (IsZonePlugin(plugin))
NextTick(ResendHello);
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
@@ -7446,6 +7482,24 @@ namespace Oxide.Plugins
public float Radius;
public string Name;
/// <summary>
/// A zone's ZoneManager flags and settings (PLAN_REDESIGNS §3.1), kept so a re-created
/// zone gets them back. Null on a zone from before protocol 13's options.
/// </summary>
public List<string> Flags;
public Dictionary<string, string> Settings;
/// <summary>What a player is told on the way in and out, and how (D193). Said by this plugin, not ZoneManager.</summary>
public string EnterMessage;
public string LeaveMessage;
public string Delivery;
public string Format;
/// <summary>A ZoneDomes dome over the zone (D194): its type (0–4) and how many spheres are stacked.</summary>
public bool Dome;
public int DomeType;
public int DomeStack;
/// <summary>When a zone is erased without being asked, on this host's clock (D96). Zero for an entity.</summary>
public long DeadlineMs;
@@ -7542,6 +7596,401 @@ namespace Oxide.Plugins
return inside is bool && (bool)inside;
}
// ---- a zone's options (PLAN_REDESIGNS §3.1, D166, D211) ----
//
// ZoneManager's flags are whatever the installed version declares, so the list is read from
// it, never kept here. `ZoneFieldListRaw` is in ZoneManager's own API region and answers
// the zone fields followed by every flag name; the fields this plugin controls or does not
// offer are dropped from the front. Plan §0.6 believed only a plugin that references
// ZoneManager's types could read the list, so it put the read in the zone helper. That was
// wrong for 3.1.14 (D211), and a server without the helper still gets every flag.
/// <summary>The fields `ZoneFieldListRaw` answers that are not flags, lower-cased.</summary>
private static readonly HashSet<string> ZoneNonFlagFields = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"name", "id", "radius", "rotation", "size", "location", "enter_message", "leave_message",
"radiation", "comfort", "temperature", "safezone", "permission", "ejectspawns", "parentid", "enabled", "enable"
};
/// <summary>
/// The settings a step may give a zone, and their bounds. Every other ZoneManager field
/// (rotation, parent, eject spawns, size) changes what this plugin believes the zone is,
/// so it stays out (§3.1). The messages are this plugin's own (D193).
/// </summary>
private static readonly string[] ZoneSettingKeys = { "radiation", "comfort", "temperature", "safezone", "permission" };
private const float ZoneRadiationMax = 500f;
private const float ZoneComfortMax = 1f;
private const float ZoneTemperatureLimit = 100f;
private const int ZoneMessageMax = 256;
private const int ZoneFlagsMax = 80;
/// <summary>ZoneDomes' own sphere types: Standard, Red, Blue, Green, Purple.</summary>
private const int DomeTypeMax = 4;
/// <summary>The most spheres one dome stacks. Each is an entity; ZoneDomes itself has no limit.</summary>
private const int DomeStackMax = 10;
/// <summary>A zone this plugin just put back says nothing to whoever is inside it for this long.</summary>
private const long ZoneQuietMs = 5000L;
/// <summary>Zone id → the moment its enter and leave messages start again. Main thread.</summary>
private readonly Dictionary<string, long> _zoneQuietUntil = new Dictionary<string, long>();
/// <summary>
/// Main thread. ZoneManager's flag names, as it spells them, or null when it is not loaded
/// or does not answer. Asked when needed: an operator can update ZoneManager at any time.
/// </summary>
private static List<string> ZoneFlagNames(Plugin zm)
{
var fields = zm == null ? null : zm.Call("ZoneFieldListRaw") as List<string>;
if (fields == null)
return null;
var flags = new List<string>();
foreach (string field in fields)
{
if (!string.IsNullOrEmpty(field) && !ZoneNonFlagFields.Contains(field))
flags.Add(field);
}
return flags;
}
/// <summary>
/// Main thread. Reads a step's flags and settings against the live ZoneManager. Each flag
/// is returned as ZoneManager spells it; each setting is checked and written as
/// ZoneManager parses it. False with a refusal on anything unknown or out of range.
/// </summary>
private static bool ReadZoneOptions(Plugin zm, Dictionary<string, object> cmd,
out List<string> flags, out Dictionary<string, string> settings, out string refusal)
{
flags = new List<string>();
settings = new Dictionary<string, string>(StringComparer.Ordinal);
refusal = null;
object raw;
if (cmd.TryGetValue("flags", out raw) && raw != null)
{
var list = raw as Newtonsoft.Json.Linq.JArray;
if (list == null)
{
refusal = "a zone's flags are a list of names";
return false;
}
if (list.Count > ZoneFlagsMax)
{
refusal = $"a zone takes at most {ZoneFlagsMax} flags, not {list.Count}";
return false;
}
List<string> known = ZoneFlagNames(zm);
if (known == null)
{
refusal = "this server's ZoneManager did not say which flags it has";
return false;
}
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (string k in known)
byName[k] = k;
foreach (Newtonsoft.Json.Linq.JToken token in list)
{
string name = token.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string)token : null;
string spelled;
if (string.IsNullOrEmpty(name) || !byName.TryGetValue(name.Trim(), out spelled))
{
refusal = $"'{name}' is not a flag this server's ZoneManager has";
return false;
}
if (!flags.Contains(spelled))
flags.Add(spelled);
}
}
if (cmd.TryGetValue("settings", out raw) && raw != null)
{
var obj = raw as Newtonsoft.Json.Linq.JObject;
if (obj == null)
{
refusal = "a zone's settings are an object of name and value";
return false;
}
foreach (Newtonsoft.Json.Linq.JProperty p in obj.Properties())
{
string key = p.Name.ToLowerInvariant();
if (Array.IndexOf(ZoneSettingKeys, key) < 0)
{
refusal = $"'{p.Name}' is not a zone setting this server offers (radiation, comfort, temperature, safezone, permission)";
return false;
}
// Invariant, because a JSON 0.5 read under the host's culture can come back "0,5".
var jv = p.Value as Newtonsoft.Json.Linq.JValue;
string value = jv == null ? null : Convert.ToString(jv.Value, System.Globalization.CultureInfo.InvariantCulture);
string written;
if (!ZoneSettingValue(key, value, out written, out refusal))
return false;
settings[key] = written;
}
}
return true;
}
private static bool ZoneSettingValue(string key, string value, out string written, out string refusal)
{
written = null;
refusal = null;
var inv = System.Globalization.CultureInfo.InvariantCulture;
if (key == "safezone")
{
bool on;
if (!bool.TryParse(value, out on))
{
refusal = $"safezone is true or false, not '{value}'";
return false;
}
written = on ? "true" : "false";
return true;
}
if (key == "permission")
{
string perm = (value ?? string.Empty).Trim().ToLowerInvariant();
if (perm.StartsWith("zonemanager.", StringComparison.Ordinal))
perm = perm.Substring("zonemanager.".Length);
if (perm.Length == 0 || perm.Length > 32)
{
refusal = "a zone's permission is 1 to 32 characters";
return false;
}
foreach (char c in perm)
{
if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_')
{
refusal = $"a zone's permission is letters, digits and _, not '{value}'";
return false;
}
}
written = perm;
return true;
}
float number;
if (!float.TryParse(value, System.Globalization.NumberStyles.Float, inv, out number) || float.IsNaN(number) || float.IsInfinity(number))
{
refusal = $"{key} is a number, not '{value}'";
return false;
}
float low = key == "temperature" ? -ZoneTemperatureLimit : 0f;
float high = key == "radiation" ? ZoneRadiationMax : key == "comfort" ? ZoneComfortMax : ZoneTemperatureLimit;
if (number < low || number > high)
{
refusal = $"{key} is {low} to {high}, not {number.ToString(inv)}";
return false;
}
written = number.ToString(inv);
return true;
}
// ---- the dome (PLAN_REDESIGNS §3.2, D167, D194) ----
//
// ZoneDomes draws the dome. Two things it lacks are made up here and in the domes helper,
// `RunicGatewayDomes.cs`: its API ends in a chat message to the player who asked, which
// throws for a call with none (the helper patches that), and at its own start it drops
// every dome whose zone does not exist yet, which is every zone this plugin re-creates.
// So this plugin puts its domes back after it puts its zones back, and only once ZoneDomes
// has finished its own start: a dome added before that is drawn twice, and the first set
// of spheres is left in the world where nothing can remove it. The helper says when.
private Plugin ZoneDomesPlugin()
{
return plugins.Find("ZoneDomes");
}
private Plugin DomesHelperPlugin()
{
return plugins.Find("RunicGatewayDomes");
}
/// <summary>Main thread. <c>patched</c>, <c>unsupported</c>, <c>no-zonedomes</c> or <c>missing</c>.</summary>
private string DomesHelperState()
{
Plugin helper = DomesHelperPlugin();
return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported";
}
/// <summary>Main thread. Whether a dome may be added now: ZoneDomes loaded, patched, and past its own start.</summary>
private bool DomesReady()
{
Plugin helper = DomesHelperPlugin();
return ZoneDomesPlugin() != null && helper != null
&& helper.Call("State") as string == "patched" && helper.Call("Ready") is bool ready && ready;
}
/// <summary>Main thread. Why a dome cannot be added now, or null when it can.</summary>
private string DomeRefusal()
{
if (ZoneDomesPlugin() == null)
return "ZoneDomes is not loaded on this server, so a zone cannot have a dome";
string state = DomesHelperState();
if (state == "missing")
return "RunicGatewayDomes.cs is not installed beside the bridge, and ZoneDomes cannot be called without it";
if (state != "patched")
return $"RunicGatewayDomes.cs could not patch this ZoneDomes ({state})";
return DomesReady() ? null : "ZoneDomes has not finished starting";
}
/// <summary>Main thread. Adds a zone's dome. True when ZoneDomes made it, or already had it.</summary>
private bool AddDome(WorldEntry e)
{
Plugin zd = ZoneDomesPlugin();
if (zd == null || !e.Dome)
return false;
object made = zd.Call("AddNewDome", null, e.Id, e.DomeType, e.DomeStack);
return made is bool && (bool)made;
}
/// <summary>
/// Main thread. Takes a zone's dome out of the world and out of ZoneDomes' data file.
/// Called BEFORE the zone is erased, so a dome never outlives its zone.
/// </summary>
private void RemoveDome(WorldEntry e)
{
Plugin zd = ZoneDomesPlugin();
if (zd == null || !e.Dome)
return;
// Without the helper the call still removes the dome and then throws on its message;
// the framework logs that, and the dome is gone either way.
zd.Call("RemoveExistingDome", null, e.Id);
}
/// <summary>
/// Main thread. Puts back the dome of every standing zone that has one. ZoneDomes answers
/// false for a dome it already has, so this is safe to repeat.
/// </summary>
private void RestoreDomes(string why)
{
if (!_worldReady || !DomesReady())
return;
Plugin zm = ZoneManagerPlugin();
if (zm == null)
return;
int added = 0;
foreach (WorldEntry e in _world.Values)
{
if (e.Kind != WorldKindZone || !e.Dome || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) == null)
continue;
if (AddDome(e))
added++;
}
if (added > 0)
{
_lastWorldSummary = $"domes after {why}: {added} put back";
Puts($"world: {_lastWorldSummary}");
}
}
/// <summary>Raised by the domes helper when ZoneDomes has finished its own start.</summary>
private void OnRgDomesReady()
{
MarkHook("OnRgDomesReady");
NextTick(() => RestoreDomes("ZoneDomes started"));
}
// ---- the zone's messages (D193) ----
//
// ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its
// own config turns popups on for every zone on the server. So this plugin never gives it
// the messages for its own zones, and says them here, as each zone's step chose.
private void OnEnterZone(string zoneId, BasePlayer player)
{
MarkHook("OnEnterZone");
SayZoneMessage(zoneId, player, true);
}
private void OnExitZone(string zoneId, BasePlayer player)
{
MarkHook("OnExitZone");
SayZoneMessage(zoneId, player, false);
}
private void SayZoneMessage(string zoneId, BasePlayer player, bool entering)
{
WorldEntry e;
if (zoneId == null || player == null || !player.IsConnected || !_world.TryGetValue(zoneId, out e) || e.Kind != WorldKindZone)
return;
string message = entering ? e.EnterMessage : e.LeaveMessage;
if (string.IsNullOrEmpty(message))
return;
// A zone this plugin just put back (a restart, a ZoneManager reload) re-enters whoever
// is standing in it. They were told when they walked in.
long quiet;
if (_zoneQuietUntil.TryGetValue(zoneId, out quiet))
{
if (NowMs() < quiet)
return;
_zoneQuietUntil.Remove(zoneId);
}
// A popup where PopupNotifications is loaded, and chat where it is not (§2.4): the
// step was accepted long before this moment, and a player is told either way.
Plugin popup = e.Delivery == DeliveryPopup ? PopupPlugin() : null;
if (popup != null)
popup.Call("CreatePopupNotification", message, player, 0f);
else
player.ChatMessage(e.Format == null ? message : Voice(e.Format, message));
}
/// <summary>
/// The map's monuments in the one order every caller agrees on: grouped by prefab short
/// name, then by position. <c>#n</c> in a value counts within its group, so it names the
@@ -7904,6 +8353,86 @@ namespace Oxide.Plugins
if (name.Length > 64)
name = name.Substring(0, 64);
// PLAN_REDESIGNS §3.1: the flags and settings, each checked against this server's own
// ZoneManager. An unknown one is refused, never dropped: a zone that quietly lacks the
// "no building" its author chose is worse than a step that fails and says why.
List<string> flags;
Dictionary<string, string> settings;
string optionRefusal;
if (!ReadZoneOptions(zm, cmd, out flags, out settings, out optionRefusal))
{
EmitWorldError(reqId, "bad-option", optionRefusal);
return;
}
// D193: what a player is told on the way in and out, and whether as chat or a popup.
string enterMessage = StringField(cmd, "enterMessage");
string leaveMessage = StringField(cmd, "leaveMessage");
if ((enterMessage != null && enterMessage.Length > ZoneMessageMax) || (leaveMessage != null && leaveMessage.Length > ZoneMessageMax))
{
EmitWorldError(reqId, "out-of-range", $"a zone's message is at most {ZoneMessageMax} characters");
return;
}
string delivery = StringField(cmd, "delivery");
if (string.IsNullOrEmpty(delivery))
delivery = DeliveryChat;
if (delivery != DeliveryChat && delivery != DeliveryPopup)
{
EmitWorldError(reqId, "malformed", $"a zone's messages go to chat or to a popup, not to '{delivery}'");
return;
}
string format = StringField(cmd, "format");
if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1))
{
EmitWorldError(reqId, "malformed", $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters");
return;
}
// D194: a dome, where ZoneDomes and the domes helper are both here.
bool dome = false;
int domeType = 0, domeStack = 1;
object domeRaw;
if (cmd.TryGetValue("dome", out domeRaw) && domeRaw != null)
{
var d = domeRaw as Newtonsoft.Json.Linq.JObject;
if (d == null)
{
EmitWorldError(reqId, "malformed", "a zone's dome is an object of type and stack");
return;
}
Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"];
// No type is Standard, the one full dome; the colours show only where they meet
// the ground (D212).
domeType = t == null ? 0 : t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1;
domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1;
if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax)
{
EmitWorldError(reqId, "out-of-range", $"a dome's type is 0 to {DomeTypeMax} and its stack 1 to {DomeStackMax}");
return;
}
string noDome = DomeRefusal();
if (noDome != null)
{
EmitWorldError(reqId, "dome-unavailable", noDome);
return;
}
dome = true;
}
var entry = new WorldEntry
{
Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}",
@@ -7915,6 +8444,15 @@ namespace Oxide.Plugins
Z = point.z,
Radius = (float)radius,
Name = name,
Flags = flags.Count > 0 ? flags : null,
Settings = settings.Count > 0 ? settings : null,
EnterMessage = string.IsNullOrEmpty(enterMessage) ? null : enterMessage,
LeaveMessage = string.IsNullOrEmpty(leaveMessage) ? null : leaveMessage,
Delivery = delivery,
Format = delivery == DeliveryChat ? format : null,
Dome = dome,
DomeType = domeType,
DomeStack = domeStack,
DeadlineMs = NowMs() + holdMs,
BootId = _bootId,
WipeId = _wipeId,
@@ -7927,11 +8465,20 @@ namespace Oxide.Plugins
return;
}
// The dome after the zone, because ZoneDomes reads the zone's place and size from
// ZoneManager. A dome that is not made takes its zone with it: the step asked for both.
if (dome && !AddDome(entry))
{
zm.Call("EraseTemporaryZone", this, entry.Id);
EmitWorldError(reqId, "dome-unavailable", "ZoneDomes did not make the dome, so the zone was not opened");
return;
}
_world[entry.Id] = entry;
SaveWorld();
EnsureWorldTimer();
_lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}";
_lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}";
Puts($"world: {_lastWorldSummary}");
EmitWorldPlaced(reqId, new List<WorldEntry> { entry }, false);
}
@@ -7959,13 +8506,35 @@ namespace Oxide.Plugins
/// </summary>
private bool CreateZone(Plugin zm, WorldEntry entry)
{
string[] args =
var args = new List<string>
{
"name", entry.Name ?? "Event zone",
"radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args, new UnityEngine.Vector3(entry.X, entry.Y, entry.Z));
// ZoneManager's own key/value arguments (§3.1): each setting by its key, and each flag
// as "<flag>", "true". Kept on the record, so a zone put back after a restart or a
// ZoneManager reload gets them back. A flag a newer ZoneManager no longer has is
// ignored by ZoneManager rather than failing the zone.
if (entry.Settings != null)
{
foreach (KeyValuePair<string, string> kv in entry.Settings)
{
args.Add(kv.Key);
args.Add(kv.Value);
}
}
if (entry.Flags != null)
{
foreach (string flag in entry.Flags)
{
args.Add(flag);
args.Add("true");
}
}
object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args.ToArray(), new UnityEngine.Vector3(entry.X, entry.Y, entry.Z));
return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null;
}
@@ -7995,9 +8564,14 @@ namespace Oxide.Plugins
continue;
if (CreateZone(zm, e))
{
restored++;
_zoneQuietUntil[e.Id] = NowMs() + ZoneQuietMs;
}
else
{
failed++;
}
}
if (restored > 0 || failed > 0)
@@ -8005,6 +8579,10 @@ namespace Oxide.Plugins
_lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed";
Puts($"world: {_lastWorldSummary}");
}
// The domes after the zones (D194). At a real start ZoneDomes has not finished its own
// start yet, and the helper's ready hook brings this back when it has.
RestoreDomes(why);
}
/// <summary>Main thread. Place crates or NPCs an event owns, one registry entry per net id.</summary>
@@ -8227,8 +8805,14 @@ namespace Oxide.Plugins
// it lives in memory only — and its record is all there is left to remove.
wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null;
// The dome first: ZoneDomes keeps it in its own data file, and a dome
// left there outlives the zone it was drawn over (D194).
RemoveDome(e);
if (wasThere)
zm.Call("EraseTemporaryZone", this, e.Id);
_zoneQuietUntil.Remove(e.Id);
}
else
{
@@ -8508,10 +9092,13 @@ namespace Oxide.Plugins
foreach (WorldEntry e in due)
{
RemoveDome(e);
if (zm != null && zm.Call("CheckZoneID", e.Id) != null)
zm.Call("EraseTemporaryZone", this, e.Id);
_world.Remove(e.Id);
_zoneQuietUntil.Remove(e.Id);
Dictionary<string, object> frame = Frame("world.expired", "event");
frame["id"] = e.Id;
@@ -10047,10 +10634,34 @@ namespace Oxide.Plugins
return new Dictionary<string, object>
{
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
{ "popupNotifications", IntegrationBody(PopupPlugin()) }
{ "popupNotifications", IntegrationBody(PopupPlugin()) },
{ "zoneDomes", ZoneDomesBody() }
};
}
/// <summary>
/// ZoneDomes and the domes helper (D194), live on every status frame like the other
/// optional mods: the site offers a dome only where both are here and patched.
/// </summary>
private Dictionary<string, object> ZoneDomesBody()
{
Dictionary<string, object> body = IntegrationBody(ZoneDomesPlugin());
var helper = new Dictionary<string, object> { { "state", ZoneDomesPlugin() == null && DomesHelperPlugin() != null ? "no-zonedomes" : DomesHelperState() } };
Plugin domes = DomesHelperPlugin();
if (domes != null)
{
helper["version"] = domes.Version.ToString();
if (domes.Call("StateReason") is string reason)
helper["reason"] = reason;
}
body["helper"] = helper;
body["ready"] = DomesReady();
return body;
}
/// <summary>A version only for a plugin that is loaded. Absent, not empty.</summary>
private static Dictionary<string, object> IntegrationBody(Plugin plugin)
{

View File

@@ -0,0 +1,261 @@
// Runic Gateway Domes — a helper for ZoneDomes, shipped beside the Runic Gateway bridge.
// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later.
//
// ONE JOB (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D168, D194):
//
// ZoneDomes draws a dome over a ZoneManager zone, and the bridge asks it to for an event zone
// whose step wants one. Its API was written for a person at a keyboard: `AddNewDome` and
// `RemoveExistingDome` both end by telling the player who asked how it went, with no check that
// there was one. Called by a plugin with no player, the dome is made and saved, and THEN the call
// throws — so the caller cannot tell a made dome from a failed one (plan §0.5, found on the rig).
//
// So this helper patches ZoneDomes with Harmony, the same way the zone helper patches ZoneManager
// (D181):
//
// * **A message with no player is not sent.** A prefix on ZoneDomes' `TranslateMessage(BasePlayer,
// string, string)` skips it when the player is null. Nothing else about the call changes, so
// `AddNewDome` returns its true or false normally.
// * **It says when ZoneDomes has finished starting.** ZoneDomes' own start (`InitializeDomes`)
// drops every dome whose zone does not exist yet and draws every one that does. A dome added
// BEFORE that is drawn a second time, and the first set of spheres stays in the world with
// nothing holding it. A postfix there raises `OnRgDomesReady`, and the bridge puts its domes
// back only after it.
//
// It is optional. Without it the bridge offers no dome and says why; the zones themselves are
// unaffected. `rgd.status` in the server console prints its state.
using System;
using System.Reflection;
using HarmonyLib;
using Oxide.Core;
using Oxide.Core.Plugins;
namespace Oxide.Plugins
{
[Info("Runic Gateway Domes", "Runic Gateway", "0.1.0")]
[Description("Helper for Runic Gateway: ZoneDomes can be called without a player, and says when it has started.")]
public class RunicGatewayDomes : RustPlugin
{
private const string HarmonyId = "runicgateway.domes";
/// <summary>The instance the static patches report to. Null while unloaded.</summary>
private static RunicGatewayDomes _instance;
private Harmony _harmony;
private MethodInfo _message;
private MethodInfo _initialize;
/// <summary><c>patched</c>, <c>unsupported</c> (ZoneDomes is here but not the shape patched), or <c>no-zonedomes</c>.</summary>
private string _state = "no-zonedomes";
private string _reason;
/// <summary>Whether ZoneDomes has finished its own start since it was patched. Main thread.</summary>
private bool _ready;
/// <summary>Set once the server is up, at this plugin's own <c>OnServerInitialized</c>.</summary>
private bool _serverUp;
// Patched as EARLY as possible — at load, or the moment ZoneDomes loads — so that at a real
// server start the patch is in before ZoneDomes' `OnServerInitialized` runs its start.
private void Loaded()
{
_instance = this;
Patch();
}
private void OnServerInitialized(bool initial)
{
_serverUp = true;
// Loaded into a running server: ZoneDomes, if it is here, started long ago.
if (!initial)
MarkReadyNextTick();
}
private void Unload()
{
Unpatch();
_instance = null;
}
private void OnPluginLoaded(Plugin plugin)
{
if (plugin == null || plugin.Name != "ZoneDomes")
return;
Unpatch();
Patch();
// A ZoneDomes loaded into a running server has run its start by now: the framework
// calls a hot-loaded plugin's `OnServerInitialized` before it tells anybody else it
// loaded. The tick is margin, and costs nothing.
if (_serverUp)
MarkReadyNextTick();
}
private void OnPluginUnloaded(Plugin plugin)
{
if (plugin != null && plugin.Name == "ZoneDomes")
{
Unpatch();
_state = "no-zonedomes";
_reason = null;
}
}
// ── The API the bridge calls ─────────────────────────────────────────────
/// <summary><c>patched</c>, <c>unsupported</c> or <c>no-zonedomes</c>.</summary>
private string State() => _state;
/// <summary>Why the state is not <c>patched</c>, or null.</summary>
private string StateReason() => _reason;
/// <summary>Whether ZoneDomes is patched and past its own start, so a dome may be added.</summary>
private bool Ready() => _state == "patched" && _ready;
/// <summary>The helper's own version, for hello.</summary>
private string HelperVersion() => Version.ToString();
// ── Patching ─────────────────────────────────────────────────────────────
private void Patch()
{
_ready = false;
Plugin zoneDomes = plugins.Find("ZoneDomes");
if (zoneDomes == null)
{
_state = "no-zonedomes";
_reason = null;
return;
}
const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
Type type = zoneDomes.GetType();
_message = type.GetMethod("TranslateMessage", any, null, new[] { typeof(BasePlayer), typeof(string), typeof(string) }, null);
_initialize = type.GetMethod("InitializeDomes", any, null, Type.EmptyTypes, null);
string missing =
_message == null ? "TranslateMessage(BasePlayer, string, string)" :
_initialize == null ? "InitializeDomes()" :
type.GetMethod("AddNewDome", any) == null ? "AddNewDome" :
type.GetMethod("RemoveExistingDome", any) == null ? "RemoveExistingDome" :
null;
if (missing != null)
{
Fail($"this ZoneDomes ({zoneDomes.Version}) has no {missing}");
return;
}
try
{
_harmony = new Harmony(HarmonyId);
_harmony.Patch(_message, prefix: new HarmonyMethod(
typeof(RunicGatewayDomes).GetMethod(nameof(BeforeMessage), BindingFlags.Static | BindingFlags.NonPublic)));
_harmony.Patch(_initialize, postfix: new HarmonyMethod(
typeof(RunicGatewayDomes).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic)));
}
catch (Exception ex)
{
Fail($"Harmony could not patch ZoneDomes: {ex.Message}");
return;
}
_state = "patched";
_reason = null;
Puts($"patched ZoneDomes {zoneDomes.Version}: its API can be called without a player");
}
private void Unpatch()
{
if (_harmony != null)
{
try
{
if (_message != null)
_harmony.Unpatch(_message, HarmonyPatchType.Prefix, HarmonyId);
if (_initialize != null)
_harmony.Unpatch(_initialize, HarmonyPatchType.Postfix, HarmonyId);
}
catch (Exception ex)
{
PrintWarning($"could not remove the ZoneDomes patches: {ex.Message}");
}
}
_harmony = null;
_message = null;
_initialize = null;
_ready = false;
}
private void Fail(string reason)
{
Unpatch();
_state = "unsupported";
_reason = reason;
PrintWarning($"{reason}. Runic Gateway will not offer a dome over its zones on this server.");
}
private void MarkReadyNextTick()
{
NextTick(() =>
{
if (_state != "patched" || _ready)
return;
_ready = true;
Interface.CallHook("OnRgDomesReady");
});
}
// ── The patches ──────────────────────────────────────────────────────────
/// <summary>
/// Harmony prefix on <c>ZoneDomes.TranslateMessage(BasePlayer, string, string)</c>.
/// Returning false skips the message; a real player still gets it.
/// </summary>
private static bool BeforeMessage(BasePlayer player)
{
return player != null;
}
/// <summary>
/// Harmony postfix on <c>ZoneDomes.InitializeDomes</c>. Anything thrown here would surface
/// inside ZoneDomes, so nothing is allowed to.
/// </summary>
private static void AfterInitialize()
{
try
{
RunicGatewayDomes self = _instance;
if (self == null || self._state != "patched")
return;
self._ready = true;
Interface.CallHook("OnRgDomesReady");
}
catch (Exception)
{
// Never ZoneDomes' problem.
}
}
// ── Console ──────────────────────────────────────────────────────────────
[ConsoleCommand("rgd.status")]
private void StatusCommand(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin)
return;
arg.ReplyWith($"Runic Gateway Domes {Version}: {_state}{(_state == "patched" ? (_ready ? ", ready" : ", waiting for ZoneDomes to start") : "")}{(_reason != null ? $" ({_reason})" : "")}");
}
}
}