Weapons/AmmoModUpgrades.cs

Defines ammo mod upgrade data for NZombies: record type Up, list of all upgrade entries (I–V) with names/effects, which upgrades are wired, lookup helpers (For, Find), player-level accessors (Level, Has), and two console commands for reporting and setting levels for diagnostics.

File Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// AMMO MOD UPGRADES — five levels for every ammo mod, bought on the Arsenal's ammo page (I–III 2026-10-05, `Sbox nzombies/Docs/
/// AMMO_MODS.md` "Upgrades"; IV and V 2026-10-06, "Tiers IV and V").
///
/// The user (2026-10-04 19:33): *"when i click an ammo mod i can buy it or i can also upgrade it up to 3 times / each upgrade
/// costing more and being permanent to that ammo mod, so i can equip on any weapon"*. I–III were decided with the user one
/// mod at a time. The prices are theirs too (2026-10-05 01:20, *"make the costs 1000 / 2000 / 3000"*), kept per machine
/// like every Arsenal price: `ArsenalSpot.AmmoUpgrade1Price` to `AmmoUpgrade5Price`.
///
/// ⚠️ IV AND V ARE THE LATE GAME (2026-10-06, decided one mod at a time as I–III were). The user: *"i'd go as far as to make tier
/// 4 5000 and tier 5 10000 like a really late game thing"*. I–III stay exactly as they were; IV is a big step on a number I–III
/// left alone, V a capstone, one signature effect.
///
/// ⛔ A LEVEL BELONGS TO THE MOD, NOT THE GUN. The player keeps one level per mod id (`NZPlayer.AmmoModLevel`), so Dead Wire II
/// is Dead Wire II on whichever gun carries Dead Wire. It lasts the rest of the game; the new-run reset clears it
/// (`RoundManager.ResetPlayerForRun`).
///
/// ⚠️ THE SHOP CAME FIRST (the user: *"let's start by building the UI for upgrades"*), THE EFFECTS THE SAME DAY (2026-10-05).
/// `Up.Built` says which upgrades do something — the keys in <see cref="Wired"/> — and the panel tags any other NOT BUILT YET.
/// An effect reads its level through <see cref="Level"/> on the machine where it already runs (the shooter's, the killer's,
/// or the host for a host-run effect), which also answers on the host for a client's player (`NZPlayer.AmmoUpgradeNet`).
/// Systems that reuse a mod's code for an effect of their own (Elemental Pop's m5 chain, Widow's Wine's webs) never read it.
///
/// ⚠️ THE TEXT HERE IS THE PANEL'S: one line each, with the value it replaces in brackets. The doc's table has the full
/// wording and the reasons, and the doc is the source if the two ever disagree.
/// </summary>
public static class AmmoModUpgrades
{
	/// <summary>One level of one mod.</summary>
	/// <param name="ModId">The mod's `AmmoMods.Mod.Id`.</param>
	/// <param name="Level">1 to <see cref="MaxLevel"/>.</param>
	/// <param name="Name">The doc's name for it, e.g. "Longer Volley".</param>
	/// <param name="Effect">One line for the panel.</param>
	public record Up( string ModId, int Level, string Name, string Effect )
	{
		/// <summary>"deadwire2": the mod and the level, for <see cref="Wired"/> and the logs.</summary>
		public string Key => $"{ModId}{Level}";

		/// <summary>Is its effect wired. Every display path reads this.</summary>
		public bool Built => Wired.Contains( Key );

		/// <summary>
		/// The level as the panel writes it. ⚠️ ALWAYS ROMAN, on every map, unlike the tiers (`HudTheme.TierNumeral`): it is how
		/// the user names them (*"Make III the 7 nearest"*).
		/// </summary>
		public string Numeral => HudTheme.ToRoman( Level );
	}

	/// <summary>
	/// How many levels a mod has. 5 SINCE 2026-10-06 (3 before): IV and V, `AMMO_MODS.md` "Tiers IV and V".
	///
	/// ⚠️ A `const`, SO EVERY READER IS BAKED WITH IT (§1): the store's clamp (`NZPlayer.AmmoModLevel`, `SetAmmoModLevel`), the
	/// Arsenal's "fully upgraded" (`Arsenal.BuyAmmoUpgrade`), the tiles' pips and `nz_ammomod_upgrades` all read this one number.
	/// </summary>
	public const int MaxLevel = 5;

	/// <summary>
	/// The upgrades whose effect exists, by <see cref="Up.Key"/>.
	///
	/// ⚠️ FILLED 2026-10-05, WHEN THE EFFECTS WERE BUILT: every upgrade in <see cref="All"/> was listed then. A key comes out the
	/// day its effect does, and the panel tags it NOT BUILT YET again: one edit, as `AmmoMods.Mod.Built` is.
	///
	/// ⚠️ IV AND V JOINED IT 2026-10-06, EVERY ONE, AS THEY WERE BUILT (`AMMO_MODS.md` "Tiers IV and V"): one workflow built all of
	/// them at once. A key whose effect does not hold up comes back out, as above.
	///
	/// ⛔ A PROPERTY THAT BUILDS THE ARRAY, NOT A `static readonly` ONE: INSTRUCTIONS §1, the shape `All` uses.
	/// </summary>
	public static string[] Wired => new[]
	{
		"fireworks1", "fireworks2", "fireworks3", "fireworks4", "fireworks5",
		"blastfurnace1", "blastfurnace2", "blastfurnace3", "blastfurnace4", "blastfurnace5",
		"deadwire1", "deadwire2", "deadwire3", "deadwire4", "deadwire5",
		"thunderwall1", "thunderwall2", "thunderwall3", "thunderwall4", "thunderwall5",
		"cryofreeze1", "cryofreeze2", "cryofreeze3", "cryofreeze4", "cryofreeze5",
		"radiation1", "radiation2", "radiation3", "radiation4", "radiation5",
		"silkshot1", "silkshot2", "silkshot3", "silkshot4", "silkshot5",
		"leech1", "leech2", "leech3", "leech4", "leech5",
		"midas1", "midas2", "midas3", "midas4", "midas5",
		"scrapper1", "scrapper2", "scrapper3", "scrapper4", "scrapper5",
		"headhunter1", "headhunter2", "headhunter3", "headhunter4", "headhunter5",
		"shatterblast1", "shatterblast2", "shatterblast3", "shatterblast4", "shatterblast5",
		"bloodhound1", "bloodhound2", "bloodhound3", "bloodhound4", "bloodhound5",
		"bleeder1", "bleeder2", "bleeder3", "bleeder4", "bleeder5",
		"tarpit1", "tarpit2", "tarpit3", "tarpit4", "tarpit5",
		"shockwave1", "shockwave2", "shockwave3", "shockwave4", "shockwave5",
		"gravitywell1", "gravitywell2", "gravitywell3", "gravitywell4", "gravitywell5",
		"icewall1", "icewall2", "icewall3", "icewall4", "icewall5",
		"reanimator1", "reanimator2", "reanimator3", "reanimator4", "reanimator5",
	};

	/// <summary>
	/// Every upgrade, mod by mod in the catalogue's order, I to V.
	///
	/// ⛔ A PROPERTY THAT BUILDS THE ARRAY, NOT A `static readonly` ONE: INSTRUCTIONS §1, the shape `AmmoMods.All` uses.
	///
	/// ⚠️ IV AND V (2026-10-06) SIT AFTER EACH MOD'S III, worded as the panel shows them: the replaced value in brackets, the doc's
	/// table the full wording.
	/// </summary>
	public static Up[] All => new[]
	{
		new Up( "fireworks", 1, "Longer Volley", "Fires until 50 of its shots hit (24)" ),
		new Up( "fireworks", 2, "Long Reach", "Targets zombies up to 350 u away (200)" ),
		new Up( "fireworks", 3, "Twin Fire", "Two copies rise, each with I and II" ),
		new Up( "fireworks", 4, "Black Powder", "Each copy's shot deals 200% of your damage (100%)" ),
		new Up( "fireworks", 5, "Headliner", "Every copy shot counts as a headshot" ),

		new Up( "blastfurnace", 1, "Wider Blast", "The 5 nearest take the blast (3)" ),
		new Up( "blastfurnace", 2, "Hotter Blast", "500% of your damage to each (300%)" ),
		new Up( "blastfurnace", 3, "Wildfire", "The 7 nearest take the blast" ),
		new Up( "blastfurnace", 4, "White Heat", "800% of your damage to each (500%)" ),
		new Up( "blastfurnace", 5, "Meltdown", "10% of hits blast every zombie within 600 u (3 s cooldown)" ),

		new Up( "deadwire", 1, "Longer Chain", "Jumps through 7 zombies (5)" ),
		new Up( "deadwire", 2, "High Voltage", "200% of your damage a zap (100%)" ),
		new Up( "deadwire", 3, "Stun Lock", "Stuns every zombie it hits for 1.5 s (not bosses)" ),
		new Up( "deadwire", 4, "Overcharge", "2.5 s cooldown (4.5 s)" ),
		new Up( "deadwire", 5, "Rising Current", "Each jump adds 100%: up to 800% on the 7th" ),

		new Up( "thunderwall", 1, "Bigger Storm", "Hits up to 30 zombies (20)" ),
		new Up( "thunderwall", 2, "Thunderclap", "200% of your damage to each (100%)" ),
		new Up( "thunderwall", 3, "Double Strike", "A second blast 0.5 s after the first" ),
		new Up( "thunderwall", 4, "Supercell", "400% of your damage to each (200%)" ),
		new Up( "thunderwall", 5, "Perfect Storm", "Reaches 1200 u (600) and hits every zombie in it" ),

		new Up( "cryofreeze", 1, "Wider Freeze", "Freezes up to 12 zombies (7)" ),
		new Up( "cryofreeze", 2, "Deep Freeze", "Frozen zombies take +60% damage (+30%)" ),
		new Up( "cryofreeze", 3, "Shatter", "Frozen under 25% health: next hit kills (not bosses)" ),
		new Up( "cryofreeze", 4, "Permafrost", "Shatter takes them under 40% health (25%)" ),
		new Up( "cryofreeze", 5, "Shatterstorm", "A Shatter bursts for 500% within 200 u; shards can shatter" ),

		new Up( "radiation", 1, "Wide Fallout", "The patch reaches 220 u (140)" ),
		new Up( "radiation", 2, "Short Half-Life", "8 s cooldown (12 s)" ),
		new Up( "radiation", 3, "Critical Mass", "250% of your damage a tick (125%)" ),
		new Up( "radiation", 4, "Hot Zone", "The patch lasts 8 s (4 s)" ),
		new Up( "radiation", 5, "Ground Zero", "440 u (220), 500% a tick (250%), no dose limit" ),

		new Up( "silkshot", 1, "Wider Web", "Webs the zombie and the 6 nearest (3)" ),
		new Up( "silkshot", 2, "Strong Silk", "Webs last 5 s (4 s)" ),
		new Up( "silkshot", 3, "Snared Prey", "Webbed zombies take +50% damage" ),
		new Up( "silkshot", 4, "Silk Storm", "Webs the 12 nearest within 400 u (6, 250 u)" ),
		new Up( "silkshot", 5, "Brood", "A webbed zombie that dies webs up to 3 within 140 u" ),

		new Up( "leech", 1, "Deeper Bite", "+10 health a kill (+5)" ),
		new Up( "leech", 2, "Engorged", "Overheal up to 100 over your max (50)" ),
		new Up( "leech", 3, "Siphon", "Every bullet that hits heals 1 health" ),
		new Up( "leech", 4, "Bloated", "Overheal up to 200 over your max (100)" ),
		new Up( "leech", 5, "Vampire", "Bullets heal 3 (1); overheal up to twice your max" ),

		// ⚠️ "1 MORE", NOT "11 (10)" (2026-10-05): a hit pays the map's `Gameplay.PointsHit`, 5 by default (`ZombieStats.PointsHit`)
		// and set per map, so any fixed figure here is wrong somewhere.
		new Up( "midas", 1, "Gilded Hits", "A paying hit pays 1 more point" ),
		new Up( "midas", 2, "Richer Kills", "Kills pay +100% points (+50%)" ),
		new Up( "midas", 3, "Golden Line", "A bullet pays for 8 zombies (4)" ),
		new Up( "midas", 4, "Solid Gold", "A paying hit pays 2 more points (1)" ),
		new Up( "midas", 5, "Gold Standard", "+100% damage per 1,000,000 points earned this game" ),

		new Up( "scrapper", 1, "Keen Eye", "Pays out on 25% of kills (15%)" ),
		new Up( "scrapper", 2, "Bigger Haul", "75 salvage a payout (50)" ),
		new Up( "scrapper", 3, "Scrap Drip", "Every paying hit also gives 1 salvage" ),
		new Up( "scrapper", 4, "Scrap Stream", "Every paying hit pays 2 salvage (1)" ),
		new Up( "scrapper", 5, "Jackpot", "1 payout in 10 pays ten times as much" ),

		new Up( "headhunter", 1, "Sharp Eye", "20% of headshot kills (10%)" ),
		new Up( "headhunter", 2, "Long Shot", "Reaches 300 u (200)" ),
		new Up( "headhunter", 3, "Overkill", "3x the killing shot to each zombie (2x)" ),
		new Up( "headhunter", 4, "Sharper Eye", "35% of headshot kills (20%)" ),
		new Up( "headhunter", 5, "Execution", "5x the killing shot (3x), reaching 600 u (300)" ),

		new Up( "shatterblast", 1, "Quick Fuse", "3 s cooldown (5 s)" ),
		new Up( "shatterblast", 2, "Big Bang", "Reaches 300 u (200)" ),
		new Up( "shatterblast", 3, "Triple Charge", "The body explodes 3 times in a row" ),
		new Up( "shatterblast", 4, "Hair Trigger", "20% of kills (10%)" ),
		new Up( "shatterblast", 5, "Unstable", "No cooldown (3 s)" ),

		new Up( "bloodhound", 1, "Quick Scent", "3 hits mark a zombie (5)" ),
		new Up( "bloodhound", 2, "Open Wound", "Marked zombies take 3x damage (2x)" ),
		new Up( "bloodhound", 3, "Pack Hunt", "The 2 zombies nearest it are marked too" ),
		new Up( "bloodhound", 4, "Mortal Wound", "Marked zombies take 4x damage (3x)" ),
		new Up( "bloodhound", 5, "Blood Trail", "A marked zombie's mark jumps on when it dies" ),

		new Up( "bleeder", 1, "Deep Cuts", "Up to 8 bleeds at once (5)" ),
		new Up( "bleeder", 2, "Fast Bleed", "A bleed deals its damage in 4 s (8 s)" ),
		new Up( "bleeder", 3, "Deep Bleed", "A bleed deals 200% of the hit (100%)" ),
		new Up( "bleeder", 4, "Lacerate", "Up to 12 bleeds at once (8)" ),
		new Up( "bleeder", 5, "Exsanguinate", "A bleed deals its damage in 1 s (4 s)" ),

		new Up( "tarpit", 1, "Wide Pool", "The pool reaches 220 u (140)" ),
		new Up( "tarpit", 2, "Long Spill", "It lasts 12 s (8 s)" ),
		new Up( "tarpit", 3, "Clinging Tar", "Still slowed 5 s after leaving it" ),
		new Up( "tarpit", 4, "Thick Tar", "Zombies in it move at 20% (40%)" ),
		new Up( "tarpit", 5, "Tar Flood", "Reaches 440 u (220) and lasts 24 s (12 s)" ),

		new Up( "shockwave", 1, "Wide Wave", "Reaches 350 u (250)" ),
		// ⚠️ II WAS "HARD FALL" UNTIL 2026-10-06, when Shockwave stopped tripping them: the same 1.2 s, now a stun.
		new Up( "shockwave", 2, "Dazed", "Stunned for 1.2 s (0.4 s)" ),
		new Up( "shockwave", 3, "Seismic Slam", "Stunned zombies take 300% of your damage" ),
		new Up( "shockwave", 4, "Quick Quake", "3 s cooldown (5 s)" ),
		new Up( "shockwave", 5, "Epicenter", "Each wave also goes off around you (350 u)" ),

		new Up( "gravitywell", 1, "Strong Pull", "Reaches 450 u (300)" ),
		new Up( "gravitywell", 2, "Event Horizon", "Holds the knot 4 s (2 s)" ),
		new Up( "gravitywell", 3, "Collapse", "Implodes for 500% of your damage" ),
		new Up( "gravitywell", 4, "Supernova", "Collapse deals 1000% (500%)" ),
		new Up( "gravitywell", 5, "Spaghettify", "Held zombies take 100% every 0.5 s" ),

		new Up( "icewall", 1, "Wide Ring", "The ring reaches 220 u (150)" ),
		new Up( "icewall", 2, "Long Freeze", "It lasts 8 s (5 s)" ),
		new Up( "icewall", 3, "Brittle Ice", "Zombies inside take double damage" ),
		new Up( "icewall", 4, "Cold Snap", "8 s cooldown (12 s)" ),
		new Up( "icewall", 5, "Absolute Zero", "Inside: 20% speed and triple damage (2x)" ),

		new Up( "reanimator", 1, "Long Lure", "The human lasts 10 s (6 s)" ),
		new Up( "reanimator", 2, "Quick Return", "20 s cooldown (30 s)" ),
		new Up( "reanimator", 3, "Last Stand", "The human explodes for 1000% of your damage" ),
		new Up( "reanimator", 4, "Live Bait", "The human hurts zombies within 200 u: 100% a second" ),
		new Up( "reanimator", 5, "Good Samaritan", "The human runs to downed players and revives them" ),
	};

	/// <summary>A mod's levels, I to V. Empty for an unknown id, or RANDOM's "".</summary>
	public static Up[] For( string modId )
		=> string.IsNullOrEmpty( modId )
			? Array.Empty<Up>()
			: All.Where( u => u.ModId == modId ).OrderBy( u => u.Level ).ToArray();

	/// <summary>One upgrade, or null.</summary>
	public static Up Find( string modId, int level ) => For( modId ).FirstOrDefault( u => u.Level == level );

	/// <summary>
	/// A player's level in a mod, 0 to <see cref="MaxLevel"/> (5). ⛔ THE READ EVERY EFFECT SHOULD USE.
	///
	/// ⚠️ IT ANSWERS ON EVERY MACHINE: from the owner's own store there, from the synced copy everywhere else
	/// (`NZPlayer.AmmoModLevel`). Levels are bought on the buyer's machine, and the host runs most mods' effects.
	/// </summary>
	public static int Level( NZPlayer player, string modId ) => player.IsValid() ? player.AmmoModLevel( modId ) : 0;

	/// <summary>Does the player have this mod at this level or above.</summary>
	public static bool Has( NZPlayer player, string modId, int level ) => Level( player, modId ) >= level;

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_ammomod_upgrades` — your level in every mod, the next upgrade and what it costs at the nearest Arsenal.
	/// </summary>
	[ConCmd( "nz_ammomod_upgrades" )]
	public static void Report()
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }

		var arsenal = Arsenal.Near( p.WorldPosition ) ?? Arsenal.All.FirstOrDefault( a => a.IsValid() );
		var all = All;

		// ⚠️ EVERY LEVEL'S PRICE, IV AND V INCLUDED (2026-10-06): read off the machine level by level, so a sixth would print too.
		Log.Info( $"[nz-ammo] upgrades ({all.Count( u => u.Built )} of {all.Length} wired)"
			+ (arsenal is null
				? " — no Arsenal on the map, so no prices"
				: " — prices here: " + string.Join( ", ", Enumerable.Range( 1, MaxLevel )
					.Select( l => $"{HudTheme.ToRoman( l )} {arsenal.AmmoUpgradePriceFor( l ):N0}" ) ) + " salvage") );

		foreach ( var mod in AmmoMods.All )
		{
			var have = Level( p, mod.Id );
			var next = Find( mod.Id, have + 1 );

			Log.Info( $"[nz-ammo]   {mod.Name,-18} {(have == 0 ? "-" : HudTheme.ToRoman( have )),-3}"
				+ (next is null
					? "  maxed"
					: $"  next {next.Numeral} {next.Name}: {next.Effect}{(next.Built ? "" : " [not built]")}") );
		}
	}

	/// <summary>
	/// `nz_ammomod_level &lt;id&gt; [0-5]` — set your level in a mod without paying, to see the panel's states. No level prints it.
	/// A level over <see cref="MaxLevel"/> is capped there, and says so.
	///
	/// ⚠️ THE ARSENAL MINUS THE SALVAGE, as `nz_ammomod_give` is the machine minus the machine. It writes through
	/// `NZPlayer.SetAmmoModLevel`, the setter the purchase uses, so the other machines hear it the same way.
	/// </summary>
	[ConCmd( "nz_ammomod_level" )]
	public static void LevelCmd( string id = "", int level = -1 )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }

		var mod = AmmoMods.Find( id );
		if ( mod is null )
		{
			Log.Warning( $"[nz-ammo] '{id}' is not a mod — {string.Join( ", ", AmmoMods.Ids )}" );
			return;
		}

		if ( level >= 0 ) p.SetAmmoModLevel( mod.Id, level );

		var now = Level( p, mod.Id );

		Log.Info( $"[nz-ammo] {mod.Name} at level {(now == 0 ? "0" : HudTheme.ToRoman( now ))}"
			+ (level > MaxLevel ? $" — {level} was capped at {MaxLevel}" : "") );
	}
}