From 5fb0e806c527cfdd19b74e37620b4cea2510da14 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 29 Nov 2025 09:55:52 -0800 Subject: [PATCH] feat: Adds proper OSI tracking up to 120 tiles. (#2281) ### Summary Fixes tracking skill to 10 tiles per 10% up to 120 tiles. --- .../Maps/ItemByDistanceEnumeratorTests.cs | 6 +- .../Maps/MobileByDistanceEnumeratorTests.cs | 6 +- .../Tests/Maps/MobileEnumeratorTests.cs | 2 +- Projects/Server/Mobiles/Mobile.Enumerators.cs | 63 ++++ Projects/Server/Mobiles/Mobile.cs | 37 --- .../Tests/Skills/TrackingTests.cs | 299 ++++++++++++++++++ .../UOContent/Skills/Tracking/Tracking.cs | 70 ++-- 7 files changed, 417 insertions(+), 66 deletions(-) create mode 100644 Projects/Server/Mobiles/Mobile.Enumerators.cs create mode 100644 Projects/UOContent.Tests/Tests/Skills/TrackingTests.cs diff --git a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs index be259044f..fe19c76fb 100644 --- a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs @@ -215,8 +215,8 @@ public class ItemByDistanceEnumeratorTests var center = new Point3D(700, 700, 0); const int range = 5; - var testItem2 = new TestItem2((Serial)Utility.RandomMinMax(0x100u, 0xFFFu)); - var testItem = new TestItem((Serial)Utility.RandomMinMax(0x100u, 0xFFFu)); + var testItem2 = new TestItem2(World.NewItem); + var testItem = new TestItem(World.NewItem); try { @@ -438,7 +438,7 @@ public class ItemByDistanceEnumeratorTests private static TestItem CreateItem(Map map, Point3D location) { - var Item = new TestItem((Serial)Utility.RandomMinMax(0x100u, 0xFFFu)); + var Item = new TestItem(World.NewItem); Item.MoveToWorld(location, map); return Item; } diff --git a/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs index 435a53cd8..0a84da9e6 100644 --- a/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs @@ -215,8 +215,8 @@ public class MobileByDistanceEnumeratorTests var center = new Point3D(700, 700, 0); const int range = 5; - var player = new TestPlayerMobile((Serial)Utility.RandomMinMax(0x100u, 0xFFFu)); - var npc = new TestMobile((Serial)Utility.RandomMinMax(0x100u, 0xFFFu)); + var player = new TestPlayerMobile(World.NewMobile); + var npc = new TestMobile(World.NewMobile); try { @@ -440,7 +440,7 @@ public class MobileByDistanceEnumeratorTests private static TestMobile CreateMobile(Map map, Point3D location) { - var mobile = new TestMobile((Serial)Utility.RandomMinMax(0x100u, 0xFFFu)); + var mobile = new TestMobile(World.NewMobile); mobile.DefaultMobileInit(); mobile.MoveToWorld(location, map); return mobile; diff --git a/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs index f1c3117eb..5df3d4b69 100644 --- a/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs @@ -247,7 +247,7 @@ public class MobileEnumeratorTests private static Mobile CreateMobile(Map map, Point3D location) { - var mobile = new Mobile((Serial)Utility.RandomMinMax(0x100u, 0xFFFu)); + var mobile = new Mobile(World.NewMobile); mobile.DefaultMobileInit(); mobile.MoveToWorld(location, map); return mobile; diff --git a/Projects/Server/Mobiles/Mobile.Enumerators.cs b/Projects/Server/Mobiles/Mobile.Enumerators.cs new file mode 100644 index 000000000..4ca864222 --- /dev/null +++ b/Projects/Server/Mobiles/Mobile.Enumerators.cs @@ -0,0 +1,63 @@ +using System.Runtime.CompilerServices; + +namespace Server; + +public partial class Mobile +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ItemAtEnumerable GetItemsAt() => + m_Map == null ? Map.ItemAtEnumerable.Empty : m_Map.GetItemsAt(m_Location); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ItemAtEnumerable GetItemsAt() where T : Item => + m_Map == null ? Map.ItemAtEnumerable.Empty : m_Map.GetItemsAt(m_Location); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ItemBoundsEnumerable GetItemsInRange(int range) => GetItemsInRange(range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ItemBoundsEnumerable GetItemsInRange(int range) where T : Item => + m_Map == null ? Map.ItemBoundsEnumerable.Empty : m_Map.GetItemsInRange(m_Location, range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.MobileAtEnumerable GetMobilesInRange() => GetMobilesInRange(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.MobileAtEnumerable GetMobilesInRange() where T : Mobile => + m_Map == null ? Map.MobileAtEnumerable.Empty : m_Map.GetMobilesAt(m_Location); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.MobileBoundsEnumerable GetMobilesInRange(int range) => GetMobilesInRange(range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.MobileBoundsEnumerable GetMobilesInRange(int range) where T : Mobile => + m_Map == null ? Map.MobileBoundsEnumerable.Empty : m_Map.GetMobilesInRange(m_Location, range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ClientAtEnumerable GetClientsAt() => + m_Map == null ? Map.ClientAtEnumerable.Empty : Map.GetClientsAt(m_Location); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ClientBoundsEnumerable GetClientsInRange(int range) => + m_Map == null ? Map.ClientBoundsEnumerable.Empty : Map.GetClientsInRange(m_Location, range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ItemDistanceEnumerable GetItemsInRangeByDistance(int range) => + GetItemsInRangeByDistance(range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ItemDistanceEnumerable GetItemsInRangeByDistance(int range) where T : Item => + m_Map == null ? default : m_Map.GetItemsInRangeByDistance(m_Location, range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.MobileDistanceEnumerable GetMobilesInRangeByDistance(int range) => + GetMobilesInRangeByDistance(range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.MobileDistanceEnumerable GetMobilesInRangeByDistance(int range) where T : Mobile => + m_Map == null ? default : m_Map.GetMobilesInRangeByDistance(m_Location, range); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Map.ClientDistanceEnumerable GetClientsInRangeByDistance(int range) => + m_Map == null ? default : m_Map.GetClientsInRangeByDistance(m_Location, range); +} diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 855244da6..035d96945 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -8032,43 +8032,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro return -1; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.ItemAtEnumerable GetItemsAt() => - m_Map == null ? Map.ItemAtEnumerable.Empty : m_Map.GetItemsAt(m_Location); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.ItemAtEnumerable GetItemsAt() where T : Item => - m_Map == null ? Map.ItemAtEnumerable.Empty : m_Map.GetItemsAt(m_Location); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.ItemBoundsEnumerable GetItemsInRange(int range) => GetItemsInRange(range); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.ItemBoundsEnumerable GetItemsInRange(int range) where T : Item => - m_Map == null ? Map.ItemBoundsEnumerable.Empty : m_Map.GetItemsInRange(m_Location, range); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.MobileAtEnumerable GetMobilesInRange() => GetMobilesInRange(); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.MobileAtEnumerable GetMobilesInRange() where T : Mobile => - m_Map == null ? Map.MobileAtEnumerable.Empty : m_Map.GetMobilesAt(m_Location); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.MobileBoundsEnumerable GetMobilesInRange(int range) => GetMobilesInRange(range); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.MobileBoundsEnumerable GetMobilesInRange(int range) where T : Mobile => - m_Map == null ? Map.MobileBoundsEnumerable.Empty : m_Map.GetMobilesInRange(m_Location, range); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.ClientAtEnumerable GetClientsAt() => - m_Map == null ? Map.ClientAtEnumerable.Empty : Map.GetClientsAt(m_Location); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Map.ClientBoundsEnumerable GetClientsInRange(int range) => - m_Map == null ? Map.ClientBoundsEnumerable.Empty : Map.GetClientsInRange(m_Location, range); - public void SayTo(Mobile to, bool ascii, string text) => PrivateOverheadMessage(MessageType.Regular, SpeechHue, ascii, text, to.NetState); diff --git a/Projects/UOContent.Tests/Tests/Skills/TrackingTests.cs b/Projects/UOContent.Tests/Tests/Skills/TrackingTests.cs new file mode 100644 index 000000000..1c436fd49 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Skills/TrackingTests.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server; +using Server.Mobiles; +using Server.SkillHandlers; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class TrackingTests +{ + /// + /// Tests that tracking correctly finds the closest mobiles when there are more than 12 available. + /// This validates the GetClosestMobs logic, especially the early exit optimization. + /// + [Fact] + public void Tracking_FindsClosestMobiles_WhenManyAvailable() + { + var map = Map.Felucca; + var center = new Point3D(1000, 1000, 0); + + var tracker = CreatePlayerMobile(map, center); + tracker.Skills.Tracking.BaseFixedPoint = 1000; // 100.0 skill = 110 range + + var mobiles = new List(); + + try + { + // Create 20 animals at various distances + // First 12 should be the closest ones we find + for (var i = 0; i < 20; i++) + { + var distance = i + 1; // Distance from 1 to 20 + var location = new Point3D(center.X + distance, center.Y, 0); + var animal = CreateAnimal(map, location); + mobiles.Add(animal); + } + + // Invoke tracking through the skill system + // We can't directly test GetClosestMobs since it's private, but we can verify + // the behavior by checking what the gump would show + + // Use reflection to test the private method + var method = typeof(TrackWhoGump).GetMethod( + "GetClosestMobs", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + ); + + Assert.NotNull(method); + + var range = Math.Clamp(10 + (int)tracker.Skills.Tracking.Value, 0, 100); + var result = (Mobile[])method.Invoke(null, new object[] { tracker, range, 0 }); // 0 = animals + + // Should return at most 12 mobiles + Assert.True(result.Length <= 12); + + // Should return the 12 closest (distances 1-12) + Assert.Equal(12, result.Length); + + // Verify they are sorted by distance + for (var i = 0; i < result.Length - 1; i++) + { + var dist1 = result[i].GetDistanceToSqrt(center); + var dist2 = result[i + 1].GetDistanceToSqrt(center); + Assert.True(dist1 <= dist2, $"Mobiles not sorted: {dist1} > {dist2}"); + } + + // Verify the first mobile is the closest (distance 1) + Assert.Equal(mobiles[0], result[0]); + + // Verify the last mobile is at distance 12 + Assert.Equal(mobiles[11], result[11]); + + // Verify mobile at distance 13 is NOT included + Assert.DoesNotContain(mobiles[12], result); + } + finally + { + tracker?.Delete(); + foreach (var mob in mobiles) + { + mob?.Delete(); + } + } + } + + /// + /// Tests that tracking correctly handles the case where there are exactly 12 mobiles available. + /// + [Fact] + public void Tracking_FindsAllMobiles_WhenExactly12Available() + { + var map = Map.Felucca; + var center = new Point3D(2000, 2000, 0); + + var tracker = CreatePlayerMobile(map, center); + tracker.Skills.Tracking.BaseFixedPoint = 1000; // 100.0 skill = 110 range + + var mobiles = new List(); + + try + { + // Create exactly 12 animals + for (var i = 0; i < 12; i++) + { + var distance = i + 1; + var location = new Point3D(center.X + distance, center.Y, 0); + var animal = CreateAnimal(map, location); + mobiles.Add(animal); + } + + var method = typeof(TrackWhoGump).GetMethod( + "GetClosestMobs", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + ); + + var range = Math.Clamp(10 + (int)tracker.Skills.Tracking.Value, 0, 100); + var result = (Mobile[])method.Invoke(null, new object[] { tracker, range, 0 }); + + Assert.Equal(12, result.Length); + + // Verify all mobiles are included + foreach (var mob in mobiles) + { + Assert.Contains(mob, result); + } + } + finally + { + tracker?.Delete(); + foreach (var mob in mobiles) + { + mob?.Delete(); + } + } + } + + /// + /// Tests that tracking correctly handles the case where there are fewer than 12 mobiles available. + /// + [Fact] + public void Tracking_FindsAllMobiles_WhenFewerThan12Available() + { + var map = Map.Felucca; + var center = new Point3D(3000, 3000, 0); + + var tracker = CreatePlayerMobile(map, center); + tracker.Skills.Tracking.BaseFixedPoint = 1000; + + var mobiles = new List(); + + try + { + // Create only 5 animals + for (var i = 0; i < 5; i++) + { + var distance = i + 1; + var location = new Point3D(center.X + distance, center.Y, 0); + var animal = CreateAnimal(map, location); + mobiles.Add(animal); + } + + var method = typeof(TrackWhoGump).GetMethod( + "GetClosestMobs", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + ); + + var range = Math.Clamp(10 + (int)tracker.Skills.Tracking.Value, 0, 100); + var result = (Mobile[])method.Invoke(null, new object[] { tracker, range, 0 }); + + Assert.Equal(5, result.Length); + + // Verify all mobiles are included + foreach (var mob in mobiles) + { + Assert.Contains(mob, result); + } + } + finally + { + tracker?.Delete(); + foreach (var mob in mobiles) + { + mob?.Delete(); + } + } + } + + /// + /// Tests that the early exit optimization works correctly when mobiles in farther sectors + /// are closer than mobiles in nearer sectors (worst case for the optimization). + /// + [Fact] + public void Tracking_EarlyExitWorksCorrectly_WithFarSectorNearMobiles() + { + var map = Map.Felucca; + // Use a location that puts mobiles in different sectors + var center = new Point3D(1500, 1500, 0); + + var tracker = CreatePlayerMobile(map, center); + tracker.Skills.Tracking.BaseFixedPoint = 1000; + + var mobiles = new List(); + + try + { + // Create 15 animals where some farther ones might be in closer proximity + // but in different sectors + for (var i = 0; i < 15; i++) + { + var distance = i + 1; + // Alternate between X and Y to potentially cross sector boundaries + var location = i % 2 == 0 + ? new Point3D(center.X + distance, center.Y, 0) + : new Point3D(center.X, center.Y + distance, 0); + var animal = CreateAnimal(map, location); + mobiles.Add(animal); + } + + var method = typeof(TrackWhoGump).GetMethod( + "GetClosestMobs", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + ); + + var range = Math.Clamp(10 + (int)tracker.Skills.Tracking.Value, 0, 100); + var result = (Mobile[])method.Invoke(null, new object[] { tracker, range, 0 }); + + Assert.True(result.Length <= 12); + + // Get the actual 12 closest by brute force + var allDistances = mobiles + .Select(m => (Mobile: m, Distance: m.GetDistanceToSqrt(center))) + .OrderBy(x => x.Distance) + .ToList(); + + var expected12Closest = allDistances.Take(12).ToList(); + + // The result should match the actual 12 closest + Assert.Equal(expected12Closest.Count, result.Length); + + // Verify all returned mobiles are in the expected 12 closest + foreach (var mob in result) + { + Assert.Contains(mob, expected12Closest.Select(x => x.Mobile)); + } + + // Verify they are sorted by distance (main invariant) + for (var i = 0; i < result.Length - 1; i++) + { + var dist1 = result[i].GetDistanceToSqrt(center); + var dist2 = result[i + 1].GetDistanceToSqrt(center); + Assert.True(dist1 <= dist2, $"Mobiles not sorted by distance: {dist1} > {dist2}"); + } + + // Verify we got the closest mobiles (not just any 12) + var maxResultDistance = result.Max(m => m.GetDistanceToSqrt(center)); + var minExcludedDistance = allDistances.Skip(12).Any() + ? allDistances.Skip(12).Min(x => x.Distance) + : double.MaxValue; + Assert.True(maxResultDistance <= minExcludedDistance, + $"Found a closer excluded mobile: max in result={maxResultDistance}, min excluded={minExcludedDistance}"); + } + finally + { + tracker?.Delete(); + foreach (var mob in mobiles) + { + mob?.Delete(); + } + } + } + + private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.MoveToWorld(location, map); + return mobile; + } + + private static TestAnimal CreateAnimal(Map map, Point3D location) + { + var animal = new TestAnimal(World.NewMobile); + animal.DefaultMobileInit(); + animal.MoveToWorld(location, map); + return animal; + } + + private class TestAnimal : BaseCreature + { + public TestAnimal(Serial serial) : base(serial) + { + Body = 0xD8; // Llama body - an animal body + } + } +} + diff --git a/Projects/UOContent/Skills/Tracking/Tracking.cs b/Projects/UOContent/Skills/Tracking/Tracking.cs index 0cb1f792a..4825f8bc4 100644 --- a/Projects/UOContent/Skills/Tracking/Tracking.cs +++ b/Projects/UOContent/Skills/Tracking/Tracking.cs @@ -51,7 +51,7 @@ public static class Tracking public static void AddInfo(Mobile tracker, Mobile target) { - var info = new TrackingInfo(tracker, target); + var info = new TrackingInfo(target); _table[tracker] = info; } @@ -80,11 +80,9 @@ public static class Tracking public Point2D _location; public readonly Map _map; public readonly Mobile _target; - public Mobile _tracker; - public TrackingInfo(Mobile tracker, Mobile target) + public TrackingInfo(Mobile target) { - _tracker = tracker; _target = target; _location = new Point2D(target); _map = target.Map; @@ -164,7 +162,8 @@ public class TrackWhoGump : DynamicGump from.CheckSkill(SkillName.Tracking, 21.1, 100.0); // Passive gain - var range = 10 + (int)(from.Skills.Tracking.Value / 10); + // 10 tiles base + 10 tiles per 10 skill + var range = 10 + (int)from.Skills.Tracking.Value / 10 * 10; var mobs = GetClosestMobs(from, range, type); @@ -231,13 +230,19 @@ public class TrackWhoGump : DynamicGump var loc = from.Location; // We only track the closest 12 + // Using a simple array with count is sufficient since we're single-threaded var mobs = new Mobile[MaxClosest]; Span distances = stackalloc double[MaxClosest]; - distances.Fill(double.MaxValue); // Fill with max values - var total = 0; + var maxDistance = double.MaxValue; + var count = 0; - foreach (var m in from.GetMobilesInRange(range)) + foreach (var (m, minDistance) in from.GetMobilesInRangeByDistance(range)) { + if (count == MaxClosest && minDistance > maxDistance) + { + break; + } + if (m == from || Core.AOS && !m.Alive || m.Hidden && m.AccessLevel != AccessLevel.Player && from.AccessLevel <= m.AccessLevel || !IsValidMobileType(m, type) || !CheckDifficulty(from, m)) @@ -245,30 +250,51 @@ public class TrackWhoGump : DynamicGump continue; } - total++; - var distance = m.GetDistanceToSqrt(loc); - for (var i = 0; i < MaxClosest; i++) + + if (count >= MaxClosest && distance >= maxDistance) + { + continue; + } + + var searchLimit = Math.Min(count, MaxClosest - 1); + var insertIndex = searchLimit; + + for (var i = 0; i < searchLimit; i++) { if (distance < distances[i]) { - // Shift down the rest - for (int j = MaxClosest - 1; j > i; j--) - { - mobs[j] = mobs[j - 1]; - distances[j] = distances[j - 1]; - } - - mobs[i] = m; - distances[i] = distance; + insertIndex = i; break; } } + + // Shift elements to make room for insertion + if (insertIndex < searchLimit) + { + var shiftCount = searchLimit - insertIndex; + Array.Copy(mobs, insertIndex, mobs, insertIndex + 1, shiftCount); + distances.Slice(insertIndex, shiftCount).CopyTo(distances[(insertIndex + 1)..]); + } + + mobs[insertIndex] = m; + distances[insertIndex] = distance; + + if (count < MaxClosest) + { + count++; + } + + if (count == MaxClosest) + { + maxDistance = distances[MaxClosest - 1]; + } } - if (total < MaxClosest) + // Only resize if we found fewer than MaxClosest + if (count < MaxClosest) { - Array.Resize(ref mobs, total); + Array.Resize(ref mobs, count); } return mobs;