diff --git a/Projects/Server/Items/BaseMulti.cs b/Projects/Server/Items/BaseMulti.cs index e723d3ae3..0ebcac7c5 100644 --- a/Projects/Server/Items/BaseMulti.cs +++ b/Projects/Server/Items/BaseMulti.cs @@ -19,6 +19,24 @@ using ModernUO.Serialization; namespace Server.Items; +/// +/// Whether a multi instance's footprint is eligible for the pathfinding interior-mask cache +/// (Server.Engines.Pathing.Cache.MultiMaskCache). Stored on +/// ; recomputed when it is +/// (e.g. after a move resets it). +/// +public enum MultiInteriorCacheState : byte +{ + /// Not yet determined; recompute on next use. + Unknown = 0, + + /// Whole footprint terrain is below the floor → interior cells may serve from the cache. + Clean = 1, + + /// Terrain intrudes into the footprint → fall back to live synthesis. + Dirty = 2 +} + [SerializationGenerator(0, false)] public abstract partial class BaseMulti : Item { @@ -35,6 +53,10 @@ public abstract partial class BaseMulti : Item Map?.OnLeave(this); base.ItemID = value; Map?.OnEnter(this); + + // The footprint shape changes with ItemID (e.g. a boat's heading swaps the MCL), so + // the pathfinding interior-cache clean/dirty status must be recomputed. + PathInteriorCacheState = MultiInteriorCacheState.Unknown; } } } @@ -65,6 +87,27 @@ public abstract partial class BaseMulti : Item public virtual MultiComponentList Components => MultiData.GetComponents(ItemID); + /// + /// Pathfinding interior-mask cache gate (Server.Engines.Pathing.Cache.MultiMaskCache). + /// Reset to whenever the footprint's world-terrain + /// relationship can change — a location change, a map change, or an ItemID change (e.g. a boat's + /// heading swaps the MCL). Subclasses that override / + /// MUST call base for the reset to fire. + /// + public MultiInteriorCacheState PathInteriorCacheState { get; set; } + + public override void OnLocationChange(Point3D oldLocation) + { + base.OnLocationChange(oldLocation); + PathInteriorCacheState = MultiInteriorCacheState.Unknown; + } + + public override void OnMapChange() + { + base.OnMapChange(); + PathInteriorCacheState = MultiInteriorCacheState.Unknown; + } + public override int GetMaxUpdateRange() => 22; public override int GetUpdateRange(Mobile m) => 22; diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 888baa83f..d84ad7309 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -57,12 +57,25 @@ internal static class TestServerInitializer Server.Network.NetState.Configure(); TestMapDefinitions.ConfigureTestMapDefinitions(); + // TileData's static cctor short-circuits when running under xUnit + // (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable + // flags are populated before anything that reads TileData (MultiData, MovementImpl, + // CheckMovement). Without this, TileData.MaxItemValue is 0 at MultiData.Configure() + // time, causing every MCL tile ID to be masked to 0 and stored as ID=0 in Tiles[x][y]. + ForceLoadTileData(); + // Production runs every static Configure() via AssemblyHandler.Invoke("Configure"); // the fixture calls a curated subset, so configure the pathfinding singleton here so // BitmapAStarAlgorithm.Instance carries its configured MaxSearchNodes before any test // calls Find. ServerConfiguration is already loaded above, so the setting resolves. BitmapAStarAlgorithm.Configure(); + // Multi component lists (multi.mul / MultiCollection.uop). Production invokes this via + // AssemblyHandler.Invoke("Configure"); the curated fixture subset must call it so that + // BaseMulti.Components (MultiData.GetComponents) returns real footprints instead of + // MultiComponentList.Empty. Required by the Multi pathfinding tests. + MultiData.Configure(); + World.Configure(); Timer.Init(0); RaceDefinitions.Configure(); @@ -72,12 +85,6 @@ internal static class TestServerInitializer World.ExitSerializationThreads(); DecayScheduler.Configure(); - // TileData's static cctor short-circuits when running under xUnit - // (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable - // flags are populated; without this, every tile reads as flag=None and - // MovementImpl.CheckMovement treats everything as walkable. - ForceLoadTileData(); - VerifyTrammelTileDataLoaded(); _initialized = true; diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs new file mode 100644 index 000000000..a9796de7f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs @@ -0,0 +1,87 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class BoatPathTests +{ + private readonly ITestOutputHelper _output; + + public BoatPathTests(ITestOutputHelper output) => _output = output; + + // Open water in the south-Britain bay (Trammel), used by the existing swim-bake test. + private const int MapId = 1; + private const int WaterX = 1450; + private const int WaterY = 1770; + + [Fact] + public void BoatDeck_HasWalkableSurfaceCells() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + // SmallBoat North heading = multiID 0x0. A deck is made of surface tiles. + var boat = new TestMulti(0x0); + map.GetAverageZ(WaterX, WaterY, out _, out var z, out _); + boat.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)z), map); + + try + { + var floor = MultiArt.FindFloorCell(boat); + Assert.True(floor.HasValue, "non-vacuity: boat deck must have surface (floor) tiles"); + + // The deck cell falls through to the live multi-aware path. + var mask = StepCache.Instance.TryGetMask(map, floor.Value.X, floor.Value.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, mask.HitKind); + _output.WriteLine($"boat deck floor cell at ({floor.Value.X},{floor.Value.Y})"); + } + finally + { + boat.Delete(); + } + } + + [Fact] + public void BoatDeck_FootprintShape_IsPositionInvariant() + { + // The property Phase 2's local-frame, movement-invariant boat cache must preserve: + // the deck's covered-cell shape (in local coords) is identical at two world positions. + var map = Map.Maps[MapId]; + + var a = new TestMulti(0x0); + map.GetAverageZ(WaterX, WaterY, out _, out var za, out _); + a.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)za), map); + var shapeA = LocalShape(a); + a.Delete(); + + var b = new TestMulti(0x0); + map.GetAverageZ(WaterX + 5, WaterY + 3, out _, out var zb, out _); + b.MoveToWorld(new Point3D(WaterX + 5, WaterY + 3, (sbyte)zb), map); + var shapeB = LocalShape(b); + b.Delete(); + + Assert.Equal(shapeA, shapeB); + Assert.NotEmpty(shapeA); + _output.WriteLine($"boat local deck shape stable across positions: {shapeA.Count} cells"); + } + + private static System.Collections.Generic.HashSet<(int lx, int ly)> LocalShape(BaseMulti multi) + { + var mcl = multi.Components; + var set = new System.Collections.Generic.HashSet<(int, int)>(); + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + if (mcl.Tiles[lx][ly].Length > 0) + { + set.Add((lx, ly)); + } + } + } + return set; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs new file mode 100644 index 000000000..3fdb4fa3e --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs @@ -0,0 +1,87 @@ +using Server; +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class FoundationRedesignTests +{ + private readonly ITestOutputHelper _output; + + public FoundationRedesignTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + private const int PlaceX = 1500; + private const int PlaceY = 1600; + + [Fact] + public void SwappingComponents_ChangesFootprint() + { + var foundation = new SwappableFoundation(0x74); // GuildHouse footprint + try + { + var beforeCount = MultiArt.FootprintCells(foundation).Count; + Assert.True(beforeCount > 0, "non-vacuity: initial footprint must cover cells"); + + foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape) + var afterCount = MultiArt.FootprintCells(foundation).Count; + + Assert.NotEqual(beforeCount, afterCount); + _output.WriteLine($"redesign footprint {beforeCount} -> {afterCount} cells"); + } + finally + { + foundation.Delete(); + } + } + + [Fact] + public void RedesignReRegistered_RoutesNewFootprintToLivePath() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + var foundation = new SwappableFoundation(0x74); + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var loc = new Point3D(PlaceX, PlaceY, (sbyte)z); + foundation.MoveToWorld(loc, map); + + try + { + // Current design's covered cells route to the live (multi-aware) path. + var before = MultiArt.FindFloorCell(foundation); + Assert.True(before.HasValue, "non-vacuity: initial design must have a floor cell"); + var maskBefore = StepCache.Instance.TryGetMask(map, before.Value.X, before.Value.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, maskBefore.HitKind); + + // Redesign, RE-REGISTERED so sectors track the new footprint (model a real commit). + // Internalize() moves the multi to Map.Internal, which fires Map.OnLeave and removes + // the OLD footprint's sector registration. We then swap the MCL and MoveToWorld back, + // which fires Map.OnEnter -> AddMulti against the fresh Components (new footprint). + foundation.Internalize(); + foundation.Redesign(MultiData.GetComponents(0x7A)); + foundation.MoveToWorld(loc, map); + + var after = MultiArt.FindFloorCell(foundation); + Assert.True(after.HasValue, "non-vacuity: redesigned design must have a floor cell"); + // The new footprint cell must be registered (HasMultis) and route to the live path. + var sector = map.GetRealSector(after.Value.X >> 4, after.Value.Y >> 4); + Assert.True(sector.HasMultis, "redesigned footprint must re-register its sector"); + var maskAfter = StepCache.Instance.TryGetMask(map, after.Value.X, after.Value.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, maskAfter.HitKind); + + _output.WriteLine($"redesign re-registered: before {before.Value.X},{before.Value.Y} after {after.Value.X},{after.Value.Y}"); + } + finally + { + if (!foundation.Deleted) + { + foundation.Delete(); + } + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs new file mode 100644 index 000000000..41caa6255 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs @@ -0,0 +1,225 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Server.Mobiles; +using Server.PathAlgorithms; +using Xunit; +using Xunit.Abstractions; +using CalcMoves = Server.Movement.Movement; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class HousePathRoutingTests +{ + private readonly ITestOutputHelper _output; + + public HousePathRoutingTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + + private sealed class WalkerStub : Mobile + { + public WalkerStub() => Body = 0xC9; + } + + [Fact] + public void PathAround_NeverTraversesAWallCell() + { + StepCache.Instance.Clear(); + var prevThreshold = StepCache.Instance.MissPromotionThreshold; + StepCache.Instance.MissPromotionThreshold = 1; + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + // Open area with lateral room to flank a 7x7 house (verified reachable all 8 dirs). + const int hx = 1480, hy = 1620; + const int sx = 1480, sy = 1630; + const int gx = 1480, gy = 1610; + + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + var wall = MultiArt.FindWallCell(multi); + Assert.True(wall.HasValue, "non-vacuity: house must have wall cells"); + + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)sz); + walker.MoveToWorld(start, map); + + try + { + var path = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal); + Assert.NotNull(path); // non-vacuity: a route around must exist + Assert.NotEmpty(path); + + // Walk the path; assert it never lands on the known wall cell. + var x = sx; + var y = sy; + foreach (var dir in path) + { + CalcMoves.Offset(dir, ref x, ref y); + Assert.False(x == wall.Value.X && y == wall.Value.Y, + $"path traversed wall cell ({wall.Value.X},{wall.Value.Y})"); + } + _output.WriteLine($"around house: {path.Length} steps, avoided wall"); + } + finally + { + StepCache.Instance.MissPromotionThreshold = prevThreshold; + walker.Delete(); + multi.Delete(); + } + } + + [Fact] + public void Demolish_ReopensCoveredCells() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + const int hx = 1480, hy = 1620; + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + // Find a wall cell that is blocked purely by the multi — not by underlying static/land + // terrain. FindWallCell returns the first MCL-impassable cell, which may land over + // terrain that is itself impassable. Instead scan until we find one where the base + // terrain is passable so that after demolish the cell provably reopens. + var w = FindPureMultiWallCell(multi, map); + Assert.True(w.HasValue, "non-vacuity: house must have a wall cell over passable terrain"); + var walker = new WalkerStub(); + + var cell = w.Value; + + try + { + // Before demolish: every neighbour fails to step onto the wall cell (it is blocked). + var blockedBefore = NeighbourBlockedInto(map, walker, cell); + Assert.True(blockedBefore, "wall cell should block entry while the house stands"); + + multi.Delete(); + + // After demolish: the cell reverts to open ground; entry from a neighbour should now + // succeed in at least one direction. + StepCache.Instance.Clear(); + var openAfter = !NeighbourBlockedInto(map, walker, cell); + Assert.True(openAfter, $"cell ({cell.X},{cell.Y}) should reopen after demolish"); + _output.WriteLine($"demolish reopened ({cell.X},{cell.Y})"); + } + finally + { + if (!multi.Deleted) + { + multi.Delete(); + } + walker.Delete(); + } + } + + /// + /// Find the first MCL wall cell (Impassable && !Surface) over terrain that is not + /// independently blocked by land or statics. Using this instead of + /// ensures the cell reopens after demolish. + /// + private static MultiArt.Cell? FindPureMultiWallCell(BaseMulti multi, Map map) + { + var mcl = multi.Components; + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + var hasWall = false; + var hasSurface = false; + foreach (var t in mcl.Tiles[lx][ly]) + { + var data = TileData.ItemTable[t.ID & TileData.MaxItemValue]; + if (data.Impassable && !data.Surface) + { + hasWall = true; + } + if (data.Surface) + { + hasSurface = true; + } + } + + if (!hasWall || hasSurface) + { + continue; + } + + var wx = multi.X + mcl.Min.X + lx; + var wy = multi.Y + mcl.Min.Y + ly; + + // Check the underlying terrain is not independently impassable. + if (IsTerrainBlocked(map, wx, wy)) + { + continue; + } + + return new MultiArt.Cell(wx, wy); + } + } + return null; + } + + /// + /// Returns true if land tile or any static tile (non-multi) at (x,y) is impassable. + /// + private static bool IsTerrainBlocked(Map map, int x, int y) + { + var lt = map.Tiles.GetLandTile(x, y); + if (!lt.Ignored) + { + var landData = TileData.LandTable[lt.ID & TileData.MaxLandValue]; + if ((landData.Flags & TileFlag.Impassable) != 0) + { + return true; + } + } + + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + if (data.Impassable) + { + return true; + } + } + + return false; + } + + // True if EVERY in-range neighbour fails to step into target (target is blocked). + private static bool NeighbourBlockedInto(Map map, Mobile walker, MultiArt.Cell target) + { + for (var d = 0; d < 8; d++) + { + var nx = target.X; + var ny = target.Y; + CalcMoves.Offset((Direction)((d + 4) & 7), ref nx, ref ny); + if (nx < 0 || ny < 0 || nx >= map.Width || ny >= map.Height) + { + continue; + } + map.GetAverageZ(nx, ny, out _, out var nz, out _); + walker.MoveToWorld(new Point3D(nx, ny, (sbyte)nz), map); + if (CalcMoves.CheckMovement(walker, map, walker.Location, (Direction)d, out _)) + { + var tx = nx; + var ty = ny; + CalcMoves.Offset((Direction)d, ref tx, ref ty); + if (tx == target.X && ty == target.Y) + { + return false; // an entry succeeded → not blocked + } + } + } + return true; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs new file mode 100644 index 000000000..fc9e58898 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs @@ -0,0 +1,50 @@ +using Server.Engines.Pathing.Cache; +using Server.PathAlgorithms; +using Server.Systems.FeatureFlags; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiCacheUsedTests +{ + private const int MapId = 1; // Trammel + private const int GuildHouseId = 0x74; + + [Fact] + public void PathNearMulti_IncrementsMultiLocalHits() + { + var map = Map.Maps[MapId]; + map.GetAverageZ(1480, 1620, out _, out var z, out _); + var houseLoc = new Point3D(1480, 1620, (sbyte)z); + var multi = new TestMulti(GuildHouseId); + + var cacheWas = ContentFeatureFlags.BitmapPathfindingCache; + ContentFeatureFlags.BitmapPathfindingCache = true; + + var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(1480, 1630, (sbyte)z)); + try + { + multi.MoveToWorld(houseLoc, map); + StepCache.Instance.Clear(); + + var before = StepCache.Instance.GetStats().MultiLocalHits; + + // Start outside, goal on the far side, forcing expansion through the multi's halo. + var start = new Point3D(1480, 1630, (sbyte)z); + var goal = new Point3D(1480, 1610, (sbyte)z); + var path = BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + + var after = StepCache.Instance.GetStats().MultiLocalHits; + + Assert.NotNull(path); + Assert.True(after > before, $"expected MultiLocalHits to increase, before={before} after={after}"); + } + finally + { + mover.Delete(); + multi.Delete(); + ContentFeatureFlags.BitmapPathfindingCache = cacheWas; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs new file mode 100644 index 000000000..eb12df7d6 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs @@ -0,0 +1,158 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiEdgeCaseTests +{ + private const int MapId = 1; // Trammel + private const int GuildHouseId = 0x74; + + // GuildHouse placement (mirrors MultiMaskSynthesisTests) — open Trammel ground. + private const int HouseX = 1480; + private const int HouseY = 1620; + + // Open water in the south-Britain bay (mirrors BoatPathTests). + private const int BoatMultiId = 0x0; // SmallBoat North heading + private const int WaterX = 1450; + private const int WaterY = 1770; + private const sbyte DeckZ = 0; // boat deck floor tiles stand at world Z 0 (not the water avgZ) + + /// + /// Two overlapping GuildHouse multis whose footprints intersect. At the stacked cells + /// GetStaticAndMultiTiles yields tiles from BOTH multis; the synthesizer must still + /// agree with CheckMovement everywhere over the union footprint + halo. + /// + [Fact] + public void OverlappingMultis_SynthesizerMatchesCheckMovement() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + map.GetAverageZ(HouseX, HouseY, out _, out var z, out _); + var locA = new Point3D(HouseX, HouseY, (sbyte)z); + // Origins 3 tiles apart on X so the GuildHouse footprints overlap. + var locB = new Point3D(HouseX + 3, HouseY, (sbyte)z); + + var multiA = new TestMulti(GuildHouseId); + var multiB = new TestMulti(GuildHouseId); + try + { + multiA.MoveToWorld(locA, map); + multiB.MoveToWorld(locB, map); + + // Non-vacuity: prove the two footprints actually intersect at the chosen 3-tile + // separation. The per-sweep touchedMulti guard only proves each multi touched its OWN + // footprint; without this, "overlapping" would be an unverified comment. + var overlap = MultiArt.FootprintCells(multiA); + var setB = new System.Collections.Generic.HashSet(MultiArt.FootprintCells(multiB)); + overlap.RemoveAll(c => !setB.Contains(c)); + Assert.NotEmpty(overlap); // the two footprints must actually intersect, else the test is meaningless + + // The synthesizer must match the oracle over BOTH footprints (each sweep crosses + // the shared, doubly-covered cells). + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multiA, map); + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multiB, map); + } + finally + { + multiA.Delete(); + multiB.Delete(); + } + } + + /// + /// A boat placed over open water: deck surface tiles are walkable, surrounding water blocks + /// the (non-swimming) walker. Exercises the synthesizer over water-adjacent deck-edge geometry. + /// + /// + /// The boat is placed at Z=0 (the deck's world Z), NOT at the water average Z (-15 here). + /// The deck floor tiles stand at world Z 0, so a non-swimming walker only finds walkable + /// transitions when the sweep origin Z equals the deck Z. Placing at the water avgZ makes the + /// sweep vacuous ("no walkable transitions") because the deck is 15 tiles overhead and water + /// blocks the rest — that vacuity is a fixture concern, not a synthesizer divergence (the + /// synthesizer agrees with the oracle at every direction either way). + /// + [Fact] + public void BoatOverWater_SynthesizerMatchesCheckMovement() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + var boat = new TestMulti(BoatMultiId); + boat.MoveToWorld(new Point3D(WaterX, WaterY, DeckZ), map); + try + { + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(boat, map); + } + finally + { + boat.Delete(); + } + } + + /// + /// Redesign a foundation in place (Internalize -> swap MCL -> MoveToWorld back, the same + /// re-registration pattern HouseFoundation uses on commit) and assert the synthesizer reads + /// the LIVE, post-redesign Components — i.e. it matches CheckMovement on the NEW shape. + /// + [Fact] + public void RedesignedFoundation_SynthesizerMatchesNewFootprint() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + var foundation = new SwappableFoundation(GuildHouseId); + map.GetAverageZ(HouseX, HouseY, out _, out var z, out _); + var loc = new Point3D(HouseX, HouseY, (sbyte)z); + foundation.MoveToWorld(loc, map); + try + { + // Redesign, RE-REGISTERED so sectors track the new footprint (model a real commit). + // Internalize() fires Map.OnLeave (removes the OLD footprint's registration); we swap + // the MCL and MoveToWorld back, firing Map.OnEnter -> AddMulti against the new shape. + foundation.Internalize(); + foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape) + foundation.MoveToWorld(loc, map); + + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(foundation, map); + } + finally + { + if (!foundation.Deleted) + { + foundation.Delete(); + } + } + } + + /// + /// Extensible slot for repo-owner-supplied gnarly placements. The assertion already covers + /// any (map,x,y) by construction — only the coordinates need filling in. + /// + /// TODO(coords): repo owner to supply (map,x,y) for a static tree inside a footprint and a + /// dungeon cave-wall corner; add InlineData rows here — the assertion already covers them by + /// construction. (Left intentionally unhunted: do not invent tree/dungeon coords.) + /// + [Theory] + [InlineData(MapId, HouseX, HouseY)] // known-good open Trammel placement (passes today) + public void UserSuppliedScenarios_SynthesizerMatchesCheckMovement(int mapId, int x, int y) + { + StepCache.Instance.Clear(); + var map = Map.Maps[mapId]; + + var multi = new TestMulti(GuildHouseId); + map.GetAverageZ(x, y, out _, out var z, out _); + multi.MoveToWorld(new Point3D(x, y, (sbyte)z), map); + try + { + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multi, map); + } + finally + { + multi.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs new file mode 100644 index 000000000..8a544897c --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -0,0 +1,273 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiMaskCacheTests +{ + private const int MapId = 1; + private const int GuildHouseId = 0x74; + private const int PlaceX = 1480; + private const int PlaceY = 1620; + + [Fact] + public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var loc = new Point3D(PlaceX, PlaceY, (sbyte)z); + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(loc, map); + + Assert.True(MultiMaskCache.TryResolveCoveringMulti(map, PlaceX, PlaceY, out var found, out var lx, out var ly)); + Assert.Same(multi, found); + var mcl = multi.Components; + Assert.Equal(PlaceX - multi.X - mcl.Min.X, lx); + Assert.Equal(PlaceY - multi.Y - mcl.Min.Y, ly); + + Assert.False(MultiMaskCache.TryResolveCoveringMulti(map, PlaceX + 200, PlaceY + 200, out _, out _, out _)); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void IsInteriorLocalCell_TrueDeepInside_FalseAtEdge() + { + var multi = new TestMulti(GuildHouseId); + try + { + var mcl = multi.Components; + + var foundInterior = false; + var foundEdge = false; + for (var ly = 0; ly < mcl.Height && (!foundInterior || !foundEdge); ly++) + { + for (var lx = 0; lx < mcl.Width; lx++) + { + if (mcl.Tiles[lx][ly].Length == 0) + { + continue; + } + if (MultiMaskCache.IsInteriorLocalCell(mcl, lx, ly)) + { + foundInterior = true; + } + else + { + foundEdge = true; + } + } + } + + Assert.True(foundInterior, "a guild house must have at least one interior cell"); + Assert.True(foundEdge, "a guild house must have at least one edge/perimeter cell"); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void LocalWorldZ_RoundTrips_AndPreservesMasks() + { + var world = new StepMask( + walkMask: 0b1010_1010, wetMask: 0b0101_0101, + walkZN: 10, walkZNE: 11, walkZE: 12, walkZSE: 13, walkZS: 14, walkZSW: 15, walkZW: 16, walkZNW: 17, + swimZN: -1, swimZNE: -2, swimZE: -3, swimZSE: -4, swimZS: -5, swimZSW: -6, swimZW: -7, swimZNW: -8 + ); + const int multiZ = 7; + + Assert.True(MultiMaskCache.TryToLocalZ(world, multiZ, out var local)); + var back = MultiMaskCache.ToWorldZ(local, multiZ); + + Assert.Equal(world.WalkMask, back.WalkMask); + Assert.Equal(world.WetMask, back.WetMask); + for (var d = 0; d < 8; d++) + { + Assert.Equal(world.GetWalkZ((Direction)d), back.GetWalkZ((Direction)d)); + Assert.Equal(world.GetSwimZ((Direction)d), back.GetSwimZ((Direction)d)); + } + } + + [Fact] + public void TryToLocalZ_RejectsOverflow() + { + var world = new StepMask( + walkMask: 0xFF, wetMask: 0, + walkZN: 100, walkZNE: 0, walkZE: 0, walkZSE: 0, walkZS: 0, walkZSW: 0, walkZW: 0, walkZNW: 0, + swimZN: 0, swimZNE: 0, swimZE: 0, swimZSE: 0, swimZS: 0, swimZSW: 0, swimZW: 0, swimZNW: 0 + ); + Assert.False(MultiMaskCache.TryToLocalZ(world, -100, out _)); + } + + [Fact] + public void TerrainTopBelow_TrueWhenFloorWellAboveGround() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var ground, out _); + + // Floor far above ground → terrain is below → guard passes. + Assert.True(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground + 50))); + // Floor at/below ground → terrain reaches the envelope → guard fails. + Assert.False(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground - 50))); + } + + [Fact] + public void PathThroughHouseInterior_IncrementsMultiMaskCacheHits() + { + // (PlaceX,PlaceY)=(1480,1620) is cluttered (footprint overlaps tall map statics → dirty), so it + // would never serve the interior cache under the footprint-clean gate. Use a known flat/clear + // spot so a house there is footprint-clean and its interior cells serve from the cache. + const int CleanX = 1560; + const int CleanY = 1616; + + var map = Map.Maps[MapId]; + map.GetAverageZ(CleanX, CleanY, out _, out var z, out _); + var houseLoc = new Point3D(CleanX, CleanY, (sbyte)z); + var multi = new TestMulti(GuildHouseId); + var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; + var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(CleanX, CleanY + 10, (sbyte)z)); + try + { + multi.MoveToWorld(houseLoc, map); + Assert.True(MultiMaskCache.ComputeFootprintClean(map, multi), + "precondition: (1560,1616) must be footprint-clean for the cache to serve"); + StepCache.Instance.Clear(); + + var start = new Point3D(CleanX, CleanY + 10, (sbyte)z); + var goal = new Point3D(CleanX, CleanY - 10, (sbyte)z); + // First pass builds the interior cache (live synth); second pass should hit it. + Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + + var before = StepCache.Instance.GetStats().MultiMaskCacheHits; + Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + var after = StepCache.Instance.GetStats().MultiMaskCacheHits; + + Assert.True(after > before, $"expected MultiMaskCacheHits to increase, before={before} after={after}"); + } + finally + { + mover.Delete(); + multi.Delete(); + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; + } + } + + [Fact] + public void ClutteredHouse_DoesNotServeCache_ButStillPaths() + { + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var multi = new TestMulti(GuildHouseId); + var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; + var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z)); + try + { + // (1480,1620) overlaps tall map statics → footprint dirty → cache must NOT serve. + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + Assert.False(MultiMaskCache.ComputeFootprintClean(map, multi), + "precondition: (1480,1620) must be footprint-dirty for this test to be meaningful"); + StepCache.Instance.Clear(); + + var start = new Point3D(PlaceX, PlaceY + 10, (sbyte)z); + var goal = new Point3D(PlaceX, PlaceY - 10, (sbyte)z); + Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); // warm + var before = StepCache.Instance.GetStats().MultiMaskCacheHits; + var path = Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + var after = StepCache.Instance.GetStats().MultiMaskCacheHits; + + Assert.NotNull(path); // pathfinding still works (degraded to live-synth) + Assert.Equal(before, after); // dirty footprint → zero cache serves + } + finally + { + mover.Delete(); + multi.Delete(); + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; + } + } + + [Fact] + public void PathInteriorCacheState_ResetsOnMove() + { + var map = Map.Maps[MapId]; + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, 0), map); + multi.PathInteriorCacheState = MultiInteriorCacheState.Clean; + + // A move changes the footprint's world terrain, so the gate must reset to Unknown and + // recompute on next use. (BaseMulti.OnLocationChange does this; subclasses like BaseHouse + // and BaseBoat must call base for it to fire — this pins that contract.) + multi.MoveToWorld(new Point3D(PlaceX + 8, PlaceY + 8, 0), map); + Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void PathInteriorCacheState_ResetsOnItemIdChange() + { + var map = Map.Maps[MapId]; + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, 0), map); + multi.PathInteriorCacheState = MultiInteriorCacheState.Clean; + + // Changing ItemID swaps the footprint (e.g. a boat changing heading), so the gate must + // reset to recompute cleanliness for the new shape. + multi.ItemID = 0x7A; // Tower + Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void ComputeFootprintClean_TrueAtNormalPlacement_FalseWhenSunk() + { + // (PlaceX,PlaceY) is a cluttered spot whose footprint overlaps tall map statics, so a guild + // house there is never footprint-clean. Use a known flat/clear spot for the clean assertion. + const int CleanX = 1560; + const int CleanY = 1616; + + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(CleanX, CleanY, out _, out var ground, out _); + + var normal = new TestMulti(GuildHouseId); + var sunk = new TestMulti(GuildHouseId); + try + { + normal.MoveToWorld(new Point3D(CleanX, CleanY, (sbyte)ground), map); + Assert.True(MultiMaskCache.ComputeFootprintClean(map, normal)); + + sunk.MoveToWorld(new Point3D(CleanX + 60, CleanY, (sbyte)(ground - 40)), map); + Assert.False(MultiMaskCache.ComputeFootprintClean(map, sunk)); + } + finally + { + normal.Delete(); + sunk.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs new file mode 100644 index 000000000..923784f4f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs @@ -0,0 +1,33 @@ +using Server.Engines.Pathing.Cache; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiMaskSynthesisTests +{ + private const int MapId = 1; // Trammel + private const int GuildHouseId = 0x74; // static house: walls, door aperture, floor + private const int PlaceX = 1480; + private const int PlaceY = 1620; + + [Fact] + public void ComputeMultiMaskAt_MatchesCheckMovement_OverFootprintAndHalo() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var loc = new Point3D(PlaceX, PlaceY, (sbyte)z); + + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(loc, map); + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multi, map); + } + finally + { + multi.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs new file mode 100644 index 000000000..9a920282f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs @@ -0,0 +1,158 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Server.Mobiles; +using Server.PathAlgorithms; +using Server.Systems.FeatureFlags; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiPathInvariantTests +{ + private readonly ITestOutputHelper _output; + + public MultiPathInvariantTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + + private sealed class WalkerStub : Mobile + { + public WalkerStub() => Body = 0xC9; + } + + private static Direction[] FindWithFlag(Mobile m, Map map, Point3D start, Point3D goal, bool cacheOn) + { + var prev = ContentFeatureFlags.BitmapPathfindingCache; + try + { + ContentFeatureFlags.BitmapPathfindingCache = cacheOn; + StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; + return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal); + } + finally + { + ContentFeatureFlags.BitmapPathfindingCache = prev; + } + } + + [Theory] + // start, goal: straddle a house placed between them (house at ~ midpoint). + [InlineData(1500, 1600, 1500, 1612)] // N-S across the footprint + [InlineData(1494, 1606, 1512, 1606)] // E-W across the footprint + public void Find_CacheOn_EqualsCacheOff_WithHousePresent(int sx, int sy, int gx, int gy) + { + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + // Place the house at the midpoint so it sits between start and goal. + var hx = (sx + gx) / 2; + var hy = (sy + gy) / 2; + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)sz); + walker.MoveToWorld(start, map); + + try + { + var on = FindWithFlag(walker, map, start, goal, cacheOn: true); + var off = FindWithFlag(walker, map, start, goal, cacheOn: false); + + // Both-null or both-equal arrays. Equality of the direction sequence is the invariant. + Assert.Equal(off == null, on == null); + if (on != null) + { + Assert.Equal(off, on); + _output.WriteLine($"({sx},{sy})->({gx},{gy}) house: {on.Length} steps, cache==slow"); + } + else + { + _output.WriteLine($"({sx},{sy})->({gx},{gy}) house: no path (both)"); + } + } + finally + { + walker.Delete(); + multi.Delete(); + } + } + + [Theory] + [InlineData(1500, 1600, 1498, 1598)] + [InlineData(1500, 1600, 1497, 1599)] + public void Find_CacheOn_EqualsCacheOff_NoMultiControl(int sx, int sy, int gx, int gy) + { + var map = Map.Maps[MapId]; + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)sz); + walker.MoveToWorld(start, map); + + try + { + var on = FindWithFlag(walker, map, start, goal, cacheOn: true); + var off = FindWithFlag(walker, map, start, goal, cacheOn: false); + Assert.Equal(off == null, on == null); + if (on != null) + { + Assert.Equal(off, on); + } + } + finally + { + walker.Delete(); + } + } + + [Fact] + public void Find_CacheOn_EqualsCacheOff_RoutesAroundHouse() + { + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + // Open area at (1480,1620,z=20): paths exist in all 8 directions (probe confirmed). + // N-S route: start=(1480,1630) goal=(1480,1610). House at (1480,1620) on direct line. + // Detour exists E (~1488+) and W (~1472-). Both cache-on and cache-off must agree. + const int hx = 1480, hy = 1620; + const int sx = 1480, sy = 1630; + const int gx = 1480, gy = 1610; + + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + map.GetAverageZ(gx, gy, out _, out var gz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)gz); + walker.MoveToWorld(start, map); + + try + { + var on = FindWithFlag(walker, map, start, goal, cacheOn: true); + var off = FindWithFlag(walker, map, start, goal, cacheOn: false); + + // Non-vacuity: a real around-the-house route must exist on BOTH sides. + Assert.NotNull(off); + Assert.NotNull(on); + // The invariant: cache and slow path agree on that route. + Assert.Equal(off, on); + + _output.WriteLine($"around-house: {on.Length} steps, cache==slow"); + } + finally + { + walker.Delete(); + multi.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs new file mode 100644 index 000000000..4775b8fac --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs @@ -0,0 +1,90 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiSplitRoutingTests +{ + private readonly ITestOutputHelper _output; + + public MultiSplitRoutingTests(ITestOutputHelper output) => _output = output; + + // Open plain on Trammel used by existing pathfinding tests; room for a 7x7 house. + private const int MapId = 1; + private const int PlaceX = 1500; + private const int PlaceY = 1600; + + [Fact] + public void PlacedMulti_RoutesFootprintAndHalo_ToFallthroughMulti() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var multi = new TestMulti(0x74); // GuildHouse footprint + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + try + { + var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4); + Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis"); + + var cells = MultiArt.FootprintWithHalo(multi); + var checkedCovered = 0; + foreach (var c in cells) + { + if (c.X < 0 || c.Y < 0 || c.X >= map.Width || c.Y >= map.Height) + { + continue; + } + var mask = StepCache.Instance.TryGetMask(map, c.X, c.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, mask.HitKind); + checkedCovered++; + } + + Assert.True(checkedCovered > 0, "non-vacuity: expected at least one covered cell"); + _output.WriteLine($"covered/halo cells routed to fallthrough: {checkedCovered}"); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void CleanMap_AfterMultiRemoved_ServesStaticHitAgain() + { + StepCache.Instance.Clear(); + var prevThreshold = StepCache.Instance.MissPromotionThreshold; + StepCache.Instance.MissPromotionThreshold = 1; // build on first touch + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4); + Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis"); + + try + { + multi.Delete(); + Assert.False(sector.HasMultis, "removing the only multi must clear HasMultis"); + + // A cell that is NOT a static-fallthrough kind (e.g. off-map / multi) should now be + // eligible for a real static answer. Use the placement center, which is open plain. + var mask = StepCache.Instance.TryGetMask(map, PlaceX, PlaceY, (sbyte)z); + Assert.True(mask.IsHit, $"expected a real static answer after removal, got {mask.HitKind}"); + _output.WriteLine($"post-removal hitKind at center: {mask.HitKind}"); + } + finally + { + StepCache.Instance.MissPromotionThreshold = prevThreshold; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs new file mode 100644 index 000000000..092b28711 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; +using Server; +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using CalcMoves = Server.Movement.Movement; + +namespace Server.Tests.Pathfinding; + +/// +/// Minimal concrete for tests. Walkability depends only on +/// Components (the shared MCL for the multiID) + Location, so this is a faithful stand-in +/// for any fixed-design multi (classic house, camp, boat heading) without the owning +/// house/boat machinery. Never serialized in tests. +/// +public sealed class TestMulti : BaseMulti +{ + public TestMulti(int itemID) : base(itemID) + { + } +} + +/// Default walker body, shared across pathfinding test fixtures. +public sealed class WalkerStub : Mobile +{ + public WalkerStub() => Body = 0xC9; +} + +/// +/// Stand-in for a customizable foundation: Components is a swappable MCL, exactly the +/// runtime-mutation shape HouseFoundation uses (it replaces its MCL wholesale on redesign +/// commit). Lets the test change the footprint and assert the engine reflects it without +/// driving full house placement/customization. +/// +public sealed class SwappableFoundation : BaseMulti +{ + private MultiComponentList _mcl; + + public SwappableFoundation(int baseMultiID) : base(baseMultiID) => + _mcl = MultiData.GetComponents(baseMultiID); + + public override MultiComponentList Components => _mcl; + + public void Redesign(MultiComponentList replacement) => _mcl = replacement; +} + +/// +/// Shared helpers for placing/probing multis in pathfinding tests. +/// +public static class MultiTestSupport +{ + // A default-walker oracle mobile (CanSwim=false, CantWalk=false) placed in-world so MovementImpl + // state reads are valid. Caller MUST Delete() it (do it in a finally). + public static Mobile GetWalkerOracle(Map map, Point3D loc) + { + var w = new WalkerStub(); + w.MoveToWorld(loc, map); + return w; + } + + public static bool HasMultiTileAt(BaseMulti multi, int wx, int wy) + { + var mcl = multi.Components; + var lx = wx - multi.X + mcl.Center.X; + var ly = wy - multi.Y + mcl.Center.Y; + if (lx < 0 || ly < 0 || lx >= mcl.Width || ly >= mcl.Height) + { + return false; + } + return mcl.Tiles[lx][ly].Length > 0; + } + + /// + /// Sweeps the multi's footprint + 1-cell halo and asserts the multi mask synthesizer + /// () agrees with the CheckMovement oracle + /// for all 8 directions at every cell, including the exact forward walk-Z on allowed moves. + /// Creates its own walker oracle internally and Delete()s it; the caller owns the multi. + /// + public static void AssertSynthesizerMatchesCheckMovement(BaseMulti multi, Map map) + { + var loc = new Point3D(multi.X, multi.Y, multi.Z); + var mover = GetWalkerOracle(map, loc); + try + { + var cells = MultiArt.FootprintWithHalo(multi); + Assert.NotEmpty(cells); + + var touchedMulti = 0; + var sawWalkable = 0; + var sawBlocked = 0; + + foreach (var c in cells) + { + var sourceZ = (sbyte)loc.Z; + var p = new Point3D(c.X, c.Y, sourceZ); + + if (HasMultiTileAt(multi, c.X, c.Y)) + { + touchedMulti++; + } + + var mask = StepProbe.ComputeMultiMaskAt(map, c.X, c.Y, sourceZ); + + for (var d = 0; d < 8; d++) + { + var dir = (Direction)d; + var expectWalk = CalcMoves.CheckMovement(mover, map, p, dir, out var expectZ); + + // The synthesizer reports the raw forward-cell step per direction and does NOT + // apply diagonal corner-cutting — by design, the caller ANDs the partner cells. + // CheckMovement (the oracle) DOES corner-cut. Replicate the caller's corner-cut + // on the mask so we compare like-for-like. The walker is not a player, so the + // diagonal is blocked only when BOTH orthogonal partner cells are blocked. + var forwardWalk = (mask.WalkMask & (1 << d)) != 0; + var gotWalk = forwardWalk; + var isDiagonal = (d & 0x1) == 0x1; + if (forwardWalk && isDiagonal) + { + var leftBit = (d - 1) & 0x7; + var rightBit = (d + 1) & 0x7; + var leftWalk = (mask.WalkMask & (1 << leftBit)) != 0; + var rightWalk = (mask.WalkMask & (1 << rightBit)) != 0; + if (!leftWalk && !rightWalk) + { + gotWalk = false; + } + } + + Assert.Equal(expectWalk, gotWalk); + + if (expectWalk) + { + // Z is taken from the forward cell only; corner-cut never alters newZ when + // the move is allowed. + Assert.Equal((sbyte)expectZ, mask.GetWalkZ(dir)); + sawWalkable++; + } + else + { + sawBlocked++; + } + } + } + + Assert.True(touchedMulti > 0, "sweep touched no multi-covered cells"); + Assert.True(sawWalkable > 0, "sweep observed no walkable transitions"); + Assert.True(sawBlocked > 0, "sweep observed no blocked transitions"); + } + finally + { + mover.Delete(); + } + } +} + +/// +/// Helpers that derive expected geometry from a multi's MCL art at runtime, so tests +/// encode no hardcoded cell coordinates and survive art-data changes. +/// +public static class MultiArt +{ + public readonly record struct Cell(int X, int Y); + + /// Every world cell the multi's footprint covers (Tiles stack non-empty). + public static List FootprintCells(BaseMulti multi) + { + var mcl = multi.Components; + var result = new List(); + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + if (mcl.Tiles[lx][ly].Length == 0) + { + continue; + } + result.Add(new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly)); + } + } + return result; + } + + /// Footprint cells plus a 1-cell halo ring (the cells the split also routes to slow path). + public static HashSet FootprintWithHalo(BaseMulti multi) + { + var foot = FootprintCells(multi); + var set = new HashSet(); + foreach (var c in foot) + { + for (var dx = -1; dx <= 1; dx++) + { + for (var dy = -1; dy <= 1; dy++) + { + set.Add(new Cell(c.X + dx, c.Y + dy)); + } + } + } + return set; + } + + /// First world cell whose MCL stack contains an impassable, non-surface (wall) tile, or null. + public static Cell? FindWallCell(BaseMulti multi) + { + var mcl = multi.Components; + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + foreach (var t in mcl.Tiles[lx][ly]) + { + var data = TileData.ItemTable[t.ID & TileData.MaxItemValue]; + if (data.Impassable && !data.Surface) + { + return new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly); + } + } + } + } + return null; + } + + /// First world cell whose MCL stack contains a walkable surface (floor) tile, or null. + public static Cell? FindFloorCell(BaseMulti multi) + { + var mcl = multi.Components; + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + foreach (var t in mcl.Tiles[lx][ly]) + { + var data = TileData.ItemTable[t.ID & TileData.MaxItemValue]; + if (data.Surface && !data.Impassable) + { + return new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly); + } + } + } + } + return null; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs new file mode 100644 index 000000000..59e583ac6 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs @@ -0,0 +1,137 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Server.Mobiles; +using Xunit; +using Xunit.Abstractions; +using CalcMoves = Server.Movement.Movement; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiWalkabilityTests +{ + private readonly ITestOutputHelper _output; + + public MultiWalkabilityTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + private const int PlaceX = 1500; + private const int PlaceY = 1600; + + private sealed class WalkerStub : Mobile + { + public WalkerStub() => Body = 0xC9; + } + + [Fact] + public void WallCell_CannotBeEnteredFromAnyAdjacentCell() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + var walker = new WalkerStub(); + walker.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + try + { + var wall = MultiArt.FindWallCell(multi); + Assert.True(wall.HasValue, "non-vacuity: house MCL must contain a wall tile"); + var w = wall.Value; + + // From each of the 8 cells surrounding the wall, try stepping in all 8 directions. + // No step may land ON the wall cell. We also require that SOME step succeeds, so a + // "zero wall entries" result reflects the wall blocking — not the walker being + // unable to move here at all (e.g. a Z mismatch blocking everything: a vacuous pass). + var entriesIntoWall = 0; + var successfulSteps = 0; + for (var around = 0; around < 8; around++) + { + var cx = w.X; + var cy = w.Y; + CalcMoves.Offset((Direction)around, ref cx, ref cy); + map.GetAverageZ(cx, cy, out _, out var cz, out _); + var from = new Point3D(cx, cy, (sbyte)cz); + + for (var d = 0; d < 8; d++) + { + if (!CalcMoves.CheckMovement(walker, map, from, (Direction)d, out _)) + { + continue; + } + + successfulSteps++; + + var tx = cx; + var ty = cy; + CalcMoves.Offset((Direction)d, ref tx, ref ty); + if (tx == w.X && ty == w.Y) + { + entriesIntoWall++; + _output.WriteLine($"UNEXPECTED entry into wall ({w.X},{w.Y}) from ({cx},{cy}) dir {d}"); + } + } + } + + Assert.True(successfulSteps > 0, "non-vacuity: walker must be able to move near the wall"); + Assert.Equal(0, entriesIntoWall); + } + finally + { + walker.Delete(); + multi.Delete(); + } + } + + [Fact] + public void FloorCell_IsStandable() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + var walker = new WalkerStub(); + + try + { + var floor = MultiArt.FindFloorCell(multi); + Assert.True(floor.HasValue, "non-vacuity: house MCL must contain a floor tile"); + var f = floor.Value; + + // Stand the walker on a cardinal neighbour of the floor cell and require at least + // one direction that successfully steps onto the floor cell. + var enteredFloor = false; + for (var d = 0; d < 8 && !enteredFloor; d++) + { + var nx = f.X; + var ny = f.Y; + CalcMoves.Offset((Direction)((d + 4) & 7), ref nx, ref ny); + map.GetAverageZ(nx, ny, out _, out var nz, out _); + walker.MoveToWorld(new Point3D(nx, ny, (sbyte)nz), map); + if (CalcMoves.CheckMovement(walker, map, walker.Location, (Direction)d, out _)) + { + var tx = nx; + var ty = ny; + CalcMoves.Offset((Direction)d, ref tx, ref ty); + if (tx == f.X && ty == f.Y) + { + enteredFloor = true; + } + } + } + + Assert.True(enteredFloor, $"expected the floor cell ({f.X},{f.Y}) to be reachable from a neighbour"); + } + finally + { + walker.Delete(); + multi.Delete(); + } + } +} diff --git a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs index db4aa35b8..9008c18bd 100644 --- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs @@ -353,7 +353,22 @@ public class BitmapAStarAlgorithm : PathAlgorithm if (!lookup.IsHit) { - return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); + // Multi-covered cells: synthesize a multi-aware mask in ONE pass (over land + statics + + // house/boat component tiles) instead of the slow path's 8x per-cell CheckMovement. + // Fliers and cache-off already returned at the top of GetSuccessors, so this only runs + // for cacheable walkers/swimmers. The synthesized mask flows through the SAME + // capability-overlay + diagonal corner-cut + dynamic-obstacle loop below as a static hit. + if (lookup.HitKind == CacheHitKind.Fallthrough_Multi) + { + // Multi-covered cell: the per-multiID interior cache serves a ~20 ns lookup for + // interior cells (and records the right counter); it falls back internally to the + // Phase-2 live synthesizer for perimeter / terrain-dirty / foundation cells. + lookup = MultiMaskCache.Instance.GetMask(map, p3D.X, p3D.Y, (sbyte)p3D.Z); + } + else + { + return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); + } } // Capability overlay: walking allowed unless cantWalk; swimming allowed if canSwim. diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs index 4b3cfc974..72c225423 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs @@ -14,6 +14,8 @@ public readonly struct CacheStats( long fallthroughSourceZMismatch, long fallthroughNotBuilt, long fallthroughMulti, + long multiLocalHits, + long multiMaskCacheHits, long evictionsByLruCap, long buildsTotal ) @@ -27,6 +29,8 @@ public readonly struct CacheStats( public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch; public readonly long FallthroughNotBuilt = fallthroughNotBuilt; public readonly long FallthroughMulti = fallthroughMulti; + public readonly long MultiLocalHits = multiLocalHits; + public readonly long MultiMaskCacheHits = multiMaskCacheHits; public readonly long EvictionsByLruCap = evictionsByLruCap; public readonly long BuildsTotal = buildsTotal; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs new file mode 100644 index 000000000..efb047d23 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using Server.Items; +using Server.Multis; + +namespace Server.Engines.Pathing.Cache; + +/// +/// Warm, in-memory cache of per-multiID local-frame walkability masks for INTERIOR multi cells +/// (cell + all 8 neighbours covered by the multi → terrain-neighbour-free → position-invariant). +/// Wraps the Phase-2 synthesizer (StepProbe.ComputeMultiMaskAt). Cleanliness is decided ONCE per +/// instance (BaseMulti.PathInteriorCacheState, via ComputeFootprintClean): a clean instance — whole +/// footprint terrain below the floor — serves interior cells from the shared per-multiID cache; +/// dirty instances, boats (movers), and HouseFoundation (runtime-mutable) fall back to live-synth. +/// Keyed by multiID & 0x3FFF. +/// +public sealed class MultiMaskCache +{ + public static MultiMaskCache Instance { get; } = new(); + + private const int StepHeight = 2; + + private readonly Dictionary _byMultiId = []; + + public void Clear() => _byMultiId.Clear(); + + /// + /// Returns the multi-aware StepMask for a covered cell (x,y,sourceZ). Serves a cached interior + /// mask when available and the guards pass (counted as a MultiMaskCacheHit); otherwise falls + /// back to the Phase-2 live synthesizer ComputeMultiMaskAt (counted as a MultiLocalHit), caching + /// the result if the cell is interior and clean. Always returns a usable mask (HitKind == Hit). + /// + public StepMask GetMask(Map map, int x, int y, sbyte sourceZ) + { + if (!TryResolveCoveringMulti(map, x, y, out var multi, out var lx, out var ly) + || multi is HouseFoundation) // runtime-mutable per-instance DesignState MCL + { + return LiveSynth(map, x, y, sourceZ); + } + + // Boats are cached too: their per-multiID deck masks are movement-invariant (built once per + // heading), and the per-instance clean gate below + the ItemID/location/map resets keep a + // moving/turning boat correct. Narrow boats have little interior; wide galleons gain a lot. + + // Per-instance footprint cleanliness (computed once, stored on the multi; reset on move). + // Clean ⇒ no terrain intrusion anywhere in the footprint ⇒ interior cells are exact from the + // shared per-multiID cache. Dirty ⇒ degrade to the live synthesizer (never serve a wrong mask). + if (multi.PathInteriorCacheState == MultiInteriorCacheState.Unknown) + { + multi.PathInteriorCacheState = + ComputeFootprintClean(map, multi) ? MultiInteriorCacheState.Clean : MultiInteriorCacheState.Dirty; + } + + if (multi.PathInteriorCacheState != MultiInteriorCacheState.Clean) + { + return LiveSynth(map, x, y, sourceZ); + } + + var mcl = multi.Components; + var local = GetOrCreate(multi.ItemID & 0x3FFF, mcl.Width, mcl.Height); + var state = local.GetState(lx, ly); + + if (state == MultiLocalMask.CellState.Cached) + { + // Footprint is clean, so only the source-Z match matters (terrain can't intrude). + var worldFloorZ = local.FloorZAt(lx, ly) + multi.Z; + if (Math.Abs(sourceZ - worldFloorZ) <= StepHeight) + { + StepCache.Instance.RecordMultiMaskCacheHit(); + return ToWorldZ(local.MaskAt(lx, ly), multi.Z); + } + + return LiveSynth(map, x, y, sourceZ); + } + + if (state == MultiLocalMask.CellState.NonInterior) + { + return LiveSynth(map, x, y, sourceZ); + } + + // Unknown → classify + (if interior) build & cache. No per-cell terrain guard needed: the + // instance is clean, so every interior cell's 3x3 terrain is below the floor. + var mask = LiveSynth(map, x, y, sourceZ); + if (IsInteriorLocalCell(mcl, lx, ly) + && TryToLocalZ(mask, multi.Z, out var localMask) + && sourceZ - multi.Z is >= sbyte.MinValue and <= sbyte.MaxValue) + { + local.SetCached(lx, ly, localMask, (sbyte)(sourceZ - multi.Z)); + } + else + { + local.SetNonInterior(lx, ly); + } + + return mask; + } + + private static StepMask LiveSynth(Map map, int x, int y, sbyte sourceZ) + { + StepCache.Instance.RecordMultiLocalHit(); + return StepProbe.ComputeMultiMaskAt(map, x, y, sourceZ); + } + + private MultiLocalMask GetOrCreate(int key, int width, int height) + { + if (!_byMultiId.TryGetValue(key, out var m)) + { + m = new MultiLocalMask(width, height); + _byMultiId[key] = m; + } + + return m; + } + + /// + /// Finds the multi covering (x,y) and the local cell indices into its MCL. Mirrors + /// Map.StaticTileEnumerator / BaseMulti.Contains. Returns false if no multi covers the cell. + /// + public static bool TryResolveCoveringMulti(Map map, int x, int y, out BaseMulti multi, out int lx, out int ly) + { + foreach (var candidate in map.GetMultisInSector(x, y)) + { + var mcl = candidate.Components; + var cx = x - candidate.X - mcl.Min.X; + var cy = y - candidate.Y - mcl.Min.Y; + if (cx >= 0 && cy >= 0 && cx < mcl.Width && cy < mcl.Height && mcl.Tiles[cx][cy].Length > 0) + { + multi = candidate; + lx = cx; + ly = cy; + return true; + } + } + + multi = null; + lx = ly = 0; + return false; + } + + /// + /// True iff local cell (lx,ly) and all 8 neighbours are covered by the multi (have MCL tiles). + /// Such a cell's 8-direction transition is fully determined by the multi (no terrain neighbour), + /// so its mask is position-invariant. A pure function of the MCL. + /// + public static bool IsInteriorLocalCell(MultiComponentList mcl, int lx, int ly) + { + for (var dy = -1; dy <= 1; dy++) + { + for (var dx = -1; dx <= 1; dx++) + { + var nx = lx + dx; + var ny = ly + dy; + if (nx < 0 || ny < 0 || nx >= mcl.Width || ny >= mcl.Height || mcl.Tiles[nx][ny].Length == 0) + { + return false; + } + } + } + + return true; + } + + /// + /// Converts a world-frame mask's per-direction Zs to local Z (subtract multiZ). Returns false + /// if any local Z doesn't fit sbyte (caller must then NOT cache the cell — rare; only when + /// |multiZ| is large enough to push a world Z out of range). Mask (walk/wet) bits are copied. + /// + public static bool TryToLocalZ(StepMask world, int multiZ, out StepMask local) + { + local = default; + Span w = stackalloc sbyte[8]; + Span s = stackalloc sbyte[8]; + for (var d = 0; d < 8; d++) + { + var lw = world.GetWalkZ((Direction)d) - multiZ; + var ls = world.GetSwimZ((Direction)d) - multiZ; + if (lw < sbyte.MinValue || lw > sbyte.MaxValue || ls < sbyte.MinValue || ls > sbyte.MaxValue) + { + return false; + } + w[d] = (sbyte)lw; + s[d] = (sbyte)ls; + } + + local = new StepMask( + world.WalkMask, world.WetMask, + w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], + s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7] + ); + return true; + } + + /// + /// True iff all terrain (land + statics) at (x,y) sits strictly below , + /// so a creature standing on the multi floor never sees terrain in its envelope and the cached + /// (terrain-free) mask is exact. Cheap: one land-top read + the cell's static-tile array scan. + /// + public static bool TerrainTopBelow(Map map, int x, int y, sbyte floorZ) + { + map.GetAverageZ(x, y, out _, out _, out var landTop); + if (landTop >= floorZ) + { + return false; + } + + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var top = tile.Z + data.CalcHeight; + if (top >= floorZ) + { + return false; + } + } + + return true; + } + + /// Highest terrain (land + statics) top at (x,y). Building block for the cleanliness check. + public static int TerrainTop(Map map, int x, int y) + { + map.GetAverageZ(x, y, out _, out _, out var top); + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var t = tile.Z + data.CalcHeight; + if (t > top) + { + top = t; + } + } + + return top; + } + + /// + /// True iff the multi's WHOLE footprint terrain sits below its lowest standable floor — i.e. + /// maxTerrain < minFloor over all covered cells. When true, no covered cell's terrain (nor any + /// neighbour's) can intrude into a creature's floor envelope, so interior cells of this design are + /// safe to serve from the shared per-multiID cache for THIS instance. One-time per instance. + /// + public static bool ComputeFootprintClean(Map map, BaseMulti multi) + { + var mcl = multi.Components; + var minFloorLocal = int.MaxValue; + var maxTerrain = int.MinValue; + + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + var col = mcl.Tiles[lx][ly]; + if (col.Length == 0) + { + continue; // uncovered local cell + } + + foreach (var tile in col) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + if (data.Surface && !data.Impassable) + { + var top = tile.Z + data.CalcHeight; + if (top < minFloorLocal) + { + minFloorLocal = top; + } + } + } + + var terrain = TerrainTop(map, multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly); + if (terrain > maxTerrain) + { + maxTerrain = terrain; + } + } + } + + if (minFloorLocal == int.MaxValue) + { + return false; // no standable floor anywhere → don't cache (defensive) + } + + return maxTerrain < minFloorLocal + multi.Z; + } + + /// Inverse of : add multiZ back to recover world Zs. + public static StepMask ToWorldZ(StepMask local, int multiZ) + { + Span w = stackalloc sbyte[8]; + Span s = stackalloc sbyte[8]; + for (var d = 0; d < 8; d++) + { + w[d] = (sbyte)(local.GetWalkZ((Direction)d) + multiZ); + s[d] = (sbyte)(local.GetSwimZ((Direction)d) + multiZ); + } + + return new StepMask( + local.WalkMask, local.WetMask, + w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], + s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7] + ); + } +} + +/// +/// Per-multiID lazily-filled grid of interior-cell masks. Cell state: Unknown (not yet classified), +/// Cached (interior + clean → mask valid), NonInterior (perimeter/edge/terrain-dirty → live-synth). +/// +internal sealed class MultiLocalMask +{ + public enum CellState : byte { Unknown = 0, Cached = 1, NonInterior = 2 } + + private readonly int _width; + private readonly int _height; + private readonly CellState[] _state; + private readonly StepMask[] _mask; // local-Z mask, valid when state == Cached + private readonly sbyte[] _floorZ; // local floor Z, valid when state == Cached + + public MultiLocalMask(int width, int height) + { + _width = width; + _height = height; + _state = new CellState[width * height]; + _mask = new StepMask[width * height]; + _floorZ = new sbyte[width * height]; + } + + public int Width => _width; + public int Height => _height; + + public CellState GetState(int lx, int ly) => _state[ly * _width + lx]; + + public void SetCached(int lx, int ly, StepMask localMask, sbyte localFloorZ) + { + var i = ly * _width + lx; + _mask[i] = localMask; + _floorZ[i] = localFloorZ; + _state[i] = CellState.Cached; + } + + public void SetNonInterior(int lx, int ly) => _state[ly * _width + lx] = CellState.NonInterior; + + public StepMask MaskAt(int lx, int ly) => _mask[ly * _width + lx]; + public sbyte FloorZAt(int lx, int ly) => _floorZ[ly * _width + lx]; +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index 62a1363ac..5955ddd35 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -55,11 +55,17 @@ public sealed class StepCache private long _fallthroughSourceZMismatch; private long _fallthroughNotBuilt; private long _fallthroughMulti; + private long _multiLocalHits; + private long _multiMaskCacheHits; private long _evictionsByLruCap; private long _buildsTotal; private StepCache() { } + public void RecordMultiLocalHit() => _multiLocalHits++; + + public void RecordMultiMaskCacheHit() => _multiMaskCacheHits++; + /// Hard cap on resident chunk count. Default 8192. Override for tests / ops. public int MaxResidentChunks { get; set; } = 8192; @@ -121,6 +127,8 @@ public sealed class StepCache fallthroughSourceZMismatch: _fallthroughSourceZMismatch, fallthroughNotBuilt: _fallthroughNotBuilt, fallthroughMulti: _fallthroughMulti, + multiLocalHits: _multiLocalHits, + multiMaskCacheHits: _multiMaskCacheHits, evictionsByLruCap: _evictionsByLruCap, buildsTotal: _buildsTotal ); @@ -134,6 +142,7 @@ public sealed class StepCache { ClearResidentChunks(); CloseLazyReaders(); + MultiMaskCache.Instance.Clear(); } /// @@ -156,6 +165,8 @@ public sealed class StepCache _fallthroughSourceZMismatch = 0; _fallthroughNotBuilt = 0; _fallthroughMulti = 0; + _multiLocalHits = 0; + _multiMaskCacheHits = 0; _evictionsByLruCap = 0; _buildsTotal = 0; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs index d42cdc91f..a8c89991b 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -186,16 +186,38 @@ public static class StepProbe return n; } - public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) + public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) => + ComputeMaskCore(map, x, y, sourceZ, includeMultis: false); + + /// + /// Multi-aware counterpart to : synthesizes the full 8-direction + /// walkability mask for a cell covered by (or adjacent to) a multi, folding house/boat component + /// tiles into the surface/step logic via GetStaticAndMultiTiles. Replaces the slow path's 8x + /// per-cell CheckMovement for Fallthrough_Multi cells. Item/mobile collision is still handled by + /// the caller's dynamic-obstacle pass. + /// + public static StepMask ComputeMultiMaskAt(Map map, int x, int y, sbyte sourceZ) => + ComputeMaskCore(map, x, y, sourceZ, includeMultis: true); + + /// + /// Shared per-cell 8-direction mask builder. With includeMultis=false this reproduces the + /// static-only bake (land + statics.mul). With includeMultis=true it also folds in multi + /// (house/boat) component tiles via GetStaticAndMultiTiles — the multi-aware synthesizer used + /// for Fallthrough_Multi cells. Item/mobile collision phases are still omitted (the dynamic pass + /// owns them). + /// + private static StepMask ComputeMaskCore(Map map, int x, int y, sbyte sourceZ, bool includeMultis) { if (map == null || map == Map.Internal) { return default; } - GetStaticStartZ(map, x, y, sourceZ, canSwim: false, cantWalk: false, + var srcTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(x, y) : map.Tiles.GetStaticTiles(x, y); + + GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: false, cantWalk: false, out var walkStartZ, out var walkStartTop, out _); - GetStaticStartZ(map, x, y, sourceZ, canSwim: true, cantWalk: true, + GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: true, cantWalk: true, out var swimStartZ, out var swimStartTop, out _); byte walkMask = 0; @@ -213,14 +235,16 @@ public static class StepProbe var dy = y; CalcMoves.Offset((Direction)d, ref dx, ref dy); - if (CheckStaticStep(map, dx, dy, walkStartZ, walkStartTop, + var dTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(dx, dy) : map.Tiles.GetStaticTiles(dx, dy); + + if (CheckStaticStep(map, dx, dy, dTiles, walkStartZ, walkStartTop, canSwim: false, cantWalk: false, out var walkZ)) { walkMask |= (byte)(1 << d); walkZs[d] = (sbyte)walkZ; } - if (CheckStaticStep(map, dx, dy, swimStartZ, swimStartTop, + if (CheckStaticStep(map, dx, dy, dTiles, swimStartZ, swimStartTop, canSwim: true, cantWalk: true, out var swimZ)) { wetMask |= (byte)(1 << d); @@ -245,7 +269,7 @@ public static class StepProbe /// public static int ComputeStandingZ(Map map, int x, int y, int locZ) { - GetStaticStartZ(map, x, y, locZ, canSwim: false, cantWalk: false, out _, out _, out var zCenter); + GetStaticStartZ(map, x, y, locZ, map.Tiles.GetStaticTiles(x, y), canSwim: false, cantWalk: false, out _, out _, out var zCenter); return zCenter; } @@ -289,7 +313,8 @@ public static class StepProbe /// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk. /// private static void GetStaticStartZ( - Map map, int x, int y, int locZ, bool canSwim, bool cantWalk, out int zLow, out int zTop, out int zCenter + Map map, int x, int y, int locZ, Map.StaticTileEnumerable tiles, + bool canSwim, bool cantWalk, out int zLow, out int zTop, out int zCenter ) { var landTile = map.Tiles.GetLandTile(x, y); @@ -315,7 +340,7 @@ public static class StepProbe isSet = true; } - foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + foreach (var tile in tiles) { var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; var calcTop = tile.Z + id.CalcHeight; @@ -353,7 +378,8 @@ public static class StepProbe /// Items and mobile collision phases are omitted. /// private static bool CheckStaticStep( - Map map, int x, int y, int startZ, int startTop, bool canSwim, bool cantWalk, out int newZ + Map map, int x, int y, Map.StaticTileEnumerable tiles, int startZ, int startTop, + bool canSwim, bool cantWalk, out int newZ ) { newZ = 0; @@ -380,7 +406,7 @@ public static class StepProbe int testTop; - foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + foreach (var tile in tiles) { var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; var notWater = !itemData.Wet; diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index 01ec4cdee..14a41b6c0 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -162,6 +162,7 @@ public static class PathCacheCommands from.SendMessage($" miss(notBuilt)={stats.MissesNotBuilt} miss(dirty)={stats.MissesDirtyRebuild}"); from.SendMessage($" fallthru(multiZ)={stats.FallthroughMultiZ} fallthru(offMap)={stats.FallthroughOffMap} fallthru(srcZ)={stats.FallthroughSourceZMismatch}"); from.SendMessage($" fallthru(multi)={stats.FallthroughMulti} fallthru(notBuilt)={stats.FallthroughNotBuilt}"); + from.SendMessage($" multiLocalHits={stats.MultiLocalHits}"); from.SendMessage($" evictions(lruCap)={stats.EvictionsByLruCap}"); } diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 167ae2202..2082056da 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -371,52 +371,27 @@ namespace Server.Multis public override void OnLocationChange(Point3D old) { - if (TillerMan != null) - { - TillerMan.Location = new Point3D( - X + (TillerMan.X - old.X), - Y + (TillerMan.Y - old.Y), - Z + (TillerMan.Z - old.Z) - ); - } + base.OnLocationChange(old); - if (Hold != null) - { - Hold.Location = new Point3D(X + (Hold.X - old.X), Y + (Hold.Y - old.Y), Z + (Hold.Z - old.Z)); - } + TillerMan?.Location = new Point3D( + X + (TillerMan.X - old.X), + Y + (TillerMan.Y - old.Y), + Z + (TillerMan.Z - old.Z) + ); - if (PPlank != null) - { - PPlank.Location = new Point3D(X + (PPlank.X - old.X), Y + (PPlank.Y - old.Y), Z + (PPlank.Z - old.Z)); - } - - if (SPlank != null) - { - SPlank.Location = new Point3D(X + (SPlank.X - old.X), Y + (SPlank.Y - old.Y), Z + (SPlank.Z - old.Z)); - } + Hold?.Location = new Point3D(X + (Hold.X - old.X), Y + (Hold.Y - old.Y), Z + (Hold.Z - old.Z)); + PPlank?.Location = new Point3D(X + (PPlank.X - old.X), Y + (PPlank.Y - old.Y), Z + (PPlank.Z - old.Z)); + SPlank?.Location = new Point3D(X + (SPlank.X - old.X), Y + (SPlank.Y - old.Y), Z + (SPlank.Z - old.Z)); } public override void OnMapChange() { - if (TillerMan != null) - { - TillerMan.Map = Map; - } + base.OnMapChange(); - if (Hold != null) - { - Hold.Map = Map; - } - - if (PPlank != null) - { - PPlank.Map = Map; - } - - if (SPlank != null) - { - SPlank.Map = Map; - } + TillerMan?.Map = Map; + Hold?.Map = Map; + PPlank?.Map = Map; + SPlank?.Map = Map; } public bool CanCommand(Mobile m) => true; diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs index 325ec479d..aea0cb8bc 100644 --- a/Projects/UOContent/Multis/Houses/BaseHouse.cs +++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs @@ -1556,6 +1556,8 @@ namespace Server.Multis public override void OnMapChange() { + base.OnMapChange(); + if (LockDowns == null) { return; @@ -1627,6 +1629,8 @@ namespace Server.Multis public override void OnLocationChange(Point3D oldLocation) { + base.OnLocationChange(oldLocation); + if (LockDowns == null) { return;