Developer console commands for the NZombies game, exposing one-click dev/test actions. Implements ConCmds to kill all zombies through the damage path, toggle separation, heal player, toggle godmode, spawn a high-HP tank zombie, grant points/salvage/armor, freeze AI, give a random box weapon, and force the held weapon to Common rarity.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// The dev menu's one-click actions.
///
/// ⛔ EVERY ONE IS A `ConCmd` AND THE MENU CALLS THE COMMAND, not a lambda with logic in it. That is
/// the shape `DevMenu` already uses for `ZombieCommands.Clear()` and `Rarity.ClearCmd()`, and the
/// reason matters: a button whose behaviour lives in a razor lambda cannot be run from the console,
/// cannot be scripted, and cannot be tested without clicking. Anything worth a button is worth a
/// command.
///
/// ⚠️ THINGS THAT ALREADY HAD A COMMAND ARE NOT DUPLICATED HERE. Clearing the held weapon's tech is
/// `WeaponTech.ResetHeld`, dropping every weapon to Common is `Rarity.ClearCmd`, clearing perks is
/// `PerkEffects.Clear`, clearing augments is `AugmentCommands`. This file holds only what had no
/// caller — see the notes on each.
/// </summary>
public static class DevActions
{
static NZPlayer Me()
=> NZPlayer.Local;
// ══ the four that were placeholders ══════════════════════════════════════
/// <summary>
/// `nz_dev_killall` — kill every zombie, rather than deleting them.
///
/// ⛔ DELIBERATELY NOT `ZombieCommands.Clear`, WHICH ALREADY EXISTS AND DOES SOMETHING ELSE.
/// Clear DESTROYS the objects; this runs them through `Health.Apply` so the whole death path
/// fires — points, drops, ragdolls, the round's alive count, every on-kill augment. Which you
/// want depends on what you are testing, so both are worth having and the difference is worth
/// saying out loud.
///
/// ⚠️ ATTRIBUTED TO THE PLAYER, so it pays points and rolls drops. An unattributed kill is what
/// `nz_zombie_hurt` does; this is the "I want the rewards" version.
/// </summary>
[ConCmd( "nz_dev_killall" )]
public static void KillAll()
{
var p = Me();
var n = 0;
// ⚠️ A SNAPSHOT, because a death handler can remove entries from `ZombieAI.All` mid-loop.
foreach ( var z in ZombieAI.All.ToList() )
{
if ( !z.IsValid() ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) continue;
hp.Apply( hp.Max * 10f, false, p.IsValid() ? p.GameObject : null );
n++;
}
Log.Info( $"[nz-dev] killed {n} zombie(s) through the damage path"
+ " — points, drops and on-kill augments all fired" );
}
static bool? _separationOff;
/// <summary>
/// `nz_dev_separation` — toggle crowd separation on every zombie.
///
/// ⚠️ IT IS THE THING THAT MAKES A HORDE A CROWD RATHER THAN A COLUMN. `ZombieAI.Separation`
/// feeds the navmesh agent, and `TO_TEST.md` records it as explicitly needing tuning in play —
/// "do zombies clump into one mass or spread into a crowd?" A toggle is how that gets answered.
///
/// ⛔ THE ORIGINAL VALUE IS REMEMBERED PER TOGGLE, NOT ASSUMED TO BE 1. The property is a
/// `[Property]` a variant or the inspector may have changed, so restoring a hardcoded 1 would
/// quietly retune the thing being measured.
/// </summary>
[ConCmd( "nz_dev_separation" )]
public static void ToggleSeparation()
{
var off = !(_separationOff ?? false);
_separationOff = off;
var n = 0;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() ) continue;
z.Separation = off ? 0f : 1f;
n++;
}
Log.Info( $"[nz-dev] separation {(off ? "OFF — they will clump" : "back to 1")}"
+ $" on {n} zombie(s)"
+ (off ? "" : " ⚠ restored to 1, not to whatever a variant authored") );
}
/// <summary>`nz_dev_heal` — back to full health.</summary>
[ConCmd( "nz_dev_heal" )]
public static void Heal()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() ) { Log.Warning( "[nz-dev] player has no Health" ); return; }
hp.Heal( MathF.Max( 0f, hp.Max - hp.Current ) );
Log.Info( $"[nz-dev] healed to {hp.Current:0}/{hp.Max:0}" );
}
/// <summary>
/// `nz_dev_god` — toggle damage immunity.
///
/// ⚠️ IT SETS `Health.Invulnerable`, WHICH THIS BUTTON REQUIRED ADDING. Nothing in the project
/// had a godmode of any kind, so the placeholder could not simply be wired — the mechanic did
/// not exist. See that property for why the gate is at the very top of `Apply`.
/// </summary>
[ConCmd( "nz_dev_god" )]
public static void ToggleGod()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() ) { Log.Warning( "[nz-dev] player has no Health" ); return; }
hp.Invulnerable = !hp.Invulnerable;
Log.Info( $"[nz-dev] godmode {(hp.Invulnerable ? "ON — you take nothing" : "off")}" );
}
// ══ the new ones ═════════════════════════════════════════════════════════
static float? _tankHp;
/// <summary>How much health `nz_dev_tank` gives. 1,000,000.</summary>
public static float TankHealth { get => _tankHp ?? 1_000_000f; set => _tankHp = value; }
/// <summary>
/// `nz_dev_tank` — a zombie with a million health, in front of you.
///
/// ⛔ THE HEALTH IS WRITTEN AFTER THE SPAWN, NOT PASSED AS A MULTIPLIER. `ZombieCommands.SpawnAt`
/// takes an hp MULTIPLIER against the round's curve, so the same argument means a different
/// number every round — useless for "does my gun kill this in a reasonable time". Setting `Max`
/// and `Current` directly makes the target identical on round 1 and round 40.
///
/// ⚠️ WHAT IT IS FOR: measuring damage. A target that cannot die lets you watch the numbers for
/// as long as you like — DPS, ammo mods, tech nodes, the Brutus helmet table — without a corpse
/// interrupting the measurement.
/// </summary>
[ConCmd( "nz_dev_tank" )]
public static void Tank( float distance = 220f )
{
var scene = Game.ActiveScene;
var p = Me();
if ( !scene.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
var fwd = p.EyeAngles.Forward.WithZ( 0f ).Normal;
var at = p.WorldPosition + fwd * MathF.Max( 50f, distance );
var z = ZombieCommands.SpawnAt( scene, at );
if ( z is null ) { Log.Warning( "[nz-dev] spawn failed" ); return; }
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( hp.IsValid() )
{
hp.Max = MathF.Max( 1f, TankHealth );
hp.Heal( hp.Max );
}
z.GameObject.Name = "nz_dev_tank";
Log.Info( $"[nz-dev] tank zombie at {at} with {hp?.Max ?? 0f:N0} hp"
+ " — a target that will not die while you measure" );
}
/// <summary>
/// `nz_dev_points` — hand over 100,000 points.
///
/// ⚠️ SEPARATE FROM `nz_rich`, WHICH SETS BOTH CURRENCIES AT ONCE. Points and salvage buy
/// different things — points buy doors, weapons and the box; salvage buys rarity, armor, tech
/// and augments — so being able to test one economy without filling the other is the point.
/// </summary>
[ConCmd( "nz_dev_points" )]
public static void GivePoints( int amount = 100000 )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
p.AddPoints( Math.Max( 0, amount ) );
Log.Info( $"[nz-dev] +{amount:N0} points — now {p.Points:N0}" );
}
/// <summary>`nz_dev_salvage` — hand over 100,000 salvage.</summary>
[ConCmd( "nz_dev_salvage" )]
public static void GiveSalvage( int amount = 100000 )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
p.Salvage = Math.Max( 0, p.Salvage + Math.Max( 0, amount ) );
Log.Info( $"[nz-dev] +{amount:N0} salvage — now {p.Salvage:N0}" );
}
/// <summary>
/// `nz_dev_armor` — top tier, full bar, full plates.
///
/// ⛔ ALL THREE, BECAUSE ANY ONE ALONE READS AS BROKEN. Armor with no tier is capped at 0 and
/// absorbs nothing; a tier with no armor is an empty vest; plates with a full bar cannot be
/// applied. `Armor.SetTier` is deliberately documented as NOT filling the bar — "handing out a
/// free 150 with the vest makes the first plate worthless" — so a dev button has to do the
/// filling itself rather than expecting the real path to.
/// </summary>
[ConCmd( "nz_dev_armor" )]
public static void FullArmor()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
var cfg = ActiveConfig.Armor;
Armor.SetTier( p, Math.Max( 0, cfg.MaxTier ) );
p.Armor = Armor.CapFor( p );
p.ArmorPlates = Math.Max( 0, cfg.MaxPlates );
Log.Info( $"[nz-dev] armor tier {p.ArmorTier}, {p.Armor:0}/{Armor.CapFor( p ):0}"
+ $", {p.ArmorPlates} plate(s) carried" );
}
/// <summary>
/// `nz_dev_freeze` — toggle every zombie's AI off and on.
///
/// ⛔ IT DISABLES THE COMPONENT RATHER THAN ZEROING SPEED, and the difference is the point. A
/// speed of 0 still leaves `Think` running — states advance, attacks resolve, statuses tick — so
/// a "frozen" zombie could still hit you. Disabling `ZombieAI` stops the thinking entirely while
/// the renderer, the collider and `Health` all stay live, which is what makes it useful for
/// looking at a pose or a hitbox.
///
/// ⚠️ IT DOES NOT AFFECT ZOMBIES SPAWNED AFTER THE TOGGLE. The flag is applied to what exists
/// now rather than remembered, so a fresh spawn arrives awake — press it again.
/// </summary>
[ConCmd( "nz_dev_freeze" )]
public static void ToggleFreeze()
{
var all = ZombieAI.All.Where( z => z.IsValid() ).ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-dev] no zombies" ); return; }
// ⚠️ READ FROM THE FIRST ONE rather than a remembered static, so the toggle cannot get out
// of step with the world after a round change or a hotload.
var freeze = all[0].Enabled;
foreach ( var z in all ) z.Enabled = !freeze;
Log.Info( $"[nz-dev] AI {(freeze ? "FROZEN" : "running")} on {all.Count} zombie(s)"
+ (freeze ? " ⚠ new spawns arrive awake" : "") );
}
/// <summary>
/// `nz_dev_giveweapon` — a random weapon from the box's pool.
///
/// ⚠️ RANDOM, BECAUSE A BUTTON TAKES NO ARGUMENT. `nz_give <name>` already covers "I want that
/// specific gun"; what a button can add is "give me something else", which is the actual test
/// loop when checking ports.
///
/// ⚠️ AND IT DRAWS FROM `MysteryBox.Pool()`, NOT `WeaponLibrary.All`. The pool already honours
/// the map's `BoxPacks` filter, so a map restricted to one pack hands out weapons from that pack
/// rather than anything on disk — one author for what "the roster" means.
/// </summary>
[ConCmd( "nz_dev_giveweapon" )]
public static void GiveRandomWeapon()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
var pool = MysteryBox.Pool();
if ( pool is null || pool.Count == 0 )
{
Log.Warning( "[nz-dev] the weapon pool is empty — no manifest and nothing in"
+ " prefabs/weapons/" );
return;
}
var pick = pool[Game.Random.Int( 0, pool.Count - 1 )];
p.GiveWeapon( pick.Prefab, makeActive: true );
Log.Info( $"[nz-dev] gave {pick.Name} ({pick.Pack}) — 1 of {pool.Count} in the pool" );
}
/// <summary>
/// `nz_dev_rarity_common` — drop the HELD weapon to Common.
///
/// ⚠️ THE HELD WEAPON ONLY, WHICH IS WHAT MAKES IT DIFFERENT FROM `Rarity.ClearCmd`. That one
/// clears EVERY weapon; this resets the one in your hands, so a two-weapon loadout can have one
/// tier tested against the other. Both are worth a button for that reason.
/// </summary>
[ConCmd( "nz_dev_rarity_common" )]
public static void HeldToCommon()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-dev] no player" ); return; }
var wep = Rarity.HeldBy( p );
if ( !wep.IsValid() ) { Log.Info( "[nz-dev] no weapon in hand" ); return; }
var prefab = Rarity.PrefabOf( wep );
if ( string.IsNullOrEmpty( prefab ) )
{
Log.Warning( "[nz-dev] held weapon has no WeaponSource — re-equip it" );
return;
}
p.SetRarityTier( prefab, 0 );
// ⚠️ PUSHED ONTO THE LIVE GUN, not just stored. Writing the dictionary alone leaves the
// weapon on its old multiplier until the next equip, and a change that needs a weapon
// switch to appear reads as a change that did nothing (§13).
p.PushStoredUpgrades();
Log.Info( $"[nz-dev] {wep.DisplayName} → Common" );
}
}