Weapons/WeaponTuningCommands.cs

Console command frontend for a weapon tuning editor. Provides commands to read, set, list, save, reload and reset tunable weapon fields for the weapon currently held by the local player, and to open the editor UI group.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console side of the weapon editor.
///
/// ⚠️ THE UI IS A SECOND FRONT END, NOT THE FEATURE. Every button in this project
/// gets a command behind it so the thing can be driven without hands on the machine —
/// and here it earns its keep twice, because a stat editor is exactly the tool you
/// want to script a sweep with.
/// </summary>
public static class WeaponTuningCommands
{
	/// <summary>
	/// The weapon actually in hand.
	///
	/// ⛔ THE ACTIVE SLOT, VIA THE INVENTORY. `GetInChildren&lt;Weapon&gt;( true )`
	/// returns whichever is first in the hierarchy — usually the HOLSTERED one since
	/// the second slot landed, which had `nz_wep_anims` reporting on the wrong gun
	/// for weeks. Editing the wrong weapon's damage would be considerably worse.
	/// </summary>
	public static SWB.Base.Weapon Held()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) return null;

		var inv = player.Components.Get<NZInventory>( FindMode.EverythingInSelf );
		var active = inv?.Active;

		return active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: null;
	}

	/// <summary>
	/// Set a stat on the held weapon: `nz_wep_set &lt;field&gt; &lt;value&gt;`.
	///
	/// ⚠️ Takes effect IMMEDIATELY and is remembered, but is not on disk until
	/// `nz_wep_save`. That split is deliberate — tuning means trying values you do
	/// not want to keep, and an editor that commits every keystroke gives you no way
	/// back to where you started.
	/// </summary>
	[ConCmd( "nz_wep_set" )]
	public static void Set( string field = "", float value = 0f )
	{
		var wep = Held();
		if ( !wep.IsValid() ) { Log.Warning( "[nz] no weapon in hand" ); return; }

		if ( string.IsNullOrWhiteSpace( field ) )
		{
			Log.Info( "[nz] nz_wep_set <field> <value> — nz_wep_fields to list them" );
			return;
		}

		var before = WeaponTuning.Get( wep, field );
		var resolved = WeaponTuning.Set( wep, field, value );

		if ( resolved is null )
		{
			Log.Warning( $"[nz] '{field}' is not a tunable field on {wep.DisplayName}"
				+ " — nz_wep_fields to list them" );
			return;
		}

		// ⛔ READ BACK, DO NOT ECHO THE REQUEST. Printing `value` here reports what was
		// ASKED FOR, which is true even when the write silently did nothing — an int
		// field rounding, a clamp, or a property with no setter all look like success.
		// This log said "Damage 45 -> 999" on a weapon that was still doing 45, and I
		// went hunting the save layer for a bug that was never there.
		var after = WeaponTuning.Get( wep, resolved );

		Log.Info( $"[nz] {wep.DisplayName}: {resolved} {before:0.###} -> {after:0.###}"
			+ (after != value ? $"  (asked {value:0.###})" : "")
			+ "   (nz_wep_save to keep)" );
	}

	/// <summary>Read one stat: `nz_wep_get &lt;field&gt;`.</summary>
	[ConCmd( "nz_wep_get" )]
	public static void Get( string field = "" )
	{
		var wep = Held();
		if ( !wep.IsValid() ) { Log.Warning( "[nz] no weapon in hand" ); return; }

		var v = WeaponTuning.Get( wep, field );

		Log.Info( v is null
			? $"[nz] '{field}' is not a tunable field on {wep.DisplayName}"
			: $"[nz] {wep.DisplayName}: {field} = {v:0.###}" );
	}

	/// <summary>
	/// Every tunable field with its current value: `nz_wep_fields [filter]`.
	///
	/// ⚠️ FILTERED, because there are about seventy. Unfiltered output buries the
	/// console and the one you wanted scrolls off — `nz_wep_fields recoil` is the
	/// way this is actually used.
	/// </summary>
	[ConCmd( "nz_wep_fields" )]
	public static void Fields( string filter = "" )
	{
		var wep = Held();
		if ( !wep.IsValid() ) { Log.Warning( "[nz] no weapon in hand" ); return; }

		var all = WeaponTuning.Fields( wep ).ToList();

		var shown = string.IsNullOrWhiteSpace( filter )
			? all
			: all.Where( f => f.name.Contains( filter, System.StringComparison.OrdinalIgnoreCase ) )
				.ToList();

		var over = WeaponTuning.Overrides( wep );

		Log.Info( $"[nz] {wep.DisplayName} — {shown.Count} of {all.Count} field(s)"
			+ (string.IsNullOrWhiteSpace( filter ) ? "" : $" matching '{filter}'") );

		foreach ( var f in shown )
		{
			// ⚠️ Marked when overridden, so "why is this gun wrong" is answerable at
			// a glance instead of by diffing against a prefab.
			var mark = over.ContainsKey( f.name ) ? " *" : "";
			Log.Info( $"[nz]   {f.owner,-6} {f.name,-28} {f.value,10:0.###}{mark}" );
		}

		if ( shown.Count == 0 )
			Log.Info( "[nz]   (nothing matched — try a shorter filter)" );
	}

	/// <summary>What is overridden, across every weapon: `nz_wep_tune`.</summary>
	[ConCmd( "nz_wep_tune" )]
	public static void List()
	{
		var t = WeaponTuning.Current;

		if ( t.Weapons.Count == 0 )
		{
			Log.Info( "[nz] no weapon overrides — everything is at authored values" );
			return;
		}

		Log.Info( $"[nz] weapon overrides ({t.Weapons.Count} weapon(s)), "
			+ $"saved to Data/{WeaponTuning.FileLocation}:" );

		foreach ( var (prefab, fields) in t.Weapons )
		{
			Log.Info( $"[nz]   {prefab}" );
			foreach ( var (name, value) in fields.OrderBy( f => f.Key ) )
				Log.Info( $"[nz]      {name,-28} {value,10:0.###}" );
		}
	}

	/// <summary>
	/// Open the editor panel: `nz_wep_editor`.
	///
	/// ⛔ SELECTS THE GROUP, IT CANNOT OPEN THE MENU. The settings column renders
	/// inside DevMenu's `@if ( Open )`, and DevMenu is a RAZOR type — those are
	/// generated, so a plain .cs file cannot reference one at all (the reason
	/// `LobbyState` and `ToolPanelState` exist). Reaching it by string reflection
	/// would work today and break the first time the file is renamed. So the command
	/// points the panel at the weapon group and says to press Q, rather than
	/// pretending to do something it cannot.
	/// </summary>
	[ConCmd( "nz_wep_editor" )]
	public static void Editor()
	{
		ToolPanelState.ShowGroup( ToolSettings.WeaponStats );

		var wep = Held();
		Log.Info( wep.IsValid()
			? $"[nz] weapon editor armed for {wep.DisplayName} — press Q (saves on edit)"
			: "[nz] weapon editor armed — press Q. No weapon in hand yet." );
	}

	/// <summary>
	/// Why an override did or did not apply: `nz_wep_key`.
	///
	/// ⛔ PRINTS THE LOOKUP KEY BESIDE THE STORED KEYS. An override that saves,
	/// reloads and then does nothing has exactly one likely cause — the key at write
	/// time and the key at apply time are different strings — and no amount of
	/// staring at either half reveals it. Putting them on adjacent lines does.
	/// </summary>
	[ConCmd( "nz_wep_key" )]
	public static void Key()
	{
		var wep = Held();
		if ( !wep.IsValid() ) { Log.Warning( "[nz] no weapon in hand" ); return; }

		var src = wep.GameObject.Components.Get<WeaponSource>( FindMode.EverythingInSelf );
		var key = WeaponTuning.KeyFor( wep );
		var has = key is not null && WeaponTuning.Current.Weapons.ContainsKey( key );

		Log.Info( $"[nz] {wep.DisplayName}" );
		Log.Info( $"[nz]   object      '{wep.GameObject.Name}'" );
		Log.Info( $"[nz]   WeaponSource {(src is null ? "MISSING" : $"prefab='{src.Prefab}' base='{src.BaseName}'")}" );
		Log.Info( $"[nz]   lookup key  '{key}'  -> {(has ? "MATCHED" : "no entry")}" );

		foreach ( var stored in WeaponTuning.Current.Weapons.Keys )
			Log.Info( $"[nz]   stored key  '{stored}'{(stored == key ? "  <= same" : "")}" );
	}

	/// <summary>Write to disk: `nz_wep_save`.</summary>
	[ConCmd( "nz_wep_save" )]
	public static void SaveCmd() => WeaponTuning.Current.Save();

	/// <summary>Re-read the file, discarding unsaved changes: `nz_wep_reload`.</summary>
	[ConCmd( "nz_wep_reload" )]
	public static void ReloadCmd() => WeaponTuning.Reload();

	/// <summary>
	/// Where the file is on disk: `nz_wep_file`.
	///
	/// ⚠️ Asked the moment anything goes wrong with saved data, and the answer is not
	/// guessable — `FileSystem.Data` resolves to a folder under the ENGINE install,
	/// not the project.
	/// </summary>
	[ConCmd( "nz_wep_file" )]
	public static void FileCmd()
	{
		Log.Info( $"[nz] weapon tuning: Data/{WeaponTuning.FileLocation}" );
		Log.Info( "[nz]   (sbox install)/data/local/nzombies_sbox#local/"
			+ WeaponTuning.FileLocation );
		Log.Info( "[nz]   edit it by hand, then nz_wep_reload" );
	}

	/// <summary>
	/// Drop the held weapon's overrides: `nz_wep_reset`.
	///
	/// ⚠️ THE LIVE WEAPON KEEPS ITS CURRENT NUMBERS until it is re-deployed — the
	/// authored values live in the prefab, and reading them back would mean
	/// instantiating one. Switching weapons and back restores them, which the message
	/// says out loud so it does not read as the reset having failed.
	/// </summary>
	[ConCmd( "nz_wep_reset" )]
	public static void Reset()
	{
		var wep = Held();
		if ( !wep.IsValid() ) { Log.Warning( "[nz] no weapon in hand" ); return; }

		var had = WeaponTuning.Reset( wep );

		Log.Info( had
			? $"[nz] {wep.DisplayName}: overrides dropped — switch away and back to "
				+ "see authored values (nz_wep_save to make it permanent)"
			: $"[nz] {wep.DisplayName} had no overrides" );
	}

	/// <summary>Drop EVERY weapon's overrides: `nz_wep_reset_all`.</summary>
	[ConCmd( "nz_wep_reset_all" )]
	public static void ResetAll()
	{
		int n = WeaponTuning.Current.Weapons.Count;
		WeaponTuning.Current.Weapons.Clear();

		Log.Info( $"[nz] cleared overrides for {n} weapon(s) — nz_wep_save to commit" );
	}
}