From 7c9c8bdacb569f25793fd88ef985a142b2b9ad5d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 06:12:04 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20Dynamic=20PVP's=20zones=20on=20?= =?UTF-8?q?protocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4 of the §14.3 integration plan, specified in docs rust-link/PROTOCOL.md §20.7 and §17.3. - pvp.zone.created { zoneId, event, x, z, radius, shape, durationSec } from OnCreatedDynamicPVP, and pvp.zone.deleted { zoneId, event } from OnDeletedDynamicPVP. Neither is sent while the world is not live. - Size and place come from ZoneManager: a sphere's Radius, or half a cube's longer side (cargo's zone is a cube); the position is the zone's transform, so cargo's moves with the ship. - map.live gains a `pvp` layer of the live zones, kept by the two hooks and seeded from Dynamic PVP's own AllDynamicPVPZones / GetEventName at world ready and when it loads; a zone ZoneManager no longer holds is dropped. - hello's integrations.dynamicPvp, sent again when it loads or unloads; rg.world prints dynamicPvp= and pvpZones=; both hooks join rg.hooks. - The bridge never makes, maps or erases one of Dynamic PVP's zones. Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 4 + overlay/oxide/plugins/RunicGateway.cs | 229 +++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3570289..df9444e 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ protection inside it, or use one of the operator's own TruePVE rulesets by name; 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. +HunterZ's `Dynamic PVP` (which needs ZoneManager and TruePVE) is optional too. Where it is loaded, the +PvP zones it opens around a downed helicopter, a Bradley, cargo and the rest reach the site's feed and +its map. The bridge only reads them: it never makes, maps or erases one of Dynamic PVP's zones. + 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 532e8ac..5d578db 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -68,7 +68,10 @@ namespace Oxide.Plugins /// 24 types, world.call, hello's console.plugins, and the tally's /// cratesHacked and dropsLooted, where apcKills and /// 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. + /// world.zone's truePve and the integrations.truePve it needs; and + /// Dynamic PVP (§20.7, PLAN_REDESIGNS.md §14.3.3): pvp.zone.created and + /// pvp.zone.deleted, map.live's pvp layer and + /// integrations.dynamicPvp. /// /// private const int ProtocolVersion = 14; @@ -272,7 +275,9 @@ namespace Oxide.Plugins "OnCargoShipHarborApproach", "OnCargoShipHarborArrived", "OnCargoShipHarborLeave", "OnCargoShipEgress", "OnHelicopterDropCrate", "OnCrateLanded", "CanHackCrate", "OnCrateHack", "OnCrateHackEnd", "OnLootEntity", "OnCargoPlaneSignaled", "OnAirdrop", "OnSupplyDropDropped", "OnSupplyDropLanded", - "OnDeepSeaOpened", "OnDeepSeaClosed" + "OnDeepSeaOpened", "OnDeepSeaClosed", + // Protocol 14, Dynamic PVP's zones (§20.7). + "OnCreatedDynamicPVP", "OnDeletedDynamicPVP" }; private void Init() @@ -1629,6 +1634,7 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); + SeedPvpZones("load"); // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which // carries what the probe found. @@ -5170,10 +5176,15 @@ namespace Oxide.Plugins // 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; 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)) + // zone step's PvP rules are refused without (§14.3.2); and Dynamic PVP (§14.3.3). + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin)) NextTick(ResendHello); + // Dynamic PVP raises its creates as it opens its zones, which at a load are its + // monuments'; seeding once it has finished starting catches any it opened before us. + if (IsDynamicPvp(plugin)) + NextTick(() => SeedPvpZones("Dynamic PVP loaded")); + // Economics loaded after players connected: what they hold now is not otherwise known // until it moves (D336). if (IsEconomics(plugin)) @@ -5201,9 +5212,13 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin)) NextTick(ResendHello); + // Its Unload deleted every zone, each with its hook; whatever is left was missed. + if (IsDynamicPvp(plugin)) + _pvpZones.Clear(); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -8730,6 +8745,204 @@ namespace Oxide.Plugins } } + // ---- Dynamic PVP's zones (PLAN_REDESIGNS §14.3.3) ---- + // + // HunterZ's Dynamic PVP opens its own PvP zones around a downed helicopter, a Bradley, a + // locked crate, an airdrop, cargo and the rest. They are its own: the bridge never makes, + // maps or erases one, and only says that they are there, for the feed and the map. The + // §14.2 test and the plugin's own source (5.2.0) shape it: + // + // * `OnCreatedDynamicPVP(zoneId, eventName, position, duration)` and + // `OnDeletedDynamicPVP(zoneId, eventName)` are raised for every zone, a reload's and an + // unload's included. Its ids are always numeric, so never one of our `rg-…`. + // * Neither hook carries the zone's size, and a zone is a sphere (`Radius`) or a cube + // (`Size`, cargo's), so both are read from ZoneManager. Cargo's zone is parented to the + // ship, so where a zone is comes from its transform, never from where it was made. + // * `AllDynamicPVPZones()` and `GetEventName(zoneId)` say which zones are live, so the + // set is seeded from Dynamic PVP itself at load: a hotload of the bridge hears no creates. + // * `OnPlayerEnterPVP` / `ExitPVP` are not subscribed: they would say who stands where. + + private const string PvpZoneCreatedKind = "pvp.zone.created"; + private const string PvpZoneDeletedKind = "pvp.zone.deleted"; + + /// One of Dynamic PVP's live zones: its event's name, and how long it was given. + private sealed class PvpZone + { + public string Event; + + /// Seconds, or -1 for "as long as its event" (cargo, a monument's). + public int DurationSec; + } + + /// Dynamic PVP's live zones, by its zone id. Main thread only. + private readonly Dictionary _pvpZones = new Dictionary(StringComparer.Ordinal); + + private Plugin DynamicPvpPlugin() + { + return plugins.Find("DynamicPVP"); + } + + private bool IsDynamicPvp(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "DynamicPVP"; + } + + private void OnCreatedDynamicPVP(string zoneId, string eventName, UnityEngine.Vector3 position, float duration) + { + MarkHook("OnCreatedDynamicPVP"); + + if (string.IsNullOrEmpty(zoneId)) + return; + + var zone = new PvpZone { Event = eventName ?? "", DurationSec = duration > 0f ? (int)Math.Round(duration) : -1 }; + _pvpZones[zoneId] = zone; + + // A boot or a reload of Dynamic PVP opens every monument's zone at once, and that is + // not news; the map has them all the same. + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(PvpZoneCreatedKind, "event"); + frame["zoneId"] = zoneId; + frame["event"] = zone.Event; + + UnityEngine.Vector3 at; + float radius; + string shape; + + if (!PvpZoneShape(zoneId, out at, out radius, out shape)) + at = position; + + PutPosition(frame, at); + frame["radius"] = Math.Round(radius, 1); + frame["shape"] = shape; + frame["durationSec"] = zone.DurationSec; + EmitFrame(frame); + } + + private void OnDeletedDynamicPVP(string zoneId, string eventName) + { + MarkHook("OnDeletedDynamicPVP"); + + if (string.IsNullOrEmpty(zoneId) || !_pvpZones.Remove(zoneId)) + return; + + // A shutdown deletes every zone, and that is not news. Dynamic PVP's own unload does too, + // and is said: the zones really have closed, and the line is staff's. + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(PvpZoneDeletedKind, "event"); + frame["zoneId"] = zoneId; + frame["event"] = eventName ?? ""; + EmitFrame(frame); + } + + /// + /// Main thread. Where one of Dynamic PVP's zones is now, and how big: a sphere's radius, + /// or for a cube half its longer side across the ground, so the map can draw a ring. + /// False when ZoneManager does not hold it. + /// + private bool PvpZoneShape(string zoneId, out UnityEngine.Vector3 at, out float radius, out string shape) + { + at = UnityEngine.Vector3.zero; + radius = 0f; + shape = "sphere"; + + Plugin zm = ZoneManagerPlugin(); + var zone = zm != null ? zm.Call("GetZoneByID", zoneId) as UnityEngine.Component : null; + + if (zone == null || zone.transform == null) + return false; + + at = zone.transform.position; + object r = zm.Call("GetZoneRadius", zoneId); + + if (r is float && (float)r > 0f) + { + radius = (float)r; + return true; + } + + object s = zm.Call("GetZoneSize", zoneId); + + if (s is UnityEngine.Vector3) + { + var size = (UnityEngine.Vector3)s; + radius = Math.Max(size.x, size.z) / 2f; + shape = "cube"; + } + + return true; + } + + /// Main thread. The live zones from Dynamic PVP itself, replacing what the hooks kept. + private void SeedPvpZones(string why) + { + _pvpZones.Clear(); + Plugin dpvp = DynamicPvpPlugin(); + + if (dpvp == null) + return; + + var ids = dpvp.Call("AllDynamicPVPZones") as string[]; + + if (ids == null) + return; + + foreach (string id in ids) + { + if (string.IsNullOrEmpty(id)) + continue; + + // How long it was given is not asked for; the map does not need it. + _pvpZones[id] = new PvpZone { Event = dpvp.Call("GetEventName", id) as string ?? "", DurationSec = -1 }; + } + + if (_pvpZones.Count > 0) + Puts($"map: {_pvpZones.Count} Dynamic PVP zone(s) after {why}"); + } + + /// Main thread. The map's pvp layer: every live zone ZoneManager still holds, where it is now. + private List PvpZoneRows() + { + var rows = new List(); + List gone = null; + + foreach (KeyValuePair z in _pvpZones) + { + UnityEngine.Vector3 at; + float radius; + string shape; + + if (!PvpZoneShape(z.Key, out at, out radius, out shape)) + { + // Erased without its hook (ZoneManager reloaded); Dynamic PVP's own state is + // its business, and the map shows only what is there. + if (gone == null) + gone = new List(); + + gone.Add(z.Key); + continue; + } + + Dictionary row = At(at); + row["zoneId"] = z.Key; + row["event"] = z.Value.Event; + row["radius"] = Math.Round(radius, 1); + row["shape"] = shape; + rows.Add(row); + } + + if (gone != null) + { + foreach (string id in gone) + _pvpZones.Remove(id); + } + + return rows; + } + // ---- the zone's messages (D193) ---- // // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its @@ -10033,6 +10246,7 @@ namespace Oxide.Plugins $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + $"truePve={(TruePvePlugin() != null)} truePveLeftover={_truePvePending.Count} " + + $"dynamicPvp={(DynamicPvpPlugin() != null)} pvpZones={_pvpZones.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 + @@ -13641,7 +13855,9 @@ namespace Oxide.Plugins { "economics", EconomicsBody() }, { "backpacks", BackpacksBody() }, // Protocol 14 (§14.3.2): whether a zone step may change PvP rules here. - { "truePve", IntegrationBody(TruePvePlugin()) } + { "truePve", IntegrationBody(TruePvePlugin()) }, + // Protocol 14 (§14.3.3): whether the map's pvp layer and the feed's zone lines can come. + { "dynamicPvp", IntegrationBody(DynamicPvpPlugin()) } }; } @@ -15046,6 +15262,7 @@ namespace Oxide.Plugins reply["events"] = events; reply["players"] = players; reply["bases"] = bases; + reply["pvp"] = PvpZoneRows(); reply["tookMs"] = clock.ElapsedMilliseconds; if (sleepersTruncated) -- 2.49.1