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.
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 <n> add` and `nz_salvage <n>`
/// 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 <field> <value>.
/// </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 <field> <value>.
///
/// 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" );
}
}