Weapons/Thunderwall.cs

Static utility that implements the Thunderwall ammo mod. It computes damage and radius based on tunables and upgrade levels, finds nearby zombies, applies damage, plays a ground ring visual and sound, and supports a delayed second strike for level III plus console commands to report and set tunables.

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

namespace NZombies;

/// <summary>
/// Thunderwall — a wide shockwave centred on the zombie you hit.
///
/// | | value |
/// |---|---|
/// | proc | **5%** per hit, **1s** cooldown |
/// | radius | **600u** around the zombie that was shot |
/// | damage | **100% of your weapon's per-shot damage** — the whole shot — to every zombie inside |
/// | cap | **20** zombies |
/// | upgrades (2026-10-05) | I cap **30** · II **200%** · III a second blast **0.5s** later, same spot, same numbers |
/// | IV and V (2026-10-06) | IV **400%** · V **1200u** and **no cap**, for both of III's blasts |
///
/// ⚠️ IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"): IV Supercell, 400% to each (II's 200%); V Perfect Storm, twice
/// the reach and every zombie inside it. The user: *"for V make it so the radius is incresed to double, and no limit on zombies
/// hit"*.
///
/// ⚠️ THE 1-SECOND COOLDOWN MAKES THIS THE MOST FREQUENT MOD OF THE SIX, which its 5% chance hides.
/// Chance and cooldown only mean anything together: Fire Works at 10%/10s can fire once every ten
/// seconds at best, while this can fire every second. At 600 RPM a 5% chance comes up about twice a
/// second, so in practice the cooldown is the limit and Thunderwall procs roughly once a second of
/// sustained fire.
///
/// ⛔ IT IS NOT A CONE AND IT DOES NOT LAUNCH, WHICH IS THE PHASE 7 SHAPE AND NOT THE ORIGINAL'S.
/// Upstream used to fire a TFA-only cone along `wep:GetAimVector` — which silently did nothing on
/// ArcCW and ARC9, because neither has that method. The rebalance replaced it with a plain sphere
/// around the victim and dropped the knockback entirely. Porting the cone would have meant porting
/// a bug.
///
/// ⚠️ UPSTREAM'S OWN REGISTRY COMMENT IS WRONG ABOUT THIS ONE. `sh_register.lua` says the rebalance
/// made it "deals % max HP, no launch" — the no-launch half is right, but the effect file plainly
/// computes `perhit * 0.20`, a share of the held weapon's PER-SHOT DAMAGE. The code is the source of
/// truth and the comment beside it is not.
///
/// ⚠️ PER-SHOT DAMAGE, NOT DPS — AND NOW THE WHOLE SHOT. Upstream's is 20% of one shot; since
/// 2026-09-24 it is 100%, asked for as *"make thunderwall deal the full weapon damage per trigger"*:
/// every zombie in the blast takes what one trigger pull of your gun deals. Dead Wire moved from DPS
/// to per-shot the same day, so the two scale alike now; `nz_thunderwall_set dps 1` still switches
/// this one to DPS.
/// </summary>
public static class Thunderwall
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static float? _radius;
	/// <summary>How far the blast reaches. 600u.</summary>
	public static float Radius { get => _radius ?? 600f; set => _radius = value; }

	static float? _fraction;
	/// <summary>Share of the held weapon's per-shot damage each zombie takes. 1.0 — all of it.</summary>
	public static float Fraction { get => _fraction ?? 1f; set => _fraction = value; }

	static int? _cap;
	/// <summary>
	/// The most zombies one blast may hit. 20.
	///
	/// ⚠️ HIGH ENOUGH THAT IT RARELY BINDS, unlike Blast Furnace's three. Upstream's number, and it
	/// reads as a runaway guard rather than as part of the design — twenty zombies inside 600u is
	/// most of a horde.
	/// </summary>
	public static int Cap { get => _cap ?? 20; set => _cap = value; }

	static bool? _useDps;
	/// <summary>
	/// Scale off the weapon's DPS instead of its per-shot damage. Off.
	///
	/// ⚠️ THE SWITCH EXISTS BECAUSE DEAD WIRE WENT THE OTHER WAY. Keeping both readings one command
	/// apart is cheaper than arguing about which is right, and makes the difference testable rather
	/// than theoretical.
	/// </summary>
	public static bool UseDps { get => _useDps ?? false; set => _useDps = value; }

	static bool? _visual;
	/// <summary>
	/// Draw the ground wave. On.
	///
	/// ⚠️ THIS USED TO SAY "show the explosion" AND THERE IS NO LONGER AN EXPLOSION. The
	/// `BlastEffect` call was removed by request; this switch now governs the `ShockRing` wave and
	/// the sound, which is the whole of the mod's presentation.
	/// </summary>
	public static bool Visual { get => _visual ?? true; set => _visual = value; }

	// ══ upgrades (2026-10-05) ════════════════════════════════════════════════
	//
	// ⛔ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE ONE, AND ONE HELPER PICKS BETWEEN THEM (§3): every reader
	// asks `CapFor` / `FractionFor` / `StrikesTwice` with the level. `AMMO_MODS.md` "Upgrades"; the user: *"Love it, proceed"*.

	static int? _biggerStormCap;
	/// <summary>
	/// I BIGGER STORM — the most zombies one blast may hit from level I. 30 (`Cap` is 20).
	///
	/// ⚠️ THE CAP, NOT THE 5% CHANCE, because the 1 s cooldown is what limits it on a fast gun: it already fires about once a
	/// second of shooting (`AMMO_MODS.md`).
	/// </summary>
	public static int BiggerStormCap { get => _biggerStormCap ?? 30; set => _biggerStormCap = value; }

	static float? _thunderclapFraction;
	/// <summary>II THUNDERCLAP — the share of a shot each zombie takes from level II. 2.0 (`Fraction` is 1.0).</summary>
	public static float ThunderclapFraction { get => _thunderclapFraction ?? 2f; set => _thunderclapFraction = value; }

	static float? _doubleStrikeDelay;
	/// <summary>III DOUBLE STRIKE — seconds from the first blast to the second. 0.5.</summary>
	public static float DoubleStrikeDelay { get => _doubleStrikeDelay ?? 0.5f; set => _doubleStrikeDelay = value; }

	// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──

	static float? _supercellFraction;
	/// <summary>IV SUPERCELL — the share of a shot each zombie takes from level IV. 4.0 (II's `ThunderclapFraction`, 2.0).</summary>
	public static float SupercellFraction { get => _supercellFraction ?? 4f; set => _supercellFraction = value; }

	static float? _perfectStormRadius;
	/// <summary>
	/// V PERFECT STORM — how far the blast reaches at level V. 1200u (`Radius`, 600): *"the radius is incresed to double"*.
	///
	/// ⚠️ BOTH OF III'S BLASTS TAKE IT (`Fire` resolves the reach once and hands it to both), and the ground ring is drawn at it:
	/// `ShockRing.Fire` takes the real reach, so the wave grows with it.
	/// </summary>
	public static float PerfectStormRadius { get => _perfectStormRadius ?? 1200f; set => _perfectStormRadius = value; }

	/// <summary>
	/// V PERFECT STORM'S CAP: none — *"no limit on zombies hit"*. The largest count `Take` accepts, so a blast takes every zombie
	/// in reach. ⚠️ THE CROWD IS WHAT BOUNDS IT NOW: the concurrent cap, 50 by default (`ZombieStats.MaxAlive`, raised from 35;
	/// a map's config and the lobby's Difficulty can move it), so at the default about 20 more than I's 30.
	///
	/// ⚠️ A `const`, NOT A TUNABLE (§1): it is a rule, not a number to retune, and a const cannot go stale.
	/// </summary>
	public const int NoCap = int.MaxValue;

	/// <summary>The most zombies a blast hits at this level: `Cap`, `BiggerStormCap` from I, and none at V (`NoCap`).</summary>
	public static int CapFor( int level ) => level >= 5 ? NoCap : level >= 1 ? BiggerStormCap : Cap;

	/// <summary>The share of a shot each zombie takes at this level: `Fraction`, `ThunderclapFraction` from II, `SupercellFraction` from IV.</summary>
	public static float FractionFor( int level ) => level >= 4 ? SupercellFraction : level >= 2 ? ThunderclapFraction : Fraction;

	/// <summary>How far a blast reaches at this level: `Radius`, or `PerfectStormRadius` at V.</summary>
	public static float RadiusFor( int level ) => level >= 5 ? PerfectStormRadius : Radius;

	/// <summary>Does a proc at this level blast twice: III.</summary>
	public static bool StrikesTwice( int level ) => level >= 3;

	/// <summary>A cap as the logs write it: "cap 30", or "no cap" at V.</summary>
	static string CapText( int cap ) => cap >= NoCap ? "no cap" : $"cap {cap}";

	/// <summary>
	/// What one zombie takes.
	///
	/// ⚠️ READ THROUGH THE CHOKEPOINTS, not off the authored numbers. `DamageFor` and `GetRealRPM`
	/// are what every perk, augment and tech node folds into — so the blast scales with
	/// Pack-a-Punch, rarity, Double Tap and Vigor without knowing any of them exist.
	///
	/// ⛔ AND THE DPS PATH GOES THROUGH `FireAugments.WeaponDps` RATHER THAN REPEATING THE SUM.
	/// `GetRealRPM` returns an INTERVAL in seconds-per-shot, so DPS is damage divided by it —
	/// writing `damage * rpm / 60` against that value is wrong by a factor of RPM²/3600, which is a
	/// hundredfold at 600 RPM. One author for that arithmetic (§3).
	/// </summary>
	/// <param name="level">
	/// The player's Thunderwall level (2026-10-05): II's 200% comes in here, for all three readings, and IV's 400% (2026-10-06).
	/// </param>
	public static float DamageFor( NZPlayer player, int level = 0 )
	{
		var share = MathF.Max( 0f, FractionFor( level ) );

		if ( UseDps )
			return MathF.Max( 1f, FireAugments.WeaponDps( player ) * share );

		// ⚠️ `AmmoMods.WeaponDamage` — the one reading of "your weapon's damage" every mod shares,
		// × Bullets for shotguns as the stats card shows it.
		var perShot = AmmoMods.WeaponDamage( player );

		// ⚠️ THE SAME NO-WEAPON FALLBACK NAPALM USES, so an empty-handed proc does something
		// visible rather than silently nothing — which reads as the mod being broken.
		if ( perShot <= 0f )
			return MathF.Max( 1f, FireAugments.NoWeaponDps * share );

		return MathF.Max( 1f, perShot * share );
	}

	/// <summary>
	/// Blast everything around the zombie that was hit.
	///
	/// ⚠️ CENTRED ON THE VICTIM, NOT THE PLAYER. Upstream parents its effect to the shot zombie and
	/// reads `WorldSpaceCenter` — so a 600u sphere reaches 600u past whatever you shot, which at
	/// range is a very different volume from one centred on you.
	///
	/// ⚠️ INSTANT AND ONE-SHOT. There is no component and nothing to tick: it damages, draws, and
	/// is done. Upstream's entity removes itself on the same frame it spawns.
	///
	/// ⚠️ EXCEPT AT III (2026-10-05), WHICH STRIKES AGAIN HALF A SECOND LATER — still with no component: `StrikeAgain` waits
	/// on a task holding only the player, the spot and the numbers.
	/// </summary>
	public static void Fire( NZPlayer player, GameObject zombie )
	{
		if ( !player.IsValid() || !zombie.IsValid() ) return;

		// ⚠️ THE UPGRADES ARE READ HERE (2026-10-05), ON THE SHOOTER'S MACHINE, FOR THE SHOOTER — where this runs, and where
		// `AmmoMods.WeaponDamage` is valid. `AmmoMods.Fire` is the only caller (the proc and `FireExternal`), so nothing else
		// inherits them; a system that reuses this blast for an effect of its own must pass a level, not read the player's.
		var level = AmmoModUpgrades.Level( player, "thunderwall" );

		var at = zombie.WorldPosition + Vector3.Up * 32f;
		var damage = DamageFor( player, level );

		// ⚠️ V PERFECT STORM (2026-10-06): the reach and the cap by level, resolved ONCE here, so both of III's blasts and the
		// ground ring take V's 1200u and no cap. IV's 400% is in `damage` (`FractionFor`).
		var reach = MathF.Max( 0f, RadiusFor( level ) );
		var cap = CapFor( level );

		Strike( player, at, reach, damage, cap, level, first: true );

		// ⚠️ III DOUBLE STRIKE: *"it strikes twice, a second blast 0.5 s after the first"* — at the same spot, on whatever
		// stands there by then, with the same cap and the damage SNAPSHOTTED ABOVE. `AmmoMods.WeaponDamage` reads the gun in
		// hand, so a weapon swap in between must not change the second blast; it is dealt from this machine, as the first.
		if ( StrikesTwice( level ) )
			StrikeAgain( player, at, reach, damage, cap, level );
	}

	/// <summary>
	/// One blast: the wave and the sound, then the damage to the nearest <paramref name="cap"/> zombies within
	/// <paramref name="reach"/> of <paramref name="at"/>. Both of III's blasts are this.
	/// </summary>
	static void Strike( NZPlayer player, Vector3 at, float reach, float damage, int cap, int level, bool first )
	{
		// ⚠️ THE VISUAL FIRST, so a damage handler that removes a body cannot cancel the
		// wave — the same ordering Dead Wire and Blast Furnace both use.
		if ( Visual )
		{
			// ⛔ NO EXPLOSION. `BlastEffect.Spawn( at, reach )` was called here and is removed by
			// request: Thunderwall is a wave, not a detonation, and a grenade-sized fireball read as
			// one. The class stays — Grenade, PhD Flopper and Napalm Nectar all still use it.
			//
			// ⚠️ AND ITS WARNING IS WORTH KEEPING EVEN THOUGH THIS FILE NO LONGER CALLS IT.
			// `BlastEffect` is not optional polish for its remaining callers: the only explosion
			// prefab in the asset system ships a `RadiusDamage` component with `DamageOnEnabled`
			// set, so cloning it directly deals a flat 100 damage in 256u and shoves the player.
			// `BlastEffect` clones it disabled, strips that, then enables — which is why nobody
			// should reach for the prefab straight.
			//
			// ⛔ THE GROUND RING IS THE ONLY LAYER OF UPSTREAM'S ORIGINAL VISUAL THAT PORTS
			// CHEAPLY, and it is the one that carries the read. `perks_aat_thunderwall.pcf` holds
			// four children — wave, energy, warp and dust. This is the wave: a `LineRenderer`
			// with an animated radius, needing no texture and no shader.
			//
			// ⛔ AND IT IS NOW THE WHOLE VISUAL, WHICH PROMOTES IT FROM SUPPORTING TO LOAD-BEARING.
			// It is the only thing that makes the 600u radius legible — drawn AT the real radius, so
			// what you see is what was hit. Turning `Visual` off now leaves the mod with nothing but
			// its sound, where before it still had a blast.
			//
			// ⚠️ AT V THAT IS 1200u (2026-10-06, Perfect Storm), and the ring grows with it. Same cost — `ShockRing.Segments` floor
			// traces a frame, whatever the radius — but each chord is twice as long (about 235u at the default 32 segments), so
			// judge its roundness in play.
			ShockRing.Fire( at, reach );

			// ⚠ THE ONE SOUND UPSTREAM STILL PLAYS. Its effect file emits this and nothing else —
			// no particles at all after Phase 7 — so this cue is the whole of what the live GMod
			// version presents for Thunderwall.
			Sound.Play( NZSound.PopThunderwallShoot, at );
		}

		var hit = ZombieAI.All
			.Where( z => z.IsValid() && z.GameObject.IsValid() )
			.Where( z => at.Distance( z.WorldPosition ) <= reach )
			.Select( z => new
			{
				Ai = z,
				Hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors ),
				Dist = at.Distance( z.WorldPosition ),
			} )
			.Where( x => x.Hp.IsValid() && !x.Hp.IsDead )
			.OrderBy( x => x.Dist )
			.Take( Math.Max( 1, cap ) )
			.ToList();

		// ⚠️ NEAREST-FIRST BEFORE THE CAP, so when twenty is not enough the twenty that get hit are
		// the closest. Upstream takes them in whatever order `FindInSphere` returned, which is
		// arbitrary — a zombie on your face surviving while one across the room dies.
		//
		// ⚠️ NO FALLOFF, DELIBERATELY. Upstream applies the same figure at every distance, and a
		// 600u sphere with falloff would be a different effect. The flat number is what makes this
		// a wall rather than a grenade.
		foreach ( var x in hit )
			x.Hp.OnDamage( new DamageInfo
			{
				Damage = damage,
				Attacker = player.GameObject,
				Position = x.Ai.WorldPosition + Vector3.Up * 32f,
				Tags = new TagSet(),
			} );

		Log.Info( $"[nz-ammo] THUNDERWALL{(level > 0 ? $" {HudTheme.ToRoman( level )}" : "")}{(first ? "" : ", second strike")}"
			+ $" — {damage:0} to {hit.Count} zombie(s)"
			+ $" within {reach:0}u ({CapText( cap )})"
			+ $" · {(UseDps ? "dps" : "per-shot")} × {FractionFor( level ):0.##}" );
	}

	/// <summary>
	/// III's second blast, <see cref="DoubleStrikeDelay"/> after the first.
	///
	/// ⚠️ `GameTask.Delay`, THE PROJECT'S DELAY IDIOM, RE-VALIDATED AFTER THE WAIT as `PhdAugments.ChainRest` is: half a second
	/// is long enough to leave the game. The zombie that was shot is not needed — it may be dead by now; the spot is.
	/// </summary>
	static async void StrikeAgain( NZPlayer player, Vector3 at, float reach, float damage, int cap, int level )
	{
		await GameTask.Delay( (int)(MathF.Max( 0.02f, DoubleStrikeDelay ) * 1000f) );

		if ( !player.IsValid() ) return;

		// ⛔ THE SECOND BLAST CAN SET A MOD OFF WHERE THE FIRST CANNOT: half a second on, a cut cooldown has run out (Catalyst's
		// ×0.5 alone makes it 0.5 s), and thirty hits at 10-20% each would all but surely proc the next Thunderwall, whose own
		// second blast would proc the one after — a storm without a shot fired. So its hits roll nothing (`AmmoMods.WithoutProcs`,
		// shared since 2026-10-05 with the other upgrades that deal damage late).
		AmmoMods.WithoutProcs( player, () => Strike( player, at, reach, damage, cap, level, first: false ) );
	}

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

	/// <summary>`nz_thunderwall` — the resolved numbers, and what a blast would deal right now.</summary>
	[ConCmd( "nz_thunderwall" )]
	public static void Report()
	{
		Log.Info( $"[nz-ammo] THUNDERWALL · 5% per hit, 1s cooldown"
			+ $" · {Radius:0}u · cap {Cap}"
			+ $" · {Fraction * 100f:0.#}% of {(UseDps ? "weapon DPS" : "per-shot damage")}"
			+ $" · wave {(Visual ? "on" : "off")}" );

		// ⚠️ THE UPGRADES (2026-10-05), AND IV AND V (2026-10-06).
		Log.Info( $"[nz-ammo]   upgrades · I cap {BiggerStormCap} · II {ThunderclapFraction * 100f:0.#}%"
			+ $" · III a second blast {DoubleStrikeDelay:0.##}s later, same spot, same numbers"
			+ $" · IV {SupercellFraction * 100f:0.#}% · V {PerfectStormRadius:0}u, no cap" );

		var me = NZPlayer.Local;

		if ( !me.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }

		// ⚠️ THE WORKED NUMBER, because "a share of per-shot damage" is not something anyone can feel —
		// and because the two scaling modes give very different answers on the same gun.
		//
		// ⚠️ AT YOUR LEVEL (2026-10-05), which is what your own Thunderwall deals.
		var level = AmmoModUpgrades.Level( me, "thunderwall" );
		var perShot = DamageFor( me, level );

		// ⚠️ AT YOUR REACH (2026-10-06): V's 1200u when you own it.
		var reach = RadiusFor( level );

		var inRange = ZombieAI.All.Count( z => z.IsValid()
			&& me.WorldPosition.Distance( z.WorldPosition ) <= reach );

		Log.Info( $"[nz-ammo]   held weapon → {perShot:0} per zombie"
			+ (level > 0 ? $" at {HudTheme.ToRoman( level )} ({CapText( CapFor( level ) )}{(StrikesTwice( level ) ? ", twice" : "")})" : "")
			+ $" · {inRange} zombie(s) within {reach:0}u of YOU"
			+ $" (a blast centres on the VICTIM, so this is only an estimate)" );
	}

	/// <summary>`nz_thunderwall_set &lt;key&gt; &lt;value&gt;` — retune one number live.</summary>
	[ConCmd( "nz_thunderwall_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "radius": Radius = value; break;
			case "fraction": Fraction = value; break;
			case "cap": Cap = (int)value; break;
			case "dps": UseDps = value > 0.5f; break;
			case "visual": Visual = value > 0.5f; break;

			// ⚠️ THE UPGRADES' OWN NUMBERS (2026-10-05): I, II and III.
			case "storm": BiggerStormCap = (int)value; break;
			case "clap": ThunderclapFraction = value; break;
			case "strike": DoubleStrikeDelay = value; break;

			// ⚠️ IV'S AND V'S NUMBERS (2026-10-06). V's "no cap" is a rule (`NoCap`), not a key.
			case "supercell": SupercellFraction = value; break;
			case "perfect": PerfectStormRadius = value; break;

			default:
				Log.Info( "[nz-ammo] nz_thunderwall_set <radius|fraction|cap|dps|visual|storm|clap|strike|supercell|perfect> <value>" );
				return;
		}

		Log.Info( $"[nz-ammo] thunderwall {key} = {value:0.###}" );
		Report();
	}
}