From 5c3a360b7059b65ed6b8321de6ccd77c320faf19 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 2 Nov 2026 04:24:14 -0600 Subject: [PATCH 1/2] fix(kit): find a kit's extra by its item definition when it has no held entity (#20) Rust's November 2026 build (staging 26344826) adds the server variable ItemModEntity.playerOnlyEntityBeltOnly, on by default: an item Rust marks player-only, the medical syringe among them, has a held entity only while it is in the belt. A kit puts its syringes in the main inventory, so the heal (D260) found no item whose held entity is a MedicalTool and never healed (s5.kit.heal on the staging drill: heals 0, health 75). FindHeld now asks the item definition's entity prefab when an item has no held entity, for every extra (heal, melee, flamethrower, grenade, rocket), and HasFuel reads the flamethrower's fuel type the same way. Holding the item moves it to the belt, where Rust makes its entity. A heal that cannot hold the item (a full belt) is now no heal, not a heal of an item never held (D331). On rust-staging: s5.kit.heal PASS, heals 2, syringes left 0, health 145. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 90b1d1c..aa69d14 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -2784,13 +2784,29 @@ namespace Oxide.Plugins if (container == null) continue; foreach (Item item in container.itemList) - if (item != null && item.GetHeldEntity() is T) + if (item != null && HeldAs(item) != null) return item; } return null; } + /// + /// The item's held entity as a , or, when it has none, its definition's + /// entity prefab's. Since Rust's November 2026 build a "player-only" item (a syringe) has a held + /// entity only in the belt (ItemModEntity.playerOnlyEntityBeltOnly), and an extra waits in + /// the main inventory until moves it (issue #20). + /// + private static T HeldAs(Item item) where T : HeldEntity + { + BaseEntity held = item.GetHeldEntity(); + if (held != null) + return held as T; + ItemModEntity mod = item.info.GetComponent(); + GameObject prefab = mod != null && mod.entityPrefab != null && mod.entityPrefab.isValid ? mod.entityPrefab.Get() : null; + return prefab != null ? prefab.GetComponent() : null; + } + /// Puts an item in its hands; only the belt can be held, so it is moved there first. private static bool Hold(RunicNpcPlayer npc, Item item) { @@ -2914,7 +2930,8 @@ namespace Oxide.Plugins if (amount <= 0f) return false; - Hold(npc, item); + if (!Hold(npc, item)) + return false; // a full belt: nowhere to hold it. npc.SignalBroadcast(BaseEntity.Signal.Attack); item.UseItem(1); npc.Heal(amount); @@ -3011,8 +3028,10 @@ namespace Oxide.Plugins private static bool HasFuel(RunicNpcPlayer npc, Item flameItem) { - FlameThrower f = flameItem.GetHeldEntity() as FlameThrower; - return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null)); + // Off the belt it may have no held entity, so no tank (HeldAs): only the kit's fuel counts then. + FlameThrower held = flameItem.GetHeldEntity() as FlameThrower; + FlameThrower f = HeldAs(flameItem); + return f != null && ((held != null && held.ammo > 0) || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null)); } /// -- 2.49.1 From df2a258f642ff0bd0be64e34892e3f40fa20d2c8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 2 Nov 2026 04:24:15 -0600 Subject: [PATCH 2/2] fix(drill): stage 5's pairs stand in each other's sight; say how the leashed NPC came to fight (#20, #22) s5.fight.shootBack failed on every staging drill run because, on the drill's 3500 map (seed 1234), a cliff rock (cliff_hills_large_e_arid) stands between the fifth fight's victim and the bully 15 m north of it: the bully never saw the victim, so never shot it. In the open the victim answers within a second. Each fight's second NPC now stands where each one's eyes see the other's centre, both ways, as RunicNPC's own line of sight runs: north first, then the other seven ways round, with a note when it moves. s5.fight.leash failed once (run 49) and did not come back in three tries. The first moment the leashed NPC has a target, the harness now notes the target, its health, and how far it and the scientist are from its home, so the next failing run says whether it sensed, was shot, or heard an alarm. Harness 0.7.5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- tools/RunicNpcTest.cs | 50 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index f2fdb27..78bffaf 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -45,7 +45,7 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.7.4")] + [Info("RunicNpcTest", "Runic Gateway", "0.7.5")] [Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { @@ -1753,6 +1753,31 @@ namespace Oxide.Plugins return false; } + /// + /// Where a stage 5 fight's second NPC stands: m from the first, on the navmesh, + /// with a clear line between their eyes. North first, as the fights were laid out; then the other seven + /// ways round. On the drill's 3500 map a cliff rock stood between the victim and the bully north of it, + /// so neither ever saw the other and s5.fight.shootBack failed on every run (issue #20). + /// + private Vector3 Partner(Vector3 at, float dist) + { + for (int k = 0; k < 8; k++) + { + float angle = k * Mathf.PI / 4f; + Vector3 p = OnMesh(at + new Vector3(Mathf.Sin(angle), 0f, Mathf.Cos(angle)) * dist); + NavMeshHit hit; + if (!SampleNavmesh(p, 1f, out hit)) continue; + // Each one's eyes to the other's centre, both ways, as RunicNPC's own line of sight runs. + if (Physics.Linecast(at + Vector3.up * 1.6f, p + Vector3.up * 0.9f, 1218519041, QueryTriggerInteraction.Ignore) || + Physics.Linecast(p + Vector3.up * 1.6f, at + Vector3.up * 0.9f, 1218519041, QueryTriggerInteraction.Ignore)) continue; + if (k > 0) Note($"s5: the partner {dist:0} m from {at} stands {k * 45}° round from north (no clear line north)"); + return p; + } + + Note($"s5: no clear line {dist:0} m round {at}; the partner stands north as before"); + return OnMesh(at + new Vector3(0, 0, dist)); + } + private int HorrorLeft() => BaseNetworkable.serverEntities.OfType().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); private IEnumerator Stage5() @@ -1793,16 +1818,18 @@ namespace Oxide.Plugins Vector3 d = OnMesh(f + new Vector3(70, 0, 0)); Vector3 e = OnMesh(f + new Vector3(140, 0, 0)); + // Each fight's second NPC where the two can see each other (issue #20), found before anyone stands there. + Vector3 aTo = Partner(a, 15f), bTo = Partner(b, 15f), cTo = Partner(c, 15f), dTo = Partner(d, 25f), eTo = Partner(e, 15f); BasePlayer red = Spawn(a, "t_red"); - BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue"); + BasePlayer blue = Spawn(aTo, "t_blue"); BasePlayer hunter = Spawn(b, "t_hunter"); - ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15)); + ScientistNPC prey = SpawnScientist(bTo); BasePlayer neutral = Spawn(c, "t_roam"); - ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15)); + ScientistNPC bystander = SpawnScientist(cTo); BasePlayer leashed = Spawn(d, "t_leashed"); - ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25)); + ScientistNPC far = SpawnScientist(dTo); BasePlayer victim = Spawn(e, "t_roam"); - BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully"); + BasePlayer bully = Spawn(eTo, "t_bully"); var fighters = new Dictionary { { "red", red }, { "blue", blue }, { "hunter", hunter }, { "prey", prey }, { "neutral", neutral }, @@ -1823,7 +1850,16 @@ namespace Oxide.Plugins if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t; if (scientistAt < 0f && Gone(prey)) scientistAt = t; if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true; - if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true; + if (!leashedFought && !Gone(leashed) && TargetOf(Describe(leashed)) != "none") + { + leashedFought = true; + // Run 49 failed here once and it has not been seen since: say how it came to fight. A target its + // own sense found is within its chase (8 m) of home; a hurt one was shot; otherwise an ally's alarm. + Dictionary dl = Describe(leashed); + Vector3 leashHome = V(dl["home"]); + Note($"s5.fight.leash: fighting at {t} s, target {dl["target"]}, health {Convert.ToSingle(dl["health"]):0}/{Convert.ToSingle(dl["maxHealth"]):0}, " + + $"{Flat(V(dl["position"]), leashHome):0.0} m from home; the scientist {(Gone(far) ? "gone" : $"{Flat(far.transform.position, leashHome):0.0} m from its home, {far.health:0} hp")}"); + } if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true; foreach (BasePlayer x in new[] { red, blue, hunter }) { -- 2.49.1