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

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

		Log.Info( $"Job difficulty: {ActiveMission.DifficultyName} " +
			$"(spawn every {EffectiveSpawnInterval:0.0}s, up to {EffectiveMaxCops} cops, " +
			$"crew {Crew.ScalingSize( Scene )}, 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.
		//
		// Note that a cop dying does NOT destroy it - SWB ragdolls it and
		// respawns the same GameObject two seconds later - so in practice
		// this list settles at the cap and stays there.
		_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. The numbers to read:
	//
	//   alive/cap      if alive sits at cap, difficulty is doing what it
	//                  says. If alive climbs past cap, the cap is broken.
	//   total          how many have ever been made this job. Should stay
	//                  close to alive, since cops respawn rather than
	//                  being replaced.
	//   crew           what difficulty is scaling off. Should equal the
	//                  number of people actually playing.
	//   playerObjects  raw count of player-tagged objects still in the
	//                  fight. If this is bigger than connections, something
	//                  is leaving player objects behind - that's what
	//                  would inflate the whole police response.
	void LogSpawnState()
	{
		if ( !DebugSpawning || _timeSinceDebugLog < DebugLogInterval )
			return;

		_timeSinceDebugLog = 0;

		Log.Info( $"[spawner] alive={_activeCops.Count}/{EffectiveMaxCops} " +
			$"total={_spawnedThisJob} " +
			$"interval={EffectiveSpawnInterval:0.00}s " +
			$"crew={Crew.ScalingSize( Scene )} " +
			$"playerObjects={Crew.ActiveCount( Scene )} " +
			$"connections={Connection.All?.Count ?? 0} " +
			$"sceneObjects={Scene.Directory.GameObjectCount}" );
	}

	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 police_spawn point outside the perimeter around the crew.
		// Shared with CopBotAI so that cops respawning after being killed
		// respect the same rule - see PoliceSpawns.
		var spawnPoint = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers );

		if ( spawnPoint is null )
		{
			Log.Warning( "MissionSpawner: no GameObjects tagged 'police_spawn' 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}";

		// 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'." );
		}
	}
}