Console command handlers for configuring and inspecting "special" rounds and special spawners. Exposes commands to view status, schedule the next special round, change tuning (enabled, first, interval, count, alive, delay, hp, speed), pick/place special enemies in the editor, add a spawn at the player, toggle fog/announce/loop/sounds, and clear spawns.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Console access to special rounds — the roster, the spawners, the tuning and a
/// way to trigger one without playing five rounds to reach it.
///
/// ⚠️ EVERY SETTING IN THE PANEL HAS A COMMAND HERE. The panel and these read and
/// write the SAME MapConfig fields, so neither can drift from the other, and a
/// feature that can only be reached by clicking cannot be tested remotely.
/// </summary>
public static class SpecialCommands
{
static RoundManager Rounds
=> Game.ActiveScene?.GetAllComponents<RoundManager>().FirstOrDefault();
/// <summary>What special rounds are doing right now: `nz_specials`.</summary>
[ConCmd( "nz_specials" )]
public static void Status()
{
var cfg = ActiveConfig.Current;
var sp = cfg.Specials;
var rm = Rounds;
Log.Info( $"[nz-special] {(sp.Enabled ? "ENABLED" : "DISABLED")} · "
+ $"round {sp.FirstRound} then every {sp.RoundInterval} · "
+ $"{sp.CountPerRound} per round, {sp.MaxAlive} alive, every {sp.SpawnDelay:0.00}s" );
Log.Info( $"[nz-special] hp x{sp.HealthMultiplier:0.##} speed x{sp.SpeedMultiplier:0.##}" );
Log.Info( $"[nz-special] roster: {string.Join( ", ", SpecialEnemies.Names )}" );
// ⛔ WHICH SET THE ROUND DRAWS FROM DECIDES WHAT "NO SPAWNS" MEANS. A map using
// `UseZombieSpawns` fields its special round through the ORDINARY windows and has no reason
// to place a special spawner at all — and this block used to answer that with "special
// rounds will never trigger", which is both false and the single most convincing thing a
// mapper could be told while staring at a round that works.
if ( cfg.Specials.UseZombieSpawns )
{
Log.Info( $"[nz-special] from the ORDINARY zombie spawns"
+ $" ({cfg.ZombieSpawns.Count} placed)"
+ $" — {( string.IsNullOrWhiteSpace( cfg.Specials.Enemy )
? "per spawn point" : cfg.Specials.Enemy )}"
+ $", {cfg.Specials.SpawnsPerTick} per tick" );
if ( cfg.ZombieSpawns.Count == 0 )
Log.Warning( "[nz-special] NO ZOMBIE SPAWNS PLACED — and this map draws its "
+ "special round from them, so it will never trigger" );
}
else if ( cfg.SpecialSpawns.Count == 0 )
{
// ⚠️ The single most likely reason a special round "does not work".
// IsSpecialRound returns false with no spawners, on purpose — see
// RoundManager — so say it plainly rather than reporting ENABLED and
// leaving the mapper to wonder.
Log.Warning( "[nz-special] NO SPECIAL SPAWNS PLACED — special rounds "
+ "will never trigger. Place one: Q > Placeables > Spawners > Special spawn, "
+ "or set Specials.UseZombieSpawns to use the ordinary ones" );
return;
}
if ( !cfg.Specials.UseZombieSpawns )
foreach ( var g in cfg.SpecialSpawns.GroupBy( s =>
string.IsNullOrWhiteSpace( s.Special ) ? SpecialEnemies.Fallback : s.Special ) )
Log.Info( $"[nz-special] {g.Count(),3} x {g.Key}" );
if ( rm.IsValid() )
{
var next = NextSpecialAfter( rm.Round, sp, cfg.SpecialSpawns.Count );
Log.Info( $"[nz-special] round {rm.Round}"
+ (rm.InSpecialRound ? " — SPECIAL ROUND IN PROGRESS" : "")
+ (next > 0 ? $" · next special round: {next}" : " · none upcoming") );
}
}
/// <summary>First special round strictly after `round`, or 0 if never.</summary>
static int NextSpecialAfter( int round, SpecialSettings sp, int spawners )
{
if ( !sp.Enabled || sp.RoundInterval < 1 || spawners == 0 ) return 0;
if ( round < sp.FirstRound ) return sp.FirstRound;
var since = round - sp.FirstRound;
return sp.FirstRound + (since / sp.RoundInterval + 1) * sp.RoundInterval;
}
/// <summary>Make the NEXT round a special one: `nz_special_next`.
///
/// ⚠️ Moves FirstRound rather than forcing a flag, so the change is visible in
/// the settings panel and survives into the save. A hidden one-shot override
/// would test a code path the real game never takes.</summary>
[ConCmd( "nz_special_next" )]
public static void Next()
{
var rm = Rounds;
if ( !rm.IsValid() ) { Log.Warning( "[nz-special] no round manager" ); return; }
// ⚠️ IT ASKS THE SET THE ROUND WOULD ACTUALLY USE. Refusing on an empty SPECIAL list while
// the map draws from the ordinary one is a command blocking a feature that works.
var useNormal = ActiveConfig.Current.Specials.UseZombieSpawns;
if ( (useNormal ? ActiveConfig.Current.ZombieSpawns.Count
: ActiveConfig.Current.SpecialSpawns.Count) == 0 )
{
Log.Warning( $"[nz-special] no {( useNormal ? "zombie" : "special" )} spawns placed"
+ " — nothing would spawn" );
return;
}
var sp = ActiveConfig.Current.Specials;
sp.Enabled = true;
sp.FirstRound = rm.Round + 1;
Log.Info( $"[nz-special] round {sp.FirstRound} is now a special round "
+ $"(then every {sp.RoundInterval})" );
}
/// <summary>Tune special rounds: `nz_special_set <field> <value>`.
///
/// Fields: enabled, first, interval, count, alive, delay, hp, speed.</summary>
[ConCmd( "nz_special_set" )]
public static void Set( string field = "", string value = "" )
{
var sp = ActiveConfig.Current.Specials;
if ( string.IsNullOrWhiteSpace( field ) )
{
Log.Info( "[nz-special] nz_special_set <enabled|first|interval|count|"
+ "alive|delay|hp|speed> <value>" );
return;
}
float.TryParse( value, out var n );
switch ( field.ToLowerInvariant() )
{
case "enabled": sp.Enabled = value is "1" or "true" or "on"; break;
case "first": sp.FirstRound = (int)n; break;
case "interval": sp.RoundInterval = (int)n; break;
case "count": sp.CountPerRound = (int)n; break;
case "alive": sp.MaxAlive = (int)n; break;
case "delay": sp.SpawnDelay = n; break;
case "hp": sp.HealthMultiplier = n; break;
case "speed": sp.SpeedMultiplier = n; break;
default:
Log.Warning( $"[nz-special] unknown field '{field}'" );
return;
}
// ⚠️ READ BACK, don't echo the request. Every one of these clamps or casts
// — "hp 0.05" lands on the 0.1 floor — and a command that prints what you
// asked for rather than what stuck is how a setting looks applied when it
// is not. Same lesson as nz_wep_set.
Status();
}
/// <summary>Which special the editor will stamp next: `nz_special_pick [name]`.</summary>
[ConCmd( "nz_special_pick" )]
public static void Pick( string name = "" )
{
var e = Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();
if ( !e.IsValid() ) { Log.Warning( "[nz-special] no map editor" ); return; }
if ( string.IsNullOrWhiteSpace( name ) )
{
Log.Info( $"[nz-special] placing '{e.SpecialEnemy}' · "
+ $"roster: {string.Join( ", ", SpecialEnemies.Names )}" );
return;
}
if ( !SpecialEnemies.IsKnown( name ) )
{
Log.Warning( $"[nz-special] '{name}' is not in the roster — "
+ $"{string.Join( ", ", SpecialEnemies.Names )}" );
return;
}
e.SpecialEnemy = name;
Log.Info( $"[nz-special] placing '{name}' — {SpecialEnemies.DescriptionFor( name )}" );
}
/// <summary>Drop a special spawn at your feet: `nz_special_here [enemy]`.
///
/// The console equivalent of clicking one down in Creative. Exists so the
/// feature can be exercised without entering the editor — and because a
/// placeable that can ONLY be placed by clicking cannot be tested remotely.</summary>
[ConCmd( "nz_special_here" )]
public static void Here( string enemy = "" )
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz-special] no player" ); return; }
var name = string.IsNullOrWhiteSpace( enemy ) ? SpecialEnemies.Fallback : enemy;
if ( !SpecialEnemies.IsKnown( name ) )
{
Log.Warning( $"[nz-special] '{name}' is not in the roster — "
+ $"{string.Join( ", ", SpecialEnemies.Names )}" );
return;
}
ActiveConfig.Current.SpecialSpawns.Add(
new SpawnPoint( player.WorldPosition, player.WorldRotation.Yaw() )
{
Special = name,
} );
Log.Info( $"[nz-special] {name} spawn placed at {player.WorldPosition} "
+ $"({ActiveConfig.Current.SpecialSpawns.Count} total)" );
}
/// <summary>`nz_special_fog [0|1]` — does a special round bring its fog? The config in memory — `nz_save` keeps it.</summary>
[ConCmd( "nz_special_fog" )]
public static void FogCmd( int on = -1 )
{
var sp = ActiveConfig.Current.Specials;
if ( on >= 0 ) sp.Fog = on != 0;
Log.Info( $"[nz-special] fog in a special round: {(sp.Fog ? "ON" : "off")}{(on >= 0 ? " — nz_save keeps it" : "")}" );
}
/// <summary>`nz_special_announce [0|1]` — does the announcer call a special round? The config in memory — `nz_save` keeps it.</summary>
[ConCmd( "nz_special_announce" )]
public static void AnnounceCmd( int on = -1 )
{
var sp = ActiveConfig.Current.Specials;
if ( on >= 0 ) sp.Announce = on != 0;
Log.Info( $"[nz-special] the announcer's call: {(sp.Announce ? "ON" : "off")}{(on >= 0 ? " — nz_save keeps it" : "")}" );
}
/// <summary>
/// `nz_special_loop [cue|-]` — the sound looped while a special round lasts, a cue under `sounds/nz/`; `-` clears it. Bare,
/// what it is and whether the sound exists. The config in memory — `nz_save` keeps it.
/// </summary>
[ConCmd( "nz_special_loop" )]
public static void LoopCmd( string cue = "" )
{
var sp = ActiveConfig.Current.Specials;
if ( !string.IsNullOrWhiteSpace( cue ) ) sp.Loop = cue.Trim() == "-" ? "" : cue.Trim();
var loop = sp.Loop?.Trim() ?? "";
Log.Info( loop.Length == 0
? "[nz-special] no loop in a special round"
: $"[nz-special] looped in a special round: '{loop}'"
+ (NZSound.Exists( loop ) ? "" : $" — ⚠ NO SUCH SOUND, sounds/nz/{loop}.sound")
+ (NZMusic.Current == loop ? " · PLAYING NOW" : "") );
}
/// <summary>
/// `nz_special_sounds [0|1]` — does a special round play the round's start and end sounds? The config in memory — `nz_save`
/// keeps it.
/// </summary>
[ConCmd( "nz_special_sounds" )]
public static void SoundsCmd( int on = -1 )
{
var sp = ActiveConfig.Current.Specials;
if ( on >= 0 ) sp.RoundSounds = on != 0;
Log.Info( $"[nz-special] the round's start and end sounds in a special round: {(sp.RoundSounds ? "ON" : "off")}"
+ (on >= 0 ? " — nz_save keeps it" : "") );
}
/// <summary>Remove every special spawn: `nz_special_clear`.</summary>
[ConCmd( "nz_special_clear" )]
public static void Clear()
{
var n = ActiveConfig.Current.SpecialSpawns.Count;
ActiveConfig.Current.SpecialSpawns.Clear();
Log.Info( $"[nz-special] removed {n} special spawn(s) — "
+ "special rounds will no longer trigger" );
}
}