Weapons/Bloodhound.cs

A static game rule module implementing the "Bloodhound" ammo mod for zombies. It tracks per-player hit counts on zombies, applies a persistent "bloodhound" status mark after a configurable number of hits (with upgrades that change hits required, apply additional status companions, spread to nearby zombies, and pass the mark at death), and logs / plays sounds for events.

ReflectionFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Bloodhound — five hits on one zombie outline it, and it takes double damage from every player until it dies.
///
/// | | value |
/// |---|---|
/// | proc | **always**, no cooldown |
/// | trigger | your **5th hit** on one zombie, any spacing, any order; the **3rd** at I Quick Scent |
/// | mark | an outline every player sees, and **×2 damage taken from every source**, until it dies; **×3** at II Open Wound, **×4** at IV Mortal Wound |
/// | III Pack Hunt | the mark also lands on the **2** zombies nearest it that are not marked yet, anywhere |
/// | V Blood Trail | a marked zombie that dies passes its mark to the **nearest** unmarked zombie, anywhere, with the owner's Open Wound and Mortal Wound |
///
/// ⚠️ THE USER'S WORDS (2026-10-04): *"hitting a zombie 5 times outlines it and makes it take 3x damage from all players
/// including myself"*, and *"the zombie does not need to be hit 5 times in a row, just 5 times in general"*.
///
/// ⚠️ ×2 SINCE 2026-10-06, ×3 BEFORE, and Open Wound ×3 where it was ×4. The user, deciding the upgrades' tiers IV and V:
/// *"bloodhound is really good already, so we could do this / base makes it 2x / II makes it 3x / IV makes it 4X"*. The figures
/// are `StatusEffects.MarkTaken` and `WoundTaken`.
///
/// ⚠️ THE MARK IS A STATUS (`bloodhound`), so it relays to every machine, its ×2 is the rule's `Vulnerability` (applied
/// where the host scales every hit), and the outline goes on in `ClassTech.OnStatusAdded` beside Spotter's and Marker's.
/// It is PERMANENT, and `ZombieAI.Die` clears it with the rest of the corpse's statuses.
///
/// ⚠️ THE COUNT IS THE SHOOTER'S OWN, ON THE SHOOTER'S MACHINE. Only your hits count toward your five: `AmmoMods.OnZombieHit`
/// returns before this for anybody whose gun is not on this machine, so each machine counts its own player's hits alone.
///
/// ⚠️ BULLETS ONLY, AND ONE PER TRIGGER PULL. A shotgun's pellets land in one frame and count once; a blast from the same
/// gun (Explosive Rounds) is not a hit for this. Penetration counts once per zombie it passes through.
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"; the user, 01:08: *"perfect!"*): I Quick Scent, 3 hits mark
/// (`QuickHits`); II Open Wound, ×3 in place of ×2 (`openwound`, a companion beside the mark, as permanent as it); III Pack
/// Hunt, the mark also lands on the 2 zombies nearest the first that are not marked yet — the full mark, the team's outline
/// and the ×2 or ×3 (`PackSize`). Each is the SHOOTER'S level, read on the machine that counts (<see cref="OnHit"/>, and
/// <see cref="Mark"/> for the companions).
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 23:06: *"bloodhound is really good already, so we
/// could do this / base makes it 2x / II makes it 3x / IV makes it 4X / and V makes it as you said"*). IV Mortal Wound, ×4 in
/// place of ×3 (`mortalwound`, a third companion beside the mark and Open Wound, as permanent as they are): the SHOOTER'S level,
/// put on with the others in <see cref="Mark"/>. V Blood Trail: when a marked zombie dies, its mark jumps to the nearest
/// unmarked zombie — no reach, as Pack Hunt has none — carrying the owner's Open Wound and Mortal Wound. That is the mark
/// OWNER'S level, read on the HOST at the death (<see cref="OnMarkedDeath"/>, from `AmmoModDeaths`), whoever made the kill. It
/// needs a death each time, so it never runs by itself.
/// </summary>
public static class Bloodhound
{
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static int? _hits;
	/// <summary>Hits that mark a zombie. 5.</summary>
	public static int Hits { get => _hits ?? 5; set => _hits = value; }

	/// <summary>The status that is the mark. Its ×2 lives on the rule (`StatusEffects.MarkTaken`), retuned with `nz_status`.</summary>
	public const string Status = "bloodhound";

	// ── the upgrades (2026-10-05) ────────────────────────────────────────────
	//
	// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and the `…For` below alone choose between them (§3).
	// Open Wound's ×3 is a status rule's (`StatusEffects`, `openwound`), as the mark's ×2 is.

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

	static int? _quickHits;
	/// <summary>Hits that mark a zombie with I, Quick Scent. 3 (5).</summary>
	public static int QuickHits { get => _quickHits ?? 3; set => _quickHits = value; }

	static int? _packSize;
	/// <summary>How many more zombies a mark lands on with III, Pack Hunt. 2.</summary>
	public static int PackSize { get => _packSize ?? 2; set => _packSize = value; }

	// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ─────────
	//
	// ⚠️ Mortal Wound's ×4 is a status rule's (`StatusEffects`, `mortalwound`), as Open Wound's ×3 is. Blood Trail's one number is
	// its own tunable, nullable-backed (§1), read through `TrailFor` alone (§3).

	static int? _trailSize;
	/// <summary>
	/// How many zombies a dying marked zombie's mark jumps to with V, Blood Trail. 1 — *"its mark jumps to the nearest unmarked
	/// zombie"* (`AMMO_MODS.md`).
	/// </summary>
	public static int TrailSize { get => _trailSize ?? 1; set => _trailSize = value; }

	/// <summary>Hits that mark a zombie for this player: <see cref="QuickHits"/> from level I, <see cref="Hits"/> below it.</summary>
	public static int HitsFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 1 ) ? QuickHits : Hits;

	/// <summary>How many more zombies this player's mark lands on: <see cref="PackSize"/> at level III, none below it.</summary>
	public static int PackFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 3 ) ? PackSize : 0;

	/// <summary>Does this player's mark carry II's Open Wound (2026-10-05).</summary>
	public static bool WoundFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 2 );

	/// <summary>Does this player's mark carry IV's Mortal Wound (2026-10-06). IV has II, so never without Open Wound.</summary>
	public static bool MortalFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 4 );

	/// <summary>How many zombies this player's mark jumps to when its zombie dies: <see cref="TrailSize"/> at level V, none below it.</summary>
	public static int TrailFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 5 ) ? TrailSize : 0;

	/// <summary>II's companion beside the mark: its share of the ×3 is on the rule (`StatusEffects`).</summary>
	public const string Wound = "openwound";

	/// <summary>
	/// IV's companion beside the mark and Open Wound (2026-10-06, Mortal Wound): its share of the ×4 is on the rule (`StatusEffects`,
	/// `mortalwound`), permanent as the mark is.
	/// </summary>
	public const string Mortal = "mortalwound";

	/// <summary>What a zombie this player marks takes, read back from the rules as the logs print it: ×2, ×3 from II, ×4 from IV.</summary>
	static float TakenFor( NZPlayer player )
	{
		var rules = StatusEffects.Rules;
		var taken = rules.TryGetValue( Status, out var m ) ? m.Vulnerability : 1f;

		if ( WoundFor( player ) && rules.TryGetValue( Wound, out var w ) )
			taken *= w.Vulnerability;

		// ⚠️ MORTAL WOUND (IV, 2026-10-06) multiplies over Open Wound, as its rule divides by Open Wound's: ×2 × 1.5 × 4/3 = ×4.
		if ( MortalFor( player ) && rules.TryGetValue( Mortal, out var mw ) )
			taken *= mw.Vulnerability;

		return taken;
	}

	/// <summary>
	/// This machine's player's hits so far, by zombie, and when the last one was counted.
	///
	/// ⚠️ A STATIC IS RIGHT HERE, NOT A BUG WAITING FOR A SECOND PLAYER: every machine counts only its own player's hits
	/// (see the header). A dead or missing zombie's entry is dropped by `Prune`.
	/// </summary>
	static readonly Dictionary<Guid, (int Count, float At)> _counts = new();

	/// <summary>A bullet from a Bloodhound gun hit this zombie. The shooter's machine.</summary>
	public static void OnHit( NZPlayer player, GameObject zombie )
	{
		if ( !player.IsValid() || !zombie.IsValid() ) return;
		if ( StatusEffects.Has( zombie, Status ) ) return;

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

		_counts.TryGetValue( zombie.Id, out var c );

		// ⚠️ ONE COUNT PER TRIGGER PULL: every pellet of a blast arrives in the same frame, so the same `Time.Now`.
		var now = Time.Now;
		if ( c.Count > 0 && c.At == now ) return;

		c = (c.Count + 1, now);

		// ⚠️ THE SHOOTER'S LEVEL, READ HERE (2026-10-05), on the machine that counts: this player's hits, this player's mark.
		var hits = Math.Max( 1, HitsFor( player ) );

		if ( c.Count < hits )
		{
			_counts[zombie.Id] = c;
			if ( _counts.Count > 64 ) Prune();
			return;
		}

		_counts.Remove( zombie.Id );

		Mark( player, zombie );

		// ⚠️ PACK HUNT (III, 2026-10-05): the FULL mark on the zombies nearest the first that are not marked yet, Open Wound
		// included at level II (and Mortal Wound at IV, `Mark`). NO REACH: the design names none, and unlike Silk Shot's web a
		// mark moves nobody, so the nearest unmarked zombie is taken wherever it stands (`Unmarked`, Blood Trail's query too).
		var pack = Unmarked( zombie.WorldPosition, zombie, PackFor( player ) );

		foreach ( var go in pack )
		{
			Mark( player, go );
			_counts.Remove( go.Id );
		}

		// ⚠️ THE HELLHOUND'S BARK, once, at the zombie marked. The name asked for it. Once for a pack, too.
		Sound.Play( NZSound.HoundBark, zombie.WorldPosition + Vector3.Up * 40f );

		Log.Info( $"[nz-ammo] BLOODHOUND — {zombie.Name} marked after {hits} hits"
			+ (pack.Count > 0 ? $", and the {pack.Count} nearest it (pack hunt)" : "")
			+ $": ×{TakenFor( player ):0.##} damage from everyone until it dies" );
	}

	/// <summary>
	/// Put the mark on one zombie, with this player's companions beside it: Open Wound at level II (2026-10-05), Mortal Wound at
	/// IV (2026-10-06). The shooter's machine, or the host for Blood Trail; all three relay, so the host scales every hit by them
	/// and every machine draws the outline (`ClassTech.OnStatusAdded`).
	///
	/// ⛔ EVERY MARK IS PUT ON HERE (§3) — the count's, Pack Hunt's and Blood Trail's — and the levels are read here, so a mark
	/// carries its owner's companions wherever it lands, and no caller decides them.
	/// </summary>
	static void Mark( NZPlayer player, GameObject zombie )
	{
		StatusEffects.Apply( zombie, Status, player.GameObject );

		if ( WoundFor( player ) ) StatusEffects.Apply( zombie, Wound, player.GameObject );

		// ⚠️ MORTAL WOUND (IV) RIDES OVER OPEN WOUND, never without it: IV has II, and its rule carries ×4 ÷ ×3.
		if ( MortalFor( player ) ) StatusEffects.Apply( zombie, Mortal, player.GameObject );
	}

	/// <summary>
	/// The <paramref name="count"/> live zombies nearest <paramref name="at"/> that are not marked yet, <paramref name="skip"/> left
	/// out, nearest first. NO REACH (see Pack Hunt in <see cref="OnHit"/>). Pack Hunt's and Blood Trail's one query (§3).
	/// </summary>
	static List<GameObject> Unmarked( Vector3 at, GameObject skip, int count )
	{
		if ( count <= 0 ) return new List<GameObject>();

		return ZombieAI.All
			.Where( z => z.IsValid() && z.GameObject.IsValid() && z.GameObject != skip && z.State != ZombieState.Dead )
			.Where( z => !StatusEffects.Has( z.GameObject, Status ) )
			.OrderBy( z => at.Distance( z.WorldPosition ) )
			.Take( count )
			.Select( z => z.GameObject )
			.ToList();
	}

	/// <summary>
	/// V, BLOOD TRAIL (2026-10-06): a zombie died — if it wore the mark, the mark jumps on. THE HOST (or solo), from
	/// `AmmoModDeaths.Died`, before the corpse's statuses are cleared, so the mark and its owner (`StatusEffects.SourceOf`) can
	/// still be read off it.
	///
	/// ⚠️ THE MARK OWNER'S LEVEL, WHOEVER MADE THE KILL — a grenade, a bleed, a teammate. The owner is the mark's source, which a
	/// client's mark brings to the host with it (`NZNet.ZombieStatus`), so a client's marks trail too. A mark nobody put there
	/// (`nz_status bloodhound`) goes nowhere.
	///
	/// ⚠️ THE FULL MARK, AS PACK HUNT'S IS (`Mark`): the team's outline, the ×2, and the owner's Open Wound and Mortal Wound — the
	/// dead one's, since they were the owner's too (`AMMO_MODS.md`). Not Pack Hunt again: that is earned by the hits, and a passed
	/// mark is one mark.
	///
	/// ⚠️ THE NEAREST UNMARKED ZOMBIE, NO REACH (`Unmarked`, Pack Hunt's query). The new mark names the same owner, so it jumps
	/// again at the next death: a trail through the crowd that needs a death each time, so it never runs by itself. Bosses too:
	/// the mark has no boss rule. Nothing here deals damage, so it cannot re-enter itself.
	/// </summary>
	public static void OnMarkedDeath( ZombieAI zombie )
	{
		if ( !zombie.IsValid() || !zombie.GameObject.IsValid() ) return;

		var body = zombie.GameObject;
		if ( !StatusEffects.Has( body, Status ) ) return;

		var source = StatusEffects.SourceOf( body, Status );
		var owner = source.IsValid() ? source.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;

		// ⚠️ THE CORPSE IS LEFT OUT BY NAME (`skip`): `Die` has already set it dead, but it still answers `IsValid` on this frame.
		var trail = Unmarked( body.WorldPosition, body, TrailFor( owner ) );
		if ( trail.Count == 0 ) return;

		foreach ( var go in trail )
		{
			Mark( owner, go );
			_counts.Remove( go.Id );
		}

		// ⚠️ THE BARK, AT THE NEW MARK, FROM THE HOST TO EVERY MACHINE (`NZSound.PlayShared`): a death happens on the host alone, and
		// the owner may be a client. Once a frame, so a blast through a marked pack barks once, as a pack hunt does.
		if ( _trailCueAt != Time.Now )
		{
			_trailCueAt = Time.Now;
			NZSound.PlayShared( NZSound.HoundBark, trail[0].WorldPosition + Vector3.Up * 40f, SoundGate.Feedback );
		}

		Log.Info( $"[nz-ammo] BLOODHOUND V BLOOD TRAIL — {body.Name} died marked: the mark jumps to {trail[0].Name},"
			+ $" {body.WorldPosition.Distance( trail[0].WorldPosition ):0}u away"
			+ (trail.Count > 1 ? $", and to {trail.Count - 1} more" : "")
			+ $": ×{TakenFor( owner ):0.##} damage from everyone until it dies" );
	}

	/// <summary>
	/// When Blood Trail last barked. RUNTIME STATE, NOT A TUNABLE (§1 is about tunables): a timestamp for the once-a-frame rule,
	/// harmless if a hotload keeps it.
	/// </summary>
	static float _trailCueAt = -1f;

	/// <summary>Drop the counts of zombies that are gone or dead.</summary>
	static void Prune()
	{
		var scene = Game.ActiveScene;
		var gone = new List<Guid>();

		foreach ( var id in _counts.Keys )
		{
			var go = scene?.Directory.FindByGuid( id );
			var hp = go.IsValid() ? go.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors ) : null;
			if ( !hp.IsValid() || hp.IsDead ) gone.Add( id );
		}

		foreach ( var id in gone ) _counts.Remove( id );
	}

	/// <summary>`nz_bloodhound [hits]` — the resolved numbers, and how many zombies this machine is counting on.</summary>
	[ConCmd( "nz_bloodhound" )]
	public static void Cmd( int hits = -1 )
	{
		if ( hits > 0 ) Hits = hits;

		Prune();

		var vuln = StatusEffects.Rules.TryGetValue( Status, out var rule ) ? rule.Vulnerability : 1f;

		// ⚠️ HOW MANY WEAR THE MARK HERE (2026-10-06): the number Blood Trail keeps up as marked zombies die.
		var marked = ZombieAI.All.Count( z => z.IsValid() && z.GameObject.IsValid() && StatusEffects.Has( z.GameObject, Status ) );

		Log.Info( $"[nz-ammo] BLOODHOUND · {Hits} hits mark a zombie · ×{vuln:0.##} damage from every source until it dies"
			+ $" · counting on {_counts.Count} zombie(s) here · {marked} marked" );

		// ⚠️ THE UPGRADES THIS FILE READS (2026-10-05; IV and V 2026-10-06), and where your own level puts them.
		var wound = StatusEffects.Rules.TryGetValue( Wound, out var w ) ? w.Vulnerability : 1f;
		var mortal = StatusEffects.Rules.TryGetValue( Mortal, out var mw ) ? mw.Vulnerability : 1f;
		var me = NZPlayer.Local;

		Log.Info( $"[nz-ammo]   upgrades: I {QuickHits} hits · II ×{vuln * wound:0.##} · III the {PackSize} nearest marked too"
			+ $" · IV ×{vuln * wound * mortal:0.##}"
			+ $" · V a marked zombie that dies passes the mark to the {TrailSize} nearest unmarked (host)"
			+ (me.IsValid()
				? $" · yours at level {AmmoModUpgrades.Level( me, ModId )}: {HitsFor( me )} hits, ×{TakenFor( me ):0.##}"
					+ (PackFor( me ) > 0 ? $", +{PackFor( me )} more" : "")
					+ (TrailFor( me ) > 0 ? $", trail {TrailFor( me )}" : "")
				: "") );
	}
}