Tools/DevActions.cs

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.

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