fix(bridge): protocol 13 step 2 — expiry, gathering, raids, plugin loads, and a ZoneManager helper (F1 F3 F4 F8 F12 F13) #18
@@ -29,8 +29,9 @@
|
|||||||
# exact commit being released.
|
# exact commit being released.
|
||||||
#
|
#
|
||||||
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
|
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
|
||||||
# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at
|
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, `RunicGatewayZones.cs`
|
||||||
# its top. It does NOT mirror `oxide/plugins/`: the same file goes to
|
# 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
|
# `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
|
# 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.
|
# 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
|
# 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
|
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The
|
||||||
# committed file keeps its placeholder; the manifest records the commit.
|
# 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
|
# 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
|
# moves only when a message shape does; the version moves on every releasable
|
||||||
@@ -73,6 +78,9 @@ env:
|
|||||||
REPO: RunicGateway/Rust-Plugins
|
REPO: RunicGateway/Rust-Plugins
|
||||||
ARTIFACT: runicgateway-rust-plugin
|
ARTIFACT: runicgateway-rust-plugin
|
||||||
PLUGIN: overlay/oxide/plugins/RunicGateway.cs
|
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"
|
SEED_VERSION: "0.1.0"
|
||||||
INSTALLER_REPO: RunicGateway/installer
|
INSTALLER_REPO: RunicGateway/installer
|
||||||
|
|
||||||
@@ -279,6 +287,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
[ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; }
|
[ -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; }
|
[ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; }
|
||||||
node scripts/checkPlugin.js
|
node scripts/checkPlugin.js
|
||||||
# The stamp below rewrites exactly one [Info(...)] line; zero or two
|
# 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" \
|
grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \
|
||||||
|| { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; }
|
|| { 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
|
# Declarations from overlay.toml. Read, don't hardcode — the point of
|
||||||
# that file is that the protocol number lives in one place.
|
# that file is that the protocol number lives in one place.
|
||||||
toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; }
|
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_oxide "${MIN_OXIDE}" \
|
||||||
--arg min_carbon "${MIN_CARBON}" \
|
--arg min_carbon "${MIN_CARBON}" \
|
||||||
--argjson requires "${REQUIRES}" \
|
--argjson requires "${REQUIRES}" \
|
||||||
--arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \
|
--argjson files "${FILES}" \
|
||||||
'{
|
'{
|
||||||
component: $component,
|
component: $component,
|
||||||
version: $version,
|
version: $version,
|
||||||
@@ -339,7 +363,7 @@ jobs:
|
|||||||
min_oxide_version: $min_oxide,
|
min_oxide_version: $min_oxide,
|
||||||
min_carbon_version: $min_carbon,
|
min_carbon_version: $min_carbon,
|
||||||
requires_plugins: $requires,
|
requires_plugins: $requires,
|
||||||
files: { "RunicGateway.cs": $sha }
|
files: $files
|
||||||
}' > "${STAGE}/manifest.json"
|
}' > "${STAGE}/manifest.json"
|
||||||
echo "----- manifest.json -----"
|
echo "----- manifest.json -----"
|
||||||
cat "${STAGE}/manifest.json"
|
cat "${STAGE}/manifest.json"
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -34,9 +34,16 @@ unauthenticated command channel on the network.
|
|||||||
## Installing it
|
## 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:
|
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
|
||||||
|
|
||||||
```json
|
```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
|
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
|
(`.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]`
|
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
|
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
|
the egg decide where the file goes. A release then asks the installer repo to recompose its Rust
|
||||||
bundle.
|
bundle.
|
||||||
|
|||||||
@@ -38,9 +38,12 @@ protocol = 13
|
|||||||
|
|
||||||
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
|
# The overlay only ADDS files — the bridge, and beside it the ZoneManager helper
|
||||||
# nothing, so it is expected to work on any reasonably current Oxide. This is the
|
# (`RunicGatewayZones.cs`, docs/modules/rust/PLAN_FIXES.md D181, D182), both into
|
||||||
# oldest build it is known good on.
|
# `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:
|
# 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
|
# 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",
|
"OnServerInitialized", "OnServerShutdown", "OnNewSave",
|
||||||
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
|
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
|
||||||
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
|
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
|
||||||
|
"OnDispenserBonus", "OnCollectiblePickup", "OnGrowableGathered",
|
||||||
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
|
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
|
||||||
"CanUserLogin", "OnUserApproved",
|
"CanUserLogin", "OnUserApproved",
|
||||||
"OnUserPermissionGranted", "OnUserPermissionRevoked",
|
"OnUserPermissionGranted", "OnUserPermissionRevoked",
|
||||||
@@ -1143,6 +1144,25 @@ namespace Oxide.Plugins
|
|||||||
// must not do it until the world it would be asking about exists (§28.6).
|
// must not do it until the world it would be asking about exists (§28.6).
|
||||||
hello["worldReady"] = _worldReady;
|
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);
|
EmitFrame(hello);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,7 +1183,7 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
var row = new Dictionary<string, object>
|
var row = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "steamId", player.UserIDString },
|
{ "steamId", SteamIdOf(player) },
|
||||||
{ "name", player.displayName },
|
{ "name", player.displayName },
|
||||||
{ "sleeping", player.IsSleeping() }
|
{ "sleeping", player.IsSleeping() }
|
||||||
};
|
};
|
||||||
@@ -1298,7 +1318,7 @@ namespace Oxide.Plugins
|
|||||||
if (attacker != null && !attacker.IsNpc)
|
if (attacker != null && !attacker.IsNpc)
|
||||||
{
|
{
|
||||||
frame["attackerType"] = "player";
|
frame["attackerType"] = "player";
|
||||||
frame["attackerId"] = attacker.UserIDString;
|
frame["attackerId"] = SteamIdOf(attacker);
|
||||||
frame["attackerName"] = attacker.displayName;
|
frame["attackerName"] = attacker.displayName;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1447,6 +1467,7 @@ namespace Oxide.Plugins
|
|||||||
// and this hook also fires on a hotload, which is exactly when ZoneManager has just
|
// 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).
|
// erased every zone the previous load of this plugin owned (D92).
|
||||||
_worldReady = true;
|
_worldReady = true;
|
||||||
|
SeedRegisteredPerms();
|
||||||
DropWorldFromOtherWipes();
|
DropWorldFromOtherWipes();
|
||||||
RecreateZones("load");
|
RecreateZones("load");
|
||||||
|
|
||||||
@@ -1533,7 +1554,7 @@ namespace Oxide.Plugins
|
|||||||
_connectedAt[player.userID] = NowMs();
|
_connectedAt[player.userID] = NowMs();
|
||||||
|
|
||||||
Dictionary<string, object> frame = Frame("player.connected", "event");
|
Dictionary<string, object> frame = Frame("player.connected", "event");
|
||||||
frame["steamId"] = player.UserIDString;
|
frame["steamId"] = SteamIdOf(player);
|
||||||
frame["name"] = player.displayName;
|
frame["name"] = player.displayName;
|
||||||
EmitFrame(frame);
|
EmitFrame(frame);
|
||||||
}
|
}
|
||||||
@@ -1550,7 +1571,7 @@ namespace Oxide.Plugins
|
|||||||
FlushTally(player.userID);
|
FlushTally(player.userID);
|
||||||
|
|
||||||
Dictionary<string, object> frame = Frame("player.disconnected", "event");
|
Dictionary<string, object> frame = Frame("player.disconnected", "event");
|
||||||
frame["steamId"] = player.UserIDString;
|
frame["steamId"] = SteamIdOf(player);
|
||||||
frame["name"] = player.displayName;
|
frame["name"] = player.displayName;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(reason))
|
if (!string.IsNullOrEmpty(reason))
|
||||||
@@ -1578,7 +1599,7 @@ namespace Oxide.Plugins
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
Dictionary<string, object> frame = Frame("player.respawned", "event");
|
Dictionary<string, object> frame = Frame("player.respawned", "event");
|
||||||
frame["steamId"] = player.UserIDString;
|
frame["steamId"] = SteamIdOf(player);
|
||||||
frame["name"] = player.displayName;
|
frame["name"] = player.displayName;
|
||||||
EmitFrame(frame);
|
EmitFrame(frame);
|
||||||
}
|
}
|
||||||
@@ -1601,7 +1622,7 @@ namespace Oxide.Plugins
|
|||||||
CreditKill(killer, true);
|
CreditKill(killer, true);
|
||||||
|
|
||||||
Dictionary<string, object> frame = Frame("player.death", "event");
|
Dictionary<string, object> frame = Frame("player.death", "event");
|
||||||
frame["steamId"] = player.UserIDString;
|
frame["steamId"] = SteamIdOf(player);
|
||||||
frame["name"] = player.displayName;
|
frame["name"] = player.displayName;
|
||||||
frame["sleeping"] = player.IsSleeping();
|
frame["sleeping"] = player.IsSleeping();
|
||||||
|
|
||||||
@@ -1625,7 +1646,7 @@ namespace Oxide.Plugins
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
Dictionary<string, object> frame = Frame("player.chat", "event");
|
Dictionary<string, object> frame = Frame("player.chat", "event");
|
||||||
frame["steamId"] = player.UserIDString;
|
frame["steamId"] = SteamIdOf(player);
|
||||||
frame["name"] = player.displayName;
|
frame["name"] = player.displayName;
|
||||||
frame["channel"] = channel.ToString();
|
frame["channel"] = channel.ToString();
|
||||||
|
|
||||||
@@ -1650,6 +1671,60 @@ namespace Oxide.Plugins
|
|||||||
TallyFor(player).Gather(item.info.shortname, item.amount);
|
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>
|
/// <summary>
|
||||||
/// Everything that dies which is not a player death: NPC kills, which are a counter,
|
/// 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
|
/// 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
|
// 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.
|
// 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++;
|
TallyFor(attacker).Structures++;
|
||||||
|
|
||||||
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
|
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
|
||||||
@@ -1834,7 +1915,7 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
if (reporter != null)
|
if (reporter != null)
|
||||||
{
|
{
|
||||||
frame["reporterId"] = reporter.UserIDString;
|
frame["reporterId"] = SteamIdOf(reporter);
|
||||||
frame["reporterName"] = reporter.displayName;
|
frame["reporterName"] = reporter.displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2620,7 +2701,7 @@ namespace Oxide.Plugins
|
|||||||
if (player == null)
|
if (player == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
string steamId = player.UserIDString;
|
string steamId = SteamIdOf(player);
|
||||||
if (string.IsNullOrEmpty(steamId))
|
if (string.IsNullOrEmpty(steamId))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -2683,7 +2764,7 @@ namespace Oxide.Plugins
|
|||||||
if (player == null)
|
if (player == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
string steamId = player.UserIDString;
|
string steamId = SteamIdOf(player);
|
||||||
if (string.IsNullOrEmpty(steamId))
|
if (string.IsNullOrEmpty(steamId))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -3965,13 +4046,90 @@ namespace Oxide.Plugins
|
|||||||
// produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin
|
// 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.
|
// that is DOWN because of an edit the website just made.
|
||||||
//
|
//
|
||||||
// They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from
|
// Protocol 13 made them a feed as well, and a narrow one (F8, D184). A grant the site made
|
||||||
// either: a busy server loads and unloads plugins for reasons that have nothing to do with
|
// for a plugin that was not loaded is `unresolved` until the plugin comes back — and the
|
||||||
// us, and a frame per event would be noise on the wire and a trigger nobody asked for.
|
// 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)
|
private void OnPluginLoaded(Plugin plugin)
|
||||||
{
|
{
|
||||||
MarkHook("OnPluginLoaded");
|
MarkHook("OnPluginLoaded");
|
||||||
|
NotePluginChange(plugin, true);
|
||||||
|
|
||||||
// ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an
|
// 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).
|
// 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)
|
private void OnPluginUnloaded(Plugin plugin)
|
||||||
{
|
{
|
||||||
MarkHook("OnPluginUnloaded");
|
MarkHook("OnPluginUnloaded");
|
||||||
|
NotePluginChange(plugin, false);
|
||||||
|
|
||||||
if (plugin != null && plugin.Name == "BetterChat")
|
if (plugin != null && plugin.Name == "BetterChat")
|
||||||
_titlesRegistered = false;
|
_titlesRegistered = false;
|
||||||
@@ -6925,6 +7084,52 @@ namespace Oxide.Plugins
|
|||||||
return plugins.Find("ZoneManager");
|
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>
|
/// <summary>
|
||||||
/// The map's monuments in the one order every caller agrees on: grouped by prefab short
|
/// 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
|
/// 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");
|
Dictionary<string, object> frame = Frame("world.expired", "event");
|
||||||
frame["id"] = e.Id;
|
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))
|
if (!string.IsNullOrEmpty(e.RunId))
|
||||||
frame["runId"] = e.RunId;
|
frame["runId"] = e.RunId;
|
||||||
@@ -8456,7 +8666,24 @@ namespace Oxide.Plugins
|
|||||||
if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null)
|
if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null)
|
||||||
return;
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -8534,6 +8761,7 @@ namespace Oxide.Plugins
|
|||||||
long now = NowMs();
|
long now = NowMs();
|
||||||
Plugin zm = null;
|
Plugin zm = null;
|
||||||
bool zmLooked = false;
|
bool zmLooked = false;
|
||||||
|
bool trusted = false;
|
||||||
|
|
||||||
foreach (RunTally t in _runTallies.Values)
|
foreach (RunTally t in _runTallies.Values)
|
||||||
{
|
{
|
||||||
@@ -8548,12 +8776,12 @@ namespace Oxide.Plugins
|
|||||||
if (!zmLooked)
|
if (!zmLooked)
|
||||||
{
|
{
|
||||||
zm = ZoneManagerPlugin();
|
zm = ZoneManagerPlugin();
|
||||||
|
trusted = ZoneMembershipTrusted();
|
||||||
zmLooked = true;
|
zmLooked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
object inside = zm != null ? zm.Call("IsPlayerInZone", t.ZoneId, killer) : null;
|
// Membership with the helper, the zone's shape without it (D182).
|
||||||
|
if (!InZone(zm, t.ZoneId, killer, trusted))
|
||||||
if (!(inside is bool) || !(bool)inside)
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8913,7 +9141,7 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
KitCredit c;
|
KitCredit c;
|
||||||
|
|
||||||
if (!_credits.TryGetValue(CreditKey(player.UserIDString, kitName), out c))
|
if (!_credits.TryGetValue(CreditKey(SteamIdOf(player), kitName), out c))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
int applied = 0, withdrawn = 0;
|
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
|
/// 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.
|
/// is an own dot and a clan mate the site can never find.
|
||||||
/// </summary>
|
/// </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)
|
private static string SteamIdOf(BasePlayer p)
|
||||||
{
|
{
|
||||||
ulong id = p.userID;
|
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