PoliceSpawns.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

// Shared spawn-point picking for cops. Used by both MissionSpawner (new
// cops) and CopBotAI (cops respawning after being killed) - without both
// going through here, dead cops would keep popping back into existence
// next to the player even though fresh ones respect the perimeter.
public static class PoliceSpawns
{
	// Spawn points are placed in the editor and never move, so finding
	// them once per scene is enough. Worth caching because Pick() runs on
	// every cop death, and with a dozen cops fighting that's constant.
	static readonly List<GameObject> _points = new();
	static Scene _cachedScene;

	/// <summary>
	/// Every point tagged police_spawn in the scene. Shared list - read
	/// it, don't hold onto it or modify it.
	/// </summary>
	public static IReadOnlyList<GameObject> All( Scene scene )
	{
		if ( scene is null )
			return Array.Empty<GameObject>();

		// Rebuild on a scene change, and also if anything in the cache has
		// been destroyed - that's the only way the set can change during a
		// job, and it's cheap to check compared to rebuilding every call.
		var stale = _cachedScene != scene || _points.Any( go => !go.IsValid() );

		if ( !stale )
			return _points;

		_cachedScene = scene;
		_points.Clear();
		_points.AddRange( scene.FindAllWithTag( "police_spawn" ) );

		return _points;
	}

	/// <summary>
	/// A random spawn point at least minDistance away from every player.
	/// Null if there are no spawn points at all.
	/// </summary>
	public static GameObject Pick( Scene scene, float minDistance )
	{
		var points = All( scene );

		if ( points.Count == 0 )
			return null;

		var players = Crew.ActivePlayers( scene );

		if ( players.Count == 0 )
			return points[Random.Shared.Next( points.Count )];

		// Squared distances - avoids a square root per point per player,
		// and comparisons come out the same as long as both sides are
		// squared. Matters because this runs every time a cop dies.
		var minDistanceSqr = minDistance * minDistance;

		float NearestPlayerSqr( GameObject point )
		{
			var nearest = float.MaxValue;

			foreach ( var player in players )
				nearest = MathF.Min( nearest, point.WorldPosition.DistanceSquared( player.WorldPosition ) );

			return nearest;
		}

		var outsidePerimeter = points
			.Where( p => NearestPlayerSqr( p ) >= minDistanceSqr )
			.ToList();

		if ( outsidePerimeter.Count > 0 )
			return outsidePerimeter[Random.Shared.Next( outsidePerimeter.Count )];

		// Every point is inside the perimeter - a small map, or the crew
		// spread out to cover the exits. Fall back to the furthest one
		// rather than refusing to spawn, because silently stopping the
		// waves is a worse failure than one cop appearing too close.
		return points.OrderByDescending( NearestPlayerSqr ).First();
	}
}