diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs
index bb900c1..b2d36a7 100644
--- a/tools/RunicNpcTest.cs
+++ b/tools/RunicNpcTest.cs
@@ -393,6 +393,74 @@ namespace Oxide.Plugins
arg.ReplyWith("rnt: kit probe started");
}
+ ///
+ /// A probe after the 2026-10-05 Rust update: a stock scientist and one of ours (t_sentry), each with a
+ /// stand-in 10 m off, every 2 s for 20 s; and how far the map's own scientists move in that time.
+ ///
+ [ConsoleCommand("rnt.stockprobe")]
+ private void CmdStockProbe(ConsoleSystem.Arg arg)
+ {
+ if (arg.Connection != null && !arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(300f, 2025, out f)) return;
+ StandIn(f + new Vector3(0, 0, 60), 50);
+ ScientistNPC stock = SpawnScientist(OnMesh(f + new Vector3(-30, 0, -60)));
+ BasePlayer a = StandIn(OnMesh(stock.transform.position + new Vector3(10, 0, 0)), 71);
+ BasePlayer ours = Spawn(OnMesh(f + new Vector3(30, 0, -60)), "t_sentry");
+ BasePlayer b = ours != null ? StandIn(OnMesh(ours.transform.position + new Vector3(10, 0, 0)), 72) : null;
+ List world = BaseNetworkable.serverEntities.OfType().Where(s => s.GetType().Name != "RunicNpcPlayer" && s != stock).Take(20).ToList();
+ Dictionary from = world.ToDictionary(s => s, s => s.transform.position);
+ _hitsOnStandIns = 0;
+ int n = 0;
+ timer.Repeat(2f, 10, () =>
+ {
+ n++;
+ string Line(ScientistNPC s, BasePlayer t)
+ {
+ if (Gone(s)) return "gone";
+ var senses = s.Brain?.Senses;
+ return $"state={s.Brain?.CurrentState?.StateType} players={senses?.Memory?.Players.Count} targets={senses?.Memory?.Targets.Count} knows={Knows(s, t)} best={((IAIAttack)s).GetBestTarget()?.ShortPrefabName ?? "none"}";
+ }
+ Puts($"[stock {n}] stock: {Line(stock, a)} | ours: {Line(ours as ScientistNPC, b)} | hits on stand-ins {_hitsOnStandIns}");
+ if (n == 10)
+ {
+ var moved = world.Where(s => !Gone(s)).Select(s => Vector3.Distance(s.transform.position, from[s])).ToList();
+ Puts($"[stock] the map's own scientists: {moved.Count}, moved >1 m in 20 s: {moved.Count(m => m > 1f)}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0} m; ai.think={ConVar.AI.think} ai.move={ConVar.AI.move} ai.ignoreplayers={ConVar.AI.ignoreplayers}");
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ }
+ });
+ arg.ReplyWith("rnt: stock probe started");
+ }
+
+ /// A probe: our monument roamer at the test's monument, and that monument's own scientists, every 4 s for 40 s.
+ [ConsoleCommand("rnt.monprobe")]
+ private void CmdMonProbe(ConsoleSystem.Arg arg)
+ {
+ if (arg.Connection != null && !arg.IsAdmin) return;
+ Vector3 at;
+ string name;
+ if (!FindMonument(out at, out name)) { arg.ReplyWith("no monument"); return; }
+ StandIn(at + new Vector3(0, 0, 100), 2);
+ BasePlayer mon = Spawn(at, "t_mon");
+ AIInformationZone zone = AIInformationZone.GetForPoint(at, false);
+ List locals = BaseNetworkable.serverEntities.OfType()
+ .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, at) < 150f).ToList();
+ Dictionary from = locals.ToDictionary(s => s, s => s.transform.position);
+ Vector3 monFrom = mon != null ? mon.transform.position : Vector3.zero;
+ Puts($"[mon] {name} at {at}: zone {(zone != null ? zone.name : "none")} movePoints={(zone != null ? zone.paths?.Count ?? -1 : -1)}, ours {(mon != null ? "spawned" : "REFUSED")}, its own scientists within 150 m: {locals.Count}");
+ int n = 0;
+ timer.Repeat(4f, 10, () =>
+ {
+ n++;
+ ScientistNPC s = mon as ScientistNPC;
+ var moved = locals.Where(x => !Gone(x)).Select(x => Vector3.Distance(x.transform.position, from[x])).ToList();
+ Puts($"[mon {n}] ours: state={s?.Brain?.CurrentState?.StateType} moved={(Gone(mon) ? -1 : Vector3.Distance(mon.transform.position, monFrom)):0.0} dest={(s?.Brain?.Navigator != null ? s.Brain.Navigator.Destination.ToString() : "-")} moving={s?.Brain?.Navigator?.Moving} | locals moved >1 m: {moved.Count(m => m > 1f)}/{moved.Count}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0}");
+ if (n == 10) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
+ });
+ arg.ReplyWith("rnt: monument probe started");
+ }
+
/// Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks.
[ConsoleCommand("rnt.list")]
private void CmdList(ConsoleSystem.Arg arg)
@@ -615,10 +683,16 @@ namespace Oxide.Plugins
Vector3 monument;
string monumentName;
BasePlayer mon = null;
+ List locals = new List();
+ var localsFrom = new Dictionary();
if (FindMonument(out monument, out monumentName))
{
StandIn(monument + new Vector3(0, 0, 100), 2);
mon = Spawn(monument, "t_mon");
+ // The monument's own scientists: a monument roamer is meant to roam as they do.
+ locals = BaseNetworkable.serverEntities.OfType()
+ .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, monument) < 150f).ToList();
+ foreach (ScientistNPC s in locals) localsFrom[s] = s.transform.position;
}
Vector3 home = wander.transform.position;
@@ -658,7 +732,13 @@ namespace Oxide.Plugins
if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false;
Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray()));
if (mon != null)
- Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)");
+ {
+ // Since the 2026-10-05 Rust update, a monument's own scientists can stand still for minutes with no
+ // real player near (stand-ins do not seem to count): then ours standing too is the same behaviour.
+ int localsMoved = locals.Count(s => !Gone(s) && Vector3.Distance(s.transform.position, localsFrom[s]) > 5f);
+ Check("move.monument.roams", monMax > 5f || (locals.Count > 0 && localsMoved == 0),
+ $"{monumentName}: ours furthest {monMax:0.0} m (Rust's own roam); the monument's own scientists moved >5 m: {localsMoved}/{locals.Count}");
+ }
else
Check("move.monument.roams", false, "no monument with an AI zone");
@@ -689,11 +769,20 @@ namespace Oxide.Plugins
private IEnumerator Sentry(Vector3 field)
{
- BasePlayer sentry = Spawn(field, "t_sentry");
+ // On a clear line (no rock between): after the 2026-10-05 Rust update's navmesh rebuild the field's
+ // first spot had one, and the sentry never saw its stand-in. A probe 10 m apart in the open hit 13 times.
+ Vector3 sentryAt, standInAt;
+ if (!ClearLine(field, 12f, out sentryAt, out standInAt))
+ {
+ sentryAt = field;
+ standInAt = field + new Vector3(12, 0, 0);
+ }
+
+ BasePlayer sentry = Spawn(sentryAt, "t_sentry");
yield return new WaitForSeconds(1f);
Vector3 spot = sentry.transform.position;
_hitsOnStandIns = 0;
- StandIn(spot + new Vector3(12, 0, 0), 4);
+ StandIn(standInAt, 4);
float moved = 0f;
for (int t = 0; t < 40; t++)
{