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.
|
||||
#
|
||||
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
|
||||
# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at
|
||||
# its top. It does NOT mirror `oxide/plugins/`: the same file goes to
|
||||
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers
|
||||
# (`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
|
||||
# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never
|
||||
# has to parse the version out of a path to find the manifest that states it.
|
||||
@@ -38,6 +39,12 @@
|
||||
# 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so
|
||||
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The
|
||||
# committed file keeps its placeholder; the manifest records the commit.
|
||||
# Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168,
|
||||
# D182 — today `RunicGatewayZones.cs` 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
|
||||
# moves only when a message shape does; the version moves on every releasable
|
||||
@@ -73,6 +80,9 @@ env:
|
||||
REPO: RunicGateway/Rust-Plugins
|
||||
ARTIFACT: runicgateway-rust-plugin
|
||||
PLUGIN: overlay/oxide/plugins/RunicGateway.cs
|
||||
# The helpers shipped beside the bridge (D168, D182). Optional at runtime —
|
||||
# the bridge loads without them — and installed by default.
|
||||
HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs overlay/oxide/plugins/RunicGatewayRaids.cs
|
||||
SEED_VERSION: "0.1.0"
|
||||
INSTALLER_REPO: RunicGateway/installer
|
||||
|
||||
@@ -279,6 +289,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; }
|
||||
for helper in $HELPERS; do
|
||||
[ -f "$helper" ] || { echo "::error::${helper} is missing"; exit 1; }
|
||||
done
|
||||
[ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; }
|
||||
node scripts/checkPlugin.js
|
||||
# The stamp below rewrites exactly one [Info(...)] line; zero or two
|
||||
@@ -304,6 +317,19 @@ jobs:
|
||||
grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \
|
||||
|| { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; }
|
||||
|
||||
# The helpers go in as committed, each keeping its own [Info] version.
|
||||
for helper in $HELPERS; do
|
||||
cp "$helper" "${STAGE}/$(basename "$helper")"
|
||||
done
|
||||
|
||||
# `files` names every .cs staged, with its sha256 — the installer places
|
||||
# exactly this set, and checks each file against it.
|
||||
PAIRS=()
|
||||
for f in "${STAGE}"/*.cs; do
|
||||
PAIRS+=("$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)")
|
||||
done
|
||||
FILES="$(jq -n '[$ARGS.positional | _nwise(2) | {(.[0]): .[1]}] | add' --args "${PAIRS[@]}")"
|
||||
|
||||
# Declarations from overlay.toml. Read, don't hardcode — the point of
|
||||
# that file is that the protocol number lives in one place.
|
||||
toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; }
|
||||
@@ -313,6 +339,10 @@ jobs:
|
||||
# requires_plugins = ["Kits", "ZoneManager"] is already a JSON array.
|
||||
# One line, quoted strings only; anything else fails the check below.
|
||||
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 "$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; }
|
||||
@@ -329,7 +359,8 @@ jobs:
|
||||
--arg min_oxide "${MIN_OXIDE}" \
|
||||
--arg min_carbon "${MIN_CARBON}" \
|
||||
--argjson requires "${REQUIRES}" \
|
||||
--arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \
|
||||
--argjson runicnpc "${RUNICNPC_API}" \
|
||||
--argjson files "${FILES}" \
|
||||
'{
|
||||
component: $component,
|
||||
version: $version,
|
||||
@@ -339,7 +370,8 @@ jobs:
|
||||
min_oxide_version: $min_oxide,
|
||||
min_carbon_version: $min_carbon,
|
||||
requires_plugins: $requires,
|
||||
files: { "RunicGateway.cs": $sha }
|
||||
runicnpc_api: $runicnpc,
|
||||
files: $files
|
||||
}' > "${STAGE}/manifest.json"
|
||||
echo "----- manifest.json -----"
|
||||
cat "${STAGE}/manifest.json"
|
||||
|
||||
94
README.md
94
README.md
@@ -34,9 +34,36 @@ unauthenticated command channel on the network.
|
||||
## Installing it
|
||||
|
||||
```
|
||||
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
||||
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
||||
overlay/oxide/plugins/RunicGatewayZones.cs → <server>/oxide/plugins/RunicGatewayZones.cs
|
||||
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:
|
||||
|
||||
```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
|
||||
(`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-<version>.tar.gz` and `SHA256SUMS`. The
|
||||
tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]`
|
||||
version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the
|
||||
version stamped to the release), the helpers beside it (each keeping its own version; every `.cs` is listed with
|
||||
its sha256 in the manifest's `files`, and the installer and the egg place them all) and a `manifest.json` folding in `overlay.toml`: the protocol, the
|
||||
Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and
|
||||
the egg decide where the file goes. A release then asks the installer repo to recompose its Rust
|
||||
bundle.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
38
overlay.toml
38
overlay.toml
@@ -26,19 +26,32 @@
|
||||
# the same change that alters the emitters, exactly as the sidecar bumps
|
||||
# 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
|
||||
# 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 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).
|
||||
protocol = 12
|
||||
# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18),
|
||||
# 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 ──────────────────────────────────────────────────────
|
||||
#
|
||||
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
|
||||
# nothing, so it is expected to work on any reasonably current Oxide. This is the
|
||||
# oldest build it is known good on.
|
||||
# The overlay only ADDS files — the bridge, and beside it the ZoneManager helper
|
||||
# (`RunicGatewayZones.cs`, docs/modules/rust/PLAN_FIXES.md D181, D182), both into
|
||||
# `oxide/plugins/` — and patches nothing on disk, so it is expected to work on any
|
||||
# reasonably current Oxide. The helper patches ZoneManager in memory with Harmony,
|
||||
# which both frameworks load; without it the bridge still loads and falls back.
|
||||
# This is the oldest build it is known good on.
|
||||
#
|
||||
# There is no `patches_verified_against` key, and there is no `patches/` tier:
|
||||
# Rust's server is a binary and Oxide's hook API is the supported way in, so
|
||||
@@ -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.
|
||||
# BetterChat is optional in the same way — titles and group styles wait for it.
|
||||
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
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const found = []
|
||||
let m
|
||||
@@ -196,8 +206,29 @@ function check(source, toml) {
|
||||
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)
|
||||
// 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)
|
||||
|
||||
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()
|
||||
|
||||
@@ -16,7 +16,7 @@ const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
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. */
|
||||
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('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