Rounds/RoundCommands.cs

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.

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