Weapons/Bleeder.cs

Bleeder and Bleeding gameplay code. Bleeder is a static utility that configures and starts bleed effects on zombies when hit, including upgrade-adjusted duration, rate, stacks and status tint. Bleeding is a Component attached to a zombie that tracks a list of bleed stacks, pays out damage over time in timed intervals, enforces stack caps and removes oldest stacks when full.

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

namespace NZombies;

/// <summary>
/// Bleeder — every hit bleeds the zombie for that hit's damage again, over 8 seconds.
///
/// | | value |
/// |---|---|
/// | proc | **every bullet hit**, no roll, no cooldown |
/// | bleed | **100%** of the hit's damage, paid over **8s**; **4s** at II Fast Bleed, **200%** at III Deep Bleed, **1s** at V Exsanguinate |
/// | pay | every **0.5s**; every **0.25s** while an Exsanguinate bleed is on the zombie |
/// | stacks | **5** at once; a 6th replaces the oldest; **8** at I Deep Cuts, **12** at IV Lacerate |
///
/// ⚠️ THE USER'S WORDS (2026-10-04): *"always apply on hit, no cooldown, deals 100% of the damage it took over 8 seconds,
/// stacks 5 times"*, and for a 6th, *"replace the oldest"*.
///
/// ⛔ HOST-SIDE, LIKE PERFORATOR'S BLEED, AND FOR ITS REASON: the zombie's health is the host's, and "that hit's damage"
/// is only final there — after the head and limb multipliers, the perks, the burn. It starts in `Health.OnDamage` below
/// the client relay, where the hit's mod is known on either machine (`Health.LastMod`: the host's own gun, or the mod a
/// client's hit carried, `NZNet.HurtRemote`).
///
/// ⚠️ UNPAID TICKS (`Health.ApplyUnpaid`), as Perforator's are: the hit that started a bleed already paid its points.
///
/// ⚠️ THE RED TINT IS A STATUS (`bleed`) WITH NO DAMAGE OF ITS OWN. The damage is here, where the stacks are; the status is
/// only the look, and it relays so every machine sees it. While a bleed is refreshed only the host's tint is extended — a
/// zombie bled for longer than 8s by one stream of fire can lose the tint on other screens while it still bleeds.
/// ⚠️ THAT WINDOW IS ONE BLEED'S LENGTH (2026-10-06): 4s with II, and only **1s** with V, so on screens other than the host's
/// an unbroken stream of Exsanguinate fire shows the red for its first second. Only a NEW status travels (`StatusEffects.Apply`),
/// and relaying every refresh would be a message per bullet per zombie. The damage is unaffected.
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"): I Deep Cuts, 8 stacks (`DeepCutsStacks`); II Fast Bleed,
/// the same total in 4s (`FastSeconds`); III Deep Bleed, 200% of the hit (`DeepShare`) — the user: *"change III to make it
/// so bleed does double the damage"*. With II, a bleed is 200% of the hit in 4s. Each is the SHOOTER'S level, read on the
/// host when a bleed starts and kept by that bleed (`Start`).
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 23:28: *"V, bleed deals it's full damage in 1
/// second"*). IV Lacerate, 12 stacks (`LacerateStacks`). V Exsanguinate, the same total in 1s (`ExsanguinateSeconds`): III's 200%
/// four times faster than II, so nearly all of it lands before the zombie dies; paid every 0.25s (`ExsanguinateInterval`), since a
/// 1s bleed paid every 0.5s is two lumps. The SHOOTER'S level again, read on the host when a bleed starts and kept by that
/// bleed. Bleeds at once equal hits a second, so only guns above 720 rpm reach IV's 12.
/// </summary>
public static class Bleeder
{
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static float? _share;
	/// <summary>The share of the hit each bleed deals in total. 1 — 100%.</summary>
	public static float Share { get => _share ?? 1f; set => _share = value; }

	static float? _seconds;
	/// <summary>How long one bleed takes to pay out. 8s.</summary>
	public static float Seconds { get => _seconds ?? 8f; set => _seconds = value; }

	static int? _stacks;
	/// <summary>How many bleeds one zombie can carry. 5.</summary>
	public static int MaxStacks { get => _stacks ?? 5; set => _stacks = value; }

	static float? _interval;
	/// <summary>How often the bleeds pay. 0.5s.</summary>
	public static float Interval { get => _interval ?? 0.5f; set => _interval = value; }

	// ── the upgrades (2026-10-05) ────────────────────────────────────────────
	//
	// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and `StacksFor`, `IntervalFor`, `SecondsFor` and `ShareFor`
	// are the only places that choose between them (§3).

	/// <summary>The mod's id, for its upgrade level (`AmmoModUpgrades.Level`).</summary>
	const string ModId = "bleeder";

	static int? _deepCutsStacks;
	/// <summary>How many bleeds one zombie can carry with I, Deep Cuts. 8 (5).</summary>
	public static int DeepCutsStacks { get => _deepCutsStacks ?? 8; set => _deepCutsStacks = value; }

	static float? _fastSeconds;
	/// <summary>How long one bleed takes with II, Fast Bleed. 4s, the same total: every tick twice the size (8s).</summary>
	public static float FastSeconds { get => _fastSeconds ?? 4f; set => _fastSeconds = value; }

	static float? _deepShare;
	/// <summary>The share of the hit each bleed deals with III, Deep Bleed. 2 — 200% (100%).</summary>
	public static float DeepShare { get => _deepShare ?? 2f; set => _deepShare = value; }

	// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ─────────
	//
	// ⚠️ EACH ITS OWN TUNABLE BESIDE THE ONE IT REPLACES (§3), NULLABLE-BACKED (§1). The helpers ask the higher level first.

	static int? _lacerateStacks;
	/// <summary>How many bleeds one zombie can carry with IV, Lacerate. 12 (I's 8).</summary>
	public static int LacerateStacks { get => _lacerateStacks ?? 12; set => _lacerateStacks = value; }

	static float? _exsanguinateSeconds;
	/// <summary>How long one bleed takes with V, Exsanguinate. 1s, the same total: every tick four times II's (4s).</summary>
	public static float ExsanguinateSeconds { get => _exsanguinateSeconds ?? 1f; set => _exsanguinateSeconds = value; }

	static float? _exsanguinateInterval;
	/// <summary>
	/// How often a zombie's bleeds pay while an Exsanguinate bleed (V) is among them. 0.25s (<see cref="Interval"/>, 0.5).
	///
	/// ⚠️ A 1s BLEED PAID EVERY 0.5s IS TWO LUMPS (`AMMO_MODS.md`'s building note), so it pays four times. Each payment is for the
	/// time that passed (`Bleeding.OnUpdate`), so the other bleeds on that zombie pay the same totals in smaller pieces.
	/// </summary>
	public static float ExsanguinateInterval { get => _exsanguinateInterval ?? 0.25f; set => _exsanguinateInterval = value; }

	/// <summary>The level a bleed keeps: its shooter's, 0 to `AmmoModUpgrades.MaxLevel`, read as it starts (`Start`). No player is 0.</summary>
	public static int LevelOf( NZPlayer player ) => AmmoModUpgrades.Level( player, ModId );

	/// <summary>
	/// How many bleeds a zombie may carry, by the HIGHEST level among its bleeds, the new one included (`Bleeding.Add`):
	/// <see cref="LacerateStacks"/> from IV, <see cref="DeepCutsStacks"/> from I, else <see cref="MaxStacks"/>.
	///
	/// ⚠️ A LEVEL, NOT A PLAYER (2026-10-06; a Deep Cuts flag until IV gave the cap a third step): the cap belongs to the zombie's
	/// stacks, which every shooter shares, so each bleed keeps the level it began with and the zombie answers with the highest.
	/// The numbers themselves are read live.
	/// </summary>
	public static int StacksFor( int level ) => level >= 4 ? LacerateStacks : level >= 1 ? DeepCutsStacks : MaxStacks;

	/// <summary>
	/// How often a zombie's bleeds pay, by the HIGHEST level among them (`Bleeding.OnUpdate`): <see cref="ExsanguinateInterval"/>
	/// with an Exsanguinate bleed (V) among them, else <see cref="Interval"/>. A level for `StacksFor`'s reason.
	/// </summary>
	public static float IntervalFor( int level ) => level >= 5 ? ExsanguinateInterval : Interval;

	/// <summary>
	/// How long this player's bleed takes: <see cref="ExsanguinateSeconds"/> at level V, <see cref="FastSeconds"/> from II, else
	/// <see cref="Seconds"/>.
	/// </summary>
	public static float SecondsFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 5 ) ? ExsanguinateSeconds
			: AmmoModUpgrades.Has( player, ModId, 2 ) ? FastSeconds
			: Seconds;

	/// <summary>
	/// The share of the hit this player's bleed deals: <see cref="DeepShare"/> at level III, else <see cref="Share"/>.
	/// </summary>
	public static float ShareFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 3 ) ? DeepShare : Share;

	/// <summary>The status that is the bleed's look. Red, no light, no damage.</summary>
	public const string Status = "bleed";

	/// <summary>A Bleeder bullet hit this zombie for <paramref name="amount"/>. HOST — `Health.OnDamage`.</summary>
	public static void Start( Health hp, float amount, GameObject attacker )
	{
		if ( !hp.IsValid() || hp.IsDead || amount <= 0f ) return;

		// ⛔ THE SHOOTER'S LEVEL, READ HERE ON THE HOST AND KEPT BY THIS BLEED (2026-10-05). `attacker` is the shooter's
		// body — the host's own, or the one a client's hit names (`NZNet.HurtRemote`), whose levels the host reads off the
		// wire (`NZPlayer.AmmoUpgradeNet`). The share, the length and the level (which sets the zombie's cap and pay, `Bleeding`)
		// go into the stack as it is made, so a level bought mid-bleed changes the next bleed, never one already running. No
		// player found reads as level 0: the bleed as it was.
		var player = attacker.IsValid() ? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;

		var total = amount * MathF.Max( 0f, ShareFor( player ) );
		if ( total <= 0f ) return;

		var seconds = MathF.Max( 0.5f, SecondsFor( player ) );

		var bleed = hp.Components.GetOrCreate<Bleeding>();
		if ( !bleed.IsValid() ) return;

		bleed.Add( total, seconds, attacker, LevelOf( player ) );

		// ⚠️ THE TINT RUNS AS LONG AS THIS BLEED: 4s with Fast Bleed, 1s with Exsanguinate. A refresh never shortens it
		// (`StatusEffects` keeps the later expiry), so a 4s bleed landing on an 8s one leaves the 8s.
		StatusEffects.Apply( hp.GameObject, Status, attacker, seconds: seconds );
	}

	/// <summary>
	/// `nz_bleeder [share] [seconds] [stacks] [share III] [seconds II] [stacks I] [stacks IV] [seconds V] [pay V]` — the
	/// resolved numbers, and retune them live; the last six are the upgrades' (IV and V's three since 2026-10-06).
	/// </summary>
	[ConCmd( "nz_bleeder" )]
	public static void Cmd( float share = -1f, float seconds = -1f, int stacks = -1,
		float deepShare = -1f, float fastSeconds = -1f, int deepStacks = -1,
		int lacerateStacks = -1, float exsanguinateSeconds = -1f, float exsanguinateInterval = -1f )
	{
		if ( share >= 0f ) Share = share;
		if ( seconds > 0f ) Seconds = seconds;
		if ( stacks > 0 ) MaxStacks = stacks;
		if ( deepShare >= 0f ) DeepShare = deepShare;
		if ( fastSeconds > 0f ) FastSeconds = fastSeconds;
		if ( deepStacks > 0 ) DeepCutsStacks = deepStacks;
		if ( lacerateStacks > 0 ) LacerateStacks = lacerateStacks;
		if ( exsanguinateSeconds > 0f ) ExsanguinateSeconds = exsanguinateSeconds;
		if ( exsanguinateInterval > 0f ) ExsanguinateInterval = exsanguinateInterval;

		var bleeding = 0;
		var most = 0;
		foreach ( var b in Game.ActiveScene?.GetAllComponents<Bleeding>() ?? Array.Empty<Bleeding>() )
		{
			if ( !b.IsValid() || b.Count <= 0 ) continue;

			bleeding++;
			most = Math.Max( most, b.Count );
		}

		// ⚠️ THE MOST STACKS ON ONE ZOMBIE (2026-10-05): Deep Cuts' 8 and Lacerate's 12 show here, and nowhere else.
		Log.Info( $"[nz-ammo] BLEEDER · every bullet hit bleeds {Share * 100f:0.#}% of its damage over {Seconds:0.#}s"
			+ $" · {MaxStacks} stacks, a new one replaces the oldest · pays every {Interval:0.##}s, unpaid"
			+ $" · {bleeding} zombie(s) bleeding, at most {most} stacks (host only)" );

		// ⚠️ THE UPGRADES (2026-10-05; IV and V 2026-10-06), and where your own level puts them.
		var me = NZPlayer.Local;

		Log.Info( $"[nz-ammo]   upgrades: I {DeepCutsStacks} stacks · II {FastSeconds:0.#}s · III {DeepShare * 100f:0.#}%"
			+ $" · IV {LacerateStacks} stacks · V {ExsanguinateSeconds:0.##}s, paid every {ExsanguinateInterval:0.##}s"
			+ (me.IsValid()
				? $" · yours at level {AmmoModUpgrades.Level( me, ModId )}:"
					+ $" {ShareFor( me ) * 100f:0.#}% over {SecondsFor( me ):0.##}s, {StacksFor( LevelOf( me ) )} stacks,"
					+ $" paid every {IntervalFor( LevelOf( me ) ):0.##}s"
				: "") );
	}
}

/// <summary>One zombie's bleeds. Created on demand by <see cref="Bleeder.Start"/>, on the host.</summary>
public sealed class Bleeding : Component
{
	struct Stack
	{
		public float PerSecond;
		public float Left;
		public float Born;

		/// <summary>
		/// Its shooter's Bleeder level when it began (`Bleeder.LevelOf`; a Deep Cuts flag until 2026-10-06): the zombie's cap and
		/// pay go by the highest among its bleeds. See `Add`.
		/// </summary>
		public int Level;
	}

	readonly List<Stack> _stacks = new();

	/// <summary>
	/// Who bled it last. ⚠️ ONE FIELD, NOT ONE PER STACK, for Perforator's reason: `Health.LastAttacker` is a single field
	/// and a kill pays one player, so the most recent shooter is the answer every damage source here gives.
	/// </summary>
	GameObject _from;

	float _since;

	/// <summary>The bleeds on this zombie right now.</summary>
	public int Count => _stacks.Count;

	/// <summary>
	/// The highest level among the bleeds on it now, 0 with none: what its cap and its pay go by (`Bleeder.StacksFor`,
	/// `Bleeder.IntervalFor`). Twelve bleeds at most to look through.
	/// </summary>
	int Top
	{
		get
		{
			var top = 0;
			foreach ( var s in _stacks ) top = Math.Max( top, s.Level );
			return top;
		}
	}

	/// <summary>
	/// One more bleed: <paramref name="total"/> over <paramref name="seconds"/>, and its shooter's Bleeder level.
	///
	/// ⚠️ DEEP CUTS (I, 2026-10-05) RAISES THE CAP WHILE ANY OF ITS BLEEDS IS ON THE ZOMBIE, the new one or one already
	/// there. The stacks are the zombie's, shared by every shooter, so a cap taken from the new bleed alone would let a
	/// teammate without the upgrade trim an upgraded player's eight back to five with every hit. With no Deep Cuts bleed on
	/// it, the cap is `MaxStacks`, read live, as it always was.
	///
	/// ⚠️ AND LACERATE (IV, 2026-10-06) THE SAME WAY: each bleed keeps its shooter's level, and the highest among them, the new
	/// one included, sets the cap (`Bleeder.StacksFor`), so a level-IV player's bleeds hold the zombie at 12 against a
	/// teammate's hits. A flag per bleed could only say "I or not"; a level says which step.
	/// </summary>
	public void Add( float total, float seconds, GameObject from, int level )
	{
		if ( total <= 0f || seconds <= 0f ) return;

		// ⚠️ FULL: THE OLDEST GOES, the user's rule. Oldest by when it began, not by what it has left — a retune of the
		// length mid-fight would otherwise make "oldest" mean something else.
		var cap = Math.Max( 1, Bleeder.StacksFor( Math.Max( level, Top ) ) );
		while ( _stacks.Count >= cap )
		{
			var oldest = 0;
			for ( var i = 1; i < _stacks.Count; i++ )
				if ( _stacks[i].Born < _stacks[oldest].Born ) oldest = i;

			_stacks.RemoveAt( oldest );
		}

		_stacks.Add( new Stack { PerSecond = total / seconds, Left = seconds, Born = Time.Now, Level = level } );

		if ( from.IsValid() ) _from = from;
		Enabled = true;
	}

	protected override void OnUpdate()
	{
		if ( _stacks.Count == 0 )
		{
			// ⚠️ IT SLEEPS RATHER THAN BEING DESTROYED, as `Perforation` does: a zombie bled once is shot again.
			Enabled = false;
			return;
		}

		_since += Time.Delta;

		// ⚠️ FASTER WHILE AN EXSANGUINATE BLEED IS ON IT (V, 2026-10-06): its 1s is four payments, not two lumps. Each payment is
		// for the time that passed (below), so the other bleeds pay the same totals in smaller pieces.
		if ( _since < MathF.Max( 0.05f, Bleeder.IntervalFor( Top ) ) ) return;

		// ⚠️ PAID FOR THE TIME THAT PASSED, so every bleed pays exactly its total over its life, whatever the frame rate.
		var dt = _since;
		_since = 0f;

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

		var total = 0f;

		for ( var i = _stacks.Count - 1; i >= 0; i-- )
		{
			var s = _stacks[i];
			var ran = MathF.Min( dt, s.Left );

			total += s.PerSecond * ran;
			s.Left -= ran;

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

		// ⛔ UNPAID — the hit that started each bleed already paid its points (see `Bleeder`).
		if ( total > 0f ) hp.ApplyUnpaid( total, _from );
	}
}