Weapons/WeaponClassRules.cs

Utility class that defines per-category weapon class rules and applies them idempotently to Weapon instances. It stores authored baselines for ShootInfo and Weapon mobility, multiplies damage, headshot multiplier and mobility based on a Rules table, and provides a console command to dump rule and current held-weapon info.

Reflection
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using SWB.Base;

namespace NZombies;

/// <summary>
/// Stat bonuses a weapon gets for belonging to a CLASS. `nz_class_rules`.
///
/// ⛔ A RULE, NOT A BAKED NUMBER. Multiplying the damage into each revolver's prefab would work
/// once and then rot: the class would stop meaning anything, a weapon moved into it later would
/// silently miss its bonus, and nobody reading the prefab could tell the 4x from the authored
/// value. Keyed on the category instead, the class IS the rule -- put a weapon in it and it gets
/// the bonus, take it out and it loses it.
///
/// ⛔ AND IT MUST BE IDEMPOTENT. `ShootInfo` is deserialised from the prefab and this runs on every
/// spawn, so multiplying in place would compound: draw the same revolver four times and it does
/// 4x, 16x, 64x, 256x. The AUTHORED value is remembered on first sight and every application is
/// computed from that, so re-applying is free.
/// </summary>
public static class WeaponClassRules
{
	/// <summary>What a class is worth. Absent from this table = no bonus, which is most classes.</summary>
	public record Rule( float Damage = 1f, float Head = 1f, float Mobility = 1f );

	public static readonly Dictionary<string, Rule> Rules = new()
	{
		// ⛔ REVOLVERS USED TO BE `Damage: 4f` AND DELIBERATELY ARE NOT ANY MORE. A class-wide
		// multiplier meant the number in the prefab was not the number the player felt: a revolver
		// written as 450 dps played as 1800, so every balance table showed a figure four times off,
		// and revolvers could not be compared against any other class without remembering the
		// multiplier. Their damage is now authored directly -- high per shot, low rate, a 1300 dps
		// class target in `Tools/dps_pass.py`. One number, and it is the true one.
		//
		// ⚠️ Battle rifles keep the HEADSHOT half. That one does not distort the comparison: it
		// changes what a hit is worth by where it lands, not what the weapon's dps is.
		["Battle Rifles"] = new Rule( Head: 1.5f ),

		// ⛔ MOBILITY IS THE LMG'S ONLY REAL COST. Its counterweights on paper are slow ADS,
		// slow reload and wider spread -- but in a mode built on training a horde, none of those
		// weigh as much as ammo capacity, so an 80-round LMG with no movement penalty is simply
		// the best class. Speed is the price that is actually felt.
		["Light Machine Guns"] = new Rule( Mobility: 0.8f ),
	};

	// ⚠️ Keyed on the ShootInfo INSTANCE. Two weapons of the same class each get their own entry,
	// and a respawned one simply re-reads its authored value rather than inheriting a scaled one.
	static readonly Dictionary<ShootInfo, (float dmg, float head)> Authored = new();

	// ⚠️ Mobility lives on the WEAPON, not the ShootInfo, so it needs its own baseline
	// table -- same reason and same trap: multiplying in place compounds on every respawn.
	static readonly Dictionary<Weapon, float> AuthoredMobility = new();

	static (float, float) BaselineOf( ShootInfo si )
	{
		if ( Authored.TryGetValue( si, out var v ) ) return v;
		v = (si.Damage, si.HeadMultiplier);
		Authored[si] = v;
		return v;
	}

	/// <summary>Apply this weapon's class bonuses. Safe to call repeatedly.</summary>
	public static void Apply( Weapon weapon )
	{
		if ( !weapon.IsValid() ) return;

		var prefab = Rarity.PrefabOf( weapon );
		if ( string.IsNullOrEmpty( prefab ) ) return;

		var category = WeaponLibrary.Find( prefab )?.Category;
		if ( string.IsNullOrEmpty( category ) ) return;

		if ( !AuthoredMobility.TryGetValue( weapon, out var baseMobility ) )
			AuthoredMobility[weapon] = baseMobility = weapon.Mobility;

		foreach ( var si in new[] { weapon.Primary, weapon.Secondary } )
		{
			if ( si is null ) continue;
			var (dmg, head) = BaselineOf( si );

			// ⚠️ NOT a lookup failure when the class has no rule — most do not. The authored value
			// is restored rather than left alone, so moving a weapon OUT of a bonus class actually
			// removes the bonus instead of leaving the last one baked in.
			var rule = Rules.TryGetValue( category, out var r ) ? r : new Rule();
			si.Damage = dmg * rule.Damage;
			si.HeadMultiplier = head * rule.Head;
		}

		var mob = Rules.TryGetValue( category, out var mr ) ? mr.Mobility : 1f;
		weapon.Mobility = baseMobility * mob;
	}

	/// <summary>`nz_class_rules` — what each class is worth, and what the held weapon got.</summary>
	[ConCmd( "nz_class_rules" )]
	public static void Dump()
	{
		Log.Info( "[class] bonuses by category:" );
		foreach ( var (cat, r) in Rules )
			Log.Info( $"[class]   {cat,-18} damage x{r.Damage:0.##}  head x{r.Head:0.##}"
				+ $"  mobility x{r.Mobility:0.##}" );

		var counts = WeaponLibrary.All
			.GroupBy( e => e.Category )
			.ToDictionary( g => g.Key, g => g.Count() );
		foreach ( var cat in Rules.Keys )
			Log.Info( $"[class]   {cat,-18} {counts.GetValueOrDefault( cat, 0 )} weapon(s) in this class" );

		var held = Game.ActiveScene?.GetAllComponents<Weapon>()
			.FirstOrDefault( w => w.IsValid() && w.GameObject.Enabled );
		if ( !held.IsValid() || held.Primary is null ) return;

		var prefab = Rarity.PrefabOf( held );
		var category = WeaponLibrary.Find( prefab )?.Category ?? "?";
		var (dmg, head) = BaselineOf( held.Primary );
		Log.Info( $"[class] holding {held.DisplayName} [{category}]"
			+ $"  damage {dmg:0.##} -> {held.Primary.Damage:0.##}"
			+ $"  head x{head:0.##} -> x{held.Primary.HeadMultiplier:0.##}" );
	}
}