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.
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 <key> <value>` — 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();
}
}