Weapons/KillMods.cs

Static utility for kill-related ammo mods. Defines tuning constants, resolves upgrade-modified values per player, checks mod ownership/cooldowns, and applies effects for Leech, Scrapper, Headhunter, Shatter Blast and Re-Animator on zombie kills; also provides reporting and live tuning console commands.

NetworkingFile AccessNative Interop
using Sandbox;
using System;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// THE AMMO MODS THAT GO OFF ON A KILL (2026-10-04, `Sbox nzombies/Docs/AMMO_MODS.md`).
///
/// | mod | trigger | effect |
/// |---|---|---|
/// | **Leech** | every kill | +5 health, up to 50 over your max; the extra never drains |
/// | **Midas** | every kill | the kill pays +50% points |
/// | **Scrapper** | 15% a kill (4% until 2026-10-06) | +50 salvage, straight to you |
/// | **Headhunter** | 10% a headshot kill | twice the killing shot's damage hits every zombie within 200u |
/// | **Shatter Blast** | 10% a kill, 5s cooldown | an explosion of 5× the killing hit's damage, within 200u |
/// | **Re-Animator** | 8% a kill, 30s cooldown | the corpse gets up as a human and runs; zombies chase it for 6s (`Reanimator`) |
///
/// ⛔ A KILL COUNTS WHEN THE MOD'S OWN GUN SHOT IT LAST — `Health.LastMod`, the mod of the last bullet that hit it, latched
/// beside `LastTech` and carried with a client's hits (`NZNet.HurtRemote`). Not the mod on whatever is in your hands when it
/// dies: a grenade kill is not an ammo kill. Blast Furnace keeps its own older rule (the gun held at the kill).
///
/// ⚠️ ON THE KILLER'S MACHINE, through `AmmoMods.OnZombieKilled` (`AugmentEffects.OnZombieKilled` relays a client's kills
/// there with the mod) — health, salvage and the cooldowns are all the killer's own. EXCEPT MIDAS, which the host applies
/// where it pays the kill (`ZombieAI.AwardKillPoints`, <see cref="MidasPoints"/>), so Double Points doubles the bonus too.
///
/// ⚠️ THEIR SPLASHES CANNOT CHAIN. A splash hit is a blast, not a bullet, so a zombie it kills has no mod
/// (`AmmoMods.HitModOf`) and sets nothing off — and a splash is never a headshot.
///
/// ⚠️ AND THEIR UPGRADES (2026-10-05, `Sbox nzombies/Docs/AMMO_MODS.md` "Upgrades"), each read by the level of the player whose
/// mod it is, where the effect already runs (`AmmoModUpgrades.Has`; levels are synced, so any machine can ask):
/// - the killer's machine, here: Leech I and II, Scrapper II, Headhunter II and III, Shatter Blast II and III;
/// - the roll, through `AmmoMods.BaseChance` / `BaseCooldown`: Scrapper I, Headhunter I, Shatter Blast I, Re-Animator II;
/// - the host, inside the award: Midas I and II (<see cref="MidasHitPoints"/>, <see cref="MidasPoints"/>);
/// - the shooter's machine, per bullet (`ShotPoints`): Leech III, Midas III and Scrapper III, the ones that change a HIT.
///
/// ⚠️ AND TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"), on the same machines by the same rule:
///
/// | mod | IV | V |
/// |---|---|---|
/// | **Leech** | Bloated: overheal up to 200 over your max (100) | Vampire: a bullet heals 3 (1); overheal up to twice your max |
/// | **Midas** | Solid Gold: a paying hit pays 2 more points (1) | Gold Standard: its bullets deal +100% per 1,000,000 points earned |
/// | **Scrapper** | Scrap Stream: a paying hit pays 2 salvage (1) | Jackpot: 1 payout in 10 pays ten times as much (750) |
/// | **Headhunter** | Sharper Eye: 35% of headshot kills (20%) | Execution: 5× the killing shot (3×), reaching 600u (300) |
///
/// - the killer's machine, here: Leech IV and V's caps (Siphon's too, on the shooter's), Scrapper V, Headhunter V;
/// - the roll (`AmmoMods.BaseChance`): Headhunter IV, and Shatter Blast IV and V (`BaseCooldown`);
/// - the host, inside the award: Midas IV (<see cref="MidasHitPoints"/>);
/// - the shooter's machine, per bullet (`ShotPoints`, riding III's hooks): Leech V's 3 and Scrapper IV's 2;
/// - the shooter's machine, IN THE DAMAGE: Midas V (<see cref="GoldStandardScale"/>, read by `Health.OnDamage` and `AttackerScale`).
/// </summary>
public static class KillMods
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1. Chances and cooldowns are the catalogue's (`AmmoMods.All`), or an
	// upgrade's in its place (`AmmoMods.BaseChance` / `BaseCooldown`).

	static float? _leechHeal;
	/// <summary>LEECH: health a kill. 5.</summary>
	public static float LeechHeal { get => _leechHeal ?? 5f; set => _leechHeal = value; }

	static float? _deeperBiteHeal;
	/// <summary>LEECH I DEEPER BITE (2026-10-05): health a kill instead. 10.</summary>
	public static float DeeperBiteHeal { get => _deeperBiteHeal ?? 10f; set => _deeperBiteHeal = value; }

	static float? _leechOver;
	/// <summary>LEECH: how far over your max it can take you. 50 — a 150 max fills to 200.</summary>
	public static float LeechOver { get => _leechOver ?? 50f; set => _leechOver = value; }

	static float? _engorgedOver;
	/// <summary>LEECH II ENGORGED (2026-10-05): how far over instead. 100 — a 150 max fills to 250. Siphon fills the same pool.</summary>
	public static float EngorgedOver { get => _engorgedOver ?? 100f; set => _engorgedOver = value; }

	static float? _siphonHeal;
	/// <summary>LEECH III SIPHON (2026-10-05): health for each bullet that hits, once however many zombies it goes through. 1.</summary>
	public static float SiphonHeal { get => _siphonHeal ?? 1f; set => _siphonHeal = value; }

	static float? _bloatedOver;
	/// <summary>LEECH IV BLOATED (2026-10-06): how far over your max instead. 200 — a 150 max fills to 350.</summary>
	public static float BloatedOver { get => _bloatedOver ?? 200f; set => _bloatedOver = value; }

	static float? _vampireHeal;
	/// <summary>
	/// LEECH V VAMPIRE (2026-10-06): Siphon's health a bullet instead. 3 — the user: *"make V so every bullet heals 3, and you can
	/// overheal up to 2x your health abr"*. Once a bullet however many zombies it goes through, as III's 1.
	/// </summary>
	public static float VampireHeal { get => _vampireHeal ?? 3f; set => _vampireHeal = value; }

	static float? _vampireOverScale;
	/// <summary>
	/// LEECH V VAMPIRE (2026-10-06): how far over your max, as a multiple of your max. 2 — the OVERHEAL reaches twice the max (a 150
	/// max carries 300, 450 in all; Juggernog's 250 carries 500), never less than IV's <see cref="BloatedOver"/>.
	/// </summary>
	public static float VampireOverScale { get => _vampireOverScale ?? 2f; set => _vampireOverScale = value; }

	static float? _midasScale;
	/// <summary>MIDAS: what a kill pays, as a multiple. 1.5 — +50%.</summary>
	public static float MidasScale { get => _midasScale ?? 1.5f; set => _midasScale = value; }

	static float? _richerKillsScale;
	/// <summary>MIDAS II RICHER KILLS (2026-10-05): the multiple instead. 2 — +100%.</summary>
	public static float RicherKillsScale { get => _richerKillsScale ?? 2f; set => _richerKillsScale = value; }

	static int? _gildedHitPoints;
	/// <summary>MIDAS I GILDED HITS (2026-10-05): points added to a paying hit from a Midas gun. 1.</summary>
	public static int GildedHitPoints { get => _gildedHitPoints ?? 1; set => _gildedHitPoints = value; }

	static int? _solidGoldHitPoints;
	/// <summary>MIDAS IV SOLID GOLD (2026-10-06): points added to a paying hit instead. 2 — the user: *"a paying hit pays 2 more points"*.</summary>
	public static int SolidGoldHitPoints { get => _solidGoldHitPoints ?? 2; set => _solidGoldHitPoints = value; }

	static float? _goldStandardPoints;
	/// <summary>
	/// MIDAS V GOLD STANDARD (2026-10-06): the points earned this game for each +100% on the Midas gun's damage. 1,000,000 — the
	/// user: *"100% for each 1000000, measured from the points in the scoreboard"*. Smooth, not in steps (250,000 is +25%), no
	/// ceiling. 0 or less turns it off.
	/// </summary>
	public static float GoldStandardPoints { get => _goldStandardPoints ?? 1_000_000f; set => _goldStandardPoints = value; }

	static int? _scrapperSalvage;
	/// <summary>SCRAPPER: salvage a proc. 50.</summary>
	public static int ScrapperSalvage { get => _scrapperSalvage ?? 50; set => _scrapperSalvage = value; }

	static int? _biggerHaulSalvage;
	/// <summary>SCRAPPER II BIGGER HAUL (2026-10-05): salvage a proc instead. 75.</summary>
	public static int BiggerHaulSalvage { get => _biggerHaulSalvage ?? 75; set => _biggerHaulSalvage = value; }

	static int? _scrapDripSalvage;
	/// <summary>SCRAPPER III SCRAP DRIP (2026-10-05): salvage for each hit that pays points. 1.</summary>
	public static int ScrapDripSalvage { get => _scrapDripSalvage ?? 1; set => _scrapDripSalvage = value; }

	static int? _scrapStreamSalvage;
	/// <summary>
	/// SCRAPPER IV SCRAP STREAM (2026-10-06): salvage for each hit that pays points instead. 2 — on Scrap Drip's points rule, so a
	/// bullet pays for at most 4 zombies and a shotgun blast for its best pellet.
	/// </summary>
	public static int ScrapStreamSalvage { get => _scrapStreamSalvage ?? 2; set => _scrapStreamSalvage = value; }

	static float? _jackpotChance;
	/// <summary>SCRAPPER V JACKPOT (2026-10-06): the chance a payout is the jackpot. 0.1 — 1 payout in 10.</summary>
	public static float JackpotChance { get => _jackpotChance ?? 0.1f; set => _jackpotChance = value; }

	static int? _jackpotScale;
	/// <summary>SCRAPPER V JACKPOT (2026-10-06): what the jackpot pays, as a multiple of the payout. 10 — 750 with II's 75.</summary>
	public static int JackpotScale { get => _jackpotScale ?? 10; set => _jackpotScale = value; }

	static float? _headhunterRadius;
	/// <summary>HEADHUNTER: how far the shot carries. 200u.</summary>
	public static float HeadhunterRadius { get => _headhunterRadius ?? 200f; set => _headhunterRadius = value; }

	static float? _longShotRadius;
	/// <summary>HEADHUNTER II LONG SHOT (2026-10-05): how far instead. 300u. The line of sight from the head stays.</summary>
	public static float LongShotRadius { get => _longShotRadius ?? 300f; set => _longShotRadius = value; }

	static float? _headhunterShare;
	/// <summary>
	/// HEADHUNTER: what each zombie takes, as a multiple of the killing shot. 2 — twice it. It was 1 until the user played it
	/// (2026-10-04): "increase the damage to 2x the damage dealt instead of 1x".
	/// </summary>
	public static float HeadhunterShare { get => _headhunterShare ?? 2f; set => _headhunterShare = value; }

	static float? _overkillShare;
	/// <summary>HEADHUNTER III OVERKILL (2026-10-05): what each zombie takes instead. 3 — three times the killing shot.</summary>
	public static float OverkillShare { get => _overkillShare ?? 3f; set => _overkillShare = value; }

	static float? _executionShare;
	/// <summary>
	/// HEADHUNTER V EXECUTION (2026-10-06): what each zombie takes instead. 5 — the user: *"V: 5x the damage and double the
	/// radius"*. The splash's kills still set nothing off, or each splash would be 5× the last.
	/// </summary>
	public static float ExecutionShare { get => _executionShare ?? 5f; set => _executionShare = value; }

	static float? _executionRadius;
	/// <summary>HEADHUNTER V EXECUTION (2026-10-06): how far instead. 600u — II's 300 doubled. The line of sight from the head stays.</summary>
	public static float ExecutionRadius { get => _executionRadius ?? 600f; set => _executionRadius = value; }

	static float? _shatterRadius;
	/// <summary>SHATTER BLAST: the explosion's reach. 200u.</summary>
	public static float ShatterRadius { get => _shatterRadius ?? 200f; set => _shatterRadius = value; }

	static float? _bigBangRadius;
	/// <summary>SHATTER BLAST II BIG BANG (2026-10-05): the reach instead. 300u.</summary>
	public static float BigBangRadius { get => _bigBangRadius ?? 300f; set => _bigBangRadius = value; }

	static float? _shatterScale;
	/// <summary>SHATTER BLAST: the explosion, as a multiple of the killing hit. 5.</summary>
	public static float ShatterScale { get => _shatterScale ?? 5f; set => _shatterScale = value; }

	static int? _tripleChargeBlasts;
	/// <summary>
	/// SHATTER BLAST III TRIPLE CHARGE (2026-10-05): how many times the body explodes, each the full blast at the same spot. 3 —
	/// the user: *"make III do 3 explosions instead of 1, the 3 in a row"*.
	/// </summary>
	public static int TripleChargeBlasts { get => _tripleChargeBlasts ?? 3; set => _tripleChargeBlasts = value; }

	static float? _tripleChargeGap;
	/// <summary>SHATTER BLAST III: seconds between those explosions. 0.25 — close enough to read as one rolling blast.</summary>
	public static float TripleChargeGap { get => _tripleChargeGap ?? 0.25f; set => _tripleChargeGap = value; }

	/// <summary>Headhunter's tracers, blood red.</summary>
	static Color HeadhunterColour => new( 0.85f, 0.08f, 0.06f );

	// ══ the upgrades' numbers (2026-10-05) ═══════════════════════════════════
	//
	// ⛔ ONE AUTHOR A NUMBER (INSTRUCTIONS.md §3): the base tunable, or the upgraded one when the player owns that level. Each
	// takes the player whose mod it is — the killer here, the shooter in `ShotPoints`, the attacker or killer `ZombieAI` already
	// has on the host. Levels stack (II owns I), so `Has` is "this level or above".
	// ⚠️ HIGHEST LEVEL FIRST SINCE IV AND V (2026-10-06): a level-V owner has every level below it, so V is asked before IV, IV
	// before the I–III number it replaces.

	static float LeechHealFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "leech", 1 ) ? DeeperBiteHeal : LeechHeal;
	static float LeechOverFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "leech", 5 ) ? VampireOverFor( p )
		: AmmoModUpgrades.Has( p, "leech", 4 ) ? BloatedOver
		: AmmoModUpgrades.Has( p, "leech", 2 ) ? EngorgedOver : LeechOver;
	static float SiphonHealFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "leech", 5 ) ? VampireHeal : SiphonHeal;
	static float MidasScaleFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "midas", 2 ) ? RicherKillsScale : MidasScale;
	static int MidasHitPointsFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "midas", 4 ) ? SolidGoldHitPoints
		: AmmoModUpgrades.Has( p, "midas", 1 ) ? GildedHitPoints : 0;
	static int ScrapperSalvageFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "scrapper", 2 ) ? BiggerHaulSalvage : ScrapperSalvage;
	static int ScrapDripSalvageFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "scrapper", 4 ) ? ScrapStreamSalvage : ScrapDripSalvage;
	static float HeadhunterRadiusFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "headhunter", 5 ) ? ExecutionRadius
		: AmmoModUpgrades.Has( p, "headhunter", 2 ) ? LongShotRadius : HeadhunterRadius;
	static float HeadhunterShareFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "headhunter", 5 ) ? ExecutionShare
		: AmmoModUpgrades.Has( p, "headhunter", 3 ) ? OverkillShare : HeadhunterShare;
	static float ShatterRadiusFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "shatterblast", 2 ) ? BigBangRadius : ShatterRadius;
	static int ShatterBlastsFor( NZPlayer p ) => AmmoModUpgrades.Has( p, "shatterblast", 3 ) ? Math.Max( 1, TripleChargeBlasts ) : 1;

	/// <summary>
	/// LEECH V VAMPIRE's cap (2026-10-06): <see cref="VampireOverScale"/> times the player's max health, never under IV's
	/// <see cref="BloatedOver"/> — so a max below 100 still keeps what IV gave. Read when the heal lands, so Juggernog's raised
	/// max raises it with it.
	/// </summary>
	static float VampireOverFor( NZPlayer p )
		=> MathF.Max( BloatedOver, p.Hp.IsValid() ? VampireOverScale * p.Hp.Max : 0f );

	/// <summary>
	/// MIDAS V GOLD STANDARD (2026-10-06): the multiple this player's Midas bullets deal — 1 + points earned this game ÷
	/// <see cref="GoldStandardPoints"/> at level V, 1 below it. The figure is the scoreboard's `PlayerStats.PointsEarned`: awards
	/// only, so spending never lowers it. The player's OWN component, which only their machine fills (`NZPlayer.AddPoints`).
	/// </summary>
	static float GoldStandardFor( NZPlayer p )
	{
		if ( !AmmoModUpgrades.Has( p, "midas", 5 ) ) return 1f;

		var per = GoldStandardPoints;
		if ( per <= 0f ) return 1f;

		return 1f + MathF.Max( 0f, PlayerStats.For( p )?.PointsEarned ?? 0 ) / per;
	}

	/// <summary>The player behind a killer or attacker object, ancestors included — the lookup `ZombieAI.KillerPlayer` makes.</summary>
	static NZPlayer PlayerOf( GameObject go )
		=> go.IsValid() ? go.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;

	// ══ the hook ═════════════════════════════════════════════════════════════

	/// <summary>
	/// A zombie died; <paramref name="mod"/> is the mod of the gun whose bullet hit it last. THE KILLER'S MACHINE.
	/// </summary>
	/// <param name="at">Where it died, 32u up from its feet — the corpse may be gone on a client.</param>
	/// <param name="damage">The killing hit's damage (`Health.LastDamage`), overkill included.</param>
	public static void OnZombieKilled( GameObject killer, GameObject victim, Vector3? at, bool headshot, float damage, string mod )
	{
		if ( string.IsNullOrEmpty( mod ) || mod == "midas" ) return;
		if ( !victim.IsValid() && at is null ) return;

		var player = PlayerOf( killer );
		if ( !player.IsValid() ) return;

		var m = AmmoMods.Find( mod );
		if ( m is null || !m.Built ) return;

		// ⚠️ AND THE KILLER STILL HAS A GUN CARRYING IT. The mod names the last BULLET; a bleed or a burn can land the kill
		// for somebody else, and a mod they do not own must not pay them.
		var prefab = PrefabWith( player, mod );
		if ( prefab is null ) return;

		var centre = at ?? victim.WorldPosition + Vector3.Up * 32f;

		switch ( mod )
		{
			case "leech":
				Leech( player );
				break;

			case "scrapper":
				if ( Roll( player, m, prefab ) ) Scrapper( player );
				break;

			case "headhunter":
				if ( headshot && Roll( player, m, prefab ) ) Headhunter( player, victim, centre, damage );
				break;

			case "shatterblast":
				if ( Roll( player, m, prefab ) ) ShatterBlast( player, victim, centre, damage );
				break;

			// ⚠️ RE-ANIMATOR (2026-10-04): the corpse gets up as a human and runs — the host's to raise (`Reanimator`).
			// ⚠️ WITH THE KILLER (2026-10-05), whose levels it reads: III's damage is armed here, on its machine, and I's life is
			// picked by the host (solo or the host's own kill from this; a client's from `NZNet.ReanimatorAsk`'s caller).
			case "reanimator":
				if ( Roll( player, m, prefab ) ) Reanimator.Fire( victim, centre, player );
				break;
		}
	}

	/// <summary>
	/// MIDAS: what a kill pays once the mod of the gun that shot it last is counted. THE HOST — `ZombieAI.AwardKillPoints`,
	/// before Double Points, so it doubles the bonus too.
	/// </summary>
	/// <param name="killer">
	/// ⚠️ WHO SHOT IT LAST (2026-10-05), for Midas II's ×2 — the user: *"II - Kills pay 100% points"*. Read from the killer's
	/// synced level here on the host. The player is looked up only for a Midas kill.
	/// </param>
	public static int MidasPoints( string mod, int amount, GameObject killer )
		=> mod == "midas" && amount > 0
			? (int)MathF.Round( amount * MathF.Max( 1f, MidasScaleFor( PlayerOf( killer ) ) ) )
			: amount;

	/// <summary>
	/// MIDAS I GILDED HITS (2026-10-05): what a paying hit adds once the mod of the gun that fired it is counted —
	/// <see cref="GildedHitPoints"/> from a Midas gun whose owner has level I, 0 otherwise. The user: *"I - Hits pay 1 extra
	/// point"*. THE HOST — `ZombieAI.OnHurt`, inside the hit's award.
	///
	/// ⚠️ BEFORE DOUBLE POINTS, AS THE KILL BONUS IS (the doc left it to the build): a gilded hit pays (hit + 1) × 2 under it.
	/// Both of Midas's bonuses then double alike, and `AwardPoints` stays the one place the multiplier is read.
	///
	/// ⚠️ "THE GUN THAT FIRED IT" IS `Health.LastMod`, the rule every kill mod reads: a tick that pays the drip after a Midas
	/// bullet (a burn, say) counts as that bullet's, as a tick kill pays Midas's kill bonus. A hit `ShotPoints` refused pays
	/// nothing at all, so a bullet gilds no more zombies than it may pay for.
	///
	/// ⚠️ MIDAS IV SOLID GOLD (2026-10-06): <see cref="SolidGoldHitPoints"/> in place of I's point, the same rules — before Double
	/// Points, on the hits `ShotPoints` lets pay.
	/// </summary>
	public static int MidasHitPoints( string mod, GameObject attacker )
		=> mod == "midas" ? Math.Max( 0, MidasHitPointsFor( PlayerOf( attacker ) ) ) : 0;

	/// <summary>
	/// MIDAS V GOLD STANDARD (2026-10-06): what a hit is multiplied by once the mod of the gun that fired it is counted —
	/// ×(1 + points earned this game ÷ <see cref="GoldStandardPoints"/>) for a bullet from a Midas gun whose owner has level V, 1
	/// for anything else. The user: *"tier V: your damage scales with the ammount of points you have had in the entire game / 100%
	/// for each 1000000, measured from the points in the scoreboard"*. The Midas gun's own damage, as every mod effect is the gun's.
	///
	/// ⛔ ONCE A HIT, AND ONLY ON THE SHOOTER'S OWN MACHINE: it answers 1 for any body that is not this machine's
	/// (`PlayerPresence.Mine`). `Health.OnDamage` asks it for the host's own bullets (solo included) with the hit's `LastMod`, and
	/// a client's relay asks it in `Health.AttackerScale` with the bullet's mod, so the hit reaches the host with it already in.
	/// On the host that hit carries the mod and its owner's level is synced, so only the earned figure stands between it and a
	/// second multiply, and that is 0 there by accident: the host's copy of a client records nothing (`NZNet.StatsAre`). The
	/// ownership test makes the host read ×1 by rule instead, as `AttackerScale` asks of every term (1 against the host's copy
	/// of the shooter), so nothing doubles the day that copy learns a figure.
	///
	/// ⚠️ THE FIGURE IS THE SHOOTER'S OWN SCOREBOARD LINE (`PlayerStats.PointsEarned`), exact on their machine, which is the one
	/// that adds to it (`NZPlayer.AddPoints`). So no other machine needs it, and the synced table (`NZNet.StatsOf`, the
	/// scoreboard's for other players) is not read: `StatsAre` keeps that table for display, never for a game decision.
	///
	/// ⚠️ BULLETS ONLY: a hit's mod is a bullet's (`AmmoMods.HitModOf` / `BulletModOf`), so a grenade, a blast or a mod's own
	/// splash is never scaled.
	/// </summary>
	public static float GoldStandardScale( GameObject attacker, string mod )
	{
		if ( mod != "midas" ) return 1f;

		var p = PlayerOf( attacker );
		if ( !p.IsValid() || !PlayerPresence.Mine( p.GameObject ) ) return 1f;

		return GoldStandardFor( p );
	}

	/// <summary>
	/// The prefab carrying this mod: the gun in hand when it is the one, otherwise the first that does. Null when none does.
	/// It is where the cooldown is stamped (`AmmoModReady`, per prefab).
	/// </summary>
	static string PrefabWith( NZPlayer player, string mod )
	{
		var held = VultureAugments.HeldWeapon( player );
		var heldPrefab = held.IsValid() ? Rarity.PrefabOf( held ) : null;

		if ( AmmoMods.On( player, heldPrefab )?.Id == mod ) return heldPrefab;

		foreach ( var pair in player.AmmoModIds )
			if ( pair.Value == mod && AmmoMods.On( player, pair.Key ) is not null ) return pair.Key;

		return null;
	}

	/// <summary>
	/// The chance and the cooldown, as `AmmoMods.OnZombieHit` takes them for a hit: Elemental Pop and Catalyst count, and the
	/// cooldown goes through `AmmoMods.CooldownFor` (Timeslip's register, INSTRUCTIONS.md §19).
	/// </summary>
	static bool Roll( NZPlayer player, AmmoMods.Mod mod, string prefab )
	{
		if ( player.AmmoModReady.TryGetValue( prefab, out var ready ) && ready > 0f ) return false;
		if ( Game.Random.Float() > AmmoMods.ChanceFor( player, mod ) ) return false;

		// ⚠️ STAMPED BEFORE THE EFFECT RUNS, the order `OnZombieHit` keeps.
		player.AmmoModReady[prefab] = AmmoMods.CooldownFor( player, mod );
		return true;
	}

	// ══ the effects ══════════════════════════════════════════════════════════

	/// <summary>LEECH: heal, and past your max into overheal (`Health.Over`), which only damage takes away.</summary>
	static void Leech( NZPlayer player ) => FillOver( player, LeechHealFor( player ) );

	/// <summary>
	/// LEECH III SIPHON (2026-10-05): a bullet from a Leech III gun hit a zombie — <see cref="SiphonHeal"/> health, into the same
	/// overheal as the kill heal, up to the level's cap (II raises it). The user's first Leech idea (2026-10-04): *"gaining 1 hp
	/// per shot fired that hits, meaning penetration still counts as just 1"*.
	///
	/// ⚠️ THE SHOOTER'S MACHINE, ONCE A BULLET: `ShotPoints.Pays` calls it at the bullet's first zombie, paying or not. A health
	/// is its owner's, and the gun fires nowhere else.
	///
	/// ⚠️ LEECH V VAMPIRE (2026-10-06): <see cref="VampireHeal"/> a bullet in place of III's 1, into a pool that reaches twice the
	/// max (<see cref="LeechOverFor"/>). The same rules carry over: once a bullet, each shotgun pellet that hits counting.
	/// </summary>
	public static void Siphon( NZPlayer player ) => FillOver( player, SiphonHealFor( player ) );

	/// <summary>Heal, and past the max into overheal up to the player's Leech cap. Nothing while down or dead.</summary>
	static void FillOver( NZPlayer player, float amount )
	{
		var hp = player.IsValid() ? player.Hp : null;
		if ( !hp.IsValid() || hp.IsDead || player.IsDown ) return;

		hp.HealOver( MathF.Max( 0f, amount ), MathF.Max( 0f, LeechOverFor( player ) ) );
	}

	/// <summary>
	/// SCRAPPER III SCRAP DRIP (2026-10-05): a hit from a Scrapper III gun paid points — <see cref="ScrapDripSalvage"/> salvage.
	/// The user: *"for Scrapper III change it to every hit gives 1 scrap"*.
	///
	/// ⚠️ THE SHOOTER'S MACHINE, ON THE POINTS RULE: `ShotPoints.Pays` calls it for each zombie hit it lets pay, so a bullet pays
	/// for no more zombies than its points do (`ShotPoints.PenetrationCap`) and a shotgun blast for its best pellet alone.
	/// ⚠️ SILENT, UNLIKE THE PAYOUT: it comes at the gun's rate of fire, where the pickup's cue would be a drone. The HUD's count
	/// shows it.
	/// ⚠️ SCRAPPER IV SCRAP STREAM (2026-10-06): <see cref="ScrapStreamSalvage"/> a paying hit in place of III's 1, on the same
	/// points rule.
	/// </summary>
	public static void ScrapDrip( NZPlayer player ) => Salvage.Award( player, Math.Max( 0, ScrapDripSalvageFor( player ) ) );

	/// <summary>
	/// SCRAPPER: salvage straight to the killer — no drop to walk to.
	///
	/// ⚠️ SCRAPPER V JACKPOT (2026-10-06): one payout in ten (<see cref="JackpotChance"/>) pays <see cref="JackpotScale"/> times as
	/// much — 750 with II's 75. Rolled once a PAYOUT, here on the killer's machine after the payout's own roll, so Scrap Drip's
	/// salvage never rolls it. It was my III idea (the user took Scrap Drip there), proposed again for V and taken: *"ok i like it,
	/// next"*.
	/// </summary>
	static void Scrapper( NZPlayer player )
	{
		var amount = Math.Max( 0, ScrapperSalvageFor( player ) );
		var jackpot = AmmoModUpgrades.Has( player, "scrapper", 5 ) && Game.Random.Float() < JackpotChance;
		if ( jackpot ) amount *= Math.Max( 1, JackpotScale );

		var paid = Salvage.Award( player, amount );
		if ( paid <= 0 ) return;

		// ⚠️ THE PICKUP'S OWN CUE, in your ears alone: it is your salvage.
		// ⚠️ A JACKPOT PLAYS THE CASH REGISTER IN ITS PLACE, FOR NOW (2026-10-06): `NZSound.Purchase`, which Loose Change plays as
		// the user's *"standard money earn sound"* (2026-09-27), 2D like the pickup's. The jackpot has no cue of its own yet.
		NZSound.Play( jackpot ? NZSound.Purchase : NZSound.PickupSalvage );

		Log.Info( jackpot
			? $"[nz-ammo] SCRAPPER JACKPOT — +{paid} salvage (×{Math.Max( 1, JackpotScale )} of {ScrapperSalvageFor( player )})"
			: $"[nz-ammo] SCRAPPER — +{paid} salvage" );
	}

	/// <summary>
	/// HEADHUNTER: twice the killing shot's damage, on every zombie the head could see within reach. ⚠️ II's reach and III's
	/// three times by the killer's levels (2026-10-05); the line of sight stays at every level.
	/// ⚠️ V EXECUTION (2026-10-06): five times, reaching 600u, through the same two helpers. Still a blast, not a bullet, so what it
	/// kills sets nothing off.
	/// </summary>
	static void Headhunter( NZPlayer player, GameObject victim, Vector3 centre, float damage )
	{
		var share = HeadhunterShareFor( player );
		var radius = MathF.Max( 0f, HeadhunterRadiusFor( player ) );

		var splash = WithoutRing( player, damage ) * MathF.Max( 0f, share );
		if ( splash <= 0f ) return;

		// ⚠️ FROM THE HEAD, about where the shot went in.
		var from = centre + Vector3.Up * 30f;
		var hitAt = new List<Vector3>();

		TechBlast.ModBlast( from, splash, radius, player.GameObject,
			VultureAugments.HeldWeapon( player )?.GameObject, hitAt, except: victim, effect: false );

		// ⚠️ NO EXPLOSION: a red line from the head to each zombie it reached, which says "this came from that kill".
		foreach ( var to in hitAt )
			ColourTracer.Draw( from, to, HeadhunterColour, "nz_headhunter" );

		Sound.Play( NZSound.ZombieGoreGush, from );

		Log.Info( $"[nz-ammo] HEADHUNTER — {splash:0} (×{share:0.#} of {damage:0}) to {hitAt.Count} zombie(s)"
			+ $" within {radius:0}u" );
	}

	/// <summary>
	/// SHATTER BLAST: the corpse explodes for five times the hit that killed it. ⚠️ II's reach and III's three explosions by the
	/// killer's levels (2026-10-05).
	/// </summary>
	static void ShatterBlast( NZPlayer player, GameObject victim, Vector3 centre, float damage )
	{
		var blast = WithoutRing( player, damage ) * MathF.Max( 0f, ShatterScale );
		if ( blast <= 0f ) return;

		var radius = MathF.Max( 0f, ShatterRadiusFor( player ) );
		var count = ShatterBlastsFor( player );

		var hit = ShatterDetonate( player, victim, centre, blast, radius );

		Log.Info( $"[nz-ammo] SHATTER BLAST — {blast:0} (×{ShatterScale:0.#} of {damage:0}) to {hit} zombie(s)"
			+ $" within {radius:0}u{(count > 1 ? $", 1 of {count}" : "")}" );

		if ( count > 1 ) ShatterRest( player, victim, centre, blast, radius, count );
	}

	/// <summary>
	/// One explosion: the grenade's fireball and bang, sized to the reach (`TechBlast.ModBlast`, announced to every machine with
	/// that reach), and the ice-break crack for "shatter". Returns how many zombies it reached.
	/// </summary>
	static int ShatterDetonate( NZPlayer player, GameObject victim, Vector3 centre, float blast, float radius )
	{
		var hitAt = new List<Vector3>();

		TechBlast.ModBlast( centre, blast, radius, player.GameObject,
			VultureAugments.HeldWeapon( player )?.GameObject, hitAt, except: victim );

		Sound.Play( NZSound.PopCryofreezeShatter, centre );

		return hitAt.Count;
	}

	/// <summary>
	/// SHATTER BLAST III TRIPLE CHARGE (2026-10-05): the 2nd..Nth explosions, <see cref="TripleChargeGap"/> apart, each the full
	/// blast at the same reach — the user: *"make III do 3 explosions instead of 1, the 3 in a row"*.
	///
	/// ⚠️ THE PLACE AND THE FIGURE ARE SNAPSHOTS, taken at the kill: the corpse can be gone by the second blast (`victim` is
	/// only ever the blast's `except`, which ignores an invalid object), and the killing hit is not re-read. Each blast is a
	/// blast, not a bullet, so what it kills sets nothing off — no chain, as the first.
	///
	/// ⚠️ `GameTask.Delay`, THE PROJECT'S DELAY IDIOM, RE-VALIDATED AFTER EVERY WAIT as `PhdAugments.ChainRest` is: the
	/// killer can leave or the game end in the gap.
	/// </summary>
	static async void ShatterRest( NZPlayer player, GameObject victim, Vector3 centre, float blast, float radius, int count )
	{
		var ms = (int)(MathF.Max( 0.02f, TripleChargeGap ) * 1000f);

		for ( var i = 2; i <= count; i++ )
		{
			await GameTask.Delay( ms );

			if ( !player.IsValid() || !Game.ActiveScene.IsValid() ) return;

			// ⚠️ ITS HITS ROLL NO MOD (2026-10-05): the first blast lands with the kill, as a rule with the Shatter Blast gun in
			// hand, whose kill mod never rolls on a hit; a quarter second on, the hand may hold a hit-mod gun off its cooldown
			// (`AmmoMods.WithoutProcs`).
			var hit = 0;
			AmmoMods.WithoutProcs( player, () => hit = ShatterDetonate( player, victim, centre, blast, radius ) );

			Log.Info( $"[nz-ammo] SHATTER BLAST — {blast:0} to {hit} zombie(s) within {radius:0}u, {i} of {count}" );
		}
	}

	/// <summary>
	/// The killing hit with the shooter's Tortoise ring taken back out (2026-10-04, the review). It is in the hit, and each
	/// splash hit picks it up again in `Health.OnDamage` — in a ×1.5 Dig In ring a 5× blast landed 7.5×. Vigor's Last Round
	/// takes it out the same way. Rings are mirrored, so the killer's machine reads its own.
	/// </summary>
	static float WithoutRing( NZPlayer player, float damage )
		=> damage / MathF.Max( 0.01f, TortoiseAugments.DamageScale( player.GameObject ) );

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>`nz_killmods` — the five kill mods' resolved numbers.</summary>
	[ConCmd( "nz_killmods" )]
	public static void Report()
	{
		// ⚠️ RESOLVED FOR YOUR LEVELS (2026-10-05), which the bracket after each name gives; "-" is none, the base numbers.
		// ⚠️ IV AND V TOO (2026-10-06), through the same helpers the effects read.
		var p = NZPlayer.Local;

		Log.Info( $"[nz-ammo] LEECH {Lv( p, "leech" )} · every kill +{LeechHealFor( p ):0.#} health, up to {LeechOverFor( p ):0}"
			+ $" over max{(AmmoModUpgrades.Has( p, "leech", 5 ) ? $" (×{VampireOverScale:0.#} your max, never under {BloatedOver:0})" : "")}"
			+ $" (never drains){(AmmoModUpgrades.Has( p, "leech", 3 ) ? $"; +{SiphonHealFor( p ):0.#} a bullet that hits" : "")}" );
		Log.Info( $"[nz-ammo] MIDAS {Lv( p, "midas" )} · every kill pays ×{MidasScaleFor( p ):0.##} (host, before Double Points)"
			+ (AmmoModUpgrades.Has( p, "midas", 1 ) ? $"; a paying hit +{MidasHitPointsFor( p )}" : "")
			+ (AmmoModUpgrades.Has( p, "midas", 3 ) ? $"; a bullet pays for {ShotPoints.CapFor( goldenLine: true )} zombies" : "")
			+ (AmmoModUpgrades.Has( p, "midas", 5 )
				? $"; its bullets deal ×{GoldStandardFor( p ):0.###} ({PlayerStats.For( p )?.PointsEarned ?? 0:N0} earned this game,"
					+ $" +100% per {GoldStandardPoints:N0})"
				: "") );
		Log.Info( $"[nz-ammo] SCRAPPER {Lv( p, "scrapper" )} · {Chance( p, "scrapper" )} a kill: +{ScrapperSalvageFor( p )} salvage"
			+ (AmmoModUpgrades.Has( p, "scrapper", 3 ) ? $"; +{ScrapDripSalvageFor( p )} a paying hit" : "")
			+ (AmmoModUpgrades.Has( p, "scrapper", 5 )
				? $"; {JackpotChance * 100f:0.#}% of payouts are a ×{Math.Max( 1, JackpotScale )} jackpot"
					+ $" (+{ScrapperSalvageFor( p ) * Math.Max( 1, JackpotScale )})"
				: "") );
		Log.Info( $"[nz-ammo] HEADHUNTER {Lv( p, "headhunter" )} · {Chance( p, "headhunter" )} a headshot kill:"
			+ $" {HeadhunterShareFor( p ) * 100f:0}% of the shot to every zombie within {HeadhunterRadiusFor( p ):0}u" );
		Log.Info( $"[nz-ammo] SHATTER BLAST {Lv( p, "shatterblast" )} · {Chance( p, "shatterblast" )} a kill:"
			+ $" ×{ShatterScale:0.#} the killing hit within {ShatterRadiusFor( p ):0}u"
			+ (ShatterBlastsFor( p ) > 1 ? $", {ShatterBlastsFor( p )} times {TripleChargeGap:0.##}s apart" : "") );
		Log.Info( "[nz-ammo]   ⚠ a kill counts when that mod's gun fired the last bullet into it (`Health.LastMod`)" );

		if ( p.IsValid() && p.Hp.IsValid() )
			Log.Info( $"[nz-ammo]   you: {p.Hp.Current:0}/{p.Hp.Max:0} health + {p.Hp.Over:0} overheal" );
	}

	/// <summary>Your chance and cooldown for a mod, its upgrade in them (`AmmoMods.BaseChance` / `BaseCooldown`), before any augment.</summary>
	static string Chance( NZPlayer p, string id )
	{
		var m = AmmoMods.Find( id );
		if ( m is null ) return "?";

		var cooldown = AmmoMods.BaseCooldown( p, m );
		return $"{AmmoMods.BaseChance( p, m ) * 100f:0.#}%{(cooldown > 0f ? $" / {cooldown:0.#}s" : "")}";
	}

	/// <summary>"[II]" for a level, "[-]" for none.</summary>
	static string Lv( NZPlayer p, string id )
	{
		var level = AmmoModUpgrades.Level( p, id );
		return $"[{(level == 0 ? "-" : HudTheme.ToRoman( level ))}]";
	}

	/// <summary>`nz_killmods_set &lt;key&gt; &lt;value&gt;` — retune one number live.</summary>
	[ConCmd( "nz_killmods_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "leech": LeechHeal = value; break;
			case "leechover": LeechOver = value; break;
			case "midas": MidasScale = value; break;
			case "scrapper": ScrapperSalvage = (int)value; break;
			case "headhunter": HeadhunterShare = value; break;
			case "headhunterradius": HeadhunterRadius = value; break;
			case "shatter": ShatterScale = value; break;
			case "shatterradius": ShatterRadius = value; break;

			// ⚠️ THE UPGRADES' NUMBERS (2026-10-05), by the upgrade's name.
			case "deeperbite": DeeperBiteHeal = value; break;
			case "engorged": EngorgedOver = value; break;
			case "siphon": SiphonHeal = value; break;
			case "gilded": GildedHitPoints = (int)value; break;
			case "richerkills": RicherKillsScale = value; break;
			case "biggerhaul": BiggerHaulSalvage = (int)value; break;
			case "scrapdrip": ScrapDripSalvage = (int)value; break;
			case "longshot": LongShotRadius = value; break;
			case "overkill": OverkillShare = value; break;
			case "bigbang": BigBangRadius = value; break;
			case "triplecharge": TripleChargeBlasts = (int)value; break;
			case "triplechargegap": TripleChargeGap = value; break;

			// ⚠️ TIERS IV AND V (2026-10-06), the same way. `goldstandard` is the points for each +100%.
			case "bloated": BloatedOver = value; break;
			case "vampire": VampireHeal = value; break;
			case "vampireover": VampireOverScale = value; break;
			case "solidgold": SolidGoldHitPoints = (int)value; break;
			case "goldstandard": GoldStandardPoints = value; break;
			case "scrapstream": ScrapStreamSalvage = (int)value; break;
			case "jackpot": JackpotChance = value; break;
			case "jackpotscale": JackpotScale = (int)value; break;
			case "execution": ExecutionShare = value; break;
			case "executionradius": ExecutionRadius = value; break;

			default:
				Log.Info( "[nz-ammo] nz_killmods_set <leech|leechover|midas|scrapper|headhunter|headhunterradius"
					+ "|shatter|shatterradius|deeperbite|engorged|siphon|gilded|richerkills|biggerhaul|scrapdrip|longshot"
					+ "|overkill|bigbang|triplecharge|triplechargegap|bloated|vampire|vampireover|solidgold|goldstandard"
					+ "|scrapstream|jackpot|jackpotscale|execution|executionradius> <value>" );
				return;
		}

		Report();
	}
}