Game/InterceptorSpawner.cs
namespace Monolith;

/// <summary>
/// Keeps a small population of interceptors alive around the shape.
///
/// The population scales with the stage rather than with time, so an early stage stays a clean
/// place to learn to shoot and later ones are genuinely contested. It is capped low: these are
/// an aiming tax, not a bullet hell.
/// </summary>
public sealed class InterceptorSpawner : Component
{
	/// <summary>Named to avoid shadowing Component.Enabled, which is a real trap.</summary>
	[Property] public bool SpawnHazards { get; set; } = true;

	private GameTimeSince timeSinceSpawn;

	protected override void OnUpdate()
	{
		// Frozen while a blocking screen or the pause menu is up. See GameTime.
		if ( GameTime.Paused )
			return;

		if ( !SpawnHazards ) return;

		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() || manager.World == null || !manager.SnapshotReady )
			return;

		UpdateLeeches( manager );
		UpdateBarriers( manager );
		UpdateSpotter( manager );
		UpdateSentinels( manager );
		UpdateAnchors( manager );
		UpdateCrawlers( manager );

		int target = TargetPopulation( manager );

		if ( Interceptor.All.Count >= target )
			return;

		if ( timeSinceSpawn < Tuning.InterceptorSpawnInterval )
			return;

		timeSinceSpawn = 0;

		// Spawn out at the edge of the shape so they visibly drift in rather than appearing
		// already in your face.
		var bounds = manager.World.WorldBounds;
		float distance = bounds.Size.Length * 0.75f;

		var position = bounds.Center
			+ Vector3.Random.Normal.WithZ( MathF.Abs( Vector3.Random.z ) * 0.6f ).Normal * distance;

		Interceptor.Spawn( Scene, position );
	}

	/// <summary>
	/// Makes the next few draws from <c>Game.Random</c> a pure function of the stage.
	///
	/// The <paramref name="channel"/> keeps independent decisions independent. Without it the
	/// Spotter roll would shift every subsequent draw, so adding a hazard later would silently
	/// re-roll every stage in the game and invalidate every existing leaderboard time. One
	/// channel per system means each can be tuned or added without disturbing the others.
	///
	/// This seeds the SHARED generator, which is safe here only because s&amp;box re-seeds it
	/// every tick: the determinism lasts for the rolls immediately following the call and does
	/// not leak into later frames. Call it directly before the draws it is meant to govern.
	/// </summary>
	public static void SeedForStage( int stageKey, int channel )
	{
		Game.SetRandomSeed( unchecked(stageKey * 73856093 ^ channel * 19349663 ^ 0x5f3759df) );
	}

	// ---------------------------------------------------------------- spotter

	private int spotterRolledForStage = -1;
	private bool spotterDueThisStage;

	/// <summary>
	/// Fraction of the stage that must be cleared before the Spotter arrives.
	///
	/// This used to be a wall-clock delay, which meant a player who cleared a stage in twenty
	/// seconds almost never saw one: the arrival landed after the stage had already ended and
	/// the next stage rerolled it. Keying it to PROGRESS makes it show up regardless of how
	/// fast you are, and it arrives at the same point in the experience for everyone.
	/// </summary>
	private float spotterArrivesAtProgress;

	/// <summary>
	/// At most one Spotter per stage, arriving at a random moment rather than at the start.
	/// A stage that opens with one is just a stage you restart; a stage that is quiet for
	/// thirty seconds and then is not is a stage you have to stay alert through.
	/// </summary>
	private void UpdateSpotter( MonolithManager manager )
	{
		int key = manager.InMonolith ? -2 : manager.Tier;

		if ( spotterRolledForStage != key )
		{
			spotterRolledForStage = key;

			// SEEDED, not random. Devil Daggers' spawn set is fully deterministic, and that is
			// what makes its leaderboard mean anything: everyone fights the identical fight, so
			// the ranking measures execution instead of luck (GOALS 6d).
			//
			// Ours was `Game.Random` for the Spotter roll, its arrival, the barrier count and the
			// shield, which meant two players' stage 40 were different stages and the
			// ladder-seconds board was partly measuring who drew the easier hazards.
			SeedForStage( key, 1 );

			// The opening is a teaching space. See Tuning.SpotterGraceStages.
			bool inGrace = !manager.InMonolith && manager.Tier < Tuning.SpotterGraceStages;

			spotterDueThisStage = Game.Random.Float()
				< (inGrace ? Tuning.SpotterGraceChance : Tuning.SpotterChance);
			spotterArrivesAtProgress = Game.Random.Float(
				Tuning.SpotterArriveMinProgress, Tuning.SpotterArriveMaxProgress );

			// SPOTTERS NO LONGER LEAVE WITH THE STAGE.
			//
			// They used to be destroyed on every stage change, which was correct when a stage
			// took a minute and quietly broken once stages started falling in about a second: a
			// three second lock cannot complete inside a one second stage, so a fast player
			// outran the threat entirely without ever dodging anything. Forty stages with no
			// deaths is what that looks like from the outside.
			//
			// Letting them carry over means blitzing stages ACCUMULATES pressure instead of
			// dodging it, which is the behaviour a speed-focused player should get. Standby ones
			// still expire so the arena cannot silently fill up with sleeping machines.
			foreach ( var old in Spotter.All.ToList() )
			{
				if ( old.IsValid() && old.Standby )
					old.GameObject.Destroy();
			}

			// Survivors keep existing but LOSE what they knew. A new stage must never open with
			// the countdown already running and a beam already on you.
			Spotter.ResetAllAcquisition();
		}

		if ( !spotterDueThisStage )
			return;

		float cleared = manager.TotalCubes <= 0
			? 0f
			: 1f - (float)((double)manager.RemainingCubes / manager.TotalCubes);

		if ( cleared < spotterArrivesAtProgress )
			return;

		// Capped rather than gated on zero. The old check refused to spawn if ANY Spotter was
		// alive, which combined with carry-over would have meant one survivor suppressing every
		// future arrival for the rest of the run. The cap is 1 during the opening grace.
		bool graceCap = !manager.InMonolith && manager.Tier < Tuning.SpotterGraceStages;

		if ( Spotter.All.Count >= (graceCap ? 1 : Tuning.SpotterCountMax) )
			return;

		spotterDueThisStage = false;

		var bounds = manager.World.WorldBounds;
		float spread = MathF.Max( 500f, bounds.Size.Length * 0.6f );

		// Several arrive together and spread out around the shape. They sit on standby, so a
		// group is not a group of threats: it is a set of positions you have to work around,
		// and each one you shoot is one fewer place that becomes dangerous later.
		// During the grace the ceiling is ONE, total, not one arrival: carried-over Spotters
		// count against it, so the early ladder can never have two in the sky at once.
		bool grace = !manager.InMonolith && manager.Tier < Tuning.SpotterGraceStages;
		int ceiling = grace ? 1 : Tuning.SpotterCountMax;

		int count = Math.Min(
			grace ? 1 : Game.Random.Int( Tuning.SpotterCountMin, Tuning.SpotterCountMax ),
			ceiling - Spotter.All.Count );

		if ( count <= 0 )
			return;

		// Spread by a fixed minimum bearing rather than evenly around a circle. With only one or
		// two arriving, an even split of a small count can still drop them side by side; forcing
		// a wide separation means a pair actually covers the arena instead of stacking.
		float baseAngle = Game.Random.Float( 0f, MathF.PI * 2f );
		float spacing = Tuning.SpotterSpacingDegrees * MathF.PI / 180f;

		for ( int i = 0; i < count; i++ )
		{
			float angle = baseAngle + i * spacing + Game.Random.Float( -0.25f, 0.25f );
			float distance = spread * Game.Random.Float( 0.7f, 1.15f );

			var position = bounds.Center + new Vector3(
				MathF.Cos( angle ) * distance,
				MathF.Sin( angle ) * distance,
				0f );

			position.z = bounds.Maxs.z
				+ Tuning.SpotterHoverHeight * Game.Random.Float( 0.6f, 1.3f );

			Spotter.Spawn( Scene, position );
		}
	}

	// ---------------------------------------------------------------- barriers

	private int barriersBuiltForStage = -1;

	/// <summary>
	/// Barriers belong to the stage, not to a spawn timer: they are rebuilt once when the shape
	/// changes and then simply orbit. Their count scales with the stage so early ones stay
	/// clean and later ones genuinely need working around.
	/// </summary>
	private void UpdateBarriers( MonolithManager manager )
	{
		int key = manager.InMonolith ? -2 : manager.Tier;

		if ( barriersBuiltForStage == key )
			return;

		barriersBuiltForStage = key;

		// Channel 2. Same stage, same barriers, for everyone.
		SeedForStage( key, 2 );

		foreach ( var existing in OrbitalBarrier.All.ToList() )
			existing.GameObject.Destroy();

		int count = manager.InMonolith
			? Tuning.BarrierMaxCount
			: Math.Clamp( (manager.Tier - Tuning.BarrierFirstStage) / Tuning.BarrierStagesPerExtra + 1,
				0, Tuning.BarrierMaxCount );

		if ( count <= 0 )
			return;

		var bounds = manager.World.WorldBounds;

		// Clamped INSIDE the arena. A barrier outside the walls is one the player can never
		// stand beyond and never shoot past, so it stops being an obstacle and becomes scenery.
		// On the shared Monolith that is exactly what happened.
		float ceiling = manager.ArenaHalfExtent * Tuning.BarrierMaxArenaFraction;
		float baseRadius = MathF.Min( ceiling, MathF.Max( 420f, bounds.Size.Length * 0.55f ) );

		for ( int i = 0; i < count; i++ )
		{
			// Alternate direction and stagger height so they cross each other rather than
			// forming one predictable wall.
			float speed = Game.Random.Float( Tuning.BarrierSpeedMin, Tuning.BarrierSpeedMax )
				* (i % 2 == 0 ? 1f : -1f);

			float radius = MathF.Min( ceiling, baseRadius * Game.Random.Float( 0.85f, 1.25f ) );
			float height = bounds.Size.z * Game.Random.Float( 0.15f, 0.75f );
			float halfHeight = bounds.Size.z * Game.Random.Float( 0.18f, 0.32f ) + 120f;

			OrbitalBarrier.Spawn( Scene, bounds.Center, radius,
				Game.Random.Float( 0f, 360f ), speed,
				height - bounds.Size.z * 0.5f, halfHeight );
		}
	}

	// ---------------------------------------------------------------- sentinels

	private int sentinelsBuiltForStage = -1;

	/// <summary>
	/// Sentinels belong to the stage, like barriers, rather than trickling in on a timer. A
	/// threat that shoots back has to be countable: you should be able to look around once, see
	/// how many guns are pointed at you, and plan. A drip feed makes that impossible.
	/// </summary>
	private void UpdateSentinels( MonolithManager manager )
	{
		int key = manager.InMonolith ? -2 : manager.Tier;

		if ( sentinelsBuiltForStage == key )
			return;

		sentinelsBuiltForStage = key;

		// SENTINELS CARRY OVER, for the same reason Spotters do.
		//
		// A sentinel needs SentinelFireInterval (3.4s) to get a single shot away. Stages are
		// currently falling in about a second, and these were being destroyed and rebuilt on
		// every stage change, so they never once reached the end of a reload. That is what
		// "the non-spotter enemy isn't working" looks like: not a broken component, a component
		// that is deleted before its first action completes.
		//
		// In-flight orbs still clear with the stage. An orb fired at a shape that no longer
		// exists is just an unfair hit from a dead threat.
		foreach ( var orb in SentinelOrb.All.ToList() )
			orb.GameObject.Destroy();

		// Survivors re-arm. Carrying over their READINESS was the bug: a sentinel that had been
		// waiting through the last stage arrived at the new one with a full reload and shot
		// immediately, which is the same unfairness as a Spotter keeping its lock.
		Sentinel.RearmAll();

		SeedForStage( key, 4 );

		int wanted = manager.InMonolith
			? Tuning.SentinelMaxCount
			: Math.Clamp( (manager.Tier - Tuning.SentinelFirstStage) / Tuning.SentinelStagesPerExtra + 1,
				0, Tuning.SentinelMaxCount );

		// Trim only if the stage wants fewer than are already out, so the population tracks the
		// stage without resetting everyone's reload.
		//
		// Bounded loop over a SNAPSHOT. `GameObject.Destroy()` is deferred, so
		// `while ( All.Count > wanted )` never terminates: the count cannot change until the
		// frame it is blocking finishes. See MiningDrone.MatchPopulation, where the identical
		// mistake was the collapse hang.
		if ( Sentinel.All.Count > wanted )
		{
			foreach ( var doomed in Sentinel.All.Skip( Math.Max( 0, wanted ) ).ToList() )
			{
				if ( doomed.IsValid() )
					doomed.GameObject.Destroy();
			}

			return;
		}

		int count = wanted - Sentinel.All.Count;

		if ( count <= 0 )
			return;

		var bounds = manager.World.WorldBounds;
		float spread = MathF.Max( 700f, bounds.Size.Length * 0.7f );

		for ( int i = 0; i < count; i++ )
		{
			// Spread around the shape, so no single facing has all of them behind you and there
			// is no corner of the arena that is safe from every gun at once.
			float angle = (i / (float)count) * MathF.PI * 2f + Game.Random.Float( -0.4f, 0.4f );

			var position = bounds.Center + new Vector3(
				MathF.Cos( angle ) * spread,
				MathF.Sin( angle ) * spread,
				0f );

			position.z = bounds.Center.z + Tuning.SentinelHoverHeight;

			Sentinel.Spawn( Scene, position );
		}
	}

	// ---------------------------------------------------------------- anchors

	private int anchorsBuiltForStage = -1;

	private void UpdateAnchors( MonolithManager manager )
	{
		int key = manager.InMonolith ? -2 : manager.Tier;

		if ( anchorsBuiltForStage == key )
			return;

		anchorsBuiltForStage = key;

		// Carry over too. An Anchor has a cooldown before it can fire and a hold time after, so
		// like the Sentinel it needs more than one stage's worth of seconds to do anything.
		//
		// But it must LET GO. A stage opening with the tether already attached and your speed
		// already halved is the same unfairness as a Spotter keeping its lock, and it is what
		// the "line attached to me on spawn" report actually was.
		Anchor.ReleaseAll();

		SeedForStage( key, 5 );

		int wanted = manager.InMonolith
			? Tuning.AnchorMaxCount
			: Math.Clamp( (manager.Tier - Tuning.AnchorFirstStage) / Tuning.AnchorStagesPerExtra + 1,
				0, Tuning.AnchorMaxCount );

		// Bounded snapshot, same reason as the sentinels above: deferred destruction means a
		// while-loop on the count is an infinite loop.
		if ( Anchor.All.Count > wanted )
		{
			foreach ( var doomed in Anchor.All.Skip( Math.Max( 0, wanted ) ).ToList() )
			{
				if ( doomed.IsValid() )
					doomed.GameObject.Destroy();
			}

			return;
		}

		int count = wanted - Anchor.All.Count;

		if ( count <= 0 )
			return;

		var bounds = manager.World.WorldBounds;
		float spread = MathF.Max( 900f, bounds.Size.Length * 0.9f );

		for ( int i = 0; i < count; i++ )
		{
			// Placed further out than the sentinels and low to the ground, so cutting a tether
			// means looking away from the shape and DOWN, which is the least convenient thing
			// to be asked to do while mining something above you.
			float angle = (i / (float)count) * MathF.PI * 2f + Game.Random.Float( -0.6f, 0.6f );

			var position = bounds.Center + new Vector3(
				MathF.Cos( angle ) * spread,
				MathF.Sin( angle ) * spread,
				0f );

			position.z = bounds.Mins.z + Tuning.AnchorHoverHeight;

			Anchor.Spawn( Scene, position );
		}
	}

	// ---------------------------------------------------------------- crawlers

	private int crawlersBuiltForStage = -1;

	/// <summary>
	/// Ground chasers. Like the other persistent hazards they carry over, because a chase that
	/// resets every stage is not a chase. What DOES reset is the wind-up: see `Crawler.CalmAll`.
	/// </summary>
	private void UpdateCrawlers( MonolithManager manager )
	{
		int key = manager.InMonolith ? -2 : manager.Tier;

		if ( crawlersBuiltForStage == key )
			return;

		crawlersBuiltForStage = key;

		// Carried over, but calmed. A stage must never open with a fully enraged crawler already
		// on top of you, which is the same fairness rule as the Spotter losing its lock.
		Crawler.CalmAll();

		SeedForStage( key, 6 );

		int wanted = manager.InMonolith
			? Tuning.CrawlerMaxCount
			: Math.Clamp( (manager.Tier - Tuning.CrawlerFirstStage) / Tuning.CrawlerStagesPerExtra + 1,
				0, Tuning.CrawlerMaxCount );

		if ( Crawler.All.Count > wanted )
		{
			// Bounded snapshot: Destroy is deferred, so a while-loop on the count never ends.
			foreach ( var doomed in Crawler.All.Skip( Math.Max( 0, wanted ) ).ToList() )
			{
				if ( doomed.IsValid() )
					doomed.GameObject.Destroy();
			}

			return;
		}

		int count = wanted - Crawler.All.Count;

		if ( count <= 0 )
			return;

		var bounds = manager.World.WorldBounds;

		for ( int i = 0; i < count; i++ )
		{
			// They walk in from the edge of the arena, on the floor, spread around it. Arriving
			// at distance is what gives you the chance to hear one coming and deal with it
			// before it has wound up.
			float angle = Game.Random.Float( 0f, MathF.PI * 2f );
			float spread = MathF.Min( manager.ArenaHalfExtent * 0.85f, Tuning.CrawlerSpawnDistance );

			var position = bounds.Center + new Vector3(
				MathF.Cos( angle ) * spread,
				MathF.Sin( angle ) * spread,
				0f );

			position.z = bounds.Mins.z + 30f;

			Crawler.Spawn( Scene, position );
		}
	}

	// ---------------------------------------------------------------- leeches

	private GameTimeSince timeSinceLeech;

	/// <summary>
	/// Leeches clamp onto the shape itself, so they are placed by tracing inward from outside
	/// and sitting on the first surface found. That keeps them visibly attached rather than
	/// floating near it.
	/// </summary>
	private void UpdateLeeches( MonolithManager manager )
	{
		if ( manager.Tier < Tuning.LeechFirstStage && !manager.InMonolith )
			return;

		if ( Leech.All.Count >= Tuning.LeechMaxPopulation )
			return;

		if ( timeSinceLeech < Tuning.LeechSpawnInterval )
			return;

		timeSinceLeech = 0;

		var bounds = manager.World.WorldBounds;
		float outer = bounds.Size.Length;

		for ( int attempt = 0; attempt < 10; attempt++ )
		{
			var from = bounds.Center + Vector3.Random.Normal * outer;
			var dir = (bounds.Center - from).Normal;

			if ( !manager.World.TraceRay( from, dir, outer * 2.5f, out var hit ) )
				continue;

			// Sit just proud of the surface it latched onto.
			var surface = manager.World.VoxelToWorld( hit.Voxel.x, hit.Voxel.y, hit.Voxel.z );
			Leech.Spawn( Scene, surface - dir * 30f );
			return;
		}
	}

	private int TargetPopulation( MonolithManager manager )
	{
		if ( manager.InMonolith )
			return Tuning.InterceptorMaxPopulation;

		// Ramp in over the first few stages so the opening is uncontested.
		int fromStage = (manager.Tier - Tuning.InterceptorFirstStage) / Tuning.InterceptorStagesPerExtra + 1;
		return Math.Clamp( fromStage, 0, Tuning.InterceptorMaxPopulation );
	}
}