feat(bridge): Dynamic PVP's zones on protocol 14 (PLAN_REDESIGNS §14.3.3)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -2m8s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-15 06:12:04 -05:00
parent 49a677e3fb
commit 7c9c8bdacb
2 changed files with 227 additions and 6 deletions

View File

@@ -68,7 +68,10 @@ namespace Oxide.Plugins
/// 24 types, <c>world.call</c>, hello's <c>console.plugins</c>, and the tally's
/// <c>cratesHacked</c> and <c>dropsLooted</c>, where <c>apcKills</c> and
/// <c>heliKills</c> are no longer sent; and TruePVE (§20.6, PLAN_REDESIGNS.md §14.3.2):
/// <c>world.zone</c>'s <c>truePve</c> and the <c>integrations.truePve</c> it needs.
/// <c>world.zone</c>'s <c>truePve</c> and the <c>integrations.truePve</c> it needs; and
/// Dynamic PVP (§20.7, PLAN_REDESIGNS.md §14.3.3): <c>pvp.zone.created</c> and
/// <c>pvp.zone.deleted</c>, <c>map.live</c>'s <c>pvp</c> layer and
/// <c>integrations.dynamicPvp</c>.
/// </para>
/// </summary>
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";
/// <summary>One of Dynamic PVP's live zones: its event's name, and how long it was given.</summary>
private sealed class PvpZone
{
public string Event;
/// <summary>Seconds, or -1 for "as long as its event" (cargo, a monument's).</summary>
public int DurationSec;
}
/// <summary>Dynamic PVP's live zones, by its zone id. Main thread only.</summary>
private readonly Dictionary<string, PvpZone> _pvpZones = new Dictionary<string, PvpZone>(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<string, object> 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<string, object> frame = Frame(PvpZoneDeletedKind, "event");
frame["zoneId"] = zoneId;
frame["event"] = eventName ?? "";
EmitFrame(frame);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Main thread. The live zones from Dynamic PVP itself, replacing what the hooks kept.</summary>
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}");
}
/// <summary>Main thread. The map's <c>pvp</c> layer: every live zone ZoneManager still holds, where it is now.</summary>
private List<object> PvpZoneRows()
{
var rows = new List<object>();
List<string> gone = null;
foreach (KeyValuePair<string, PvpZone> 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<string>();
gone.Add(z.Key);
continue;
}
Dictionary<string, object> 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)