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()) }
};
}