diff --git a/overlay.toml b/overlay.toml
index 1b3315f..76fff64 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,10 +26,11 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 8 — the transport, the read path, identity, the permission mirror,
-# configuration from the site, first-party clans, and a raid frame that names who
-# lives there (docs/rust-link/PROTOCOL.md §13).
-protocol = 8
+# Current: 9 — the transport, the read path, identity, the permission mirror,
+# configuration from the site, first-party clans, a raid frame that names who
+# lives there, the event leases, and the world verbs events place and give back
+# (docs/rust-link/PROTOCOL.md §15).
+protocol = 9
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 7350e89..70b44af 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 8;
+ private const int ProtocolVersion = 9;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -317,6 +317,10 @@ namespace Oxide.Plugins
// them: a plugin reload is not the end of an event (§27.3).
StopLeaseTimer();
+ // Zones are not erased here by us — ZoneManager erases them itself as this plugin
+ // unloads — and they are NOT forgotten: the next load re-creates them (D92).
+ StopWorldTimer();
+
_running = false;
_stopping.Set();
Wake.Set();
@@ -372,6 +376,16 @@ namespace Oxide.Plugins
/// turning it off never strands a value somebody already borrowed.
///
public bool EventsEnabled = false;
+
+ ///
+ /// The most crates one event step may place (protocol 9, D95). An operator may
+ /// lower it; is the ceiling it cannot be raised
+ /// past. A step over the bound is refused, never trimmed to fit.
+ ///
+ public int EventsMaxCrates = WorldHardMaxCrates;
+
+ /// The most NPCs one event step may place (D95). Lowerable, like the above.
+ public int EventsMaxNpcs = WorldHardMaxNpcs;
}
protected override void LoadDefaultConfig()
@@ -400,7 +414,13 @@ namespace Oxide.Plugins
// A config written before protocol 8 has no `EventsEnabled`, and a key that is not in
// the file is a key the website's config editor cannot show an operator. Written back
// once, with its default, so the switch is visible before anybody needs it.
- if (Config["EventsEnabled"] == null)
+ // The two bounds are clamped to their ceilings rather than refused: a config asking
+ // for MORE than the plugin will ever place is an operator's typo, and the ceiling is
+ // what the website mirrors (D95).
+ _config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates));
+ _config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs));
+
+ if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null)
Config.WriteObject(_config, true);
}
@@ -413,6 +433,7 @@ namespace Oxide.Plugins
// lease command can arrive before the world has finished loading, and it must find
// the holds already in place rather than have them loaded over it afterwards.
LoadLeases();
+ LoadWorld();
}
// ---- the outbound queue ----
@@ -850,6 +871,28 @@ namespace Oxide.Plugins
HandleLeaseRelease(cmd, reqId);
break;
+ case "world.monuments":
+ // Protocol 9 (PLAN.md §28). The monuments this wipe's map has, for the
+ // authoring form — and the same vocabulary a step's location is written in.
+ HandleWorldMonuments(reqId);
+ break;
+
+ case "world.zone":
+ HandleWorldZone(cmd, reqId);
+ break;
+
+ case "world.place":
+ HandleWorldPlace(cmd, reqId);
+ break;
+
+ case "world.revert":
+ HandleWorldRevert(cmd, reqId);
+ break;
+
+ case "world.owned":
+ HandleWorldOwned(cmd, reqId);
+ break;
+
default:
Puts($"unknown command '{verb}'");
break;
@@ -938,6 +981,10 @@ namespace Oxide.Plugins
hello["bootId"] = _bootId;
hello["plugin"] = Version.ToString();
+ // The website compares boot ids to decide when to reconcile the events ledger, and
+ // must not do it until the world it would be asking about exists (§28.6).
+ hello["worldReady"] = _worldReady;
+
EmitFrame(hello);
}
@@ -1254,12 +1301,24 @@ namespace Oxide.Plugins
// one — to show `wipeId=none` on a server with a save sitting on disk.
_wipeId = ResolveWipeId();
+ // Only now is the wipe known, so only now can what an earlier map owned be dropped;
+ // and this hook also fires on a hotload, which is exactly when ZoneManager has just
+ // erased every zone the previous load of this plugin owned (D92).
+ _worldReady = true;
+ DropWorldFromOtherWipes();
+ RecreateZones("load");
+
// Started here rather than in Init: this hook fires both at the end of server startup
// and immediately on a hotload, so it is the one place that runs exactly once per
// load with the world available either way.
if (_cadence == null)
_cadence = timer.Every(BoardCadenceSeconds, Cadence);
+ // Say the world is ready now rather than at the next tick: the website holds its
+ // reconcile until it hears this.
+ if (_connected)
+ SendHello();
+
Dictionary frame = Frame("server.initialized", "event");
frame["initial"] = initial;
EmitFrame(frame);
@@ -1294,11 +1353,14 @@ namespace Oxide.Plugins
EmitFrame(frame);
- // Nothing from the old world is still true. The tallies are the only such state this
- // plugin holds; everything else lives on the far side of the link.
+ // Nothing from the old world is still true. The tallies are one such piece of state
+ // this plugin holds.
_tallies.Clear();
_connectedAt.Clear();
+ // Nor is anything an event placed: the old map, and everything on it, is gone.
+ DropWorldFromOtherWipes();
+
Puts($"new save; wipe is now {(_wipeId ?? "unknown")}");
}
@@ -2775,6 +2837,13 @@ namespace Oxide.Plugins
public readonly List Foreign = new List();
public readonly List Pending = new List();
+ ///
+ /// Grants the store did not hold after being told to (subject:permission ).
+ /// Reported rather than counted, so the site can never again be told a grant
+ /// landed that did not (D85).
+ ///
+ public readonly List NotLanded = new List();
+
///
/// Group permissions an event lease holds (protocol 8). Neither applied to nor
/// reported foreign; what the site asked for is kept on the hold instead.
@@ -3138,12 +3207,22 @@ namespace Oxide.Plugins
if (permission.GroupHasPermission(op.Subject, op.Object))
{
job.AlreadyCorrect++;
+ break;
}
- else
- {
- permission.GrantGroupPermission(op.Subject, op.Object, this);
+
+ // **`null`, not `this`, as the owner (D85).** With an owner, Oxide's grant
+ // first asks `PermissionExists(perm, owner)` — whether THAT plugin registered
+ // the name — and returns without a word when it did not. Every permission the
+ // site grants belongs to some other plugin, so from phase 7 until phase 13 this
+ // call did nothing on Oxide and was counted as applied (PLAN.md §27.6). The
+ // read-back is what makes the count true: only a grant the store now holds
+ // is reported as one.
+ permission.GrantGroupPermission(op.Subject, op.Object, null);
+
+ if (permission.GroupHasPermission(op.Subject, op.Object))
job.GroupPermissions++;
- }
+ else
+ job.NotLanded.Add(op.Subject + ":" + op.Object);
break;
@@ -3173,12 +3252,16 @@ namespace Oxide.Plugins
if (HoldsDirectly(op.Subject, op.Object))
{
job.AlreadyCorrect++;
+ break;
}
- else
- {
- permission.GrantUserPermission(op.Subject, op.Object, this);
+
+ // The same owner trap as the group grant above (D85), and the same read-back.
+ permission.GrantUserPermission(op.Subject, op.Object, null);
+
+ if (HoldsDirectly(op.Subject, op.Object))
job.Grants++;
- }
+ else
+ job.NotLanded.Add(op.Subject + ":" + op.Object);
break;
@@ -3478,6 +3561,7 @@ namespace Oxide.Plugins
report["foreign"] = job.Foreign;
report["leased"] = job.Leased;
report["pending"] = job.Pending;
+ report["notLanded"] = job.NotLanded;
report["operations"] = job.Ops.Count;
if (!string.IsNullOrEmpty(job.SetId))
@@ -3490,7 +3574,7 @@ namespace Oxide.Plugins
$"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " +
$"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " +
$"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " +
- $"pending={job.Pending.Count} at={DateTime.UtcNow:HH:mm:ss}Z";
+ $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} at={DateTime.UtcNow:HH:mm:ss}Z";
EmitFrame(report);
}
@@ -3568,6 +3652,11 @@ namespace Oxide.Plugins
{
MarkHook("OnPluginLoaded");
+ // ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an
+ // event opened. They are put back from this plugin's own record (D92).
+ if (plugin != null && plugin != this && plugin.Name == "ZoneManager")
+ NextTick(() => RecreateZones("ZoneManager reloaded"));
+
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
@@ -5775,6 +5864,1203 @@ namespace Oxide.Plugins
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
}
+ // ---- the world verbs (protocol 9, PLAN.md §28) ----
+ //
+ // Two things an event makes that did not exist — a zone, and a crate or NPC placed in the
+ // world — and one registry that remembers which run made what. Four rules shape all of it,
+ // and each came from the rig rather than from a document (§28.1):
+ //
+ // * **A restart is not proof a placed thing is gone.** Crates are saved by the game and
+ // come back with the same net id; NPCs are not saved and do not. So nothing here is
+ // inferred from a boot id. `world.owned` LOOKS, by net id, every time.
+ // * **A wipe is proof everything is gone.** The registry is dropped whole on a new save
+ // and at load when it names another wipe.
+ // * **ZoneManager forgets.** It holds temporary zones in memory and erases every zone a
+ // plugin owns when that plugin unloads — so an `oxide.reload` of this bridge deletes
+ // every event zone mid-run. The registry is the record, and zones are put back from it
+ // on every load and every ZoneManager reload (D92).
+ // * **There is no at-most-once store on this bridge.** The registry is keyed by the
+ // website's idempotency key instead: a repeat answers with the ids the first call made
+ // and places nothing, which is the whole of what the key is for.
+ //
+ // What may be erased is decided by the registry and never by ZoneManager, whose
+ // `EraseTemporaryZone` deletes an operator's own hand-made zone and returns true (§12.4).
+
+ /// The ceiling on crates per step. EventsMaxCrates may lower it, never raise it.
+ private const int WorldHardMaxCrates = 25;
+
+ /// The ceiling on NPCs per step (D95).
+ private const int WorldHardMaxNpcs = 20;
+
+ /// How far a group may be scattered around its point, in metres.
+ private const float WorldMaxSpread = 50f;
+
+ /// How far from a monument's centre a step may put its point, in metres.
+ private const float WorldMaxOffset = 150f;
+
+ private const float ZoneMinRadius = 5f;
+ private const float ZoneMaxRadius = 150f;
+
+ ///
+ /// The longest a zone may stand: seven days, the lease ceiling (D96). A zone is priced
+ /// by its minutes, so it has a deadline, and the deadline is kept HERE — a zone never
+ /// outlives its event even when the website that opened it has gone.
+ ///
+ private const long ZoneMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
+
+ private const string WorldKindZone = "zone";
+ private const string WorldKindCrate = "crate";
+ private const string WorldKindNpc = "npc";
+
+ /// One thing this plugin will place, by the key the website names it with.
+ private sealed class WorldPrefab
+ {
+ public readonly string Key;
+ public readonly string Kind;
+ public readonly string Path;
+ public readonly string Label;
+
+ public WorldPrefab(string key, string kind, string path, string label)
+ {
+ Key = key;
+ Kind = kind;
+ Path = path;
+ Label = label;
+ }
+ }
+
+ ///
+ /// The allowlist (D88), and the only one: the sidecar forwards and the website offers
+ /// what this answers. Crates and NPCs, never vehicles — a player can take a vehicle, and
+ /// a revert would kill something a person may be sitting in. Every path here was
+ /// spawned on the rig and reported its type before it was listed (§28.1).
+ ///
+ private static readonly WorldPrefab[] WorldPrefabs =
+ {
+ new WorldPrefab("crate.basic", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_basic.prefab", "Basic crate"),
+ new WorldPrefab("crate.normal", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal.prefab", "Military crate"),
+ new WorldPrefab("crate.normal2", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal_2.prefab", "Crate"),
+ new WorldPrefab("crate.elite", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_elite.prefab", "Elite crate"),
+ new WorldPrefab("crate.tools", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_tools.prefab", "Tool box"),
+ new WorldPrefab("crate.hackable", WorldKindCrate, "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", "Locked crate (hackable)"),
+ new WorldPrefab("supply.drop", WorldKindCrate, "assets/prefabs/misc/supply drop/supply_drop.prefab", "Supply drop"),
+ new WorldPrefab("barrel.loot", WorldKindCrate, "assets/bundled/prefabs/radtown/loot_barrel_1.prefab", "Loot barrel"),
+ new WorldPrefab("npc.scientist", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab", "Scientist"),
+ new WorldPrefab("npc.scientist.heavy", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab", "Heavy scientist"),
+ new WorldPrefab("npc.scientist.tethered", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roamtethered.prefab", "Scientist (stays put)"),
+ new WorldPrefab("npc.bandit.guard", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/banditguard/npc_bandit_guard.prefab", "Bandit guard")
+ };
+
+ /// One thing an event made. Persisted as it is, so every field is public and plain.
+ private sealed class WorldEntry
+ {
+ /// The net id for an entity, as text; the zone id for a zone.
+ public string Id;
+
+ public string Kind;
+
+ /// The allowlist key, for a crate or an NPC.
+ public string Prefab;
+
+ public string RunId;
+
+ /// The website's idempotency key for the step that made it.
+ public string Key;
+
+ public float X, Y, Z;
+
+ /// A zone's radius and name, for re-creating it.
+ public float Radius;
+ public string Name;
+
+ /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity.
+ public long DeadlineMs;
+
+ public string BootId;
+ public string WipeId;
+ public long TakenMs;
+ }
+
+ /// Everything events have made on this map, by id. Main thread only.
+ private Dictionary _world = new Dictionary();
+
+ /// A counter for zone ids, so two zones one run opens in one tick never collide.
+ private int _zoneSerial;
+
+ ///
+ /// Whether the world is loaded for THIS load of the plugin — set at
+ /// OnServerInitialized , which fires at the end of a real start and at once on a
+ /// hotload. Until then every world command is refused not-ready .
+ ///
+ ///
+ /// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in
+ /// Init , BEFORE the save loads, so a website that saw the new boot id asked
+ /// world.owned into an empty world — and the plugin, finding no entity behind
+ /// any net id, pruned three live crates from its own registry. They stayed in the
+ /// world owned by nothing, and a revert would have refused them. An empty world at
+ /// boot is not an answer; it is the question arriving too early.
+ ///
+ ///
+ private bool _worldReady;
+
+ private string _lastWorldSummary = "none yet";
+
+ private Timer _worldTimer;
+
+ private static WorldPrefab FindWorldPrefab(string key)
+ {
+ foreach (WorldPrefab p in WorldPrefabs)
+ {
+ if (string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase))
+ return p;
+ }
+
+ return null;
+ }
+
+ private Plugin ZoneManagerPlugin()
+ {
+ return plugins.Find("ZoneManager");
+ }
+
+ ///
+ /// The map's monuments in the one order every caller agrees on: grouped by prefab short
+ /// name, then by position. #n in a value counts within its group, so it names the
+ /// same monument every time it is asked on this map (D93).
+ ///
+ private static List>> MonumentGroups()
+ {
+ var byKind = new SortedDictionary>(StringComparer.Ordinal);
+ List all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null;
+
+ if (all != null)
+ {
+ foreach (MonumentInfo m in all)
+ {
+ if (m == null || string.IsNullOrEmpty(m.name))
+ continue;
+
+ string kind = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant();
+ List list;
+
+ if (!byKind.TryGetValue(kind, out list))
+ {
+ list = new List();
+ byKind[kind] = list;
+ }
+
+ list.Add(m);
+ }
+ }
+
+ var groups = new List>>();
+
+ foreach (KeyValuePair> kv in byKind)
+ {
+ kv.Value.Sort((a, b) =>
+ {
+ int cx = a.transform.position.x.CompareTo(b.transform.position.x);
+ return cx != 0 ? cx : a.transform.position.z.CompareTo(b.transform.position.z);
+ });
+
+ groups.Add(kv);
+ }
+
+ return groups;
+ }
+
+ ///
+ /// Main thread. A monument value — kind or kind#n — to the monument it
+ /// names on this map, or null with a reason. A bare kind that repeats is its first
+ /// instance, so a step written against a map where it was unique still means something
+ /// on one where it is not.
+ ///
+ private static MonumentInfo ResolveMonument(string value, out string refusal)
+ {
+ refusal = null;
+ string text = Lower(value);
+
+ if (string.IsNullOrEmpty(text))
+ {
+ refusal = "no monument was named";
+ return null;
+ }
+
+ int instance = 1;
+ string kind = text;
+ int hash = text.LastIndexOf('#');
+
+ if (hash > 0)
+ {
+ kind = text.Substring(0, hash);
+
+ if (!int.TryParse(text.Substring(hash + 1), out instance) || instance < 1)
+ {
+ refusal = $"'{value}' is not a monument, followed by an instance number";
+ return null;
+ }
+ }
+
+ foreach (KeyValuePair> kv in MonumentGroups())
+ {
+ if (kv.Key != kind)
+ continue;
+
+ if (instance > kv.Value.Count)
+ {
+ refusal = $"this map has {kv.Value.Count} {kind}, so there is no #{instance}";
+ return null;
+ }
+
+ return kv.Value[instance - 1];
+ }
+
+ refusal = $"this map has no {kind}";
+ return null;
+ }
+
+ private static double? DoubleField(Dictionary row, string key)
+ {
+ object value;
+
+ if (row == null || !row.TryGetValue(key, out value) || value == null)
+ return null;
+
+ double parsed;
+ return double.TryParse(
+ value.ToString(), System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out parsed) && !double.IsNaN(parsed) && !double.IsInfinity(parsed)
+ ? parsed
+ : (double?)null;
+ }
+
+ ///
+ /// Main thread. Where a step puts its thing: a monument (plus an offset), or raw world
+ /// coordinates — exactly one of the two (D87). Returns false with a reason and the
+ /// refusal code that goes with it.
+ ///
+ private bool ResolveWorldPoint(Dictionary cmd, out UnityEngine.Vector3 point, out string reason, out string refusal)
+ {
+ point = UnityEngine.Vector3.zero;
+ reason = null;
+ refusal = null;
+
+ string monument = StringField(cmd, "monument");
+ double? x = DoubleField(cmd, "x");
+ double? z = DoubleField(cmd, "z");
+ double? y = DoubleField(cmd, "y");
+ bool byMonument = !string.IsNullOrEmpty(monument);
+ bool byCoords = x.HasValue || z.HasValue;
+
+ if (byMonument == byCoords)
+ {
+ reason = "malformed";
+ refusal = "a location is a monument or x and z, and exactly one of them";
+ return false;
+ }
+
+ if (byMonument)
+ {
+ MonumentInfo m = ResolveMonument(monument, out refusal);
+
+ if (m == null)
+ {
+ reason = "no-monument";
+ return false;
+ }
+
+ double dx = DoubleField(cmd, "offsetX") ?? 0.0;
+ double dz = DoubleField(cmd, "offsetZ") ?? 0.0;
+
+ if (Math.Sqrt(dx * dx + dz * dz) > WorldMaxOffset)
+ {
+ reason = "out-of-range";
+ refusal = $"an offset from a monument is at most {WorldMaxOffset} m";
+ return false;
+ }
+
+ point = m.transform.position + new UnityEngine.Vector3((float)dx, 0f, (float)dz);
+ point.y = TerrainMeta.HeightMap.GetHeight(point);
+ return true;
+ }
+
+ if (!x.HasValue || !z.HasValue)
+ {
+ reason = "malformed";
+ refusal = "coordinates need both x and z";
+ return false;
+ }
+
+ float half = ConVar.Server.worldsize / 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;
+ }
+
+ point = new UnityEngine.Vector3((float)x.Value, 0f, (float)z.Value);
+ point.y = y.HasValue ? (float)y.Value : TerrainMeta.HeightMap.GetHeight(point);
+ return true;
+ }
+
+ /// Main thread. Refuses a world command until the world is loaded (see _worldReady ).
+ private bool WorldReady(string reqId)
+ {
+ if (_worldReady)
+ return true;
+
+ EmitWorldError(reqId, "not-ready",
+ "the server is still loading its world; ask again once it has started");
+ return false;
+ }
+
+ private bool WorldWritesAllowed(string reqId)
+ {
+ if (!WorldReady(reqId))
+ return false;
+
+ if (_config != null && _config.EventsEnabled)
+ return true;
+
+ // One consent for everything an event does to this world unattended (D94). Giving
+ // back, listing and asking what is still out there are never behind it.
+ EmitWorldError(reqId, "events-disabled",
+ "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
+ return false;
+ }
+
+ ///
+ /// Main thread. The ids a key already made, or null. A repeat of a key the registry has
+ /// seen is a retry whose first answer was lost, and it is answered with the first
+ /// call's ids rather than a second set of things (§28.1).
+ ///
+ private List EntriesForKey(string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ return null;
+
+ List found = null;
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (e.Key != key)
+ continue;
+
+ if (found == null)
+ found = new List();
+
+ found.Add(e);
+ }
+
+ return found;
+ }
+
+ private void HandleWorldMonuments(string reqId)
+ {
+ if (!WorldReady(reqId))
+ return;
+
+ var rows = new List();
+
+ foreach (KeyValuePair> kv in MonumentGroups())
+ {
+ for (int i = 0; i < kv.Value.Count; i++)
+ {
+ MonumentInfo m = kv.Value[i];
+ UnityEngine.Vector3 p = m.transform.position;
+ string phrase = m.displayPhrase != null ? m.displayPhrase.english : null;
+
+ var row = new Dictionary
+ {
+ { "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key },
+ { "kind", kv.Key },
+ { "instance", i + 1 },
+ { "of", kv.Value.Count },
+ { "label", string.IsNullOrEmpty(phrase) ? kv.Key : phrase },
+ { "x", Math.Round(p.x, 1) },
+ { "z", Math.Round(p.z, 1) },
+ { "grid", Grid(p) }
+ };
+
+ rows.Add(row);
+ }
+ }
+
+ Dictionary reply = Frame("world.monuments", "reply");
+ reply["worldSize"] = ConVar.Server.worldsize;
+ reply["monuments"] = rows;
+ reply["prefabs"] = WorldPrefabRows();
+ reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
+ reply["maxCrates"] = _config != null ? _config.EventsMaxCrates : WorldHardMaxCrates;
+ reply["maxNpcs"] = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs;
+ reply["zoneManager"] = ZoneManagerPlugin() != null;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
+ private static List WorldPrefabRows()
+ {
+ var rows = new List();
+
+ foreach (WorldPrefab p in WorldPrefabs)
+ {
+ rows.Add(new Dictionary
+ {
+ { "key", p.Key },
+ { "kind", p.Kind },
+ { "label", p.Label }
+ });
+ }
+
+ return rows;
+ }
+
+ /// Main thread. Open a zone an event owns.
+ private void HandleWorldZone(Dictionary cmd, string reqId)
+ {
+ if (!WorldWritesAllowed(reqId))
+ return;
+
+ string key = StringField(cmd, "key");
+ string runId = StringField(cmd, "runId");
+ List repeat = EntriesForKey(key);
+
+ if (repeat != null)
+ {
+ EmitWorldPlaced(reqId, repeat, true);
+ return;
+ }
+
+ if (string.IsNullOrEmpty(runId))
+ {
+ EmitWorldError(reqId, "malformed", "a zone is opened for a run");
+ return;
+ }
+
+ Plugin zm = ZoneManagerPlugin();
+
+ if (zm == null)
+ {
+ EmitWorldError(reqId, "zonemanager-missing", "ZoneManager is not loaded on this server");
+ return;
+ }
+
+ double radius = DoubleField(cmd, "radius") ?? 0.0;
+
+ if (radius < ZoneMinRadius || radius > ZoneMaxRadius)
+ {
+ EmitWorldError(reqId, "out-of-range",
+ $"a zone's radius is {ZoneMinRadius} to {ZoneMaxRadius} m, not {radius}");
+ return;
+ }
+
+ UnityEngine.Vector3 point;
+ string reason, refusal;
+
+ if (!ResolveWorldPoint(cmd, out point, out reason, out refusal))
+ {
+ EmitWorldError(reqId, reason, refusal);
+ return;
+ }
+
+ long holdMs = LongField(cmd, "holdMs");
+
+ if (holdMs <= 0 || holdMs > ZoneMaxHoldMs)
+ {
+ EmitWorldError(reqId, "out-of-range",
+ $"a zone stands for 1 minute to {ZoneMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
+ return;
+ }
+
+ string name = StringField(cmd, "name");
+
+ if (string.IsNullOrEmpty(name))
+ name = "Event zone";
+
+ if (name.Length > 64)
+ name = name.Substring(0, 64);
+
+ var entry = new WorldEntry
+ {
+ Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}",
+ Kind = WorldKindZone,
+ RunId = runId,
+ Key = key,
+ X = point.x,
+ Y = point.y,
+ Z = point.z,
+ Radius = (float)radius,
+ Name = name,
+ DeadlineMs = NowMs() + holdMs,
+ BootId = _bootId,
+ WipeId = _wipeId,
+ TakenMs = NowMs()
+ };
+
+ if (!CreateZone(zm, entry))
+ {
+ EmitWorldError(reqId, "refused", "ZoneManager did not create the zone");
+ return;
+ }
+
+ _world[entry.Id] = entry;
+ SaveWorld();
+ EnsureWorldTimer();
+
+ _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}";
+ Puts($"world: {_lastWorldSummary}");
+ EmitWorldPlaced(reqId, new List { entry }, false);
+ }
+
+ private static string ShortRun(string runId)
+ {
+ var chars = new StringBuilder();
+
+ foreach (char c in runId ?? "")
+ {
+ if (char.IsLetterOrDigit(c))
+ chars.Append(c);
+
+ if (chars.Length >= 12)
+ break;
+ }
+
+ return chars.Length > 0 ? chars.ToString() : "run";
+ }
+
+ ///
+ /// Main thread. Creates, or re-creates, one zone from its record, and says whether
+ /// ZoneManager now has it. Our plugin is the owner, so ZoneManager itself refuses to
+ /// let another plugin's zone be overwritten under the same id.
+ ///
+ private bool CreateZone(Plugin zm, WorldEntry entry)
+ {
+ string[] args =
+ {
+ "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));
+
+ return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null;
+ }
+
+ ///
+ /// Main thread. Puts back every zone the registry holds that ZoneManager does not (D92).
+ /// Called on this plugin's load, on ZoneManager's, and from world.owned .
+ ///
+ private void RecreateZones(string why)
+ {
+ // At a real start ZoneManager loads before the world does and initialises its own
+ // zones at OnTerrainInitialized; ours wait for OnServerInitialized, which calls here.
+ if (!_worldReady)
+ return;
+
+ Plugin zm = ZoneManagerPlugin();
+
+ if (zm == null)
+ return;
+
+ int restored = 0, failed = 0;
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ // A zone past its deadline is the timer's to end, not this loop's to revive.
+ if (e.Kind != WorldKindZone || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) != null)
+ continue;
+
+ if (CreateZone(zm, e))
+ restored++;
+ else
+ failed++;
+ }
+
+ if (restored > 0 || failed > 0)
+ {
+ _lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed";
+ Puts($"world: {_lastWorldSummary}");
+ }
+ }
+
+ /// Main thread. Place crates or NPCs an event owns, one registry entry per net id.
+ private void HandleWorldPlace(Dictionary cmd, string reqId)
+ {
+ if (!WorldWritesAllowed(reqId))
+ return;
+
+ string key = StringField(cmd, "key");
+ string runId = StringField(cmd, "runId");
+ List repeat = EntriesForKey(key);
+
+ if (repeat != null)
+ {
+ EmitWorldPlaced(reqId, repeat, true);
+ return;
+ }
+
+ if (string.IsNullOrEmpty(runId))
+ {
+ EmitWorldError(reqId, "malformed", "a placement is made for a run");
+ return;
+ }
+
+ WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab"));
+
+ if (prefab == null)
+ {
+ EmitWorldError(reqId, "unknown-prefab",
+ $"'{StringField(cmd, "prefab")}' is not something this server places for events");
+ return;
+ }
+
+ int count = IntField(cmd, "count");
+ int max = prefab.Kind == WorldKindNpc
+ ? (_config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs)
+ : (_config != null ? _config.EventsMaxCrates : WorldHardMaxCrates);
+
+ if (count < 1 || count > max)
+ {
+ EmitWorldError(reqId, "out-of-range",
+ $"this server places 1 to {max} {(prefab.Kind == WorldKindNpc ? "NPCs" : "crates")} at a time, not {count}");
+ return;
+ }
+
+ double spread = DoubleField(cmd, "spread") ?? (count > 1 ? 10.0 : 0.0);
+
+ if (spread < 0 || spread > WorldMaxSpread)
+ {
+ EmitWorldError(reqId, "out-of-range", $"a scatter is 0 to {WorldMaxSpread} m, not {spread}");
+ return;
+ }
+
+ UnityEngine.Vector3 center;
+ string reason, refusal;
+
+ if (!ResolveWorldPoint(cmd, out center, out reason, out refusal))
+ {
+ EmitWorldError(reqId, reason, refusal);
+ return;
+ }
+
+ bool explicitY = DoubleField(cmd, "y").HasValue;
+ var made = new List();
+
+ for (int i = 0; i < count; i++)
+ {
+ UnityEngine.Vector3 at = center;
+
+ if (count > 1)
+ {
+ UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread;
+ at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y);
+
+ if (!explicitY)
+ at.y = TerrainMeta.HeightMap.GetHeight(at);
+ }
+
+ BaseEntity entity = GameManager.server.CreateEntity(prefab.Path, at);
+
+ if (entity == null)
+ {
+ // All or nothing: a group half of which exists is not what the step asked for,
+ // and the half that does exist has no record yet to be given back through.
+ foreach (WorldEntry e in made)
+ KillEntity(e.Id);
+
+ EmitWorldError(reqId, "refused", $"the game could not create {prefab.Label}");
+ return;
+ }
+
+ entity.Spawn();
+
+ made.Add(new WorldEntry
+ {
+ Id = entity.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ Kind = prefab.Kind,
+ Prefab = prefab.Key,
+ RunId = runId,
+ Key = key,
+ X = at.x,
+ Y = at.y,
+ Z = at.z,
+ BootId = _bootId,
+ WipeId = _wipeId,
+ TakenMs = NowMs()
+ });
+ }
+
+ foreach (WorldEntry e in made)
+ _world[e.Id] = e;
+
+ SaveWorld();
+
+ _lastWorldSummary = $"placed {count} {prefab.Key} for run {runId}";
+ Puts($"world: {_lastWorldSummary}");
+ EmitWorldPlaced(reqId, made, false);
+ }
+
+ private static BaseNetworkable FindEntity(string id)
+ {
+ ulong value;
+
+ if (!ulong.TryParse(id, out value))
+ return null;
+
+ BaseNetworkable found = BaseNetworkable.serverEntities.Find(new NetworkableId(value));
+ return found != null && !found.IsDestroyed ? found : null;
+ }
+
+ /// Main thread. Whether the entity was there to kill; either way it is not now.
+ private static bool KillEntity(string id)
+ {
+ BaseNetworkable found = FindEntity(id);
+
+ if (found == null)
+ return false;
+
+ found.Kill();
+ return true;
+ }
+
+ ///
+ /// Main thread. Give back what a run owns. Never behind EventsEnabled : switching
+ /// events off must not strand anything already placed (D94).
+ ///
+ ///
+ /// Named ids first; else everything under the key (core lost the answer to a dispatch
+ /// and knows only its key); else everything the run owns. An id already gone is a
+ /// success. An id the registry does not hold for this run is refused — this
+ /// bridge erases only what it made, whatever ZoneManager would allow (§12.4).
+ ///
+ ///
+ private void HandleWorldRevert(Dictionary cmd, string reqId)
+ {
+ // Before the world loads, every entity looks gone, and a revert would forget things
+ // that are about to exist again.
+ if (!WorldReady(reqId))
+ return;
+
+ string runId = StringField(cmd, "runId");
+ string key = StringField(cmd, "key");
+ var ids = new List(Strings(cmd, "ids"));
+
+ if (string.IsNullOrEmpty(runId))
+ {
+ EmitWorldError(reqId, "malformed", "a revert names the run whose things to give back");
+ return;
+ }
+
+ var targets = new List();
+ var refused = new List();
+ var gone = new List();
+ Plugin zm = ZoneManagerPlugin();
+
+ if (ids.Count > 0)
+ {
+ foreach (string id in ids)
+ {
+ WorldEntry e;
+
+ if (_world.TryGetValue(id, out e))
+ {
+ if (e.RunId == runId)
+ targets.Add(e);
+ else
+ refused.Add(id);
+
+ continue;
+ }
+
+ // Not on record. That is either something a wipe or a prune already took —
+ // gone, which is a success — or something this bridge never made. Only a
+ // thing that is actually THERE can be the second, and only that is refused.
+ bool exists = id.StartsWith("rg-", StringComparison.Ordinal)
+ ? zm != null && zm.Call("CheckZoneID", id) != null
+ : FindEntity(id) != null;
+
+ (exists ? refused : gone).Add(id);
+ }
+ }
+ else
+ {
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (e.RunId == runId && (string.IsNullOrEmpty(key) || e.Key == key))
+ targets.Add(e);
+ }
+ }
+
+ var removed = new List();
+
+ foreach (WorldEntry e in targets)
+ {
+ bool wasThere;
+
+ if (e.Kind == WorldKindZone)
+ {
+ // A zone ZoneManager does not have (unloaded, or already erased) is gone —
+ // it lives in memory only — and its record is all there is left to remove.
+ wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null;
+
+ if (wasThere)
+ zm.Call("EraseTemporaryZone", this, e.Id);
+ }
+ else
+ {
+ wasThere = KillEntity(e.Id);
+ }
+
+ _world.Remove(e.Id);
+ (wasThere ? removed : gone).Add(e.Id);
+ }
+
+ if (targets.Count > 0)
+ SaveWorld();
+
+ _lastWorldSummary = $"revert run {runId}: {removed.Count} removed, {gone.Count} already gone, {refused.Count} refused";
+ Puts($"world: {_lastWorldSummary}");
+
+ Dictionary reply = Frame("world.ok", "reply");
+ reply["removed"] = removed;
+ reply["gone"] = gone;
+ reply["refused"] = refused;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
+ ///
+ /// Main thread. What the world still holds of what events made — looked at, never
+ /// inferred (§28.1). An entity that is gone is pruned as the walk passes it. A zone is
+ /// put back if ZoneManager has lost it; while ZoneManager is not loaded a zone is
+ /// reported held, because "I cannot ask" is not "it is gone", and its load will put
+ /// the zone back.
+ ///
+ private void HandleWorldOwned(Dictionary cmd, string reqId)
+ {
+ if (!WorldReady(reqId))
+ return;
+
+ string runId = StringField(cmd, "runId");
+ RecreateZones("owned");
+
+ Plugin zm = ZoneManagerPlugin();
+ var rows = new List();
+ List pruned = null;
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (!string.IsNullOrEmpty(runId) && e.RunId != runId)
+ continue;
+
+ bool held;
+
+ if (e.Kind == WorldKindZone)
+ held = zm == null || zm.Call("CheckZoneID", e.Id) != null;
+ else
+ held = FindEntity(e.Id) != null;
+
+ if (!held)
+ {
+ if (pruned == null)
+ pruned = new List();
+
+ pruned.Add(e.Id);
+ continue;
+ }
+
+ rows.Add(WorldRow(e));
+ }
+
+ if (pruned != null)
+ {
+ foreach (string id in pruned)
+ _world.Remove(id);
+
+ SaveWorld();
+ }
+
+ Dictionary reply = Frame("world.owned", "reply");
+ reply["owned"] = rows;
+ reply["pruned"] = pruned != null ? pruned.Count : 0;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
+ private static Dictionary WorldRow(WorldEntry e)
+ {
+ var row = new Dictionary
+ {
+ { "id", e.Id },
+ { "kind", e.Kind },
+ { "runId", e.RunId },
+ { "x", Math.Round(e.X, 1) },
+ { "y", Math.Round(e.Y, 1) },
+ { "z", Math.Round(e.Z, 1) }
+ };
+
+ if (!string.IsNullOrEmpty(e.Prefab))
+ row["prefab"] = e.Prefab;
+
+ if (e.Kind == WorldKindZone)
+ {
+ row["radius"] = e.Radius;
+ row["name"] = e.Name;
+ row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs());
+ }
+
+ return row;
+ }
+
+ private void EmitWorldPlaced(string reqId, List entries, bool repeat)
+ {
+ var placed = new List();
+
+ foreach (WorldEntry e in entries)
+ placed.Add(WorldRow(e));
+
+ Dictionary reply = Frame("world.ok", "reply");
+ reply["placed"] = placed;
+ reply["repeat"] = repeat;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
+ private void EmitWorldError(string reqId, string reason, string message)
+ {
+ Dictionary frame = Frame("world.error", "reply");
+ frame["reason"] = reason;
+
+ if (!string.IsNullOrEmpty(message))
+ frame["message"] = message;
+
+ if (reqId != null)
+ frame["reqId"] = reqId;
+
+ _lastWorldSummary = $"refused ({reason}): {message}";
+ EmitFrame(frame);
+ }
+
+ private string WorldPath()
+ {
+ return Path.Combine(Interface.Oxide.DataDirectory, Name, "world.json");
+ }
+
+ /// Main thread. Written on every change, like the lease holds and for the same reason.
+ private void SaveWorld()
+ {
+ try
+ {
+ string path = WorldPath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+
+ var rows = new List(_world.Values);
+ string tmp = path + ".tmp";
+ File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
+
+ if (File.Exists(path))
+ File.Delete(path);
+
+ File.Move(tmp, path);
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not save the world registry: {Flatten(ex.Message)}");
+ }
+ }
+
+ ///
+ /// Main thread, once per load. Reads the registry and nothing more: whether each thing is
+ /// still out there is asked when somebody wants to know, because at a real server start
+ /// this runs before the save has loaded and every entity would look gone.
+ ///
+ private void LoadWorld()
+ {
+ _world = new Dictionary();
+ List rows = null;
+
+ try
+ {
+ string path = WorldPath();
+
+ if (File.Exists(path))
+ rows = JsonConvert.DeserializeObject>(File.ReadAllText(path));
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not read the world registry: {Flatten(ex.Message)}");
+ }
+
+ if (rows == null)
+ return;
+
+ foreach (WorldEntry e in rows)
+ {
+ if (e != null && !string.IsNullOrEmpty(e.Id))
+ _world[e.Id] = e;
+ }
+
+ Puts($"world: {_world.Count} things events made are on record");
+
+ // Due now or later; the first tick ends any zone whose deadline passed while the
+ // server was down, before anything could re-create it.
+ EnsureWorldTimer();
+ }
+
+ private static bool ZoneExpired(WorldEntry e)
+ {
+ return e.Kind == WorldKindZone && e.DeadlineMs > 0 && e.DeadlineMs <= NowMs();
+ }
+
+ private void EnsureWorldTimer()
+ {
+ if (_worldTimer != null)
+ return;
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (e.Kind == WorldKindZone)
+ {
+ _worldTimer = timer.Every(LeaseCheckSeconds, CheckZoneDeadlines);
+ return;
+ }
+ }
+ }
+
+ private void StopWorldTimer()
+ {
+ if (_worldTimer != null)
+ {
+ _worldTimer.Destroy();
+ _worldTimer = null;
+ }
+ }
+
+ ///
+ /// Main thread, every second while a zone stands. The fail-safe D96 bought: a zone whose
+ /// time is up is erased without anybody asking, and says so — the same promise a lease
+ /// makes, for the same reason.
+ ///
+ private void CheckZoneDeadlines()
+ {
+ List due = null;
+ bool anyZone = false;
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (e.Kind != WorldKindZone)
+ continue;
+
+ anyZone = true;
+
+ if (!ZoneExpired(e))
+ continue;
+
+ if (due == null)
+ due = new List();
+
+ due.Add(e);
+ }
+
+ if (!anyZone)
+ {
+ StopWorldTimer();
+ return;
+ }
+
+ if (due == null)
+ return;
+
+ Plugin zm = ZoneManagerPlugin();
+
+ foreach (WorldEntry e in due)
+ {
+ if (zm != null && zm.Call("CheckZoneID", e.Id) != null)
+ zm.Call("EraseTemporaryZone", this, e.Id);
+
+ _world.Remove(e.Id);
+
+ Dictionary frame = Frame("world.expired", "event");
+ frame["id"] = e.Id;
+ frame["kind"] = e.Kind;
+
+ if (!string.IsNullOrEmpty(e.RunId))
+ frame["runId"] = e.RunId;
+
+ EmitFrame(frame);
+ }
+
+ SaveWorld();
+ _lastWorldSummary = $"{due.Count} zone(s) reached their deadline and were erased";
+ Puts($"world: {_lastWorldSummary}");
+ }
+
+ ///
+ /// Main thread. Drops everything recorded against another wipe — that map, and all of
+ /// it, is gone. Only callable once the wipe is known, which a real server start does not
+ /// know until OnServerInitialized .
+ ///
+ private void DropWorldFromOtherWipes()
+ {
+ if (string.IsNullOrEmpty(_wipeId) || _world.Count == 0)
+ return;
+
+ var stale = new List();
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (!string.IsNullOrEmpty(e.WipeId) && e.WipeId != _wipeId)
+ stale.Add(e.Id);
+ }
+
+ if (stale.Count == 0)
+ return;
+
+ foreach (string id in stale)
+ _world.Remove(id);
+
+ SaveWorld();
+ _lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map";
+ Puts($"world: {_lastWorldSummary}");
+ }
+
+ /// What events have made on this map, from the server console.
+ [ConsoleCommand("rg.world")]
+ private void CmdWorld(ConsoleSystem.Arg arg)
+ {
+ int zones = 0, crates = 0, npcs = 0;
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (e.Kind == WorldKindZone)
+ zones++;
+ else if (e.Kind == WorldKindNpc)
+ npcs++;
+ else
+ crates++;
+ }
+
+ arg.ReplyWith(
+ $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
+ $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " +
+ $"wipeId={(_wipeId ?? "none")}" + Environment.NewLine +
+ $"last: {_lastWorldSummary}");
+ }
+
// ---- reading a command's fields ----
//
// `JsonConvert` hands back `object`, and every field below arrives as one. These are the