From e5f04e0c0ce9d6b5ec2ec605cac63069bcb2a980 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 7 Oct 2026 13:41:57 -0500 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 84 ++++++++++++++++----------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 32b679c..4217de3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1875,7 +1875,22 @@ namespace Oxide.Plugins // two meanings of one word would be two answers to "how many NPCs did I kill". // `animalKills` counts it AGAIN, separately (§5.2): the public NPC column keeps its // meaning mid-wipe, and the site takes the human NPCs as the difference. - if (entity is BaseNpc) + // + // Rust's own animals are now `Rust.Ai.Gen2.BaseNPC2` — stag, boar, wolf, bears, big cats, + // crocodile, farm animals, critters, sea life — and not `BaseNpc`, which is all this + // tested until the RunicNPC 9d walk found no animal kill had counted at all. `BaseNpc` + // stays for whatever still derives from it. `ScientistNPC2` is the one person among + // them, so it is a human NPC kill, not an animal. + if (entity is Rust.Ai.Gen2.ScientistNPC2) + { + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.Killed(info, entity, false); + CreditKill(attacker, false); + return; + } + + if (entity is BaseNpc || entity is Rust.Ai.Gen2.BaseNPC2) { Tally tally = TallyFor(attacker); tally.NpcKills++; @@ -2065,55 +2080,56 @@ namespace Oxide.Plugins }); } - // A syringe on a downed player raises `OnPlayerRevive` and then `OnHealingItemUse` for the - // same use; one act of help is one heal. The revive is remembered for the frame it happened in. - private ulong _reviveHealer; - private ulong _reviveTarget; - private int _reviveFrame = -1; + // A syringe on a downed player raises `OnHealingItemUse` and then, from inside the same + // `GiveEffectsTo`, `OnPlayerRevive`; one act of help is one heal. The last one counted is + // remembered for the frame it happened in, and the other hook skips the same pair. + private ulong _helpHealer; + private ulong _helpTarget; + private int _helpFrame = -1; - /// - /// Void: a non-null return refuses the heal. is what is healed; - /// the tool's owner is who heals. Healing yourself is not a heal (D174). - /// - /// - /// The target is taken as object: the game's `GiveEffectsTo` now takes an - /// `IMedicalToolTarget`, which is not always a player, and a parameter typed - /// `BasePlayer` would simply never be called for the hook's current shape. Only a - /// player healed counts. - /// - /// - private void OnHealingItemUse(MedicalTool tool, object target) + /// Counts one act of help unless it is yourself, an NPC, or the other hook already did this frame. + private void CountHelp(BasePlayer healer, BasePlayer player) { - MarkHook("OnHealingItemUse"); - - BasePlayer player = target as BasePlayer; - BasePlayer healer = tool != null ? tool.GetOwnerPlayer() : null; - if (healer == null || healer.IsNpc || player == null || player.IsNpc || (ulong)healer.userID == (ulong)player.userID) return; - if (_reviveFrame == UnityEngine.Time.frameCount - && _reviveHealer == (ulong)healer.userID && _reviveTarget == (ulong)player.userID) + int frame = UnityEngine.Time.frameCount; + + if (_helpFrame == frame && _helpHealer == (ulong)healer.userID && _helpTarget == (ulong)player.userID) return; + _helpHealer = healer.userID; + _helpTarget = player.userID; + _helpFrame = frame; + TallyFor(healer).Healed++; } + /// + /// Void: a non-null return refuses the heal. Healing yourself is not a heal (D174). + /// + /// + /// Rust's `MedicalTool.GiveEffectsTo(fromPlayer, toTarget)` raises it as + /// (tool, fromPlayer, toTarget). Until the RunicNPC 9d walk this took two + /// parameters, so the second — the healer — arrived as the target, every heal looked like + /// healing yourself, and none counted. The target is object: `IMedicalToolTarget` + /// is not always a player, and only a player healed counts. + /// + /// + private void OnHealingItemUse(MedicalTool tool, BasePlayer fromPlayer, object target) + { + MarkHook("OnHealingItemUse"); + + CountHelp(fromPlayer ?? (tool != null ? tool.GetOwnerPlayer() : null), target as BasePlayer); + } + /// Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted. private void OnPlayerRevive(BasePlayer reviver, BasePlayer player) { MarkHook("OnPlayerRevive"); - if (reviver == null || reviver.IsNpc || player == null || player.IsNpc - || (ulong)reviver.userID == (ulong)player.userID) - return; - - _reviveHealer = reviver.userID; - _reviveTarget = player.userID; - _reviveFrame = UnityEngine.Time.frameCount; - - TallyFor(reviver).Healed++; + CountHelp(reviver, player); } /// No return behaviour. One rocket leaving a launcher. From a785ad91efed55d549d986971a3e9c4edd8c4c86 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 7 Oct 2026 13:41:57 -0500 Subject: [PATCH 2/2] fix(zones): an event's zone enforces its flags (RunicGatewayZones 0.2.0, runicnpc 9d) 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGatewayZones.cs | 52 +++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/overlay/oxide/plugins/RunicGatewayZones.cs b/overlay/oxide/plugins/RunicGatewayZones.cs index 33c2ecb..ed28130 100644 --- a/overlay/oxide/plugins/RunicGatewayZones.cs +++ b/overlay/oxide/plugins/RunicGatewayZones.cs @@ -1,7 +1,7 @@ // Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge. // Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. // -// ONE JOB (docs/modules/rust/PLAN_FIXES.md F12, D168, D181, D182): +// 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, @@ -27,6 +27,17 @@ // * **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 @@ -41,8 +52,8 @@ using UnityEngine; namespace Oxide.Plugins { - [Info("Runic Gateway Zones", "Runic Gateway", "0.1.0")] - [Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded.")] + [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"; @@ -61,6 +72,9 @@ namespace Oxide.Plugins private FieldInfo _definition; private PropertyInfo _id; + /// ZoneManager's private UpdateHookSubscriptions, or null when this ZoneManager has none (the second job is then off). + private MethodInfo _updateHooks; + /// /// What the bridge reads through : patched, unsupported /// (ZoneManager is here but not the shape this helper patches), or no-zonemanager. @@ -134,6 +148,7 @@ namespace Oxide.Plugins 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 @@ -169,10 +184,14 @@ namespace Oxide.Plugins _state = "patched"; _reason = null; - Puts($"patched ZoneManager {_zoneManager.Version}: a zone takes in the players already inside it"); + 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(); } @@ -258,10 +277,32 @@ namespace Oxide.Plugins var batch = new List>(_pending); _pending.Clear(); + // The second job first: the hooks that enforce the new zones' flags, then the players. + RefreshHooks(); + foreach (KeyValuePair entry in batch) Sweep(entry.Key, entry.Value); } + /// + /// 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. + /// + 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[]; @@ -328,7 +369,8 @@ namespace Oxide.Plugins if (arg.Connection != null && !arg.IsAdmin) return; - arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}"); + arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}" + + $"; new zones' flags {(_updateHooks != null ? "enforced" : "NOT refreshed (no UpdateHookSubscriptions)")}"); } } }