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