Weapons/WeaponTuning.cs

WeaponTuning is a utility class that stores per-weapon numeric overrides (float/int/bool) that persist across restarts via a small JSON overlay. It can load a shipped default, read/write the user file, resolve tunable fields via s&box TypeLibrary on either the weapon's Primary (ShootInfo) or the Weapon itself, apply stored overrides when a weapon deploys, and provide helpers to set/get/reset and enumerate fields.

File AccessReflection
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Per-weapon stat overrides that survive a restart.
///
/// ⛔ AN OVERLAY, NOT AN EDIT OF THE PREFAB. The obvious implementation is to write
/// the tuned numbers straight back into `Assets/prefabs/weapons/nz_*.prefab`, since
/// that is where they live and it would make them the game's real values. Three
/// reasons not to:
///
///   1. A prefab is kin to a `.scene`, and this project does not rewrite those from
///      script — the format carries component GUIDs, nested prefab references and
///      ordering that a hand-rolled serializer silently flattens.
///   2. `Assets/` is not writable in a published build, so the feature would work on
///      this machine and quietly do nothing anywhere else.
///   3. An overlay is REVERSIBLE. `nz_wep_reset` puts a weapon back to its authored
///      values; a prefab rewrite has destroyed them.
///
/// So edits live in a small JSON of just the changed fields, applied over each
/// weapon as it deploys. `MapConfig` established this exact pattern and this follows
/// it — <see cref="FileSystem.Data"/>, which persists across restarts AND rebuilds.
///
/// ⚠️ KEYED BY PREFAB PATH, NOT DISPLAY NAME. Pack-a-Punch rewrites `DisplayName` to
/// "M1911 MK2", so a name key would lose every override the moment a weapon is
/// upgraded — and would apply the base gun's tuning to a different weapon that
/// happened to share a name.
/// </summary>
public sealed class WeaponTuning
{
	/// <summary>prefab path -> field name -> value.</summary>
	public Dictionary<string, Dictionary<string, float>> Weapons { get; set; } = new();

	/// <summary>A mapper's own tuning — per-user and writable.</summary>
	const string FilePath = "weapon_tuning.json";

	/// <summary>The SHIPPED tuning, under Assets. See `WeaponPlacement.SHIPPED` — a published
	/// copy's `FileSystem.Data` is empty, so without this the published game runs every weapon at
	/// its prefab defaults.</summary>
	const string Shipped = "weapons/tuning.json";

	static WeaponTuning _current;

	/// <summary>The live set, loaded on first use.</summary>
	public static WeaponTuning Current => _current ??= Load();

	static WeaponTuning Load()
	{
		try
		{
			// ⚠️ Data wins wholesale, then the shipped copy — the same order and the same
			// reason as `WeaponPlacement` and `MapConfig.Load`.
			BaseFileSystem fs = null;
			string path = null;

			if ( FileSystem.Data.FileExists( FilePath ) ) { fs = FileSystem.Data; path = FilePath; }
			else if ( FileSystem.Mounted.FileExists( Shipped ) ) { fs = FileSystem.Mounted; path = Shipped; }

			if ( fs is not null )
			{
				var loaded = fs.ReadJson<WeaponTuning>( path );
				if ( loaded is not null )
				{
					loaded.Weapons ??= new();

					Log.Info( $"[nz-wep] tuning: {loaded.Weapons.Count} weapon(s) "
						+ $"[{( fs == FileSystem.Data ? "user" : "shipped" )}]" );
					return loaded;
				}
			}
		}
		catch ( System.Exception e )
		{
			// ⚠️ A CORRUPT FILE MUST NOT TAKE THE GAME DOWN. This is tuning data:
			// losing it costs a re-tune, while throwing here means no weapons at all
			// because the exception lands inside weapon deploy.
			Log.Warning( $"[nz] weapon tuning failed to load ({e.Message}) — starting empty" );
		}

		return new WeaponTuning();
	}

	/// <summary>
	/// Re-read from disk, discarding anything unsaved in memory.
	///
	/// ⚠️ NEEDED BECAUSE `Current` IS CACHED FOR THE SESSION. Hand-editing the JSON —
	/// or restoring it after a mistaken `nz_wep_reset_all` — changes the file while
	/// memory still holds the old set, and the next save writes memory straight back
	/// over it. Without this the only way to pick up an external edit is to restart.
	/// </summary>
	public static void Reload()
	{
		_current = Load();
		Log.Info( $"[nz] weapon tuning reloaded — {_current.Weapons.Count} weapon(s), "
			+ $"{_current.Weapons.Sum( w => w.Value.Count )} value(s)" );
	}

	/// <summary>Write to disk. Survives restarts and rebuilds.</summary>
	public void Save()
	{
		FileSystem.Data.WriteJson( FilePath, this );
		Log.Info( $"[nz] weapon tuning saved — {Weapons.Count} weapon(s), "
			+ $"{Weapons.Sum( w => w.Value.Count )} value(s)" );
	}

	/// <summary>Where the file actually is, for the "where did it go" question.</summary>
	public static string FileLocation => FilePath;

	// ── identity ─────────────────────────────────────────────────────────────

	/// <summary>
	/// The key for a weapon: its source prefab.
	///
	/// ⚠️ Falls back to the GameObject name only if `WeaponSource` is missing, which
	/// means a weapon spawned by a path that forgot to stamp it. That is worth
	/// knowing about, so it is not silent.
	/// </summary>
	public static string KeyFor( SWB.Base.Weapon wep )
	{
		if ( !wep.IsValid() ) return null;

		var src = wep.GameObject.Components
			.Get<WeaponSource>( FindMode.EverythingInSelf );

		if ( src is not null && !string.IsNullOrEmpty( src.Prefab ) )
			return src.Prefab;

		return wep.GameObject.Name;
	}

	// ── reflection ───────────────────────────────────────────────────────────

	/// <summary>
	/// Resolve a field name to the object that owns it.
	///
	/// ⛔ TWO OBJECTS, SEARCHED IN ORDER. The numbers a person means by "the weapon's
	/// stats" are split: damage, RPM, spread and recoil live on `Primary` (a
	/// ShootInfo), while reload and draw times live on the Weapon itself. Asking the
	/// user to know which is which would make the editor a lookup exercise, so the
	/// name is resolved against ShootInfo first and the Weapon second.
	///
	/// ⚠️ `TypeLibrary`, NOT `System.Reflection`. It is s&box's own descriptor system
	/// and the one that survives into a built game.
	/// </summary>
	static (object target, PropertyDescription prop) Resolve( SWB.Base.Weapon wep, string field )
	{
		if ( !wep.IsValid() || string.IsNullOrWhiteSpace( field ) ) return (null, null);

		foreach ( var target in new object[] { wep.Primary, wep } )
		{
			if ( target is null ) continue;

			var td = TypeLibrary.GetType( target.GetType() );
			if ( td is null ) continue;

			var prop = td.Properties.FirstOrDefault( p =>
				p.Name.Equals( field, System.StringComparison.OrdinalIgnoreCase )
				&& p.CanWrite && IsNumeric( p.PropertyType ) );

			if ( prop is not null ) return (target, prop);
		}

		return (null, null);
	}

	static bool IsNumeric( System.Type t ) =>
		t == typeof( float ) || t == typeof( int ) || t == typeof( bool );

	/// <summary>Every tunable field on a weapon, with its current value.</summary>
	public static IEnumerable<(string name, float value, string owner)> Fields( SWB.Base.Weapon wep )
	{
		if ( !wep.IsValid() ) yield break;

		foreach ( var (target, owner) in new[] { ((object)wep.Primary, "shot"), (wep, "weapon") } )
		{
			if ( target is null ) continue;

			var td = TypeLibrary.GetType( target.GetType() );
			if ( td is null ) continue;

			foreach ( var p in td.Properties
				.Where( p => p.CanRead && p.CanWrite && IsNumeric( p.PropertyType ) )
				.OrderBy( p => p.Name ) )
			{
				float v;
				try { v = ToFloat( p.GetValue( target ) ); }
				catch ( System.Exception ) { continue; }

				yield return (p.Name, v, owner);
			}
		}
	}

	static float ToFloat( object o ) => o switch
	{
		float f => f,
		int i => i,
		bool b => b ? 1f : 0f,
		_ => 0f
	};

	static object FromFloat( System.Type t, float v )
	{
		if ( t == typeof( int ) ) return (int)System.MathF.Round( v );
		if ( t == typeof( bool ) ) return v != 0f;
		return v;
	}

	// ── the operations ───────────────────────────────────────────────────────

	/// <summary>
	/// Set a value on the live weapon AND record it. Returns the resolved field name,
	/// or null if there is no such field.
	/// </summary>
	public static string Set( SWB.Base.Weapon wep, string field, float value )
	{
		var (target, prop) = Resolve( wep, field );
		if ( prop is null ) return null;

		prop.SetValue( target, FromFloat( prop.PropertyType, value ) );

		var key = KeyFor( wep );
		if ( key is null ) return prop.Name;

		if ( !Current.Weapons.TryGetValue( key, out var fields ) )
			Current.Weapons[key] = fields = new();

		// ⚠️ Stored under the RESOLVED name, not what was typed — so `nz_wep_set rpm`
		// and `nz_wep_set RPM` produce one entry, not two that fight on load.
		fields[prop.Name] = value;

		return prop.Name;
	}

	/// <summary>Read one value off the live weapon.</summary>
	public static float? Get( SWB.Base.Weapon wep, string field )
	{
		var (target, prop) = Resolve( wep, field );
		if ( prop is null ) return null;

		try { return ToFloat( prop.GetValue( target ) ); }
		catch ( System.Exception ) { return null; }
	}

	/// <summary>
	/// Push every stored override onto a weapon. Called as it deploys.
	///
	/// ⚠️ SILENT ON UNKNOWN FIELDS. A saved file outlives the code — renaming a
	/// property in ShootInfo would otherwise spam a warning per weapon per deploy
	/// forever. The stale entry is simply skipped and `nz_wep_tune` shows it.
	/// </summary>
	public static void Apply( SWB.Base.Weapon wep )
	{
		var key = KeyFor( wep );
		if ( key is null ) return;
		if ( !Current.Weapons.TryGetValue( key, out var fields ) ) return;

		foreach ( var (name, value) in fields )
		{
			var (target, prop) = Resolve( wep, name );
			if ( prop is null ) continue;

			try { prop.SetValue( target, FromFloat( prop.PropertyType, value ) ); }
			catch ( System.Exception ) { }
		}
	}

	/// <summary>Drop a weapon's overrides. It returns to authored values on respawn.</summary>
	public static bool Reset( SWB.Base.Weapon wep )
	{
		var key = KeyFor( wep );
		return key is not null && Current.Weapons.Remove( key );
	}

	/// <summary>What is overridden for this weapon right now.</summary>
	public static IReadOnlyDictionary<string, float> Overrides( SWB.Base.Weapon wep )
	{
		var key = KeyFor( wep );
		if ( key is not null && Current.Weapons.TryGetValue( key, out var f ) ) return f;
		return new Dictionary<string, float>();
	}
}