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