From ec287d7691716880cdbbccf88b22981c74c2413b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:25:42 -0800 Subject: [PATCH] fix: Fixes zero height spawners and normalizes spawn bounds before use. (#2301) --- Projects/Server/Geometry/Rectangle3D.cs | 10 +++++++++ .../UOContent/Engines/Spawners/BaseSpawner.cs | 21 ++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Projects/Server/Geometry/Rectangle3D.cs b/Projects/Server/Geometry/Rectangle3D.cs index c7ec149cc..6ee8da556 100644 --- a/Projects/Server/Geometry/Rectangle3D.cs +++ b/Projects/Server/Geometry/Rectangle3D.cs @@ -81,6 +81,16 @@ public struct Rectangle3D : IEquatable, ISpanFormattable [CommandProperty(AccessLevel.Counselor)] public int Depth => _end.Z - _start.Z; + /// + /// Returns a normalized rectangle where Start contains the minimum coordinates + /// and End contains the maximum coordinates in all dimensions. + /// Use this when Start/End ordering matters (e.g., Contains checks, Z range calculations). + /// + public Rectangle3D Normalized => new( + new Point3D(Math.Min(_start.X, _end.X), Math.Min(_start.Y, _end.Y), Math.Min(_start.Z, _end.Z)), + new Point3D(Math.Max(_start.X, _end.X), Math.Max(_start.Y, _end.Y), Math.Max(_start.Z, _end.Z)) + ); + public bool Equals(Rectangle3D other) => _start == other._start && _end == other._end; public override bool Equals(object obj) => obj is Rectangle3D other && Equals(other); diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 8121bd585..529fe7f75 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -568,18 +568,21 @@ public abstract partial class BaseSpawner : Item, ISpawner var maxAttempts = _maxSpawnAttempts > 0 ? _maxSpawnAttempts : DefaultMaxSpawnAttempts; for (var i = 0; i < maxAttempts; i++) { - var bounds = GetBoundsForSpawnAttempt(); + var rawBounds = GetBoundsForSpawnAttempt(); // No bounds = spawn at spawner location - if (bounds == default) + if (rawBounds == default) { return Location; } + // Normalize to ensure Start <= End in all dimensions + var bounds = rawBounds.Normalized; + var x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1); var y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1); var minZ = bounds.Start.Z; - var maxZ = bounds.End.Z - 1; + var maxZ = Math.Max(minZ, bounds.End.Z - 1); bool success; int spawnZ; @@ -647,11 +650,12 @@ public abstract partial class BaseSpawner : Item, ISpawner else if (!_spawnPositionState.SpiralComplete) { // Use the first bounds for spiral center/range - var primaryBounds = allBounds.Length > 0 ? allBounds[0] : default; - if (primaryBounds != default) + var rawPrimaryBounds = allBounds.Length > 0 ? allBounds[0] : default; + if (rawPrimaryBounds != default) { + var primaryBounds = rawPrimaryBounds.Normalized; var minZ = primaryBounds.Start.Z; - var maxZ = primaryBounds.End.Z - 1; + var maxZ = Math.Max(minZ, primaryBounds.End.Z - 1); // Scan more rings initially (3), fewer once cache has positions (1) var ringsPerTick = _spawnPositionState.SpiralRing == 0 ? 3 : 1; @@ -727,8 +731,9 @@ public abstract partial class BaseSpawner : Item, ISpawner } // Re-verify in 3D using the bounds that contains this position - var minZ = containingBounds.Start.Z; - var maxZ = containingBounds.End.Z - 1; + var normalizedBounds = containingBounds.Normalized; + var minZ = normalizedBounds.Start.Z; + var maxZ = Math.Max(minZ, normalizedBounds.End.Z - 1); var verified = isMobile ? map.CanSpawnMobile(cachedPos.X, cachedPos.Y, minZ, maxZ, canSwim, cantWalk, out var verifiedZ)