Merge pull request 'feat(bridge): Dynamic PVP's zones on protocol 14 (PLAN_REDESIGNS §14.3.3)' (#37) from feat/dynamic-pvp into edge

Reviewed-on: #37
This commit is contained in:
2026-10-15 11:17:45 +00:00
2 changed files with 227 additions and 6 deletions

View File

@@ -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`).

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)