Weapons/ShotPoints.cs

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.

File Access
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}" );
	}
}