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.
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&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 < 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<NZPlayer>().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" );
}
}