33 Commits

Author SHA1 Message Date
f0f2e31345 Merge pull request 'feat(bridge): protocol 13 with RunicNPC, edge → main (runicnpc 9e step 3, merge 2 of 3)' (#29) from edge into main
All checks were successful
Release plugin / release (push) Successful in 24s
Reviewed-on: #29
2026-10-09 07:20:24 +00:00
d391c521c7 Merge pull request 'fix: animals and heals count toward titles; an event's zone enforces its flags (runicnpc 9d, D320–D322)' (#28) from fix/walk-titles-zones into edge
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 13s
Reviewed-on: #28
2026-10-07 18:47:14 +00:00
a785ad91ef fix(zones): an event's zone enforces its flags (RunicGatewayZones 0.2.0, runicnpc 9d)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 13s
ZoneManager subscribes the hooks that enforce its flags only for the zones it
had when it loaded; a zone made later through its API, which is every zone an
event opens, never re-subscribes them, so an event's PvpGod or NoBuild zone
enforced nothing. The helper's postfix, which already sees every zone created,
updated or loaded, now has ZoneManager run its own UpdateHookSubscriptions on
the next tick, before entering the players already inside. A ZoneManager
without that method is warned about and reported by rgz.status; the first job
still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-07 13:41:57 -05:00
e5f04e0c0c fix(bridge): Rust's animals and heals count toward titles again (runicnpc 9d)
- Animal kills: Rust's animals are now Rust.Ai.Gen2.BaseNPC2 (stag, boar, wolf,
  bears, big cats, crocodile, farm animals, critters, sea life), not BaseNpc, so
  no animal kill counted as an animal or an NPC kill. They count as both again;
  ScientistNPC2, the one person among them, counts as a human NPC kill.
- Heals: OnHealingItemUse is (tool, fromPlayer, toTarget). The bridge took two
  parameters, received the healer as the target, and dropped every heal as
  healing yourself. It now reads the target, and one act of help (a syringe
  revive raises both hooks, the heal first) counts once whichever hook comes
  first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-07 13:41:57 -05:00
912b115e83 Merge pull request 'feat(bridge): every NPC an event places needs RunicNPC (runicnpc stage 9, D310)' (#27) from feat/runicnpc-stage9 into edge
Reviewed-on: #27
2026-10-07 02:43:10 +00:00
3117b0c349 feat(bridge): every NPC an event places needs RunicNPC (runicnpc stage 9, D310)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
world.place of one of Rust's own NPC prefabs now needs RunicNPC loaded and
answering RunicNpcApiNeeded, as a profile placement does, so a module that
missed the hello cannot place NPCs on a server the site calls incomplete. The
refusal comes before anything spawns, with the existing reasons
runicnpc-missing and runicnpc-old; a profile placement now also refuses an old
RunicNPC up front (it was refused only when it carried orders). Crates are
unaffected. No message changes shape; protocol stays 13.

overlay.toml's runicnpc_api comment and rg.npc's line say RunicNPC is required.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:58:30 -05:00
87774b051a Merge pull request 'feat(bridge): map.live names RunicNPC's NPCs, marks bosses and adds (runicnpc stage 8)' (#26) from feat/runicnpc-stage8 into edge
Reviewed-on: #26
2026-10-06 13:32:31 +00:00
74bcb0f895 feat(bridge): map.live names RunicNPC's NPCs, marks bosses and adds (runicnpc stage 8)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 11s
One RunicNpc_List per map.live ask, joined to the ledger by net id
(D302, D303):

- A ledgered NPC's row gains its name and the step key that placed it.
  A boss's row also gains `boss` and its `health` (0-1); the health is
  for the event page's progress line.
- A RunicNPC NPC owned by a run that no step ledgered is a boss's add.
  It joins the events layer under that run with `add: true`.
- Any other boss joins the world layer as `kind: "boss"` with its name.
- A placement's ordinary NPCs are not sent.

Protocol 13, still unreleased; the fields are additive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 08:23:10 -05:00
e966ea6c5b Merge pull request 'feat(bridge): a Place NPCs step's "drop loot" switch (runicnpc stage 7)' (#25) from feat/runicnpc-stage7 into edge
Reviewed-on: #25
2026-10-06 07:16:19 +00:00
1df3db57d3 feat(bridge): a Place NPCs step's "drop loot" switch (runicnpc stage 7)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
world.place with a profile takes dropLoot (D296): false spawns the
NPCs with RunicNPC's {"loot":{"dropTable":false}} override, so they
keep their corpse's start but roll no table. Absent or true, as before.
runicnpc_api and RunicNpcApiNeeded are 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 22:36:29 -05:00
0792564ffe Merge pull request 'feat(bridge): boss frames for the site's public triggers (runicnpc stage 6)' (#24) from feat/runicnpc-stage6 into edge
Reviewed-on: #24
2026-10-06 00:45:32 +00:00
90f403422a feat(bridge): boss frames for the site's public triggers (runicnpc stage 6)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
npc.boss.spawned, npc.boss.phase and npc.boss.died, from RunicNPC
API 5's boss hooks, for the site's spawned, phase and killed triggers
(D277). The death frame names the killer and counts the players who
hurt the boss; npc.died still carries every contributor.

A profile's `boss` and `use` blocks travel in the push unchanged.
runicnpc_api and RunicNpcApiNeeded are 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 10:48:28 -05:00
9a3ae47850 Merge pull request 'feat(bridge): the faction table and an event's escort, ally and tether (runicnpc stage 5)' (#23) from feat/runicnpc-stage5 into edge
Reviewed-on: #23
2026-10-05 08:38:55 +00:00
cf26b05abc feat(bridge): the faction table and an event's escort, ally and tether (runicnpc stage 5)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 19s
- npc.profiles answers with RunicNPC's faction table, and npc.profiles.set
  pushes one beside the profiles (RunicNpc_SetFactions), set first so the
  profiles are checked against it; a refused table comes back as
  factionsRefused and the old one is kept (D254);
- world.place with a profile takes escort (a Steam id; the player must be
  on, else escort-offline), ally ({kind: clan|player, id}) and tether (the
  run's own zone that holds the point, else no-zone), and gives each NPC
  its orders through RunicNpc_Escort, _Ally and _Tether. All or nothing:
  an order RunicNPC refuses removes every NPC the step placed (D269, D270,
  D272);
- the RunicNPC API the bridge needs is 4 (overlay.toml runicnpc_api = 4).

Compiled on the Oxide rig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 07:58:16 -05:00
d1039ff95d Merge pull request 'feat(bridge): RunicNPC, the site's NPC profiles and placements (runicnpc stage 4)' (#22) from feat/runicnpc-stage4 into edge
Reviewed-on: #22
2026-09-30 19:52:41 +00:00
6347c25d57 feat(release): declare the RunicNPC API the bridge needs (runicnpc stage 4, D224)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m36s
overlay.toml gains runicnpc_api = 3, the release folds it into manifest.json,
and checkPlugin.js holds it equal to RunicNpcApiNeeded in the code. The
installer's bundle carries the latest RunicNPC release that answers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:48:51 -05:00
742e18ba0a feat(bridge): RunicNPC, the site's NPC profiles and placements (runicnpc stage 4)
Protocol 13 gains npc.profiles, npc.profiles.set, npc.placements and
npc.placement (add, set, remove, rename, respawn), each calling RunicNPC's
API 3; the frames npc.died, npc.health and npc.placement.changed from its
hooks; runicNpc in the integrations body; world.place with a profile; the
killfeed's attackerNpc and attackerProfile; and the tally's
npcProfileKills. rg.npc reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:21:50 -05:00
428055f6ef Merge pull request 'feat(bridge): count the twenty-three chat-title conditions in player.tally (PLAN_REDESIGNS §5)' (#20) from feat/title-conditions into edge
Reviewed-on: #20
2026-09-29 17:01:02 +00:00
f7d5b4d691 Merge edge into feat/title-conditions: §3's zones and domes
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
One conflict, ExpectedHooks: both phases' hooks are kept (48, all void).
Compiled and loaded on both rigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 11:34:26 -05:00
463a50dbb3 Merge pull request 'feat(bridge): a zone's ZoneManager options, its messages, and a ZoneDomes dome (PLAN_REDESIGNS §3)' (#21) from feat/zones-domes into edge
Reviewed-on: #21
2026-09-29 16:28:51 +00:00
b30c6edd59 feat(bridge): a dome without a type is the full dome (D212)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 7s
ZoneDomes' Standard type is its one whole dome; the four colours show only
where they meet the ground or a building. Walked on both rigs 2026-09-29.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 09:42:17 -05:00
746523387e feat(bridge): a zone's ZoneManager options, its messages, and a ZoneDomes dome (PLAN_REDESIGNS §3)
world.zone gains flags, settings, enterMessage, leaveMessage, delivery,
format and dome (PROTOCOL §19.11). Flags are checked against the live
ZoneManager's own list, read through its API call ZoneFieldListRaw() (D211),
and refused bad-option rather than dropped; settings are an allowlist of
five with bounds. Both are kept on the registry entry and re-applied when
a zone is re-created.

The bridge says a zone's enter and leave messages itself (D193), as chat
or a popup (chat when PopupNotifications is missing), and stays quiet for
5 s after re-creating a zone.

RunicGatewayDomes.cs, a new helper, patches ZoneDomes so its API can be
called without a player, and raises OnRgDomesReady after ZoneDomes' own
start; the bridge puts its domes back only after it, so none is drawn
twice. Domes are removed before their zone at expiry and revert.

hello carries zoneManager { version, flags }, integrations.zoneDomes the
ZoneDomes and helper state, and hello is sent again when ZoneManager,
ZoneDomes or either helper loads or unloads. The release ships the new
helper beside the zone helper.

Not compiled or walked on a rig: the panel's file API was down on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 05:25:43 -05:00
f67f9e3b22 fix(bridge): the three members the rig corrected — crafter owner, mission assignee, heal target
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m28s
Compiled on both rigs (2026-09-29): ItemCraftTask has no owner, so the crafter is ItemCrafter.owner; OnMissionSucceeded credits its third argument, the assignee, since MissionInstance has no accessor for it; OnHealingItemUse takes its target as object, because GiveEffectsTo now takes an IMedicalToolTarget, and counts only a player. GameObject.ToBaseEntity is not in scope for a plugin, so OnEntityBuilt asks GetComponent<BuildingBlock>().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 02:07:29 -05:00
ff49f8f6ed feat(bridge): count the twenty-three chat-title conditions in player.tally (PLAN_REDESIGNS §5)
player.tally gains animalKills, weaponKills (by the killfeed's weapon
prefab name), headshots, bestPvpM/bestPveM (this interval's maximum),
apcKills, heliKills, crafted (by item category), built, repaired, healed,
rockets, explosives and missions. Every field is absent when nothing moved.

Ten new void hooks, all in ExpectedHooks: OnEntityTakeDamage (the last
player to hurt an APC or helicopter, for a crash that names nobody),
OnItemCraftFinished, OnEntityBuilt, OnStructureRepair (counted only when
the health rose by the next tick), OnHealingItemUse and OnPlayerRevive
(another player only; a syringe revive counts once), OnRocketLaunched,
OnExplosiveThrown/Dropped (a damaging TimedExplosive only) and
OnMissionSucceeded.

npcKills keeps counting animals too, so the public column does not change
meaning mid-wipe; the site takes human NPCs as the difference (D209).
WeaponOf and DistanceOf are split out of DescribeAttacker so the killfeed
and the tally read one name and one distance.

Not yet compiled on a rig: the rigs' storage is unmounted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 23:32:58 -05:00
3f44d78944 Merge pull request 'feat(bridge): read the whole permission store (perm.inventory), and sync group parents' (#19) from feat/perm-inventory into edge
Reviewed-on: #19
2026-09-28 16:38:56 +00:00
8b4b65249f feat(bridge): read the whole permission store (perm.inventory), and sync group parents
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
The permission manager (PLAN_REDESIGNS section 1, D160): the site owns every
permission and group on the server, so the bridge now reads all of it.

- perm.inventory: every registered permission with the plugin that
  registered it (PermissionExists(name, plugin), public on both
  frameworks), every group with title, rank, parent and permissions,
  every holder's direct permissions and groups (not default), and the
  pairs event leases hold. Built a slice per tick, sent in pages under
  the 1 MiB line cap, paged from one snapshot. rg.inventory logs it.
- perm.sync sets an existing group's title and rank and every group's
  parent; an absent title or rank means "leave it" (the adopt policy's
  hold). The managed list and the foreign scan are gone: the site reads
  the store and decides.

Walked on both rigs: Oxide 85 permissions all owned, Carbon 104 with its
30 adminmodule.* unowned; ZoneManager owns ignoreflag.nokits.

Refs RunicGateway/Module-Rust#21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 06:58:39 -05:00
7a1f5aa072 Merge pull request 'fix(bridge): protocol 13 step 2 — expiry, gathering, raids, plugin loads, and a ZoneManager helper (F1 F3 F4 F8 F12 F13)' (#18) from fix/protocol-13-step2 into edge
Reviewed-on: #18
2026-09-27 06:11:46 +00:00
d1d4a00d1d fix(bridge): the ZoneManager helper copies the player list without ToArray
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 10s
Found compiling on the rust-oxide rig: BasePlayer.activePlayerList is
Facepunch's ListHashSet, which has no ToArray, so the helper did not compile.
It now copies into a List, which it needs anyway: entering a player can eject
or kill them and change the live list mid-loop. HarmonyLib itself resolved on
Oxide with no reference directive, and the helper patched ZoneManager 3.1.14 on
both frameworks once this compiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:43:22 -05:00
586ad7ceba fix(bridge): protocol 13 step 2 — expiry, gathering, raids, plugin loads, and a ZoneManager helper (F1 F3 F4 F8 F12 F13)
The bridge's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288).

- F13: `world.expired` carries the entity's kind as `what`. It used to write
  it over the frame's own `kind`, so every expiry left filed as "zone".
- F1 (D159): `gathered` also counts OnDispenserBonus (the final-hit bonus),
  OnCollectiblePickup (hemp, mushrooms, stones off the ground) and
  OnGrowableGathered (farmed plants); all three are void and in ExpectedHooks.
- F3: taking down a building block you own, or one on a cupboard you are
  authorised on, no longer counts toward `structures`. The frame still goes.
- F4: every remaining UserIDString is SteamIdOf(userID), so a player another
  plugin spawned is never `steamId: null`.
- F8 (D184): `plugin.loaded` / `plugin.unloaded`, once the world is ready,
  with the permissions that plugin added or removed (a diff of the registered
  list, taken a tick after the hook).
- F12 (D181, D182): RunicGatewayZones.cs, a D168 helper shipped beside the
  bridge, Harmony-postfixes ZoneManager's Zone.InitializeZone and, a tick
  later, enters every connected player ZoneManager's own IsPositionInZone
  places inside, through ZoneManager's own OnPlayerEnterZone. Without it (or
  when it cannot find what it patches) the bridge scores its zones by
  IsPositionInZone. Hello reports `zoneHelper: { state, version?, reason? }`.
- release.yml stages every helper beside the bridge and lists each .cs with
  its sha256 in manifest.files; the installer and the egg place them all.

Not yet compiled: this is walked on both rigs before it merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:36:04 -05:00
fc7dccb90b Merge pull request 'fix(bridge): protocol 13 — a configuration reload that works on Carbon (W1-W5)' (#17) from fix/protocol-13-carbon-reload into edge
Reviewed-on: #17
2026-09-27 01:23:56 +00:00
616f4fbca8 fix(bridge): protocol 13 — a configuration reload that works on Carbon (W1-W5)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
The step-1 rig walk (PLAYER_WALK configuration 2, 2b, 4 on both rigs) found:

- W3: Carbon's Oxide-compatible ReloadPlugin is not a reload. One call unloaded
  Kits and never compiled it again; the next did nothing. Carbon now reloads
  with `c.reload` on the server console (ConsoleSystem.Run), as an operator
  would at the panel. Oxide keeps ReloadPlugin, falling back to `oxide.reload`.
- W4: Carbon's log is carbon/logs/Carbon.Core.log, with no *.txt in the
  directory at all, so the log evidence never fired on Carbon. Chosen by name.
- W5: Carbon loads a plugin whose LoadConfig threw and raises Loaded in the
  same instant. OnPluginLoaded is now noted, and the job settles on the first
  log read a poll after it. Carbon's "Failed ILoadConfig" is on the word list.
- W2: Oxide's "Could not initialize plugin 'Kits v4.4.9' (JsonReaderException:
  ...)" is on the word list, so the outcome quotes the exception.
- W1: Oxide's idle compiler shutdown drops a queued reload with "Timed out
  waiting for plugin to be compiled: X". The reload is asked for once more;
  a second give-up settles at once by what is loaded.

rg.config prints the log file it follows (log=).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 20:21:06 -05:00
5de406f356 Merge pull request 'fix(bridge): protocol 13 opens — UTF-8 from the site, a reload that fails on evidence (F15, F9, F10)' (#16) from fix/protocol-13-config-reload into edge
Reviewed-on: #16
2026-09-26 22:22:09 +00:00
042727e5fe fix(bridge): protocol 13 opens — UTF-8 from the site, a reload that fails on evidence (F15, F9, F10)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 20s
F15: the read loop cast each byte to a char, decoding the site's UTF-8 as
Latin-1, so every non-ASCII announcement, title, kit name and saved config
file arrived mangled. Lines are now split as bytes and decoded whole, which
keeps a character that straddles two reads intact.

F9/F10 (D177, D178): config.write answers at once with pending, writeId and
ceilingMs, and the outcome follows as a config.outcome event. A pending
reload reads the framework log each second and rolls back the moment the
target fails to compile or initialise; a slow compile is waited for, under
a 30 s ceiling per phase. At the ceiling the plugin looks at what is loaded
before restoring anything, and a rollback waits for the restore's own
reload so the outcome says whether the plugin is back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:04:41 -05:00
7 changed files with 4376 additions and 327 deletions

View File

@@ -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`) 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,11 @@
# 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) 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 +79,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
SEED_VERSION: "0.1.0"
INSTALLER_REPO: RunicGateway/installer
@@ -279,6 +288,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 +316,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 +338,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 +358,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 +369,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"

View File

@@ -34,9 +34,24 @@ 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
```
`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.
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
```json
@@ -69,7 +84,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.

View File

@@ -26,19 +26,24 @@
# 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: 13 — 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.
protocol = 13
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
# nothing, so it is expected to work on any reasonably current Oxide. This is the
# oldest build it is known good on.
# The overlay only ADDS files — the bridge, and beside it the ZoneManager helper
# (`RunicGatewayZones.cs`, docs/modules/rust/PLAN_FIXES.md D181, D182), both into
# `oxide/plugins/` — and patches nothing on disk, so it is expected to work on any
# reasonably current Oxide. The helper patches ZoneManager in memory with Harmony,
# which both frameworks load; without it the bridge still loads and falls back.
# This is the oldest build it is known good on.
#
# There is no `patches_verified_against` key, and there is no `patches/` tier:
# Rust's server is a binary and Oxide's hook API is the supported way in, so
@@ -72,3 +77,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

View 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})" : "")}");
}
}
}

View 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)")}");
}
}
}

View File

@@ -198,6 +198,14 @@ function check(source, toml) {
// 5. 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')