Player/PlayerCommands.cs

Debug and developer console commands for the player and game testing. Provides commands to hurt/heal the local player, inspect health/stamina, simulate stamina timing, respawn players, aim locking, manipulate points/salvage/perk slots, and edit active gameplay and player configuration values at runtime.

File AccessNetworking
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// PLAYER/COMMANDS — test harness for the things you cannot easily produce on
/// demand: taking damage, running out of stamina, waiting out a regen delay.
///
/// These exist because the alternative is playing the game to reach the state
/// you want to test, which is slow and unreliable — and impossible at all when
/// driving the editor remotely, where nobody can hold Shift.
/// </summary>
public static class PlayerCommands
{
	static NZPlayer First => NZPlayer.Local;

	// ── HEALTH ───────────────────────────────────────────────────────────────

	/// <summary>Damage yourself. The way to test health regen without a zombie.</summary>
	[ConCmd( "nz_hurt" )]
	public static void Hurt( float amount = 50f )
	{
		var p = First;
		var hp = p?.Components.Get<Health>();
		if ( hp is null ) { Log.Warning( "[nz] no player" ); return; }

		hp.Apply( amount );
		Log.Info( $"[nz] hurt {amount} -> {hp.Current}/{hp.Max}. "
			+ $"Regen starts in {ActiveConfig.Player.HealthRegenDelay}s "
			+ $"(nz_hp to watch)." );
	}

	/// <summary>
	/// Heal, and stand back up if downed.
	///
	/// ⚠️ Heal alone CANNOT recover a downed player — Health.Heal won't raise a
	/// pool that has hit zero, so nz_heal on a corpse silently did nothing and
	/// the only way back was restarting play. Being downed is a state, not just
	/// a number, so it needs Revive. Once real revives exist this stays the
	/// debug shortcut past them.
	///
	/// ⚠️ AND A POOL AT ZERO THAT IS NOT DOWN, WHICH CREATIVE LEAVES: a hit there takes health to 0
	/// without downing, and Heal will not raise it from 0 either. The gap lava round basalt's boss
	/// arena left a tester at 0/150 — the screen red — with `nz_heal` answering "healed -> 0/150"
	/// until `nz_revive` (2026-09-27). Revive resets the pool, and takes nothing from a player who was
	/// not down.
	/// </summary>
	[ConCmd( "nz_heal" )]
	public static void HealCmd( float amount = 9999f )
	{
		var p = First;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		if ( p.IsDown || (p.Hp is { } hp && hp.Current <= 0f) )
		{
			p.Revive();
			Log.Info( $"[nz] revived -> {p.Hp?.Current}/{p.Hp?.Max}" );
			return;
		}

		p.Hp?.Heal( amount );
		Log.Info( $"[nz] healed -> {p.Hp?.Current}/{p.Hp?.Max}" );
	}

	/// <summary>Current health and stamina, for watching a regen in progress.</summary>
	[ConCmd( "nz_hp" )]
	public static void Hp()
	{
		var p = First;
		if ( p is null ) { Log.Warning( "[nz] no player" ); return; }

		var hp = p.Components.Get<Health>();
		var st = p.Components.Get<Stamina>();

		Log.Info( $"[nz] health {hp?.Current:0.0}/{hp?.Max:0.0}   "
			+ $"stamina {st?.Current:0.0}/{ActiveConfig.Player.StaminaMax:0.0}"
			+ (st?.Exhausted == true ? "  EXHAUSTED" : "") );
	}

	/// <summary>
	/// Can we load the sandbox tool models without referencing the package?
	///
	/// They are cached on this machine under download/assets as content-addressed
	/// files (v_physgun.46a581eb074f8711.vmdl_c), which is NOT the same as being
	/// available to this project — the project has no PackageReferences. This
	/// asks the engine directly rather than guessing from what is on disk.
	/// </summary>
	[ConCmd( "nz_testmodels" )]
	public static void TestModels()
	{
		foreach ( var path in new[]
		{
			"models/weapons/sbox_physgun/v_physgun.vmdl",
			"models/weapons/sbox_toolgun/v_toolgun.vmdl",
			"weapons/sbox_physgun/v_physgun.vmdl",
			"models/citizen/citizen.vmdl",             // control — known good
		} )
		{
			var m = Model.Load( path );
			var state = m is null ? "NULL"
				: m.IsError ? "ERROR MODEL"
				: $"ok  {m.BoneCount} bones";
			Log.Info( $"[nz] {state,-18} {path}" );
		}
	}

	// ── STAMINA ──────────────────────────────────────────────────────────────

	/// <summary>Force a stamina value — mostly to reach 0 and confirm sprint
	/// actually gets blocked, which is the half that cannot be seen in a
	/// number.</summary>
	[ConCmd( "nz_stamina" )]
	public static void SetStamina( float value = 0f )
	{
		var p = First;
		var st = p?.Components.Get<Stamina>();
		var pc = p?.Components.Get<PlayerController>();
		if ( st is null ) { Log.Warning( "[nz] no player stamina" ); return; }

		st.Debug_Set( value );
		Log.Info( $"[nz] stamina -> {st.Current:0.0}"
			+ $"   exhausted {st.Exhausted}"
			+ $"   run speed {pc?.RunSpeed} (walk {pc?.WalkSpeed})" );
	}

	/// <summary>
	/// Work out how the configured numbers actually play out, without needing
	/// anyone to hold Shift.
	///
	/// ⚠️ Prints BOTH interval models. The original gates its drain on 0.05s
	/// inside a per-frame Think, so at 60fps it really fires every ~0.0667s —
	/// which is where its "around 8 seconds" comment comes from. A faithful
	/// port of the code gives 5.6s; matching the FEEL needs the frame-quantised
	/// figure. Showing both makes the choice explicit rather than accidental.
	/// </summary>
	[ConCmd( "nz_stamina_sim" )]
	public static void Simulate()
	{
		var s = ActiveConfig.Player;

		float Seconds( float amount, float perTick, float interval )
			=> perTick <= 0f ? 0f : amount / perTick * interval;

		Log.Info( $"[nz] stamina sim — max {s.StaminaMax}, "
			+ $"drain {s.StaminaDrainPerTick}/tick, regen {s.StaminaRegenPerTick}/tick" );

		foreach ( var (label, interval) in new[] {
			("exact 0.05s (their code)", 0.05f),
			("0.0667s (their code at 60fps — their tuned feel)", 0.0667f) } )
		{
			Log.Info( $"[nz]   {label}" );
			Log.Info( $"[nz]     sprint {Seconds( s.StaminaMax, s.StaminaDrainPerTick, interval ):0.0}s"
				+ $"   refill {Seconds( s.StaminaMax, s.StaminaRegenPerTick, interval ):0.0}s"
				+ $" (after {s.StaminaRegenDelay}s pause)" );
		}

		var healTicks = s.HealthRegenPercent > 0f ? 100f / s.HealthRegenPercent : 0f;
		Log.Info( $"[nz]   health: wait {s.HealthRegenDelay}s, then "
			+ $"{healTicks:0} ticks x {s.HealthRegenRate}s = "
			+ $"{healTicks * s.HealthRegenRate:0.00}s empty to full" );
	}

	// ── SPAWNING ─────────────────────────────────────────────────────────────

	/// <summary>
	/// Re-roll the player onto a random player spawn.
	///
	/// The whole point is that it is RANDOM, so verifying it needs repeats —
	/// this is how you get them without restarting a game each time.
	/// </summary>
	/// <remarks>
	/// ⚠️ THE ONLY `nz_respawn` SINCE 2026-10-05. `BodyCheck.Respawn` registered the name too, and the engine kept whichever it
	/// met first. Its host check came over with it: the host deals the spawns.
	/// </remarks>
	[ConCmd( "nz_respawn" )]
	public static void Respawn( int index = -1 )
	{
		if ( NZGame.IsClient )
		{
			Log.Warning( "[nz-bodies] the HOST deals the spawns — run this there" );
			return;
		}

		if ( PlayerSpawner.PlaceAll( index ) == 0 )
			Log.Warning( "[nz] nobody was moved" );
	}

	// ── DIAGNOSTIC AIM ───────────────────────────────────────────────────────

	/// <summary>Lock the view onto the nearest zombie: nz_aimlock [0/1].</summary>
	[ConCmd( "nz_aimlock" )]
	public static void AimLock( int on = -1 )
	{
		NZPlayer.AimLock = on < 0 ? !NZPlayer.AimLock : on != 0;

		Log.Info( NZPlayer.AimLock
			? "[nz] aimlock ON — view tracks the nearest zombie"
			: "[nz] aimlock off" );
	}

	/// <summary>Aim height on the target, for framing a riser: nz_aimlock_h 40.</summary>
	[ConCmd( "nz_aimlock_h" )]
	public static void AimLockHeight( float height = 40f )
	{
		NZPlayer.AimLockHeight = height;
		Log.Info( $"[nz] aimlock height {height:0}" );
	}

	// ── POINTS ───────────────────────────────────────────────────────────────

	/// <summary>
	/// Set or add points: nz_points 1500, or nz_points 500 add.
	///
	/// ⚠️ There is NO points economy yet — nothing awards or spends these. This
	/// exists so the HUD's points readout can be driven while it's being built,
	/// which is the only way to check the number's width and alignment without
	/// a kill to earn from.
	/// </summary>
	/// <summary>
	/// Fire a points popup and report the list: nz_points_pop [amount].
	///
	/// ⛔ EXISTS BECAUSE `nz_points 50` DOES NOT MAKE ONE. That command SETS the
	/// total unless you pass a second argument — `nz_points 50 add` — so the
	/// obvious way to test the popup silently exercises the one path that cannot
	/// produce it. This calls the award path directly and says what happened, so
	/// "no popup" separates into "never created" and "created but not drawn".
	/// </summary>
	/// <summary>
	/// Move the points popups: nz_points_pos [right] [bottom], in pixels.
	///
	/// ⚠️ EXISTS BECAUSE THE ANCHOR CANNOT BE COMPUTED RELIABLY. The popups are
	/// their own panel — so a HUD rebuild cannot kill them mid-flight — and the
	/// cost of that is they no longer inherit the counter's position. The current
	/// numbers come from SurvivalHud's layout constants, but `.lower`'s height is
	/// content-driven, so the vertical is arithmetic rather than measurement.
	///
	/// Bare command reports where they are.
	/// </summary>
	[ConCmd( "nz_points_pos" )]
	public static void PointsPos( float right = -1f, float bottom = -1f )
	{
		if ( right >= 0f ) PointsPopups.AnchorRight = right;
		if ( bottom >= 0f ) PointsPopups.AnchorBottom = bottom;

		Log.Info( $"[nz] points popups at right {PointsPopups.AnchorRight:0}px, "
			+ $"bottom {PointsPopups.AnchorBottom:0}px"
			+ (right < 0f && bottom < 0f
				? "   (nz_points_pos <right> <bottom> to move — bottom UP is a bigger number)"
				: "") );
	}

	[ConCmd( "nz_points_pop" )]
	public static void PointsPop( int amount = 50 )
	{
		var p = First;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		int before = PointsPopups.Active.Count;

		if ( amount >= 0 ) p.AddPoints( amount );
		else p.TrySpend( -amount );

		Log.Info( $"[nz-points] {amount:+#;-#;0} -> popups {before} to "
			+ $"{PointsPopups.Active.Count}, version {PointsPopups.Version}, "
			+ $"points now {p.Points}" );

		foreach ( var pop in PointsPopups.Active )
			Log.Info( $"[nz-points]   '{pop.Text}' tone={pop.Tone} "
				+ $"drift={pop.DriftClass} age={(float)pop.Age:0.00}s" );
	}

	[ConCmd( "nz_points" )]
	public static void SetPoints( int amount = 0, string mode = "" )
	{
		var p = First;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		if ( mode.Equals( "add", StringComparison.OrdinalIgnoreCase ) )
			p.AddPoints( amount );
		else
			p.SetPoints( amount );

		Log.Info( $"[nz] points = {p.Points}" );
	}

	/// <summary>
	/// `nz_rich [amount]` — top up points AND salvage in one go. 100,000 each by default.
	///
	/// ⛔ THIS EXISTS BECAUSE CREATIVE IS NOT A SUBSTITUTE FOR IT. Creative already tops both
	/// currencies to 100,000 every frame, but perks, the Arsenal and the augments are what need
	/// testing and they need a real Survival run — rounds, downs, a horde. `RoundManager.StartGame`
	/// deliberately zeroes both on entering Survival precisely so a Creative session cannot leak its
	/// wallet in, which left no way to buy anything while testing.
	///
	/// ⚠️ IT SETS RATHER THAN ADDS, so running it twice is not different from running it once — the
	/// point is to be topped up, not to accumulate. `nz_points &lt;n&gt; add` and `nz_salvage &lt;n&gt;`
	/// are still there for a specific amount or a negative one.
	///
	/// ⚠️ AND IT GOES THROUGH `SetPoints`, not the backing field, so anything watching points (the
	/// HUD's hash, the popup panel) sees the change the same way it would see a kill reward.
	/// </summary>
	[ConCmd( "nz_rich" )]
	public static void Rich( int amount = 100000 )
	{
		var p = First;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var n = Math.Max( 0, amount );

		p.SetPoints( n );
		p.Salvage = n;

		Log.Info( $"[nz] rich — {p.Points:N0} points, {p.Salvage:N0} salvage" );

		// ⚠️ THE PERK-SLOT CAP IS NAMED BECAUSE MONEY DOES NOT LIFT IT. Four slots is the config's
		// allowance and buying a fifth is its own 10,000-point purchase at the Wunderfizz — a
		// wallet full of points still reports "No free perk slot (4/4)", which reads as this
		// command not having worked.
		Log.Info( $"[nz]   perk slots {p.Perks.Count}/{ActiveConfig.Player.PerkSlots + p.BonusPerkSlots}"
			+ $" (+{p.BonusPerkSlots} bought) — buy more slots at the Wunderfizz" );
	}

	/// <summary>
	/// Change a gameplay/economy setting: nz_gameplay_set &lt;field&gt; &lt;value&gt;.
	/// </summary>
	[ConCmd( "nz_gameplay_set" )]
	public static void SetGameplay( string field = "", int value = 0 )
	{
		var g = ActiveConfig.Gameplay;

		if ( string.IsNullOrWhiteSpace( field ) )
		{
			Log.Info( "[nz] nz_gameplay_set <field> <value>. Fields:" );
			Log.Info( $"[nz]   startpoints   {g.StartingPoints}" );
			Log.Info( $"[nz]   hit           {g.PointsHit}" );
			Log.Info( $"[nz]   kill          {g.PointsKill}" );
			Log.Info( $"[nz]   headshot      {g.PointsKillHeadshot}" );
			Log.Info( $"[nz]   knife         {g.PointsKillKnife}  (no melee weapon yet)" );
			return;
		}

		switch ( field.ToLowerInvariant() )
		{
			case "startpoints": g.StartingPoints = value; break;
			case "hit": g.PointsHit = value; break;
			case "kill": g.PointsKill = value; break;
			case "headshot": g.PointsKillHeadshot = value; break;
			case "knife": g.PointsKillKnife = value; break;

			default:
				Log.Warning( $"[nz] unknown field '{field}' — run nz_gameplay_set "
					+ "with no arguments for the list" );
				return;
		}

		Log.Info( $"[nz] {field} = {value}  (unsaved — nz_save to keep it)" );

		if ( field.Equals( "startpoints", StringComparison.OrdinalIgnoreCase ) )
			Log.Info( "[nz]   note: applies on next spawn, not to the live player" );
	}

	// ── EDITING ──────────────────────────────────────────────────────────────

	/// <summary>
	/// Change a player setting on the live config: nz_player_set &lt;field&gt; &lt;value&gt;.
	///
	/// The Settings tab will drive these same fields, but a command means they
	/// are testable now and testable remotely — the whole point of the
	/// button-plus-command rule.
	///
	/// ⚠️ Edits the ACTIVE config in memory only. Nothing is written to disk
	/// until nz_save, matching the "save only on explicit save" rule.
	/// </summary>
	[ConCmd( "nz_player_set" )]
	public static void SetPlayerSetting( string field = "", string value = "" )
	{
		var s = ActiveConfig.Player;

		if ( string.IsNullOrWhiteSpace( field ) )
		{
			Log.Info( "[nz] nz_player_set <field> <value>. Fields:" );
			Log.Info( "[nz]   health, walk, sprint, jump" );
			Log.Info( "[nz]   stamina, staminadrain, staminaregen, staminadelay" );
			Log.Info( "[nz]   regendelay, regenpercent, regenrate" );
			Log.Info( "[nz]   (points moved -> nz_gameplay_set)" );
			return;
		}

		if ( !float.TryParse( value, out var v ) )
		{
			Log.Warning( $"[nz] '{value}' is not a number" );
			return;
		}

		switch ( field.ToLowerInvariant() )
		{
			case "health": s.MaxHealth = v; break;
			case "walk": s.WalkSpeed = v; break;
			case "sprint": s.SprintSpeed = v; break;
			case "jump": s.JumpPower = v; break;

			case "stamina": s.StaminaMax = v; break;
			case "staminadrain": s.StaminaDrainPerTick = v; break;
			case "staminaregen": s.StaminaRegenPerTick = v; break;
			case "staminadelay": s.StaminaRegenDelay = v; break;

			case "regendelay": s.HealthRegenDelay = v; break;
			case "regenpercent": s.HealthRegenPercent = v; break;
			case "regenrate": s.HealthRegenRate = v; break;

			// Moved to the economy block — redirect rather than fail, since this
			// is where it used to live and muscle memory will send you here.
			case "startpoints":
				Log.Warning( "[nz] startpoints moved — use nz_gameplay_set startpoints" );
				return;

			default:
				Log.Warning( $"[nz] unknown field '{field}' — run nz_player_set with "
					+ "no arguments for the list" );
				return;
		}

		Log.Info( $"[nz] {field} = {v}  (unsaved — nz_save to keep it)" );

		// MaxHealth is the one that doesn't apply itself: Health.Max was already
		// read at spawn, so a live player keeps the old ceiling until respawn.
		if ( field.Equals( "health", StringComparison.OrdinalIgnoreCase ) )
			Log.Info( "[nz]   note: applies on next spawn, not to the live player" );
	}
}