Console command helpers for creative-mode round controls. Exposes concommands to start/stop/step rounds, adjust timing (prep/first round delay), spawn selection weighting and path-cost options, and debug/status outputs about current round and spawn costs.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// ROUND/COMMANDS — the creative-mode round controls.
///
/// In survival the wave loop runs itself. In creative you drive it by hand,
/// which is how you test a config without playing thirty waves to see whether
/// round 30 is survivable.
/// </summary>
public static class RoundCommands
{
/// <summary>
/// The manager, created on demand.
///
/// ⚠️ Lives on its own GameObject rather than the player — a round survives
/// the player dying, and hanging it off something that gets destroyed is how
/// a wave loop silently stops mid-game.
/// </summary>
/// <remarks>
/// ⚠️ DELEGATED TO `RoundManager.Ensure`, which the dev menu also needs — the create-on-demand
/// logic and the "its own GameObject" rule now have one author (§3).
/// </remarks>
static RoundManager Manager => RoundManager.Ensure();
/// <summary>Start the wave loop from round 1.</summary>
[ConCmd( "nz_round_start" )]
public static void Start()
{
var m = Manager;
if ( m is null ) { Log.Warning( "[nz] no scene" ); return; }
if ( ActiveConfig.Current.ZombieSpawns.Count == 0 )
Log.Warning( "[nz] no zombie spawns placed — the round will have "
+ "nowhere to spawn. nz_tool zombie_spawn, then nz_place." );
m.StartGame();
}
[ConCmd( "nz_round_stop" )]
public static void Stop() => Manager?.Stop();
/// <summary>Skip forward a round, abandoning the current wave.</summary>
[ConCmd( "nz_round_next" )]
public static void Next() => Manager?.NextRound();
/// <summary>Step back a round.</summary>
[ConCmd( "nz_round_prev" )]
public static void Prev() => Manager?.PreviousRound();
/// <summary>Jump to a round — for checking a late-game curve directly.</summary>
[ConCmd( "nz_round_set" )]
public static void SetRound( int round = 1 ) => Manager?.SetRound( round );
/// <summary>
/// `nz_round1_delay [seconds]` — how long round 1 waits before its first zombie (`Gameplay.FirstRoundDelay`, 3 by default).
/// The config in memory — `nz_save` keeps it.
/// </summary>
[ConCmd( "nz_round1_delay" )]
public static void FirstRoundDelay( float seconds = -1f )
{
var g = ActiveConfig.Gameplay;
if ( seconds >= 0f ) g.FirstRoundDelay = seconds;
Log.Info( $"[nz] round 1 waits {g.FirstRoundDelay:0.#}s before its first zombie" + (seconds >= 0f ? " — nz_save keeps it" : "") );
}
/// <summary>
/// `nz_round_prep [seconds] [first]` — the breather between rounds, live.
///
/// ⚠️ IT IS A FEEL NUMBER, SO IT HAS TO BE MOVABLE WITHOUT A REBUILD. 7.5 vs 15 is not
/// something anyone can settle by reading it; the halving that this doubling reversed was
/// argued for on paper and played wrong.
///
/// ⚠️ IT TAKES EFFECT ON THE **NEXT** PREP, not this one. `BeginPrep` reads `PrepTime` once and
/// writes `_nextPhase`; changing the field mid-breather does not move a countdown already
/// running. Set it during a round, not between two.
///
/// ⚠️ `first` IS THE PRE-ROUND-1 WAIT AND IS DELIBERATELY 1 SECOND — *"you should be fighting
/// almost immediately on starting"*. It was left out of the doubling on purpose; it is here so
/// the two can be compared rather than so it should be changed.
/// </summary>
[ConCmd( "nz_round_prep" )]
public static void PrepTimeCmd( float seconds = -1f, float first = -1f )
{
var m = RoundManager.Instance;
if ( !m.IsValid() ) { Log.Info( "[nz] no round manager — nz_round_start" ); return; }
if ( seconds >= 0f ) m.PrepTime = seconds;
if ( first >= 0f ) m.FirstPrepTime = first;
Log.Info( $"[nz] prep {m.PrepTime:0.#}s between rounds · {m.FirstPrepTime:0.#}s before round 1"
+ ( m.State == RoundState.Prep ? " (a prep is running — this applies to the NEXT one)" : "" ) );
}
/// <summary>Where the wave loop is, and what the curves say for this round.</summary>
[ConCmd( "nz_round" )]
public static void Status()
{
var m = RoundManager.Instance;
if ( !m.IsValid() ) { Log.Info( "[nz] no round manager — nz_round_start" ); return; }
Log.Info( $"[nz] {m.State} {m.Summary}" );
var r = Math.Max( 1, m.Round );
Log.Info( $"[nz] curve @ round {r}: {ZombieStats.HealthForRound( r )} hp, "
+ $"speed rating {ZombieStats.SpeedForRound( r )} "
+ $"({WalkerAnimations.TierName( ZombieStats.SpeedForRound( r ) )}), "
+ $"wave {ZombieStats.WaveTotal( r, 1 )}, "
+ $"max alive {ZombieStats.MaxAliveForRound( r )}, "
+ $"delay {ZombieStats.SpawnDelayForRound( r ):0.00}s" );
Log.Info( $"[nz] zombie spawns placed: "
+ $"{ActiveConfig.Current.ZombieSpawns.Count}"
+ $", eligible now: {m.EligibleSpawns.Count}" );
Log.Info( $"[nz] spawns used this wave: {m.SpawnUsage}" );
}
/// <summary>
/// `nz_spawn_bias [bias] [softening]` — how strongly the wave favours spawners near a player.
///
/// ⛔ EVERY BUTTON GETS A COMMAND, and this one more than most: spawn pressure is a FEEL setting
/// with no correct value, so it has to be dialled while playing rather than argued about here.
/// `nz_spawn_bias 0` is exactly the uniform pick this replaced.
///
/// ⚠️ IT REPORTS THE ACTUAL ODDS, not just the numbers. Two exponents and a softening distance do
/// not tell anyone what will happen; "100u beats 3000u by 14x" does.
/// </summary>
[ConCmd( "nz_spawn_bias" )]
public static void SpawnBias( float bias = -1f, float softening = -1f )
{
if ( bias >= 0f ) RoundManager.NearSpawnBias = bias;
if ( softening >= 0f ) RoundManager.NearSpawnSoftening = softening;
var b = RoundManager.NearSpawnBias;
var soft = RoundManager.NearSpawnSoftening;
if ( b <= 0f )
{
Log.Info( "[nz-spawn] bias 0 — spawners are picked UNIFORMLY (the original behaviour)" );
return;
}
var measure = RoundManager.PathWeighted ? "WALKED PATH" : "straight line";
Log.Info( $"[nz-spawn] bias {b:0.##}, softening {soft:0}u, distance measured as {measure}" );
// A worked example beats the formula. Same maths as PickSpawnNearPlayers.
float W( float d ) => MathF.Pow( 1f / (d + soft), b );
Log.Info( $"[nz-spawn] a spawner 100u away is {W( 100f ) / W( 3000f ):0.#}x more likely"
+ " than one 3000u away" );
Log.Info( $"[nz-spawn] 500u vs 1500u: {W( 500f ) / W( 1500f ):0.##}x" );
if ( RoundManager.PathWeighted )
Log.Info( $"[nz-spawn] table refreshes every {RoundManager.PathCostRefresh:0.##}s or when"
+ $" a player moves {RoundManager.PathCostPlayerMove:0}u"
+ $" — {RoundManager.PathCostFallbacks} spawn(s) had no route last rebuild" );
}
/// <summary>
/// `nz_spawn_path [0|1] [refreshSeconds] [playerMoveUnits]` — measure spawn distance along the
/// navmesh instead of through walls.
///
/// ⛔ THE SETTING EXISTS BECAUSE STRAIGHT-LINE DISTANCE LIES ON MULTI-FLOOR MAPS. A spawner on
/// the deck below is 100u away and wins every roll, then the zombie walks the length of the
/// ship looking for a staircase. `nz_spawn_path 0` restores the old measure exactly.
///
/// ⚠️ `nz_spawn_cost` is the one that shows you whether it is working — this only sets it.
/// </summary>
[ConCmd( "nz_spawn_path" )]
public static void SpawnPath( int on = -1, float refresh = -1f, float playerMove = -1f )
{
if ( on >= 0 ) RoundManager.PathWeighted = on != 0;
if ( refresh > 0f ) RoundManager.PathCostRefresh = refresh;
if ( playerMove > 0f ) RoundManager.PathCostPlayerMove = playerMove;
Log.Info( RoundManager.PathWeighted
? $"[nz-spawn] path weighting ON — refresh {RoundManager.PathCostRefresh:0.##}s, "
+ $"rebuild if a player moves {RoundManager.PathCostPlayerMove:0}u"
: "[nz-spawn] path weighting OFF — straight-line distance, ignoring walls and floors" );
}
/// <summary>
/// `nz_spawn_cost` — what every eligible spawner actually costs right now, both ways.
///
/// ⚠️ THE POINT IS THE `ratio` COLUMN. Path ÷ flat is how badly straight-line distance was
/// lying about that spawner: 1.0 means the two agree and the old measure was fine, 8.0 means a
/// spawner the old picker called "next to the player" is eight times that far to walk. Anything
/// large is a staircase, a locked route, or a floor with no connection near the player — the
/// exact case this replaced.
/// </summary>
[ConCmd( "nz_spawn_cost" )]
public static void SpawnCost()
{
var m = RoundManager.Instance;
if ( !m.IsValid() ) { Log.Info( "[nz-spawn] no round manager — start a round first" ); return; }
var costs = m.SpawnCostReport();
if ( costs.Count == 0 ) { Log.Info( "[nz-spawn] no eligible spawns" ); return; }
var soft = RoundManager.NearSpawnSoftening;
var b = RoundManager.NearSpawnBias;
float W( float d ) => b <= 0f ? 1f : MathF.Pow( 1f / (d + soft), b );
float total = costs.Sum( c => W( RoundManager.PathWeighted ? c.path : c.flat ) );
Log.Info( "[nz-spawn] flat path ratio share spawner" );
foreach ( var c in costs.OrderBy( c => RoundManager.PathWeighted ? c.path : c.flat ) )
{
var ratio = c.flat > 1f ? c.path / c.flat : 1f;
var share = total > 0f ? W( RoundManager.PathWeighted ? c.path : c.flat ) / total : 0f;
Log.Info( $"[nz-spawn] {c.flat,5:0} {c.path,6:0} {ratio,6:0.0}x {share * 100,6:0.0}%"
+ $" {c.at}{(c.unreachable ? " ⛔ NO ROUTE — using flat" : "")}" );
}
}
/// <summary>
/// `nz_zvoice_range [scale]` — how much further than authored a zombie can be heard.
///
/// ⚠️ APPLIES TO SOUNDS STARTED FROM NOW ON. A handle's Distance is set when the sound begins, so
/// anything already playing keeps the range it started with — which reads as the command not
/// working if you are standing still in a quiet moment.
/// </summary>
[ConCmd( "nz_zvoice_range" )]
public static void VoiceRange( float scale = -1f )
{
if ( scale >= 0f ) ZombieAI.VoiceRangeScale = scale;
Log.Info( ZombieAI.VoiceRangeScale <= 0f
? "[nz-audio] zombie voice range x0 — authored distance is used unchanged"
: $"[nz-audio] zombie voice range x{ZombieAI.VoiceRangeScale:0.##} (applies to new sounds)" );
}
}