Player/VultureAugments.cs

Static utility class that implements the Vulture Aid perk augments for a NZombies game. It stores tunable augment values, checks augment ownership, computes scaled drop chances, gas cloak parameters, wildcard weapon roll/arming logic, ammo/points/salvage adjustments, gas-feed reserve regeneration, reach scaling, and provides console commands and reporting utilities.

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

namespace NZombies;

/// <summary>
/// Vulture Aid's augments. Base perk: extra drops from your own kills, plus a gas cloud
/// zombies cannot see into.
///
/// | id | effect | status |
/// |----|--------|--------|
/// | M1 Carrion       | every drop rate x1.5 | redesigned |
/// | M2 Gas Cloak     | gas 12% / 12s cooldown / 15s life | redesigned |
/// | M3 Fortune's Gin | +2 perk slots | as the original |
/// | M4 Wildcard      | every weapon-slot change hands you a random gun; rarity and pack scale to round 50 | redesigned |
/// | m1 Scavenger     | salvage 100, ammo 8-13%, points +30% | ⚠ the ammo half is mostly clipped by the 10-round cap |
/// | m2 Extra Slot    | +1 perk slot | as the original |
/// | m3 Gas Feed      | reserve regenerates a clip's worth per 5s in your gas, linearly | redesigned |
/// | m4 Deep Pockets  | ammo drops twice as common; ammo cap 10 -> 15 rounds | ⚠ "twice as large" is now +5 to the cap |
/// | m5 Long Arms     | 3x pickup reach | redesigned |
///
/// ⚠️ THE BASE PERK'S OWN NUMBERS LIVE IN `PickupDrops`, not here. This file only ever
/// scales them, so there is one authored value per drop and the augment is visibly a
/// multiplier on it — the alternative is two numbers that have to be kept agreeing (§3).
/// </summary>
public static class VultureAugments
{
	// ══ M1 Carrion ════════════════════════════════════════════════════════════

	/// <summary>
	/// Multiplier on every drop chance.
	///
	/// ⚠️ IT DOES NOT TOUCH THE GAS. M2 Gas Cloak owns the gas numbers outright, and a
	/// Carrion that also raised the gas chance would mean stacking them multiplied one
	/// value twice while every other drop scaled once — which reads as Gas Cloak being
	/// stronger on some builds than others for no visible reason.
	/// </summary>
	public static float CarrionScale { get; set; } = 1.5f;

	// ══ M2 Gas Cloak ══════════════════════════════════════════════════════════

	public static float CloakChance { get; set; } = 0.12f;
	public static float CloakCooldown { get; set; } = 12f;
	public static float CloakLifetime { get; set; } = 15f;

	// ══ M3 Fortune's Gin / m2 Extra Slot ══════════════════════════════════════

	public static int GinSlots { get; set; } = 2;
	public static int ExtraSlots { get; set; } = 1;

	// ══ M4 Wildcard ═══════════════════════════════════════════════════════════

	/// <summary>
	/// Chance a weapon-slot change swaps your weapon for a random one.
	///
	/// ⚠️ 1.0 — EVERY SWITCH, as specified. Kept as a knob rather than hardcoded because
	/// "every switch" is a very large behavioural claim, and this is the one number that
	/// turns it into an occasional surprise without touching any code.
	/// </summary>
	public static float WildcardChance { get; set; } = 1f;

	/// <summary>
	/// The round at which Wildcard hands out Legendary MK3.
	///
	/// ⛔ DELIBERATELY NOT `Rarity.MaxTierForRound`, WHICH TOPS OUT AT ROUND 28. The box's
	/// gates are 7/14/21/28 and this curve is asked to reach the top at 50, so they are
	/// genuinely different curves rather than a copy that drifted. Do not "fix" one to
	/// match the other — see `WildcardRarity` for the gates this one uses.
	/// </summary>
	public static int WildcardMaxRound { get; set; } = 50;

	// ══ m1 Scavenger ══════════════════════════════════════════════════════════

	public static int ScavengerSalvage { get; set; } = 100;
	/// <summary>
	/// Scavenger's ammo range, replacing the base. Was 8-13 per cent, now 6.5-11.
	///
	/// ⚠️ CUT ALONGSIDE THE BASE, BY THE SAME PROPORTION, so Scavenger stays worth taking: it is
	/// still about a third more ammo than the base perk gives, which is what the augment claims.
	/// Nerfing only the base would have made the augment relatively STRONGER, which is the
	/// opposite of what was reported.
	///
	/// ⛔️ LOW/HIGH, NOT MIN/MAX: renamed on purpose so a hotload cannot carry the old 0.08 / 0.13
	/// forward by name and leave the editor running numbers the source no longer contains.
	/// </summary>
	public static float ScavengerAmmoLow { get; set; } = 0.065f;
	public static float ScavengerAmmoHigh { get; set; } = 0.11f;
	public static float ScavengerPoints { get; set; } = 1.3f;

	// ══ m3 Gas Feed ═══════════════════════════════════════════════════════════

	public static float FeedSeconds { get; set; } = 5f;

	// ══ m4 Deep Pockets ═══════════════════════════════════════════════════════

	public static float PocketsAmmoScale { get; set; } = 2f;

	/// <summary>
	/// Rounds a single Vulture ammo drop may give, whatever the percentage works out to. 10.
	///
	/// ⛔ A PERCENTAGE OF MAX RESERVE IS A HUGE NUMBER ON A BIG GUN. The award is
	/// `MaxReserve × fraction`, so at the 600 reserve ceiling a 13% Scavenger roll doubled by
	/// Deep Pockets is 156 rounds from ONE pickup — a quarter of a full reserve for walking
	/// over something. The percentage still decides how the drop SCALES with the weapon; this
	/// decides how much it can ever actually be worth.
	///
	/// ⚠️ AN OUTER LIMIT, NOT A FLOOR. A small gun whose percentage works out to 3 rounds
	/// still gets 3 — `Math.Min`, not an award of 10.
	/// </summary>
	public static int AmmoDropCap { get; set; } = 10;

	/// <summary>
	/// What m4 Deep Pockets adds to that ceiling. +5, so 15.
	///
	/// ⚠️ DEEP POCKETS NOW RAISES THE CAP RATHER THAN THE AMOUNT. Its `PocketsAmmoScale`
	/// doubling still applies to the fraction, but against a ceiling this low the doubling is
	/// almost always clipped away — so the augment would have read as doing nothing at all.
	/// Raising the CEILING is the only way "twice as large" still means something once a flat
	/// cap exists.
	/// </summary>
	public static int PocketsAmmoCapBonus { get; set; } = 5;

	// ══ m5 Long Arms ══════════════════════════════════════════════════════════

	public static float LongArmsScale { get; set; } = 3f;

	// ══ the gate ══════════════════════════════════════════════════════════════

	/// <summary>
	/// Does this player have Vulture Aid AND this augment.
	///
	/// ⛔ BOTH HALVES, EVERY TIME. An augment loadout outlives the perk — losing the perk
	/// does not clear what was equipped on it — so a check that only asks about the
	/// augment keeps paying out after the perk is gone. Every sibling augment file carries
	/// this same note because the same bug was written twice before it became a rule.
	/// </summary>
	static bool Has( NZPlayer player, string augId )
		=> player.IsValid()
			&& PerkEffects.HasVulture( player )
			&& PerkAugments.Has( player, "vulture", augId );

	// ══ M1 Carrion — drop rates ═══════════════════════════════════════════════

	/// <summary>Scale a drop chance by Carrion, if equipped.</summary>
	public static float DropChance( NZPlayer player, float chance )
	{
		if ( !player.IsValid() ) return chance;

		// ⛔ CARRION WAS DEAD FOR EVERY CLIENT, AND IT WAS THE USER'S OWN HUNCH THAT FOUND IT —
		// *"might have to do with vulture aid, and carrion."* The drop roll runs on the host where
		// the zombie died, against the host's proxy of the killer, and `Has()` is false on a proxy
		// for everything. See `NZPlayer.VultureLuck`.
		var scale = Networking.IsActive && PlayerPresence.Theirs( player.GameObject )
			? MathF.Max( 0f, player.VultureLuck )
			: DropScaleLocal( player );

		return chance * scale;
	}

	/// <summary>M1's multiplier read from the REAL loadout. Only meaningful on the owner.</summary>
	public static float DropScaleLocal( NZPlayer player )
		=> Has( player, "M1" ) ? CarrionScale : 1f;

	/// <summary>
	/// Scale a one-in-N drop rate by Carrion.
	///
	/// ⚠️ DIVIDES AND ROUNDS, and the floor of 1 matters: 1-in-9 becomes 1-in-6, and
	/// without the floor a large enough Carrion would produce 1-in-0 and throw inside the
	/// death handler.
	/// </summary>
	public static int OneIn( NZPlayer player, int oneIn )
	{
		// ⚠️ A TIGHTER ONE-IN-N IS A SMALLER NUMBER, so the published value is used directly
		// rather than scaled again — the owner has already divided and floored it.
		if ( Remote( player ) ) return Math.Max( 1, player.VultureOneIn );

		return Has( player, "M1" )
			? Math.Max( 1, (int)MathF.Round( oneIn / CarrionScale ) )
			: oneIn;
	}

	// ══ M2 Gas Cloak ══════════════════════════════════════════════════════════

	public static float GasChance( NZPlayer player, float baseChance )
		=> Has( player, "M2" ) ? CloakChance : baseChance;

	public static float GasCooldown( NZPlayer player, float baseCooldown )
		=> Has( player, "M2" ) ? CloakCooldown : baseCooldown;

	public static float GasLifetime( NZPlayer player )
		=> Has( player, "M2" ) ? CloakLifetime : VultureStink.Lifetime;

	// ══ M3 / m2 — perk slots ══════════════════════════════════════════════════

	/// <summary>
	/// Extra perk slots from Fortune's Gin and Extra Slot, together.
	///
	/// ⛔ DERIVED, NOT ADDED TO `BonusPerkSlots`. That counter is a stored total that
	/// Wunderfizz increments and RoundManager clears, so granting through it would need an
	/// exactly matching decrement when the augment is refunded or the perk is lost — and
	/// an augment going away silently is precisely the case that would leak a permanent
	/// free slot. Reading it as a function of the current loadout cannot leak.
	///
	/// ⚠️ THEY STACK. Both are authored as "+N slots" with no exclusion between them, so
	/// running M3 and m2 together is +3, and that is the intended ceiling.
	/// </summary>
	public static int BonusSlots( NZPlayer player )
	{
		if ( !player.IsValid() ) return 0;

		var slots = 0;
		if ( Has( player, "M3" ) ) slots += GinSlots;
		if ( Has( player, "m2" ) ) slots += ExtraSlots;
		return slots;
	}

	// ══ M4 Wildcard ═══════════════════════════════════════════════════════════

	/// <summary>
	/// Rarity tier Wildcard hands out at a round: Common at 1, Legendary at
	/// <see cref="WildcardMaxRound"/>.
	///
	/// ⚠️ EVEN QUARTERS OF THE CLIMB, not the box's gates. Measured with
	/// `nz_aug_vulture_curve`, the tiers land at rounds 1 / 14 / 26 / 38 / 50 — the straight
	/// line the design asked for. Expressed as a fraction of the climb so moving
	/// `WildcardMaxRound` moves every gate with it, instead of stranding four hardcoded
	/// rounds.
	///
	/// ⚠️ 14, NOT 13. The quarter point of a 49-round climb is 13.25, so the floor lands one
	/// round later than the arithmetic suggests. These numbers are the PRINTED ladder, not a
	/// prediction of it — §6, a count in a comment is a claim.
	/// </summary>
	public static int WildcardRarity( int round )
	{
		var span = Math.Max( 1, WildcardMaxRound - 1 );
		var t = (round - 1) / (float)span;

		// ⚠️ TO THE TOP TO BE HAD NOW, AND CLAMPED THERE, as the pack's curve follows `PapMaxLevel`: Legendary — Godly once
		// basalt's Easter egg is complete. `Rarity.Clamp` alone stops at Godly, which past round 50 would hand it out egg or not.
		var top = Rarity.TopTier;
		return Math.Clamp( (int)MathF.Floor( t * top + 0.0001f ), 0, top );
	}

	/// <summary>
	/// Pack level Wildcard hands out at a round: unpacked at 1, MK3 at
	/// <see cref="WildcardMaxRound"/>. Measured gates: rounds 1 / 18 / 34 / 50.
	/// </summary>
	public static int WildcardPap( int round )
	{
		var span = Math.Max( 1, WildcardMaxRound - 1 );
		var t = (round - 1) / (float)span;
		return Math.Clamp( (int)MathF.Floor( t * NZPlayer.PapMaxLevel + 0.0001f ),
			0, NZPlayer.PapMaxLevel );
	}

	/// <summary>
	/// Swap the held weapon for a random one, scaled to the round. Returns the prefab it
	/// gave, or null if it declined.
	///
	/// ⚠️ `MysteryBox.Pool()` IS THE SOURCE, not `WeaponLibrary.All`. The box pool already
	/// honours the map's `BoxPacks` restriction, so a map that deliberately limits its
	/// weapon set limits this too — pulling from the full library would hand out guns the
	/// mapper excluded on purpose.
	///
	/// ⛔ RARITY AND PACK ARE SET, NOT ADDED. `AddRarityTier`/`AddPapLevel` step by one, so
	/// on a gun the player had held before they would compound on every switch and reach
	/// Legendary MK3 within a few rounds regardless of the curve. The round decides the
	/// level outright.
	///
	/// ⛔ `GiveWeapon( makeActive: true )`, NOT `GiveWeaponByPath`. That wrapper exists for
	/// Mule Kick's Insurance and passes `makeActive: false`, which ADDS to a spare slot —
	/// so the first version of this handed you a random gun without taking the old one, and
	/// you could simply switch back to it. Wildcard is specified as "you cannot go back", so
	/// it has to go through `GiveOrReplace`, which is what `makeActive: true` reaches.
	/// </summary>
	public static string RollWildcard( NZPlayer player )
	{
		if ( !Has( player, "M4" ) ) return null;
		if ( Game.Random.Float() > WildcardChance ) return null;

		var pool = MysteryBox.Pool();
		if ( pool is null || pool.Count == 0 ) return null;

		var pick = pool[Game.Random.Int( 0, pool.Count - 1 )];
		if ( string.IsNullOrWhiteSpace( pick.Prefab ) ) return null;

		// ⚠️ THE UPGRADES GO ON BEFORE THE GUN DOES. Both dictionaries are keyed by prefab
		// path and the weapon reads its own multipliers as it is created, so giving it
		// first would spawn a Common unpacked gun that only became Legendary on the next
		// respawn.
		StampRound( player, pick.Prefab );

		return player.GiveWeapon( pick.Prefab, makeActive: true ).IsValid()
			? pick.Prefab
			: null;
	}

	/// <summary>
	/// How many weapons Wildcard makes sure you are carrying when you equip it.
	///
	/// ⛔ TWO, BECAUSE ONE IS UNSWITCHABLE. `NZPlayer.TickWeaponSwitch` returns immediately on
	/// `inv.Count < 2` — with a single weapon there is no other slot to change to, so the
	/// scroll wheel does nothing and Wildcard could never fire. Equipping the augment with one
	/// gun in hand produced an augment that was correctly wired and completely inert, which is
	/// §9: a feature whose only trigger cannot occur.
	/// </summary>
	public static int WildcardArmSlots { get; set; } = 2;

	/// <summary>
	/// Fill the player's slots with random weapons, so Wildcard has something to switch
	/// between. Returns how many it handed over.
	///
	/// ⚠️ THE ORDER MATTERS AND IS NOT INTERCHANGEABLE. `GiveOrReplace` only destroys the held
	/// weapon when the inventory is ALREADY FULL, so topping up to the cap has to happen
	/// first; replacing first would just add and leave the player one gun short of being able
	/// to switch at all.
	///
	/// ⚠️ THE SPARE SLOT MAY KEEP A GUN YOU CHOSE, when you equip Wildcard already carrying
	/// two. Only the held one is certainly replaced — the other is rerolled the instant you
	/// switch to it, which is the augment working rather than an omission.
	/// </summary>
	public static int ArmForWildcard( NZPlayer player )
	{
		if ( !player.IsValid() ) return 0;

		// ⛔ `player.Inventory`, NOT `Components.Get<NZInventory>( ... )`. The property
		// CREATES the component when it is missing, which is how every other caller in this
		// project reaches it — a raw Get returns invalid on a player whose inventory has not
		// been touched yet, and this runs the instant an augment is granted. Re-deriving a
		// lookup the class already exposes is §10: the docs answer neither "can I call it from
		// here" nor "is this the accessor everyone else uses".
		var inv = player.Inventory;
		if ( !inv.IsValid() ) { Log.Warning( "[nz-aug-vulture] arm: no inventory" ); return 0; }

		// ⚠️ EVERY EARLY-OUT SAYS WHY. The first version returned 0 silently on an empty pool
		// and on a missing inventory, so "Wildcard armed nothing" was indistinguishable from
		// "OnGained never ran" — and that cost three rounds of guessing.
		var pool = MysteryBox.Pool();
		if ( pool is null || pool.Count == 0 )
		{
			Log.Warning( "[nz-aug-vulture] arm: the box pool is empty —"
				+ " no weapons to hand out" );
			return 0;
		}

		var want = Math.Min( WildcardArmSlots, Math.Max( 1, inv.EffectiveMaxSlots ) );
		var given = 0;

		// ── 1. top up to the slot count, without disturbing what is in hand ──
		//
		// ⚠️ `makeActive: false` so filling the spare slot does not yank the player's gun
		// away mid-fight. `Add` puts the first weapon in hand on its own when nothing is
		// active, which is the empty-handed case.
		var guard = 0;
		while ( inv.Count < want && guard++ < 8 )
		{
			var fill = pool[Game.Random.Int( 0, pool.Count - 1 )];
			if ( string.IsNullOrWhiteSpace( fill.Prefab ) ) continue;

			StampRound( player, fill.Prefab );
			if ( player.GiveWeapon( fill.Prefab, makeActive: false ).IsValid() ) given++;
		}

		// ── 2. and replace whatever is in hand with a random one ─────────────
		if ( RollWildcard( player ) is { } held ) given++;

		// ⚠️ "HANDED OVER", NOT "ARMED N SLOTS". `given` counts weapons handed over, and the
		// replacement step destroys one — so an empty-handed player reads 3 while carrying 2,
		// and "armed 3 slots" on a 2-slot inventory is a false claim (§6). The carried count
		// beside it is the state; `given` is the work done.
		Log.Info( $"[nz-aug-vulture] Wildcard handed over {given} weapon(s)"
			+ $" — carrying {inv.Count}/{inv.EffectiveMaxSlots}" );

		return given;
	}

	/// <summary>
	/// Give a prefab the rarity and pack level this round is worth.
	///
	/// ⛔ SHARED BY `RollWildcard` AND `ArmForWildcard` rather than written twice. A weapon
	/// handed over at the wrong tier is invisible until someone reads the damage numbers, and
	/// two copies of the round-to-tier write is the §3 shape that produces exactly that.
	/// </summary>
	static void StampRound( NZPlayer player, string prefab )
	{
		var round = RoundManager.Instance?.Round ?? 1;

		player.SetRarityTier( prefab, WildcardRarity( round ) );
		player.SetPapLevel( prefab, WildcardPap( round ) );
	}

	// ══ m1 Scavenger / m4 Deep Pockets — award sizes ══════════════════════════

	public static int SalvagePerPickup( NZPlayer player, int baseAmount )
		=> Has( player, "m1" ) ? ScavengerSalvage : baseAmount;

	/// <summary>Points from a Vulture points drop, after Scavenger.</summary>
	public static int PointsAward( NZPlayer player, int baseAmount )
		=> Has( player, "m1" )
			? (int)MathF.Round( baseAmount * ScavengerPoints )
			: baseAmount;

	/// <summary>
	/// The fraction of max reserve a Vulture ammo drop gives.
	///
	/// ⚠️ SCAVENGER PICKS THE RANGE, DEEP POCKETS DOUBLES IT — in that order, so running
	/// both is 16-26% rather than either replacing the other. Two augments that both claim
	/// to own one number is how a stat ends up contradicting itself.
	/// </summary>
	/// <summary>
	/// The most rounds one Vulture ammo drop may give this player. 10, or 15 with m4.
	///
	/// ⛔ APPLIED AT THE PICKUP, AFTER THE PERCENTAGE, so it outranks both augments that raise
	/// the award — Scavenger's wider range and Deep Pockets' doubling. Capping inside
	/// `AmmoFraction` instead would cap a FRACTION, which cannot express "10 rounds": the same
	/// fraction is worth 3 rounds on a pistol and 78 on a weapon at the reserve ceiling.
	/// </summary>
	public static int AmmoCapFor( NZPlayer player )
		=> AmmoDropCap + (Has( player, "m4" ) ? PocketsAmmoCapBonus : 0);

	public static float AmmoFraction( NZPlayer player, float baseMin, float baseMax )
	{
		var min = Has( player, "m1" ) ? ScavengerAmmoLow : baseMin;
		var max = Has( player, "m1" ) ? ScavengerAmmoHigh : baseMax;

		var f = Game.Random.Float( min, max );
		return Has( player, "m4" ) ? f * PocketsAmmoScale : f;
	}

	/// <summary>
	/// Does this player get Deep Pockets' second, ammo-only drop roll.
	///
	/// ⛔ AN EXTRA ROLL, NOT A HEAVIER WEIGHT IN THE EXISTING TABLE. The Vulture table is
	/// weighted Points 3 / Ammo 2 out of a fixed 1-in-9, so doubling the ammo weight would
	/// have taken points drops from 60% of drops down to 43% — "ammo twice as common" is
	/// not meant to halve your points income. A separate roll leaves points untouched.
	/// </summary>
	public static bool HasExtraAmmoRoll( NZPlayer player )
		=> Remote( player ) ? player.VultureExtraRoll : Has( player, "m4" );

	/// <summary>
	/// Is this a body whose loadout lives on another machine?
	///
	/// ⚠️ ONE TEST, so the four Vulture readers below cannot disagree about when to trust the
	/// published value and when to read the real thing.
	/// </summary>
	static bool Remote( NZPlayer p )
		=> p.IsValid() && Networking.IsActive && PlayerPresence.Theirs( p.GameObject );

	/// <summary>
	/// An augment was just equipped on Vulture Aid.
	///
	/// ⚠️ ONLY M4 DOES ANYTHING HERE. The other eight are read on demand, which is the shape
	/// every augment should have — nothing to apply, nothing to unwind. Wildcard is the
	/// exception because it needs the player to be ABLE to switch weapons before its trigger
	/// exists at all.
	/// </summary>
	public static void OnGained( NZPlayer player, string augId )
	{
		if ( !player.IsValid() ) return;
		if ( augId != "M4" ) return;

		ArmForWildcard( player );
	}

	// ══ m3 Gas Feed ═══════════════════════════════════════════════════════════

	/// <summary>
	/// The weapon this player is holding, or null.
	///
	/// ⛔ ONE HELPER, TWO CALLERS — Gas Feed and `nz_aug_vulture_empty`. Two copies of a
	/// "what am I holding" lookup is §3, and here it would be worse than usual: the test
	/// command and the feature it tests would be asking different questions, so a green test
	/// would prove nothing about the feature.
	///
	/// ⚠️ THE INVENTORY IS THE FIRST ANSWER, NOT THE ONLY ONE. `NZInventory.Active` is the
	/// authority on what is in your hands, but it is null before the inventory has settled
	/// and the component itself is not always found from the player — `nz_aug_vulture_empty`
	/// reported "nothing held" with a 30/30 weapon plainly equipped. Falling back to the one
	/// ENABLED weapon under the player finds it, because holstered weapons are disabled in
	/// this project (the same fact `EverythingInSelf` exists to work around elsewhere).
	///
	/// ⛔ AND THE FALLBACK NOW REFUSES TO GUESS. It was `FirstOrDefault( w =&gt; w.Enabled )`,
	/// which is the hierarchy-order idiom that `TradeTable` and `AmmoBox` both carry warnings
	/// about — "returns whichever Weapon comes first, usually the HOLSTERED one". The
	/// `w.Enabled` filter is what made it look safe, and it holds only while exactly one
	/// weapon is enabled. A swap animation, a third Mule Kick slot mid-deploy, or any future
	/// weapon that is enabled while stowed breaks that silently, and then Fire's, PhD's and
	/// Vigor's augments all act on the gun on your back. One candidate is a fact; two is a
	/// coin toss, and returning null makes the augment do nothing for a frame instead of
	/// doing something to the wrong weapon.
	/// </summary>
	public static SWB.Base.Weapon HeldWeapon( NZPlayer player )
	{
		if ( !player.IsValid() ) return null;

		// ⚠️ THE SAME ACCESSOR `ArmForWildcard` USES, and for the same reason — see the note
		// there. Two different ways of reaching one component is how they end up disagreeing.
		var inv = player.Inventory;

		if ( inv.IsValid() && inv.Active.IsValid()
			&& inv.Active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
				is { } active )
		{
			return active;
		}

		SWB.Base.Weapon only = null;

		foreach ( var w in player.Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInDescendants ) )
		{
			if ( !w.IsValid() || !w.Enabled ) continue;
			if ( only is not null ) return null;

			only = w;
		}

		return only;
	}

	/// <summary>
	/// Standing in your own gas REGENERATES reserve ammo, a clip's worth every
	/// <see cref="FeedSeconds"/>, accrued smoothly.
	///
	/// ⛔ IT CREATES ROUNDS IN THE RESERVE. IT DOES NOT MOVE THEM INTO THE MAGAZINE. The first
	/// version topped the magazine up out of the reserve, which is Speed Cola's Auto-Loader
	/// wearing a different name — and it made the reserve go DOWN, so standing in a cloud
	/// watching the reserve number produced no visible effect at all (and none whatsoever on a
	/// full magazine, where it correctly did nothing). Vulture Aid is the AMMO perk; the gas
	/// being a source of ammunition is the whole point of standing in it.
	///
	/// ⛔ LINEAR, NOT A LUMP EVERY FIVE SECONDS. The rate is `ClipSize / FeedSeconds` rounds
	/// per second and whole rounds are paid out as they accrue, so the counter climbs visibly
	/// the entire time you are in the cloud. A lump payout is indistinguishable from the
	/// augment being broken for the first 4.9 seconds — which is exactly how it was reported.
	///
	/// ⚠️ THE TIMER RESETS WHENEVER THE PLAYER IS NOT IN GAS. A fractional remainder left to
	/// run outside the cloud would make the first frame of the next cloud pay out, which is
	/// not a rate at all.
	///
	/// ⚠️ THE CAP IS CHECKED BEFORE THE ACCRUAL, so a player sitting at full reserve does not
	/// bank progress and dump it the instant they fire one shot.
	/// </summary>
	public static void TickGasFeed( NZPlayer player )
	{
		if ( !Has( player, "m3" ) || !VultureStink.IsInGas( player ) )
		{
			if ( player.IsValid() ) player.GasFeedProgress = 0f;
			return;
		}

		var wep = HeldWeapon( player );
		if ( !wep.IsValid() ) return;

		var si = wep.Primary;
		if ( si is null || si.ClipSize <= 0 ) return;

		// ⚠️ NZAmmo IS THE RESERVE STORE, reached the same way `AwardVultureAmmo` reaches it —
		// `EverythingInSelf`, because a holstered weapon's components are disabled. That is
		// the shipped path for putting rounds into a reserve, so this uses it rather than
		// inventing a second one (§3).
		var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
		if ( !ammo.IsValid() ) return;

		if ( ammo.Reserve >= ammo.MaxReserve )
		{
			player.GasFeedProgress = 0f;
			return;
		}

		player.GasFeedProgress += Time.Delta * (si.ClipSize / MathF.Max( 0.1f, FeedSeconds ));

		// ⚠️ WHOLE ROUNDS OUT, FRACTION KEPT. Subtracting what was paid rather than zeroing
		// the accumulator is what makes the rate exact over time instead of losing a sliver
		// every frame — the same shape Speed Cola's Auto-Loader uses.
		var rounds = (int)player.GasFeedProgress;
		if ( rounds <= 0 ) return;

		player.GasFeedProgress -= rounds;
		ammo.Reserve = Math.Min( ammo.Reserve + rounds, ammo.MaxReserve );
	}

	/// <summary>Rounds per second Gas Feed regenerates for the held weapon, or 0.</summary>
	public static float FeedRate( NZPlayer player )
	{
		var wep = HeldWeapon( player );
		if ( !wep.IsValid() || wep.Primary is null || wep.Primary.ClipSize <= 0 ) return 0f;

		return wep.Primary.ClipSize / MathF.Max( 0.1f, FeedSeconds );
	}

	// ══ m5 Long Arms ══════════════════════════════════════════════════════════

	/// <summary>
	/// Pickup reach after Long Arms.
	///
	/// ⚠️ APPLIES TO EVERY KIND, not just Vulture drops. Salvage and plates are drops too,
	/// and a "longer arms" that could not reach a plate a step away would read as broken
	/// rather than as scoped.
	/// </summary>
	public static float ReachFor( NZPlayer player, float baseRadius )
		=> Remote( player )
			? baseRadius * MathF.Max( 1f, player.VultureReach )
			: Has( player, "m5" ) ? baseRadius * LongArmsScale : baseRadius;

	// ══ report ════════════════════════════════════════════════════════════════

	/// <summary>
	/// ⚠️ PRINTS RESOLVED NUMBERS, NOT MULTIPLIERS. "x1.5" cannot be checked against the
	/// game; "20% -> 30%" can. Every sibling augment report does the same.
	/// </summary>
	public static void Report( NZPlayer player )
	{
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug-vulture] no player" ); return; }

		var has = PerkEffects.HasVulture( player );
		var round = RoundManager.Instance?.Round ?? 1;
		var sal = ActiveConfig.Salvage;
		var arm = ActiveConfig.Armor;

		Log.Info( $"[nz-aug-vulture] perk {(has ? "YES" : "no")} · round {round}" );

		Log.Info( $"[nz-aug-vulture] M1 Carrion {(Has( player, "M1" ) ? "ON" : "off")}"
			+ $" — salvage {sal.DropChance * 100f:0.#}% -> {DropChance( player, sal.DropChance ) * 100f:0.#}%"
			+ $" · plates {arm.PlateDropChance * 100f:0.#}% -> {DropChance( player, arm.PlateDropChance ) * 100f:0.#}%"
			+ $" · vulture 1-in-{PickupDrops.VultureOneIn} -> 1-in-{OneIn( player, PickupDrops.VultureOneIn )}" );

		Log.Info( $"[nz-aug-vulture] M2 Gas Cloak {(Has( player, "M2" ) ? "ON" : "off")}"
			+ $" — chance {GasChance( player, PickupDrops.GasChance ) * 100f:0.#}%"
			+ $" · cooldown {GasCooldown( player, PickupDrops.GasCooldown ):0.#}s"
			+ $" · life {GasLifetime( player ):0.#}s" );

		Log.Info( $"[nz-aug-vulture] M3/m2 slots +{BonusSlots( player )}"
			+ $" (total {player.PerkSlots})" );

		Log.Info( $"[nz-aug-vulture] M4 Wildcard {(Has( player, "M4" ) ? "ON" : "off")}"
			+ $" — at round {round}: {Rarity.NameFor( WildcardRarity( round ) )}"
			+ $" MK{WildcardPap( round )}"
			+ $" · tops out {Rarity.NameFor( Rarity.TopTier )} MK{NZPlayer.PapMaxLevel} at round {WildcardMaxRound}"
			+ $" · {WildcardChance * 100f:0.#}% per weapon switch" );

		Log.Info( $"[nz-aug-vulture] m1 Scavenger {(Has( player, "m1" ) ? "ON" : "off")}"
			+ $" — salvage {SalvagePerPickup( player, sal.PerPickup )} each"
			+ $" · points {PointsAward( player, 100 )}-{PointsAward( player, 200 )}" );

		// ⚠️ The ammo figure is a RANGE rolled per pickup, so this prints the bounds. A
		// sample would read as a fixed value and be wrong on every other drop.
		// ⛔️ READ FROM PickupDrops, NOT RESTATED. These two lines used to hardcode the base
		// 0.05/0.10 a second time, so this report would have kept printing the old range after the
		// real one was tuned -- a diagnostic that lies is worse than no diagnostic.
		var lo = Has( player, "m1" ) ? ScavengerAmmoLow : PickupDrops.VultureAmmoLow;
		var hi = Has( player, "m1" ) ? ScavengerAmmoHigh : PickupDrops.VultureAmmoHigh;
		var scale = Has( player, "m4" ) ? PocketsAmmoScale : 1f;

		// ⛔ THE PERCENTAGE ALONE NOW OVERSTATES THE DROP, usually by a lot. Since the flat
		// cap, a 13% roll on a big gun is not 78 rounds, it is 10 — so a report that prints
		// only the range advertises a payout the player will essentially never receive. It
		// prints what the range resolves to ON THE HELD WEAPON, and the ceiling beside it.
		var capWep = HeldWeapon( player );
		var capAmmo = capWep.IsValid()
			? capWep.Components.Get<NZAmmo>( FindMode.EverythingInSelf )
			: null;
		var cap = AmmoCapFor( player );
		var uncapped = capAmmo.IsValid()
			? $"{MathF.Ceiling( capAmmo.MaxReserve * lo * scale ):0}-{MathF.Ceiling( capAmmo.MaxReserve * hi * scale ):0} rounds"
			: "no weapon held";

		Log.Info( $"[nz-aug-vulture] m4 Deep Pockets {(Has( player, "m4" ) ? "ON" : "off")}"
			+ $" — ammo drop {lo * scale * 100f:0.#}-{hi * scale * 100f:0.#}% of reserve"
			+ $" = {uncapped}, CAPPED AT {cap}"
			+ $" · extra ammo roll {(HasExtraAmmoRoll( player ) ? "YES" : "no")}" );

		// ⚠️ THE RATE AND THE LIVE RESERVE, because the thing being claimed is a rate. A
		// countdown to the next payout was the right report for the lump version and is
		// meaningless for this one.
		var feedWep = HeldWeapon( player );
		var feedAmmo = feedWep.IsValid()
			? feedWep.Components.Get<NZAmmo>( FindMode.EverythingInSelf )
			: null;

		Log.Info( $"[nz-aug-vulture] m3 Gas Feed {(Has( player, "m3" ) ? "ON" : "off")}"
			+ $" — one clip / {FeedSeconds:0.#}s = {FeedRate( player ):0.#} rounds/sec"
			+ $" · in gas now: {VultureStink.IsInGas( player )}"
			+ $" · reserve {(feedAmmo.IsValid() ? $"{feedAmmo.Reserve}/{feedAmmo.MaxReserve}" : "n/a")}"
			+ $" · {player.GasFeedProgress:0.00} banked" );

		Log.Info( $"[nz-aug-vulture] m5 Long Arms {(Has( player, "m5" ) ? "ON" : "off")}"
			+ $" — salvage/plate reach 32 -> {ReachFor( player, 32f ):0}u"
			+ $" · vulture reach 48 -> {ReachFor( player, 48f ):0}u" );
	}

	[ConCmd( "nz_aug_vulture" )]
	public static void Cmd()
		=> Report( NZPlayer.Local );

	/// <summary>Tune every Vulture augment number. A negative value leaves one alone.</summary>
	[ConCmd( "nz_aug_vulture_set" )]
	public static void SetCmd( float carrion = -1f, float gasChance = -1f,
		float gasCooldown = -1f, float gasLife = -1f, float wildcard = -1f,
		int wildcardRound = -1, int salvage = -1, float points = -1f,
		float feed = -1f, float pockets = -1f, float arms = -1f )
	{
		if ( carrion > 0f ) CarrionScale = carrion;
		if ( gasChance >= 0f ) CloakChance = gasChance;
		if ( gasCooldown >= 0f ) CloakCooldown = gasCooldown;
		if ( gasLife > 0f ) CloakLifetime = gasLife;
		if ( wildcard >= 0f ) WildcardChance = wildcard;
		if ( wildcardRound > 1 ) WildcardMaxRound = wildcardRound;
		if ( salvage > 0 ) ScavengerSalvage = salvage;
		if ( points > 0f ) ScavengerPoints = points;
		if ( feed > 0f ) FeedSeconds = feed;
		if ( pockets > 0f ) PocketsAmmoScale = pockets;
		if ( arms > 0f ) LongArmsScale = arms;

		Cmd();
	}

	/// <summary>
	/// `nz_aug_vulture_reroll` — fire one Wildcard roll without touching the scroll wheel.
	///
	/// ⛔ EXISTS BECAUSE THE TRIGGER AND THE EFFECT ARE SEPARATELY BREAKABLE. Wildcard runs
	/// off `TickWeaponSwitch`, which needs real input and at least two weapons held — so
	/// "nothing happened when I scrolled" could be the input branch, the two-weapon guard,
	/// the pool, the rarity write, or `GiveOrReplace`. This command exercises everything
	/// except the input, which narrows a failure to one half in a single reading.
	///
	/// ⚠️ Prints what it GAVE and at what tier, not just that it ran. "Rolled" without a name
	/// cannot be checked against the gun in your hands.
	/// </summary>
	[ConCmd( "nz_aug_vulture_reroll" )]
	public static void RerollCmd()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug-vulture] no player" ); return; }

		if ( !Has( player, "M4" ) )
		{
			Log.Warning( "[nz-aug-vulture] M4 Wildcard is not equipped —"
				+ " nz_perk_give vulture, then nz_aug_all vulture" );
			return;
		}

		var pool = MysteryBox.Pool();
		Log.Info( $"[nz-aug-vulture] pool has {(pool is null ? 0 : pool.Count)} weapon(s)" );

		var gave = RollWildcard( player );

		if ( gave is null )
		{
			Log.Warning( "[nz-aug-vulture] rolled nothing — empty pool, or the give failed" );
			return;
		}

		var round = RoundManager.Instance?.Round ?? 1;

		Log.Info( $"[nz-aug-vulture] gave {gave}"
			+ $" · {Rarity.NameFor( player.RarityTierFor( gave ) )}"
			+ $" MK{player.PapLevelFor( gave )}"
			+ $" (round {round})" );
	}

	/// <summary>
	/// `nz_aug_vulture_empty` — drain the held magazine AND reserve, so Gas Feed has something
	/// to regenerate.
	///
	/// ⛔ EXISTS BECAUSE GAS FEED IS OTHERWISE UNTESTABLE WITHOUT PLAYING. It only does
	/// anything below full reserve, so a freshly spawned player standing in a cloud correctly
	/// does nothing at all — which is indistinguishable from the augment being broken. A
	/// feature whose only obvious test cannot trigger it is §9.
	///
	/// ⚠️ IT DRAINS THE RESERVE TOO, not just the magazine. Gas Feed regenerates the RESERVE,
	/// so a command that emptied only the clip left the exact condition under test — a reserve
	/// below its cap — unreachable. That is what made the first report of this augment read as
	/// "nothing happens".
	/// </summary>
	[ConCmd( "nz_aug_vulture_empty" )]
	public static void EmptyCmd( int leave = 0 )
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug-vulture] no player" ); return; }

		var wep = HeldWeapon( player );
		if ( !wep.IsValid() || wep.Primary is null )
		{
			// ⚠️ NAMES WHAT IT SEARCHED, not just that it failed. "nothing held" with a gun
			// plainly in your hands is the least useful message this command could print.
			var mine = player.Components
				.GetAll<SWB.Base.Weapon>( FindMode.EverythingInDescendants )
				.Where( w => w.IsValid() ).ToArray();

			var inv2 = player.Inventory;

			Log.Warning( $"[nz-aug-vulture] no held weapon — {mine.Length} under the player"
				+ $" · inventory {(inv2.IsValid() ? "found" : "MISSING")}"
				+ $" · active {(inv2.IsValid() && inv2.Active.IsValid() ? inv2.Active.Name : "none")}" );

			// ⚠️ WIDENS TO THE WHOLE SCENE AND PRINTS THE PARENT, because "0 under the
			// player" does not say where they are instead — and the answer decides which
			// FindMode every ammo-touching augment has to use.
			foreach ( var w in Game.ActiveScene.GetAllComponents<SWB.Base.Weapon>() )
			{
				if ( !w.IsValid() ) continue;

				Log.Info( $"[nz-aug-vulture]   weapon {w.GameObject.Name}"
					+ $" · parent {(w.GameObject.Parent.IsValid() ? w.GameObject.Parent.Name : "ROOT")}"
					+ $" · enabled {w.Enabled}" );
			}

			return;
		}

		var si = wep.Primary;
		var beforeClip = si.Ammo;
		si.Ammo = Math.Clamp( leave, 0, Math.Max( 0, si.ClipSize ) );

		var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
		var beforeReserve = ammo.IsValid() ? ammo.Reserve : 0;
		if ( ammo.IsValid() ) ammo.Reserve = 0;

		Log.Info( $"[nz-aug-vulture] clip {beforeClip} -> {si.Ammo}/{si.ClipSize}"
			+ $" · reserve {beforeReserve} -> "
			+ $"{(ammo.IsValid() ? $"{ammo.Reserve}/{ammo.MaxReserve}" : "n/a")}"
			+ $" — stand in gas and watch nz_aug_vulture" );
	}

	/// <summary>
	/// `nz_aug_vulture_curve` — the whole Wildcard ladder, printed at each step.
	///
	/// ⚠️ EXISTS BECAUSE THE CURVE *IS* THE DESIGN. "Legendary MK3 at round 50" is a claim
	/// about fifty rounds, and checking it by playing to fifty is not checking it.
	/// </summary>
	[ConCmd( "nz_aug_vulture_curve" )]
	public static void CurveCmd()
	{
		Log.Info( $"[nz-aug-vulture] Wildcard ladder, topping out at round {WildcardMaxRound}:" );

		var lastR = -1;
		var lastP = -1;

		for ( var round = 1; round <= WildcardMaxRound; round++ )
		{
			var r = WildcardRarity( round );
			var p = WildcardPap( round );
			if ( r == lastR && p == lastP ) continue;

			Log.Info( $"[nz-aug-vulture]   round {round,3} -> {Rarity.NameFor( r ),-10} MK{p}" );
			lastR = r;
			lastP = p;
		}
	}
}