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