Rounds/BossCommands.cs

Console commands and helpers for boss spawn points and boss-related reports. Provides listing, placement, clearing, immediate spawning at the player or at placed points, pool reporting, and tuning of same-level height; uses RoundManager, ActiveConfig and SpecialEnemies to enforce eligibility and roster rules.

File Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// Boss spawn points: place them, list them, use them.
///
/// ⛔ A SEPARATE FILE FROM `SpecialCommands` BECAUSE THE TWO ROSTERS ARE SEPARATE SETS. Bosses and
/// specials share a `SpawnPoint` shape and nothing else — different config list, different
/// eligibility question, different roster (`SpecialEnemies.BossNames` vs `NonBossNames`). Folding
/// them together would mean every command taking a "which kind" argument that is never in doubt at
/// the call site.
///
/// ⚠️ THE SCHEDULE EXISTS AS DATA; NOTHING CONSULTS IT YET. `BossSettings` holds the first round,
/// the interval, the count and the ramp, and `IsBossRound` / `CountForRound` compute them — but no
/// round loop asks. So the numbers are editable, previewable and correct, and a boss still only
/// appears via `nz_boss_spawn` or the dev menu button.
///
/// ⚠️ THAT ORDER IS DELIBERATE. Spawn points, then the boss, then the schedule as data, then the
/// round loop that reads it — each step testable before the next. A scheduler built first would have
/// had nowhere to put a boss and no boss to put.
/// </summary>
public static class BossCommands
{
	static MapConfig Cfg => ActiveConfig.Current;

	static NZPlayer Me()
		=> NZPlayer.Local;

	/// <summary>
	/// `nz_bosses` — every boss spawn placed, and whether it can be used right now.
	///
	/// ⛔ IT SEPARATES "NONE PLACED" FROM "ALL GATED", the distinction Phase Shift's warning got
	/// wrong until it was corrected. They need opposite fixes — place some, versus open a door —
	/// and a single "no boss spawns" message cannot tell you which.
	/// </summary>
	/// <summary>
	/// `nz_boss_level_height [units]` — how far above or below a player a boss spawn still counts
	/// as the same level. No argument reports it.
	///
	/// ⚠️ A COMMAND BECAUSE IT IS A JUDGEMENT CALL, not a constant. What reads as "the same floor"
	/// depends on the map — a ramp, a mezzanine and a stairwell landing all sit at heights a fixed
	/// number will get wrong somewhere — and the alternative to tuning it is arguing about it in a
	/// comment.
	/// </summary>
	[ConCmd( "nz_boss_level_height" )]
	public static void LevelHeight( float units = -1f )
	{
		if ( units >= 0f ) RoundManager.BossSameLevelHeight = units;

		Log.Info( $"[nz-boss] same-level height: {RoundManager.BossSameLevelHeight:0}u"
			+ " — a spawn within this much of a player's z ranks above any spawn on another floor"
			+ (units >= 0f ? "" : " (nz_boss_level_height <units> to change)") );
	}

	[ConCmd( "nz_bosses" )]
	public static void List()
	{
		var placed = Cfg?.BossSpawns ?? new System.Collections.Generic.List<SpawnPoint>();
		// ⚠️ `Ensure` HERE TOO, so the listing's "usable right now" column is answerable in Creative
		// rather than reading 0 because no manager happens to exist yet.
		var rm = RoundManager.Ensure();
		var eligible = rm?.EligibleBossSpawns?.Count ?? 0;

		// ⚠️ THE ORDER IS THE ANSWER TO "why did it spawn THERE". `BossSpawnsNearestFirst` is what
		// both the scheduler and `nz_boss_spawn` consume, so printing the list it returns — rather
		// than the config order — is the only version of it that explains a spawn after the fact.
		var ordered = rm?.BossSpawnsNearestFirst() ?? new System.Collections.Generic.List<SpawnPoint>();
		var who = Me();

		Log.Info( $"[nz-boss] roster: {(SpecialEnemies.BossNames.Length == 0 ? "NONE" : string.Join( ", ", SpecialEnemies.BossNames ))}" );

		// ⛔ THE SCHEDULE IS PRINTED BEFORE THE POINTS, because seven interacting numbers decide it
		// and no one can read them off the fields. `Preview` runs the real `IsBossRound` and
		// `CountForRound`, so this line cannot drift from what the round loop will actually do.
		var b = ActiveConfig.Bosses;

		Log.Info( $"[nz-boss] schedule: {(b.Enabled ? "on" : "OFF")}"
			+ $" · first round {b.FirstRound}, then every {b.RoundInterval}"
			+ $" · {b.CountPerRound} per round"
			+ (b.RampFromRound > 0 && b.CountStep > 0
				? $" · +{b.CountStep} from round {b.RampFromRound}"
					+ (b.RampInterval > 0 ? $" and every {b.RampInterval} after" : " (once)")
				: " · no ramp")
			+ $" · cap {b.MaxAlive}"
			+ " · arrives DURING a normal round, never on a special one" );

		Log.Info( $"[nz-boss]   arrives on: {Cfg?.BossPreview( 8 ) ?? "no config"}" );

		// ⛔ THE HEALTH CURVE, AS RESOLVED NUMBERS RATHER THAN THE TWO INPUTS. A boss's health is
		// `HealthForRound(round) × (authored + elapsed × HealthPerRound)` — two curves multiplied,
		// one of which climbs with no cap (2026-10-06). Nobody reads that off `x15` and `+1`, and "is Brutus too
		// spongy" is a question about the product.
		//
		// ⚠️ THE EFFECTIVE POOL IS PRINTED TOO. `BrutusHelmet` scales damage BEFORE it lands
		// (body ×0.15), so raw HP understates what the player must actually deal by ~7x — and the
		// raw number is the one that makes the curve look reasonable when it is not.
		// ⛔ PRINTED WHETHER OR NOT THE PER-ROUND GROWTH IS ON. It was gated on
		// `HealthPerRound > 0` and the table vanished the moment that went back to 0 — but the
		// health curve is the thing being asked about, and "flat ×15 on a walker curve that
		// keeps growing" is exactly as much of an answer as the growing version. A report
		// that goes silent when a feature is off tells you nothing about the feature OR the
		// thing it was modifying.
		{
			Log.Info( b.HealthPerRound > 0f
				? $"[nz-boss]   health: variant multiplier +{b.HealthPerRound:0.##} per round past {b.FirstRound}"
				: "[nz-boss]   health: flat variant multiplier, no per-round growth"
					+ " (BossSettings.HealthPerRound = 0)" );

			// ⛔ THE AUTHORED MULTIPLIER IS READ FROM THE VARIANT, NOT SPELLED OUT HERE. It is
			// 15 today and it lives in `zombies/brutus.zvar`; writing 15 in this report would be
			// a second copy of one number, and the report is precisely where somebody would go
			// to check after retuning the first. Falls back to 1 if the roster is empty.
			var bossVar = SpecialEnemies.VariantFor( SpecialEnemies.BossFallback );
			var authored = bossVar?.HealthMultiplier ?? 1f;

			foreach ( var r in new[] { b.FirstRound, b.FirstRound + 10, b.FirstRound + 20,
				b.FirstRound + 30, b.FirstRound + 50 } )
			{
				var mult = authored + MathF.Max( 0f, r - b.FirstRound ) * b.HealthPerRound;
				var hp = ZombieStats.HealthForRound( r ) * mult;
				Log.Info( $"[nz-boss]     round {r,3}  x{mult,-5:0.#} {hp,12:N0} hp"
					+ $"  ({hp / NZombies.BrutusHelmet.BodyScale,14:N0} effective, body hits)" );
			}
		}

		if ( ordered.Count > 0 && who.IsValid() )
		{
			Log.Info( $"[nz-boss] pick order (nearest first, same level wins) —"
				+ $" same-level height {RoundManager.BossSameLevelHeight:0}u:" );

			for ( var i = 0; i < ordered.Count && i < 8; i++ )
			{
				var s = ordered[i];
				var dz = s.Position.z - who.WorldPosition.z;
				var flat = s.Position.WithZ( 0 ).Distance( who.WorldPosition.WithZ( 0 ) );
				var level = MathF.Abs( dz ) <= RoundManager.BossSameLevelHeight
					? "same level" : $"{(dz > 0 ? "above" : "below")} by {MathF.Abs( dz ):0}u";

				Log.Info( $"[nz-boss]   {i + 1}. {flat:0}u away, {level}" );
			}

			if ( ordered.Count > 8 )
				Log.Info( $"[nz-boss]   ... and {ordered.Count - 8} more" );
		}
		else if ( ordered.Count > 0 )
		{
			Log.Info( "[nz-boss] pick order: config order — nobody is up, so there is no"
				+ " nearest player to sort by" );
		}

		if ( Cfg?.BossNeverArrives() ?? false )
			Log.Warning( "[nz-boss] ⛔ SCHEDULE CLASH — every round a boss is due is also a special"
				+ " round, so none will ever arrive. Change the boss interval or the special one." );

		if ( placed.Count == 0 )
		{
			Log.Info( "[nz-boss] no boss spawns placed on this map — stand where you want one"
				+ " and run `nz_boss_here`, or use the Boss spawn tool in the dev menu" );
			return;
		}

		Log.Info( $"[nz-boss] {placed.Count} placed, {eligible} usable right now"
			+ $" (round {rm?.Round ?? 0}, power {(Power.IsOn ? "on" : "off")})" );

		for ( var i = 0; i < placed.Count; i++ )
		{
			var s = placed[i];
			var ok = rm is not null && s.IsEligible( rm.Round, Power.IsOn );

			Log.Info( $"[nz-boss]   #{i} {s.Special,-10} at {s.Position}"
				+ $" · link {(DoorLinks.IsUnlinked( s.Link ) ? "-" : s.Link)}"
				+ (s.ActiveRound > 1 ? $" · from round {s.ActiveRound}" : "")
				+ (s.RequiresPower ? " · needs power" : "")
				+ $" · {(ok ? "USABLE" : "gated")}" );
		}

		if ( eligible == 0 )
			Log.Warning( "[nz-boss] every one is gated — a door is unbought, the power is off,"
				+ " or the round is too early" );
	}

	/// <summary>
	/// `nz_boss_here [name]` — place a boss spawn where you stand.
	///
	/// ⚠️ IT FACES THE WAY YOU DO, so a boss appears looking where you were looking. The editor
	/// tool does the same; this is the console equivalent for when the dev menu is inconvenient.
	/// </summary>
	[ConCmd( "nz_boss_here" )]
	public static void Here( string name = "" )
	{
		var p = Me();
		if ( !p.IsValid() ) { Log.Warning( "[nz-boss] no player" ); return; }
		if ( Cfg is null ) { Log.Warning( "[nz-boss] no map config" ); return; }

		var pick = string.IsNullOrWhiteSpace( name ) ? SpecialEnemies.BossFallback : name;

		// ⚠️ CHECKED AGAINST THE BOSS ROSTER, NOT THE FULL ONE. `nz_boss_here hellhound` should be
		// refused rather than quietly placing a dog on a boss point — `IsBossName` reads the
		// variant's own flag, which is what `SpecialEnemies` insists on for a rule.
		if ( !SpecialEnemies.IsBossName( pick ) )
		{
			Log.Warning( $"[nz-boss] '{pick}' is not a boss. Bosses are: "
				+ $"{string.Join( ", ", SpecialEnemies.BossNames )}" );
			return;
		}

		Cfg.BossSpawns.Add( new SpawnPoint( p.WorldPosition, p.WorldRotation.Yaw() )
		{
			Special = pick,
		} );

		Log.Info( $"[nz-boss] {pick} spawn placed at {p.WorldPosition}"
			+ $" ({Cfg.BossSpawns.Count} total)" );

		// ⛔ A BOSS NEEDS FLOOR SPACE AND A SPAWN POINT CANNOT CHECK THAT FOR YOU. Brutus is 80
		// units tall with a 22-unit body radius; a doorway that a walker fits through can wedge him,
		// and it stays invisible until one actually spawns there.
		if ( Cfg.BossSpawns.Count == 1 )
			Log.Info( "[nz-boss]   ⚠ place these in open floor — a boss is much larger than a"
				+ " walker and a doorway will trap him" );
	}

	/// <summary>
	/// `nz_boss_at [name] [forward]` — put a boss on the floor in front of you, now.
	///
	/// ⛔ IT IGNORES PLACED SPAWNS ENTIRELY, WHICH IS THE POINT. `nz_boss_spawn` deliberately uses a
	/// placed point so it exercises the config data and the eligibility gating; that makes it the
	/// wrong tool for "let me look at the boss", because it refuses on a map with no boss spawns and
	/// drops him somewhere you are not. Both exist because they answer different questions.
	///
	/// ⚠️ IT STILL GOES THROUGH `SpawnBossAt` — via a throwaway `SpawnPoint` — rather than spawning
	/// one itself. That method owns the roster check, the variant lookup and the naming, and a
	/// bespoke spawn here would be a second author for all three.
	///
	/// ⚠️ HE FACES YOU. A boss dropped in front of you looking away is useless for looking at, which
	/// is the entire reason to use this over the other one.
	/// </summary>
	[ConCmd( "nz_boss_at" )]
	public static void SpawnHere( string name = "", float forward = 300f )
	{
		var scene = Game.ActiveScene;

		var p = Me();
		if ( !p.IsValid() ) { Log.Warning( "[nz-boss] no player" ); return; }

		// ⛔ `Ensure`, NOT `Instance` — same reason as `Spawn`. The dev menu runs in Creative, where
		// no manager exists until the wave loop first starts.
		var rm = RoundManager.Ensure();
		if ( rm is null ) { Log.Warning( "[nz-boss] no scene" ); return; }

		// ⚠️ EYE ANGLES FROM THE PLAYER, NOT `Scene.Camera` — the camera is a separate object that
		// trails the player, so "300 in front of me" measured from it lands off screen. This is the
		// mistake `nz_spawn_at` documents having made.
		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;
		var flat = rot.Forward.WithZ( 0 ).Normal;
		var at = p.WorldPosition + flat * MathF.Max( 64f, forward );

		// ⚠️ DROP TO THE FLOOR, or he spawns in the air and the navmesh agent has nothing to stand
		// on — which presents as a boss that does not move.
		var tr = scene.Trace.Ray( at + Vector3.Up * 128f, at - Vector3.Up * 4096f ).Run();
		var pos = tr.Hit ? tr.HitPosition : at;

		var facing = Rotation.LookAt( (p.WorldPosition - pos).WithZ( 0 ).Normal ).Yaw();

		var go = rm.SpawnBossAt(
			new SpawnPoint( pos, facing ) { Special = string.IsNullOrWhiteSpace( name ) ? SpecialEnemies.BossFallback : name },
			string.IsNullOrWhiteSpace( name ) ? null : name );

		if ( go is null ) { Log.Warning( "[nz-boss] spawn refused — see the reason above" ); return; }

		Log.Info( $"[nz-boss] dropped in front of you at {pos}"
			+ $" ({(tr.Hit ? "on the floor" : "NO FLOOR FOUND — he may be in the air")})" );
	}

	/// <summary>`nz_boss_pool` — this map's boss pool (`BossSettings.Pool`, 2026-10-07): what a boss round picks from, which of
	/// them are alive, and what the next pick could be.</summary>
	[ConCmd( "nz_boss_pool" )]
	public static void PoolReport()
	{
		var raw = ActiveConfig.Current?.Bosses?.Pool;
		var pool = RoundManager.PoolBosses();
		if ( pool.Count == 0 )
		{
			Log.Info( $"[nz-boss] no pool on this map{(raw is { Count: > 0 } ? $" (set to '{string.Join( ", ", raw )}', none of them a boss)" : "")}"
				+ " — each boss is its spawn point's own" );
			return;
		}

		Log.Info( $"[nz-boss] pool: {string.Join( ", ", pool.Select( n => RoundManager.BossAlive( n ) ? n + " (ALIVE)" : n ) )}"
			+ " — a boss round picks at random, never the same one twice nor one alive while others are left;"
			+ " `nz_boss_spawn` with no name picks from it too" );
	}

	/// <summary>`nz_boss_clear` — remove every boss spawn.</summary>
	[ConCmd( "nz_boss_clear" )]
	public static void Clear()
	{
		if ( Cfg is null ) return;

		var n = Cfg.BossSpawns.Count;
		Cfg.BossSpawns.Clear();

		Log.Info( $"[nz-boss] removed {n} boss spawn(s) — bosses can no longer appear" );
	}

	/// <summary>
	/// `nz_boss_spawn [name]` — put a boss at one of its points, now.
	///
	/// ⚠️ IT USES A PLACED POINT RATHER THAN YOUR POSITION, because that is the thing being tested.
	/// `nz_spawn_at` already exists for dropping a zombie in front of you; this exercises the
	/// eligibility gating and the config data.
	/// </summary>
	[ConCmd( "nz_boss_spawn" )]
	public static void Spawn( string name = "" )
	{
		// ⛔ `Ensure`, NOT `Instance`. This reported "no round manager — press Play first" to a player
		// who WAS in play: a manager is only created when the wave loop first starts, and the dev
		// menu runs in Creative where it never has. Creating one is harmless — it sits in
		// `RoundState.Waiting`, which the update switch has no case for.
		var rm = RoundManager.Ensure();

		if ( rm is null ) { Log.Warning( "[nz-boss] no scene" ); return; }

		var spawns = rm.BossSpawnsNearestFirst();

		if ( spawns.Count == 0 )
		{
			var placed = Cfg?.BossSpawns?.Count ?? 0;

			Log.Warning( placed == 0
				? "[nz-boss] no boss spawns placed — use `nz_boss_here`"
				: $"[nz-boss] all {placed} boss spawn(s) are gated right now"
					+ " (door unbought, power off, or round not reached)" );
			return;
		}

		// ⚠️ THE NEAREST, NOT A RANDOM ONE. This used to roll uniformly, which on a map with
		// spawners spread across three floors put the boss somewhere you had to go looking for it.
		// The list arrives sorted; index 0 is the closest point on the player's own level.
		var point = spawns[0];

		// ⚠️ NO NAME ON A MAP WITH A POOL (`BossSettings.Pool`, 2026-10-07): a pick from it, never one alive — so two in a row are
		// two different ones, as a boss round's would be
		var pick = string.IsNullOrWhiteSpace( name ) ? RoundManager.PickPooledBoss() : name;
		var go = rm.SpawnBossAt( point, pick );

		if ( go is null )
			Log.Warning( "[nz-boss] spawn refused — see the reason above" );
	}
}