Player/FireAugments.cs

Perk and augment logic for a Napalm Nectar fire perk and its augments. Defines ignite chance/cooldown, spreading (Wildfire/Wildspread), pit spawning and ticking, area/chain damage, diagnostics and console commands, plus a NapalmPit component that applies periodic damage.

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

namespace NZombies;

/// <summary>
/// Napalm Nectar — the base ignite AND its augments.
///
/// | | effect |
/// |---|---|
/// | base | **1-in-6** hits ignite, then a **6-hit cooldown**. Burning: **5s**, takes **×2** damage |
/// | M1 **Wildfire** | an ignite also lights the **3 nearest** zombies within 300u |
/// | M2 **Demolitionist** | **×3** area damage |
/// | M3 **Scorched Earth** | shots have a **1%** chance to leave a **burning pit** |
/// | M4 **Chain Reaction** | kills have a **10%** chance to explode — and that explosion's kills can chain |
/// | m1 **Accelerant** | burning zombies take **×2.5** instead of ×2 |
/// | m2 **Incendiary Rounds** | ignite **1-in-4** instead of 1-in-6, and the cooldown **3 hits** instead of 6 |
/// | m3 **Hot Blade** | knifing a zombie ignites it |
/// | m4 **Powder Keg** | every AoE radius **+20%** |
/// | m5 **Wildspread** | an ignite has a **10%** chance to light **5** nearby instead of 1 |
///
/// ⛔ THE BASE PERK WAS REBUILT, NOT JUST RETUNED. It used to be two unrelated mechanics: a
/// 1-in-6 roll for DOUBLE DAMAGE (no fire at all), and a separate per-zombie counter of 5 hits
/// followed by a 1-in-3 roll to ignite. The counter froze while the target burned, so the real
/// cost was ~7 hits on one zombie and spraying a crowd built five separate counters and lit
/// nothing. It is now one mechanic: **1-in-6 to ignite, then a 6-hit cooldown**.
///
/// ⚠️ THE COOLDOWN IS COUNTED IN HITS, NOT SHOTS, and the difference is real: a miss never
/// reaches `Health.OnDamage`, so it cannot tick the cooldown down. Requested as "6 shot
/// cooldown"; hits are what the damage path can actually see, and counting shots would need a
/// hook in the weapon rather than the victim.
///
/// ⚠️ THE COOLDOWN IS PER PLAYER, NOT PER ZOMBIE. "In between each ignition" reads as a limit on
/// how often YOU can set something alight, which is also what stops a crowd from all igniting
/// at once. The old design was per zombie and that is exactly why it felt dead.
///
/// ⚠️ THE BURN STILL DOES ITS OWN TICK DAMAGE (8 per 0.5s, from the `burn` rule). Not mentioned
/// in the request, and removing it would be a silent nerf to a number nobody asked to change.
/// </summary>
public static class FireAugments
{
	const string Perk = "fire";

	/// <summary>The name every napalm pit GameObject carries.</summary>
	public const string PitName = "nz_napalm_pit";

	// ══ tuning ════════════════════════════════════════════════════════════════

	static float? _wildfireRadius;
	/// <summary>M1 Wildfire — the "medium radius" it spreads within. 300u.</summary>
	public static float WildfireRadius { get => _wildfireRadius ?? 300f; set => _wildfireRadius = value; }

	static int? _wildfireCount;
	/// <summary>M1 Wildfire — how many extra zombies catch. 3.</summary>
	public static int WildfireCount { get => _wildfireCount ?? 3; set => _wildfireCount = value; }

	static float? _areaDamage;
	/// <summary>M2 Demolitionist — multiplier on all area damage. ×3.</summary>
	public static float AreaDamage { get => _areaDamage ?? 3f; set => _areaDamage = value; }

	static float? _pitChance;
	/// <summary>M3 Scorched Earth — chance per hit to leave a pit. 1%.</summary>
	public static float PitChance { get => _pitChance ?? 0.01f; set => _pitChance = value; }

	static float? _pitRadius;
	/// <summary>M3 Scorched Earth — the pit's radius. 200u.</summary>
	public static float PitRadius { get => _pitRadius ?? 200f; set => _pitRadius = value; }

	static float? _pitSeconds;
	/// <summary>M3 Scorched Earth — how long a pit burns. 8s.</summary>
	public static float PitSeconds { get => _pitSeconds ?? 8f; set => _pitSeconds = value; }

	static float? _pitTickInterval;
	/// <summary>M3 Scorched Earth — how often a pit damages what is inside it. 0.5s.</summary>
	public static float PitTickInterval { get => _pitTickInterval ?? 0.5f; set => _pitTickInterval = value; }

	static float? _pitDpsPerTick;
	/// <summary>
	/// M3 Scorched Earth — how many SECONDS of your weapon's DPS each tick deals. 1.0.
	///
	/// ⛔ THE REQUEST IS AMBIGUOUS BY A FACTOR OF TWO AND THIS IS THE KNOB THAT SETTLES IT.
	/// "Dealing the calculated DPS of your held weapon every half a second" reads literally as one
	/// full second of DPS per half-second tick — so the pit's throughput is TWICE your gun's DPS.
	/// The other reading, "the pit does as much damage per second as your gun", is 0.5 here.
	///
	/// Built literally, because that is what the words say. `nz_fire_set pitDps 0.5` is the other
	/// reading, and it is one command.
	/// </summary>
	public static float PitDpsPerTick { get => _pitDpsPerTick ?? 1f; set => _pitDpsPerTick = value; }

	static float? _noWeaponDps;
	/// <summary>
	/// M3 Scorched Earth — DPS assumed when nothing is held. 50.
	///
	/// ⚠ NOT ZERO. An empty-handed player is reachable — mid-swap, a fresh Creative spawn — and a
	/// pit that silently does nothing reads as the augment being broken rather than as an edge
	/// case. Same reasoning as PhD's `NoWeaponDamage`.
	/// </summary>
	public static float NoWeaponDps { get => _noWeaponDps ?? 50f; set => _noWeaponDps = value; }

	static float? _chainChance;
	/// <summary>M4 Chain Reaction — chance a kill explodes. 10%, and it applies to chained kills too.</summary>
	public static float ChainChance { get => _chainChance ?? 0.10f; set => _chainChance = value; }

	static int? _chainDepth;
	/// <summary>
	/// M4 Chain Reaction — how many links deep a chain may run. 8.
	///
	/// ⛔ A HARD STOP, NOT A BALANCE KNOB. Each explosion can kill zombies whose deaths roll for
	/// another explosion, so the recursion is genuinely unbounded — a dense horde with a lucky
	/// streak would recurse until the stack gave out. 10% makes eight links vanishingly rare and
	/// the cap costs nothing in practice.
	/// </summary>
	public static int ChainDepth { get => _chainDepth ?? 8; set => _chainDepth = value; }

	static float? _burnVulnerability;
	/// <summary>
	/// m1 Accelerant — what a burning zombie's damage multiplier BECOMES. 2.5.
	///
	/// ⚠️ AN ABSOLUTE, NOT A BONUS, so the number here is the number in the augment text. The
	/// delta against the `burn` rule's own 2 is computed at the read site, which means retuning
	/// the rule cannot silently change what this augment is worth.
	/// </summary>
	public static float BurnVulnerability { get => _burnVulnerability ?? 2.5f; set => _burnVulnerability = value; }

	static int? _igniteOneIn;
	/// <summary>m2 Incendiary Rounds — the ignite roll BECOMES 1-in-this. 4.</summary>
	public static int IgniteOneIn { get => _igniteOneIn ?? 4; set => _igniteOneIn = value; }

	static int? _igniteCooldownHits;
	/// <summary>
	/// m2 Incendiary Rounds — the cooldown BECOMES this many hits. 3.
	///
	/// ⚠ m2 MOVES BOTH HALVES OF THE BASE ROLL, not just the chance. Halving the cooldown is the
	/// bigger effect of the two: at 1-in-4 with a 6-hit wait the cooldown is what limits you, so
	/// improving only the chance would have been most of a wasted augment.
	/// </summary>
	public static int IgniteCooldownHits { get => _igniteCooldownHits ?? 3; set => _igniteCooldownHits = value; }

	static float? _areaRadius;
	/// <summary>m4 Powder Keg — multiplier on every AoE radius. +20%.</summary>
	public static float AreaRadius { get => _areaRadius ?? 1.2f; set => _areaRadius = value; }

	static float? _spreadChance;
	/// <summary>m5 Wildspread — chance an ignite lights a crowd instead of one. 10%.</summary>
	public static float SpreadChance { get => _spreadChance ?? 0.10f; set => _spreadChance = value; }

	static int? _spreadCount;
	/// <summary>m5 Wildspread — how many catch when it does. 5.</summary>
	public static int SpreadCount { get => _spreadCount ?? 5; set => _spreadCount = value; }

	static float? _spreadRadius;
	/// <summary>m5 Wildspread — how far it reaches. 400u.</summary>
	public static float SpreadRadius { get => _spreadRadius ?? 400f; set => _spreadRadius = value; }

	static bool Has( NZPlayer p, string augId )
		=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );

	static NZPlayer PlayerOf( GameObject go )
		=> go.IsValid()
			? go.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;

	// ══ the base ignite ═══════════════════════════════════════════════════════

	/// <summary>
	/// Hits until this player can ignite again. 6, or 3 with m2.
	///
	/// ⚠ READ WHEREVER THE COOLDOWN IS SET, never cached, so buying m2 shortens the NEXT wait
	/// rather than only the one after that.
	/// </summary>
	public static int CooldownFor( NZPlayer player )
		=> Has( player, "m2" )
			? Math.Max( 0, IgniteCooldownHits )
			: Math.Max( 0, PerkEffects.IgniteCooldownHits );

	/// <summary>The ignite roll denominator. 1-in-6, or 1-in-4 with m2.</summary>
	public static int ChanceFor( NZPlayer player )
		=> Has( player, "m2" )
			? Math.Max( 1, IgniteOneIn )
			: Math.Max( 1, PerkEffects.IgniteOneIn );

	/// <summary>
	/// A non-melee hit landed on something. THE base perk.
	///
	/// ⛔ THE COOLDOWN IS SPENT BEFORE THE ROLL, so a hit during the cooldown ticks it down and
	/// does not roll. Rolling first and discarding would burn 1-in-6 chances the player cannot
	/// see, making the real rate quietly worse than the number advertised.
	///
	/// ⚠️ ALREADY-BURNING TARGETS ARE SKIPPED ENTIRELY — no roll, no cooldown tick. Re-igniting
	/// something that is alight would spend the cooldown for no effect, and refreshing the burn
	/// was never part of the design.
	/// </summary>
	public static void TryIgnite( GameObject attacker, GameObject victim )
	{
		var p = PlayerOf( attacker );
		if ( !p.IsValid() || !victim.IsValid() ) return;

		if ( StatusEffects.Has( victim, "burn" ) ) return;

		if ( victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) is not { } ai
			|| !ai.IsValid() ) return;

		if ( p.NapalmCooldown > 0 )
		{
			p.NapalmCooldown--;
			return;
		}

		if ( Game.Random.Int( 1, ChanceFor( p ) ) != 1 ) return;

		Ignite( p, victim, ai.WorldPosition );
	}

	/// <summary>
	/// Light a zombie, spend the cooldown, and run M1 and m5's spread.
	///
	/// ⚠️ THE COOLDOWN IS SET HERE, so every path that ignites pays it — including m3's knife.
	/// Setting it at the call sites would mean the knife could ignite for free.
	///
	/// ⚠️ THE SPREAD DOES NOT SPREAD AGAIN. Lighting the neighbours calls `StatusEffects.Apply`
	/// directly rather than recursing through here, or one ignite would chain across the whole
	/// map and the cooldown would never bind.
	/// </summary>
	public static void Ignite( NZPlayer player, GameObject victim, Vector3 at )
	{
		if ( !player.IsValid() || !victim.IsValid() ) return;

		StatusEffects.Apply( victim, "burn", player.GameObject );
		player.NapalmCooldown = CooldownFor( player );
		player.FlashNapalm();

		// ── m5 Wildspread: a chance at a crowd, checked first because it is the bigger effect ──
		var spread = Has( player, "m5" )
			&& Game.Random.Float() < MathF.Max( 0f, SpreadChance );

		var count = spread ? Math.Max( 1, SpreadCount ) : 0;
		var radius = spread ? SpreadRadius : 0f;

		// ── M1 Wildfire: always the 3 nearest, and it takes the wider of the two ──
		if ( Has( player, "M1" ) && WildfireCount > count )
		{
			count = Math.Max( 1, WildfireCount );
			radius = MathF.Max( radius, WildfireRadius );
		}
		else if ( Has( player, "M1" ) )
		{
			radius = MathF.Max( radius, WildfireRadius );
		}

		if ( count <= 0 ) return;

		var lit = SpreadTo( player, victim, at, count, radius );

		if ( lit > 0 )
			Log.Info( $"[nz-aug] fire spread — lit {lit} more within {radius:0}u"
				+ (spread ? " (m5 Wildspread)" : " (M1 Wildfire)") );
	}

	/// <summary>
	/// Light the nearest <paramref name="count"/> zombies, excluding the one already alight.
	///
	/// ⚠️ NEAREST FIRST, not "any within radius". "The 3 nearest ones" is an ordering, and taking
	/// an arbitrary three from a crowd of twenty would look random rather than like fire
	/// spreading outward from what you shot.
	/// </summary>
	static int SpreadTo( NZPlayer player, GameObject already, Vector3 at, int count, float radius )
	{
		var lit = 0;

		foreach ( var z in ZombieAI.All
			.Where( z => z.IsValid() && z.GameObject.IsValid() )
			.Where( z => z.GameObject != already )
			.Where( z => !StatusEffects.Has( z.GameObject, "burn" ) )
			.Where( z => at.Distance( z.WorldPosition ) <= radius )
			.OrderBy( z => at.Distance( z.WorldPosition ) )
			.Take( count ) )
		{
			StatusEffects.Apply( z.GameObject, "burn", player.GameObject );
			lit++;
		}

		return lit;
	}

	/// <summary>
	/// m3 Hot Blade — a knife hit ignites.
	///
	/// ⚠️ IT GOES THROUGH `Ignite`, so it pays the same cooldown a bullet does. A free ignite on
	/// a weapon with no ammo cost would make the whole roll irrelevant.
	/// </summary>
	public static void OnKnifeHit( NZPlayer player, GameObject victim, Vector3 at )
	{
		if ( !Has( player, "m3" ) ) return;
		if ( !victim.IsValid() || StatusEffects.Has( victim, "burn" ) ) return;
		if ( player.NapalmCooldown > 0 ) return;

		Ignite( player, victim, at );
	}

	// ══ m1 — the burn's own multiplier ════════════════════════════════════════

	/// <summary>
	/// Extra factor on a BURNING target's damage, on top of the `burn` rule's own vulnerability.
	///
	/// ⛔ A DELTA AGAINST THE RULE, not a replacement. `StatusEffects.VulnerabilityOf` has already
	/// applied the rule's 2 by the time this is read, so returning 2.5 would give ×5. This returns
	/// 2.5/2 = 1.25 — computed from the rule rather than hardcoded, so retuning the rule cannot
	/// silently change what the augment is worth.
	///
	/// ⚠️ READ OFF THE ATTACKER, applied to the VICTIM's burn. The augment belongs to the shooter;
	/// the fire belongs to the zombie.
	/// </summary>
	public static float BurnDamageBonus( GameObject attacker, GameObject victim )
	{
		var p = PlayerOf( attacker );
		if ( !Has( p, "m1" ) ) return 1f;
		if ( !victim.IsValid() || !StatusEffects.Has( victim, "burn" ) ) return 1f;

		var ruleBase = StatusEffects.Rules.TryGetValue( "burn", out var rule ) && rule.Vulnerability > 0f
			? rule.Vulnerability
			: 2f;

		return MathF.Max( 0f, BurnVulnerability ) / ruleBase;
	}

	// ══ M2 / m4 — area damage and radius ══════════════════════════════════════

	/// <summary>
	/// Multiplier on AREA damage — M2's ×3.
	///
	/// ⛔ "AREA DAMAGE" IS A LIST OF CALL SITES, NOT A DAMAGE TYPE. This project has no explosive
	/// tag; `Health.IsMelee` checks `"melee"`/`"knife"` and that is the only damage typing there
	/// is. So M2 and m4 are maintained by the six sites that scale themselves:
	///
	/// | site | radius |
	/// |------|--------|
	/// | `Grenade` | 220u |
	/// | `PhdAugments` blasts | 260u |
	/// | `DeadshotAugments` Cranial Detonation | 260u |
	/// | `VigorAugments` Last Round | 160u |
	/// | `PerkEffects` Elemental Pop burst | 150u |
	/// | `FireAugments` pits and chain explosions | 200u |
	///
	/// ⚠️ `TechBlast.Radius` IS A `const` AND IS NOT COVERED. Scaling it would mean turning that
	/// into a read-through, which changes a weapon-tech node rather than a perk — recorded here
	/// instead of done quietly.
	///
	/// ⚠️ DELIBERATELY EXCLUDES NON-DAMAGE RADII: Widow's webs, Tortoise's ring and stun,
	/// Timeslip's aura and pit, Vulture's gas. A fire perk widening a web would be a surprise.
	/// </summary>
	public static float AreaDamageScale( GameObject attacker )
	{
		var p = PlayerOf( attacker );

		return Has( p, "M2" ) ? MathF.Max( 0f, AreaDamage ) : 1f;
	}

	/// <summary>Multiplier on every AoE radius — m4's +20%. Same call-site register as above.</summary>
	public static float AreaRadiusScale( GameObject attacker )
	{
		var p = PlayerOf( attacker );

		return Has( p, "m4" ) ? MathF.Max( 0.1f, AreaRadius ) : 1f;
	}

	// ══ M3 — the pit ══════════════════════════════════════════════════════════

	static float _pitStamp = -1f;
	static (Vector3 At, NZPlayer Owner)[] _pitCache = Array.Empty<(Vector3, NZPlayer)>();

	/// <summary>Live pits, resolved once per frame. Same cache shape as Timeslip's.</summary>
	static (Vector3 At, NZPlayer Owner)[] Pits()
	{
		if ( _pitStamp == Time.Now ) return _pitCache;
		_pitStamp = Time.Now;

		var scene = Game.ActiveScene;

		_pitCache = scene.IsValid()
			? scene.Directory.FindByName( PitName )
				.Where( g => g.IsValid() )
				.Select( g => (g.WorldPosition,
					g.Components.Get<NapalmPit>()?.Owner) )
				.ToArray()
			: Array.Empty<(Vector3, NZPlayer)>();

		return _pitCache;
	}

	/// <summary>
	/// M3 Scorched Earth — roll for a pit where a shot landed.
	///
	/// ⚠️ NOT PORTED FROM ANYTHING. Asked to check "Explosive Everclear's napalm pit" — that perk
	/// does not exist in this project (Napalm Nectar is the only fire perk of the eighteen) and
	/// the GMod lua is not in this repo. Built on Timeslip's pit primitive instead, and NOT
	/// claimed to match any original.
	/// </summary>
	public static void TryPit( GameObject attacker, Vector3 at )
	{
		var p = PlayerOf( attacker );
		if ( !Has( p, "M3" ) ) return;
		if ( Game.Random.Float() > MathF.Max( 0f, PitChance ) ) return;

		SpawnPitShared( p, at );
	}

	/// <summary>
	/// Drop a pit on EVERY machine, and tell them whose it is.
	///
	/// ⛔ A PIT IS `NetworkMode.Never`, SO NOBODY HAS EVER SEEN ANYBODY ELSE'S — INCLUDING THE
	/// HOST'S. It is created with `scene.CreateObject()` and never announced, so a burning ring
	/// eight seconds long existed on exactly one screen. That was true before any of this session's
	/// changes: a host's pit was already invisible to every client.
	///
	/// ⚠️ EVERY MACHINE BUILDS ITS OWN, rather than one machine networking an object. The pit is
	/// a position, a radius and a clock — three numbers — and `PitVisual` builds the same ring from
	/// them anywhere. Replicating the object would mean a networked prefab for something that lives
	/// eight seconds.
	///
	/// ⚠️ AND ONLY THE OWNER'S COPY DAMAGES — see `NapalmPit.OnUpdate`. Every copy ticking would
	/// hurt each zombie once per machine.
	/// </summary>
	public static void SpawnPitShared( NZPlayer owner, Vector3 at )
	{
		SpawnPit( owner, at );

		if ( !Networking.IsActive || Connection.Local is null ) return;

		NZNet.NapalmPitDropped( Connection.Local.Id.ToString(),
			NZPlayers.OwnerOf( owner.IsValid() ? owner.GameObject : null ), at );
	}

	/// <summary>Build a pit that arrived from another machine, owned by whoever made it.</summary>
	public static void SpawnPitFromNetwork( string ownerId, Vector3 at )
	{
		var owner = PlayerSpawner.AllBodies()
			.Select( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
			.FirstOrDefault( p => p.IsValid() && NZPlayers.OwnerOf( p.GameObject ) == ownerId );

		SpawnPit( owner, at );
	}

	/// <summary>
	/// Drop a burning pit.
	///
	/// ⚠️ THE PIT IGNITES RATHER THAN DAMAGING. Fire is already a damage-over-time with a
	/// vulnerability rider, so applying `burn` gives the pit its damage, its tint, its light and
	/// its expiry for free — and m1's multiplier applies to it without knowing pits exist.
	///
	/// ⚠️ THE PLACEHOLDER CUBE IS GONE. It was a flattened `models/dev/box.vmdl` scaled to the
	/// radius; the look now comes from `PitVisual` in its `Fire` style, whose light is a straight
	/// port of the `DynamicLight` all three of GMod's fire-pit entities create.
	/// </summary>
	public static GameObject SpawnPit( NZPlayer owner, Vector3 at )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var radius = PitRadius * AreaRadiusScale( owner?.GameObject );

		var go = scene.CreateObject();
		go.Name = PitName;
		go.WorldPosition = at;
		go.NetworkMode = NetworkMode.Never;

		// ⛔ THE VISUAL BEFORE THE PIT COMPONENT, BECAUSE `Attach` MOVES THE OBJECT TO THE FLOOR and
		// `NapalmPit` ignites a sphere around `WorldPosition`. Attaching after would burn a volume
		// centred where the ring is not — the same ordering `RadioactiveDecay` documents at its own
		// call site, and the reason that ordering is worth a comment in both places.
		//
		// ⚠️ THE VISUAL IS GIVEN THE SCALED RADIUS, not `PitRadius`, so a player carrying an
		// area-size augment sees the ring they actually burn inside.
		PitVisual.Attach( go, radius, PitVisual.Style.Fire, PitSeconds );

		var pit = go.Components.Create<NapalmPit>();
		pit.Owner = owner;
		pit.Radius = radius;

		SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.1f, PitSeconds ) );

		Log.Info( $"[nz-aug] fire M3 Scorched Earth — pit at {at}"
			+ $" · {radius:0}u for {PitSeconds:0.#}s" );

		return go;
	}

	/// <summary>
	/// The held weapon's damage per second, computed the way the game computes it.
	///
	/// ⛔ `GetRealRPM` RETURNS AN INTERVAL, NOT AN RPM — seconds between shots. The stats card
	/// makes the same reading (`perkedRPM = 60 / interval`), so DPS is simply damage divided by
	/// that interval. Treating the return value as an RPM would be wrong by a factor of
	/// `RPM²/3600`, which at 600 RPM is a hundredfold.
	///
	/// ⚠ IT GOES THROUGH `GetRealRPM` AND `DamageFor`, not the authored `si.RPM` and `si.Damage`.
	/// Those two are the chokepoints every perk, augment and tech node folds into — so a pit
	/// scales with Pack-a-Punch, Double Tap's fire rate and Vigor's damage without knowing any of
	/// them exist. Reading the authored values would make the pit ignore the whole upgrade tree.
	///
	/// ⚠ `× Bullets` FOR SHOTGUNS, matching the stats card's Damage row. Per-pellet damage would
	/// make a KS23 pit a tenth of what its card claims.
	/// </summary>
	public static float WeaponDps( NZPlayer player )
	{
		var wep = VultureAugments.HeldWeapon( player );
		var si = wep.IsValid() ? wep.Primary : null;

		if ( si is null ) return MathF.Max( 0f, NoWeaponDps );

		var interval = wep.GetRealRPM( si.RPM );
		if ( interval <= 0.0001f ) return MathF.Max( 0f, NoWeaponDps );

		var perShot = si.DamageFor( 0f, null ) * Math.Max( 1, si.Bullets );

		return perShot / interval;
	}

	/// <summary>What one pit tick deals, including M2's area-damage multiplier.</summary>
	public static float PitTickDamage( NZPlayer player )
		=> WeaponDps( player )
			* MathF.Max( 0f, PitDpsPerTick )
			* AreaDamageScale( player?.GameObject );

	// ══ M4 — the chain ════════════════════════════════════════════════════════

	/// <summary>
	/// A zombie died. M4 rolls for an explosion, and that explosion's kills roll again.
	///
	/// ⚠️ `depth` IS THE RECURSION GUARD and it is not optional. An explosion can kill zombies
	/// whose deaths roll for another explosion, so the chain is genuinely unbounded — see
	/// <see cref="ChainDepth"/>.
	/// </summary>
	public static void OnZombieKilled( NZPlayer killer, Vector3 position, int depth = 0 )
	{
		if ( !Has( killer, "M4" ) ) return;
		if ( depth >= Math.Max( 1, ChainDepth ) ) return;
		if ( Game.Random.Float() > MathF.Max( 0f, ChainChance ) ) return;

		Detonate( killer, position, depth + 1 );
	}

	/// <summary>
	/// One link of the chain: damage everything nearby, and roll again on whatever it kills.
	///
	/// ⚠️ THE KILLS ARE COLLECTED BEFORE RECURSING, not while iterating. Recursing inside the loop
	/// would mutate `ZombieAI.All` underneath it, and the chain would visit some zombies twice and
	/// others never.
	/// </summary>
	static void Detonate( NZPlayer player, Vector3 at, int depth )
	{
		var radius = PitRadius * AreaRadiusScale( player.GameObject );
		var damage = ChainDamage( player );

		var killed = new System.Collections.Generic.List<Vector3>();

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( at.Distance( z.WorldPosition ) > radius ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			var wasAlive = hp.Current;

			hp.OnDamage( new DamageInfo
			{
				Damage = damage,
				Attacker = player.GameObject,
				Position = z.WorldPosition + Vector3.Up * 32f,
				Tags = new TagSet(),
			} );

			if ( wasAlive > 0f && hp.IsDead ) killed.Add( z.WorldPosition );
		}

		BlastEffect.Spawn( at, radius );

		Log.Info( $"[nz-aug] fire M4 Chain Reaction [link {depth}] — {damage:0} within {radius:0}u"
			+ $", killed {killed.Count}" );

		foreach ( var k in killed )
			OnZombieKilled( player, k, depth );
	}

	/// <summary>
	/// What one chain explosion deals.
	///
	/// ⚠️ SCALED BY M2, because a chain explosion IS area damage. That is the whole reason M2 is
	/// expressed as a call-site register rather than a damage type — this is one of the sites.
	/// </summary>
	public static float ChainDamage( NZPlayer player )
	{
		var round = RoundManager.Instance?.Round ?? 1;

		// ⚠️ SCALED OFF THE ROUND CURVE so it stays relevant, the same way the knife is. A flat
		// figure would be a one-shot at round 5 and a tickle at round 40.
		var baseDamage = ZombieStats.HealthForRound( round ) * 0.5f;

		return baseDamage * AreaDamageScale( player?.GameObject );
	}

	// ══ per-frame: the pits burn ══════════════════════════════════════════════

	// ⛔ `TickPits` REMOVED. It applied the `burn` status to everything inside the pit every
	// frame, and it did not work: a 75 HP zombie survived 8 seconds in a pit that should have
	// dealt ~128. Re-applying a status at frame rate appears to keep resetting its tick timer, so
	// the 0.5s damage tick never fired — the pit was permanently about to hurt something.
	//
	// The pit now deals damage ITSELF, on its own timer, in `NapalmPit.OnUpdate`. See there.

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

	public static void Report( NZPlayer player )
	{
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var has = player.HasPerk( Perk );
		var equipped = PerkAugments.EquippedOn( player, Perk );

		Log.Info( $"[nz-aug] NAPALM NECTAR {(has ? "owned" : "NOT OWNED — every line below is inert")}"
			+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );

		var rule = StatusEffects.Rules.TryGetValue( "burn", out var r ) ? r : null;

		Log.Info( $"[nz-aug]  base ignite     1-in-{ChanceFor( player )} per hit"
			// ⚠ BOTH HALVES READ THE RESOLVED VALUE. Printing `ChanceFor` beside the RAW
		// cooldown made one number reflect m2 and the other not, on the same line.
		+ $" · {CooldownFor( player )}-hit cooldown"
			+ $" · cooldown now {player.NapalmCooldown}"
			+ $" · burn {rule?.Seconds ?? 0f:0.#}s at x{rule?.Vulnerability ?? 0f:0.##}" );

		Log.Info( $"[nz-aug]  M1 Wildfire     {(Has( player, "M1" ) ? $"lights {WildfireCount} nearest within {WildfireRadius:0}u" : "-")}" );
		Log.Info( $"[nz-aug]  M2 Demolitionist {(Has( player, "M2" ) ? $"area damage x{AreaDamage:0.##}" : "-")}" );
		Log.Info( $"[nz-aug]  M3 Scorched     {(Has( player, "M3" ) ? $"{PitChance * 100f:0.#}% per hit -> {PitRadius * AreaRadiusScale( player.GameObject ):0}u pit for {PitSeconds:0.#}s" : "-")}"
			+ $"   {Pits().Length} pit(s) live" );
		Log.Info( $"[nz-aug]  M4 Chain        {(Has( player, "M4" ) ? $"{ChainChance * 100f:0.#}% per kill -> {ChainDamage( player ):0} in {PitRadius * AreaRadiusScale( player.GameObject ):0}u, up to {ChainDepth} links" : "-")}" );
		Log.Info( $"[nz-aug]  m1 Accelerant   {(Has( player, "m1" ) ? $"burning take x{BurnVulnerability:0.##} (rule x{rule?.Vulnerability ?? 0f:0.##} x{BurnVulnerability / MathF.Max( 0.01f, rule?.Vulnerability ?? 2f ):0.##})" : "-")}" );
		Log.Info( $"[nz-aug]  m2 Incendiary   {(Has( player, "m2" ) ? $"1-in-{IgniteOneIn} (was 1-in-{PerkEffects.IgniteOneIn}) · {IgniteCooldownHits}-hit cooldown (was {PerkEffects.IgniteCooldownHits})" : "-")}" );
		Log.Info( $"[nz-aug]  m3 Hot Blade    {(Has( player, "m3" ) ? "knife ignites" : "-")}" );
		Log.Info( $"[nz-aug]  m4 Powder Keg   {(Has( player, "m4" ) ? $"every AoE radius x{AreaRadius:0.##}" : "-")}"
			+ $"   grenade {220f * AreaRadiusScale( player.GameObject ):0}u"
			+ $" · phd {PhdAugments.Radius * AreaRadiusScale( player.GameObject ):0}u" );
		Log.Info( $"[nz-aug]  m5 Wildspread   {(Has( player, "m5" ) ? $"{SpreadChance * 100f:0.#}% -> {SpreadCount} within {SpreadRadius:0}u" : "-")}" );
	}

	/// <summary>`nz_aug_fire` — the resolved state of the base and all nine.</summary>
	[ConCmd( "nz_aug_fire" )]
	public static void ReportCmd()
		=> Report( NZPlayer.Local );

	/// <summary>`nz_fire_pit [distance]` — drop a napalm pit, ignoring the 1%.</summary>
	[ConCmd( "nz_fire_pit" )]
	public static void PitCmd( float distance = 200f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;

		SpawnPit( p, p.WorldPosition + rot.Forward.WithZ( 0f ).Normal * distance );
	}

	/// <summary>
	/// `nz_fire_chain` — force a chain explosion at the nearest zombie, ignoring the 10%.
	///
	/// ⚠️ 10% OF KILLS, CHAINED, IS UNTESTABLE BY PLAYING. Forcing link one and letting the rest
	/// roll naturally is the only way to see whether the chain actually chains.
	/// </summary>
	[ConCmd( "nz_fire_chain" )]
	public static void ChainCmd()
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		if ( !Has( p, "M4" ) )
		{
			Log.Warning( "[nz-aug] M4 Chain Reaction not equipped" );
			return;
		}

		var z = ZombieAI.All
			.Where( a => a.IsValid() )
			.OrderBy( a => a.WorldPosition.Distance( p.WorldPosition ) )
			.FirstOrDefault();

		Detonate( p, z.IsValid() ? z.WorldPosition : p.WorldPosition, 1 );
	}

	/// <summary>`nz_fire_set` — retune live. Negative or omitted leaves a value alone.</summary>
	[ConCmd( "nz_fire_set" )]
	public static void SetCmd( float wildfireRadius = -1f, int wildfireCount = -1,
		float areaDamage = -1f, float pitChance = -1f, float pitRadius = -1f, float pitSeconds = -1f,
		float chainChance = -1f, int chainDepth = -1, float burnVuln = -1f, int igniteOneIn = -1,
		float areaRadius = -1f, float spreadChance = -1f, int spreadCount = -1,
		float spreadRadius = -1f, int cooldownHits = -1 )
	{
		if ( wildfireRadius >= 0f ) WildfireRadius = wildfireRadius;
		if ( wildfireCount >= 0 ) WildfireCount = wildfireCount;
		if ( areaDamage >= 0f ) AreaDamage = areaDamage;
		if ( pitChance >= 0f ) PitChance = pitChance;
		if ( pitRadius >= 0f ) PitRadius = pitRadius;
		if ( pitSeconds >= 0f ) PitSeconds = pitSeconds;
		if ( chainChance >= 0f ) ChainChance = chainChance;
		if ( chainDepth >= 1 ) ChainDepth = chainDepth;
		if ( burnVuln >= 0f ) BurnVulnerability = burnVuln;
		if ( igniteOneIn >= 1 ) IgniteOneIn = igniteOneIn;
		if ( cooldownHits >= 0 ) IgniteCooldownHits = cooldownHits;
		if ( areaRadius >= 0f ) AreaRadius = areaRadius;
		if ( spreadChance >= 0f ) SpreadChance = spreadChance;
		if ( spreadCount >= 1 ) SpreadCount = spreadCount;
		if ( spreadRadius >= 0f ) SpreadRadius = spreadRadius;

		ReportCmd();
	}
}

/// <summary>
/// A burning napalm pit. Carries its owner so the burn it applies is credited correctly.
///
/// ⚠️ THE OWNER MATTERS FOR MORE THAN CREDIT: m1's vulnerability and m4's radius are read off the
/// attacker, so a pit that forgot who dropped it would apply the base burn instead of the
/// augmented one.
/// </summary>
public sealed class NapalmPit : Component
{
	public NZPlayer Owner { get; set; }

	public float Radius { get; set; } = 200f;

	/// <summary>When this pit next damages what is standing in it.</summary>
	TimeUntil _nextTick;

	/// <summary>
	/// Damage everything inside, on the pit's own clock.
	///
	/// ⛔ THE PIT OWNS ITS TIMER, WHICH IS WHY THIS IS HERE AND NOT IN `NZPlayer.OnUpdate`. Driven
	/// from the player, two players standing near one pit would tick it twice as fast — and the
	/// previous version was driven that way and applied a status every frame instead of damaging
	/// on a cadence, which is exactly why it never hurt anything.
	///
	/// ⚠ A RUNTIME-CREATED COMPONENT DOES NOT SURVIVE A HOTLOAD, and that is acceptable here
	/// where it would not be elsewhere: a pit lives eight seconds. `Slide` carries the warning for
	/// the case that matters, and `DestroyAsync`'s own timer component is created the same way.
	///
	/// ⚠ DAMAGE, NOT `burn`. Requested outright — "the pit must damage zombies inside the radius".
	/// A consequence worth knowing: pits no longer IGNITE, so M1 Wildfire and m5 Wildspread do not
	/// spread from a pit, and m1 Accelerant does not scale pit damage. One line adds the burn back
	/// alongside if that is wanted.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( !_nextTick ) return;

		_nextTick = MathF.Max( 0.05f, FireAugments.PitTickInterval );

		// ⛔ ONE MACHINE DAMAGES, EVERY MACHINE DRAWS. The pit now exists on all of them so the
		// ring is visible to everybody — but each copy ticking would hurt every zombie inside it
		// once PER MACHINE, and on a client each of those hits would be relayed to the host as a
		// separate one. Two players means double damage from one pit.
		//
		// ⚠❗ THE OWNER'S MACHINE, NOT THE HOST'S, so the damage carries the owner's own perks
		// through `Health.AttackerScale` — which is the whole reason a client's fire is worth
		// anything now.
		if ( Networking.IsActive
			&& (!Owner.IsValid() || !PlayerPresence.Mine( Owner.GameObject )) ) return;

		var damage = FireAugments.PitTickDamage( Owner );
		if ( damage <= 0f ) return;

		var at = WorldPosition;
		var hit = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( at.Distance( z.WorldPosition ) > Radius ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			hp.OnDamage( new DamageInfo
			{
				Damage = damage,
				Attacker = Owner.IsValid() ? Owner.GameObject : null,
				Position = z.WorldPosition + Vector3.Up * 32f,
				Tags = new TagSet(),
			} );

			hit++;
		}

		if ( hit > 0 )
			Log.Info( $"[nz-aug] fire pit tick — {damage:0} to {hit} zombie(s) within {Radius:0}u" );
	}
}