From 8fdc374ab3c6b39ab1b87c9b5aef817e53854339 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 03:03:30 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20TruePVE=20in=20the=20zone=20step=20?= =?UTF-8?q?(PLAN=5FREDESIGNS=20=C2=A714.3.2,=20WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the §14.3 integration plan (D362, D368): world.zone's optional truePve ({exclude: true} or {ruleset}), all or nothing, unmapped on every path that erases one of our zones, with truepve.json for erases while TruePVE is away. Docs and PRs still to come. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 265 +++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 3 deletions(-) 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()) } }; } -- 2.49.1 From 491760d81733d3a7c486138bb95f240d849e035e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 05:47:58 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(bridge):=20TruePVE=20in=20a=20zone=20o?= =?UTF-8?q?n=20protocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the §14.3 integration plan (D362, D368), specified in docs rust-link/PROTOCOL.md §20.6. - world.zone's optional truePve: {"exclude": true} or {"ruleset": "name"}, exactly one, the name 1-64 characters. Malformed is `malformed`; TruePVE not loaded is `truepve-missing`. - All or nothing: the zone (and its dome) is made, then AddOrUpdateMapping. A false erases both and answers `truepve-ruleset` with the name (or `refused` for exclude). - The registry entry remembers its mapping, and every path that erases one of our zones calls RemoveMapping first: the deadline, world.revert, a zone world.owned finds gone, a new wipe. While TruePVE is away the id waits in data/RunicGateway/truepve.json, flushed at OnPluginLoaded(TruePVE) and at the bridge's own load. - hello's integrations.truePve { loaded, version }, sent again when TruePVE loads or unloads. rg.world prints truePve= and truePveLeftover=. Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262, TruePVE 2.4.6). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 5 +++++ overlay/oxide/plugins/RunicGateway.cs | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index faf0d7e..3570289 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ backpack from the site, a player can see their own, and an event can lend a bigg the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own `backpacks.size.N` permissions, pushed like any other grant. +nivex's `TruePVE` is optional in the same way. Where it is loaded an event's zone can turn off PvP +protection inside it, or use one of the operator's own TruePVE rulesets by name; where it is not, the +zone step says "TruePVE is not installed on this server". The bridge only ever adds and removes the +mappings of zones it opened, and removes each one with its zone, even when TruePVE was away at the time. + Rust's own world events need no plugin at all. The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea reach the site's feed and its event triggers on every server, and an event can call one of them (`world.call`). diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index f5bdd35..532e8ac 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -5169,8 +5169,9 @@ namespace Oxide.Plugins // 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; - // and Backpacks, whose sizes are what a Bigger backpack step offers. - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin)) + // and Backpacks, whose sizes are what a Bigger backpack step offers; and TruePVE, which a + // zone step's PvP rules are refused without (§14.3.2). + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin)) NextTick(ResendHello); // Economics loaded after players connected: what they hold now is not otherwise known @@ -5200,7 +5201,7 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin)) NextTick(ResendHello); ConfigWriteJob job = _configJob; -- 2.49.1