Weapons/BouncyRounds.cs

A static utility implementing the Bouncy Rounds weapon effect. When a zombie dies from a qualifying hit, Chain finds nearby living zombies up to the weapon penetration cap and applies progressively amplified damage to them until a link survives or the cap is reached.

Native Interop
using Sandbox;
using System;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// BOUNCY ROUNDS (`t5_bouncy`) — a round that kills carries on to the next zombie, twice as hard.
///
/// ⛔ IT CHAINS ON A KILL, NOT ON A HIT, AND THAT IS WHAT KEEPS IT FROM BEING A CROWD CLEARER.
/// A shot that leaves a zombie standing does nothing at all, so the node pays for OVERKILL — the
/// surplus a round had left over — rather than for firing into a pack. Most shots in a real round
/// do not kill, so most shots never bounce.
///
/// ⚠️ ONCE IT STARTS THOUGH, IT TENDS TO FINISH. Damage doubles each link while zombie health
/// stays fixed, so a link that killed almost guarantees the next one will. The cap is what bounds
/// it, and the cap is the weapon's own PENETRATION — thematic (the bullet has that much left in
/// it), already tuned per class, and it makes tier 3's Overpenetrator a real synergy:
///
///     shotgun 2 links (final x4)      assault rifle 5 (x32)      sniper 10 (x1024)
///
/// So a sniper that one-shots clears eleven zombies with one round and a shotgun clears three.
///
/// ⛔ IT IS TRIGGERED FROM `Health.OnDamage`, NOT FROM `HitScan`, AND THE FIRST VERSION HAD THAT
/// WRONG. The trigger is a KILL, and on a CLIENT the local copy of a zombie never takes the damage
/// at all — `OnDamage` relays to the host and returns before `Apply` ever subtracts — so `IsDead`
/// read from the shooter's machine was permanently false and the node did nothing for anybody but
/// the host. The kill is knowable on exactly one machine, which is the machine that owns the
/// zombie, so that is where this runs.
///
/// ⚠️ AND THE SAME MOVE FIXED THE DAMAGE FIGURE. `HitScan` could only offer the bullet's damage
/// BEFORE `AttackerScale`, the hit group and the ammo mods were applied to it; `OnDamage` knows
/// what the zombie actually lost. The doubling now doubles the real number.
///
/// ⚠️ IT STILL DOES NOTHING FOR A CLIENT'S OWN SHOTS, and that is a known, shared gap rather than
/// this node's FORMER bug, fixed 2026-09-15: `NZNet.HurtRemote` carried a damage figure and no
/// weapon, so the host could not
/// see which tech the shooter's gun had. Adrenaline Rounds — which sits on the very next lines of
/// `OnDamage` — has the identical limitation for the identical reason. It closes when weapon tech
/// replicates, which is one coherent job, not eleven per-node hacks.
///
/// ⚠️ A BOUNCE CANNOT START ANOTHER BOUNCE, because the link damage built below carries no
/// `ShotId` and the trigger only fires on damage that has one. That test costs nothing, needs no
/// depth counter, and excludes blasts and Flechette shards by the same stroke.
/// </summary>
public static class BouncyRounds
{
	/// <summary>How much harder each link hits than the one before.</summary>
	public static float Step { get => _step ?? 2f; set => _step = value; }
	static float? _step;

	/// <summary>
	/// How far a bounce will look for its next target, in units. 400.
	///
	/// ⛔ A RANGE LIMIT AS WELL AS A COUNT, BECAUSE "THE NEAREST ZOMBIE" HAS NO NATURAL END. Without
	/// it a chain would cross the map to find its next link, and a round fired into one corner would
	/// kill something the player cannot see in another. The bullet should run out of pack, not out
	/// of patience.
	/// </summary>
	public static float Reach { get => _reach ?? 400f; set => _reach = value; }
	static float? _reach;

	/// <summary>The tag every link carries, so the damage can be told apart downstream.</summary>
	public const string Tag = "bounce";

	// ⚠️ REUSED ACROSS CALLS RATHER THAN ALLOCATED PER KILL. A chain runs inside one bullet's
	// resolution, never concurrently with another, so one list is enough — and this is on the
	// damage path, where INSTRUCTIONS records an array-per-hit already costing frames.
	static readonly List<GameObject> _chained = new();

	/// <summary>
	/// A zombie just died to this weapon. Carry the round on while it keeps killing.
	/// </summary>
	/// <param name="killed">The body that died — the first link, already paid for.</param>
	/// <param name="damage">What the killing hit dealt, before doubling.</param>
	/// <param name="cap">Links allowed: the weapon's penetration depth.</param>
	public static void Chain( in TechEffects.TechRef tech, GameObject killed, float damage, float cap )
	{
		if ( !tech.Valid || !killed.IsValid() || damage <= 0f ) return;

		var links = (int)cap;
		if ( links < 1 ) return;

		// ⛔ THE SHOOTER COMES FROM THE `TechRef`, NOT FROM `weapon.Owner`, AND THAT IS THE
		// WHOLE FIX FOR THIS NODE ON A CLIENT. There is no weapon object on the host for a hit a
		// client relayed, so `weapon.Owner` was a null dereference waiting to happen and the
		// guard above simply returned instead — the node did nothing at all for a client. The
		// player replicates; the weapon does not.
		var attacker = tech.AttackerGo;

		// ⚠️ NULL ON A RELAYED HIT, WHICH IS WHY THE STAMP GOES ON BESIDE IT. `LastWeapon`
		// wants the object when there is one; `Extra` carries the prefab when there is not, and
		// between them every link arrives at `OnDamage` knowing which tree fired it.
		var weaponGo = tech.WeaponGo;
		var stamp = tech.Stamp();

		_chained.Clear();
		_chained.Add( killed );

		var from = killed;
		var hit = damage;

		for ( var i = 0; i < links; i++ )
		{
			hit *= Step;

			var next = Nearest( from );
			if ( !next.IsValid() ) return;

			var hp = next.Components.Get<Health>( FindMode.EverythingInSelf );
			if ( hp is null ) return;

			_chained.Add( next );

			// ⚠️ THROUGH `OnDamage`, NOT `Apply`, AND BOTH HALVES OF THAT MATTER. `Apply` takes
			// no weapon, so `LastWeapon` would be null and a bounce kill would read the wrong
			// gun's tech tree under Mule Kick — `TechBlast.Detonate` records the same finding.
			// It is also local-only, which is half of what was wrong with the first version.
			//
			// ⚠️ NO `head` TAG, EVER. A bounce is not aimed, so it earns neither the head damage
			// product nor the 100-point award; the doubling is the reward. Paying head money for
			// a shot nobody placed would make the node a points engine as well as a damage one.
			hp.OnDamage( new SWB.Shared.DamageInfo
			{
				Attacker = attacker,
				Weapon = weaponGo,
				Damage = hit,
				Position = next.WorldPosition,
				Origin = from.WorldPosition,
				Tags = [Tag],
				Extra = stamp,
			} );

			// ⛔ THE CHAIN ENDS THE MOMENT A LINK SURVIVES, which is the node's whole contract.
			// Checked AFTER the damage, so the zombie that absorbed the round still took it.
			if ( !hp.IsDead ) return;

			from = next;
		}
	}

	/// <summary>
	/// The closest living zombie to a point that this chain has not already used.
	///
	/// ⚠️ THE EXCLUSION LIST IS WHAT STOPS IT BOUNCING BETWEEN TWO CORPSES. A dead zombie is not
	/// removed from `ZombieAI.All` in the same frame it dies, so "nearest living" alone would keep
	/// finding the body it just killed and spend the whole chain on it.
	///
	/// ⛔ ITERATED DOWNWARD WITH A BOUNDS CHECK, NOT WITH `foreach`, FOR THE REASON
	/// `TechBlast.Detonate` RECORDS: the previous link's damage has already killed a zombie inside
	/// this same call stack, and `Die` → `StopBeingSolid` can end with the component disabled,
	/// which removes it from `All`. A `foreach` over a list something else is shortening is an
	/// exception waiting for a busy round; walking down means a removal only shifts entries
	/// already passed.
	/// </summary>
	static GameObject Nearest( GameObject from )
	{
		if ( !from.IsValid() ) return null;

		var origin = from.WorldPosition;
		var best = (GameObject)null;
		var bestDist = Reach * Reach;

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

			var z = ZombieAI.All[i];
			if ( !z.IsValid() ) continue;

			var go = z.GameObject;
			if ( !go.IsValid() || _chained.Contains( go ) ) 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 = go.Components.Get<Health>( FindMode.EverythingInSelf );
			if ( hp is null || hp.IsDead ) continue;

			var d = go.WorldPosition.DistanceSquared( origin );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = go;
		}

		return best;
	}

	/// <summary>`nz_bouncy [step] [reach]` — how hard each link hits, and how far it will look.</summary>
	[ConCmd( "nz_bouncy" )]
	public static void Cmd( float step = -1f, float reach = -1f )
	{
		if ( step > 0f ) Step = step;
		if ( reach > 0f ) Reach = reach;

		Log.Info( $"[nz-bouncy] x{Step:0.##} per link, looks {Reach:0} units for the next" );
		Log.Info( "[nz-bouncy]   links are capped by the weapon's PENETRATION — shotgun 2, rifle 5,"
			+ " sniper 10" );
		Log.Info( $"[nz-bouncy]   so a sniper's last link hits x{MathF.Pow( Step, 10 ):0} and clears"
			+ " eleven zombies with one round" );
		Log.Info( "[nz-bouncy]   host-only for now: a client's relayed hit carries no weapon, so"
			+ " the host cannot see the tech (same gap as Adrenaline Rounds)" );
	}
}