Weapons/Rarity.cs

Static utility class for weapon rarity. Defines rarity tiers, colors, damage multipliers, box roll weights and console commands to inspect and modify a players stored rarity tiers.

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// WEAPON RARITY — five tiers, each a flat x1.5 damage step; and a sixth, Godly, red — basalt's Easter egg's
/// (<see cref="GodlyTier"/>), had only once it is complete.
///
/// Ported from the GMod original's `weapons/sh_wep_rarity.lua`, which is pure data
/// plus helpers with no side effects. This is the same: no state lives here.
///
/// ⛔ THE TIER IS OWNED BY THE PLAYER, KEYED ON PREFAB PATH — see
/// `NZPlayer.RarityTiers`. The original hangs it on the weapon entity as a networked
/// int, which cannot work here for the reason `PapLevels` records at length: the
/// weapon is a CLONE of a read-only prefab and Pack-a-Punch destroys and respawns it,
/// so anything stored on the instance is lost on the next equip. The original has to
/// work around exactly this — `perk_machine/shared.lua:822` captures NZRarityTier
/// before the swap specifically because "the weapon entity is REPLACED on most paths,
/// so the tier would otherwise be lost". Keying on the prefab means there is nothing
/// to carry across and no swap path that can forget.
///
/// ⚠️ DAMAGE ONLY. The original changes nothing else — no fire rate, no reload, no
/// handling, no ammo. Widening it is not a port.
///
/// ⚠️ ALWAYS-ON AND INDEPENDENT OF PACK-A-PUNCH: the two multiply, so the chain is
/// `base x pap x rarity x perks`. A box-rolled or salvage-bought gun deals its rarity
/// damage whether or not it has ever been packed.
/// </summary>
public static class Rarity
{
	/// <summary>
	/// Highest tier index there is. 0 = Common, 4 = Legendary, 5 = Godly — basalt's Easter egg's (<see cref="GodlyTier"/>).
	/// What a stored tier is clamped to; what can be HAD is <see cref="TopTier"/>.
	/// </summary>
	public const int MaxTier = 5;

	/// <summary>The top of a game's own ladder: Legendary — where the wonder weapon is pinned, and where the wall buys stop.</summary>
	public const int LegendaryTier = 4;

	/// <summary>
	/// Godly: basalt's Easter egg's tier, red — *"rarity up to Godly with the color red — these are just normal scaling pack a
	/// punch and rarity, nothing new"*: one more x1.5 and an Arsenal price like any tier's. ⛔ NO ROUND OF ITS OWN: the Easter
	/// egg is its only gate — *"not locked behind a round number, instead it's only unlocked after beating the easter egg, no
	/// other way to do it"*. Before the egg nothing hands it out, and a Godly kept from elsewhere reads as Legendary
	/// (<see cref="TierHeld"/>); after it, the box rolls it at any round (<see cref="BoxWeights"/>) and the Arsenal sells it.
	/// </summary>
	public const int GodlyTier = 5;

	/// <summary>The highest tier to be had right now: Legendary — Godly, once basalt's Easter egg is complete.</summary>
	public static int TopTier => TopTierFor( HexPlatforms.EggComplete );

	/// <summary>The same, as the rule has it. The egg apart, for the selftest.</summary>
	public static int TopTierFor( bool egg ) => egg ? GodlyTier : LegendaryTier;

	/// <summary>
	/// A stored tier as it counts: clamped to the table — and a Godly, the Easter egg not complete (<paramref name="egg"/>),
	/// Legendary: kept from a game where it was (the trade table), or set by a test command, it is no way round the egg.
	/// `NZPlayer.RarityTierFor` reads every tier through here. The egg apart, for the selftest.
	/// </summary>
	public static int TierHeld( int stored, bool egg ) => Math.Min( Clamp( stored ), TopTierFor( egg ) );

	/// <summary>Damage multiplier gained per tier step. The original's `STEP`.</summary>
	public const float Step = 1.5f;

	/// <summary>Tier name. The original's `nzRarity.Names`.</summary>
	public static string NameFor( int tier ) => Clamp( tier ) switch
	{
		0 => "Common",
		1 => "Uncommon",
		2 => "Rare",
		3 => "Epic",
		4 => "Legendary",
		5 => "Godly",
		_ => "Common",
	};

	/// <summary>
	/// Tier colour. Grey -> green -> blue -> purple -> gold, the original's
	/// `nzRarity.Colors` converted exactly — and red for Godly, basalt's Easter egg's tier, by the user's word.
	///
	/// ⛔ THE ONE DEFINITION OF THE PALETTE, and it is a `Color` rather than a hex
	/// string because two consumers need actual colours: the box's rarity outline
	/// (HighlightOutline takes a Color) and the HUD's weapon name. HexFor formats THIS,
	/// so a hex list beside it would be a second copy of five values that must agree
	/// — §3, and the copy the stylesheets read is the one that would go stale.
	///
	/// ⚠️ It lives in C#, not in any SCSS, for the same reason: three screens now
	/// read it (weapon stats, Arsenal cards, box outline).
	/// </summary>
	public static Color ColorFor( int tier ) => Clamp( tier ) switch
	{
		0 => Rgb( 175, 175, 175 ),
		1 => Rgb( 120, 210, 110 ),
		2 => Rgb( 90, 165, 240 ),
		3 => Rgb( 190, 120, 235 ),
		4 => Rgb( 255, 221, 64 ),
		5 => Rgb( 255, 50, 50 ),
		_ => Rgb( 175, 175, 175 ),
	};

	static Color Rgb( int r, int g, int b ) => new( r / 255f, g / 255f, b / 255f, 1f );

	/// <summary>
	/// Tier colour as CSS hex, for razor inline styles.
	///
	/// ⚠️ DERIVED FROM ColorFor, never a parallel list. See the note there.
	/// </summary>
	public static string HexFor( int tier )
	{
		var c = ColorFor( tier );

		return $"#{(int)MathF.Round( c.r * 255f ):X2}"
			+ $"{(int)MathF.Round( c.g * 255f ):X2}"
			+ $"{(int)MathF.Round( c.b * 255f ):X2}";
	}

	/// <summary>
	/// Damage multiplier for a tier — `Step ^ tier`.
	///
	/// Common x1, Uncommon x1.5, Rare x2.25, Epic x3.38, Legendary x5.06, Godly x7.59.
	/// </summary>
	public static float Mult( int tier ) => MathF.Pow( Step, Clamp( tier ) );

	/// <summary>
	/// The damage multiplier this weapon's rarity actually gives it — `Mult( tier )`, except for
	/// the wonder weapon, which gets x1.
	/// </summary>
	///
	/// ⛔ THE PRISMA IS LEGENDARY BY NAME, NOT BY DAMAGE. It is never rolled — `RarityTierFor` pins
	/// it to Legendary (<see cref="LegendaryTier"/>, never Godly) so it wears the colour — and its punch is the number on its prefab, as
	/// asked: *"even in legendary tier it has 4000 damage, not the 25000 it currently has"*.
	/// Multiplied like a rolled gun, the prefab's 5000 was landing as 25,312 on every shot.
	///
	/// ⚠️ ONE HELPER FOR THE TWO PLACES THAT MUST AGREE: `NZPlayer.ApplyStoredUpgrades`, which puts
	/// the multiplier on the gun, and `nz_rarity`, which checks it arrived. Testing the wonder weapon
	/// in only one of them makes the check cry MISMATCH on a gun that is doing exactly what it should.
	public static float DamageMult( string prefab, int tier )
		=> BuildParts.IsWonderWeapon( prefab ) ? 1f : Mult( tier );

	public static int Clamp( int tier ) => Math.Clamp( tier, 0, MaxTier );

	// ── the mystery box's roll ───────────────────────────────────

	/// <summary>
	/// The round each tier unlocks at. The original's `ROUND_GATE` — 7 / 14 / 21 / 28.
	/// Below round 7 the box only ever hands out Common.
	///
	/// ⛔ NONE FOR GODLY: basalt's Easter egg is its only gate, at any round (<see cref="BoxWeights"/>).
	/// </summary>
	public static int GateForTier( int tier ) => tier switch
	{
		1 => 7,
		2 => 14,
		3 => 21,
		4 => 28,
		_ => 0,
	};

	/// <summary>
	/// Highest of the round's own tiers the box may roll: Common to Legendary, by their gates. Godly is no round's — the box
	/// adds it once the Easter egg is complete, and opens none of these by it (<see cref="BoxWeights"/>).
	/// </summary>
	public static int MaxTierForRound( int round )
	{
		var max = 0;

		for ( var t = 1; t <= LegendaryTier; t++ )
			if ( round >= GateForTier( t ) ) max = t;

		return max;
	}

	/// <summary>
	/// The box's odds at a round: a weight for each tier, Common to Godly, 0 for one it cannot roll.
	///
	/// ⚠️ `2^(5 - t)`, EACH STEP DOWN TWICE AS LIKELY — the original's curve, Godly the rarest, half Legendary's weight. Over
	/// the round's own tiers the odds are the original's exactly. Godly joins them once basalt's Easter egg is complete
	/// (<paramref name="egg"/>), AT ANY ROUND, and opens none of the others: *"godly weapons only appear in box after beating
	/// the easter egg"*. After it, some 3% of rolls on round 1, 1.6% from round 28.
	/// </summary>
	public static float[] BoxWeights( int round, bool egg )
	{
		var w = new float[MaxTier + 1];
		var top = MaxTierForRound( round );
		for ( var t = 0; t <= top; t++ ) w[t] = MathF.Pow( 2f, GodlyTier - t );
		if ( egg ) w[GodlyTier] = 1f;
		return w;
	}

	/// <summary>
	/// Force every box roll to a tier, for testing. -1 rolls normally.
	///
	/// ⚠️ A STATIC, WHICH MEANS IT SURVIVES HOTLOAD and will keep forcing until it is
	/// switched off — the single most expensive pattern in this project
	/// (INSTRUCTIONS.md §1, seven occurrences). That is the right trade for a test
	/// override, which is useless if a code edit silently resets it, but `nz_box_rarity`
	/// prints the state loudly every time for exactly this reason.
	/// </summary>
	public static int ForcedTier { get; set; } = -1;

	/// <summary>
	/// Roll a tier for a box weapon at this round, by <see cref="BoxWeights"/>.
	///
	/// ⚠️ WEIGHTED TOWARD THE BOTTOM: each step down the ladder is twice as likely — the original's
	/// exact curve. At the round-28 gate that is roughly 52 / 26 / 13 / 6 / 3% across Common..Legendary — unlocking
	/// Legendary is not the same as being likely to see it. With basalt's Easter egg complete Godly takes 1.6% more at the
	/// top, and some 3% on round 1: the egg is its gate, not the round.
	///
	/// ⚠️ THE FORCED TIER IS STILL CLAMPED BY NOTHING. A forced roll ignores the
	/// round gates entirely, on purpose: the point of the override is to see a
	/// Legendary on round 1 without playing to 28. A forced Godly still reads as Legendary until the Easter egg is complete
	/// (<see cref="TierHeld"/>).
	/// </summary>
	public static int RollForRound( int round )
	{
		if ( ForcedTier >= 0 ) return Clamp( ForcedTier );

		var w = BoxWeights( round, HexPlatforms.EggComplete );
		var total = 0f;
		foreach ( var x in w ) total += x;
		if ( total <= 0f ) return 0;

		var pick = Game.Random.Float( 0f, total );
		var cum = 0f;

		for ( var t = 0; t < w.Length; t++ )
		{
			if ( w[t] <= 0f ) continue;
			cum += w[t];
			if ( pick <= cum ) return t;
		}

		return 0;
	}

	// ── reading a live weapon ────────────────────────────────────────────────

	/// <summary>
	/// The prefab path a live weapon came from — the join key for both rarity and
	/// Pack-a-Punch levels.
	///
	/// ⛔ `FindMode.EverythingInSelf`, BECAUSE A HOLSTERED WEAPON IS A DISABLED ONE
	/// and the default Get skips disabled components. Without it this returns null for
	/// the holstered gun and the caller falls back to the starting weapon — which, on
	/// the Pack-a-Punch side, once stamped the ACTIVE weapon's level onto the other
	/// slot and handed out a free MK2. NZPlayer records that as the third time the
	/// trap has cost a bug; this is the fourth place that must not repeat it.
	/// </summary>
	public static string PrefabOf( SWB.Base.Weapon weapon )
		=> weapon.IsValid()
			? weapon.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab
			: null;

	/// <summary>This weapon's tier for its owner, or 0.</summary>
	public static int TierOf( NZPlayer player, SWB.Base.Weapon weapon )
	{
		if ( !player.IsValid() || !weapon.IsValid() ) return 0;

		var prefab = PrefabOf( weapon );
		return string.IsNullOrEmpty( prefab )
			? 0
			: player.RarityTierFor( prefab );
	}

	/// <summary>The active weapon of a player, or null.</summary>
	public static SWB.Base.Weapon HeldBy( NZPlayer player )
	{
		// ⚠️ THE ACTIVE WEAPON, NOT THE FIRST FOUND. With two slots the component
		// list usually yields the HOLSTERED gun first — PackAPunchCommands records
		// what that cost: a diagnostic that reads the wrong object accuses working
		// code. Rarity is per-weapon, so reading the wrong one is worse here than a
		// bad report; it would set a tier on the gun that is not in your hands.
		var active = player.IsValid() ? player.Inventory?.Active : null;

		return active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: null;
	}

	// ── commands ─────────────────────────────────────────────────────────────

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>
	/// `nz_rarity` — what the held weapon's rarity actually is, end to end.
	///
	/// ⚠️ PRINTS THE LIVE `RarityMultiplier` OFF THE SHOOTINFO, not just the stored
	/// tier, and prints what `DamageFor` returns beside it. The tier is DATA; the
	/// multiplier on the gun is the WORLD, and §13 is the rule that changing one is
	/// not changing the other. Reporting only the tier would have looked identical
	/// whether or not the value ever reached the weapon — which is precisely how three
	/// perks stayed dead for months.
	/// </summary>
	[ConCmd( "nz_rarity" )]
	public static void Report()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-rarity] no player — press Play first" ); return; }

		var wep = HeldBy( p );
		if ( !wep.IsValid() )
		{
			Log.Info( "[nz-rarity] no weapon in hand — expected while a machine holds it" );
			return;
		}

		var prefab = PrefabOf( wep );
		var tier = TierOf( p, wep );
		var si = wep.Primary;

		Log.Info( $"[nz-rarity] {wep.DisplayName} — tier {tier} {NameFor( tier ).ToUpper()}"
			+ $" ({HexFor( tier )}) · stored x{DamageMult( prefab, tier ):0.##}" );

		if ( si is null )
		{
			Log.Warning( "[nz-rarity]   no Primary ShootInfo — cannot check the live value" );
			return;
		}

		Log.Info( $"[nz-rarity]   live on the gun: rarity x{si.RarityMultiplier:0.##}"
			+ $" · pap x{si.DamageMultiplier:0.##}"
			+ $" · base {si.Damage:0.#}"
			+ $" -> DamageFor says {si.DamageFor( 0f, null ):0.#}" );

		if ( MathF.Abs( si.RarityMultiplier - DamageMult( prefab, tier ) ) > 0.01f )
			Log.Warning( "[nz-rarity]   MISMATCH — the stored tier never reached the gun. "
				+ "Re-equip, or check NZPlayer.ApplyStoredUpgrades." );

		Log.Info( $"[nz-rarity]   prefab key: {(string.IsNullOrEmpty( prefab ) ? "NONE" : prefab)}" );

		// ⛔ EVERY WEAPON HELD, NOT JUST THE ACTIVE ONE, BECAUSE THE REPORTED BUG IS ABOUT TWO
		// WEAPONS AGREEING WHEN THEY SHOULD NOT. "Buying a tier colours every weapon's name" has
		// exactly two possible causes and this line separates them: if the KEYS below are identical
		// then the tiers are shared and the fault is in how the key is stamped; if the keys differ
		// but the TIERS agree, the dictionary is being written for the wrong weapon.
		//
		// ⚠️ THE HUD READS `TierOf( player, activeWeapon )` AND NOTHING ELSE, so a per-weapon
		// disagreement here is the only thing that can make its colour wrong. `WeaponName` and
		// `WeaponNameStyle` are both in `SurvivalHud`'s build hash, so a stale repaint is already
		// ruled out — do not go looking there again.
		//
		// ⚠️ `EverythingInSelf` ON THE LOOKUP. A holstered weapon is a DISABLED component and the
		// default Get skips it, which would print the active gun's key twice and manufacture the
		// very agreement this line exists to test for. Fourth time this trap has mattered.
		var all = p.Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
			.Where( w => w.IsValid() )
			.ToList();

		Log.Info( $"[nz-rarity]   {all.Count} weapon(s) held:" );

		foreach ( var w in all )
		{
			var k = w.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
			var t = string.IsNullOrEmpty( k ) ? 0 : p.RarityTierFor( k );

			Log.Info( $"[nz-rarity]     {(w.Active ? "*" : " ")} {w.DisplayName,-18}"
				+ $" tier {t} {NameFor( t ).ToUpper(),-9} {HexFor( t )}"
				+ $" · live x{w.Primary?.RarityMultiplier ?? 0f:0.##}"
				+ $" · key {(string.IsNullOrEmpty( k ) ? "NONE — no WeaponSource stamp" : k)}" );
		}

		// ⚠️ THE WHOLE STORE IS DUMPED TOO, because a key present here that matches NO held weapon
		// is the signature of a tier written under one spelling and read under another.
		Log.Info( $"[nz-rarity]   store has {p.RarityTiers.Count} entr(ies): "
			+ (p.RarityTiers.Count == 0
				? "empty"
				: string.Join( "  ", p.RarityTiers.Select( kv => $"{kv.Key}={kv.Value}" ) )) );
	}

	/// <summary>
	/// `nz_rarity_set [0-5]` — set the held weapon's tier. 5 is Godly, which reads as Legendary until the Easter egg is
	/// complete (`nz_hex_boss done`) — as every Godly does.
	///
	/// ⚠️ PUSHES IT ONTO THE LIVE GUN as well as storing it. Writing only the
	/// dictionary would leave the weapon in hand on its old multiplier until the next
	/// equip, and a test that needs a re-equip to take effect is a test that will be
	/// read as a failure (§13).
	/// </summary>
	[ConCmd( "nz_rarity_set" )]
	public static void SetCmd( int tier = 0 )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-rarity] no player — press Play first" ); return; }

		var wep = HeldBy( p );
		if ( !wep.IsValid() ) { Log.Info( "[nz-rarity] no weapon in hand" ); return; }

		var prefab = PrefabOf( wep );
		if ( string.IsNullOrEmpty( prefab ) )
		{
			Log.Warning( "[nz-rarity] this weapon has no WeaponSource — nothing to key on. "
				+ "Expected only for a gun that survived a hotload." );
			return;
		}

		p.SetRarityTier( prefab, tier );
		p.PushStoredUpgrades();

		Report();
	}

	/// <summary>`nz_rarity_list` — every prefab this player has a tier on.</summary>
	[ConCmd( "nz_rarity_list" )]
	public static void ListCmd()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-rarity] no player — press Play first" ); return; }

		if ( p.RarityTiers.Count == 0 )
		{
			Log.Info( "[nz-rarity] nothing upgraded — every weapon is Common" );
			return;
		}

		foreach ( var kv in p.RarityTiers )
			Log.Info( $"[nz-rarity] {NameFor( kv.Value ).ToUpper()} (x{Mult( kv.Value ):0.##})"
				+ $" — {kv.Key}" );
	}

	/// <summary>
	/// `nz_box_rarity [-1..5]` — force what the mystery box rolls. -1 rolls normally.
	///
	/// ⚠️ REPORTS THE ROUND GATES ALONGSIDE, because "the box keeps giving me
	/// Common" has two completely different causes — the override being off, or the
	/// round being below 7 — and they look identical from the box.
	///
	/// ⚠️ Also prints that the setting is a surviving static, so a forced tier left
	/// on does not get mistaken for the box being broken three sessions later.
	/// </summary>
	[ConCmd( "nz_box_rarity" )]
	public static void BoxRarityCmd( int tier = -99 )
	{
		if ( tier != -99 )
			ForcedTier = tier < 0 ? -1 : Clamp( tier );

		var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;
		var gate = MaxTierForRound( round );

		if ( ForcedTier >= 0 )
		{
			Log.Info( $"[nz-rarity] box FORCED to {NameFor( ForcedTier ).ToUpper()}"
				+ $" (x{Mult( ForcedTier ):0.##}) — ignores the round gates."
				+ " `nz_box_rarity -1` to roll normally." );
			Log.Info( "[nz-rarity]   ⚠ this is a static and survives hotload — it stays "
				+ "forced until you turn it off" );
			if ( ForcedTier == GodlyTier && !HexPlatforms.EggComplete )
				Log.Info( "[nz-rarity]   ⚠ a Godly reads as Legendary until basalt's Easter egg is complete — `nz_hex_boss done` to test it" );
			return;
		}

		Log.Info( $"[nz-rarity] box rolling normally — round {round} allows up to "
			+ $"{NameFor( gate ).ToUpper()}" );

		if ( gate < LegendaryTier )
			Log.Info( $"[nz-rarity]   next unlock: {NameFor( gate + 1 ).ToUpper()}"
				+ $" at round {GateForTier( gate + 1 )}" );

		Log.Info( HexPlatforms.EggComplete
			? "[nz-rarity]   GODLY in the box too, at any round — basalt's Easter egg is complete"
			: "[nz-rarity]   Godly: only once basalt's Easter egg is complete — no round opens it" );

		if ( gate == 0 )
			Log.Info( "[nz-rarity]   below round 7 the box only gives Common — "
				+ "`nz_box_rarity 4` to test without playing there" );
	}

	/// <summary>`nz_rarity_clear` — back to Common everywhere.</summary>
	[ConCmd( "nz_rarity_clear" )]
	public static void ClearCmd()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-rarity] no player — press Play first" ); return; }

		p.ClearRarity();
		p.PushStoredUpgrades();

		Log.Info( "[nz-rarity] cleared — every weapon back to Common" );
	}
}