Zombies/AmbientSpecials.cs

Component that manages ambient special enemies (napalm zombie, Shrieker) that intermittently spawn into normal rounds. It tracks per-rule quotas each round, defers or drops quotas across special rounds, checks alive caps, picks spawn points, and issues spawns; includes console commands to report and force spawns.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// SPECIALS THAT TRICKLE INTO ORDINARY ROUNDS — the napalm zombie and the Shrieker.
///
/// ⛔ NEITHER OF THE TWO EXISTING SPAWN SYSTEMS WOULD DO IT, AND THE REASONS ARE DIFFERENT. A
/// SPECIAL round replaces a round's contents and runs on a fixed cadence; a BOSS round is excluded
/// from special rounds entirely, so a napalm zombie on a 3-round cadence would silently lose every
/// collision with Basalt's 5-round pest horde — about one in five, with nothing in the log saying
/// so. This is the third case: an enemy that joins a normal round without being the round.
///
/// ⚠️ IT ALSO LEAVES BOSS ROUNDS ALONE, which matters because the Basalt easter egg ends in an
/// actual boss. Parking the napalm zombie on the boss schedule would mean untangling them later.
///
/// ⛔ WHETHER THE ROUND CAN END WITH ONE ALIVE IS NOT DECIDED HERE. `RoundManager.AliveBlocking`
/// counts every zombie whose variant is not `IsBoss`, so:
///
///     napalm.zvar   IsBoss true    the round ends with one still walking around
///     shrieker.zvar IsBoss false   the round waits until it is dead
///
/// That is the whole mechanism, it already existed, and this class does not touch it. Which also
/// means changing a `.zvar` flag silently changes round pacing — worth knowing before anyone
/// "tidies" one.
/// </summary>
public sealed class AmbientSpecials : Component
{
	/// <summary>Live tracker per rule, rebuilt each round.</summary>
	sealed class Track
	{
		public AmbientSpecial Rule;
		public int Quota;
		public int Made;
		public TimeUntil Next;
	}

	readonly List<Track> _tracks = new();

	/// <summary>
	/// Arrivals a special round pushed into the next one, by enemy id.
	///
	/// ⛔ DEFERRED, NOT DROPPED, AND THE DIFFERENCE IS A WHOLE ARRIVAL. The first version of this
	/// threw the quota away — Basalt's round 20 is both a napalm round and a pest horde, so that
	/// napalm zombie simply never existed, and the cadence the designer wrote quietly lost one in
	/// five. Skipping a round should move the arrival, not delete it.
	///
	/// ⚠️ KEYED BY ENEMY ID RATHER THAN BY RULE OBJECT. The config is reloaded on a map change and
	/// the rule instances are replaced, so holding references would carry a debt owed by objects
	/// that no longer exist.
	/// </summary>
	readonly Dictionary<string, int> _carried = new();

	int _round = -1;

	/// <summary>Turn the whole system off: `nz_ambient 0`.</summary>
	/// ⛔ NOT CALLED `Enabled`. `Component` already has an `Enabled`, and a static of that name
	/// SHADOWS it — so every `if ( !Enabled )` inside this class silently reads the convar instead
	/// of "is this component running", and the compiler says so as CS0108 rather than an error.
	/// This project has hit that collision seven times now; `SonicDazeOverlay.ShowOverlay` carries
	/// the same note. The CONVAR NAME IS UNCHANGED — `nz_ambient` is what anyone actually types.
	[ConVar( "nz_ambient" )] public static bool AmbientOn { get; set; } = true;

	/// <summary>
	/// Chatter every arrival to the console.
	///
	/// ⚠️ OFF BY DEFAULT BUT WORTH HAVING, because "why is there no napalm zombie" has four
	/// possible answers — not due this round, at the alive cap, no usable spawn point, or the
	/// variant failed to load — and they need completely different fixes.
	/// </summary>
	[ConVar( "nz_ambient_debug" )] public static bool Debug { get; set; } = false;

	protected override void OnUpdate()
	{
		if ( !AmbientOn ) return;

		var rm = RoundManager.Instance;
		if ( !rm.IsValid() || rm.State != RoundState.Active ) return;

		// ⛔ NOR WHILE BASALT'S BOSS FIGHT FREEZES THE ROUND: the fight spawns its own (`HexPlatforms.FreezesRound`)
		if ( HexPlatforms.FreezesRound ) return;

		if ( rm.Round != _round ) Rebuild( rm.Round );

		foreach ( var t in _tracks ) Tick( t, rm );
	}

	/// <summary>Work out each rule's quota for a new round.</summary>
	void Rebuild( int round )
	{
		// ⚠️ A ROUND NUMBER GOING BACKWARDS IS A NEW GAME. Carrying a debt across one would have the
		// first ordinary round of a fresh run pay for a special round nobody played.
		if ( round < _round ) _carried.Clear();

		_round = round;
		_tracks.Clear();

		var cfg = ActiveConfig.Current;
		if ( cfg?.AmbientSpecials is null ) return;

		foreach ( var rule in cfg.AmbientSpecials )
		{
			var id = rule.Enemy ?? "";
			_carried.TryGetValue( id, out var carried );

			var due = rule.CountForRound( round );

			// ⛔ PER RULE, NOT GLOBAL, AND IT MOVES THE QUOTA RATHER THAN DELETING IT. See
			// `AmbientSpecial.SkipSpecialRounds` — this is the one place the three spawn systems
			// have to know about each other at all.
			if ( rule.SkipSpecialRounds && RoundManager.Instance.IsValid()
				&& RoundManager.Instance.InSpecialRound )
			{
				// ⚠️ A RULE THAT DOES NOT DEFER SIMPLY LOSES THE ROUND, which is correct for an
				// every-round enemy: there is another next round by definition, and carrying one
				// forward would stack a whole quota on top of the next. See `DeferOnSkip`.
				if ( !rule.DeferOnSkip )
				{
					if ( carried != 0 ) _carried[id] = 0;

					if ( Debug )
						Log.Info( $"[nz-ambient] round {round}: {due} x {rule.Enemy} skipped"
							+ " — special round, and this rule does not defer" );

					continue;
				}

				// ⚠️ CAPPED, SO A RUN OF SPECIALS CANNOT COMPOUND INTO AN AVALANCHE. It cannot happen
				// on Basalt — specials are every fifth round — but a map with an interval of 1 would
				// otherwise bank an arrival per round forever and pay it all at once the first time
				// it stopped.
				_carried[id] = Math.Min( carried + due, Math.Max( 1, due ) * 3 );

				if ( Debug )
					Log.Info( $"[nz-ambient] round {round}: {due} x {rule.Enemy} DEFERRED"
						+ $" — special round; {_carried[id]} now owed to the next ordinary round" );

				continue;
			}

			var n = due + carried;

			// ⚠️ CLEARED WHETHER OR NOT ANYTHING WAS OWED, so a debt cannot be paid twice.
			if ( carried != 0 ) _carried[id] = 0;

			if ( n <= 0 ) continue;

			// ⚠️ UNSPAWNED QUOTA IS DROPPED AT ROUND END, NOT CARRIED. A player who cleared a round
			// fast enough that the third Shrieker never arrived has earned that; banking it would
			// mean the next round opens with a backlog nobody caused.
			_tracks.Add( new Track
			{
				Rule = rule,
				Quota = n,
				Made = 0,
				Next = MathF.Max( 0f, rule.FirstDelay ),
			} );

			if ( Debug )
				Log.Info( $"[nz-ambient] round {round}: {n} x {rule.Enemy}"
					+ ( carried > 0 ? $" ({due} due + {carried} deferred)" : "" )
					+ $" (max {rule.MaxAliveForRound( round )} alive,"
					+ $" every {rule.SpawnDelay:0.#}s, first at {rule.FirstDelay:0.#}s)" );
		}
	}

	void Tick( Track t, RoundManager rm )
	{
		if ( t.Made >= t.Quota ) return;
		if ( t.Next > 0f ) return;

		// ⛔ THE ALIVE CAP IS CHECKED WITHOUT PUSHING THE TIMER, so the moment one dies the next
		// arrives rather than waiting out another interval. Same population-target shape the wave
		// spawner uses, and for the same reason: "there are always two Shriekers" should mean two,
		// not "two, then a gap you can feel".
		if ( AliveOf( t.Rule.Enemy ) >= t.Rule.MaxAliveForRound( rm.Round ) ) return;

		if ( !Spawn( t.Rule, rm ) ) return;

		t.Made++;
		t.Next = MathF.Max( 1f, t.Rule.SpawnDelay );
	}

	/// <summary>
	/// How many of this enemy are alive.
	///
	/// ⚠️ BY VARIANT PATH, NOT BY COMPONENT TYPE. The Pest has no component of its own and a future
	/// special may not either, so asking "does it have a ShriekerZombie" would work for exactly the
	/// two enemies that happen to have one today.
	/// </summary>
	static int AliveOf( string id )
	{
		var path = SpecialEnemies.PathFor( id );
		if ( string.IsNullOrWhiteSpace( path ) ) return 0;

		return ZombieAI.All.Count( z => z.IsValid()
			&& z.State != ZombieState.Dead
			&& (z.Variant?.ResourcePath?.EndsWith( path, StringComparison.OrdinalIgnoreCase ) ?? false) );
	}

	bool Spawn( AmbientSpecial rule, RoundManager rm )
	{
		var variant = SpecialEnemies.VariantFor( rule.Enemy );

		if ( variant is null )
		{
			if ( Debug ) Log.Warning( $"[nz-ambient] '{rule.Enemy}' did not load" );
			return false;
		}

		// ⚠️ BOSS POINTS ARE TAKEN NEAREST-FIRST, ordinary ones weighted toward the players — both
		// reusing what the round manager already does, so an ambient arrival reads the same as any
		// other and does not need its own idea of where the player is.
		var points = rule.UseBossSpawns
			? rm.BossSpawnsNearestFirst()
			: rm.EligibleSpawns;

		if ( points is null || points.Count == 0 )
		{
			if ( Debug )
				Log.Warning( $"[nz-ambient] no usable {( rule.UseBossSpawns ? "boss" : "zombie" )}"
					+ $" spawn for '{rule.Enemy}' — placed but gated, or none placed" );
			return false;
		}

		var point = rule.UseBossSpawns
			? points[Game.Random.Int( 0, points.Count - 1 )]
			: rm.PickSpawnNearPlayers( points );

		if ( point is null ) return false;

		var z = ZombieCommands.SpawnAt( Scene, point.Position, variant );
		if ( z is null ) return false;

		// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
		// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
		// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
		// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
		z.WorldRotation = point.Rotation;
		z.GameObject.Name = rule.Enemy;

		if ( Debug )
			Log.Info( $"[nz-ambient] round {rm.Round}: {rule.Enemy} at {point.Position:0}"
				+ $" — {AliveOf( rule.Enemy )} alive" );

		return true;
	}

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_ambient_report` — every rule, what it does this round, and what it will do next.
	///
	/// ⛔ IT PRINTS THE NEXT FEW ROUNDS, NOT JUST THIS ONE. A cadence is the thing most likely to be
	/// wrong and the thing least visible from a single round: "every 3 from 8" and "every 3 from 9"
	/// look identical until you see 8/11/14 against 9/12/15.
	/// </summary>
	[ConCmd( "nz_ambient_report" )]
	public static void Report()
	{
		var cfg = ActiveConfig.Current;
		var rm = Game.ActiveScene?.GetAllComponents<RoundManager>().FirstOrDefault();
		var round = rm.IsValid() ? rm.Round : 0;

		if ( cfg?.AmbientSpecials is null || cfg.AmbientSpecials.Count == 0 )
		{
			Log.Warning( "[nz-ambient] no ambient rules on this map's config" );
			return;
		}

		Log.Info( $"[nz-ambient] {( AmbientOn ? "ENABLED" : "DISABLED" )} · round {round}" );

		foreach ( var r in cfg.AmbientSpecials )
		{
			var t = r.TierFor( round );

			Log.Info( $"[nz-ambient] {r.Enemy,-10} from {r.FirstRound,3}"
				+ $" · {( r.UseBossSpawns ? "boss" : "zombie" )} spawns"
				+ $" · now {r.CountForRound( round )} due, max {r.MaxAliveForRound( round )} alive"
				+ $" · {AliveOf( r.Enemy )} alive"
				+ $"{( t is null ? "  (no tier)" : $"  (tier from {t.FromRound}, every {t.RoundInterval})" )}" );

			var next = new List<int>();
			for ( var i = round + 1; i <= round + 40 && next.Count < 6; i++ )
				if ( r.CountForRound( i ) > 0 ) next.Add( i );

			// ⚠️ THE MOVED ROUNDS ARE NAMED. A cadence that shifts an arrival by a round is fine; one
			// that silently LOSES it reads as "the napalm zombie is broken" months later, and the
			// two are indistinguishable from a schedule printed without this line.
			var moved = r.SkipSpecialRounds
				? next.Where( i => cfg.IsSpecialRound( i ) ).Select( i => $"{i}->{i + 1}" ).ToList()
				: new List<string>();

			var owed = _Owed( r.Enemy );

			Log.Info( $"[nz-ambient]   next: {( next.Count == 0 ? "none in 40 rounds" : string.Join( ", ", next ) )}"
				+ ( moved.Count == 0 ? "" : $"   (special round, moved: {string.Join( ", ", moved )})" )
				+ ( owed > 0 ? $"   [{owed} deferred right now]" : "" ) );
		}
	}

	/// <summary>How many arrivals are owed to the next ordinary round, for the report.</summary>
	static int _Owed( string id )
	{
		var sys = Game.ActiveScene?.GetAllComponents<AmbientSpecials>().FirstOrDefault();

		return sys.IsValid() && sys._carried.TryGetValue( id ?? "", out var n ) ? n : 0;
	}

	/// <summary>`nz_ambient_now [id]` — force this round's rules to fire immediately.</summary>
	[ConCmd( "nz_ambient_now" )]
	public static void NowCmd( string id = "" )
	{
		var sys = Game.ActiveScene?.GetAllComponents<AmbientSpecials>().FirstOrDefault();
		var rm = RoundManager.Instance;

		if ( !sys.IsValid() || !rm.IsValid() ) { Log.Warning( "[nz-ambient] not running" ); return; }

		int n = 0;

		foreach ( var t in sys._tracks )
		{
			if ( !string.IsNullOrWhiteSpace( id ) && t.Rule.Enemy != id ) continue;
			t.Next = 0f;
			n++;
		}

		Log.Info( $"[nz-ambient] {n} rule(s) armed to spawn on the next tick" );
	}
}