Weapons/Perforator.cs

Perforator weapon node and a component that applies delayed "bleed" damage to zombies. Perforator.Absorb splits a bullet's damage into an immediate tick and a queued per-tick pool stored on the victim's Perforation component, which drains over time and applies unpaid damage to the host-side Health component.

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

namespace NZombies;

/// <summary>
/// PERFORATOR (`t5_perforator`) — the round does not hit, it bleeds. Four times the damage,
/// paid out over a second, and every shot adds another second on top of the last.
///
/// ⛔ x4 IS NOT A x4 DAMAGE NODE, AND THE DIFFERENCE IS THE ENTIRE DESIGN. Damage delivered a
/// second late is damage the zombie gets to keep walking on: a shot that would have stopped
/// something at arm's length now stops it a step and a half further in. Against a sprinter in a
/// corridor that is the difference between a kill and a hit, and it is why the multiplier can be
/// this large without the node simply being the best in its tier.
///
/// ⚠️ IT ALSO WASTES EVERY POINT OF OVERKILL, TWICE OVER. A pool of 4x on a zombie that needed
/// 1.2x is 2.8x that drains into a corpse — and unlike an instant hit, the player cannot see that
/// it happened and re-aim. The node rewards firing exactly enough into exactly one thing, which is
/// the opposite of what tier 5's crowd nodes reward.
///
/// ⚠️ STACKING IS ADDITIVE AND PER SHOT, so sustained fire converges on 4x the weapon's DPS with
/// a one-second lag rather than compounding into anything. That is the honest reading of "stacking
/// with each shot" and it is also the only reading that does not turn a 900rpm SMG into an
/// exponential.
///
/// ⛔ THE FIRST TICK LANDS INSTANTLY, WHICH IS NOT A COMPROMISE — IT IS THE POINTS ECONOMY. Every
/// hit in this game pays a per-hit drip through `ZombieAI.OnHurt`, fired by `Health.OnDamaged`;
/// a bullet that applied NOTHING at the moment it struck would fire nothing, and the player would
/// shoot a horde for no points at all. One tenth arriving immediately through the normal path
/// pays exactly once, flinches once, and leaves the other nine tenths to this component — which
/// pays for none of them, for the same reason: one trigger pull, one award.
///
/// ⚠️ ONE `Apply` PER TICK NO MATTER HOW MANY STACKS. The queue is a list of (per-tick, ticks
/// remaining) entries and the tick sums them, so a 900rpm weapon holding fifteen overlapping
/// stacks still costs ten damage calls a second on that zombie, not a hundred and fifty.
///
/// ⚠️ HOST-SIDE BY CONSTRUCTION. It is created from `Health.OnDamage` below the client relay, so
/// it only ever exists on the machine that owns the zombie — the same place the damage it replaces
/// would have landed. Nothing here is networked because nothing here needs to be: the health it
/// drains already replicates.
/// </summary>
public static class Perforator
{
	/// <summary>How long one shot's pool takes to drain, in seconds. 1.</summary>
	public static float Seconds { get => _seconds ?? 1f; set => _seconds = value; }
	static float? _seconds;

	/// <summary>
	/// How many payments one shot is split into. 10.
	///
	/// ⛔ TEN, BECAUSE THE FIRST ONE IS THE HIT. At 10 ticks a second the instant tenth that pays
	/// the points drip is a tenth of the pool — small enough that the node still reads as "nothing
	/// happened yet", large enough to flinch the zombie and register as a hit. At 2 ticks the
	/// "instant" half would make it an ordinary x2 weapon with a tail; at 60 the drip-paying first
	/// tick would be invisible and so would the hit.
	/// </summary>
	public static int Ticks { get => _ticks ?? 10; set => _ticks = value; }
	static int? _ticks;

	/// <summary>
	/// How many separate shots one zombie may be bleeding from. 64.
	///
	/// ⚠️ A BOUND, NOT A BALANCE LEVER, and it is unreachable in play: sixty-four overlapping
	/// stacks needs 3840rpm sustained into one body. It exists because the list is walked every
	/// tick and an unbounded list on the damage path is how a frame budget disappears.
	/// </summary>
	public const int MaxStacks = 64;

	/// <summary>
	/// A perforating round just struck. Returns what the bullet should deal RIGHT NOW; the rest is
	/// queued on the victim.
	/// </summary>
	/// <param name="amount">The bullet's fully scaled damage, as it would have been dealt.</param>
	/// <param name="factor">The node's multiplier — 4.</param>
	public static float Absorb( Health hp, float amount, float factor, GameObject attacker )
	{
		if ( !hp.IsValid() || amount <= 0f || factor <= 0f ) return amount;

		var ticks = Math.Max( 1, Ticks );
		var perTick = amount * factor / ticks;

		// ⚠️ ONE TICK STAYS BEHIND. See the header — this is the payment that keeps the hit a hit.
		if ( ticks == 1 ) return perTick;

		var bleed = hp.Components.GetOrCreate<Perforation>();
		if ( bleed.IsValid() )
			bleed.Add( perTick, ticks - 1, attacker, Seconds / ticks );

		return perTick;
	}

	/// <summary>
	/// `nz_perforator [seconds] [ticks]` — how the pool drains, and what it is worth.
	/// </summary>
	[ConCmd( "nz_perforator" )]
	public static void Cmd( float seconds = -1f, int ticks = -1 )
	{
		if ( seconds > 0f ) Seconds = seconds;
		if ( ticks > 0 ) Ticks = ticks;

		var node = WeaponTech.Find( "t5_perforator" );
		var factor = node?.Factor ?? 0f;

		Log.Info( $"[nz-perforator] x{factor:0.##} total over {Seconds:0.##}s in {Ticks} ticks" );
		Log.Info( $"[nz-perforator]   the hit itself deals x{factor / Math.Max( 1, Ticks ):0.###};"
			+ " the other x" + $"{factor * (Ticks - 1) / Math.Max( 1, Ticks ):0.##} bleeds out" );
		Log.Info( "[nz-perforator]   stacks add, so sustained fire is x" + $"{factor:0.##}"
			+ " the DPS with a one-second lag" );
	}
}

/// <summary>
/// One zombie's outstanding bleed. Created on demand by <see cref="Perforator.Absorb"/>.
/// </summary>
public sealed class Perforation : Component
{
	/// <summary>One shot's remaining payments.</summary>
	struct Bleed
	{
		public float PerTick;
		public int Left;
	}

	readonly List<Bleed> _stacks = new();

	/// <summary>
	/// Who fired. ⚠️ KEPT SO THE KILL IS ATTRIBUTED, and overwritten by the most recent shooter
	/// rather than tracked per stack: `Health.LastAttacker` is a single field, kill points pay one
	/// player, and "whoever shot it last" is both the closest answer available and the one every
	/// other damage source in this game already gives.
	/// </summary>
	GameObject _from;

	float _interval = 0.1f;
	float _since;

	/// <summary>The stacks on this zombie right now, for `nz_perforator`.</summary>
	public int Count => _stacks.Count;

	public void Add( float perTick, int ticks, GameObject from, float interval )
	{
		if ( perTick <= 0f || ticks < 1 ) return;

		if ( _stacks.Count >= Perforator.MaxStacks ) return;

		if ( from.IsValid() ) _from = from;
		if ( interval > 0f ) _interval = interval;

		_stacks.Add( new Bleed { PerTick = perTick, Left = ticks } );
		Enabled = true;
	}

	protected override void OnUpdate()
	{
		if ( _stacks.Count == 0 )
		{
			// ⚠️ THE COMPONENT SLEEPS RATHER THAN BEING DESTROYED. A zombie that has been shot
			// once will be shot again, and `GetOrCreate` on the next bullet is cheaper than a
			// destroy-and-recreate per magazine.
			Enabled = false;
			return;
		}

		_since += Time.Delta;
		if ( _since < _interval ) return;

		// ⚠️ ONE INTERVAL PER FRAME AT MOST, NOT A CATCH-UP LOOP. A hitch must not dump four
		// ticks of a pool into one frame — the whole node is that the damage arrives LATE, and a
		// stall that made it arrive all at once would hand the player back exactly what they paid
		// for. The pool is not lost, only stretched.
		_since = 0f;

		var hp = Components.Get<Health>( FindMode.EverythingInSelf );
		if ( !hp.IsValid() || hp.IsDead )
		{
			_stacks.Clear();
			Enabled = false;
			return;
		}

		var total = 0f;

		// ⚠️ WALKED DOWNWARD SO A REMOVAL DOES NOT SKIP THE NEXT ENTRY. Nothing outside this
		// method touches the list, so this is the ordinary reason rather than the mid-iteration
		// death hazard `BouncyRounds.Nearest` guards against.
		for ( var i = _stacks.Count - 1; i >= 0; i-- )
		{
			var s = _stacks[i];
			total += s.PerTick;

			if ( --s.Left <= 0 ) _stacks.RemoveAt( i );
			else _stacks[i] = s;
		}

		if ( total <= 0f ) return;

		// ⛔ UNPAID, AND THAT IS THE POINT OF THE METHOD. The trigger pull that started this bleed
		// has already been paid for by its instant tenth; paying again on every tick would turn a
		// damage node into a points engine worth ten times an ordinary hit.
		//
		// ⛔ AND STRAIGHT TO `Apply`, NOT THROUGH `OnDamage`, for the reason the status ticks in
		// `StatusEffects` record: `OnDamage` is the path that APPLIES statuses and scales damage by
		// the attacker's perks, so re-entering it would re-scale a figure that is already scaled —
		// and, here, would perforate the bleed itself, forever.
		hp.ApplyUnpaid( total, _from );
	}
}