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:
parent
6d51b33cf8
commit
3e8d548f38
23 changed files with 1938 additions and 153 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue