diff --git a/Projects/Server.Tests/Helpers/PredictableRandom.cs b/Projects/Server.Tests/Helpers/PredictableRandom.cs
new file mode 100644
index 000000000..1f270520a
--- /dev/null
+++ b/Projects/Server.Tests/Helpers/PredictableRandom.cs
@@ -0,0 +1,43 @@
+using System;
+using Server.Random;
+
+namespace Server.Tests;
+
+///
+/// Replaces with a that returns
+/// a fixed value, making skill checks and other RNG-dependent code deterministic in tests.
+/// Restores the original generator on .
+/// Usage:
+///
+/// using var rng = new PredictableRandom(10); // Utility.Random(21) returns 10
+///
+///
+public sealed class PredictableRandom : IDisposable
+{
+ private readonly System.Random _original;
+
+ public PredictableRandom(int fixedValue)
+ {
+ _original = BuiltInRng.Generator;
+ BuiltInRng.Generator = new FixedRandom(fixedValue);
+ }
+
+ public void Dispose()
+ {
+ BuiltInRng.Generator = _original;
+ }
+
+ private sealed class FixedRandom(int value) : System.Random
+ {
+ public override int Next() => value;
+ public override int Next(int maxValue) => Math.Clamp(value, 0, maxValue - 1);
+ public override int Next(int minValue, int maxValue) => Math.Clamp(value + minValue, minValue, maxValue - 1);
+ public override long NextInt64() => value;
+ public override long NextInt64(long maxValue) => Math.Clamp(value, 0, maxValue - 1);
+ public override long NextInt64(long minValue, long maxValue) => Math.Clamp(value + minValue, minValue, maxValue - 1);
+ public override double NextDouble() => Math.Clamp(value / 20.0, 0.0, 1.0);
+
+ public override void NextBytes(byte[] buffer) => Array.Fill(buffer, (byte)Math.Clamp(value, 0, 255));
+ public override void NextBytes(Span buffer) => buffer.Fill((byte)Math.Clamp(value, 0, 255));
+ }
+}
diff --git a/Projects/Server/Random/BuiltInRng.cs b/Projects/Server/Random/BuiltInRng.cs
index d3927242d..2472f68a4 100644
--- a/Projects/Server/Random/BuiltInRng.cs
+++ b/Projects/Server/Random/BuiltInRng.cs
@@ -20,7 +20,7 @@ namespace Server.Random;
public static class BuiltInRng
{
- public static System.Random Generator { get; private set; } = new();
+ public static System.Random Generator { get; internal set; } = new();
public static void Reset() => Generator = new System.Random();
diff --git a/Projects/UOContent.Tests/Tests/Skills/DetectHiddenTests.cs b/Projects/UOContent.Tests/Tests/Skills/DetectHiddenTests.cs
new file mode 100644
index 000000000..bc416d148
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Skills/DetectHiddenTests.cs
@@ -0,0 +1,213 @@
+using Server;
+using Server.Mobiles;
+using Server.SkillHandlers;
+using Server.Tests;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class DetectHiddenTests
+{
+ // All coordinates use Y=500 to stay well within Felucca's 7168x4096 boundary.
+ // X values are spread 200 tiles apart to avoid overlap with each other and
+ // with the Tracking tests that use coordinates around (1000-4000, 1000-4000).
+
+ ///
+ /// A detector with higher Detect Hidden than the stealther's Hiding should reveal them.
+ /// With PredictableRandom both rolls get the same offset, so detection succeeds
+ /// when detectSkill >= hiding.
+ ///
+ [Fact]
+ public void TryDetectStealther_Reveals_WhenDetectSkillExceedsHiding()
+ {
+ using var rng = new PredictableRandom(10);
+ DetectHidden.ClearDebounceCache();
+ var map = Map.Felucca;
+ var detector = CreatePlayerMobile(map, new Point3D(1000, 500, 0));
+ var stealther = CreatePlayerMobile(map, new Point3D(1001, 500, 0));
+
+ try
+ {
+ detector.Skills.DetectHidden.BaseFixedPoint = 600; // 60.0
+ stealther.Skills.Hiding.BaseFixedPoint = 500; // 50.0
+ stealther.Hidden = true;
+
+ var result = DetectHidden.TryDetectStealther(detector, stealther);
+
+ Assert.True(result);
+ Assert.False(stealther.Hidden);
+ }
+ finally
+ {
+ detector.Delete();
+ stealther.Delete();
+ DetectHidden.ClearDebounceCache();
+ }
+ }
+
+ ///
+ /// When detect skill exactly equals hiding, detection succeeds (ss >= ts).
+ ///
+ [Fact]
+ public void TryDetectStealther_Reveals_WhenSkillsAreEqual()
+ {
+ using var rng = new PredictableRandom(10);
+ DetectHidden.ClearDebounceCache();
+ var map = Map.Felucca;
+ var detector = CreatePlayerMobile(map, new Point3D(1200, 500, 0));
+ var stealther = CreatePlayerMobile(map, new Point3D(1201, 500, 0));
+
+ try
+ {
+ detector.Skills.DetectHidden.BaseFixedPoint = 500; // 50.0
+ stealther.Skills.Hiding.BaseFixedPoint = 500; // 50.0
+ stealther.Hidden = true;
+
+ var result = DetectHidden.TryDetectStealther(detector, stealther);
+
+ Assert.True(result);
+ Assert.False(stealther.Hidden);
+ }
+ finally
+ {
+ detector.Delete();
+ stealther.Delete();
+ DetectHidden.ClearDebounceCache();
+ }
+ }
+
+ ///
+ /// When hiding skill exceeds detect skill, detection fails.
+ ///
+ [Fact]
+ public void TryDetectStealther_DoesNotReveal_WhenHidingExceedsDetectSkill()
+ {
+ using var rng = new PredictableRandom(10);
+ DetectHidden.ClearDebounceCache();
+ var map = Map.Felucca;
+ var detector = CreatePlayerMobile(map, new Point3D(1400, 500, 0));
+ var stealther = CreatePlayerMobile(map, new Point3D(1401, 500, 0));
+
+ try
+ {
+ detector.Skills.DetectHidden.BaseFixedPoint = 500; // 50.0
+ stealther.Skills.Hiding.BaseFixedPoint = 600; // 60.0
+ stealther.Hidden = true;
+
+ var result = DetectHidden.TryDetectStealther(detector, stealther);
+
+ Assert.False(result);
+ Assert.True(stealther.Hidden);
+ }
+ finally
+ {
+ detector.Delete();
+ stealther.Delete();
+ DetectHidden.ClearDebounceCache();
+ }
+ }
+
+ ///
+ /// A detector with zero Detect Hidden skill should never passively detect anyone.
+ /// Uses Elf race since Humans get a 20.0 racial bonus (Jack of All Trades).
+ ///
+ [Fact]
+ public void TryDetectStealther_DoesNotReveal_WhenDetectorHasNoSkill()
+ {
+ using var rng = new PredictableRandom(10);
+ DetectHidden.ClearDebounceCache();
+ var map = Map.Felucca;
+ var detector = CreatePlayerMobile(map, new Point3D(1600, 500, 0));
+ var stealther = CreatePlayerMobile(map, new Point3D(1601, 500, 0));
+
+ try
+ {
+ detector.Race = Race.Elf;
+ detector.Skills.DetectHidden.BaseFixedPoint = 0; // 0.0
+ stealther.Skills.Hiding.BaseFixedPoint = 0; // 0.0
+ stealther.Hidden = true;
+
+ var result = DetectHidden.TryDetectStealther(detector, stealther);
+
+ Assert.False(result);
+ Assert.True(stealther.Hidden);
+ }
+ finally
+ {
+ detector.Delete();
+ stealther.Delete();
+ DetectHidden.ClearDebounceCache();
+ }
+ }
+
+ ///
+ /// TryDetectStealther is a no-op when the target is not actually hidden.
+ ///
+ [Fact]
+ public void TryDetectStealther_DoesNothing_WhenStealtherIsNotHidden()
+ {
+ using var rng = new PredictableRandom(10);
+ DetectHidden.ClearDebounceCache();
+ var map = Map.Felucca;
+ var detector = CreatePlayerMobile(map, new Point3D(1800, 500, 0));
+ var stealther = CreatePlayerMobile(map, new Point3D(1801, 500, 0));
+
+ try
+ {
+ detector.Skills.DetectHidden.BaseFixedPoint = 1000;
+ stealther.Hidden = false;
+
+ var result = DetectHidden.TryDetectStealther(detector, stealther);
+
+ Assert.False(result);
+ Assert.False(stealther.Hidden);
+ }
+ finally
+ {
+ detector.Delete();
+ stealther.Delete();
+ DetectHidden.ClearDebounceCache();
+ }
+ }
+
+ ///
+ /// Passive detection only works on Felucca. A stealther on Trammel
+ /// should never be revealed by passive detection.
+ ///
+ [Fact]
+ public void TryDetectStealther_DoesNotReveal_WhenNotOnFelucca()
+ {
+ using var rng = new PredictableRandom(10);
+ DetectHidden.ClearDebounceCache();
+ var map = Map.Trammel;
+ var detector = CreatePlayerMobile(map, new Point3D(2000, 500, 0));
+ var stealther = CreatePlayerMobile(map, new Point3D(2001, 500, 0));
+
+ try
+ {
+ detector.Skills.DetectHidden.BaseFixedPoint = 1000; // 100.0
+ stealther.Skills.Hiding.BaseFixedPoint = 0; // 0.0
+ stealther.Hidden = true;
+
+ var result = DetectHidden.TryDetectStealther(detector, stealther);
+
+ Assert.False(result);
+ Assert.True(stealther.Hidden);
+ }
+ finally
+ {
+ detector.Delete();
+ stealther.Delete();
+ DetectHidden.ClearDebounceCache();
+ }
+ }
+
+ private static PlayerMobile CreatePlayerMobile(Map map, Point3D location)
+ {
+ var mobile = new PlayerMobile(World.NewMobile);
+ mobile.DefaultMobileInit();
+ mobile.MoveToWorld(location, map);
+ return mobile;
+ }
+}
diff --git a/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs b/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs
index 25daeeef8..70da09660 100644
--- a/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs
+++ b/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs
@@ -12,4 +12,5 @@ public static class ContentFeatureFlags
public static bool HousePlacement { get; set; } = true;
public static bool BoatPlacement { get; set; } = true;
public static bool BulkOrders { get; set; } = true;
+ public static bool PassiveDetectHidden { get; set; } = true;
}
diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs
index ef9aba91e..213fea951 100644
--- a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs
+++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs
@@ -967,12 +967,13 @@ public static class FeatureFlagManager
"bank_access" => ServerFeatureFlags.BankAccess = enabled,
// UOContent flags
- "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled,
- "vendor_sell" => ContentFeatureFlags.VendorSell = enabled,
- "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled,
- "house_placement" => ContentFeatureFlags.HousePlacement = enabled,
- "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled,
- "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled,
+ "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled,
+ "vendor_sell" => ContentFeatureFlags.VendorSell = enabled,
+ "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled,
+ "house_placement" => ContentFeatureFlags.HousePlacement = enabled,
+ "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled,
+ "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled,
+ "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled,
};
}
diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs
index c26e3f918..c23023caf 100644
--- a/Projects/UOContent/Mobiles/PlayerMobile.cs
+++ b/Projects/UOContent/Mobiles/PlayerMobile.cs
@@ -3534,6 +3534,26 @@ namespace Server.Mobiles
return true;
}
+ public override void OnMovement(Mobile m, Point3D oldLocation)
+ {
+ base.OnMovement(m, oldLocation);
+
+ // Passive detect hidden: either party moving within range can trigger detection
+ if (m is PlayerMobile && Utility.InRange(Location, m.Location, 4))
+ {
+ if (m.Hidden && AccessLevel == AccessLevel.Player)
+ {
+ // A hidden mobile (stealther) moved near us — we try to detect them
+ DetectHidden.TryDetectStealther(this, m);
+ }
+ else if (Hidden && m.AccessLevel == AccessLevel.Player)
+ {
+ // We're hidden and a potential detector moved near us — they try to detect us
+ DetectHidden.TryDetectStealther(m, this);
+ }
+ }
+ }
+
public void AddFollower(Mobile m)
{
_allFollowers ??= new HashSet();
diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs
index 85f73c085..b1b0a02fb 100644
--- a/Projects/UOContent/Skills/DetectHidden.cs
+++ b/Projects/UOContent/Skills/DetectHidden.cs
@@ -1,59 +1,191 @@
using System;
+using System.Collections.Generic;
+using Server.Collections;
+using Server.Engines.PartySystem;
using Server.Factions;
+using Server.Guilds;
+using Server.Items;
using Server.Mobiles;
using Server.Multis;
+using Server.Network;
+using Server.Systems.FeatureFlags;
using Server.Targeting;
-namespace Server.SkillHandlers
+namespace Server.SkillHandlers;
+
+public static class DetectHidden
{
- public static class DetectHidden
+ // Debounce tracking: (stealther, detector) -> last detection time
+ private static readonly Dictionary<(Mobile, Mobile), long> PassiveDetectDebounce = [];
+
+ private const int PassiveDetectDebounceMs = 3000; // 3 seconds
+ private const int DebounceCleanupIntervalMs = 10000; // Run cleanup every 10 seconds
+ private const int DebounceExpiryMs = 10000; // Remove entries older than 10 seconds
+ private static long _lastCleanupTime;
+
+ public static void Initialize()
{
- public static void Initialize()
+ SkillInfo.Table[(int)SkillName.DetectHidden].Callback = OnUse;
+ }
+
+ public static TimeSpan OnUse(Mobile src)
+ {
+ src.SendLocalizedMessage(500819); // Where will you search?
+ src.Target = new InternalTarget();
+
+ return TimeSpan.FromSeconds(30.0);
+ }
+
+ // Clean up old debounce entries to prevent memory bloat
+ private static void CleanupDebounceCache(long now)
+ {
+ using var entriesToRemove = PooledRefQueue<(Mobile, Mobile)>.Create();
+
+ foreach (var entry in PassiveDetectDebounce)
{
- SkillInfo.Table[(int)SkillName.DetectHidden].Callback = OnUse;
+ if (now - entry.Value > DebounceExpiryMs)
+ {
+ entriesToRemove.Enqueue(entry.Key);
+ }
}
- public static TimeSpan OnUse(Mobile src)
+ while (entriesToRemove.Count > 0)
{
- src.SendLocalizedMessage(500819); // Where will you search?
- src.Target = new InternalTarget();
+ PassiveDetectDebounce.Remove(entriesToRemove.Dequeue());
+ }
+ }
- return TimeSpan.FromSeconds(30.0);
+ // For testing: clear the debounce cache to prevent cross-test contamination
+ internal static void ClearDebounceCache()
+ {
+ PassiveDetectDebounce.Clear();
+ }
+
+ // Passive detection: check if a detector can passively detect a stealther.
+ // Called via OnMovement when either party moves within range.
+ // Returns true if detection was successful (and the stealther was revealed).
+ // NOTE: OSI uncertain - The exact chance calculation and distance dropoff is unknown.
+ // We use a ±10 variance on both skills, matching active detection mechanics.
+ public static bool TryDetectStealther(Mobile detector, Mobile stealther)
+ {
+ if (!ContentFeatureFlags.PassiveDetectHidden || stealther == detector || !stealther.Hidden || !detector.Alive)
+ {
+ return false;
}
- private class InternalTarget : Target
+ // Felucca PvP only
+ if (stealther.Map != Map.Felucca)
{
- public InternalTarget() : base(12, true, TargetFlags.None)
+ return false;
+ }
+
+ var detectSkill = detector.Skills.DetectHidden.Value;
+ if (detectSkill <= 0)
+ {
+ return false;
+ }
+
+ var now = Core.TickCount;
+
+ // Debounce: skip pairs already checked recently (cheap dictionary lookup before expensive checks)
+ var key = (stealther, detector);
+ if (PassiveDetectDebounce.TryGetValue(key, out var lastDetect) && now - lastDetect < PassiveDetectDebounceMs)
+ {
+ return false;
+ }
+
+ // Periodic cleanup (amortized, not every call)
+ if (now - _lastCleanupTime > DebounceCleanupIntervalMs)
+ {
+ _lastCleanupTime = now;
+ CleanupDebounceCache(now);
+ }
+
+ // Excludes blessed, dead, bonded pets, and region-based PvP rules
+ if (stealther.AccessLevel > AccessLevel.Player || !detector.CanBeHarmful(stealther, false))
+ {
+ return false;
+ }
+
+ // Exclude party members
+ var stealtherParty = Party.Get(stealther);
+ var detectorParty = Party.Get(detector);
+ if (stealtherParty != null && stealtherParty == detectorParty)
+ {
+ return false;
+ }
+
+ // Exclude guild members and allies
+ if (stealther.Guild is Guild sg && detector.Guild is Guild dg && (sg == dg || sg.IsAlly(dg)))
+ {
+ return false;
+ }
+
+ var ss = detectSkill + Utility.Random(21) - 10;
+ var ts = stealther.Skills.Hiding.Value + Utility.Random(21) - 10;
+
+ if (ss >= ts)
+ {
+ stealther.RevealingAction();
+ stealther.SendLocalizedMessage(500814); // You have been revealed!
+ PassiveDetectDebounce[key] = now;
+ return true;
+ }
+
+ return false;
+ }
+
+ private class InternalTarget : Target
+ {
+ public InternalTarget() : base(12, true, TargetFlags.None)
+ {
+ }
+
+ protected override void OnTargetCancel(Mobile from, TargetCancelType cancelType)
+ {
+ from.NextSkillTime = Core.TickCount;
+ }
+
+ protected override void OnTarget(Mobile src, object targ)
+ {
+ var foundAnyone = false;
+ var srcSkill = src.Skills.DetectHidden.Value;
+ var range = (int)(srcSkill / 10.0);
+
+ if (targ is TrappableContainer container && container.TrapType != TrapType.None)
{
+ // Direct container targeting: show [trapped] if within detection range and skill check passes
+ if (src.InRange(container.GetWorldLocation(), range) &&
+ src.CheckSkill(SkillName.DetectHidden, 0.0, 100.0))
+ {
+ src.NetState.SendMessageLocalized(
+ container.Serial,
+ container.ItemID,
+ MessageType.Regular,
+ 0x3B2,
+ 3,
+ 500813 // [trapped]
+ );
+
+ foundAnyone = true;
+ }
}
-
- protected override void OnTargetCancel(Mobile from, TargetCancelType cancelType)
+ else if (targ is not Item && (targ is not Mobile m || m == src))
{
- from.NextSkillTime = Core.TickCount;
- }
-
- protected override void OnTarget(Mobile src, object targ)
- {
- var foundAnyone = false;
-
+ // Area scan only when targeting self or the ground
var p = targ switch
{
Mobile mobile => mobile.Location,
- Item item => item.Location,
IPoint3D d => new Point3D(d),
_ => src.Location
};
- var srcSkill = src.Skills.DetectHidden.Value;
- var range = (int)(srcSkill / 10.0);
-
if (!src.CheckSkill(SkillName.DetectHidden, 0.0, 100.0))
{
range /= 2;
}
var house = BaseHouse.FindHouseAt(p, src.Map, 16);
-
var inHouse = house?.IsFriend(src) == true;
if (inHouse)
@@ -88,40 +220,57 @@ namespace Server.SkillHandlers
foundAnyone = true;
}
- if (Faction.Find(src) != null)
+ foreach (var item in src.Map.GetItemsInRange(p, range))
{
- foreach (var trap in src.Map.GetItemsInRange(p, range))
+ if (item is BaseFactionTrap factionTrap)
{
- if (src.CheckTargetSkill(SkillName.DetectHidden, trap, 80.0, 100.0))
+ if (Faction.Find(src) != null &&
+ src.CheckTargetSkill(SkillName.DetectHidden, factionTrap, 80.0, 100.0))
{
src.SendLocalizedMessage(
1042712, // You reveal a trap placed by a faction:
true,
- $" {(trap.Faction == null ? "" : trap.Faction.Definition.FriendlyName)}"
+ $" {(factionTrap.Faction == null ? "" : factionTrap.Faction.Definition.FriendlyName)}"
);
- trap.Visible = true;
- trap.BeginConceal();
+ factionTrap.Visible = true;
+ factionTrap.BeginConceal();
foundAnyone = true;
}
}
+ else if (item is BaseTrap { Visible: false } trap)
+ {
+ // High Seas (Publish 79): Requires 75 Detect Hidden to detect dungeon traps
+ if (Core.HS && srcSkill < 75.0)
+ {
+ continue;
+ }
+
+ trap.Visible = true;
+ Timer.StartTimer(TimeSpan.FromSeconds(10.0), () =>
+ {
+ if (!trap.Deleted)
+ {
+ trap.Visible = false;
+ }
+ });
+
+ foundAnyone = true;
+ }
}
}
-
- if (!foundAnyone)
- {
- src.SendLocalizedMessage(500817); // You can see nothing hidden there.
- }
-
- const int TargeterCooldown = 30000; // 30s
- const int SkillCooldown = 10000; // 10s
-
- // Calculate how much time has passed since the targeter was opened
- var ticksSinceTargeter = (int)(Core.TickCount - (src.NextSkillTime - TargeterCooldown));
- var remainingCooldown = Math.Max(0, SkillCooldown - ticksSinceTargeter);
- src.NextSkillTime = Core.TickCount + remainingCooldown;
}
+
+ if (!foundAnyone)
+ {
+ src.SendLocalizedMessage(500817); // You can see nothing hidden there.
+ }
+
+ // Calculate how much time has passed since the targeter was opened
+ var ticksSinceTargeter = (int)(Core.TickCount - (src.NextSkillTime - 30000));
+ var remainingCooldown = Math.Max(0, 10000 - ticksSinceTargeter);
+ src.NextSkillTime = Core.TickCount + remainingCooldown;
}
}
}
diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj
index 482d3a9e1..d0a761137 100644
--- a/Projects/UOContent/UOContent.csproj
+++ b/Projects/UOContent/UOContent.csproj
@@ -52,5 +52,8 @@
+
+ <_Parameter1>UOContent.Tests
+