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.
//
// --- Two-stage response ------------------------------------------------
//
// A job has two very different shapes, and the police response should
// reflect that rather than trickling out of the same doorway for the whole
// run:
//
//   Robbery phase   you're dug in holding the store. Cops come from the
//                   HeistTag points - place these on the approaches, so
//                   the fight is about defending the place you're in.
//
//   Escape phase    the exit is open and you're moving. Cops come from the
//                   EscapeTag points instead - place these between the
//                   store and the extraction, so the run out is contested
//                   rather than a walk down an empty street.
//
// A point can carry BOTH tags if it should be used in both phases - tags
// are additive, so that costs nothing and needs no extra code.
//
// MissionTimer drives the switch; nothing else needs to know which phase
// it is, because Pick() reads it for everyone.
public static class PoliceSpawns
{
	/// <summary>Spawn points used while the robbery is in progress.</summary>
	public const string HeistTag = "police_spawn";

	/// <summary>Spawn points used once the escape zone has opened.</summary>
	public const string EscapeTag = "police_spawn_escape";

	/// <summary>True once the crew is pushing for the exit.</summary>
	public static bool EscapePhase { get; private set; }

	/// <summary>Which tag Pick is currently drawing from.</summary>
	public static string ActiveTag => EscapePhase ? EscapeTag : HeistTag;

	// Static because it's a property of the job rather than of any one
	// object, and because both callers need the same answer without having
	// to be wired to each other in the editor. Reset by MissionTimer at
	// the start of every job, which matters: statics outlive the scene
	// load, so without that reset a second job would begin already in
	// escape phase.
	public static void SetPhase( bool escapePhase )
	{
		if ( EscapePhase == escapePhase )
			return;

		EscapePhase = escapePhase;

		// Cheap and makes the switch legible in the console, which is
		// worth it for something that changes where every subsequent cop
		// comes from.
		Log.Info( $"Police response now spawning from '{ActiveTag}' points." );
	}

	// 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.
	//
	// Keyed by tag so the two phases don't evict each other's list every
	// time the phase flips.
	static readonly Dictionary<string, List<GameObject>> _pointsByTag = new();
	static Scene _cachedScene;

	/// <summary>
	/// Drop everything cached from the current scene, and go back to the
	/// robbery phase.
	/// </summary>
	///
	/// Call this immediately BEFORE a scene change. _cachedScene, the
	/// cached point lists and _lastPicked are all references into the
	/// scene we are leaving, and while they are held the engine cannot
	/// finish tearing it down - see the longer note on Crew.Reset.
	public static void Reset()
	{
		_pointsByTag.Clear();
		_cachedScene = null;
		_lastPicked = null;
		EscapePhase = false;
	}

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

		// A scene change invalidates everything at once - the cached
		// GameObjects belong to a scene that no longer exists.
		if ( _cachedScene != scene )
		{
			_cachedScene = scene;
			_pointsByTag.Clear();
		}

		// Also rebuild if anything in the cached list has been destroyed,
		// which is the only way the set can change mid-job. Cheaper to
		// check than to rebuild every call.
		if ( _pointsByTag.TryGetValue( tag, out var cached ) && cached.All( go => go.IsValid() ) )
			return cached;

		var points = scene.FindAllWithTag( tag ).ToList();
		_pointsByTag[tag] = points;

		return points;
	}

	/// <summary>
	/// A random spawn point for the current phase, at least minDistance
	/// away from every player. Null if the map has no spawn points at all.
	/// </summary>
	public static GameObject Pick( Scene scene, float minDistance, GameObject ignore = null )
	{
		var points = All( scene, ActiveTag );

		// A map that hasn't had escape-phase points placed yet falls back
		// to the robbery set rather than quietly going silent. Adding this
		// feature shouldn't be able to break a map that already worked -
		// the response just stays where it was until the points exist.
		if ( points.Count == 0 && EscapePhase )
			points = All( scene, HeistTag );

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

		var players = Crew.ActivePlayers( scene );
		var cops = CopPositions( scene, ignore );

		// 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;

		var scored = points
			.Select( p => (
				Point: p,
				PlayerSqr: NearestSqr( p.WorldPosition, players ),
				CopSqr: NearestSqr( p.WorldPosition, cops )) )
			.ToList();

		// First choice: everything outside the no-spawn perimeter.
		var tier = scored.Where( s => s.PlayerSqr >= minDistanceSqr ).ToList();

		if ( tier.Count == 0 )
		{
			// Nothing qualifies - a small map, or the crew has moved into
			// the middle of the spawn points, which is exactly what
			// happens once they break for the exit.
			//
			// This used to return the single furthest point, and that was
			// the bug: it's deterministic, so from that moment on EVERY
			// cop respawned on one identical spot. They arrived stacked
			// inside one another, and the physics push-out that separates
			// overlapping bodies shoved them through whatever was nearest
			// - the store walls. It also explains why only respawns broke:
			// at the start of a job the crew is at the objective, far from
			// every spawn, so the perimeter is satisfied and the random
			// branch above is the one that runs.
			//
			// Take the furthest half instead, so there is always a choice.
			var ordered = scored.OrderByDescending( s => s.PlayerSqr ).ToList();
			tier = ordered.Take( Math.Max( 1, ordered.Count / 2 ) ).ToList();
		}

		// Prefer somewhere no other cop is already standing. Checked by
		// position rather than by collider, because a cop that is dead has
		// had its body disabled and would trace as empty space right up
		// until it respawns into you.
		var unoccupied = tier.Where( s => s.CopSqr >= OccupiedRadius * OccupiedRadius ).ToList();

		if ( unoccupied.Count > 0 )
			tier = unoccupied;

		// And don't use the same point twice running when there's an
		// alternative - even with everything above satisfied, consecutive
		// picks landing on one spot is what makes a squad arrive as a
		// single clump.
		if ( tier.Count > 1 )
		{
			var notLast = tier.Where( s => s.Point != _lastPicked ).ToList();

			if ( notLast.Count > 0 )
				tier = notLast;
		}

		var chosen = tier[Random.Shared.Next( tier.Count )].Point;
		_lastPicked = chosen;

		return chosen;
	}

	// How close another cop has to be for a spawn point to count as taken.
	// Comfortably more than two body widths, so they arrive beside each
	// other rather than inside each other.
	const float OccupiedRadius = 128f;

	static GameObject _lastPicked;

	static float NearestSqr( Vector3 from, IReadOnlyList<Vector3> targets )
	{
		var nearest = float.MaxValue;

		foreach ( var target in targets )
			nearest = MathF.Min( nearest, from.DistanceSquared( target ) );

		return nearest;
	}

	static float NearestSqr( Vector3 from, IReadOnlyList<GameObject> targets )
	{
		var nearest = float.MaxValue;

		foreach ( var target in targets )
			nearest = MathF.Min( nearest, from.DistanceSquared( target.WorldPosition ) );

		return nearest;
	}

	// Every cop in the scene except the one asking. Dead ones count: they
	// are about to come back, and where they come back is what we're
	// trying not to collide with.
	static List<Vector3> CopPositions( Scene scene, GameObject ignore )
	{
		var positions = new List<Vector3>();

		foreach ( var cop in scene.GetAllComponents<CopBotAI>() )
		{
			if ( !cop.IsValid() || cop.GameObject == ignore )
				continue;

			positions.Add( cop.WorldPosition );
		}

		return positions;
	}
}