Merge pull request 'fix(bridge): protocol 13 step 2 — expiry, gathering, raids, plugin loads, and a ZoneManager helper (F1 F3 F4 F8 F12 F13)' (#18) from fix/protocol-13-step2 into edge
Reviewed-on: #18
This commit is contained in:
@@ -29,8 +29,9 @@
|
||||
# exact commit being released.
|
||||
#
|
||||
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
|
||||
# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at
|
||||
# its top. It does NOT mirror `oxide/plugins/`: the same file goes to
|
||||
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, `RunicGatewayZones.cs`
|
||||
# and `manifest.json` at its top. Every `.cs` in it is listed in the
|
||||
# manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to
|
||||
# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each
|
||||
# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never
|
||||
# has to parse the version out of a path to find the manifest that states it.
|
||||
@@ -38,6 +39,10 @@
|
||||
# 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so
|
||||
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The
|
||||
# committed file keeps its placeholder; the manifest records the commit.
|
||||
# Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168,
|
||||
# D182 — today `RunicGatewayZones.cs`, the ZoneManager helper) keeps its own
|
||||
# version: it does one job, changes rarely, and the bridge reads its version at
|
||||
# hello to know which job it does.
|
||||
#
|
||||
# The version is never the protocol. The protocol lives in overlay.toml and
|
||||
# moves only when a message shape does; the version moves on every releasable
|
||||
@@ -73,6 +78,9 @@ env:
|
||||
REPO: RunicGateway/Rust-Plugins
|
||||
ARTIFACT: runicgateway-rust-plugin
|
||||
PLUGIN: overlay/oxide/plugins/RunicGateway.cs
|
||||
# The helpers shipped beside the bridge (D168, D182). Optional at runtime —
|
||||
# the bridge loads without them — and installed by default.
|
||||
HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs
|
||||
SEED_VERSION: "0.1.0"
|
||||
INSTALLER_REPO: RunicGateway/installer
|
||||
|
||||
@@ -279,6 +287,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; }
|
||||
for helper in $HELPERS; do
|
||||
[ -f "$helper" ] || { echo "::error::${helper} is missing"; exit 1; }
|
||||
done
|
||||
[ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; }
|
||||
node scripts/checkPlugin.js
|
||||
# The stamp below rewrites exactly one [Info(...)] line; zero or two
|
||||
@@ -304,6 +315,19 @@ jobs:
|
||||
grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \
|
||||
|| { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; }
|
||||
|
||||
# The helpers go in as committed, each keeping its own [Info] version.
|
||||
for helper in $HELPERS; do
|
||||
cp "$helper" "${STAGE}/$(basename "$helper")"
|
||||
done
|
||||
|
||||
# `files` names every .cs staged, with its sha256 — the installer places
|
||||
# exactly this set, and checks each file against it.
|
||||
PAIRS=()
|
||||
for f in "${STAGE}"/*.cs; do
|
||||
PAIRS+=("$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)")
|
||||
done
|
||||
FILES="$(jq -n '[$ARGS.positional | _nwise(2) | {(.[0]): .[1]}] | add' --args "${PAIRS[@]}")"
|
||||
|
||||
# Declarations from overlay.toml. Read, don't hardcode — the point of
|
||||
# that file is that the protocol number lives in one place.
|
||||
toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; }
|
||||
@@ -329,7 +353,7 @@ jobs:
|
||||
--arg min_oxide "${MIN_OXIDE}" \
|
||||
--arg min_carbon "${MIN_CARBON}" \
|
||||
--argjson requires "${REQUIRES}" \
|
||||
--arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \
|
||||
--argjson files "${FILES}" \
|
||||
'{
|
||||
component: $component,
|
||||
version: $version,
|
||||
@@ -339,7 +363,7 @@ jobs:
|
||||
min_oxide_version: $min_oxide,
|
||||
min_carbon_version: $min_carbon,
|
||||
requires_plugins: $requires,
|
||||
files: { "RunicGateway.cs": $sha }
|
||||
files: $files
|
||||
}' > "${STAGE}/manifest.json"
|
||||
echo "----- manifest.json -----"
|
||||
cat "${STAGE}/manifest.json"
|
||||
|
||||
12
README.md
12
README.md
@@ -34,9 +34,16 @@ unauthenticated command channel on the network.
|
||||
## Installing it
|
||||
|
||||
```
|
||||
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
||||
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
||||
overlay/oxide/plugins/RunicGatewayZones.cs → <server>/oxide/plugins/RunicGatewayZones.cs
|
||||
```
|
||||
|
||||
`RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager
|
||||
counts a player in a zone only when its trigger fires on entry, so a zone created, updated or re-created around
|
||||
somebody already standing there holds nobody. The helper patches that with Harmony. It is optional: without it the
|
||||
bridge still loads, scores its own zones by position, and reports the helper missing at hello. `rgz.status` in the
|
||||
server console says whether it patched.
|
||||
|
||||
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
|
||||
|
||||
```json
|
||||
@@ -69,7 +76,8 @@ egg, both of which deploy a **released** plugin and install the sidecar beside i
|
||||
Every merge to `main` that carries a `feat`, `fix` or `perf` commit cuts a release
|
||||
(`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-<version>.tar.gz` and `SHA256SUMS`. The
|
||||
tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]`
|
||||
version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the
|
||||
version stamped to the release), the helpers beside it (each keeping its own version; every `.cs` is listed with
|
||||
its sha256 in the manifest's `files`, and the installer and the egg place them all) and a `manifest.json` folding in `overlay.toml`: the protocol, the
|
||||
Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and
|
||||
the egg decide where the file goes. A release then asks the installer repo to recompose its Rust
|
||||
bundle.
|
||||
|
||||
@@ -38,9 +38,12 @@ protocol = 13
|
||||
|
||||
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
||||
#
|
||||
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
|
||||
# nothing, so it is expected to work on any reasonably current Oxide. This is the
|
||||
# oldest build it is known good on.
|
||||
# The overlay only ADDS files — the bridge, and beside it the ZoneManager helper
|
||||
# (`RunicGatewayZones.cs`, docs/modules/rust/PLAN_FIXES.md D181, D182), both into
|
||||
# `oxide/plugins/` — and patches nothing on disk, so it is expected to work on any
|
||||
# reasonably current Oxide. The helper patches ZoneManager in memory with Harmony,
|
||||
# which both frameworks load; without it the bridge still loads and falls back.
|
||||
# This is the oldest build it is known good on.
|
||||
#
|
||||
# There is no `patches_verified_against` key, and there is no `patches/` tier:
|
||||
# Rust's server is a binary and Oxide's hook API is the supported way in, so
|
||||
|
||||
@@ -234,6 +234,7 @@ namespace Oxide.Plugins
|
||||
"OnServerInitialized", "OnServerShutdown", "OnNewSave",
|
||||
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
|
||||
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
|
||||
"OnDispenserBonus", "OnCollectiblePickup", "OnGrowableGathered",
|
||||
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
|
||||
"CanUserLogin", "OnUserApproved",
|
||||
"OnUserPermissionGranted", "OnUserPermissionRevoked",
|
||||
@@ -1143,6 +1144,25 @@ namespace Oxide.Plugins
|
||||
// must not do it until the world it would be asking about exists (§28.6).
|
||||
hello["worldReady"] = _worldReady;
|
||||
|
||||
// The ZoneManager helper (D182), only where there is a ZoneManager for it to help.
|
||||
// `patched` is the whole fix; anything else means this plugin scores its zones by
|
||||
// position and ZoneManager's flags miss somebody already inside a new zone.
|
||||
if (ZoneManagerPlugin() != null)
|
||||
{
|
||||
var helper = new Dictionary<string, object> { { "state", ZoneHelperState() } };
|
||||
Plugin zones = ZoneHelperPlugin();
|
||||
|
||||
if (zones != null)
|
||||
{
|
||||
helper["version"] = zones.Version.ToString();
|
||||
|
||||
if (zones.Call("StateReason") is string reason)
|
||||
helper["reason"] = reason;
|
||||
}
|
||||
|
||||
hello["zoneHelper"] = helper;
|
||||
}
|
||||
|
||||
EmitFrame(hello);
|
||||
}
|
||||
|
||||
@@ -1163,7 +1183,7 @@ namespace Oxide.Plugins
|
||||
|
||||
var row = new Dictionary<string, object>
|
||||
{
|
||||
{ "steamId", player.UserIDString },
|
||||
{ "steamId", SteamIdOf(player) },
|
||||
{ "name", player.displayName },
|
||||
{ "sleeping", player.IsSleeping() }
|
||||
};
|
||||
@@ -1298,7 +1318,7 @@ namespace Oxide.Plugins
|
||||
if (attacker != null && !attacker.IsNpc)
|
||||
{
|
||||
frame["attackerType"] = "player";
|
||||
frame["attackerId"] = attacker.UserIDString;
|
||||
frame["attackerId"] = SteamIdOf(attacker);
|
||||
frame["attackerName"] = attacker.displayName;
|
||||
}
|
||||
else
|
||||
@@ -1447,6 +1467,7 @@ namespace Oxide.Plugins
|
||||
// and this hook also fires on a hotload, which is exactly when ZoneManager has just
|
||||
// erased every zone the previous load of this plugin owned (D92).
|
||||
_worldReady = true;
|
||||
SeedRegisteredPerms();
|
||||
DropWorldFromOtherWipes();
|
||||
RecreateZones("load");
|
||||
|
||||
@@ -1533,7 +1554,7 @@ namespace Oxide.Plugins
|
||||
_connectedAt[player.userID] = NowMs();
|
||||
|
||||
Dictionary<string, object> frame = Frame("player.connected", "event");
|
||||
frame["steamId"] = player.UserIDString;
|
||||
frame["steamId"] = SteamIdOf(player);
|
||||
frame["name"] = player.displayName;
|
||||
EmitFrame(frame);
|
||||
}
|
||||
@@ -1550,7 +1571,7 @@ namespace Oxide.Plugins
|
||||
FlushTally(player.userID);
|
||||
|
||||
Dictionary<string, object> frame = Frame("player.disconnected", "event");
|
||||
frame["steamId"] = player.UserIDString;
|
||||
frame["steamId"] = SteamIdOf(player);
|
||||
frame["name"] = player.displayName;
|
||||
|
||||
if (!string.IsNullOrEmpty(reason))
|
||||
@@ -1578,7 +1599,7 @@ namespace Oxide.Plugins
|
||||
return;
|
||||
|
||||
Dictionary<string, object> frame = Frame("player.respawned", "event");
|
||||
frame["steamId"] = player.UserIDString;
|
||||
frame["steamId"] = SteamIdOf(player);
|
||||
frame["name"] = player.displayName;
|
||||
EmitFrame(frame);
|
||||
}
|
||||
@@ -1601,7 +1622,7 @@ namespace Oxide.Plugins
|
||||
CreditKill(killer, true);
|
||||
|
||||
Dictionary<string, object> frame = Frame("player.death", "event");
|
||||
frame["steamId"] = player.UserIDString;
|
||||
frame["steamId"] = SteamIdOf(player);
|
||||
frame["name"] = player.displayName;
|
||||
frame["sleeping"] = player.IsSleeping();
|
||||
|
||||
@@ -1625,7 +1646,7 @@ namespace Oxide.Plugins
|
||||
return;
|
||||
|
||||
Dictionary<string, object> frame = Frame("player.chat", "event");
|
||||
frame["steamId"] = player.UserIDString;
|
||||
frame["steamId"] = SteamIdOf(player);
|
||||
frame["name"] = player.displayName;
|
||||
frame["channel"] = channel.ToString();
|
||||
|
||||
@@ -1650,6 +1671,60 @@ namespace Oxide.Plugins
|
||||
TallyFor(player).Gather(item.info.shortname, item.amount);
|
||||
}
|
||||
|
||||
// ---- the rest of what a player gathers (F1, D159) ----
|
||||
//
|
||||
// `OnDispenserGather` alone is the swings. The first walk had 825 wood in the inventory
|
||||
// and 587 on the tally, and 20 cloth from hemp and none on it: the bonus on a tree's final
|
||||
// hit is `OnDispenserBonus`, and anything picked up off the ground — hemp, mushrooms,
|
||||
// stones, a wild pumpkin — is `OnCollectiblePickup`. A farmed plant is
|
||||
// `OnGrowableGathered`. D159: "gathered" is everything a player harvests, so the
|
||||
// leaderboard agrees with the inventory. All three are counters (rule 2) and void (rule 1).
|
||||
|
||||
/// <summary>The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus.</summary>
|
||||
private void OnDispenserBonus(ResourceDispenser dispenser, BasePlayer player, Item item)
|
||||
{
|
||||
MarkHook("OnDispenserBonus");
|
||||
|
||||
if (player == null || player.IsNpc || item == null || item.info == null)
|
||||
return;
|
||||
|
||||
TallyFor(player).Gather(item.info.shortname, item.amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pick-up off the ground. Void: a non-null return cancels it. It fires BEFORE the
|
||||
/// items move, so what is counted is the collectible's list — and a pick-up another
|
||||
/// plugin vetoes is counted anyway, which is a rarity worth less than a second hook.
|
||||
/// </summary>
|
||||
private void OnCollectiblePickup(CollectibleEntity collectible, BasePlayer player)
|
||||
{
|
||||
MarkHook("OnCollectiblePickup");
|
||||
|
||||
if (player == null || player.IsNpc || collectible == null || collectible.itemList == null)
|
||||
return;
|
||||
|
||||
Tally tally = TallyFor(player);
|
||||
|
||||
foreach (ItemAmount amount in collectible.itemList)
|
||||
{
|
||||
if (amount == null || amount.itemDef == null)
|
||||
continue;
|
||||
|
||||
tally.Gather(amount.itemDef.shortname, (int)amount.amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A farmed plant, harvested. No return behaviour on either framework.</summary>
|
||||
private void OnGrowableGathered(GrowableEntity plant, Item item, BasePlayer player)
|
||||
{
|
||||
MarkHook("OnGrowableGathered");
|
||||
|
||||
if (player == null || player.IsNpc || item == null || item.info == null)
|
||||
return;
|
||||
|
||||
TallyFor(player).Gather(item.info.shortname, item.amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything that dies which is not a player death: NPC kills, which are a counter,
|
||||
/// and raided structures, which are an event with a `staff` class because a
|
||||
@@ -1712,7 +1787,13 @@ namespace Oxide.Plugins
|
||||
|
||||
// The tally's meaning is unchanged since protocol 2 — building blocks only — so a
|
||||
// leaderboard column does not quietly change what it counts in the middle of a wipe.
|
||||
if (structure == "block")
|
||||
//
|
||||
// **But not your own** (F3). The first walk's org lead broke their own wall and the
|
||||
// next tally carried `structures: 1`. Taking down a piece you placed, or one on a
|
||||
// cupboard you are authorised on, is building, not raiding. The frame still goes: the
|
||||
// site already skips the raid ALERT for an authorised attacker (D59), and a staff feed
|
||||
// that loses a line because the player happened to own it is a feed with a hole.
|
||||
if (structure == "block" && !OwnsOrAuthed(attacker, entity))
|
||||
TallyFor(attacker).Structures++;
|
||||
|
||||
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
|
||||
@@ -1834,7 +1915,7 @@ namespace Oxide.Plugins
|
||||
|
||||
if (reporter != null)
|
||||
{
|
||||
frame["reporterId"] = reporter.UserIDString;
|
||||
frame["reporterId"] = SteamIdOf(reporter);
|
||||
frame["reporterName"] = reporter.displayName;
|
||||
}
|
||||
|
||||
@@ -2620,7 +2701,7 @@ namespace Oxide.Plugins
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
string steamId = player.UserIDString;
|
||||
string steamId = SteamIdOf(player);
|
||||
if (string.IsNullOrEmpty(steamId))
|
||||
return;
|
||||
|
||||
@@ -2683,7 +2764,7 @@ namespace Oxide.Plugins
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
string steamId = player.UserIDString;
|
||||
string steamId = SteamIdOf(player);
|
||||
if (string.IsNullOrEmpty(steamId))
|
||||
return;
|
||||
|
||||
@@ -3965,13 +4046,90 @@ namespace Oxide.Plugins
|
||||
// produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin
|
||||
// that is DOWN because of an edit the website just made.
|
||||
//
|
||||
// They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from
|
||||
// either: a busy server loads and unloads plugins for reasons that have nothing to do with
|
||||
// us, and a frame per event would be noise on the wire and a trigger nobody asked for.
|
||||
// Protocol 13 made them a feed as well, and a narrow one (F8, D184). A grant the site made
|
||||
// for a plugin that was not loaded is `unresolved` until the plugin comes back — and the
|
||||
// first walk watched it land thirteen minutes after the plugin did, on the fifteen-minute
|
||||
// audit, under a page promising "it will land by itself". So a load or an unload is now
|
||||
// announced with the permissions that plugin added or removed, and the site re-syncs when
|
||||
// that list is not empty. Only once the world is ready: a boot loads every plugin, and a
|
||||
// boot is the restart sync's job.
|
||||
|
||||
/// <summary>The registered permission list as the last load or unload left it. Main thread.</summary>
|
||||
private HashSet<string> _registeredPerms;
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. The list as it is now; the first call is the baseline. From
|
||||
/// <c>OnServerInitialized</c>, which is also the moment <c>_worldReady</c> turns true.
|
||||
/// </summary>
|
||||
private void SeedRegisteredPerms()
|
||||
{
|
||||
string[] now = permission.GetPermissions();
|
||||
_registeredPerms = new HashSet<string>(now ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread, one tick after the hook. The diff is taken then rather than inside the
|
||||
/// hook because the order of "the framework drops an unloaded plugin's permissions" and
|
||||
/// "the framework calls <c>OnPluginUnloaded</c>" is the framework's, and a tick later
|
||||
/// both have happened on either. Two plugins loaded in the same tick share one diff and
|
||||
/// the first name gets it — the site's answer (re-sync) is the same either way, and
|
||||
/// §4.1's owner is taken from the inventory, not from this.
|
||||
/// </summary>
|
||||
private void EmitPluginChange(string name, string version, bool loaded)
|
||||
{
|
||||
if (_registeredPerms == null)
|
||||
return;
|
||||
|
||||
string[] now = permission.GetPermissions() ?? new string[0];
|
||||
HashSet<string> current = new HashSet<string>(now, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<string> changed = new List<string>();
|
||||
|
||||
if (loaded)
|
||||
{
|
||||
foreach (string perm in current)
|
||||
{
|
||||
if (!_registeredPerms.Contains(perm))
|
||||
changed.Add(perm);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string perm in _registeredPerms)
|
||||
{
|
||||
if (!current.Contains(perm))
|
||||
changed.Add(perm);
|
||||
}
|
||||
}
|
||||
|
||||
_registeredPerms = current;
|
||||
changed.Sort(StringComparer.Ordinal);
|
||||
|
||||
Dictionary<string, object> frame = Frame(loaded ? "plugin.loaded" : "plugin.unloaded", "event");
|
||||
frame["name"] = name;
|
||||
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
frame["version"] = version;
|
||||
|
||||
frame["permissions"] = changed;
|
||||
EmitFrame(frame);
|
||||
}
|
||||
|
||||
/// <summary>Main thread. Queues <see cref="EmitPluginChange"/> for somebody else's plugin, once the world is up.</summary>
|
||||
private void NotePluginChange(Plugin plugin, bool loaded)
|
||||
{
|
||||
if (plugin == null || plugin == this || !_worldReady)
|
||||
return;
|
||||
|
||||
string name = plugin.Name;
|
||||
string version = plugin.Version.ToString();
|
||||
NextTick(() => EmitPluginChange(name, version, loaded));
|
||||
}
|
||||
|
||||
private void OnPluginLoaded(Plugin plugin)
|
||||
{
|
||||
MarkHook("OnPluginLoaded");
|
||||
NotePluginChange(plugin, true);
|
||||
|
||||
// ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an
|
||||
// event opened. They are put back from this plugin's own record (D92).
|
||||
@@ -3995,6 +4153,7 @@ namespace Oxide.Plugins
|
||||
private void OnPluginUnloaded(Plugin plugin)
|
||||
{
|
||||
MarkHook("OnPluginUnloaded");
|
||||
NotePluginChange(plugin, false);
|
||||
|
||||
if (plugin != null && plugin.Name == "BetterChat")
|
||||
_titlesRegistered = false;
|
||||
@@ -6925,6 +7084,52 @@ namespace Oxide.Plugins
|
||||
return plugins.Find("ZoneManager");
|
||||
}
|
||||
|
||||
// ---- the ZoneManager helper (F12, D181, D182) ----
|
||||
//
|
||||
// ZoneManager counts a player in a zone only when its trigger fires on entry, so a zone
|
||||
// created, updated or re-created around somebody standing there holds nobody. The helper,
|
||||
// `RunicGatewayZones.cs`, ships beside this file and patches that. It is optional: without it,
|
||||
// or when it loaded and could not find what it patches, the tally and the kill credit ask
|
||||
// ZoneManager's public `IsPositionInZone` instead of its membership — so a missing helper
|
||||
// costs ZoneManager's own flags for somebody already inside, never their score.
|
||||
|
||||
private Plugin ZoneHelperPlugin()
|
||||
{
|
||||
return plugins.Find("RunicGatewayZones");
|
||||
}
|
||||
|
||||
/// <summary>Main thread. <c>patched</c>, <c>unsupported</c>, <c>no-zonemanager</c> or <c>missing</c>.</summary>
|
||||
private string ZoneHelperState()
|
||||
{
|
||||
Plugin helper = ZoneHelperPlugin();
|
||||
return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. Whether ZoneManager's own membership can be trusted for a zone that may
|
||||
/// have been made around somebody. Asked once per tally tick or kill, not per player.
|
||||
/// </summary>
|
||||
private bool ZoneMembershipTrusted()
|
||||
{
|
||||
return ZoneHelperState() == "patched";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. Is this player in the zone: ZoneManager's membership when the helper has
|
||||
/// made it right, and the zone's shape otherwise (D182).
|
||||
/// </summary>
|
||||
private static bool InZone(Plugin zm, string zoneId, BasePlayer player, bool trusted)
|
||||
{
|
||||
if (zm == null || player == null)
|
||||
return false;
|
||||
|
||||
object inside = trusted
|
||||
? zm.Call("IsPlayerInZone", zoneId, player)
|
||||
: zm.Call("IsPositionInZone", zoneId, player.transform.position);
|
||||
|
||||
return inside is bool && (bool)inside;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The map's monuments in the one order every caller agrees on: grouped by prefab short
|
||||
/// name, then by position. <c>#n</c> in a value counts within its group, so it names the
|
||||
@@ -7898,7 +8103,12 @@ namespace Oxide.Plugins
|
||||
|
||||
Dictionary<string, object> frame = Frame("world.expired", "event");
|
||||
frame["id"] = e.Id;
|
||||
frame["kind"] = e.Kind;
|
||||
|
||||
// `what`, never `kind` (F13, protocol 13). Until 13 this line wrote the entity's
|
||||
// kind over the FRAME's, so every expiry left the plugin filed as `kind: "zone"`
|
||||
// and nothing listening for `world.expired` ever heard one. `what` is the name the
|
||||
// site's resource rows already use for the same thing.
|
||||
frame["what"] = e.Kind;
|
||||
|
||||
if (!string.IsNullOrEmpty(e.RunId))
|
||||
frame["runId"] = e.RunId;
|
||||
@@ -8456,7 +8666,24 @@ namespace Oxide.Plugins
|
||||
if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null)
|
||||
return;
|
||||
|
||||
present = zm.Call("GetPlayersInZone", t.ZoneId) as List<BasePlayer>;
|
||||
if (ZoneMembershipTrusted())
|
||||
{
|
||||
present = zm.Call("GetPlayersInZone", t.ZoneId) as List<BasePlayer>;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No helper (D182): ZoneManager's membership misses whoever was standing here
|
||||
// when the zone was made, so the zone's own shape decides.
|
||||
var inside = new List<BasePlayer>();
|
||||
|
||||
foreach (BasePlayer player in BasePlayer.activePlayerList)
|
||||
{
|
||||
if (InZone(zm, t.ZoneId, player, false))
|
||||
inside.Add(player);
|
||||
}
|
||||
|
||||
present = inside;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -8534,6 +8761,7 @@ namespace Oxide.Plugins
|
||||
long now = NowMs();
|
||||
Plugin zm = null;
|
||||
bool zmLooked = false;
|
||||
bool trusted = false;
|
||||
|
||||
foreach (RunTally t in _runTallies.Values)
|
||||
{
|
||||
@@ -8548,12 +8776,12 @@ namespace Oxide.Plugins
|
||||
if (!zmLooked)
|
||||
{
|
||||
zm = ZoneManagerPlugin();
|
||||
trusted = ZoneMembershipTrusted();
|
||||
zmLooked = true;
|
||||
}
|
||||
|
||||
object inside = zm != null ? zm.Call("IsPlayerInZone", t.ZoneId, killer) : null;
|
||||
|
||||
if (!(inside is bool) || !(bool)inside)
|
||||
// Membership with the helper, the zone's shape without it (D182).
|
||||
if (!InZone(zm, t.ZoneId, killer, trusted))
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8913,7 +9141,7 @@ namespace Oxide.Plugins
|
||||
|
||||
KitCredit c;
|
||||
|
||||
if (!_credits.TryGetValue(CreditKey(player.UserIDString, kitName), out c))
|
||||
if (!_credits.TryGetValue(CreditKey(SteamIdOf(player), kitName), out c))
|
||||
return;
|
||||
|
||||
int applied = 0, withdrawn = 0;
|
||||
@@ -10470,6 +10698,26 @@ namespace Oxide.Plugins
|
||||
/// read it back empty from a sleeper made on the server: an id the map could not name
|
||||
/// is an own dot and a clan mate the site can never find.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Whether the player placed this piece or is authorised on the cupboard that covers
|
||||
/// it — the test for "their own base" (F3). Main thread: it reads the building's
|
||||
/// privilege. A piece with no cupboard is judged on its owner alone.
|
||||
/// </summary>
|
||||
private static bool OwnsOrAuthed(BasePlayer player, BaseEntity entity)
|
||||
{
|
||||
if (player == null || entity == null)
|
||||
return false;
|
||||
|
||||
if (entity.OwnerID == (ulong)player.userID)
|
||||
return true;
|
||||
|
||||
// `authorizedPlayers` rather than `IsAuthed`, for AddAuthorization's reason: it is the
|
||||
// set this plugin already reads for the raid frame, proven on both frameworks.
|
||||
BuildingPrivlidge privilege = entity.GetBuildingPrivilege();
|
||||
return privilege != null && privilege.authorizedPlayers != null
|
||||
&& privilege.authorizedPlayers.Contains(player.userID);
|
||||
}
|
||||
|
||||
private static string SteamIdOf(BasePlayer p)
|
||||
{
|
||||
ulong id = p.userID;
|
||||
|
||||
334
overlay/oxide/plugins/RunicGatewayZones.cs
Normal file
334
overlay/oxide/plugins/RunicGatewayZones.cs
Normal file
@@ -0,0 +1,334 @@
|
||||
// Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge.
|
||||
// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later.
|
||||
//
|
||||
// ONE JOB (docs/modules/rust/PLAN_FIXES.md F12, D168, D181, D182):
|
||||
//
|
||||
// ZoneManager counts a player as inside a zone only when the zone's trigger fires on ENTRY. A
|
||||
// zone created, updated or loaded around somebody who is already standing there never fires it,
|
||||
// so ZoneManager says nobody is inside — and its flags, its enter message and `OnEnterZone` never
|
||||
// happen for them. The first player walk found it twice: after a restart the org lead woke up
|
||||
// eighteen metres inside an event zone the bridge had just re-created, and ZoneManager's
|
||||
// `IsPlayerInZone` said no; and a zone opened around a player did the same. Every participant of
|
||||
// an event that survives a restart scores nothing until they walk out and back in.
|
||||
//
|
||||
// ZoneManager has no public way to ask it to look again: the routine that does (`UpdatePlayerZones`)
|
||||
// is private and runs only for a player who is connecting. So this helper patches ZoneManager with
|
||||
// Harmony, which is the org lead's decision (D181):
|
||||
//
|
||||
// * **The patch point is `Zone.InitializeZone`**, the one method a zone passes through when it is
|
||||
// created (`CreateOrUpdateZone`), updated (`Zone.Reset`) or loaded from ZoneManager's own data
|
||||
// file at startup. A postfix there sees every one of them.
|
||||
// * **It does not decide who is inside.** One tick later — when ZoneManager has finished
|
||||
// registering the zone — it asks ZoneManager's own public `IsPositionInZone` for each connected
|
||||
// player, and enters those it names through ZoneManager's own `OnPlayerEnterZone`. So ZoneManager's
|
||||
// flags, eject and kill rules, enter message and `OnEnterZone` all apply exactly as if the
|
||||
// player had walked in. A player ZoneManager already counts is skipped, and ZoneManager refuses
|
||||
// a second entry anyway, so a trigger that does fire after all changes nothing.
|
||||
// * **It fixes every zone on the server**, not only the bridge's. That is deliberate: the fault is
|
||||
// ZoneManager's, and it is the same fault for an arena another plugin made.
|
||||
//
|
||||
// It is optional. The bridge never depends on it to load: without it — deleted, or loaded but
|
||||
// unable to find what it patches after a ZoneManager update — the bridge scores the zones it made
|
||||
// by `IsPositionInZone` instead (D182), and reports this helper's state at hello so the site can
|
||||
// say what is missing. `rgz.status` in the server console prints the same state.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using Oxide.Core.Plugins;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Oxide.Plugins
|
||||
{
|
||||
[Info("Runic Gateway Zones", "Runic Gateway", "0.1.0")]
|
||||
[Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded.")]
|
||||
public class RunicGatewayZones : RustPlugin
|
||||
{
|
||||
private const string HarmonyId = "runicgateway.zones";
|
||||
|
||||
/// <summary>The instance the static postfix reports to. Null while unloaded.</summary>
|
||||
private static RunicGatewayZones _instance;
|
||||
|
||||
private Harmony _harmony;
|
||||
private MethodInfo _patched;
|
||||
|
||||
// Reflected once per ZoneManager load. A ZoneManager reload is a new assembly, so all of it
|
||||
// is looked up again then.
|
||||
private Plugin _zoneManager;
|
||||
private MethodInfo _enter;
|
||||
private MethodInfo _getZone;
|
||||
private FieldInfo _definition;
|
||||
private PropertyInfo _id;
|
||||
|
||||
/// <summary>
|
||||
/// What the bridge reads through <see cref="State"/>: <c>patched</c>, <c>unsupported</c>
|
||||
/// (ZoneManager is here but not the shape this helper patches), or <c>no-zonemanager</c>.
|
||||
/// </summary>
|
||||
private string _state = "no-zonemanager";
|
||||
private string _reason;
|
||||
|
||||
/// <summary>Zones initialised this tick, swept once on the next. Main thread.</summary>
|
||||
private readonly Dictionary<string, object> _pending = new Dictionary<string, object>();
|
||||
private bool _sweepQueued;
|
||||
|
||||
private void OnServerInitialized()
|
||||
{
|
||||
_instance = this;
|
||||
Patch();
|
||||
}
|
||||
|
||||
private void Unload()
|
||||
{
|
||||
Unpatch();
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
private void OnPluginLoaded(Plugin plugin)
|
||||
{
|
||||
if (plugin != null && plugin.Name == "ZoneManager")
|
||||
{
|
||||
Unpatch();
|
||||
Patch();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPluginUnloaded(Plugin plugin)
|
||||
{
|
||||
if (plugin != null && plugin.Name == "ZoneManager")
|
||||
{
|
||||
Unpatch();
|
||||
_state = "no-zonemanager";
|
||||
_reason = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The API the bridge calls ─────────────────────────────────────────────
|
||||
|
||||
/// <summary><c>patched</c>, <c>unsupported</c> or <c>no-zonemanager</c>.</summary>
|
||||
private string State() => _state;
|
||||
|
||||
/// <summary>Why the state is not <c>patched</c>, or null.</summary>
|
||||
private string StateReason() => _reason;
|
||||
|
||||
/// <summary>The helper's own version, for hello.</summary>
|
||||
private string HelperVersion() => Version.ToString();
|
||||
|
||||
// ── Patching ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void Patch()
|
||||
{
|
||||
_zoneManager = plugins.Find("ZoneManager");
|
||||
|
||||
if (_zoneManager == null)
|
||||
{
|
||||
_state = "no-zonemanager";
|
||||
_reason = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
Type zmType = _zoneManager.GetType();
|
||||
Type zoneType = zmType.GetNestedType("Zone", BindingFlags.Public | BindingFlags.NonPublic);
|
||||
MethodInfo initialize = zoneType?.GetMethod("InitializeZone", any);
|
||||
|
||||
_getZone = zmType.GetMethod("GetZoneByID", any, null, new[] { typeof(string) }, null);
|
||||
_definition = zoneType?.GetField("definition", any);
|
||||
_id = _definition?.FieldType.GetProperty("Id", any);
|
||||
_enter = zoneType == null
|
||||
? null
|
||||
: zmType.GetMethod("OnPlayerEnterZone", any, null, new[] { typeof(BasePlayer), zoneType }, null);
|
||||
|
||||
string missing =
|
||||
zoneType == null ? "ZoneManager.Zone" :
|
||||
initialize == null ? "Zone.InitializeZone" :
|
||||
_definition == null || _id == null ? "Zone.definition.Id" :
|
||||
_enter == null ? "ZoneManager.OnPlayerEnterZone(BasePlayer, Zone)" :
|
||||
_getZone == null ? "ZoneManager.GetZoneByID(string)" :
|
||||
null;
|
||||
|
||||
if (missing != null)
|
||||
{
|
||||
Fail($"this ZoneManager ({_zoneManager.Version}) has no {missing}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_harmony = new Harmony(HarmonyId);
|
||||
_harmony.Patch(initialize, postfix: new HarmonyMethod(
|
||||
typeof(RunicGatewayZones).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic)));
|
||||
_patched = initialize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Fail($"Harmony could not patch Zone.InitializeZone: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_state = "patched";
|
||||
_reason = null;
|
||||
Puts($"patched ZoneManager {_zoneManager.Version}: a zone takes in the players already inside it");
|
||||
|
||||
// The zones ZoneManager loaded before this helper did were initialised unwatched —
|
||||
// which, at a server start, is all of them. Sweep them once now.
|
||||
SweepAll();
|
||||
}
|
||||
|
||||
private void Unpatch()
|
||||
{
|
||||
if (_harmony != null && _patched != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_harmony.Unpatch(_patched, HarmonyPatchType.Postfix, HarmonyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PrintWarning($"could not remove the ZoneManager patch: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_harmony = null;
|
||||
_patched = null;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
private void Fail(string reason)
|
||||
{
|
||||
Unpatch();
|
||||
_state = "unsupported";
|
||||
_reason = reason;
|
||||
PrintWarning($"{reason}. Runic Gateway scores its zones by position instead; ZoneManager's own flags will not apply to somebody already inside a new zone.");
|
||||
}
|
||||
|
||||
// ── The patch ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Harmony postfix on <c>ZoneManager.Zone.InitializeZone</c>. Static, as Harmony needs;
|
||||
/// it only records the zone. Anything thrown here would surface inside ZoneManager, so
|
||||
/// nothing is allowed to.
|
||||
/// </summary>
|
||||
private static void AfterInitialize(object __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
_instance?.Queue(__instance);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Never ZoneManager's problem.
|
||||
}
|
||||
}
|
||||
|
||||
private void Queue(object zone)
|
||||
{
|
||||
object definition = _definition.GetValue(zone);
|
||||
string id = definition == null ? null : _id.GetValue(definition) as string;
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return;
|
||||
|
||||
// The newest instance wins: a zone re-created in the same tick is one sweep.
|
||||
_pending[id] = zone;
|
||||
|
||||
if (_sweepQueued)
|
||||
return;
|
||||
|
||||
_sweepQueued = true;
|
||||
|
||||
// A tick later, not now. `CreateOrUpdateZone` initialises a zone once with an empty
|
||||
// definition and again after the arguments are applied, and ZoneManager's startup load
|
||||
// registers the zone in its table only AFTER initialising it — so `IsPositionInZone`
|
||||
// answers correctly on the next tick and not before.
|
||||
NextTick(SweepPending);
|
||||
}
|
||||
|
||||
private void SweepPending()
|
||||
{
|
||||
_sweepQueued = false;
|
||||
|
||||
if (_state != "patched" || _pending.Count == 0)
|
||||
{
|
||||
_pending.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var batch = new List<KeyValuePair<string, object>>(_pending);
|
||||
_pending.Clear();
|
||||
|
||||
foreach (KeyValuePair<string, object> entry in batch)
|
||||
Sweep(entry.Key, entry.Value);
|
||||
}
|
||||
|
||||
private void SweepAll()
|
||||
{
|
||||
var ids = _zoneManager.Call("GetZoneIDs") as string[];
|
||||
|
||||
if (ids == null)
|
||||
return;
|
||||
|
||||
foreach (string id in ids)
|
||||
Sweep(id, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. Enters every connected player ZoneManager says is inside the zone and
|
||||
/// does not yet count. <paramref name="zone"/> is the instance to enter them into, or null
|
||||
/// to look it up (the startup sweep).
|
||||
/// </summary>
|
||||
private void Sweep(string id, object zone)
|
||||
{
|
||||
Plugin zm = _zoneManager;
|
||||
|
||||
if (zm == null || zm.Call("CheckZoneID", id) == null)
|
||||
return;
|
||||
|
||||
zone = zone ?? _getZone.Invoke(zm, new object[] { id });
|
||||
|
||||
if (zone == null || (zone is UnityEngine.Object unity && unity == null))
|
||||
return;
|
||||
|
||||
int entered = 0;
|
||||
|
||||
// A copy: entering a player can eject or kill them, which changes the live list.
|
||||
// `activePlayerList` is Facepunch's ListHashSet, which has no ToArray.
|
||||
foreach (BasePlayer player in new List<BasePlayer>(BasePlayer.activePlayerList))
|
||||
{
|
||||
if (player == null || player.IsNpc || player.transform == null)
|
||||
continue;
|
||||
|
||||
if (zm.Call("IsPlayerInZone", id, player) is bool already && already)
|
||||
continue;
|
||||
|
||||
if (!(zm.Call("IsPositionInZone", id, player.transform.position) is bool inside) || !inside)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
_enter.Invoke(zm, new[] { (object)player, zone });
|
||||
entered++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PrintWarning($"could not enter {player.displayName} into zone {id}: {(ex.InnerException ?? ex).Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (entered > 0)
|
||||
Puts($"zone {id}: {entered} player(s) already inside were counted in");
|
||||
}
|
||||
|
||||
// ── Console ──────────────────────────────────────────────────────────────
|
||||
|
||||
[ConsoleCommand("rgz.status")]
|
||||
private void StatusCommand(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null && !arg.IsAdmin)
|
||||
return;
|
||||
|
||||
arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user