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 0fa0ad1..85c9a41 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;
@@ -1254,6 +1297,12 @@ 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).
+ 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.
@@ -1294,11 +1343,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")}");
}
@@ -3590,6 +3642,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)
@@ -5797,6 +5854,1157 @@ 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;
+
+ 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;
+ }
+
+ private bool WorldWritesAllowed(string reqId)
+ {
+ 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)
+ {
+ var rows = new List