Utility class that enforces per-shot and per-bullet rules for awarding points on zombie hits. It tracks penetration caps, per-pellet best-paid logic, handles ammo-mod interactions (midas, scrapper, leech) and provides a console command to inspect and change settings.
using Sandbox;
namespace NZombies;
/// <summary>
/// HOW MANY HITS FROM ONE TRIGGER PULL ARE ALLOWED TO PAY POINTS.
///
/// ⛔ THE PER-HIT AWARD WAS MULTIPLIED BY TWO THINGS THAT WERE NEVER MEANT TO MULTIPLY IT, and
/// together they broke the economy outright. Points are paid per BODY HIT, so:
///
/// 1. **Penetration** — one round through a packed horde paid for every zombie in the line.
/// 2. **Pellets** — every pellet of a shotgun blast paid independently, and Double Tap's M1
/// fires the whole spread a second time.
///
/// User, with an Olympia carrying the ×6 pellet tech and Double Tap's penetration augment:
/// *"each shot can give me literally like 14k points."* Twelve pellets × ten bodies is a hundred
/// and twenty awards for one trigger pull.
///
/// ⚠️ TWO RULES, AND THE SECOND ONE IS THE INTERESTING ONE:
///
/// 1. A bullet pays for at most the first <see cref="PenetrationCap"/> zombies it passes through.
/// 2. Only ONE pellet of a shot pays — the one that earned the most.
///
/// ⛔ RULE 2 IS DECIDED INCREMENTALLY RATHER THAN BY DEFERRING THE AWARD, which is what makes this
/// twenty lines instead of a ledger. The obvious reading — "tally every pellet, then pay the best
/// one" — needs the award held back until the shot is over, and the award does not happen on this
/// machine: a client's hits are resolved by the HOST, which pays as each one lands. Holding them
/// would mean a second message carrying a points total, and a second author for what a hit is
/// worth.
///
/// Instead: pay a hit only when it pushes THIS pellet's count past the most any single pellet has
/// been paid for so far. The running total then converges on exactly the best pellet's worth, one
/// hit at a time, with no deferral:
///
/// pellet A hits 2 → pays 2 (best = 2)
/// pellet B hits 1 → pays 0 (1 is not past 2)
/// pellet C hits 4 → pays 2 (hits 3 and 4 are past 2 — best = 4)
/// ───────────────────────────────────────────────────────────
/// paid 4, which is what pellet C alone would have earned.
///
/// ⚠️ ZOMBIES ONLY. Walls, props and doors do not consume a penetration slot, because the rule the
/// user asked for is *"the first 4 zombies it hits"* — a round that clips a barricade on the way in
/// should not lose a quarter of its budget to it.
///
/// ⚠️ IT DECIDES, IT DOES NOT PAY. The answer travels to the damage as a `nopay` tag and the host
/// goes on owning the arithmetic — see `ZombieAI.OnHurt` and `NZNet.HurtRemote`. This class never
/// touches a points total, so it cannot disagree with one.
///
/// ⚠️ AND IT IS PER MACHINE, WHICH IS CORRECT HERE RATHER THAN THE USUAL BUG. A weapon is
/// `NetworkMode.Never` and fires on exactly one machine, so "the shot in progress" is a genuinely
/// machine-local fact — unlike a perk, which is a fact about a player.
///
/// ⚠️ THE AMMO MOD UPGRADES THAT CHANGE A HIT LIVE HERE TOO (2026-10-05, `AmmoModUpgrades`), because this is the one place
/// that sees each zombie hit of each bullet exactly once, on the machine that fired: Midas III's wider cap, Scrapper III's
/// salvage on a paying hit, Leech III's heal on a bullet's first zombie. It still pays nothing itself — the salvage and the
/// heal are `KillMods`'.
///
/// ⚠️ TIERS IV AND V RIDE THE SAME HOOKS (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"): Scrapper IV's 2 salvage a paying hit and
/// Leech V's 3 health a bullet are only the amounts `KillMods` pays here, and a level-IV or V owner has III, so `BeginPellet`
/// finds the gun as before. Midas V is NOT here: it scales the hit's damage, not its points (`KillMods.GoldStandardScale`, in
/// `Health`).
/// </summary>
public static class ShotPoints
{
/// <summary>How many zombies one bullet may be paid for. 4.</summary>
public static int PenetrationCap { get => _cap ?? 4; set => _cap = value; }
static int? _cap;
/// <summary>
/// MIDAS III GOLDEN LINE (2026-10-05): the cap for a bullet from a Midas gun whose owner has level III. 8 — the user: *"the
/// limit of points given per bullet doubles"*, then *"for midas that cap increases to 8 then"*. Rule 2 stays: a shotgun
/// blast still pays for its best pellet alone.
/// </summary>
public static int GoldenLineCap { get => _goldenLineCap ?? 8; set => _goldenLineCap = value; }
static int? _goldenLineCap;
/// <summary>Turn the whole limit off — `nz_shot_points 0`. For comparing against the old economy.</summary>
public static bool Enabled { get; set; } = true;
// ⚠️ THE MOST ANY ONE PELLET HAS BEEN PAID FOR THIS SHOT, and the count for the pellet in
// flight. Two ints are the entire mechanism; see the worked example in the header.
static int _bestPaid;
static int _thisPellet;
// ⚠️ THE BULLET IN FLIGHT'S UPGRADED MOD (2026-10-05): "midas", "scrapper" or "leech" when its gun carries that mod and the
// shooter owns its level III, null otherwise; and that shooter. Set by `BeginPellet`, the one place the gun is known — a
// Flechette shard is asked inside its pellet, so it goes with its bullet. Machine-local like the counters above.
static string _pelletMod;
static NZPlayer _pelletShooter;
// ⚠️ HAS THE BULLET IN FLIGHT HIT A ZOMBIE YET — Leech III heals once a bullet, however many it goes through.
static bool _pelletLanded;
/// <summary>Counters for `nz_shot_points`.</summary>
public static int Paid, Refused;
/// <summary>
/// Rule 1's cap for a bullet: <see cref="PenetrationCap"/>, or for a Midas III gun <see cref="GoldenLineCap"/> — never below
/// the base, so a cap raised from the console is not an upgrade's loss.
/// </summary>
public static int CapFor( bool goldenLine )
{
var cap = System.Math.Max( 1, PenetrationCap );
return goldenLine ? System.Math.Max( cap, GoldenLineCap ) : cap;
}
/// <summary>A trigger was pulled. Called once per shot, outside Double Tap's second pass.</summary>
public static void BeginShot()
{
_bestPaid = 0;
// ⚠️ NO GUN UNTIL A PELLET NAMES ONE (2026-10-05), so nothing can read the last shot's upgrades.
BeginPellet( null );
}
/// <summary>
/// A pellet is leaving the barrel. Called once per bullet, before its penetrations.
///
/// ⚠️ <paramref name="weapon"/> IS THE GUN FIRING IT (2026-10-05), for the level IIIs that change a hit. Resolved once a
/// bullet, the way `HitScan` hoists its tech reads: the shooter's levels first, which are dictionary reads, and the gun's
/// mod only when one of those is owned — `AmmoMods.On` builds the catalogue to find it, and this runs per pellet.
/// ⚠️ NO DEFAULT: a bullet path that left it out would quietly drop Midas III, Scrapper III and Leech III.
/// </summary>
public static void BeginPellet( SWB.Base.Weapon weapon )
{
_thisPellet = 0;
_pelletLanded = false;
_pelletMod = null;
_pelletShooter = null;
var tech = TechEffects.Of( weapon );
if ( !tech.Valid ) return;
var p = tech.Player;
if ( !AmmoModUpgrades.Has( p, "midas", 3 ) && !AmmoModUpgrades.Has( p, "scrapper", 3 )
&& !AmmoModUpgrades.Has( p, "leech", 3 ) )
return;
var mod = AmmoMods.On( p, tech.Prefab )?.Id;
if ( (mod is "midas" or "scrapper" or "leech") && AmmoModUpgrades.Has( p, mod, 3 ) )
{
_pelletMod = mod;
_pelletShooter = p;
}
}
/// <summary>
/// Should this hit pay points.
///
/// ⚠️ IT MUTATES, so it must be asked EXACTLY ONCE PER HIT and its answer carried. Asking twice
/// consumes two of the four penetration slots for one zombie.
/// </summary>
public static bool Pays( GameObject hit )
{
// ⚠️ NOT A ZOMBIE, NOT OUR BUSINESS. Nothing pays points for a wall, so refusing here would
// change nothing except to make the counters lie.
// ⚠️ ASKED BEFORE `Enabled` SINCE 2026-10-05, which it followed: with the limit off every hit still pays, and Leech III
// and Scrapper III below must still see the zombie ones.
if ( !hit.IsValid()
|| hit.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) is null )
return true;
// ⚠️ LEECH III SIPHON (2026-10-05): the bullet's FIRST zombie heals, whether or not it pays — the user's first Leech idea
// (2026-10-04): *"gaining 1 hp per shot fired that hits, meaning penetration still counts as just 1"*. 3 with Leech V
// (VAMPIRE, 2026-10-06), still once a bullet.
if ( !_pelletLanded )
{
_pelletLanded = true;
if ( _pelletMod == "leech" ) KillMods.Siphon( _pelletShooter );
}
if ( !Enabled ) return Paying();
// ── rule 1: the first N bodies of this bullet ──
// ⚠️ N IS GOLDEN LINE'S FROM A MIDAS III GUN (2026-10-05).
if ( _thisPellet >= CapFor( _pelletMod == "midas" ) ) { Refused++; return false; }
_thisPellet++;
// ── rule 2: only the best pellet of this shot ──
if ( _thisPellet <= _bestPaid ) { Refused++; return false; }
_bestPaid = _thisPellet;
Paid++;
return Paying();
}
/// <summary>
/// A zombie hit that pays — and from a Scrapper III gun, salvage with it (SCRAP DRIP, 2026-10-05). The user: *"for Scrapper III
/// change it to every hit gives 1 scrap"*, on this class's rule, so a 12-pellet blast pays for its best pellet alone.
/// ⚠️ 2 FROM A SCRAPPER IV GUN (SCRAP STREAM, 2026-10-06), on the same rule — `KillMods.ScrapDrip` picks the amount.
/// </summary>
static bool Paying()
{
if ( _pelletMod == "scrapper" ) KillMods.ScrapDrip( _pelletShooter );
return true;
}
/// <summary>
/// `nz_shot_points [on] [cap] [golden]` — the limit, and what it has been doing. `golden` is Midas III's cap.
///
/// ⚠️ THE COUNTERS ARE THE POINT. "Points feel low" and "the cap is eating hits it should not"
/// are the same complaint from opposite sides, and the paid/refused ratio is what separates
/// them: a rifle should refuse almost nothing, a six-pellet shotgun should refuse most of it.
/// </summary>
[ConCmd( "nz_shot_points" )]
public static void Cmd( int on = -1, int cap = -1, int golden = -1 )
{
if ( on >= 0 ) Enabled = on > 0;
if ( cap > 0 ) PenetrationCap = cap;
if ( golden > 0 ) GoldenLineCap = golden;
Log.Info( $"[nz] shot points {(Enabled ? "LIMITED" : "unlimited (old economy)")}"
+ $" · up to {PenetrationCap} zombie(s) per bullet ({CapFor( goldenLine: true )} from a Midas III gun), one pellet per shot"
+ $" · paid {Paid}, refused {Refused}" );
}
}