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

@ -1199,7 +1199,7 @@ namespace Server.Commands.Generic
{
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} {(m_Ban ? "banning" : "kicking")} {(CommandLogging.Format(targ))}"
$"{from.AccessLevel} {CommandLogging.Format(from)} {(m_Ban ? "banning" : "kicking")} {CommandLogging.Format(targ)}"
);
targ.Say(m_Ban ? "I've been banned." : "I've been kicked");

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Text;
using Server.Commands.Generic;

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using ModernUO.CodeGeneratedEvents;
using Server.Mobiles;

View file

@ -1,7 +1,6 @@
using System;
using ModernUO.CodeGeneratedEvents;
using ModernUO.Serialization;
using Server.Items;
using Server.Misc;
using Server.Mobiles;

View file

@ -31,6 +31,38 @@ public abstract partial class BaseSpawner
}
_spawnLocationIsHome = false;
// New v12 fields default to automatic/default
_spawnPositionMode = SpawnPositionMode.Automatic;
_maxSpawnAttempts = DefaultMaxSpawnAttempts;
}
private void MigrateFrom(V11Content content)
{
_guid = content.Guid;
_returnOnDeactivate = content.ReturnOnDeactivate;
_entries = content.Entries;
_walkingRange = content.WalkingRange;
_wayPoint = content.WayPoint;
_group = content.Group;
_minDelay = content.MinDelay;
_maxDelay = content.MaxDelay;
_count = content.Count;
_team = content.Team;
// Moved to Spawner
if (this is Spawner spawner)
{
spawner.SpawnBounds = content.SpawnBounds;
}
_running = content.Running;
_spawnLocationIsHome = content.SpawnLocationIsHome;
_end = _running ? content.End : Core.Now;
// New v12 fields default to automatic/default
_spawnPositionMode = SpawnPositionMode.Automatic;
_maxSpawnAttempts = DefaultMaxSpawnAttempts;
}
private void Deserialize(IGenericReader reader, int version)

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,

View file

@ -1,6 +1,6 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2023 - ModernUO Development Team *
* Copyright 2019-2025 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: RegionSpawner.cs *
* *
@ -74,6 +74,48 @@ public partial class RegionSpawner : Spawner
}
}
// RegionSpawner does not support spiral scan (disjoint rectangles make it ineffective)
protected override bool SupportsSpiralScan => false;
protected override Rectangle3D GetBoundsForSpawnAttempt()
{
if (_spawnRegion == null || _spawnRegion.TotalWeight <= 0)
{
return default;
}
// Pick a weighted random rectangle from the region
var rand = Utility.Random(_spawnRegion.TotalWeight);
for (var j = 0; j < _spawnRegion.RectangleWeights.Length; j++)
{
var curWeight = _spawnRegion.RectangleWeights[j];
if (rand < curWeight)
{
return _spawnRegion.Rectangles[j];
}
rand -= curWeight;
}
return default;
}
protected override ReadOnlySpan<Rectangle3D> GetAllSpawnBounds() => _spawnRegion?.Rectangles;
public override Point3D GetSpawnPosition(ISpawnable spawned, Map map)
{
// Check for region/map mismatch before delegating to base
if (_spawnRegion == null || map == null || map == Map.Internal ||
map != _spawnRegion.Map || _spawnRegion.TotalWeight <= 0)
{
return Location;
}
return base.GetSpawnPosition(spawned, map);
}
public override void ToJson(DynamicJson json, JsonSerializerOptions options)
{
base.ToJson(json, options);
@ -90,55 +132,6 @@ public partial class RegionSpawner : Spawner
}
}
public override Point3D GetSpawnPosition(ISpawnable spawned, Map map)
{
if (_spawnRegion == null || map == null || map == Map.Internal || map != _spawnRegion.Map ||
_spawnRegion.TotalWeight <= 0)
{
return Location;
}
// Try 10 times to find a valid location.
for (var i = 0; i < 10; i++)
{
var rand = Utility.Random(_spawnRegion.TotalWeight);
var x = int.MinValue;
var y = int.MinValue;
var minZ = (int)sbyte.MinValue;
var maxZ = (int)sbyte.MaxValue;
for (var j = 0; j < _spawnRegion.RectangleWeights.Length; j++)
{
var curWeight = _spawnRegion.RectangleWeights[j];
if (rand < curWeight)
{
var rect = _spawnRegion.Rectangles[j];
x = rect.Start.X + rand % rect.Width;
y = rect.Start.Y + rand / rect.Width;
// Use rectangle's Z range for multi-floor region support
minZ = rect.Start.Z;
maxZ = rect.End.Z - 1;
break;
}
rand -= curWeight;
}
if (spawned is Mobile mob && map.CanSpawnMobile(x, y, minZ, maxZ, mob.CanSwim, mob.CantWalk, out var spawnZ)
|| spawned is Item && map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ))
{
return new Point3D(x, y, spawnZ);
}
}
return Location;
}
[AfterDeserialization(false)]
private void AfterDeserialization()
{

View file

@ -0,0 +1,493 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2025 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: SectorSpawnCache.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics.X86;
using Server.Collections;
using Server.Regions;
namespace Server.Engines.Spawners;
/// <summary>
/// Cached spawn position data for a 16x16 sector.
/// Uses a bitmap to track valid spawn positions (256 bits = 4 ulongs = 32 bytes).
/// </summary>
public struct SectorSpawnCache
{
public ulong Bits0;
public ulong Bits1;
public ulong Bits2;
public ulong Bits3;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly int GetCount() =>
BitOperations.PopCount(Bits0) +
BitOperations.PopCount(Bits1) +
BitOperations.PopCount(Bits2) +
BitOperations.PopCount(Bits3);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetBit(int bitIndex)
{
var ulongIndex = bitIndex >> 6; // / 64
var bitPosition = bitIndex & 0x3F; // % 64
switch (ulongIndex)
{
case 0: Bits0 |= 1UL << bitPosition; break;
case 1: Bits1 |= 1UL << bitPosition; break;
case 2: Bits2 |= 1UL << bitPosition; break;
case 3: Bits3 |= 1UL << bitPosition; break;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool GetBit(int bitIndex)
{
var ulongIndex = bitIndex >> 6;
var bitPosition = bitIndex & 0x3F;
return ulongIndex switch
{
0 => (Bits0 & (1UL << bitPosition)) != 0,
1 => (Bits1 & (1UL << bitPosition)) != 0,
2 => (Bits2 & (1UL << bitPosition)) != 0,
3 => (Bits3 & (1UL << bitPosition)) != 0,
_ => false
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ClearBit(int bitIndex)
{
var ulongIndex = bitIndex >> 6;
var bitPosition = bitIndex & 0x3F;
switch (ulongIndex)
{
case 0: Bits0 &= ~(1UL << bitPosition); break;
case 1: Bits1 &= ~(1UL << bitPosition); break;
case 2: Bits2 &= ~(1UL << bitPosition); break;
case 3: Bits3 &= ~(1UL << bitPosition); break;
}
}
/// <summary>
/// Gets the Nth set bit position (0-indexed) from the bitmap.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly int GetNthBitPosition(int n)
{
var count0 = BitOperations.PopCount(Bits0);
if (n < count0)
{
return GetNthBitInUlong(Bits0, n);
}
n -= count0;
var count1 = BitOperations.PopCount(Bits1);
if (n < count1)
{
return 64 + GetNthBitInUlong(Bits1, n);
}
n -= count1;
var count2 = BitOperations.PopCount(Bits2);
if (n < count2)
{
return 128 + GetNthBitInUlong(Bits2, n);
}
n -= count2;
return 192 + GetNthBitInUlong(Bits3, n);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int GetNthBitInUlong(ulong bits, int n)
{
// BMI2 PDEP: O(1) - deposits the nth selector bit into the position of the nth set bit
if (Bmi2.X64.IsSupported)
{
var deposited = Bmi2.X64.ParallelBitDeposit(1UL << n, bits);
return BitOperations.TrailingZeroCount(deposited);
}
// Fallback: O(popcount) - clear n set bits, then find position of next one
while (n > 0 && bits != 0)
{
bits &= bits - 1; // Clear lowest set bit
n--;
}
return bits == 0 ? -1 : BitOperations.TrailingZeroCount(bits);
}
}
/// <summary>
/// Global manager for sector-based spawn position caching.
/// Shared across all spawners for efficient memory usage and house invalidation.
/// Uses separate caches for land and water to minimize memory usage since most
/// sectors are either all land or all water.
/// </summary>
public static class SectorSpawnCacheManager
{
private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _landCaches = [];
private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _waterCaches = [];
/// <summary>
/// Marks a position as valid for spawning in the global cache.
/// </summary>
/// <param name="map">The map containing the position</param>
/// <param name="pos">The valid spawn position</param>
/// <param name="isWater">True for water mob, false for land mob</param>
public static void SetValid(Map map, Point3D pos, bool isWater)
{
var sectorX = pos.X >> Map.SectorShift;
var sectorY = pos.Y >> Map.SectorShift;
var bitIndex = (pos.X & (Map.SectorSize - 1)) + ((pos.Y & (Map.SectorSize - 1)) << Map.SectorShift);
var caches = isWater ? _waterCaches : _landCaches;
ref var cache = ref CollectionsMarshal.GetValueRefOrAddDefault(caches, (map, sectorX, sectorY), out _);
cache.SetBit(bitIndex);
}
/// <summary>
/// Attempts to get a random valid position from cached sectors within the specified bounds.
/// </summary>
/// <param name="map">The map to search</param>
/// <param name="bounds">The spawn bounds to search within</param>
/// <param name="isWater">True for water mob, false for land mob</param>
/// <param name="pos">The selected position (X, Y only - caller must verify Z)</param>
/// <returns>True if a cached position was found</returns>
public static bool TryGetRandomPosition(Map map, Rectangle3D bounds, bool isWater, out Point2D pos)
{
ReadOnlySpan<Rectangle3D> singleBounds = [bounds];
return TryGetRandomPosition(map, singleBounds, isWater, out pos, out _);
}
/// <summary>
/// Attempts to get a random valid position from cached sectors across multiple bounds.
/// Deduplicates overlapping sectors for uniform distribution.
/// </summary>
/// <param name="map">The map to search</param>
/// <param name="allBounds">All spawn bounds to search within</param>
/// <param name="isWater">True for water mob, false for land mob</param>
/// <param name="pos">The selected position (X, Y only - caller must verify Z)</param>
/// <param name="containingBounds">The bounds rectangle containing the selected position</param>
/// <param name="maxRetries">Maximum retries if selected position is outside bounds</param>
/// <returns>True if a cached position was found</returns>
public static bool TryGetRandomPosition(
Map map,
ReadOnlySpan<Rectangle3D> allBounds,
bool isWater,
out Point2D pos,
out Rectangle3D containingBounds,
int maxRetries = 5)
{
pos = Point2D.Zero;
containingBounds = default;
if (allBounds.Length == 0)
{
return false;
}
var caches = isWater ? _waterCaches : _landCaches;
// Collect unique sectors and their counts
using var sectorList = PooledRefList<(int sx, int sy, int count)>.Create();
var totalPositions = 0;
for (var i = 0; i < allBounds.Length; i++)
{
var bounds = allBounds[i];
var startSectorX = bounds.Start.X >> Map.SectorShift;
var startSectorY = bounds.Start.Y >> Map.SectorShift;
var endSectorX = (bounds.End.X - 1) >> Map.SectorShift;
var endSectorY = (bounds.End.Y - 1) >> Map.SectorShift;
for (var sx = startSectorX; sx <= endSectorX; sx++)
{
for (var sy = startSectorY; sy <= endSectorY; sy++)
{
// Check if we've already added this sector
var alreadyAdded = false;
for (var j = 0; j < sectorList.Count; j++)
{
var (checkSX, checkSY, _) = sectorList[j];
if (checkSX == sx && checkSY == sy)
{
alreadyAdded = true;
break;
}
}
if (alreadyAdded)
{
continue;
}
if (caches.TryGetValue((map, sx, sy), out var cache))
{
var count = cache.GetCount();
if (count > 0)
{
sectorList.Add((sx, sy, count));
totalPositions += count;
}
}
}
}
}
if (totalPositions == 0)
{
return false;
}
// Retry loop for when selected position is outside all bounds
for (var attempt = 0; attempt <= maxRetries; attempt++)
{
// Pick a random position
var targetIndex = Utility.Random(totalPositions);
// Find the sector containing that index
var currentIndex = 0;
for (var i = 0; i < sectorList.Count; i++)
{
var (sx, sy, sectorCount) = sectorList[i];
if (targetIndex < currentIndex + sectorCount)
{
// Target is in this sector - look up cache again
if (!caches.TryGetValue((map, sx, sy), out var cache))
{
break; // Should not happen, try again
}
var indexInSector = targetIndex - currentIndex;
var bitPosition = cache.GetNthBitPosition(indexInSector);
var localX = bitPosition & (Map.SectorSize - 1);
var localY = bitPosition >> Map.SectorShift;
pos = new Point2D((sx << Map.SectorShift) + localX, (sy << Map.SectorShift) + localY);
// Find which bounds contains this position
for (var j = 0; j < allBounds.Length; j++)
{
var bounds = allBounds[j];
if (pos.X >= bounds.Start.X && pos.X < bounds.End.X &&
pos.Y >= bounds.Start.Y && pos.Y < bounds.End.Y)
{
containingBounds = bounds;
return true;
}
}
// Position outside all bounds - retry
break;
}
currentIndex += sectorCount;
}
}
return false;
}
/// <summary>
/// Checks if a position is blocked by a private house.
/// Public AoS houses (with unlocked doors) allow spawning.
/// </summary>
/// <param name="map">The map to check</param>
/// <param name="x">X coordinate</param>
/// <param name="y">Y coordinate</param>
/// <param name="z">Z coordinate</param>
/// <returns>True if blocked by a private house</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsBlockedByHouse(Map map, int x, int y, int z)
{
if (Region.Find(new Point3D(x, y, z), map) is HouseRegion houseRegion)
{
var house = houseRegion.House;
// Allow spawning in public AoS houses (unlocked doors, free entry)
return !(house.IsAosRules && house.Public);
}
return false;
}
/// <summary>
/// Invalidates all cached data for sectors within the specified bounds.
/// Called when houses are placed or demolished.
/// </summary>
/// <param name="map">The map to invalidate</param>
/// <param name="bounds">The affected area</param>
public static void InvalidateSectors(Map map, Rectangle2D bounds)
{
var startSectorX = bounds.Start.X >> Map.SectorShift;
var startSectorY = bounds.Start.Y >> Map.SectorShift;
var endSectorX = bounds.End.X >> Map.SectorShift;
var endSectorY = bounds.End.Y >> Map.SectorShift;
for (var sx = startSectorX; sx <= endSectorX; sx++)
{
for (var sy = startSectorY; sy <= endSectorY; sy++)
{
var key = (map, sx, sy);
_landCaches.Remove(key);
_waterCaches.Remove(key);
}
}
}
/// <summary>
/// Performs incremental spiral scanning to find and cache valid spawn positions.
/// </summary>
/// <param name="map">The map to scan</param>
/// <param name="center">The center point to spiral from</param>
/// <param name="bounds">The spawn bounds to stay within</param>
/// <param name="minZ">Minimum Z for spawn checks</param>
/// <param name="maxZ">Maximum Z for spawn checks</param>
/// <param name="canSwim">Whether to find water positions</param>
/// <param name="cantWalk">Whether the mob can't walk (water-only)</param>
/// <param name="currentRing">Current ring being scanned (updated on return)</param>
/// <param name="ringPosition">Position within current ring (updated on return)</param>
/// <param name="ringsPerTick">Number of rings to scan per call</param>
/// <returns>True if scan is complete (exhausted bounds)</returns>
public static bool ContinueSpiralScan(
Map map,
Point3D center,
Rectangle3D bounds,
int minZ,
int maxZ,
bool canSwim,
bool cantWalk,
ref int currentRing,
ref int ringPosition,
int ringsPerTick = 3)
{
var maxRing = Math.Max(bounds.Width, bounds.Height) / 2 + 1;
var ringsToScan = Math.Min(currentRing + ringsPerTick, maxRing);
while (currentRing < ringsToScan)
{
// Ring 0 is just the center point
if (currentRing == 0)
{
CheckAndCachePosition(map, center.X, center.Y, minZ, maxZ, bounds, canSwim, cantWalk);
}
else
{
// Ring N has 8*N positions
var positionsInRing = currentRing * 8;
for (var p = 0; p < positionsInRing; p++)
{
var (dx, dy) = GetSpiralOffset(currentRing, p);
var x = center.X + dx;
var y = center.Y + dy;
CheckAndCachePosition(map, x, y, minZ, maxZ, bounds, canSwim, cantWalk);
}
}
currentRing++;
}
ringPosition = 0;
return currentRing >= maxRing;
}
private static void CheckAndCachePosition(
Map map,
int x, int y,
int minZ, int maxZ,
Rectangle3D bounds,
bool canSwim,
bool cantWalk)
{
// Check bounds
if (x < bounds.Start.X || x >= bounds.End.X ||
y < bounds.Start.Y || y >= bounds.End.Y)
{
return;
}
// Check if position is valid for spawning
if (map.CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out var spawnZ))
{
// Skip positions inside private houses
if (IsBlockedByHouse(map, x, y, spawnZ))
{
return;
}
var pos = new Point3D(x, y, spawnZ);
var isWater = canSwim && cantWalk;
SetValid(map, pos, isWater);
}
}
/// <summary>
/// Gets the X,Y offset for a position in a spiral ring.
/// Ring 0 = center (no offset)
/// Ring 1 = 8 positions around center
/// Ring N = 8*N positions
/// </summary>
public static (int dx, int dy) GetSpiralOffset(int ring, int position)
{
// Each ring has 4 sides, each side has ring*2 positions
var sideLength = ring * 2;
var side = position / sideLength;
var sidePos = position % sideLength;
return side switch
{
0 => (-ring + sidePos, -ring), // Top edge, left to right
1 => (ring, -ring + sidePos), // Right edge, top to bottom
2 => (ring - sidePos, ring), // Bottom edge, right to left
3 => (-ring, ring - sidePos), // Left edge, bottom to top
_ => (0, 0)
};
}
/// <summary>
/// Clears all cached data. Used for testing or server restart.
/// </summary>
public static void ClearAll()
{
_landCaches.Clear();
_waterCaches.Clear();
}
/// <summary>
/// Gets the number of sectors currently cached (land + water).
/// </summary>
public static int CachedSectorCount => _landCaches.Count + _waterCaches.Count;
/// <summary>
/// Gets the number of land sectors currently cached.
/// </summary>
public static int LandCacheCount => _landCaches.Count;
/// <summary>
/// Gets the number of water sectors currently cached.
/// </summary>
public static int WaterCacheCount => _waterCaches.Count;
}

View file

@ -0,0 +1,80 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2025 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: SpawnPositionState.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
namespace Server.Engines.Spawners;
/// <summary>
/// Runtime state for spawn position optimization.
/// Not serialized - resets on server restart.
/// </summary>
public sealed class SpawnPositionState
{
// Tunable thresholds for auto-detection
private const int FailureThreshold = 5;
private const int AbandonThreshold = 25;
// Failure tracking for auto-detection
private int _nonTransientFailures;
// Abandon tracking - counts consecutive cache misses or Location-only results
private int _consecutiveUselessResults;
// Spiral scan state
public int SpiralRing;
public int SpiralRingPosition;
public bool SpiralComplete;
/// <summary>
/// Resets all state. Called when spawner moves or bounds change.
/// </summary>
public void Reset()
{
_nonTransientFailures = 0;
_consecutiveUselessResults = 0;
SpiralRing = 0;
SpiralRingPosition = 0;
SpiralComplete = false;
}
/// <summary>
/// Records a useful cache hit (position other than spawner's own location).
/// Resets the abandon counter.
/// </summary>
public void RecordUsefulCacheHit() => _consecutiveUselessResults = 0;
/// <summary>
/// Records a useless result (cache miss or cache returned spawner's location).
/// Counts toward abandonment threshold.
/// </summary>
public void RecordUselessResult() => _consecutiveUselessResults++;
/// <summary>
/// Records a non-transient spawn failure for auto-detection.
/// </summary>
public void RecordNonTransientFailure() => _nonTransientFailures++;
/// <summary>
/// Returns true if the spawner should cache successful positions.
/// </summary>
public bool ShouldCachePositions(SpawnPositionMode mode) =>
mode == SpawnPositionMode.Enabled || mode == SpawnPositionMode.Automatic && _nonTransientFailures > FailureThreshold;
/// <summary>
/// Returns true if the spawner should be marked as abandoned.
/// Triggers after spiral completes, and we get consecutive useless results
/// (cache misses or cache only returning spawner's own location).
/// </summary>
public bool ShouldAbandon() => SpiralComplete && _consecutiveUselessResults >= AbandonThreshold;
}

View file

@ -5,9 +5,35 @@ using Server.Json;
namespace Server.Engines.Spawners;
[SerializationGenerator(0)]
[SerializationGenerator(1)]
public partial class Spawner : BaseSpawner
{
/// <summary>
/// When true, enables proactive spiral scanning to find valid spawn positions.
/// Only relevant when SpawnPositionMode is Automatic or Enabled.
/// </summary>
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeUseSpiralScan() => _useSpiralScan;
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _useSpiralScan;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeSpawnBounds() => _spawnBounds != default;
[SerializableProperty(1)]
public override Rectangle3D SpawnBounds
{
get => _spawnBounds;
set
{
_spawnBounds = value;
InvalidateProperties();
this.MarkDirty();
}
}
[Constructible(AccessLevel.Developer)]
public Spawner()
{
@ -32,57 +58,33 @@ public partial class Spawner : BaseSpawner
public Spawner(DynamicJson json, JsonSerializerOptions options) : base(json, options)
{
// Read spawnBounds (not in BaseSpawner to allow RegionSpawner to skip it)
if (json.GetProperty("spawnBounds", options, out Rectangle3D spawnBounds))
{
SpawnBounds = spawnBounds;
}
}
/*
public override bool OnDefragSpawn(ISpawnable spawned, bool remove)
public override void ToJson(DynamicJson json, JsonSerializerOptions options)
{
// To despawn a mob that was lured 4x away from its spawner
// TODO: Move this to a config
if (spawned is BaseCreature c && c.Combatant == null && c.GetDistanceToSqrt( Location ) > c.RangeHome * 4)
{
c.Delete();
remove = true;
}
base.ToJson(json, options);
return base.OnDefragSpawn(entry, spawned, remove);
if (SpawnBounds != default)
{
json.SetProperty("spawnBounds", options, SpawnBounds);
}
}
*/
public override Region Region => Region.Find(Location, Map);
public override Point3D GetSpawnPosition(ISpawnable spawned, Map map)
protected override bool SupportsSpiralScan => _useSpiralScan;
protected override Rectangle3D GetBoundsForSpawnAttempt() => SpawnBounds;
protected override ReadOnlySpan<Rectangle3D> GetAllSpawnBounds() => new(ref _spawnBounds);
private void MigrateFrom(V0Content content)
{
if (map == null || map == Map.Internal)
{
return Location;
}
var bounds = SpawnBounds;
// No bounds = HomeRange of 0, spawn at spawner location
if (bounds == default)
{
return Location;
}
// Z range from SpawnBounds (supports multi-story buildings)
var minZ = bounds.Start.Z;
var maxZ = bounds.End.Z - 1;
// Try 10 times to find a valid location.
for (var i = 0; i < 10; i++)
{
var x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1);
var y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1);
if (spawned is Mobile mob && map.CanSpawnMobile(x, y, minZ, maxZ, mob.CanSwim, mob.CantWalk, out var spawnZ)
|| spawned is Item && map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ))
{
return new Point3D(x, y, spawnZ);
}
}
return Location;
// V0 had no fields in Spawner, new v1 field _useSpiralScan defaults to false
}
}

View file

@ -112,7 +112,7 @@ public class VirtueGump : Gump
private int GetHueFor(int index)
{
var value = VirtueSystem.GetVirtues((_beheld))?.GetValue(index) ?? 0;
var value = VirtueSystem.GetVirtues(_beheld)?.GetValue(index) ?? 0;
if (value < 4000)
{

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Network;

View file

@ -0,0 +1,123 @@
{
"version": 12,
"type": "Server.Engines.Spawners.BaseSpawner",
"properties": [
{
"name": "Guid",
"type": "System.Guid",
"rule": "PrimitiveTypeMigrationRule"
},
{
"name": "ReturnOnDeactivate",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Entries",
"type": "System.Collections.Generic.List\u003CServer.Engines.Spawners.SpawnerEntry\u003E",
"rule": "ListMigrationRule",
"ruleArguments": [
"Server.Engines.Spawners.SpawnerEntry",
"RawSerializableMigrationRule",
"DeserializationRequiresParent"
]
},
{
"name": "WalkingRange",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "WayPoint",
"type": "Server.Items.WayPoint",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "Group",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "MinDelay",
"type": "System.TimeSpan",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule"
},
{
"name": "MaxDelay",
"type": "System.TimeSpan",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule"
},
{
"name": "Count",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Team",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Running",
"type": "bool",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "SpawnLocationIsHome",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "End",
"type": "System.DateTime",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "SpawnPositionMode",
"type": "Server.Engines.Spawners.SpawnPositionMode",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "MaxSpawnAttempts",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
}
]
}

View file

@ -0,0 +1,24 @@
{
"version": 1,
"type": "Server.Engines.Spawners.Spawner",
"properties": [
{
"name": "UseSpiralScan",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "SpawnBounds",
"type": "Server.Rectangle3D",
"usesSaveFlag": true,
"rule": "PrimitiveUOTypeMigrationRule",
"ruleArguments": [
"Rect3D"
]
}
]
}

View file

@ -13,8 +13,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
************************************************************************/
using Server.Engines.Spawners;
namespace Server.Mobiles;
public abstract partial class BaseAI

View file

@ -4,6 +4,7 @@ using ModernUO.CodeGeneratedEvents;
using Server.Accounting;
using Server.Collections;
using Server.ContextMenus;
using Server.Engines.Spawners;
using Server.Ethics;
using Server.Guilds;
using Server.Gumps;
@ -1561,6 +1562,19 @@ namespace Server.Multis
UpdateRegion();
// Invalidate spawn position cache for affected sectors
if (Map != null && Map != Map.Internal)
{
var mcl = Components;
var bounds = new Rectangle2D(
X + mcl.Min.X,
Y + mcl.Min.Y,
mcl.Width,
mcl.Height
);
SectorSpawnCacheManager.InvalidateSectors(Map, bounds);
}
if (Sign?.Deleted == false)
{
Sign.Map = Map;
@ -3329,6 +3343,19 @@ namespace Server.Multis
{
RestoreRelocatedEntities();
// Invalidate spawn position cache for affected sectors before deletion
if (Map != null && Map != Map.Internal)
{
var mcl = Components;
var bounds = new Rectangle2D(
X + mcl.Min.X,
Y + mcl.Min.Y,
mcl.Width,
mcl.Height
);
SectorSpawnCacheManager.InvalidateSectors(Map, bounds);
}
new FixColumnTimer(this).Start();
base.OnDelete();

View file

@ -13,7 +13,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

View file

@ -39,7 +39,7 @@ namespace Server.Spells.Fifth
if (Core.AOS)
{
var secs = (GetDamageSkill(Caster) / 10 - GetResistSkill(m) / 10);
var secs = GetDamageSkill(Caster) / 10 - GetResistSkill(m) / 10;
if (!Core.AOS)
{