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.
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>();
}
}