/*************************************************************************
* 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 . *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Server.Collections;
using Server.Regions;
namespace Server.Engines.Spawners;
///
/// 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.
///
public static class SectorSpawnCacheManager
{
private static readonly Dictionary<(Map, int, int), BitMask256> _landCaches = [];
private static readonly Dictionary<(Map, int, int), BitMask256> _waterCaches = [];
///
/// Marks a position as valid for spawning in the global cache.
///
/// The map containing the position
/// The valid spawn position
/// True for water mob, false for land mob
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);
}
///
/// Attempts to get a random valid position from cached sectors within the specified bounds.
///
/// The map to search
/// The spawn bounds to search within
/// True for water mob, false for land mob
/// The selected position (X, Y only - caller must verify Z)
/// True if a cached position was found
public static bool TryGetRandomPosition(Map map, Rectangle3D bounds, bool isWater, out Point2D pos)
{
ReadOnlySpan singleBounds = [bounds];
return TryGetRandomPosition(map, singleBounds, isWater, out pos, out _);
}
///
/// Attempts to get a random valid position from cached sectors across multiple bounds.
/// Deduplicates overlapping sectors for uniform distribution.
///
/// The map to search
/// All spawn bounds to search within
/// True for water mob, false for land mob
/// The selected position (X, Y only - caller must verify Z)
/// The bounds rectangle containing the selected position
/// Maximum retries if selected position is outside bounds
/// True if a cached position was found
public static bool TryGetRandomPosition(
Map map,
ReadOnlySpan 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;
}
///
/// Checks if a position is blocked by a private house.
/// Public AoS houses (with unlocked doors) allow spawning.
///
/// The map to check
/// X coordinate
/// Y coordinate
/// Z coordinate
/// True if blocked by a private house
[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;
}
///
/// Invalidates all cached data for sectors within the specified bounds.
/// Called when houses are placed or demolished.
///
/// The map to invalidate
/// The affected area
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);
}
}
}
///
/// Performs incremental spiral scanning to find and cache valid spawn positions.
///
/// The map to scan
/// The center point to spiral from
/// The spawn bounds to stay within
/// Minimum Z for spawn checks
/// Maximum Z for spawn checks
/// Whether to find water positions
/// Whether the mob can't walk (water-only)
/// Current ring being scanned (updated on return)
/// Position within current ring (updated on return)
/// Number of rings to scan per call
/// True if scan is complete (exhausted bounds)
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);
}
}
///
/// 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
///
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)
};
}
///
/// Clears all cached data. Used for testing or server restart.
///
public static void ClearAll()
{
_landCaches.Clear();
_waterCaches.Clear();
}
///
/// Gets the number of sectors currently cached (land + water).
///
public static int CachedSectorCount => _landCaches.Count + _waterCaches.Count;
///
/// Gets the number of land sectors currently cached.
///
public static int LandCacheCount => _landCaches.Count;
///
/// Gets the number of water sectors currently cached.
///
public static int WaterCacheCount => _waterCaches.Count;
}