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.
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 => 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;
}
}
}