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

// Produces the police response for a job.
//
// Sits dormant until MissionStartTrigger calls StartSpawning(), then feeds
// cops in continuously - a trickle at an interval rather than one big wave,
// so pressure is sustained instead of arriving all at once and then never
// again.
//
// Three separate things scale the difficulty, and they stack:
//   1. The map's own baseline, set on this component in the editor.
//   2. The job difficulty picked at the board (ActiveMission).
//   3. How many players are actually in the fight right now.
//
// All three are combined in the Effective* properties below. The editor
// values are never written to, only read - so the inspector always shows
// the map's baseline rather than whatever the last job mutated it into.
public sealed class MissionSpawner : Component
{
	// The cop to clone. Should be COP.prefab: a DemoBot with CopBotAI and
	// a NavMeshAgent on it.
	[Property]
	public GameObject CopPrefab { get; set; }

	// Seconds between spawns at baseline, before difficulty and crew size
	// are taken into account.
	[Property]
	public float SpawnInterval { get; set; } = 4f;

	// Caps how many cops can be alive at once so continuous spawning
	// doesn't spiral out of control over a long job.
	[Property]
	public int MaxActiveCops { get; set; } = 8;

	// The number no amount of stacked multipliers is allowed to exceed.
	//
	// EffectiveMaxCops is a product of three separate numbers, two set in
	// the editor and one derived from live scene state. Any one of them
	// being wrong multiplies straight into the cop count, and that failure
	// mode is the worst kind: the map fills with pathfinding, shooting
	// bots until the game stops responding, which is hard to even diagnose
	// because you can't play long enough to read the console.
	//
	// So this is a floor under the whole difficulty system, not a tuning
	// knob. Raise it only if a map genuinely wants more than this.
	[Property]
	public int AbsoluteMaxCops { get; set; } = 40;

	// No-spawn perimeter around every player still in the fight. Cops
	// appearing in your face reads as cheating rather than difficulty, so
	// this is deliberately generous - roughly 50 metres.
	//
	// If the whole map falls inside the perimeter, the furthest point is
	// used anyway; see PoliceSpawns.Pick.
	[Property]
	public float MinSpawnDistanceFromPlayers { get; set; } = 2000f;

	// --- Crew scaling -----------------------------------------------------
	//
	// Without these a four-player job is the same fight as a solo one,
	// which makes bringing friends strictly easier while paying out four
	// times as much.
	//
	// Both are expressed as "per EXTRA player" - the first player counts as
	// the baseline and adds nothing - so a solo run is exactly the map's
	// configured values and nothing has to be special-cased for 1 player.

	// Multiplies the cop cap. 0.75 means each extra player adds 75% of the
	// base cap, so 8 cops becomes 14 at two players and 26 at four.
	//
	// A multiplier rather than a flat "+3 per player" so that it tracks the
	// map: a dense map configured for 20 cops scales proportionally instead
	// of getting the same small bump a quiet 4-cop map would.
	[Property]
	public float MaxCopsMultiplierPerExtraPlayer { get; set; } = 0.75f;

	// How close this map's cops get before they stop advancing and fight
	// from where they stand. 0 leaves the cop prefab's own EngageRange
	// alone (500 at the time of writing), which is what the gas station
	// uses.
	//
	// It lives on the spawner because it's a property of the MAP, not the
	// cop. At 500 a cop that can see you through the vault door plants
	// its feet in the lobby and shoots from there - fine for a shop
	// front, wrong for a vault the crew is meant to have to hold. Lower
	// it and cops keep walking in while they shoot, which is what makes a
	// room feel pushed rather than besieged.
	//
	// Each cop still gets its own +/- EngageRangeVariation on top, so they
	// don't all stop on one ring.
	[Property]
	public float CopEngageRange { get; set; } = 0f;

	// How much faster cops arrive per extra player, as a fraction. 0.75
	// means a second player makes them arrive 1.75x as fast.
	[Property]
	public float SpawnRateBonusPerExtraPlayer { get; set; } = 0.75f;

	// Prints what the spawner is actually doing, once every
	// DebugLogInterval seconds while a job is running. Worth leaving on
	// until the difficulty numbers are tuned - it's the difference between
	// "cops feel wrong" and knowing the cap is 12 with 12 alive.
	[Property]
	public bool DebugSpawning { get; set; } = true;

	[Property]
	public float DebugLogInterval { get; set; } = 10f;

	// --- Runtime state ----------------------------------------------------

	bool _spawning = false;

	// TimeSince is an s&box struct that counts UP in seconds by itself once
	// assigned. Setting it to 0 means "this just happened"; comparing it
	// against a duration is how you ask "has that long passed yet?".
	TimeSince _timeSinceLastSpawn = 0;
	TimeSince _timeSinceDebugLog = 0;

	// Every cop this spawner has produced and that is still alive. Used
	// only to enforce the cap - see the RemoveAll in OnUpdate for why it
	// needs pruning rather than just counting.
	readonly List<GameObject> _activeCops = new();

	// Total ever produced this job. Only feeds the debug line, but it's
	// the number that gives the game away if the cap ever fails: a job
	// that has made 300 cops against a cap of 12 is a very different
	// problem from one that made 12.
	int _spawnedThisJob = 0;

	// --- Effective values -------------------------------------------------

	// Players beyond the first. Counted from players still IN THE FIGHT,
	// not everyone who started: as the crew is whittled down the pressure
	// drops with it, which stops a partial wipe becoming a death spiral and
	// gives a lone survivor a real chance of reaching the exit.
	//
	// Crew.ScalingSize rather than a raw count of player-tagged objects, so
	// a stray duplicate player object can't silently multiply the entire
	// police response - see the comment there.
	int ExtraPlayers => Crew.ScalingSize( Scene ) - 1;

	// Interval is DIVIDED because a shorter interval means more cops. With
	// the 0.75 default: 1 player divides by 1.0, two by 1.75, four by 3.25.
	//
	// Floored well above zero: this is one bad divisor away from being a
	// spawn-every-frame loop, and the clamp costs nothing.
	float EffectiveSpawnInterval => MathF.Max( SpawnInterval
		* ActiveMission.SpawnIntervalScale
		/ (1f + SpawnRateBonusPerExtraPlayer * ExtraPlayers), 0.1f );

	// Both modifiers are multipliers applied to the map's own cap, so they
	// compound: a 2x job run by four players is 8 * 2 * 3.25 = 52 cops.
	//
	// Multiplying rather than adding means both scale WITH the map - a
	// dense map built for 20 cops gets a proportional increase, where a
	// flat "+4" would barely register on it while overwhelming a quiet
	// 4-cop map.
	//
	// Then clamped, because a product of three numbers is only ever as
	// sane as its worst input - see AbsoluteMaxCops.
	int EffectiveMaxCops => Math.Clamp( (int)(MaxActiveCops
		* ActiveMission.MaxCopsMultiplier
		* (1f + MaxCopsMultiplierPerExtraPlayer * ExtraPlayers)), 0, AbsoluteMaxCops );

	// ----------------------------------------------------------------------

	// Called by MissionStartTrigger once the player holds the interact
	// button long enough to kick off the job.
	public void StartSpawning()
	{
		_spawning = true;
		_spawnedThisJob = 0;
		_timeSinceDebugLog = 0;

		// Pre-loading the timer with a full interval makes the first cop
		// spawn immediately rather than after a wait - the response should
		// feel like it starts the moment the alarm goes off.
		_timeSinceLastSpawn = EffectiveSpawnInterval;

		// Raw player-object counts alongside the clamped one.
		//
		// "crew" is clamped to the connection count, so it reads 1 whether
		// there is one player object in the scene or five - which is
		// exactly the case worth being able to see. Cops target the
		// NEAREST active player, so a leftover player object is a cop
		// getting a genuinely clear line to something that is not you,
		// and firing along it.
		var activePlayers = Crew.ActiveCount( Scene );
		var totalPlayers = Scene.GetAllComponents<SWB.Player.PlayerBase>()
			.Count( p => p.IsValid() && !p.IsBot );

		Log.Info( $"Job difficulty: {ActiveMission.DifficultyName} " +
			$"(spawn every {EffectiveSpawnInterval:0.0}s, up to {EffectiveMaxCops} cops, " +
			$"crew {Crew.ScalingSize( Scene )}, activePlayers {activePlayers}, " +
			$"totalPlayers {totalPlayers}, payout x{ActiveMission.PayoutMultiplier:0.00})" );
	}

	public void StopSpawning()
	{
		_spawning = false;
	}

	protected override void OnUpdate()
	{
		// Host spawns, everyone else receives. Without this every client
		// runs its own copy of this loop and network-spawns a full set of
		// cops, so a two-player game gets two squads - twice the enemies
		// and twice the gunfire audio.
		if ( !Networking.IsHost )
			return;

		if ( !_spawning )
			return;

		// Prunes cops that have been destroyed, so the cap counts what's
		// actually on the map. IsValid() is the s&box way to test a
		// destroyed object; a plain null check won't catch it, because the
		// reference still points at an object that has been torn down.
		//
		// This is also how replacements happen. A cop that dies destroys
		// itself rather than respawning in place (see CopBotAI's
		// DespawnOnDeath), so the list shrinks, and the interval below
		// feeds a brand new one in to take its place. Every cop on the map
		// is therefore always on its first life, which is the only state
		// SWB's player teardown leaves fully intact.
		_activeCops.RemoveAll( go => !go.IsValid() );

		LogSpawnState();

		if ( _activeCops.Count >= EffectiveMaxCops )
			return;

		if ( _timeSinceLastSpawn < EffectiveSpawnInterval )
			return;

		// Reset BEFORE spawning so the next interval is measured from now.
		_timeSinceLastSpawn = 0;
		SpawnOneCop();
	}

	// One line, every DebugLogInterval seconds, and only when something
	// is actually wrong.
	//
	// The previous version printed the same healthy numbers every ten
	// seconds forever, which is worse than no logging: it buries the one
	// line that matters in nine that don't. It did earn its keep once -
	// playerObjects reading 174 against a single connection is what
	// exposed tag inheritance counting every bone in the player model as
	// a separate player - so the check stays, it just keeps quiet until
	// the numbers disagree.
	void LogSpawnState()
	{
		if ( !DebugSpawning || _timeSinceDebugLog < DebugLogInterval )
			return;

		_timeSinceDebugLog = 0;

		var players = Crew.ActiveCount( Scene );
		var connections = Connection.All?.Count ?? 0;

		// One player object per connected human is the only healthy shape.
		// More means something is being miscounted as a player, and every
		// system that asks "where are the players" - difficulty scaling,
		// the spawn perimeter, cop targeting - is being fed that answer.
		if ( players > connections )
		{
			Log.Warning( $"[spawner] {players} player objects for {connections} connections. " +
				"Something is being counted as a player that isn't one." );
		}

		// A cop count that has outrun its own cap is the other thing worth
		// shouting about, because it compounds until the game stops.
		if ( _activeCops.Count > EffectiveMaxCops )
		{
			Log.Warning( $"[spawner] {_activeCops.Count} cops alive against a cap of {EffectiveMaxCops} " +
				$"({_spawnedThisJob} spawned this job)." );
		}
	}

	void SpawnOneCop()
	{
		if ( !CopPrefab.IsValid() )
		{
			// Nothing to clone, and nothing is going to fix that mid-job -
			// so stop rather than logging this every interval forever.
			Log.Warning( "MissionSpawner: no CopPrefab assigned - spawning disabled." );
			StopSpawning();
			return;
		}

		// Picks a spawn point outside the perimeter around the crew, from
		// whichever set the current phase calls for - the approaches to
		// the store during the robbery, the route to the exit after it
		// opens. Shared with CopBotAI so that cops respawning after being
		// killed respect both rules - see PoliceSpawns.
		var spawnPoint = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers );

		if ( spawnPoint is null )
		{
			Log.Warning( $"MissionSpawner: no GameObjects tagged '{PoliceSpawns.ActiveTag}' found in the scene." );
			return;
		}

		// Clone() copies the prefab into the scene; NetworkSpawn() then
		// makes it exist for every connected client. Both are needed - a
		// clone that is never network-spawned is invisible to everyone but
		// the host.
		//
		// The position passed here barely matters: PlayerBase.Respawn()
		// fires automatically on spawn and moves it to a generic spawn
		// point, and CopBotAI then corrects it back to a police_spawn
		// point once the cop comes alive (see RelocateToPoliceSpawn).
		var cop = CopPrefab.Clone( spawnPoint.WorldPosition, spawnPoint.WorldRotation );
		cop.Name = $"Cop_{_spawnedThisJob}";

		// Set before the cop's first life starts: CopBotAI reads
		// EngageRange when it rolls its per-life personality on coming
		// alive, which is a frame or more after Clone. Set on the object
		// rather than per life, so it carries through every respawn of
		// this cop without the spawner having to be involved again.
		if ( CopEngageRange > 0f && cop.Components.Get<CopBotAI>() is { } ai )
			ai.EngageRange = CopEngageRange;

		// Counted BEFORE network-spawning, on purpose. NetworkSpawn
		// returns false rather than throwing when it can't do its job (the
		// prefab root being set to NetworkMode.Never is the usual reason),
		// and a cop that never makes it into this list doesn't count
		// toward the cap - which would mean unlimited spawning.
		_activeCops.Add( cop );
		_spawnedThisJob++;

		if ( !cop.NetworkSpawn() )
		{
			Log.Warning( $"MissionSpawner: {cop.Name} failed to network spawn - other players " +
				"won't see it. Check the cop prefab root's NetworkMode isn't set to 'Never'." );
		}
	}
}