ModernUO/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs
2026-03-05 19:36:54 -08:00

376 lines
14 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - 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.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Server.Collections;
using Server.Regions;
namespace Server.Engines.Spawners;
/// <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), BitMask256> _landCaches = [];
private static readonly Dictionary<(Map, int, int), BitMask256> _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.PopCount();
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.GetNthSetBit(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;
}