Weapons/TechBlast.cs

Static utility that implements 'explosive rounds' and other small blasts for weapons. It rate-limits per weapon class, computes blast damage from a weapon's DamageFor and a tunable share, finds zombies via ZombieAI.All with LOS checks, applies flat damage via Health.OnDamage with attacker/weapon set, and spawns a visual/sound effect.

File Access
using Sandbox;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// EXPLOSIVE ROUNDS (`t5_explosive`) — the small blast the bullet paths trigger.
///
/// ⛔ NOT `Grenade.Detonate`, AND THE VETO IS WRITTEN DOWN BECAUSE IT LOOKS LIKE FREE
/// REUSE. Docs/TIER5_WIRING_PLAN.md §6.1 surveyed that method and rejected it on six
/// counts, every one of which this file exists to avoid:
///
///   • Every magnitude is an instance `[Property]` — `Damage = 500`, `Radius = 220`,
///     `SelfDamage = 0.25`. There is no way to ask it for a small blast.
///   • It damages the SHOOTER. Any `Health` carrying an `NZPlayer` takes a scaled
///     share, so a round fired at a zombie in melee range can kill the player.
///   • Its `DamageInfo` carries no `Attacker` and no `Weapon`, so every blast
///     overwrites `Health.LastAttacker` to NULL — which `ZombieAI._lastAttacker`
///     latches from — breaking Bounty, `PickupDrops.RollOnDeath` and kill points on
///     every AoE kill.
///   • Per call it runs `scene.GetAllComponents&lt;Health&gt;().ToList()`: a scene-wide
///     component scan plus a List allocation, 35 live zombies plus lingering corpses
///     plus the player.
///   • Per call it also clones a prefab, creates a GameObject, creates a PointLight
///     and plays a sound — 35 times a second on a G11.
///   • `Weapon.Shoot` calls `BulletType.Shoot` once per PELLET, so a 16-pellet KS23
///     would fire SIXTEEN of those per trigger pull.
///
/// ⚠️ SO THE SHAPE OF THIS FILE IS THE LIST OF THINGS IT DOES NOT DO. No prefab clone,
/// no light, no sound, no `Log.Info`, no scene scan, no allocation on the hot path, and
/// a hard rate limit that collapses a shotgun's pellets into one blast and caps a
/// 2100 RPM weapon at <see cref="MinInterval"/>.
///
/// ✅ §6.1's "no points awarded from blast hits" IS NOW DONE, elsewhere. It was unreachable from
/// this file — every damage path crosses `Health.Apply` → `OnDamaged` → `ZombieAI`, which paid
/// unconditionally — so <see cref="BlastTag"/> was stamped here and left waiting for a reader.
/// `Health.Pays` is that reader, built for `ShotPoints` and picking this up in the same
/// expression: `ZombieAI.OnHurt` skips the drip for either refusal.
/// </summary>
public static class TechBlast
{
	/// <summary>
	/// Stamped on every blast hit so the points path can recognise one.
	///
	/// ✅ AND IT IS READ NOW. It was written as a hand-off for §6.1 and spent its life unread,
	/// describing the fix it wanted: *"latch it in `Health.OnDamage` beside the melee latch, and
	/// skip the `AwardPoints` call in `ZombieAI.OnHurt`."* That is exactly what
	/// `Health.LastHitPays` does — built for `ShotPoints`' penetration and pellet caps, which is
	/// the same requirement arriving from the other side.
	///
	/// ⚠️ DELIBERATELY NOT `TagsHelper.Bullet`. That tag is stamped in exactly one
	/// place — `DamageInfo.FromBullet` — and `Health.OnDamage` reads it to apply Vigor
	/// Rush's double bullet damage. A blast is not a bullet, and borrowing the tag
	/// would quietly hand the perk a second multiply on the same trigger pull.
	/// </summary>
	public const string BlastTag = "blast";

	/// <summary>
	/// How far the blast reaches, in units.
	///
	/// ⚠️ A NAMED CONSTANT AND NOT IN THE CATALOGUE, WHICH MEANS `nz_tech` CANNOT
	/// PRINT IT. `WeaponTech.Node` has two numeric slots; `t5_explosive` spends
	/// `Factor` on the -40% direct damage and `Bound` on
	/// <see cref="DamageShareFallback"/>, so the third and fourth numbers this node
	/// needs have no catalogue home. This is the tier-4 precedent (the `ScatterRpm` /
	/// `DrumWalk` block in NZPlayer.cs), followed knowingly — see
	/// Docs/TIER5_WIRING_PLAN.md §4.0.
	///
	/// ⚠️ 70 units is ~1.8 m, against the grenade's 220. "A small AoE blast" is the
	/// catalogue's own wording, and a bullet blast that reached a grenade's radius
	/// would clear a room from a doorway.
	/// </summary>
	// ⛔ THE CATALOGUE HOLDS IT NOW (2026-10-04): the shotguns' Explosive Rounds declares `radius` 100 (the user: *"increase
	// the units to 100"*), so `nz_tech` prints it at last. 70, the old constant, is only the fallback.
	static float Radius => WeaponTech.MagOf( "t5_explosive", "radius", 70f );

	/// <summary>
	/// The blast's share of the round that fired it.
	///
	/// ⛔ A FRACTION OF THE ROUND, NOT A FLAT NUMBER, AND THAT IS THE WHOLE REASON
	/// THIS NODE IS TUNEABLE AT ALL. Authored damage across the roster runs 25 (MAC11)
	/// to 1465 (AWM) — a 59x spread — while fire rate runs the other way, 55 to 2100
	/// RPM. Any flat blast figure is negligible on one end and absurd on the other. A
	/// share of the round makes the blast's DPS proportional to the weapon's own, which
	/// is the only normalisation the roster supports, and it composes for free with
	/// Pack-a-Punch, rarity and every tier-1..4 damage node because it reads
	/// `DamageFor`.
	///
	/// ⚠️ IT IS A SHARE OF THE **ALREADY REDUCED** ROUND. The node's `Factor` of 0.6
	/// is written into `si.Damage` at spawn time, so by the time a bullet reads
	/// `DamageFor` the -40% has already landed. Single-target arithmetic therefore
	/// comes out at 0.6 + 0.6 x 0.5 = **0.9x stock**, and every additional zombie
	/// inside the radius adds another 0.3x. That is the trade, stated in numbers.
	///
	/// ⚠️ A FALLBACK, NOT THE MAGNITUDE — the `WeaponTech.BoundOf` precedent
	/// `t5_deadeye` set. Declaring `Bound = 0.5f` on `t5_explosive` makes this literal
	/// unreachable and puts the number in the table `nz_tech` prints.
	/// </summary>
	const float DamageShareFallback = 0.5f;

	/// <summary>
	/// The hard rate limit: no weapon may produce two blasts closer together than this.
	///
	/// ⛔ THIS SINGLE GATE IS BOTH HALVES OF §6.1'S "ONE PER TRIGGER PULL, NOT PER
	/// PELLET" AND ITS "OR A HARD RATE LIMIT", and that is why it is one number and not
	/// two mechanisms. A shotgun's pellets all resolve inside one frame, so any
	/// positive interval collapses a KS23's sixteen into one. A G11 at 2100 RPM shoots
	/// every 0.029 s, so 0.15 s caps it at 6.7 blasts a second instead of 35.
	///
	/// ⚠️ 0.15 IS BELOW EVERY WEAPON'S SHOT INTERVAL EXCEPT THE FAST AUTOS, which is
	/// the intent: the slower half of the roster gets a blast on literally every shot
	/// and only the weapons that would have made this node a performance problem are
	/// throttled. Enumerated from the RPM census in Docs/TIER5_WIRING_PLAN.md §5b: at
	/// 0.15 s the throttle binds above 400 RPM.
	///
	/// ⚠️ Also not in the catalogue — see <see cref="Radius"/>.
	/// </summary>
	const float MinInterval = 0.15f;

	/// <summary>
	/// When each weapon last produced a blast, by `ClassName`.
	///
	/// ⚠️ KEYED BY CLASS, NOT BY INSTANCE, FOR TWO REASONS. Pack-a-Punch destroys the
	/// weapon clone and respawns it, so an instance key would hand a freshly upgraded
	/// gun a free blast on its first shot and leave a dead entry behind — and the
	/// roster is 31 weapons, so a class key is bounded for the whole session where an
	/// instance key grows with every upgrade and every re-equip.
	///
	/// ⚠️ `Time.Now` IS SCENE TIME AND RESTARTS AT ZERO WITH THE SCENE, the same trap
	/// `NZPlayer._fabDue` records. A stored stamp in the future therefore means the
	/// clock was rewound, not that a blast is pending, so it is treated as ready.
	/// </summary>
	static readonly Dictionary<string, float> _lastBlastAt = new();

	/// <summary>
	/// Fire the blast at <paramref name="pos"/>, if this weapon owns the node and its
	/// rate limit allows one.
	///
	/// ⚠️ CALLED FROM THE BULLET PATHS' IMPACT SITES, ONCE PER PELLET AT MOST — the
	/// rate limit is what turns that into once per trigger pull. Calling it once per
	/// PENETRATION iteration instead would put sixteen pellets x ten bodies through
	/// this method on a KS23, which the gate would absorb but which would still cost
	/// the lookup 160 times.
	/// </summary>
	public static void TryBlast( SWB.Base.Weapon weapon, SWB.Base.ShootInfo shootInfo, Vector3 pos )
	{
		if ( !weapon.IsValid() || shootInfo is null ) return;
		if ( !TechEffects.Has( weapon, "t5_explosive" ) ) return;

		var key = weapon.ClassName ?? string.Empty;

		if ( _lastBlastAt.TryGetValue( key, out var last )
			&& last <= Time.Now
			&& Time.Now - last < MinInterval ) return;

		_lastBlastAt[key] = Time.Now;

		// ⚠️ Distance 0 and no hit tags: the blast is not a headshot and carries no
		// falloff of its own. `DamageFor` is the one choke point both bullet paths
		// read, so this composes with Pack-a-Punch, rarity and every damage node
		// rather than duplicating their arithmetic.
		var damage = shootInfo.DamageFor( 0f, null )
			* WeaponTech.BoundOf( "t5_explosive", DamageShareFallback );

		if ( damage <= 0f ) return;

		var attacker = weapon.Owner.IsValid() ? weapon.Owner.GameObject : null;

		Detonate( pos, damage, attacker, weapon.GameObject );

		// ⛔ AND NOW YOU CAN SEE IT (2026-10-04, the user: *"needs to have an actual explosion effect"*). The "no prefab, no
		// light, no sound" rule above was for per-PELLET blasts; the rate limit has already made this one per trigger pull.
		Effect( pos, Radius );
	}

	/// <summary>
	/// The visible half: the grenade's fireball and flash, sized to the blast, on every machine (`BlastEffect.Spawn`
	/// announces it), and the grenade's bang, quieter — Explosive Rounds goes off on every shot.
	/// </summary>
	static void Effect( Vector3 pos, float radius )
	{
		BlastEffect.Spawn( pos, radius );

		var snd = NZSound.Play( NZSound.GrenadeExplode, pos );
		if ( snd.IsValid() ) snd.Volume *= 0.6f;
	}

	/// <summary>
	/// THE UNDERBARREL LAUNCHER'S GRENADE (`t5_ar_underbarrel`, 2026-10-04): this file's zombies-only, line-of-sight blast at
	/// a damage and radius of its own, with the explosion. No rate limit — it is charged by kills.
	/// </summary>
	public static void Launch( SWB.Base.Weapon weapon, Vector3 pos, float damage, float radius )
	{
		if ( !weapon.IsValid() || damage <= 0f || radius <= 0f ) return;

		var attacker = weapon.Owner.IsValid() ? weapon.Owner.GameObject : null;

		Detonate( pos, damage, attacker, weapon.GameObject, radius );
		Effect( pos, radius );
	}

	/// <summary>
	/// THE AMMO MODS' BLASTS (2026-10-04, `KillMods`): Shatter Blast's explosion and Headhunter's carried shot — this file's
	/// zombies-only, line-of-sight blast at a damage and reach of their own. <paramref name="hitAt"/> collects where each zombie
	/// it reached stood; <paramref name="except"/> is the corpse it came from; <paramref name="effect"/> false leaves out the
	/// fireball and the bang.
	/// </summary>
	public static void ModBlast( Vector3 pos, float damage, float radius, GameObject attacker, GameObject weapon,
		List<Vector3> hitAt = null, GameObject except = null, bool effect = true )
	{
		if ( damage <= 0f || radius <= 0f ) return;

		Detonate( pos, damage, attacker, weapon, radius, hitAt, except );

		if ( effect ) Effect( pos, radius );
	}

	/// <summary>
	/// The blast itself.
	///
	/// ⛔ THE TARGET QUERY IS `ZombieAI.All`, NOT `GetAllComponents&lt;Health&gt;()`.
	/// That static list is maintained by `OnEnabled`/`OnDisabled` and is capped by
	/// `MaxAlive = 50`, so this is a bounded walk over a list that already exists — no
	/// scene scan, no LINQ, no allocation. It also excludes the PLAYER structurally
	/// rather than by a test: the player carries a `Health` but never a `ZombieAI`, so
	/// there is no path by which this method can hurt whoever fired the shot. That is
	/// §6.1's "players excluded" requirement satisfied by the choice of collection.
	///
	/// ⚠️ ITERATED DOWNWARD, WITH A BOUNDS CHECK. `Health.Apply` can kill a zombie
	/// inside this loop, and `Die` -&gt; `StopBeingSolid` can end with the component
	/// disabled, which removes it from `All` mid-iteration. Walking down means a
	/// removal only shifts entries we have already passed.
	/// </summary>
	static void Detonate( Vector3 pos, float damage, GameObject attacker, GameObject weapon, float radius = -1f,
		List<Vector3> hitAt = null, GameObject except = null )
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) return;

		// ⚠️ THE CALLER'S RADIUS, OR EXPLOSIVE ROUNDS' — resolved once, not per zombie.
		var reach = radius > 0f ? radius : Radius;

		for ( int i = ZombieAI.All.Count - 1; i >= 0; i-- )
		{
			if ( i >= ZombieAI.All.Count ) continue;

			var z = ZombieAI.All[i];
			if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
			if ( except.IsValid() && z.GameObject == except ) continue;

			// ⚠️ Aimed at the middle of the body using the zombie's OWN authored
			// height rather than a literal — a variant that authors a taller or
			// shorter body stays correctly centred, and the grenade's hardcoded
			// `Up * 32` does not have to be repeated here.
			var target = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f);
			var dist = target.Distance( pos );
			if ( dist > reach ) continue;

			// ⛔ LINE OF SIGHT, FOR THE REASON `Grenade.Detonate` RECORDS: without it
			// the blast kills through walls, floors and closed doors, and on a map
			// built of small rooms that is most of its kills.
			//
			// ⛔ AND THE TARGET'S OWN BODY IS IGNORED, which is the other lesson from
			// that method. Without it a zombie BLOCKS ITS OWN LINE OF SIGHT: the ray
			// strikes its front surface a few units short, the occlusion test sees a
			// hit closer than the target, and the zombie is skipped as though it were
			// behind a wall.
			//
			// ⚠️ The slack is the zombie's own `HitRadius`, not a typed-in tolerance:
			// anything that stops within one body radius of the target has reached it.
			var trace = scene.Trace.Ray( pos, target )
				.WithoutTags( "player", "trigger" )
				.IgnoreGameObjectHierarchy( z.GameObject );

			// ⛔ AND THE CORPSE A KILL MOD'S BLAST COMES FROM (2026-10-04, the review). On the host its capsule is still
			// there in the frame it dies — `Destroy` is deferred — and the blast starts inside it, so every ray read as
			// blocked a few units out and the blast hit nothing.
			if ( except.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( except );

			var tr = trace.Run();

			if ( tr.Hit && tr.Distance < dist - z.HitRadius ) continue;

			// ⚠️ `EverythingInSelf` — trap 2 in INSTRUCTIONS.md. A component on a
			// disabled object is invisible to a plain `Get`, and this list can hold a
			// zombie mid-transition.
			var hp = z.Components.Get<Health>( FindMode.EverythingInSelf );
			if ( !hp.IsValid() ) continue;

			// ⛔ FLAT DAMAGE, NOT DISTANCE-SCALED, following the grenade's own
			// reasoning verbatim: a blast either clears a group or it does not, and
			// making a horde's survivors depend on where each one stood turns a shot
			// into a lottery the player cannot read.
			//
			// ⛔ `Attacker` AND `Weapon` ARE BOTH CARRIED. `Health.OnDamage` latches
			// them into `LastAttacker`/`LastWeapon`, which `ZombieAI._lastAttacker`
			// and `AwardPoints` read for Bounty, kill points and `PickupDrops`. The
			// grenade sets neither, which is why every one of its blasts wipes the
			// attribution the killing bullet just set.
			//
			// ⚠️ ROUTED THROUGH `OnDamage` RATHER THAN `Apply`, deliberately: `Apply`
			// takes only an attacker and would leave `LastWeapon` null, so a blast
			// kill would read the wrong weapon's tech tree under Mule Kick.
			hp.OnDamage( new SWB.Shared.DamageInfo
			{
				Attacker = attacker,
				Weapon = weapon,
				Damage = damage,
				Position = target,
				Origin = pos,
				Tags = [BlastTag],
			} );

			// ⚠️ WHERE IT STOOD, for a caller that draws to each one (Headhunter's lines).
			hitAt?.Add( target );
		}
	}
}