Pickups/PickupDrops.cs

Static helper that decides and spawns item drops when a zombie dies. It implements salvage, armor plate and Vulture perk drops, applies wave falloff, perk augment modifiers, caps and cooldowns, and exposes console commands to tune and report drop-related values.

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// What a dying zombie leaves behind.
///
/// ⚠️ TWO DIFFERENT KINDS OF DROP, AND THEY ARE NOT SYMMETRIC:
///
///   • Salvage and armor plates drop from the ZOMBIE. Anyone can take them, the
///     chance depends only on what died, and no perk is involved.
///   • Vulture drops come from the KILLER. They need the perk, they are owned by
///     that player, and they are capped at four of theirs alive at once.
///
/// Rolling both through one uniform "chance per death" would be wrong for whichever
/// one lost the argument, so they are deliberately separate rolls here.
///
/// ⚠️ NO PER-ROUND CAP, unlike PowerupDrops. Powerups are rare and dramatic, so the
/// original caps them per round; salvage and plates are ordinary income and the
/// original caps neither. Adding one would quietly starve the economy in long rounds
/// — which is the opposite of what a long round should pay.
/// </summary>
public static class PickupDrops
{
	/// <summary>Master switch for every drop type here.</summary>
	public static bool Enabled { get; set; } = true;

	/// <summary>
	/// Chance a Vulture Aid holder gets a drop, per kill.
	///
	/// ⚠️ 1-IN-9, from the original's `local chance = 9; math.random(chance) == 1`
	/// (enemies/sv_hooks.lua:150). The upgraded perk moves it to 1-in-5 there, but
	/// s&amp;box has no perk-upgrade concept, so only the base rate exists here.
	/// </summary>
	public static int VultureOneIn { get; set; } = 9;

	/// <summary>Most Vulture drops one player may have on the floor at once.
	/// (`nz.VultureCount &lt; 4`)</summary>
	public static int VultureMaxLive { get; set; } = 4;

	/// <summary>
	/// The fraction of max reserve a Vulture AMMO drop gives, before augments. 4 to 8.5 per cent.
	///
	/// ⛔️ WAS 5-10, AND IT WAS TOO MUCH. A shotgun's reserve is small (49-60 shells), so a drop
	/// at the old numbers handed back a full clip and the gun stopped consuming -- reported as
	/// "my shotgun never ran out of ammo basically", running M1 Carrion with m1 Scavenger.
	///
	/// ⚠️ THE AUTHORED BASE LIVES HERE, and now genuinely only here. It used to be written inline
	/// at the Pickup.cs call AND a second time in the nz_aug_vulture report, so the diagnostic
	/// would have gone on printing 5-10 after this was tuned -- a stat lying about itself in the
	/// one place you would look to check it.
	///
	/// ⚠️ LOW/HIGH, NOT MIN/MAX, AND THE RENAME IS THE POINT. A hotload copies statics forward BY
	/// NAME and skips the initialiser, so re-tuning a static under its old name leaves the running
	/// editor on the old value while the source says otherwise (INSTRUCTIONS.md).
	/// </summary>
	public static float VultureAmmoLow { get; set; } = 0.04f;

	/// <summary>Top of the ammo range. See <see cref="VultureAmmoLow"/>.</summary>
	public static float VultureAmmoHigh { get; set; } = 0.085f;

	/// <summary>
	/// Relative weight of ammo against points in the Vulture table. Was 2, now 1.
	///
	/// ⚠️ THIS IS THE "HOW OFTEN", where VultureAmmoLow/High are the "how much" -- both were
	/// asked for. Ammo falls from 2-of-5 drops (40 per cent) to 1-of-4 (25).
	///
	/// ⛔️ IT DOES NOT CHANGE HOW OFTEN A DROP HAPPENS, only which one it is. The 1-in-N roll is
	/// untouched, so points drops become correspondingly MORE common (60 per cent of drops to 75).
	/// That is the trade for nerfing ammo without touching the perk's overall generosity -- if the
	/// whole perk should give less, VultureOneIn is the lever, not this.
	/// </summary>
	public static int VultureAmmoWeight { get; set; } = 1;

	/// <summary>Relative weight of points in the Vulture table. The original's 3.</summary>
	public static int VulturePointsWeight { get; set; } = 3;

	/// <summary>
	/// Chance a Vulture Aid kill leaves a gas cloud. 8%.
	///
	/// ⚠️ SMALL ON PURPOSE, AND THE COOLDOWN IS THE REAL LIMIT. At 8% a busy round rolls
	/// this dozens of times, so without the cooldown the map would be permanently fogged —
	/// the chance decides whether it feels earned, the cooldown decides whether it feels
	/// rare.
	/// </summary>
	public static float GasChance { get; set; } = 0.08f;

	/// <summary>
	/// Seconds before another gas cloud may drop for the same player. 20.
	///
	/// ⚠️ LONGER THAN THE CLOUD LIVES (12s), deliberately, so there is a visible gap between
	/// one clearing and the next appearing. A cooldown shorter than the lifetime would let
	/// two overlap and the effect would read as continuous rather than as an event.
	/// </summary>
	public static float GasCooldown { get; set; } = 20f;

	/// <summary>
	/// Relative weights for which Vulture drop appears.
	///
	/// ⚠️ THE ORIGINAL'S WEIGHTS, MINUS WHAT WE CANNOT BUILD. It offers points 3,
	/// ammo 2, armor 2 and gas 1. Armor needs `models/items/battery.mdl` — an HL2
	/// stock model that is in no workshop pack and has not been extracted — and gas
	/// needs a particle system, so both are omitted rather than stubbed. Points and
	/// ammo keep their 3:2 ratio, so the mix between them is the original's.
	/// </summary>
	static (PickupKind Kind, int Weight)[] VultureTable => new[]
	{
		(PickupKind.VulturePoints, VulturePointsWeight),
		(PickupKind.VultureAmmo, VultureAmmoWeight),
	};

	/// <summary>
	/// Roll every drop type for one death.
	///
	/// ⚠️ `killer` may be null — a zombie can die to the world, a trap, or a Nuke.
	/// Salvage and plates still drop in that case; Vulture cannot, because it has
	/// nobody to check a perk on or hand ownership to.
	/// </summary>
	public static void RollOnDeath( Vector3 pos, GameObject killer, bool isSpecial,
		GameObject corpse = null )
	{
		if ( !Enabled ) return;

		// Lift it clear of the corpse, as the original does (`+ Vector(0,0,32)`),
		// or the drop spawns inside the body and reads as buried in the floor.
		var at = pos + Vector3.Up * 32f;

		// ⚠️ THE KILLER REACHES THE SALVAGE AND PLATE ROLLS TOO, now that Vulture Aid's M1
		// Carrion scales every drop rate rather than only its own. Those two rolls used to
		// be player-independent, which is why they did not take it.
		var by = killer.IsValid()
			? killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;

		RollSalvage( at, isSpecial, corpse, by );
		RollPlate( at, isSpecial, corpse, by );
		RollVulture( at, killer );

		// ⚠️ THE GAS IS NOT A PICKUP, so it does not go through `Pickup.Spawn` and is not
		// counted against `VultureMaxLive`. The original made it a collectible drop that
		// expired in 12 seconds; here the cloud simply appears where the zombie died, which
		// removes a step the player was never really choosing to take.
		//
		// ⚠️ PASSED `pos`, NOT `at`. The other three are lifted 32 units clear of the corpse
		// so they do not read as buried; the gas is a GROUND effect and rises on its own, so
		// lifting it would start the cloud at knee height and miss the floor entirely.
		// ⛔ MOVED TO THE KILLER'S OWN MACHINE — see `AugmentEffects.OnZombieKilled`. Four separate
		// reads inside `RollGas` ask the killer what they own: M2's chance, its cooldown, its
		// lifetime, and Timeslip m4's shortening of that cooldown. All four asked a proxy.
		//
		// ⚠️ IT COULD NOT BE FIXED WITH SYNCED SCALARS LIKE THE DROP ROLLS BESIDE IT, because it
		// also SPAWNS a cloud that a second augment then QUERIES — Vulture m3 Gas Feed reads the
		// cloud list on its owner's machine. Publishing four numbers would have made the roll
		// correct and left the thing it produces on the wrong computer.
	}

	// ── salvage + plates: from the zombie, unowned ───────────────────────────

	/// <summary>
	/// How much the salvage drop chance is scaled down for the size of the current wave.
	/// 1 early, falling toward <see cref="SalvageSettings.DropFalloffFloor"/> as waves grow.
	///
	/// ⚠️ READS `WaveTotal` OFF THE LIVE ROUND, not a round number. That figure already folds
	/// in player count and the early-round scaling table, so a 4-player game gets the same
	/// treatment without this having to know anything about either.
	///
	/// ⚠️ RETURNS 1 WHEN THERE IS NO ROUND MANAGER OR NO WAVE YET. A zombie killed outside a
	/// wave — the editor, a test spawn — should drop at the authored rate rather than at
	/// whatever a division by zero produces.
	/// </summary>
	public static float SalvageFalloff( SalvageSettings cfg )
	{
		if ( cfg.DropFalloffExponent <= 0f ) return 1f;

		var rm = RoundManager.Instance;
		var wave = rm.IsValid() ? rm.WaveTotal : 0;
		var reference = MathF.Max( 1f, cfg.DropFalloffReference );

		if ( wave <= reference ) return 1f;

		var scale = MathF.Pow( reference / wave, cfg.DropFalloffExponent );
		return MathF.Max( cfg.DropFalloffFloor.Clamp( 0f, 1f ), scale );
	}

	static void RollSalvage( Vector3 at, bool isSpecial, GameObject corpse, NZPlayer by )
	{
		var cfg = ActiveConfig.Salvage;
		if ( !cfg.Enabled ) return;

		// ⛔ THE KILLER'S OWN, OR NOBODY'S (2026-09-27 — *"only that player can see and pick up the salvage"*). A death with no
		// player behind it — the world, a trap, a Nuke, a Shrieker's death pulse — has nobody for it to belong to.
		if ( !by.IsValid() ) return;

		// ⚠️ TIMES THE MATCH'S SALVAGE DROPS (the lobby's Difficulty, 2026-10-05)
		var chance = (isSpecial ? cfg.DropChanceSpecial : cfg.DropChance) * Difficulty.SalvageDrops;

		// ⛔ THE WAVE FALLOFF GOES FIRST, BEFORE CARRION. Salvage income is
		// `zombies × chance × perPickup` and only `zombies` ever moved, so income rose
		// tenfold from round 5 to 50 and then paid a flat 7,200 forever once WaveTotal
		// pinned at its cap. See SalvageSettings.DropFalloffExponent.
		//
		// ⚠️ BEFORE CARRION SO THE AUGMENT STILL MULTIPLIES WHAT THE PLAYER ACTUALLY GETS.
		// Applying it after would scale Carrion's own contribution down too, which reads as
		// the augment weakening as the game goes on — a different and much worse change.
		chance *= SalvageFalloff( cfg );

		// ⚠️ CARRION SCALES THE RESOLVED CHANCE, so it lifts the special rate as well as the
		// normal one. Scaling only the base would have made the augment quietly do nothing
		// on the enemies it matters most against.
		chance = VultureAugments.DropChance( by, chance );

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

		// ⚠️ TO THE KILLER ALONE: on their machine, seen and taken by them only (`Pickup.DropFor`)
		Pickup.DropFor( by, at, PickupKind.Salvage, corpse );
	}

	static void RollPlate( Vector3 at, bool isSpecial, GameObject corpse, NZPlayer by )
	{
		var cfg = ActiveConfig.Armor;
		if ( !cfg.Enabled ) return;

		// ⚠️ TIMES THE MATCH'S PLATE DROPS (the lobby's Difficulty, 2026-10-05)
		var chance = (isSpecial ? cfg.PlateDropChanceSpecial : cfg.PlateDropChance) * Difficulty.PlateDrops;
		chance = VultureAugments.DropChance( by, chance );

		// ⚠️ STACKS WITH VULTURE'S CARRION rather than replacing it. Both are
		// multipliers on this one roll, so the order is presentational.
		chance = DeathAugments.PlateChance( by, chance );

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

		Pickup.Spawn( at, PickupKind.ArmorPlate, null, corpse );
	}

	// ── vulture: from the killer, owned, capped ──────────────────────────────

	/// <summary>
	/// Vulture Aid's drop.
	///
	/// ⛔ RESOLVED FROM THE KILLER, NOT FROM `GetAllComponents&lt;NZPlayer&gt;().First()`.
	/// The perk belongs to whoever pulled the trigger, and SERVER_ROADMAP.md §2A
	/// counts 72 first-player-found sites already and asks for no more. Health now
	/// records LastAttacker precisely so this one does not have to be the 73rd.
	/// </summary>
	static void RollVulture( Vector3 at, GameObject killer )
	{
		if ( !killer.IsValid() ) return;

		var player = killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
		if ( !player.IsValid() ) return;

		if ( !PerkEffects.HasVulture( player ) ) return;

		// ⚠️ The cap is checked BEFORE the roll, matching the original's
		// `math.random(chance) == 1 and VultureCount < 4`. Rolling first and then
		// discarding would make the effective rate depend on how many drops happen to
		// be lying around, which is not what the numbers describe.
		if ( player.VultureDrops >= VultureMaxLive ) return;

		// ⚠️ CARRION TIGHTENS THE ONE-IN-N: 1-in-9 becomes 1-in-6.
		var oneIn = VultureAugments.OneIn( player, VultureOneIn );

		// ⛔ DEEP POCKETS' EXTRA ROLL IS SEPARATE AND INDEPENDENT, rather than a heavier ammo
		// weight in the table, and the reason is arithmetic: the table is a fixed 3:2 split
		// of ONE 1-in-9 roll, so doubling ammo's weight would have cut points drops from 60%
		// of drops to 43%. "Ammo twice as common" must not halve your points income. This
		// can therefore drop TWO pickups on one kill, which is intended.
		if ( VultureAugments.HasExtraAmmoRoll( player )
			&& Game.Random.Int( 1, Math.Max( 1, oneIn ) ) == 1 )
		{
			Pickup.Spawn( at, PickupKind.VultureAmmo, player );

			// ⚠️ RE-CHECKED, because the spawn above just incremented the live count and the
			// cap has to hold across both rolls.
			if ( player.VultureDrops >= VultureMaxLive ) return;
		}

		if ( Game.Random.Int( 1, Math.Max( 1, oneIn ) ) != 1 ) return;

		var kind = PickWeighted();

		// ⚠️ Pickup.Spawn does the counting — it owns both the increment and the
		// matching decrement in Release(), so a failed spawn cannot leak a slot and
		// the two halves cannot drift apart.
		Pickup.Spawn( at, kind, player );
	}

	/// <summary>
	/// Vulture Aid's gas cloud — a small chance per kill, rate-limited per player.
	///
	/// ⛔ THE COOLDOWN IS CHECKED BEFORE THE ROLL, matching how `RollVulture` above checks
	/// its cap first. Rolling and then discarding would make the effective rate depend on
	/// how recently the last cloud dropped, which is not what an 8% chance describes — and
	/// it would burn the roll on kills that could never have produced anything.
	///
	/// ⚠️ THE COOLDOWN IS ONLY STAMPED ON A SUCCESS. A failed roll costs nothing, so the
	/// player is not silently rate-limited by bad luck on top of the chance.
	/// </summary>
	public static void RollGas( Vector3 pos, GameObject killer )
	{
		if ( !killer.IsValid() ) return;

		var player = killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
		if ( !player.IsValid() ) return;

		if ( !PerkEffects.HasVulture( player ) ) return;
		if ( player.VultureGasReady > 0f ) return;

		// ⚠️ ALL THREE GAS NUMBERS ARE RESOLVED IN ONE PLACE, so M2 Gas Cloak cannot move the
		// chance and leave the cooldown behind — the shape of bug that makes an augment feel
		// like it half works.
		var chance = VultureAugments.GasChance( player, GasChance );
		var cooldown = VultureAugments.GasCooldown( player, GasCooldown );
		var life = VultureAugments.GasLifetime( player );

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

		if ( VultureStink.Spawn( pos, life ) is null ) return;

		// ⚠ Timeslip m4 Time Warp shortens this too — see the note at PhdAugments' sprint
		// cooldown. Wrapped at the ASSIGNMENT because `TimeUntil` is an absolute deadline: once
		// set there is nothing left to speed up.
		player.VultureGasReady = TimeAugments.Cooldown( player, cooldown );

		Log.Info( $"[nz-stink] gas from a kill — next in {GasCooldown:0.#}s" );
	}

	static PickupKind PickWeighted()
	{
		var table = VultureTable;
		var total = table.Sum( e => e.Weight );

		var roll = Game.Random.Int( 1, Math.Max( 1, total ) );

		foreach ( var (kind, weight) in table )
		{
			roll -= weight;
			if ( roll <= 0 ) return kind;
		}

		return table[0].Kind;
	}

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

	/// <summary>
	/// `nz_vulture_ammo [lowPct] [highPct] [weight]` -- tune the ammo economy in play.
	///
	/// ⛔️ EVERY NUMBER GETS A COMMAND. This one is a balance value that can only be judged by
	/// playing, and the alternative is a rebuild per guess -- which is how four reasoned guesses
	/// in a row got made once before instead of one adjustable control.
	///
	/// ⚠️ PERCENTAGES, NOT FRACTIONS, because that is how the report prints them and how they
	/// were discussed. `nz_vulture_ammo 4 8.5 1` restates the current defaults.
	/// </summary>
	[ConCmd( "nz_vulture_ammo" )]
	public static void VultureAmmoCmd( float lowPct = -1f, float highPct = -1f, int weight = -1 )
	{
		if ( lowPct >= 0f ) VultureAmmoLow = lowPct / 100f;
		if ( highPct >= 0f ) VultureAmmoHigh = highPct / 100f;
		if ( weight >= 0 ) VultureAmmoWeight = weight;

		// ⚠️ ORDER ENFORCED, not assumed. Game.Random.Float with min above max does not throw, it
		// returns nonsense, and a silently inverted range would read as the drop being broken.
		if ( VultureAmmoHigh < VultureAmmoLow )
			(VultureAmmoLow, VultureAmmoHigh) = (VultureAmmoHigh, VultureAmmoLow);

		Cmd();
	}

	/// <summary>
	/// `nz_salvage_falloff [exponent] [reference] [floor]` — the wave falloff, and the curve
	/// it produces.
	///
	/// ⛔ IT PRINTS THE WHOLE CURVE, NOT THE CURRENT SCALE. A single "×0.42 right now" says
	/// nothing about whether the shape is right, and the shape is the entire decision — the
	/// difference between exponents is invisible at any one round and enormous over a run.
	///
	/// ⚠️ SALVAGE PER ROUND IS WHAT IT SHOWS, not the chance. The chance falling is the
	/// mechanism; income per round is the thing being balanced, and the two move in opposite
	/// directions as the wave grows.
	/// </summary>
	[ConCmd( "nz_salvage_falloff" )]
	public static void SalvageFalloffCmd( float exponent = -1f, int reference = -1,
		float floor = -1f )
	{
		var cfg = ActiveConfig.Salvage;
		if ( exponent >= 0f ) cfg.DropFalloffExponent = exponent;
		if ( reference > 0 ) cfg.DropFalloffReference = reference;
		if ( floor >= 0f ) cfg.DropFalloffFloor = floor.Clamp( 0f, 1f );

		var players = Math.Max( 1, Game.ActiveScene?.GetAllComponents<NZPlayer>()?.Count() ?? 1 );

		Log.Info( $"[nz-salvage] falloff ^{cfg.DropFalloffExponent:0.##}"
			+ $" · reference wave {cfg.DropFalloffReference}"
			+ $" · floor {cfg.DropFalloffFloor * 100f:0.#}% of base"
			+ $" · {cfg.DropChance * 100f:0.#}% base, {cfg.PerPickup} each · {players} player(s)" );

		foreach ( var r in new[] { 5, 10, 20, 30, 40, 50, 60 } )
		{
			var wave = ZombieStats.WaveTotal( r, players );
			var reff = MathF.Max( 1f, cfg.DropFalloffReference );
			var scale = cfg.DropFalloffExponent <= 0f || wave <= reff
				? 1f
				: MathF.Max( cfg.DropFalloffFloor.Clamp( 0f, 1f ),
					MathF.Pow( reff / wave, cfg.DropFalloffExponent ) );

			var chance = cfg.DropChance * scale;
			Log.Info( $"[nz-salvage]   round {r,2}  {wave,3} zombies"
				+ $"  chance {chance * 100f,5:0.#}%"
				+ $"  ->{wave * chance * cfg.PerPickup,7:0} salvage"
				+ $"  (was {wave * cfg.DropChance * cfg.PerPickup,7:0})" );
		}
	}

	/// <summary>Report and tune: nz_drops [vultureOneIn]</summary>
	[ConCmd( "nz_drops" )]
	public static void Cmd( int vultureOneIn = -1 )
	{
		if ( vultureOneIn > 0 ) VultureOneIn = vultureOneIn;

		var sal = ActiveConfig.Salvage;
		var arm = ActiveConfig.Armor;

		Log.Info( $"[nz-drops] {(Enabled ? "on" : "OFF")}" );
		Log.Info( $"[nz-drops]   salvage  {sal.DropChance * 100f:0.#}% normal"
			+ $" / {sal.DropChanceSpecial * 100f:0.#}% special"
			+ $"  ({sal.PerPickup} each){(sal.Enabled ? "" : "  [DISABLED]")}" );
		Log.Info( $"[nz-drops]   plates   {arm.PlateDropChance * 100f:0.#}% normal"
			+ $" / {arm.PlateDropChanceSpecial * 100f:0.#}% special"
			+ $"{(arm.Enabled ? "" : "  [DISABLED]")}" );
		Log.Info( $"[nz-drops]   vulture  1-in-{VultureOneIn}"
			+ $" ({100f / MathF.Max( 1, VultureOneIn ):0.#}%) on a PERK HOLDER's kill,"
			+ $" max {VultureMaxLive} live" );

		// ⚠️ THE SPLIT AND THE SIZE TOGETHER, because neither one is the answer on its own. "How
		// often is it ammo" and "how much ammo" multiply, and the ammo economy that was reported
		// as too generous was the product of the two -- reading either alone hides it.
		var wTotal = MathF.Max( 1, VulturePointsWeight + VultureAmmoWeight );

		Log.Info( $"[nz-drops]     split  points {VulturePointsWeight}"
			+ $" ({VulturePointsWeight / wTotal * 100f:0.#}%)"
			+ $" · ammo {VultureAmmoWeight} ({VultureAmmoWeight / wTotal * 100f:0.#}%)" );

		Log.Info( $"[nz-drops]     ammo   {VultureAmmoLow * 100f:0.#}-{VultureAmmoHigh * 100f:0.#}%"
			+ $" of reserve base"
			+ $" · {VultureAugments.ScavengerAmmoLow * 100f:0.#}"
			+ $"-{VultureAugments.ScavengerAmmoHigh * 100f:0.#}% with m1 Scavenger"
			+ $" · x{VultureAugments.PocketsAmmoScale:0.#} with m4" );

		// ⚠️ THE EFFECTIVE PER-KILL RATE, spelled out for the build that prompted the tuning.
		// Three multiplied fractions is not a sum anyone should have to do at a console.
		var carrion = MathF.Max( 1, MathF.Round( VultureOneIn / VultureAugments.CarrionScale ) );
		var perKill = 1f / carrion * (VultureAmmoWeight / wTotal);
		var mean = (VultureAugments.ScavengerAmmoLow + VultureAugments.ScavengerAmmoHigh) * 0.5f;

		Log.Info( $"[nz-drops]     M1+m1  1-in-{carrion:0} x {VultureAmmoWeight / wTotal * 100f:0.#}%"
			+ $" = {perKill * 100f:0.##}% of kills give ammo,"
			+ $" mean {mean * 100f:0.#}% of reserve"
			+ $"  ->  {perKill * mean * 100f:0.###}% of a reserve per kill" );

		var p = NZPlayer.Local;
		if ( p.IsValid() )
			Log.Info( $"[nz-drops]   you: vulture {(PerkEffects.HasVulture( p ) ? "YES" : "no")}"
				+ $", {p.VultureDrops}/{VultureMaxLive} of yours on the floor" );
	}
}