Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7483e131ef | |||
| 59db88d006 | |||
| 19ad9279fa | |||
| 22da0026ca | |||
| ef9596ce67 | |||
| 25a698e86e | |||
| 3c0fcb0003 | |||
| 38f854d580 | |||
| 48aa62a07a | |||
| e0d6c46850 | |||
| 594acb5351 | |||
| 5104e0dbcd | |||
| b52b8a8f80 | |||
| 9c99aceb65 | |||
| 454ce53937 | |||
| 92915f7de6 | |||
| 4e83def0dd | |||
| 7b1eabcd8b | |||
| 72f51c3d9e | |||
| 8e8553c589 | |||
| 7c9c8bdacb | |||
| 49a677e3fb | |||
| 491760d817 | |||
| 8fdc374ab3 | |||
| 51de1b492f | |||
| adb9df6a2e | |||
| 6be14c7270 | |||
| e3dc918400 | |||
| 3db35cdd02 | |||
| 66fbbd6b57 | |||
| cc7b2b2a0e | |||
| 6952fbf123 | |||
| f0f2e31345 | |||
| d391c521c7 | |||
| a785ad91ef | |||
| e5f04e0c0c | |||
| 912b115e83 | |||
| 3117b0c349 | |||
| 87774b051a | |||
| 74bcb0f895 | |||
| e966ea6c5b | |||
| 1df3db57d3 | |||
| 0792564ffe | |||
| 90f403422a | |||
| 9a3ae47850 | |||
| cf26b05abc | |||
| d1039ff95d | |||
| 6347c25d57 | |||
| 742e18ba0a | |||
| 428055f6ef | |||
| f7d5b4d691 | |||
| 463a50dbb3 | |||
| b30c6edd59 | |||
| 746523387e | |||
| f67f9e3b22 | |||
| ff49f8f6ed | |||
| 3f44d78944 | |||
| 8b4b65249f | |||
| 7a1f5aa072 | |||
| d1d4a00d1d | |||
| 586ad7ceba | |||
| fc7dccb90b | |||
| 616f4fbca8 | |||
| 5de406f356 | |||
| 042727e5fe |
@@ -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`, its helpers
|
||||||
# its top. It does NOT mirror `oxide/plugins/`: the same file goes to
|
# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`, `RunicGatewayRaids.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
|
# `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,12 @@
|
|||||||
# 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` for ZoneManager and `RunicGatewayDomes.cs`
|
||||||
|
# for ZoneDomes, PLAN_REDESIGNS D194, and `RunicGatewayRaids.cs` for RaidableBases,
|
||||||
|
# D204) 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 +80,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 overlay/oxide/plugins/RunicGatewayDomes.cs overlay/oxide/plugins/RunicGatewayRaids.cs
|
||||||
SEED_VERSION: "0.1.0"
|
SEED_VERSION: "0.1.0"
|
||||||
INSTALLER_REPO: RunicGateway/installer
|
INSTALLER_REPO: RunicGateway/installer
|
||||||
|
|
||||||
@@ -279,6 +289,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 +317,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/'; }
|
||||||
@@ -313,6 +339,10 @@ jobs:
|
|||||||
# requires_plugins = ["Kits", "ZoneManager"] is already a JSON array.
|
# requires_plugins = ["Kits", "ZoneManager"] is already a JSON array.
|
||||||
# One line, quoted strings only; anything else fails the check below.
|
# One line, quoted strings only; anything else fails the check below.
|
||||||
REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')"
|
REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')"
|
||||||
|
# The RunicNPC API the bridge's npc.* commands need (runicnpc stage 4). 0 when
|
||||||
|
# absent, which a bundle reads as "carries no RunicNPC".
|
||||||
|
RUNICNPC_API="$(grep -m1 -E '^runicnpc_api[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
|
||||||
|
RUNICNPC_API="${RUNICNPC_API:-0}"
|
||||||
[ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; }
|
[ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; }
|
||||||
[ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; }
|
[ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; }
|
||||||
[ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; }
|
[ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; }
|
||||||
@@ -329,7 +359,8 @@ 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 runicnpc "${RUNICNPC_API}" \
|
||||||
|
--argjson files "${FILES}" \
|
||||||
'{
|
'{
|
||||||
component: $component,
|
component: $component,
|
||||||
version: $version,
|
version: $version,
|
||||||
@@ -339,7 +370,8 @@ 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 }
|
runicnpc_api: $runicnpc,
|
||||||
|
files: $files
|
||||||
}' > "${STAGE}/manifest.json"
|
}' > "${STAGE}/manifest.json"
|
||||||
echo "----- manifest.json -----"
|
echo "----- manifest.json -----"
|
||||||
cat "${STAGE}/manifest.json"
|
cat "${STAGE}/manifest.json"
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -34,9 +34,36 @@ 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
|
||||||
|
overlay/oxide/plugins/RunicGatewayDomes.cs → <server>/oxide/plugins/RunicGatewayDomes.cs
|
||||||
|
overlay/oxide/plugins/RunicGatewayRaids.cs → <server>/oxide/plugins/RunicGatewayRaids.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.
|
||||||
|
|
||||||
|
`RunicGatewayDomes.cs` is the **ZoneDomes helper** (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D194). ZoneDomes'
|
||||||
|
API ends by telling the player who asked how it went, and throws when a plugin calls it with no player; and at its
|
||||||
|
own start it drops the dome of every zone that does not exist yet, which is every zone the bridge re-creates. The
|
||||||
|
helper patches the first with Harmony and tells the bridge when ZoneDomes has started, so the bridge can put its
|
||||||
|
domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is
|
||||||
|
missing. `rgd.status` in the server console says whether it patched.
|
||||||
|
|
||||||
|
`RunicGatewayRaids.cs` is the **RaidableBases helper** (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D204–D207,
|
||||||
|
D387). RaidableBases' API spawns nothing and despawns nothing, so an event's base cannot be the event's from the
|
||||||
|
outside. The helper reaches its internals by reflection and patches its spawn queue, `OpenEvent` and each base's
|
||||||
|
`BypassUseOwners()` with Harmony: it tags the base a run asked for at birth, applies that spawn's own PvP, NPCs,
|
||||||
|
despawn time and lock on a copy of its profile, places one at a spot after checking it, despawns that one base at
|
||||||
|
the run's end, and reloads RaidableBases' profiles when the site edits them. It serves both editions, free and
|
||||||
|
paid, and reads every difficulty, profile and base name from the installed plugin. It is optional: without it the
|
||||||
|
bridge still reports raid bases on the feed and the map, and a step can still ask RaidableBases for a base where it
|
||||||
|
picks, but that base despawns on its own timer, and the site says so. `rgr.status` in the server console says
|
||||||
|
whether it patched, which edition's spawn call it found, and whether the plugin's spawn grid is built.
|
||||||
|
|
||||||
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 +96,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.
|
||||||
@@ -85,6 +113,68 @@ Clans on the website come from Rust's **own** clan system, not from the uMod `Cl
|
|||||||
is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
|
is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
|
||||||
only the game's own clans become Teams on the site.
|
only the game's own clans become Teams on the site.
|
||||||
|
|
||||||
|
Wulf's `Economics` is optional too, and not in `overlay.toml`. Where it is loaded the site can pay
|
||||||
|
coins from an event, show balances and a richest column, and let staff adjust a balance; where it is
|
||||||
|
not, each of those says "Economics is not installed on this server".
|
||||||
|
|
||||||
|
WhiteThunder's `Backpacks` is optional in the same way. Where it is loaded staff can open a player's
|
||||||
|
backpack from the site, a player can see their own, and an event can lend a bigger one for its run;
|
||||||
|
the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own `backpacks.size.N`
|
||||||
|
permissions, pushed like any other grant.
|
||||||
|
|
||||||
|
nivex's `TruePVE` is optional in the same way. Where it is loaded an event's zone can turn off PvP
|
||||||
|
protection inside it, or use one of the operator's own TruePVE rulesets by name; where it is not, the
|
||||||
|
zone step says "TruePVE is not installed on this server". The bridge only ever adds and removes the
|
||||||
|
mappings of zones it opened, and removes each one with its zone, even when TruePVE was away at the time.
|
||||||
|
|
||||||
|
HunterZ's `Dynamic PVP` (which needs ZoneManager and TruePVE) is optional too. Where it is loaded, the
|
||||||
|
PvP zones it opens around a downed helicopter, a Bradley, cargo and the rest reach the site's feed and
|
||||||
|
its map. The bridge only reads them: it never makes, maps or erases one of Dynamic PVP's zones.
|
||||||
|
|
||||||
|
nivex's `Dangerous Treasures` is optional as well. Where it is loaded, its treasure events reach the
|
||||||
|
site's feed, its map and the *Treasure Hunter* title, and an event can open one (`dt.start`) through
|
||||||
|
DT's own `dtevent` console command; DT picks the spot and despawns its own chest. For about ten minutes
|
||||||
|
after DT loads it is still finding its spots, and a start then is answered "not ready yet". A framework
|
||||||
|
that cannot run another plugin's console command from code (`rg.world` says `consolePlugins=False`)
|
||||||
|
cannot open one.
|
||||||
|
|
||||||
|
Bazz3l's `Guarded Crate` is optional too, and needs `NpcSpawn` (a free Codefling download, not shipped
|
||||||
|
here) for its guards. Where both are loaded, a guarded crate landing, being cleared and timing out
|
||||||
|
reach the site's feed, its map and the *Warden* title, and an event can place one (`gc.place`) at a
|
||||||
|
monument or a point through Guarded Crate's own `gcrate position` console command. A placed crate is
|
||||||
|
kept when the event ends: Guarded Crate has no way to end one event alone. Without NpcSpawn a placement
|
||||||
|
is refused, because the crate would land unguarded; and, as for Dangerous Treasures, a framework with
|
||||||
|
`consolePlugins=False` cannot place one.
|
||||||
|
|
||||||
|
nivex's `RaidableBases` is optional, free or paid, and needs `CopyPaste` to paste its bases. Where it is
|
||||||
|
loaded, a base appearing, being raided, bought or locked to a player reach the site's feed, its map's own
|
||||||
|
*Raid bases* layer and the *Warlord* title, and an event can spawn one (`raid.spawn`). With
|
||||||
|
`RunicGatewayRaids.cs` (above) the base is the event's: at a spot or where RaidableBases picks, with its own
|
||||||
|
settings, despawned when the event ends; and the site can edit RaidableBases' profiles from its configuration
|
||||||
|
page, reloading them under watch. For up to ten minutes after RaidableBases loads it builds its spawn grid and
|
||||||
|
refuses every spawn, and a spawn then is answered "not ready". Every difficulty, profile and base name is read
|
||||||
|
from the installed plugin.
|
||||||
|
|
||||||
|
nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is loaded. Its levels are
|
||||||
|
never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an
|
||||||
|
event lends one as it lends any other permission, and it takes effect at once.
|
||||||
|
|
||||||
|
An event can also run console commands an admin typed (`command.run`): once, as a step, or once for each
|
||||||
|
recipient of a reward who is online, with `{steamid}` and `{name}` filled in (the name as one quoted
|
||||||
|
argument). That is how a plugin with no support of its own here is used from an event, for example
|
||||||
|
RaidableBases' `rbe`. There is no allowlist: the site's switchboard is the switch, and `EventsEnabled`
|
||||||
|
here is the server's. Each request runs at most once: its key is written to
|
||||||
|
`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A step's
|
||||||
|
teardown commands are kept with its key too, so the site can have them run even for a step that failed
|
||||||
|
part-way or whose answer was lost (`teardownOf`). A command the
|
||||||
|
server does not know stops the request there as `unknown-command`. On a framework with
|
||||||
|
`consolePlugins=False`, that is every plugin's command.
|
||||||
|
|
||||||
|
Rust's own world events need no plugin at all. The patrol helicopter, Bradley, the cargo ship, the
|
||||||
|
Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea reach the
|
||||||
|
site's feed and its event triggers on every server, and an event can call one of them (`world.call`).
|
||||||
|
What a call starts is kept when the event ends; `EventsMaxWorldCalls` caps how many one run may call.
|
||||||
|
|
||||||
## Diagnosing it
|
## Diagnosing it
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
38
overlay.toml
38
overlay.toml
@@ -26,19 +26,32 @@
|
|||||||
# the same change that alters the emitters, exactly as the sidecar bumps
|
# the same change that alters the emitters, exactly as the sidecar bumps
|
||||||
# PROTOCOL_VERSION and the module bumps its own constant.
|
# PROTOCOL_VERSION and the module bumps its own constant.
|
||||||
#
|
#
|
||||||
# Current: 12 — the transport, the read path, identity, the permission mirror,
|
# Current: 14 — the transport, the read path, identity, the permission mirror,
|
||||||
# configuration from the site, first-party clans, a raid frame that names who
|
# configuration from the site, first-party clans, a raid frame that names who
|
||||||
# lives there, the event leases, the world verbs events place and give back, and
|
# lives there, the event leases, the world verbs events place and give back, and
|
||||||
# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16),
|
# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16),
|
||||||
# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the
|
# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the
|
||||||
# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18).
|
# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18),
|
||||||
protocol = 12
|
# and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration
|
||||||
|
# save that answers at once and reports its reload as a `config.outcome` frame,
|
||||||
|
# and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward
|
||||||
|
# messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3):
|
||||||
|
# `economics.pay`, `economics.balances`, `economics.adjust`, the tally's
|
||||||
|
# `balance` and the `economics.wiped` event; and Backpacks (PROTOCOL.md §20.4):
|
||||||
|
# `backpacks.read` and hello's `integrations.backpacks.sizes`; and Rust's own world
|
||||||
|
# events (PROTOCOL.md §20.5): `world.event` with its `event`, `world.call`, hello's
|
||||||
|
# `console`, and the tally's `cratesHacked` and `dropsLooted` in place of `apcKills`
|
||||||
|
# and `heliKills`.
|
||||||
|
protocol = 14
|
||||||
|
|
||||||
# ── 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
|
||||||
@@ -72,3 +85,16 @@ min_carbon_version = "2.0.259"
|
|||||||
# asked of a server without it is refused with a reason, and chat is the default.
|
# asked of a server without it is refused with a reason, and chat is the default.
|
||||||
# BetterChat is optional in the same way — titles and group styles wait for it.
|
# BetterChat is optional in the same way — titles and group styles wait for it.
|
||||||
requires_plugins = ["Kits", "ZoneManager"]
|
requires_plugins = ["Kits", "ZoneManager"]
|
||||||
|
|
||||||
|
# ── The RunicNPC API the bridge needs ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# RunicNPC (docs/runicnpc/PLAN.md stages 4 to 7): the bridge's npc.* commands call
|
||||||
|
# RunicNPC's API and need at least this version of it. It MUST equal
|
||||||
|
# `RunicNpcApiNeeded` in RunicGateway.cs; `node scripts/checkPlugin.js` holds the
|
||||||
|
# two equal. The release copies it into manifest.json, and the installer's
|
||||||
|
# bundle carries the latest RunicNPC release that answers at least it (D224).
|
||||||
|
#
|
||||||
|
# RunicNPC is required since its stage 9 (D310): without it, or with one older
|
||||||
|
# than this, every npc.* command and every NPC an event places, Rust's own
|
||||||
|
# scientists included, is refused as `runicnpc-missing` or `runicnpc-old`.
|
||||||
|
runicnpc_api = 6
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
261
overlay/oxide/plugins/RunicGatewayDomes.cs
Normal file
261
overlay/oxide/plugins/RunicGatewayDomes.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// Runic Gateway Domes — a helper for ZoneDomes, shipped beside the Runic Gateway bridge.
|
||||||
|
// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later.
|
||||||
|
//
|
||||||
|
// ONE JOB (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D168, D194):
|
||||||
|
//
|
||||||
|
// ZoneDomes draws a dome over a ZoneManager zone, and the bridge asks it to for an event zone
|
||||||
|
// whose step wants one. Its API was written for a person at a keyboard: `AddNewDome` and
|
||||||
|
// `RemoveExistingDome` both end by telling the player who asked how it went, with no check that
|
||||||
|
// there was one. Called by a plugin with no player, the dome is made and saved, and THEN the call
|
||||||
|
// throws — so the caller cannot tell a made dome from a failed one (plan §0.5, found on the rig).
|
||||||
|
//
|
||||||
|
// So this helper patches ZoneDomes with Harmony, the same way the zone helper patches ZoneManager
|
||||||
|
// (D181):
|
||||||
|
//
|
||||||
|
// * **A message with no player is not sent.** A prefix on ZoneDomes' `TranslateMessage(BasePlayer,
|
||||||
|
// string, string)` skips it when the player is null. Nothing else about the call changes, so
|
||||||
|
// `AddNewDome` returns its true or false normally.
|
||||||
|
// * **It says when ZoneDomes has finished starting.** ZoneDomes' own start (`InitializeDomes`)
|
||||||
|
// drops every dome whose zone does not exist yet and draws every one that does. A dome added
|
||||||
|
// BEFORE that is drawn a second time, and the first set of spheres stays in the world with
|
||||||
|
// nothing holding it. A postfix there raises `OnRgDomesReady`, and the bridge puts its domes
|
||||||
|
// back only after it.
|
||||||
|
//
|
||||||
|
// It is optional. Without it the bridge offers no dome and says why; the zones themselves are
|
||||||
|
// unaffected. `rgd.status` in the server console prints its state.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using HarmonyLib;
|
||||||
|
using Oxide.Core;
|
||||||
|
using Oxide.Core.Plugins;
|
||||||
|
|
||||||
|
namespace Oxide.Plugins
|
||||||
|
{
|
||||||
|
[Info("Runic Gateway Domes", "Runic Gateway", "0.1.0")]
|
||||||
|
[Description("Helper for Runic Gateway: ZoneDomes can be called without a player, and says when it has started.")]
|
||||||
|
public class RunicGatewayDomes : RustPlugin
|
||||||
|
{
|
||||||
|
private const string HarmonyId = "runicgateway.domes";
|
||||||
|
|
||||||
|
/// <summary>The instance the static patches report to. Null while unloaded.</summary>
|
||||||
|
private static RunicGatewayDomes _instance;
|
||||||
|
|
||||||
|
private Harmony _harmony;
|
||||||
|
private MethodInfo _message;
|
||||||
|
private MethodInfo _initialize;
|
||||||
|
|
||||||
|
/// <summary><c>patched</c>, <c>unsupported</c> (ZoneDomes is here but not the shape patched), or <c>no-zonedomes</c>.</summary>
|
||||||
|
private string _state = "no-zonedomes";
|
||||||
|
private string _reason;
|
||||||
|
|
||||||
|
/// <summary>Whether ZoneDomes has finished its own start since it was patched. Main thread.</summary>
|
||||||
|
private bool _ready;
|
||||||
|
|
||||||
|
/// <summary>Set once the server is up, at this plugin's own <c>OnServerInitialized</c>.</summary>
|
||||||
|
private bool _serverUp;
|
||||||
|
|
||||||
|
// Patched as EARLY as possible — at load, or the moment ZoneDomes loads — so that at a real
|
||||||
|
// server start the patch is in before ZoneDomes' `OnServerInitialized` runs its start.
|
||||||
|
|
||||||
|
private void Loaded()
|
||||||
|
{
|
||||||
|
_instance = this;
|
||||||
|
Patch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnServerInitialized(bool initial)
|
||||||
|
{
|
||||||
|
_serverUp = true;
|
||||||
|
|
||||||
|
// Loaded into a running server: ZoneDomes, if it is here, started long ago.
|
||||||
|
if (!initial)
|
||||||
|
MarkReadyNextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Unload()
|
||||||
|
{
|
||||||
|
Unpatch();
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPluginLoaded(Plugin plugin)
|
||||||
|
{
|
||||||
|
if (plugin == null || plugin.Name != "ZoneDomes")
|
||||||
|
return;
|
||||||
|
|
||||||
|
Unpatch();
|
||||||
|
Patch();
|
||||||
|
|
||||||
|
// A ZoneDomes loaded into a running server has run its start by now: the framework
|
||||||
|
// calls a hot-loaded plugin's `OnServerInitialized` before it tells anybody else it
|
||||||
|
// loaded. The tick is margin, and costs nothing.
|
||||||
|
if (_serverUp)
|
||||||
|
MarkReadyNextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPluginUnloaded(Plugin plugin)
|
||||||
|
{
|
||||||
|
if (plugin != null && plugin.Name == "ZoneDomes")
|
||||||
|
{
|
||||||
|
Unpatch();
|
||||||
|
_state = "no-zonedomes";
|
||||||
|
_reason = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The API the bridge calls ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary><c>patched</c>, <c>unsupported</c> or <c>no-zonedomes</c>.</summary>
|
||||||
|
private string State() => _state;
|
||||||
|
|
||||||
|
/// <summary>Why the state is not <c>patched</c>, or null.</summary>
|
||||||
|
private string StateReason() => _reason;
|
||||||
|
|
||||||
|
/// <summary>Whether ZoneDomes is patched and past its own start, so a dome may be added.</summary>
|
||||||
|
private bool Ready() => _state == "patched" && _ready;
|
||||||
|
|
||||||
|
/// <summary>The helper's own version, for hello.</summary>
|
||||||
|
private string HelperVersion() => Version.ToString();
|
||||||
|
|
||||||
|
// ── Patching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void Patch()
|
||||||
|
{
|
||||||
|
_ready = false;
|
||||||
|
Plugin zoneDomes = plugins.Find("ZoneDomes");
|
||||||
|
|
||||||
|
if (zoneDomes == null)
|
||||||
|
{
|
||||||
|
_state = "no-zonedomes";
|
||||||
|
_reason = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||||
|
Type type = zoneDomes.GetType();
|
||||||
|
|
||||||
|
_message = type.GetMethod("TranslateMessage", any, null, new[] { typeof(BasePlayer), typeof(string), typeof(string) }, null);
|
||||||
|
_initialize = type.GetMethod("InitializeDomes", any, null, Type.EmptyTypes, null);
|
||||||
|
|
||||||
|
string missing =
|
||||||
|
_message == null ? "TranslateMessage(BasePlayer, string, string)" :
|
||||||
|
_initialize == null ? "InitializeDomes()" :
|
||||||
|
type.GetMethod("AddNewDome", any) == null ? "AddNewDome" :
|
||||||
|
type.GetMethod("RemoveExistingDome", any) == null ? "RemoveExistingDome" :
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (missing != null)
|
||||||
|
{
|
||||||
|
Fail($"this ZoneDomes ({zoneDomes.Version}) has no {missing}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_harmony = new Harmony(HarmonyId);
|
||||||
|
_harmony.Patch(_message, prefix: new HarmonyMethod(
|
||||||
|
typeof(RunicGatewayDomes).GetMethod(nameof(BeforeMessage), BindingFlags.Static | BindingFlags.NonPublic)));
|
||||||
|
_harmony.Patch(_initialize, postfix: new HarmonyMethod(
|
||||||
|
typeof(RunicGatewayDomes).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Fail($"Harmony could not patch ZoneDomes: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = "patched";
|
||||||
|
_reason = null;
|
||||||
|
Puts($"patched ZoneDomes {zoneDomes.Version}: its API can be called without a player");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Unpatch()
|
||||||
|
{
|
||||||
|
if (_harmony != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_message != null)
|
||||||
|
_harmony.Unpatch(_message, HarmonyPatchType.Prefix, HarmonyId);
|
||||||
|
|
||||||
|
if (_initialize != null)
|
||||||
|
_harmony.Unpatch(_initialize, HarmonyPatchType.Postfix, HarmonyId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PrintWarning($"could not remove the ZoneDomes patches: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_harmony = null;
|
||||||
|
_message = null;
|
||||||
|
_initialize = null;
|
||||||
|
_ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Fail(string reason)
|
||||||
|
{
|
||||||
|
Unpatch();
|
||||||
|
_state = "unsupported";
|
||||||
|
_reason = reason;
|
||||||
|
PrintWarning($"{reason}. Runic Gateway will not offer a dome over its zones on this server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkReadyNextTick()
|
||||||
|
{
|
||||||
|
NextTick(() =>
|
||||||
|
{
|
||||||
|
if (_state != "patched" || _ready)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_ready = true;
|
||||||
|
Interface.CallHook("OnRgDomesReady");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The patches ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Harmony prefix on <c>ZoneDomes.TranslateMessage(BasePlayer, string, string)</c>.
|
||||||
|
/// Returning false skips the message; a real player still gets it.
|
||||||
|
/// </summary>
|
||||||
|
private static bool BeforeMessage(BasePlayer player)
|
||||||
|
{
|
||||||
|
return player != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Harmony postfix on <c>ZoneDomes.InitializeDomes</c>. Anything thrown here would surface
|
||||||
|
/// inside ZoneDomes, so nothing is allowed to.
|
||||||
|
/// </summary>
|
||||||
|
private static void AfterInitialize()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RunicGatewayDomes self = _instance;
|
||||||
|
|
||||||
|
if (self == null || self._state != "patched")
|
||||||
|
return;
|
||||||
|
|
||||||
|
self._ready = true;
|
||||||
|
Interface.CallHook("OnRgDomesReady");
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Never ZoneDomes' problem.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Console ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ConsoleCommand("rgd.status")]
|
||||||
|
private void StatusCommand(ConsoleSystem.Arg arg)
|
||||||
|
{
|
||||||
|
if (arg.Connection != null && !arg.IsAdmin)
|
||||||
|
return;
|
||||||
|
|
||||||
|
arg.ReplyWith($"Runic Gateway Domes {Version}: {_state}{(_state == "patched" ? (_ready ? ", ready" : ", waiting for ZoneDomes to start") : "")}{(_reason != null ? $" ({_reason})" : "")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1697
overlay/oxide/plugins/RunicGatewayRaids.cs
Normal file
1697
overlay/oxide/plugins/RunicGatewayRaids.cs
Normal file
File diff suppressed because it is too large
Load Diff
376
overlay/oxide/plugins/RunicGatewayZones.cs
Normal file
376
overlay/oxide/plugins/RunicGatewayZones.cs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
// Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge.
|
||||||
|
// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later.
|
||||||
|
//
|
||||||
|
// ITS FIRST 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.
|
||||||
|
//
|
||||||
|
// A SECOND JOB (RunicNPC stage 9d walk, the org lead's call, 2026-10-07):
|
||||||
|
//
|
||||||
|
// ZoneManager subscribes the hooks that enforce its flags (`OnEntityTakeDamage` for PvpGod and the
|
||||||
|
// other damage flags, `OnEntityBuilt` for NoBuild, and so on) only for the flags some zone had when
|
||||||
|
// ZoneManager loaded, or when a flag is toggled by command. A zone made later through its API —
|
||||||
|
// which is every zone an event opens — never re-subscribes them, so on a server with no such zone
|
||||||
|
// already, an event's PvpGod or NoBuild zone enforces nothing: the 9d walk shot a player dead and
|
||||||
|
// built a foundation inside one. The same postfix that sees every zone made therefore also has
|
||||||
|
// ZoneManager run its own `UpdateHookSubscriptions` on the next tick. ZoneManager decides what to
|
||||||
|
// subscribe; this only asks it to look.
|
||||||
|
//
|
||||||
|
// 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.2.0")]
|
||||||
|
[Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded, and enforces a new zone's flags.")]
|
||||||
|
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>ZoneManager's private <c>UpdateHookSubscriptions</c>, or null when this ZoneManager has none (the second job is then off).</summary>
|
||||||
|
private MethodInfo _updateHooks;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
_updateHooks = zmType.GetMethod("UpdateHookSubscriptions", any, null, Type.EmptyTypes, 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" +
|
||||||
|
(_updateHooks != null ? ", and a new zone's flags are enforced" : ""));
|
||||||
|
if (_updateHooks == null)
|
||||||
|
PrintWarning($"this ZoneManager ({_zoneManager.Version}) has no UpdateHookSubscriptions: a zone made after it loaded may not enforce its flags until ZoneManager reloads");
|
||||||
|
|
||||||
|
// The zones ZoneManager loaded before this helper did were initialised unwatched —
|
||||||
|
// which, at a server start, is all of them. Sweep them once now.
|
||||||
|
RefreshHooks();
|
||||||
|
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();
|
||||||
|
|
||||||
|
// The second job first: the hooks that enforce the new zones' flags, then the players.
|
||||||
|
RefreshHooks();
|
||||||
|
|
||||||
|
foreach (KeyValuePair<string, object> entry in batch)
|
||||||
|
Sweep(entry.Key, entry.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main thread. Has ZoneManager subscribe the hooks its zones' flags now need. ZoneManager
|
||||||
|
/// only adds subscriptions here, and subscribing a hook twice is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshHooks()
|
||||||
|
{
|
||||||
|
if (_updateHooks == null || _zoneManager == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_updateHooks.Invoke(_zoneManager, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PrintWarning($"could not have ZoneManager refresh its hooks: {(ex.InnerException ?? ex).Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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})" : "")}" +
|
||||||
|
$"; new zones' flags {(_updateHooks != null ? "enforced" : "NOT refreshed (no UpdateHookSubscriptions)")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,16 @@ function readChatCommands(source) {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every `[ConsoleCommand("x")]` the plugin declares. */
|
||||||
|
function readConsoleCommands(source) {
|
||||||
|
return [...source.matchAll(/\[ConsoleCommand\("([^"]+)"\)\]/g)].map((m) => m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `rg.*` commands the plugin runs on itself through `ConsoleSystem.Run`. */
|
||||||
|
function readOwnConsoleRuns(source) {
|
||||||
|
return [...source.matchAll(/ConsoleSystem\.Run\([^,]+,\s*"(rg\.[\w.]+)"/g)].map((m) => m[1])
|
||||||
|
}
|
||||||
|
|
||||||
function readMethods(source) {
|
function readMethods(source) {
|
||||||
const found = []
|
const found = []
|
||||||
let m
|
let m
|
||||||
@@ -196,8 +206,29 @@ function check(source, toml) {
|
|||||||
seen.add(cmd.command)
|
seen.add(cmd.command)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. The two declaration sites this repository owns must agree.
|
// 5. Protocol 14 (D363): the console probe runs one of this plugin's own commands
|
||||||
|
// through `ConsoleSystem.Run` to learn whether the framework runs a plugin's
|
||||||
|
// command from code. A probe naming a command the plugin does not declare
|
||||||
|
// answers "no" on every framework, which reads exactly like Carbon's bug.
|
||||||
|
for (const name of readOwnConsoleRuns(source)) {
|
||||||
|
if (!readConsoleCommands(source).includes(name)) {
|
||||||
|
problems.push(
|
||||||
|
`ConsoleSystem.Run runs "${name}", but the plugin declares no [ConsoleCommand("${name}")]: ` +
|
||||||
|
'the console probe would report false on every framework (D363).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. The two declaration sites this repository owns must agree.
|
||||||
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
|
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
|
||||||
|
// runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol.
|
||||||
|
const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml)
|
||||||
|
const npcCode = /const\s+int\s+RunicNpcApiNeeded\s*=\s*(\d+)\s*;/.exec(source)
|
||||||
|
if (npcCode && !npcToml) problems.push('the plugin needs a RunicNPC API (RunicNpcApiNeeded) and overlay.toml declares no runicnpc_api')
|
||||||
|
if (npcCode && npcToml && npcCode[1] !== npcToml[1]) {
|
||||||
|
problems.push(`the plugin needs RunicNPC API ${npcCode[1]} and overlay.toml declares runicnpc_api = ${npcToml[1]}. The bundle picks a RunicNPC by the second, so they must agree.`)
|
||||||
|
}
|
||||||
|
|
||||||
const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml)
|
const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml)
|
||||||
|
|
||||||
if (!inCode) problems.push('could not read ProtocolVersion from the plugin source')
|
if (!inCode) problems.push('could not read ProtocolVersion from the plugin source')
|
||||||
@@ -234,6 +265,6 @@ function main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME }
|
module.exports = { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME }
|
||||||
|
|
||||||
if (require.main === module) main()
|
if (require.main === module) main()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const assert = require('node:assert')
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
|
|
||||||
const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin')
|
const { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME } = require('./checkPlugin')
|
||||||
|
|
||||||
/** A minimal plugin that passes, as the baseline every case below deviates from. */
|
/** A minimal plugin that passes, as the baseline every case below deviates from. */
|
||||||
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
|
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
|
||||||
@@ -210,3 +210,55 @@ test('the chat-command parser reads the real plugin, rather than quietly matchin
|
|||||||
assert.ok(names.has('link'), '/link was not found by the chat-command parser')
|
assert.ok(names.has('link'), '/link was not found by the chat-command parser')
|
||||||
assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser')
|
assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── The console probe (protocol 14, D363) ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// The plugin runs its own `rg.probe` through `ConsoleSystem.Run` to learn whether
|
||||||
|
// the framework runs a plugin's command from code. A probe that names a command
|
||||||
|
// the plugin does not declare answers false everywhere, and false is exactly what
|
||||||
|
// Carbon's bug looks like, so nobody would ever notice.
|
||||||
|
|
||||||
|
/** A plugin that runs `ran` on itself and declares `declared`. */
|
||||||
|
function withProbe(ran, declared) {
|
||||||
|
return source({
|
||||||
|
expected: [],
|
||||||
|
methods: ` private void StartWorldEvents()
|
||||||
|
{
|
||||||
|
ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "${ran}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("${declared}")]
|
||||||
|
private void CmdProbe(ConsoleSystem.Arg arg)
|
||||||
|
{
|
||||||
|
}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a probe that runs a command the plugin declares passes', () => {
|
||||||
|
assert.deepEqual(check(withProbe('rg.probe', 'rg.probe'), toml()), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a probe that runs a command the plugin does not declare is caught', () => {
|
||||||
|
const problems = check(withProbe('rg.probe', 'rg.prob'), toml())
|
||||||
|
assert.equal(problems.length, 1)
|
||||||
|
assert.match(problems[0], /ConsoleSystem\.Run runs "rg\.probe", but the plugin declares no \[ConsoleCommand\("rg\.probe"\)\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a native command run through the console is not the probe, and is left alone', () => {
|
||||||
|
const methods = ` private void Call()
|
||||||
|
{
|
||||||
|
ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random");
|
||||||
|
}`
|
||||||
|
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the probe parsers read the real plugin, rather than quietly matching nothing', () => {
|
||||||
|
const real = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ok(readOwnConsoleRuns(real).includes('rg.probe'), 'the console probe was not found by the parser')
|
||||||
|
assert.ok(readConsoleCommands(real).includes('rg.probe'), 'rg.probe was not found among the console commands')
|
||||||
|
assert.ok(readConsoleCommands(real).includes('rg.hooks'), 'rg.hooks was not found among the console commands')
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user