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 +