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(); + + 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) + { + 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) + { + 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) + { + 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