Utility that builds an index of weapon prefab stats and classes from on-disk JSON/prefab text, and exposes percentile-based UI metrics for how a weapon value ranks inside its class. It reads weapons/manifest.json and compiled prefab JSON text, parses numeric fields, caches results, and provides Fraction/Rank helpers and a console command to dump class summaries.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace NZombies;
/// <summary>
/// Per-class minimum and maximum for every weapon stat, so a bar reads as
/// "where this gun sits among its peers" rather than against an arbitrary ceiling.
///
/// ⛔ READ FROM THE PREFAB JSON, NEVER FROM A LIVE WEAPON. Two reasons, and both are
/// requirements rather than conveniences:
///
/// 1. **Pack-a-Punch must not move the bars.** A live weapon carries
/// `DamageMultiplier`, and an upgraded gun would otherwise redefine the maximum
/// for its whole class — every other weapon's bar would shrink because you
/// bought an upgrade. The prefab is the authored value and cannot drift.
/// 2. Only one weapon exists at a time. The other thirty are never instantiated,
/// so there is nothing live to measure them against.
///
/// ⚠️ Also ignores `weapon_tuning.json`. Editor overrides change what a gun DOES;
/// leaving them out of the comparison keeps the scale stable while you tune, which is
/// what makes the bars usable AS a tuning readout.
/// </summary>
public static class WeaponClassStats
{
/// <summary>prefab path -> field -> value, straight from the prefab.</summary>
static Dictionary<string, Dictionary<string, float>> _stats;
/// <summary>prefab path -> category from the manifest.</summary>
static Dictionary<string, string> _class;
/// <summary>
/// The class a weapon belongs to, e.g. "Assault Rifles".
///
/// ⚠️ From `manifest.json`, which is the same source the mystery box and the
/// wall-buy category filter use — so "class" means one thing across the game.
/// </summary>
public static string ClassOf( string prefabPath )
{
Build();
return prefabPath is not null && _class.TryGetValue( prefabPath, out var c ) ? c : "";
}
/// <summary>Every weapon prefab in a class.</summary>
public static IEnumerable<Dictionary<string, float>> InClass( string category )
{
Build();
if ( string.IsNullOrEmpty( category ) ) return Enumerable.Empty<Dictionary<string, float>>();
return _class.Where( kv => kv.Value == category )
.Select( kv => _stats.TryGetValue( kv.Key, out var s ) ? s : null )
.Where( s => s is not null );
}
// ── percentile ranking ───────────────────────────────────────────────────
// ⛔ THESE BARS WERE A LINEAR MIN-MAX MAP AND THAT IS THE WRONG SHAPE FOR THIS DATA.
// `(value - min) / (max - min)` says where a number sits between the two EXTREMES of its
// class, which is only informative when the class is spread evenly — and none of them are.
// Penetration is the clearest case: 249 weapons sit at the engine ceiling and 187 pierce
// nothing, so a linear bar renders almost the entire fleet as either full or empty and the
// sixty weapons in between get the whole middle of the track to themselves. One outlier
// stretches the scale for everybody.
//
// ⚠️ A PERCENTILE ANSWERS THE QUESTION A PLAYER IS ACTUALLY ASKING — "is this good FOR AN
// SMG" — rather than "how close is this to the single best SMG". Twenty sectors, p0 to
// p100 in steps of five, requested exactly that way.
/// <summary>How many sectors a bar is cut into. 20 = one per 5 percentiles.</summary>
public const int Sectors = 20;
/// <summary>
/// Percentile rank of <paramref name="value"/> among <paramref name="vals"/>, snapped to a
/// sector boundary.
///
/// ⛔ TIES TAKE THE MIDPOINT OF THE BLOCK THEY SHARE, not its top or bottom, and with this
/// data that is not a detail. 249 weapons carry the identical penetration value: counting
/// "strictly below" would park every one of them at p50 while 247 weapons sit beneath them,
/// and counting "at or below" would put all 249 at p100. The mid-rank —
/// `(below + equal / 2) / n` — is the standard treatment and puts the block in the middle of
/// the band it actually occupies.
///
/// ⚠️ STILL NEVER RETURNS 0, for the reason the old note gives and one more: this card uses
/// an explicit 0 to mean "does not have this stat" — the Penetration row passes it for
/// "None". Worst-in-class and has-none-at-all must not draw the same bar, so the floor is
/// one sector.
/// </summary>
static float Rank( List<float> vals, float value, bool lowerIsBetter )
{
if ( vals is null || vals.Count == 0 ) return 0.55f; // unknown class — a neutral half bar
int below = 0, equal = 0;
foreach ( var v in vals )
{
// ⚠️ AN EPSILON, NOT `==`. These are floats out of prefab json; 0.0100000002 and 0.01
// are the same authored number and must land in the same tie block, or a class splits
// into bands that exist only in the last decimal place.
if ( MathF.Abs( v - value ) <= 0.0001f ) equal++;
else if ( v < value ) below++;
}
var rank = (below + equal * 0.5f) / vals.Count;
if ( lowerIsBetter ) rank = 1f - rank;
// ⚠️ ROUNDED TO A SECTOR so the bar lands on a boundary — the whole point of asking for
// twenty of them. A bar free to stop anywhere is the linear map again with extra steps.
var sector = (int)MathF.Round( rank * Sectors );
sector = Math.Clamp( sector, 1, Sectors );
return sector / (float)Sectors;
}
/// <summary>
/// Where <paramref name="value"/> sits within its class, as a bar fraction — its PERCENTILE
/// among the weapons of that class, snapped to one of 20 sectors.
///
/// ⚠️ A FULL BAR NOW MEANS "top of its class", NOT "equal to the best number". When every
/// weapon in a class is identical — `HeadMultiplier` very often is — they all tie, the
/// mid-rank is 0.5, and they draw a HALF bar rather than the full one the old code gave.
/// That is the honest reading: jointly average is not jointly best, and a row of full bars
/// across a class told the player nothing.
/// </summary>
public static float Fraction( string category, string field, float value, bool lowerIsBetter = false )
{
var vals = InClass( category )
.Select( s => s.TryGetValue( field, out var v ) ? v : (float?)null )
.Where( v => v.HasValue )
.Select( v => v.Value )
.ToList();
return Rank( vals, value, lowerIsBetter );
}
/// <summary>Same, for a value derived from several fields.</summary>
public static float Fraction( string category, Func<Dictionary<string, float>, float> select,
float value, bool lowerIsBetter = false )
{
// ⚠️ THE SAME `Rank`, not a second copy of the maths. The two overloads differed only in
// how they gathered the class's values, and keeping one ranker is what stops a derived
// stat's bar from being scaled differently to a plain one.
var vals = InClass( category ).Select( select ).ToList();
return Rank( vals, value, lowerIsBetter );
}
/// <summary>Read one field out of an indexed weapon, 0 when absent.</summary>
public static float Get( Dictionary<string, float> s, string field )
=> s is not null && s.TryGetValue( field, out var v ) ? v : 0f;
// ── the index ────────────────────────────────────────────────────────────
/// <summary>
/// Build once per session.
///
/// ⚠️ THIRTY-ONE FILES PARSED ON FIRST OPEN of the stats card, then cached. Doing
/// it lazily rather than at startup keeps it off the loading path, and the panel
/// is opened by a keypress so a few milliseconds there is invisible.
/// </summary>
static void Build()
{
if ( _stats is not null ) return;
_stats = new();
_class = new();
try
{
var manifest = FileSystem.Mounted.ReadAllText( "weapons/manifest.json" );
using var doc = JsonDocument.Parse( manifest );
foreach ( var entry in doc.RootElement.EnumerateObject() )
{
var path = entry.Name;
if ( entry.Value.TryGetProperty( "category", out var cat ) )
_class[path] = cat.GetString() ?? "";
var fields = ReadPrefab( path );
if ( fields is not null ) _stats[path] = fields;
}
}
catch ( Exception e )
{
// ⚠️ A broken index must not take the stats card down — it degrades to
// neutral half-bars, which is a cosmetic loss, not a crash mid-round.
Log.Warning( $"[nz] weapon class stats failed to build ({e.Message})" );
}
Log.Info( $"[nz] weapon class index: {_stats.Count} weapons, "
+ $"{_class.Values.Distinct().Count()} classes" );
}
/// <summary>
/// Pull the numbers we compare on out of a prefab.
///
/// ⛔ FIRST OCCURRENCE OF EACH NAME. A weapon prefab can carry a second ShootInfo
/// for an underbarrel or akimbo; the primary is written first, and taking the
/// first match is what keeps a grenade launcher's damage out of the rifle's bar.
/// </summary>
static Dictionary<string, float> ReadPrefab( string path )
{
// ⛔ THE COMPILED PREFAB'S JSON, NOT THE `.prefab` TEXT, which no longer ships (PrefabText).
var text = PrefabText.Read( path );
if ( string.IsNullOrEmpty( text ) ) return null;
var wanted = new[]
{
"Damage", "Bullets", "RPM", "ClipSize", "ReloadTime",
"Spread", "SpreadAddHipFire", "PenetrationDepth", "HeadMultiplier",
"RecoilUp", "RecoilSide", "Recoil",
};
var result = new Dictionary<string, float>();
foreach ( var key in wanted )
{
var needle = $"\"{key}\":";
var i = text.IndexOf( needle, StringComparison.Ordinal );
if ( i < 0 ) continue;
var j = i + needle.Length;
while ( j < text.Length && (text[j] == ' ' || text[j] == '\t') ) j++;
var start = j;
while ( j < text.Length && (char.IsDigit( text[j] ) || text[j] == '.'
|| text[j] == '-' || text[j] == 'E' || text[j] == 'e' || text[j] == '+') ) j++;
if ( j > start && float.TryParse( text[start..j],
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v ) )
result[key] = v;
}
return result.Count > 0 ? result : null;
}
/// <summary>Rebuild the index: `nz_wep_classes`.</summary>
[ConCmd( "nz_wep_classes" )]
public static void Dump()
{
_stats = null;
Build();
foreach ( var g in _class.GroupBy( kv => kv.Value ).OrderBy( g => g.Key ) )
{
var members = g.Select( kv => kv.Key ).Where( p => _stats.ContainsKey( p ) ).ToList();
if ( members.Count == 0 ) { Log.Info( $"[nz] {g.Key}: no stats" ); continue; }
float Tot( string p ) => Get( _stats[p], "Damage" ) * Math.Max( 1f, Get( _stats[p], "Bullets" ) );
Log.Info( $"[nz] {g.Key} ({members.Count}) — damage {members.Min( Tot ):0.#}"
+ $" to {members.Max( Tot ):0.#}" );
}
}
}