feat: Add spawn position caching and spiral scan optimization (#2295)

### Summary

Adds spawn position caching and optimization for constrained spawners (e.g., those near houses, water, or blocked terrain).

### Key features:
- Sector-based bitmap cache (32 bytes per 16x16 sector) stores valid spawn positions
- Spiral scan progressively discovers positions from spawner center outward
- Automatic mode detects constrained spawners after 5+ non-transient failures
- Prevents mob spawning inside private houses (allows public AoS buildings)
- Deduplicates sector lookups for multi-bounds spawners (RegionSpawner)
- Cache invalidation on house placement/demolition
- Moves SpawnBounds to Spawner

### New spawner properties:
- SpawnPositionMode: Automatic (default), Enabled, Disabled, Abandoned
- MaxSpawnAttempts: Configurable attempts before optimization engages (default: 5)
This commit is contained in:
Kamron Batman 2025-12-28 02:40:21 -08:00 committed by GitHub
parent 6d51b33cf8
commit 3e8d548f38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1938 additions and 153 deletions

View file

@ -7,19 +7,58 @@ using Server.Commands;
using Server.Gumps;
using Server.Items;
using Server.Json;
using Server.Logging;
using Server.Mobiles;
using static Server.Attributes;
namespace Server.Engines.Spawners;
[SerializationGenerator(11, false)]
/// <summary>
/// Controls how a spawner handles spawn position optimization.
/// </summary>
public enum SpawnPositionMode : byte
{
/// <summary>
/// Auto-detect if optimization is needed based on failure patterns.
/// Only engages lazy caching after non-transient spawn failures.
/// </summary>
Automatic = 0,
/// <summary>
/// Force optimization on. Always cache successful spawn positions.
/// </summary>
Enabled = 1,
/// <summary>
/// Force optimization off. Use only random position attempts.
/// </summary>
Disabled = 2,
/// <summary>
/// Spawner has given up due to 100% failure rate.
/// Skips all spawn position logic and returns spawner location.
/// Admin can reset via [props.
/// </summary>
Abandoned = 3
}
[SerializationGenerator(12, false)]
public abstract partial class BaseSpawner : Item, ISpawner
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseSpawner));
// Default values for serialization optimization
private static readonly TimeSpan DefaultMinDelay = TimeSpan.FromMinutes(5);
private static readonly TimeSpan DefaultMaxDelay = TimeSpan.FromMinutes(10);
[SerializedIgnoreDupe]
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.Developer)]
private Guid _guid;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _returnOnDeactivate;
@ -30,48 +69,105 @@ public abstract partial class BaseSpawner : Item, ISpawner
private int _walkingRange = -1;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeWayPoint() => _wayPoint != null;
[SerializableField(4)]
[SerializedCommandProperty(AccessLevel.Developer)]
private WayPoint _wayPoint;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeGroup() => _group;
[InvalidateProperties]
[SerializableField(5)]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _group;
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay;
[SerializableFieldDefault(6)]
private TimeSpan MinDelayDefault() => DefaultMinDelay;
[InvalidateProperties]
[SerializableField(6)]
[SerializedCommandProperty(AccessLevel.Developer)]
private TimeSpan _minDelay;
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay;
[SerializableFieldDefault(7)]
private TimeSpan MaxDelayDefault() => DefaultMaxDelay;
[InvalidateProperties]
[SerializableField(7)]
[SerializedCommandProperty(AccessLevel.Developer)]
private TimeSpan _maxDelay;
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializeTeam() => _team != 0;
[InvalidateProperties]
[SerializableField(9)]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _team;
[InvalidateProperties]
[SerializableField(10)]
[SerializedCommandProperty(AccessLevel.Developer)]
private Rectangle3D _spawnBounds;
/// <summary>
/// The spawn bounds for this spawner. Abstract to allow derived classes to manage their own storage.
/// </summary>
[CommandProperty(AccessLevel.Developer)]
public abstract Rectangle3D SpawnBounds { get; set; }
/// <summary>
/// If true, the home location of the spawn is the location where it spawned
/// If false, the home location of the spawn is the location of the spawner
/// </summary>
[SerializableFieldSaveFlag(11)]
private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome;
[InvalidateProperties]
[SerializableField(12)]
[SerializableField(11)]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _spawnLocationIsHome;
[SerializableField(13)]
[SerializableFieldSaveFlag(12)]
private bool ShouldSerializeEnd() => _end != default;
[SerializableField(12)]
[SerializedCommandProperty(AccessLevel.Developer)]
private DateTime _end;
/// <summary>
/// Controls how spawn position optimization is handled.
/// </summary>
[SerializableFieldSaveFlag(13)]
private bool ShouldSerializeSpawnPositionMode() =>
_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned;
[SerializableField(13)]
[SerializedCommandProperty(AccessLevel.Developer)]
private SpawnPositionMode _spawnPositionMode;
private const int DefaultMaxSpawnAttempts = 10;
/// <summary>
/// Maximum number of random position attempts before engaging optimization.
/// </summary>
[SerializableFieldSaveFlag(14)]
private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts;
[SerializableFieldDefault(14)]
private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts;
[SerializableField(14)]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _maxSpawnAttempts;
// Non-serialized: Runtime state for spawn position optimization
private SpawnPositionState _spawnPositionState;
private InternalTimer _timer;
/// <summary>
@ -83,16 +179,16 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
get
{
if (_spawnBounds == default)
if (SpawnBounds == default)
{
return 0;
}
// Distance from spawner location to nearest edge
var distToMinX = Math.Abs(Location.X - _spawnBounds.Start.X);
var distToMaxX = Math.Abs(_spawnBounds.End.X - Location.X);
var distToMinY = Math.Abs(Location.Y - _spawnBounds.Start.Y);
var distToMaxY = Math.Abs(_spawnBounds.End.Y - Location.Y);
var distToMinX = Math.Abs(Location.X - SpawnBounds.Start.X);
var distToMaxX = Math.Abs(SpawnBounds.End.X - Location.X);
var distToMinY = Math.Abs(Location.Y - SpawnBounds.Start.Y);
var distToMaxY = Math.Abs(SpawnBounds.End.Y - Location.Y);
// Return smallest distance to any edge
return Math.Min(Math.Min(distToMinX, distToMaxX), Math.Min(distToMinY, distToMaxY));
@ -105,7 +201,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
: Location.Z;
// Create square bounds centered on spawner, Z range from surface to surface + 16
_spawnBounds = new Rectangle3D(
SpawnBounds = new Rectangle3D(
Location.X - value,
Location.Y - value,
surfaceZ,
@ -128,20 +224,20 @@ public abstract partial class BaseSpawner : Item, ISpawner
/// </summary>
public bool IsHomeRangeStyleAt(Point3D location)
{
if (_spawnBounds == default)
if (SpawnBounds == default)
{
return true; // No bounds = default HomeRange behavior
}
// Must be square
if (_spawnBounds.Width != _spawnBounds.Height)
if (SpawnBounds.Width != SpawnBounds.Height)
{
return false;
}
// Given location must be at center
var centerX = _spawnBounds.Start.X + _spawnBounds.Width / 2;
var centerY = _spawnBounds.Start.Y + _spawnBounds.Height / 2;
var centerX = SpawnBounds.Start.X + SpawnBounds.Width / 2;
var centerY = SpawnBounds.Start.Y + SpawnBounds.Height / 2;
return centerX == location.X && centerY == location.Y;
}
@ -152,7 +248,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
/// </summary>
public virtual bool IsInSpawnBounds(Point3D location)
{
return _spawnBounds == default || _spawnBounds.Contains(location);
return SpawnBounds == default || SpawnBounds.Contains(location);
}
public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10))
@ -198,23 +294,18 @@ public abstract partial class BaseSpawner : Item, ISpawner
}
json.GetProperty("count", options, out int amount);
json.GetProperty("minDelay", options, out TimeSpan minDelay);
json.GetProperty("maxDelay", options, out TimeSpan maxDelay);
json.GetProperty("minDelay", options, DefaultMinDelay, out TimeSpan minDelay);
json.GetProperty("maxDelay", options, DefaultMaxDelay, out TimeSpan maxDelay);
json.GetProperty("team", options, out int team);
json.GetProperty("homeRange", options, out int homeRange);
json.GetProperty("walkingRange", options, out int walkingRange);
_walkingRange = walkingRange;
json.GetProperty("walkingRange", options, out _walkingRange);
// Try new format first
if (json.GetProperty("spawnBounds", options, out Rectangle3D spawnBounds))
{
_spawnBounds = spawnBounds;
}
else if (homeRange > 0 && json.GetProperty("location", options, out Point3D location))
// Handle legacy homeRange format (new spawnBounds format handled by derived classes)
if (homeRange > 0 && json.GetProperty("location", options, out Point3D location))
{
// Fall back to homeRange with location for oldest format
// Note: Map not available during JSON loading, so use location.Z directly
_spawnBounds = new Rectangle3D(
SpawnBounds = new Rectangle3D(
location.X - homeRange,
location.Y - homeRange,
location.Z,
@ -224,10 +315,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
);
}
json.GetProperty("spawnLocationIsHome", options, out bool spawnLocationIsHome);
_spawnLocationIsHome = spawnLocationIsHome;
json.GetProperty("spawnLocationIsHome", options, out _spawnLocationIsHome);
json.GetProperty("spawnPositionMode", options, out _spawnPositionMode);
json.GetProperty("maxSpawnAttempts", options, DefaultMaxSpawnAttempts, out _maxSpawnAttempts);
InitSpawn(amount, minDelay, maxDelay, team, _spawnBounds);
InitSpawn(amount, minDelay, maxDelay, team, SpawnBounds);
json.GetProperty("entries", options, out List<SpawnerEntry> entries);
@ -279,7 +371,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
}
}
[SerializableProperty(11)]
[SerializableProperty(10)]
[CommandProperty(AccessLevel.Developer)]
public bool Running
{
@ -319,6 +411,29 @@ public abstract partial class BaseSpawner : Item, ISpawner
public abstract Region Region { get; }
/// <summary>
/// Returns the bounds to use for a single spawn attempt.
/// Called for each random attempt in Phase 1.
/// Spawner: returns SpawnBounds
/// RegionSpawner: picks a weighted random rectangle from the region
/// </summary>
protected abstract Rectangle3D GetBoundsForSpawnAttempt();
/// <summary>
/// Returns all possible spawn bounds for cache operations.
/// Used in Phase 3 to search for cached positions.
/// Spawner: returns single-element array with SpawnBounds
/// RegionSpawner: returns all region rectangles
/// </summary>
protected abstract ReadOnlySpan<Rectangle3D> GetAllSpawnBounds();
/// <summary>
/// Whether this spawner supports spiral scanning.
/// Only makes sense for contiguous bounds (Spawner).
/// Disjoint rectangles (RegionSpawner) should return false.
/// </summary>
protected virtual bool SupportsSpiralScan => false;
public void Remove(ISpawnable spawn)
{
Defrag();
@ -356,21 +471,260 @@ public abstract partial class BaseSpawner : Item, ISpawner
public virtual void ToJson(DynamicJson json, JsonSerializerOptions options)
{
json.Type = GetType().Name;
json.SetProperty("name", options, Name);
// Always required
json.SetProperty("guid", options, _guid);
json.SetProperty("location", options, Location);
json.SetProperty("map", options, Map);
json.SetProperty("count", options, Count);
json.SetProperty("minDelay", options, MinDelay);
json.SetProperty("maxDelay", options, MaxDelay);
json.SetProperty("team", options, Team);
json.SetProperty("walkingRange", options, WalkingRange);
json.SetProperty("entries", options, Entries);
json.SetProperty("spawnBounds", options, SpawnBounds);
json.SetProperty("spawnLocationIsHome", options, SpawnLocationIsHome);
// Only write if non-default
if (!string.IsNullOrEmpty(Name))
{
json.SetProperty("name", options, Name);
}
if (_minDelay != DefaultMinDelay)
{
json.SetProperty("minDelay", options, MinDelay);
}
if (_maxDelay != DefaultMaxDelay)
{
json.SetProperty("maxDelay", options, MaxDelay);
}
if (_team != 0)
{
json.SetProperty("team", options, Team);
}
if (_walkingRange != 0)
{
json.SetProperty("walkingRange", options, WalkingRange);
}
if (_spawnLocationIsHome)
{
json.SetProperty("spawnLocationIsHome", options, SpawnLocationIsHome);
}
if (_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned)
{
json.SetProperty("spawnPositionMode", options, _spawnPositionMode);
}
if (_maxSpawnAttempts != DefaultMaxSpawnAttempts)
{
json.SetProperty("maxSpawnAttempts", options, _maxSpawnAttempts);
}
}
public abstract Point3D GetSpawnPosition(ISpawnable spawned, Map map);
public virtual Point3D GetSpawnPosition(ISpawnable spawned, Map map)
{
// Abandoned spawners skip all work
if (map == null || map == Map.Internal || _spawnPositionMode == SpawnPositionMode.Abandoned)
{
return Location;
}
// Ensure state is initialized
_spawnPositionState ??= new SpawnPositionState();
// Determine mob type for caching
var isMobile = spawned is Mobile;
var canSwim = isMobile && ((Mobile)spawned).CanSwim;
var cantWalk = isMobile && ((Mobile)spawned).CantWalk;
var isWaterMob = canSwim && cantWalk;
var hasNonTransientFailure = false;
// Phase 1: Random attempts (always first - maintains randomness)
var maxAttempts = _maxSpawnAttempts > 0 ? _maxSpawnAttempts : DefaultMaxSpawnAttempts;
for (var i = 0; i < maxAttempts; i++)
{
var bounds = GetBoundsForSpawnAttempt();
// No bounds = spawn at spawner location
if (bounds == default)
{
return Location;
}
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;
bool success;
int spawnZ;
SpawnFailureReason failureReason;
if (isMobile)
{
success = map.CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out spawnZ, out failureReason);
}
else
{
success = map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ);
failureReason = success ? SpawnFailureReason.None : SpawnFailureReason.NonTransientBlocker;
}
if (success)
{
// Check if blocked by a private house (non-transient)
if (isMobile && SectorSpawnCacheManager.IsBlockedByHouse(map, x, y, spawnZ))
{
hasNonTransientFailure = true;
}
else
{
// Lazy cache: add successful position to global sector cache
if (_spawnPositionState.ShouldCachePositions(_spawnPositionMode))
{
SectorSpawnCacheManager.SetValid(map, new Point3D(x, y, spawnZ), isWaterMob);
}
return new Point3D(x, y, spawnZ);
}
}
else if ((failureReason & SpawnFailureReason.NonTransientBlocker) != 0)
{
hasNonTransientFailure = true;
}
}
// Phase 2: Check if optimization should engage
if (_spawnPositionMode == SpawnPositionMode.Disabled)
{
return Location;
}
var useOptimization = _spawnPositionMode == SpawnPositionMode.Enabled
|| _spawnPositionMode == SpawnPositionMode.Automatic && hasNonTransientFailure;
if (!useOptimization)
{
// Transient failure only - just use spawner location
return Location;
}
_spawnPositionState.RecordNonTransientFailure();
var allBounds = GetAllSpawnBounds();
// Phase 3: Continue spiral scan to populate cache (before selecting from it)
if (!SupportsSpiralScan)
{
// Mark spiral complete for non-spiral spawners so ShouldAbandon() can trigger
_spawnPositionState.SpiralComplete = true;
}
else if (!_spawnPositionState.SpiralComplete)
{
// Use the first bounds for spiral center/range
var primaryBounds = allBounds.Length > 0 ? allBounds[0] : default;
if (primaryBounds != default)
{
var minZ = primaryBounds.Start.Z;
var maxZ = primaryBounds.End.Z - 1;
// Scan more rings initially (3), fewer once cache has positions (1)
var ringsPerTick = _spawnPositionState.SpiralRing == 0 ? 3 : 1;
_spawnPositionState.SpiralComplete = SectorSpawnCacheManager.ContinueSpiralScan(
map,
Location,
primaryBounds,
minZ,
maxZ,
canSwim,
cantWalk,
ref _spawnPositionState.SpiralRing,
ref _spawnPositionState.SpiralRingPosition,
ringsPerTick
);
}
}
// Phase 4: Try cached positions from global sector cache (uses deduplicated sectors)
if (TryGetVerifiedCachedPosition(map, allBounds, isMobile, isWaterMob, canSwim, cantWalk, out var spawnPos))
{
// Check if cache returned a useful position (not just spawner's own location)
if (spawnPos != Location)
{
_spawnPositionState.RecordUsefulCacheHit();
return spawnPos;
}
}
// Cache miss or returned Location only
_spawnPositionState.RecordUselessResult();
// Phase 5: Check for abandoned state (only auto-abandon from Automatic mode)
if (_spawnPositionMode == SpawnPositionMode.Automatic && _spawnPositionState.ShouldAbandon())
{
SpawnPositionMode = SpawnPositionMode.Abandoned;
_spawnPositionState = null;
logger.Warning(
"Spawner {Serial} at {Location} ({Map}) marked abandoned - no valid spawn positions found after spiral scan.",
Serial,
Location,
map.Name ?? "null"
);
}
return Location;
}
/// <summary>
/// Attempts to get a verified spawn position from the sector cache across all bounds.
/// Uses deduplicated sector lookup for uniform distribution.
/// </summary>
private static bool TryGetVerifiedCachedPosition(
Map map,
ReadOnlySpan<Rectangle3D> allBounds,
bool isMobile,
bool isWaterMob,
bool canSwim,
bool cantWalk,
out Point3D spawnPos)
{
if (!SectorSpawnCacheManager.TryGetRandomPosition(
map,
allBounds,
isWaterMob,
out var cachedPos,
out var containingBounds
))
{
spawnPos = default;
return false;
}
// Re-verify in 3D using the bounds that contains this position
var minZ = containingBounds.Start.Z;
var maxZ = containingBounds.End.Z - 1;
var verified = isMobile
? map.CanSpawnMobile(cachedPos.X, cachedPos.Y, minZ, maxZ, canSwim, cantWalk, out var verifiedZ)
: map.CanSpawnItem(cachedPos.X, cachedPos.Y, minZ, maxZ, out verifiedZ);
if (!verified)
{
spawnPos = default;
return false;
}
// Skip positions inside private houses
if (isMobile && SectorSpawnCacheManager.IsBlockedByHouse(map, cachedPos.X, cachedPos.Y, verifiedZ))
{
spawnPos = default;
return false;
}
spawnPos = new Point3D(cachedPos.X, cachedPos.Y, verifiedZ);
return true;
}
public override void OnAfterDuped(Item newItem)
{
@ -402,7 +756,25 @@ public abstract partial class BaseSpawner : Item, ISpawner
// Recalculate HomeRange-style bounds when spawner moves
if (IsHomeRangeStyleAt(oldLocation))
{
HomeRange = _spawnBounds.Width / 2;
HomeRange = SpawnBounds.Width / 2;
}
// Reset spawn position optimization state when spawner moves
ResetSpawnPositionState();
}
/// <summary>
/// Resets the spawn position optimization state.
/// Called when spawner moves or bounds change.
/// </summary>
protected void ResetSpawnPositionState()
{
_spawnPositionState?.Reset();
// If we were abandoned, reset to automatic to give it another chance
if (_spawnPositionMode == SpawnPositionMode.Abandoned)
{
_spawnPositionMode = SpawnPositionMode.Automatic;
}
}
@ -438,7 +810,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
if (spawnBounds != default)
{
_spawnBounds = spawnBounds;
SpawnBounds = spawnBounds;
}
else
{
@ -1027,7 +1399,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
if (_pendingHomeRangeMigrations.Remove(this, out var homeRange))
{
var surfaceZ = Map?.GetTopSurfaceZ(Location) ?? Location.Z;
_spawnBounds = new Rectangle3D(
SpawnBounds = new Rectangle3D(
Location.X - homeRange,
Location.Y - homeRange,
surfaceZ,