UI/WeaponClassStats.cs

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.

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