Powerups/PowerupDrops.cs

Static helper that controls powerup drops from dying zombies. It stores global settings (chance, per-round cap, enabled), tracks how many have dropped this round, chooses random powerup kinds, enforces special-round guaranteed MaxAmmo, and exposes a console command to tune values.

NetworkingFile Access
using Sandbox;

namespace NZombies;

/// <summary>
/// Whether a dying zombie leaves a powerup behind.
///
/// ⛔ THE CAP IS PER ROUND, NOT PER MINUTE OR PER LIFE. The original counts drops
/// against the round and stops — which is what keeps a long round from raining
/// powerups while a fast one gives none. Without it, 2% of a round-30 horde is a
/// dozen drops.
/// </summary>
public static class PowerupDrops
{
	/// <summary>Chance per zombie death, 0-1.</summary>
	public static float Chance { get; set; } = 0.02f;

	/// <summary>How many may drop in a single round.</summary>
	public static int MaxPerRound { get; set; } = 4;

	/// <summary>Master switch.</summary>
	public static bool Enabled { get; set; } = true;

	static int _thisRound;
	static int _lastRound = -1;

	/// <summary>How many have dropped in the current round.</summary>
	public static int DroppedThisRound => _thisRound;

	/// <summary>
	/// The kinds a kill can produce.
	///
	/// ⚠️ ONLY THE IMPLEMENTED KINDS. DeathMachine is in the enum but
	/// has no model, no cue and no effect — dropping one would put a fallback ammo
	/// can on the floor that announces nothing and does nothing.
	///
	/// ⚠️ Uniform for now. The original weights its table (Max Ammo is commoner than
	/// a Nuke); worth revisiting once these have been played with, but a weighting
	/// invented before anyone has felt the frequency is a guess dressed as a rule.
	/// </summary>
	static readonly PowerupKind[] Table =
	{
		PowerupKind.MaxAmmo,
		PowerupKind.InstaKill,
		PowerupKind.DoublePoints,
		PowerupKind.BonusPoints,
		PowerupKind.Carpenter,
		PowerupKind.Nuke,
		PowerupKind.FireSale,
	};

	/// <summary>
	/// One random droppable kind.
	///
	/// ⛔ EXISTS SO THE TABLE STAYS PRIVATE. Soul boxes reward a powerup too, and the alternative
	/// was making `Table` visible — which would let a second caller drift from the "only the
	/// implemented kinds" rule above the moment someone adds an eighth. One place decides what can
	/// drop; everything else asks.
	/// </summary>
	public static PowerupKind RandomKind()
		=> Table[Game.Random.Int( 0, Table.Length - 1 )];

	/// <summary>
	/// Reset the counter when the round changes.
	///
	/// ⛔ DETECTED HERE RATHER THAN HOOKED INTO THE ROUND MANAGER. A round can change
	/// by five different routes — cleared, `nz_round_set`, `nz_round_next`,
	/// `nz_round_prev`, a restart — and hooking one of them means the counter
	/// silently survives the other four. Comparing the number every roll cannot miss
	/// a path.
	/// </summary>
	/// <summary>
	/// Was this the kill that finished a special round.
	///
	/// ⚠️ `Alive <= 0`, not `<= 1`. ZombieAI.Die sets the state to Dead on its FIRST
	/// line, well before drops roll, so the zombie that just died is already out of
	/// the Alive count. Testing `<= 1` here would pay out on the second-to-last kill.
	///
	/// ⚠️ Remaining counts what is still to SPAWN, not what is still alive — so both
	/// halves are needed. This is the same pair the round-end check uses, deliberately:
	/// "the wave is finished" should not have two definitions.
	///
	/// ⛔ AliveBlocking, FOR EXACTLY THE REASON THE LINE ABOVE GIVES. Bosses no longer hold a round
	/// open, so the round-end check moved from `Alive` to `AliveBlocking` — and this test had to move
	/// with it or the two definitions would have diverged the moment a boss survived into a special
	/// round: the round would end while this never fired, and the hound-round powerup would silently
	/// stop dropping. The comment above predicted this; it is being honoured rather than quoted.
	/// </summary>
	static bool LastOfSpecialRound()
	{
		var rm = RoundManager.Instance;
		if ( !rm.IsValid() ) return false;

		return rm.InSpecialRound && rm.Remaining <= 0 && rm.AliveBlocking <= 0;
	}

	static void SyncRound()
	{
		var round = RoundManager.Instance?.Round ?? 0;

		if ( round == _lastRound ) return;

		_lastRound = round;
		_thisRound = 0;
	}

	/// <summary>
	/// Roll for a drop at a dead zombie's position.
	///
	/// ⚠️ Called from the zombie's own death, so a zombie killed by ANYTHING — shot,
	/// knifed, grenaded, nuked — can drop. A hook on the weapon path would have
	/// meant no drops from a Nuke, which is where the original's do come from.
	/// </summary>
	/// <summary>
	/// ⛔ THE KILLER WAS ADDED FOR DEATH PERCEPTION'S m2 FORTUNE'S SENSE. This
	/// took a position only, unlike `PickupDrops.RollOnDeath` right beside it at the same
	/// death site, which already took the killer for Vulture Aid. A perk that changes YOUR
	/// power-up rate cannot read a rate that belongs to nobody.
	///
	/// ⚠️ NULLABLE, and every caller but the death site can leave it out. The roll
	/// is player-independent without an augment, which is what it was before.
	/// </summary>
	public static void RollOnDeath( Vector3 pos, GameObject killer = null )
	{
		if ( !Enabled ) return;

		SyncRound();

		// ⛔ THE LAST KILL OF A SPECIAL ROUND ALWAYS PAYS A MAX AMMO. Ported from
		// enemies/sv_hooks.lua:374:
		//
		//     if nzRound:IsSpecial() and nzRound:GetZombiesKilled() >= nzRound:GetZombiesMax()
		//         then nzPowerUps:SpawnPowerUp(enemy:GetPos(), "maxammo")
		//
		// ⚠️ GUARANTEED, SO IT SKIPS BOTH GATES BELOW — not the chance roll and not
		// MaxPerRound. A dog round that happened to have used its four drops would
		// otherwise silently owe you the ammo, and the whole point of the hound round
		// is that it hands your ammunition back.
		//
		// ⚠️ It also does NOT increment _thisRound. Counting it would let a guaranteed
		// reward eat one of the round's random slots, quietly making powerups rarer in
		// exactly the rounds that already pay one.
		if ( LastOfSpecialRound() )
		{
			if ( Powerup.Spawn( pos, PowerupKind.MaxAmmo ) is not null )
				Log.Info( "[nz] special round cleared — Max Ammo" );

			// ⚠️ Returns, so the same death cannot also roll a random powerup on top.
			// The original's random roll runs BEFORE its special-round check and can
			// stack them; ours would be two powerups landing in the same spot, which
			// reads as a bug rather than a bonus.
			return;
		}

		// ⚠️ THE MATCH'S POWER-UP DROPS (the lobby's Difficulty, 2026-10-05) scale the cap with the chance: a late round reaches
		// the cap whatever the chance, so the chance alone would change nothing past the first rounds.
		var cap = Difficulty.PowerupCap( MaxPerRound );
		if ( _thisRound >= cap ) return;

		// ⚠️ m2 FORTUNE'S SENSE SCALES THE ROLL, NOT THE CAP. `MaxPerRound` is
		// checked above and deliberately left alone — the augment makes power-ups
		// come sooner, not make more of them exist than the round allows.
		var chance = DeathAugments.PowerupChance( killer, Chance * Difficulty.PowerupDrops );

		if ( Game.Random.Float() > chance ) return;

		var kind = Table[Game.Random.Int( 0, Table.Length - 1 )];

		if ( Powerup.Spawn( pos, kind ) is null ) return;

		_thisRound++;
		Log.Info( $"[nz] powerup dropped from a kill — {kind} "
			+ $"({_thisRound}/{cap} this round)" );
	}

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>Tune drops: `nz_powerup_drops [chance] [maxPerRound]`.</summary>
	[ConCmd( "nz_powerup_drops" )]
	public static void Cmd( float chance = -1f, int maxPerRound = -1 )
	{
		if ( chance >= 0f )
		{
			Chance = MathX.Clamp( chance, 0f, 1f );
			Enabled = Chance > 0f;
		}

		if ( maxPerRound >= 0 ) MaxPerRound = maxPerRound;

		SyncRound();

		Log.Info( Enabled
			? $"[nz] powerup drops: {Chance * 100f:0.#}% per kill, max {MaxPerRound}/round"
				+ $" — {_thisRound} so far this round"
			: "[nz] powerup drops off" );
	}
}