diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 989d3e3..f5bdd35 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -67,7 +67,8 @@ namespace Oxide.Plugins /// world events (§20.5, PLAN_REDESIGNS.md §14.3.1): the world.event kind and its /// 24 types, world.call, hello's console.plugins, and the tally's /// cratesHacked and dropsLooted, where apcKills and - /// heliKills are no longer sent. + /// heliKills are no longer sent; and TruePVE (§20.6, PLAN_REDESIGNS.md §14.3.2): + /// world.zone's truePve and the integrations.truePve it needs. /// /// private const int ProtocolVersion = 14; @@ -561,6 +562,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(); + LoadTruePvePending(); LoadWorld(); LoadRewards(); LoadWorldCalls(); @@ -1621,6 +1623,10 @@ namespace Oxide.Plugins DropWorldFromOtherWipes(); RecreateZones("load"); + // Mappings an erase left while TruePVE was away (§14.3.2). When TruePVE loads after + // this, its own OnPluginLoaded does it. + FlushTruePvePending("load"); + // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); @@ -5155,6 +5161,11 @@ namespace Oxide.Plugins if (plugin != null && plugin != this && plugin.Name == "ZoneManager") NextTick(() => RecreateZones("ZoneManager reloaded")); + // TruePVE has read its table by now (it does in Init), so what an erase left while it + // was away can go (§14.3.2). + if (IsTruePve(plugin)) + NextTick(() => FlushTruePvePending("TruePVE loaded")); + // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). // Economics too: hello carries its starting balance, which the richest column needs; @@ -8086,6 +8097,13 @@ namespace Oxide.Plugins public int DomeType; public int DomeStack; + /// + /// The TruePVE mapping this zone was given (PLAN_REDESIGNS §14.3.2): exclude or a + /// ruleset's name, or null for none. Kept so the path that erases the zone removes the + /// mapping too (D362), and touches nothing else in TruePVE's table. + /// + public string TruePve; + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; @@ -8525,6 +8543,192 @@ namespace Oxide.Plugins NextTick(() => RestoreDomes("ZoneDomes started")); } + // ---- TruePVE in the zone step (PLAN_REDESIGNS §14.3.2, D362, D368) ---- + // + // A zone may ask TruePVE to leave it alone (`exclude`) or to use one of its rulesets. The + // §14.2 test found the three facts this is built on: + // + // * `AddOrUpdateMapping(zoneId, name)` answers false for a ruleset TruePVE does not have, + // and nothing lists the names, so the call is the check (D368). + // * TruePVE keeps its mappings in its own data file, across its reloads and a restart, and + // prunes a mapping whose zone is gone only when the zone id is a number. Ours are `rg-…`, + // so the path that erases one of our zones removes its mapping (D362). + // * It reads that file in `Init`, so by `OnPluginLoaded` the table is there to change. + // + // An erase while TruePVE is not loaded keeps the id in `truepve.json` and removes it when + // TruePVE comes back. Only ids this plugin mapped are ever removed. + + private const string TruePveExclude = "exclude"; + + /// The longest ruleset name a step may send. + private const int TruePveRulesetMax = 64; + + /// Zone ids whose mapping is still to remove, because TruePVE was not loaded. Main thread only. + private HashSet _truePvePending = new HashSet(StringComparer.Ordinal); + + private Plugin TruePvePlugin() + { + return plugins.Find("TruePVE"); + } + + private bool IsTruePve(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "TruePVE"; + } + + /// + /// A zone's truePve: {"exclude": true} or {"ruleset": "name"}, exactly one. + /// Null with true when the command has none; false with a reason when it is malformed. + /// + private static bool ReadTruePve(Dictionary cmd, out string mapping, out string refusal) + { + mapping = null; + refusal = null; + object raw; + + if (!cmd.TryGetValue("truePve", out raw) || raw == null) + return true; + + var o = raw as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken exclude = o != null ? o["exclude"] : null; + Newtonsoft.Json.Linq.JToken ruleset = o != null ? o["ruleset"] : null; + bool hasExclude = exclude != null && exclude.Type != Newtonsoft.Json.Linq.JTokenType.Null; + bool hasRuleset = ruleset != null && ruleset.Type != Newtonsoft.Json.Linq.JTokenType.Null; + + if (o == null || hasExclude == hasRuleset) + { + refusal = "a zone's truePve is {\"exclude\": true} or {\"ruleset\": \"name\"}, exactly one"; + return false; + } + + if (hasExclude) + { + if (exclude.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || !(bool)exclude) + { + refusal = "a zone's truePve exclude is true, or left out"; + return false; + } + + mapping = TruePveExclude; + return true; + } + + string name = ruleset.Type == Newtonsoft.Json.Linq.JTokenType.String ? ((string)ruleset).Trim() : null; + + if (string.IsNullOrEmpty(name) || name.Length > TruePveRulesetMax) + { + refusal = $"a TruePVE ruleset is named by 1 to {TruePveRulesetMax} characters"; + return false; + } + + mapping = name; + return true; + } + + /// + /// Main thread. Removes the TruePVE mapping of a zone this plugin is erasing, if it gave the + /// zone one. Called before the zone goes, on every path that erases one (D362). + /// + private void UnmapTruePve(WorldEntry e) + { + if (e == null || e.Kind != WorldKindZone || string.IsNullOrEmpty(e.TruePve)) + return; + + Plugin tp = TruePvePlugin(); + + if (tp != null) + { + // False is "not in the table", which is what this was for. + tp.Call("RemoveMapping", e.Id); + return; + } + + if (_truePvePending.Add(e.Id)) + { + SaveTruePvePending(); + Puts($"world: TruePVE is not loaded; {e.Id}'s mapping is removed when it is"); + } + } + + /// Main thread. Removes every mapping an erase left while TruePVE was away. + private void FlushTruePvePending(string why) + { + Plugin tp = TruePvePlugin(); + + if (tp == null || _truePvePending.Count == 0) + return; + + int removed = 0; + + foreach (string id in _truePvePending) + { + object gone = tp.Call("RemoveMapping", id); + + if (gone is bool && (bool)gone) + removed++; + } + + int count = _truePvePending.Count; + _truePvePending.Clear(); + SaveTruePvePending(); + + _lastWorldSummary = $"TruePVE after {why}: {removed} of {count} left-over mapping(s) removed"; + Puts($"world: {_lastWorldSummary}"); + } + + private string TruePvePendingPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "truepve.json"); + } + + private void SaveTruePvePending() + { + try + { + string path = TruePvePendingPath(); + + if (_truePvePending.Count == 0) + { + if (File.Exists(path)) + File.Delete(path); + + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, JsonConvert.SerializeObject(new List(_truePvePending), Formatting.Indented), Utf8NoBom); + } + catch (Exception ex) + { + Puts($"could not save the TruePVE leftovers: {Flatten(ex.Message)}"); + } + } + + private void LoadTruePvePending() + { + _truePvePending = new HashSet(StringComparer.Ordinal); + + try + { + string path = TruePvePendingPath(); + + if (!File.Exists(path)) + return; + + List ids = JsonConvert.DeserializeObject>(File.ReadAllText(path)); + + foreach (string id in ids ?? new List()) + { + if (!string.IsNullOrEmpty(id)) + _truePvePending.Add(id); + } + } + catch (Exception ex) + { + Puts($"could not read the TruePVE leftovers: {Flatten(ex.Message)}"); + } + } + // ---- the zone's messages (D193) ---- // // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its @@ -9020,6 +9224,22 @@ namespace Oxide.Plugins dome = true; } + // §14.3.2 (D368): TruePVE's rules in the zone, as `exclude` or one ruleset by name. + string truePve; + string truePveRefusal; + + if (!ReadTruePve(cmd, out truePve, out truePveRefusal)) + { + EmitWorldError(reqId, "malformed", truePveRefusal); + return; + } + + if (truePve != null && TruePvePlugin() == null) + { + EmitWorldError(reqId, "truepve-missing", "TruePVE is not loaded on this server, so a zone cannot change its PvP rules"); + return; + } + var entry = new WorldEntry { Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", @@ -9061,11 +9281,35 @@ namespace Oxide.Plugins return; } + // The mapping last, all or nothing like the dome: only TruePVE's own answer can say + // whether it has a ruleset of that name (D368), and a zone without the rules its + // step asked for is not opened at all. + if (truePve != null) + { + object mapped = TruePvePlugin()?.Call("AddOrUpdateMapping", entry.Id, truePve); + + if (!(mapped is bool && (bool)mapped)) + { + RemoveDome(entry); + zm.Call("EraseTemporaryZone", this, entry.Id); + + if (truePve == TruePveExclude) + EmitWorldError(reqId, "refused", "TruePVE did not map the zone, so it was not opened"); + else + EmitWorldError(reqId, "truepve-ruleset", + $"TruePVE on this server has no ruleset named '{truePve}' (names are case-sensitive), so the zone was not opened"); + + return; + } + + entry.TruePve = truePve; + } + _world[entry.Id] = entry; SaveWorld(); EnsureWorldTimer(); - _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}"; + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}{(truePve != null ? $", TruePVE {truePve}" : "")}, run {runId}"; Puts($"world: {_lastWorldSummary}"); EmitWorldPlaced(reqId, new List { entry }, false); } @@ -9416,6 +9660,9 @@ namespace Oxide.Plugins // left there outlives the zone it was drawn over (D194). RemoveDome(e); + // And its TruePVE mapping, which TruePVE never prunes for an id like ours (D362). + UnmapTruePve(e); + if (wasThere) zm.Call("EraseTemporaryZone", this, e.Id); @@ -9493,7 +9740,11 @@ namespace Oxide.Plugins if (pruned != null) { foreach (string id in pruned) + { + // A zone ZoneManager could not put back is erased all the same, mapping and all. + UnmapTruePve(_world[id]); _world.Remove(id); + } SaveWorld(); } @@ -9700,6 +9951,7 @@ namespace Oxide.Plugins foreach (WorldEntry e in due) { RemoveDome(e); + UnmapTruePve(e); if (zm != null && zm.Call("CheckZoneID", e.Id) != null) zm.Call("EraseTemporaryZone", this, e.Id); @@ -9748,8 +10000,12 @@ namespace Oxide.Plugins if (stale.Count == 0) return; + // TruePVE's table outlives a wipe, so the old map's mappings are removed with it. foreach (string id in stale) + { + UnmapTruePve(_world[id]); _world.Remove(id); + } SaveWorld(); _lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map"; @@ -9775,6 +10031,7 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + + $"truePve={(TruePvePlugin() != null)} truePveLeftover={_truePvePending.Count} " + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + $"worldEvents={(WorldEventsLive() ? "live" : $"quiet (ready={_worldReady} quitting={Rust.Application.isQuitting} loadingSave={Rust.Application.isLoadingSave})")}" + Environment.NewLine + @@ -13381,7 +13638,9 @@ namespace Oxide.Plugins { "zoneDomes", ZoneDomesBody() }, { "runicNpc", RunicNpcBody() }, { "economics", EconomicsBody() }, - { "backpacks", BackpacksBody() } + { "backpacks", BackpacksBody() }, + // Protocol 14 (§14.3.2): whether a zone step may change PvP rules here. + { "truePve", IntegrationBody(TruePvePlugin()) } }; }