Static class implementing Mule Kick perk augments for NZombies. It defines tuning constants, checks equipped augments, computes bonuses to reserve ammo and clip size, handles overflow refunds, fabricator ammo-on-kill, grenade capacity and per-round refill, insurance for lost weapons (escrow/restore), refresh logic to apply changes, diagnostics report, and console commands to tweak and inspect settings.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Mule Kick's augments. Base perk: a third weapon slot.
///
/// | id | effect | status |
/// |----|--------|--------|
/// | M1 **Bandolier** | +3 reserve magazines, capped at +100 rounds | ⚠ was +4, uncapped |
/// | M2 Overflow | 25% chance a hit refunds a round | as the original |
/// | M3 **Pack Mule** | a FOURTH weapon slot | ⚠ NEW — replaced Hot Swap |
/// | M4 Insurance | the extra weapon survives losing the perk | ⚠ redesigned, swap half dropped |
/// | m1 **Wide Mags** | every clip 10% bigger | ⚠ NEW — replaced Quick Draw |
/// | m2 **Deep Reserves** | +10% reserve | ⚠ was +2 magazines |
/// | m3 Grenadier | +2 lethals on purchase, +1 per round | as the original |
/// | m4 **Resupply** | grenades fully refilled every round | ⚠ NEW — replaced Trickle Charge |
/// | m5 Fabricator | the HELD weapon gains 1 reserve per kill | ⚠ was holstered weapons |
///
/// ⛔ THREE OF THE ORIGINAL NINE WERE DUPLICATES OF THINGS ALREADY SHIPPED, and finding
/// that is why this perk was audited before it was written:
///
/// • **m1 Quick Draw** was "faster weapon swap" — which is Speed Cola's m2 Swift Draw, on
/// the same lever. The original literally wires m1, M4's swap half and its Speed Cola
/// equivalent through ONE shared `SetAugmentQuickDraw` predicate.
/// • **M4's own swap half** — same stat again, now dropped, so Insurance is purely about
/// keeping the gun.
/// • **m4 Trickle Charge** refilled the held magazine at `10 / clipSize` seconds per round
/// — which is a full clip per ten seconds, i.e. *exactly* Speed Cola's M2 Auto-Loader as
/// specified for this project. Identical mechanic, and Speed Cola's covers every weapon.
///
/// ⛔ M1 AND m2 WERE THE SAME EFFECT AT TWO SIZES AND ARE NO LONGER. They were +4 and +2
/// magazines, a major and a minor on one axis. They are now a CAPPED FLAT bonus and a
/// PERCENTAGE, which pull in opposite directions across the roster:
///
/// Bandolier 3 magazines, never more than 100 rounds -> best on SMALL clips
/// Deep Res. 10% of the reserve the weapon already has -> best on LARGE reserves
///
/// A 6-round shotgun gets +18 from Bandolier and almost nothing from Deep Reserves; a 100-round
/// LMG hits Bandolier's cap at +100 and takes the percentage instead. Two augments on one axis
/// that reward different guns is a choice; two sizes of the same number was not.
///
/// ⚠️ THEY STILL STACK, and both are computed from the SAME tech-scaled reserve rather than
/// in sequence — so the percentage cannot inflate the flat bonus past its cap, and the flat
/// bonus cannot inflate the percentage. Order-independent by construction.
/// </summary>
public static class MuleKickAugments
{
const string Perk = "mulekick";
// ── tuning ───────────────────────────────────────────────────────────────
/// <summary>M1 Bandolier — extra reserve magazines. 3.</summary>
public static int BandolierMags { get; set; } = 3;
/// <summary>
/// M1 Bandolier — the most rounds it may ever add, whatever the clip. 100.
///
/// ⛔ WITHOUT A CAP THE AUGMENT SCALED WITH THE WEAPON IT LEAST NEEDED TO HELP. Measured in
/// magazines it paid 3 x ClipSize, so a 100-round LMG took +300 rounds while a 6-round shotgun
/// took +18 — seventeen times the value, on the gun that already carries the most ammo in the
/// game. The magazine unit is still right for "how many more times can I reload"; the cap is
/// what stops that unit running away at the top of the roster.
///
/// ⚠️ IT BINDS ABOVE A 33-ROUND CLIP and nowhere below, so it is invisible on handguns,
/// shotguns, SMGs and most rifles and only bites on LMGs and drum magazines — which is the
/// intent, not a side effect.
/// </summary>
public static int BandolierCap { get; set; } = 100;
/// <summary>
/// m2 Deep Reserves — extra reserve as a FRACTION of what the weapon already carries. 0.10.
///
/// ⚠️ A PERCENTAGE, SO TECH SCALES IT AND THAT IS CORRECT. `t1_reserve` multiplies the
/// weapon's reserve before this is read, so ten percent of a tech-boosted pouch is more rounds
/// than ten percent of a bare one. That is what "+10% reserve" means — unlike Bandolier, which
/// is added after the tech scale precisely so a node cannot multiply a flat player-carried
/// bonus.
/// </summary>
public static float DeepReserveFraction { get; set; } = 0.10f;
/// <summary>M2 Overflow — chance a damaging hit refunds a round. 25%.</summary>
public static float OverflowChance { get; set; } = 0.25f;
/// <summary>M3 Pack Mule — extra weapon slots on top of the perk's own.</summary>
public static int PackMuleSlots { get; set; } = 1;
/// <summary>m1 Wide Mags — clip size multiplier. 1.10.</summary>
public static float WideMagsClip { get; set; } = 1.10f;
/// <summary>m3 Grenadier — extra grenade capacity, granted once on purchase.</summary>
public static int GrenadierBonus { get; set; } = 2;
/// <summary>m3 Grenadier — grenades recovered per round.</summary>
public static int GrenadierPerRound { get; set; } = 1;
/// <summary>m5 Fabricator — reserve rounds generated per kill.</summary>
public static int FabricatorRounds { get; set; } = 1;
// ── helpers ──────────────────────────────────────────────────────────────
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
static NZPlayer PlayerOf( GameObject attacker )
=> attacker.IsValid()
? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
// ── M3 Pack Mule ─────────────────────────────────────────────────────────
/// <summary>
/// Extra weapon slots from M3, on top of the perk's own third slot.
///
/// ⛔ FOLDED INTO `PerkEffects.BonusWeaponSlots`, WHICH IS DERIVED AND NEVER WRITTEN.
/// That method's own note explains why and it applies doubly here: `MaxSlots` is authored
/// config, and anything that WROTE 4 into it would have to write 3 back on augment loss —
/// restoring a value it does not own. Deriving means losing the augment is free.
///
/// ⚠️ AND IT MAKES `TrimToCap` DO THE RIGHT THING FOR FREE. That method trims to
/// `EffectiveMaxSlots`, so losing M3 while holding four guns destroys the fourth exactly
/// as losing the perk destroys the third — no separate path, and Insurance below covers
/// both.
/// </summary>
public static int BonusSlots( NZPlayer player )
=> Has( player, "M3" ) ? Math.Max( 0, PackMuleSlots ) : 0;
// ── M1 · m2 · reserve magazines ──────────────────────────────────────────
/// <summary>
/// Extra reserve ROUNDS from M1 and m2, for a weapon whose tech-scaled reserve is
/// <paramref name="reserve"/> and whose magazine holds <paramref name="clipSize"/>. They STACK.
///
/// ⛔ IT RETURNS ROUNDS, NOT MAGAZINES, AND THE SIGNATURE CHANGE IS THE POINT. `BonusMags`
/// could express both augments while both were measured in magazines; Deep Reserves is a
/// percentage now and has no magazine count to return. A method that answered in magazines
/// would have had to convert the percentage to one and back, losing the remainder twice.
///
/// ⚠️ BOTH TERMS READ THE SAME `reserve`, never each other's output. Applying one to the
/// other's result would make the pair order-dependent: the percentage would inflate the flat
/// bonus past its own cap, or the flat bonus would inflate the percentage. Independent terms
/// on one base cannot do either.
///
/// ⚠️ MAGAZINES ARE STILL THE RIGHT UNIT FOR BANDOLIER — "three more reloads" is worth the
/// same to a shotgun and an LMG in the only sense a player feels — and `BandolierCap` is what
/// keeps that unit from paying 300 rounds at the top of the roster.
/// </summary>
public static int BonusReserve( NZPlayer player, int reserve, int clipSize )
{
var bonus = 0;
if ( Has( player, "M1" ) && clipSize > 0 )
bonus += Math.Min( Math.Max( 0, BandolierMags ) * clipSize, Math.Max( 0, BandolierCap ) );
if ( Has( player, "m2" ) )
bonus += (int)MathF.Round( Math.Max( 0, reserve ) * Math.Max( 0f, DeepReserveFraction ) );
return bonus;
}
// ⛔ `ApplyReserve` DELETED. It raised `MaxReserve` itself, which made it the SECOND
// author of that field - `NZPlayer.ApplyStoredUpgrades` is the first - and the two took
// turns. Whichever ran last won, so a Pack-a-Punch, an ammo purchase or simply drawing the
// gun wrote the tech figure back without the augment and Bandolier's magazines vanished.
//
// The bonus is now a TERM inside `ApplyStoredUpgrades`, exactly as m1 Wide Mags is a term
// inside `ApplyClipTech` rather than a writer of `ClipSize`. `BonusReserve` above is what both
// of those read.
//
// ⚠ Left as a note rather than removed silently: a deleted method with no explanation is
// an invitation to add it back.
// ── m1 Wide Mags ─────────────────────────────────────────────────────────
/// <summary>
/// Clip size multiplier from m1. 1 when not equipped.
///
/// ⛔ CONSUMED BY `NZPlayer.ApplyClipTech`, NOT APPLIED HERE. That method is documented
/// as "the ONLY thing that touches ClipSize", rebuilds from a remembered `TechBase` and
/// carries the -1 no-magazine sentinel guard. Writing `si.ClipSize` from this file would
/// be a second author of one field — and because ApplyClipTech runs on every equip, it
/// would overwrite us anyway.
/// </summary>
public static float ClipMultiplier( NZPlayer player )
=> Has( player, "m1" ) ? MathF.Max( 0.1f, WideMagsClip ) : 1f;
// ── M2 Overflow ──────────────────────────────────────────────────────────
/// <summary>
/// Roll M2 Overflow on a damaging hit and refund a round if it lands.
///
/// ⚠️ THE HELD WEAPON, not the one that fired. The original checks the inflictor and
/// bails if it is not the active weapon; here the active weapon IS what fired in every
/// case that reaches this — grenades and traps carry no `Weapon` — so asking for the
/// held one is the same answer with no lookup that can disagree.
///
/// ⚠️ SILENT WHEN THE MAGAZINE IS FULL rather than wasting the roll. A refund that
/// clamped to a full clip would consume the 25% and give nothing.
/// </summary>
public static void TryOverflow( GameObject attacker )
{
var p = PlayerOf( attacker );
if ( !Has( p, "M2" ) ) return;
if ( Game.Random.Float() >= OverflowChance ) return;
var wep = p.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
var si = wep.IsValid() ? wep.Primary : null;
if ( si is null || si.ClipSize <= 0 || si.Ammo >= si.ClipSize ) return;
si.Ammo++;
}
// ── m5 Fabricator ────────────────────────────────────────────────────────
/// <summary>
/// m5 Fabricator — the held weapon gains reserve ammo on a kill.
///
/// ⚠️ THE HELD WEAPON, by request — the original fabricated for HOLSTERED weapons only.
/// Held is the more useful half and the more legible one: ammo appears in the number you
/// are looking at.
///
/// ⚠️ IT CREATES AMMO RATHER THAN MOVING IT, which is what keeps it distinct from Speed
/// Cola's Auto-Loader. That one takes from reserve to fill a magazine; this one adds to
/// reserve out of nothing.
///
/// ⚠️ CLAMPED TO `MaxReserve`, so it cannot outgrow what a Max Ammo would give — and it
/// therefore composes with M1 and m2 rather than making them redundant.
/// </summary>
public static void OnZombieKilled( NZPlayer player )
{
if ( !Has( player, "m5" ) ) return;
var wep = player.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
var ammo = wep.IsValid()
? wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf )
: null;
if ( !ammo.IsValid() || ammo.Reserve >= ammo.MaxReserve ) return;
ammo.Reserve = Math.Min( ammo.MaxReserve, ammo.Reserve + FabricatorRounds );
}
// ── m3 Grenadier · m4 Resupply ───────────────────────────────────────────
static Grenade GrenadeOf( NZPlayer player )
=> player.IsValid()
? player.Components.Get<Grenade>( FindMode.EverythingInSelfAndDescendants )
: null;
/// <summary>
/// m3 Grenadier's capacity bonus, applied to `Grenade.MaxCount`.
///
/// ⛔ REBUILT FROM A REMEMBERED BASE for the same reason the reserve bonus is: `MaxCount`
/// is an authored `[Property]` and this reconciles on every refresh, so a `+=` would walk
/// it upward forever.
/// </summary>
public static void ApplyGrenadeCapacity( NZPlayer player )
{
var g = GrenadeOf( player );
if ( !g.IsValid() ) return;
if ( g.AuthoredMaxCount < 0 ) g.AuthoredMaxCount = g.MaxCount;
var target = g.AuthoredMaxCount + (Has( player, "m3" ) ? GrenadierBonus : 0);
if ( g.MaxCount == target ) return;
var delta = target - g.MaxCount;
g.MaxCount = target;
// ⚠️ The CARRIED count moves with the capacity, so buying the augment hands over
// grenades rather than just room for them — and losing it clamps rather than leaving
// you over the cap.
g.Count = Math.Clamp( g.Count + Math.Max( 0, delta ), 0, target );
}
/// <summary>
/// m3's per-round recovery and m4's full refill.
///
/// ⚠️ m4 SUPERSEDES m3's TRICKLE rather than stacking with it. Refilling to full and then
/// adding one would be a no-op on the second half, so the order is: m4 first, and m3 only
/// matters when m4 is absent. Both being equipped is a legitimate build that simply gets
/// m4's behaviour — stated because "m3 did nothing" is otherwise a reasonable bug report.
/// </summary>
public static void OnRoundStart( NZPlayer player )
{
var g = GrenadeOf( player );
if ( !g.IsValid() ) return;
if ( Has( player, "m4" ) )
{
if ( g.Count >= g.MaxCount ) return;
Log.Info( $"[nz-aug] mulekick m4 Resupply — grenades {g.Count} → {g.MaxCount}" );
g.Count = g.MaxCount;
return;
}
if ( Has( player, "m3" ) && g.Count < g.MaxCount )
g.Count = Math.Min( g.MaxCount, g.Count + GrenadierPerRound );
}
// ── M4 Insurance ─────────────────────────────────────────────────────────
/// <summary>
/// Does this player keep weapons trimmed off by losing a slot.
///
/// ⛔ THE AUGMENT IS CHECKED, NOT THE PERK, AND THAT IS THE WHOLE TRICK. By the time
/// `TrimToCap` runs the perk is already gone — `LosePerksOnDown` removes it and then
/// trims — so a `HasPerk` test here would always be false and Insurance would never fire.
///
/// ⚠️ THE SECOND HALF OF THAT RACE IS GONE. This also had to be read before
/// `PerkAugments.ClearFor` wiped it on the same loss path; that call was removed by request, so
/// the augment now outlives the perk until game over and only the ordering above still matters.
///
/// ⛔ THIS IS THE ONE PLACE THAT READS AN AUGMENT WITHOUT ITS PERK, and it stays the exception.
/// Every other `Has` tests both halves precisely because an orphaned augment is now a normal
/// state rather than a bug.
/// </summary>
public static bool KeepsWeapons( NZPlayer player )
=> player.IsValid() && PerkAugments.Has( player, Perk, "M4" );
/// <summary>
/// Remember a weapon that a slot loss destroyed, so re-buying the perk returns it.
///
/// ⚠️ THE PREFAB PATH, NOT THE GameObject. The object is destroyed moments later, so
/// holding a reference would hold a corpse. A path can be re-spawned through the normal
/// give path, which also means it arrives with `ApplyStoredUpgrades` having run — so its
/// Pack-a-Punch tier, rarity and tech come back with it rather than needing their own
/// snapshot.
///
/// ⚠️ QUEUED, NOT OVERWRITTEN. Losing M3 and then the perk destroys two weapons; a
/// single slot would return one of them.
/// </summary>
public static void Remember( NZPlayer player, string prefabPath )
{
if ( !player.IsValid() || string.IsNullOrWhiteSpace( prefabPath ) ) return;
if ( !KeepsWeapons( player ) ) return;
player.InsuredWeapons.Add( prefabPath );
Log.Info( $"[nz-aug] mulekick M4 Insurance — '{prefabPath}' held in escrow"
+ $" ({player.InsuredWeapons.Count} waiting)" );
}
/// <summary>
/// Hand back everything Insurance is holding, as far as the slots allow.
///
/// ⛔ CALLED ON PERK GAIN, so the slot exists by the time this runs. Restoring during the
/// loss would put the weapon straight back into a cap that had just shrunk, and
/// `TrimToCap` would destroy it again on the next reconcile.
///
/// ⚠️ WHAT DOES NOT FIT STAYS IN ESCROW. A player who lost two weapons and re-bought
/// only the perk (not M3) gets one back and keeps the other in reserve for later — which
/// is what an insurance policy should do, and avoids silently destroying the second.
/// </summary>
public static void Restore( NZPlayer player )
{
if ( !player.IsValid() || player.InsuredWeapons.Count == 0 ) return;
if ( !KeepsWeapons( player ) ) return;
var inv = player.Inventory;
if ( !inv.IsValid() ) return;
var restored = 0;
// ⚠️ Iterated over a COPY, because a successful give removes from the list.
foreach ( var path in player.InsuredWeapons.ToList() )
{
if ( inv.IsFull ) break;
if ( !player.GiveWeaponByPath( path ) ) continue;
player.InsuredWeapons.Remove( path );
restored++;
}
if ( restored > 0 )
Log.Info( $"[nz-aug] mulekick M4 Insurance — {restored} weapon(s) returned"
+ $"{(player.InsuredWeapons.Count > 0 ? $", {player.InsuredWeapons.Count} still in escrow" : "")}" );
}
// ── refresh ──────────────────────────────────────────────────────────────
/// <summary>
/// Reconcile everything stat-based. Called from AugmentEffects.Refresh.
///
/// ⚠️ THE CLIP BONUS IS NOT HERE. It rides `NZPlayer.ApplyClipTech`, which runs on equip
/// from `PushStoredUpgrades` — so this method calls that instead of writing ClipSize
/// itself, keeping one author for the field.
/// </summary>
public static void Refresh( NZPlayer player )
{
if ( !player.IsValid() ) return;
ApplyGrenadeCapacity( player );
Restore( player );
// ⛔ THE CLIP BONUS NEEDS A PUSH, NOT A WRITE. `ApplyClipTech` only runs from
// `ApplyStoredUpgrades`, which runs from here — so without this call, buying m1 would
// do nothing until the next weapon equip, and the augment would read as broken for
// however long the player held one gun.
//
// ⚠️ SAFE TO CALL REPEATEDLY. `ApplyClipTech` rebuilds from a remembered `TechBase`
// rather than touching the live field, which is exactly what makes it idempotent — its
// own note records the `+=` version walking a 30-round mag to 42.
//
// ⚠️ BEFORE the reserve pass, because the reserve bonus is measured in MAGAZINES
// and therefore reads `ClipSize`. Reversed, a freshly-bought m1 would size its reserve
// bonus off the OLD clip for one refresh.
// ⛔ THE CAP IS NOT WRITTEN HERE ANY MORE. `NZPlayer.ApplyStoredUpgrades` owns
// `MaxReserve` outright and now adds `BonusReserve` as one of its own terms, so the value
// is correct after ANY push - a Pack-a-Punch, an ammo buy, a weapon draw. Writing it
// from here as well is what made those three events reset the bonus.
//
// ⚠ WHAT IS STILL OWED HERE IS THE IMMEDIATE ROUNDS. Raising a cap gives a player
// nothing until they find a Max Ammo, so buying Bandolier has to hand over the four
// magazines there and then. That is an AUGMENT-CHANGE event, which is exactly what this
// method is - and crucially is NOT every equip, so the credit cannot repeat itself by
// holstering and drawing.
var before = new Dictionary<NZAmmo, int>();
foreach ( var wep in player.Components
.GetAll<SWB.Base.Weapon>( FindMode.EverythingInDescendants ) )
{
var a = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
if ( a.IsValid() ) before[a] = a.MaxReserve;
}
player.PushStoredUpgrades();
foreach ( var (a, was) in before )
{
if ( !a.IsValid() ) continue;
// ⚠ CREDITS THE DIFFERENCE, not the new maximum. Filling to full would make
// buying the augment a free Max Ammo. Only an INCREASE pays out; losing the perk
// lowers the cap and the clamp below takes the rounds back with it.
var gained = a.MaxReserve - was;
if ( gained > 0 ) a.Reserve = Math.Min( a.Reserve + gained, a.MaxReserve );
else a.Reserve = Math.Min( a.Reserve, a.MaxReserve );
}
}
// ── diagnostics ──────────────────────────────────────────────────────────
public static void Report( NZPlayer player )
{
if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var has = player.HasPerk( Perk );
var equipped = PerkAugments.EquippedOn( player, Perk );
var inv = player.Inventory;
var wep = player.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
var ammo = wep.IsValid() ? wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf ) : null;
var g = GrenadeOf( player );
Log.Info( $"[nz-aug] MULE KICK {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );
// ⚠️ PRINTS THE RESOLVED SLOT COUNT off the inventory, not the bonus. The base perk
// grants one and M3 another, so "+1" tells you nothing about how many guns you can
// actually hold.
Log.Info( $"[nz-aug] M3 Pack Mule +{BonusSlots( player )} slot(s)"
+ $" carrying {(inv.IsValid() ? $"{inv.Count}/{inv.EffectiveMaxSlots}" : "?")}" );
// ⚠️ THE TWO ARE PRINTED SEPARATELY NOW, because they no longer share a unit and a
// combined "+N" would be a number neither augment claims. The cap is called out when it
// binds — an augment silently paying less than its magazine count says is exactly the kind
// of thing this report exists to make visible.
var clip = wep.Primary?.ClipSize ?? 0;
var uncapped = Math.Max( 0, BandolierMags ) * clip;
var bandolier = Has( player, "M1" ) && clip > 0
? Math.Min( uncapped, Math.Max( 0, BandolierCap ) ) : 0;
Log.Info( $"[nz-aug] M1 Bandolier {(Has( player, "M1" )
? $"+{bandolier} rounds ({BandolierMags} x clip {clip})"
+ (bandolier < uncapped ? $" ⚠ CAPPED at {BandolierCap}, would be +{uncapped}" : "")
: "-")}" );
Log.Info( $"[nz-aug] m2 Deep Reserv {(Has( player, "m2" )
? $"+{DeepReserveFraction * 100f:0.#}% of reserve" : "-")}"
+ (ammo.IsValid() && wep.Primary is not null
? $" {wep.DisplayName}: {ammo.Reserve}/{ammo.MaxReserve}"
: " no weapon held") );
Log.Info( $"[nz-aug] M2 Overflow {(Has( player, "M2" ) ? $"{OverflowChance * 100f:0}% chance of +1 round per hit" : "-")}" );
// ⚠️ THE ESCROW COUNT IS THE POINT OF THIS LINE. Insurance is invisible until a perk
// is lost AND re-bought, so "is it holding anything" is the only observable state it
// has between those two moments.
Log.Info( $"[nz-aug] M4 Insurance {(KeepsWeapons( player ) ? "extra weapons survive perk loss" : "-")}"
+ $" {player.InsuredWeapons.Count} in escrow"
+ (player.InsuredWeapons.Count > 0
? $" [{string.Join( ", ", player.InsuredWeapons )}]"
: "") );
Log.Info( $"[nz-aug] m1 Wide Mags clip x{ClipMultiplier( player ):0.##}"
+ (wep.IsValid() && wep.Primary is not null
? $" {wep.DisplayName}: {wep.Primary.ClipSize} rounds"
: "") );
Log.Info( $"[nz-aug] m3 Grenadier {(Has( player, "m3" ) ? $"+{GrenadierBonus} capacity, +{GrenadierPerRound}/round" : "-")}"
+ $" m4 Resupply {(Has( player, "m4" ) ? "full every round" : "-")}"
+ (g.IsValid() ? $" grenades {g.Count}/{g.MaxCount} (authored max {g.AuthoredMaxCount})" : "") );
Log.Info( $"[nz-aug] m5 Fabricator {(Has( player, "m5" ) ? $"+{FabricatorRounds} reserve per kill, held weapon" : "-")}" );
// ⚠️ m3 + m4 IS A LEGITIMATE BUILD THAT WASTES m3, and the menu cannot show that.
if ( Has( player, "m3" ) && Has( player, "m4" ) )
Log.Info( "[nz-aug] ⚠ m4 Resupply supersedes m3's per-round trickle — m3 still"
+ " gives the capacity bonus, but its +1 a round does nothing while m4 refills"
+ " to full." );
}
// ── commands ─────────────────────────────────────────────────────────────
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>`nz_aug_mulekick` — the report.</summary>
[ConCmd( "nz_aug_mulekick" )]
public static void MuleCmd() => Report( Me() );
/// <summary>
/// `nz_aug_mulekick_set [bandolier] [deepReserves] [overflow] [slots] [clip]`.
///
/// ⚠️ REFRESHES AFTER SETTING, because four of these are stat-based and would otherwise
/// sit unapplied until the next equip — which reads as the command doing nothing.
/// </summary>
[ConCmd( "nz_aug_mulekick_set" )]
public static void SetCmd( int bandolier = -1, int cap = -1, float deepReserves = -1f,
float overflow = -1f, int slots = -1, float clip = -1f )
{
// ⚠️ `deepReserves` IS A FRACTION NOW, NOT A MAGAZINE COUNT, and `cap` was inserted
// ahead of it — so the old `nz_aug_mulekick_set 4 2` sets 3 magazines and a cap of 2
// rather than silently setting 200% reserve. Shifting the position is what makes a stale
// invocation obviously wrong instead of quietly catastrophic.
if ( bandolier >= 0 ) BandolierMags = bandolier;
if ( cap >= 0 ) BandolierCap = cap;
if ( deepReserves >= 0f ) DeepReserveFraction = deepReserves;
if ( overflow >= 0f ) OverflowChance = MathX.Clamp( overflow, 0f, 1f );
if ( slots >= 0 ) PackMuleSlots = slots;
if ( clip > 0f ) WideMagsClip = clip;
var p = Me();
if ( p.IsValid() ) { Refresh( p ); p.PushStoredUpgrades(); }
Report( p );
}
/// <summary>`nz_aug_mulekick_nades [bonus] [perRound] [fabricator]`.</summary>
[ConCmd( "nz_aug_mulekick_nades" )]
public static void NadeCmd( int bonus = -1, int perRound = -1, int fabricator = -1 )
{
if ( bonus >= 0 ) GrenadierBonus = bonus;
if ( perRound >= 0 ) GrenadierPerRound = perRound;
if ( fabricator >= 0 ) FabricatorRounds = fabricator;
var p = Me();
if ( p.IsValid() ) ApplyGrenadeCapacity( p );
Report( p );
}
/// <summary>
/// `nz_aug_mulekick_escrow` — what Insurance is holding, and force a restore.
///
/// ⛔ THE ONLY WAY TO TEST INSURANCE WITHOUT DYING. Reaching its interesting state means
/// buying the perk, picking up a third weapon, going down, and buying the perk again —
/// four steps, one of which is losing a life. This prints the escrow and re-runs the
/// restore so the second half can be checked on its own.
/// </summary>
[ConCmd( "nz_aug_mulekick_escrow" )]
public static void EscrowCmd()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
Log.Info( $"[nz-aug] escrow: {p.InsuredWeapons.Count}"
+ (p.InsuredWeapons.Count > 0 ? $" [{string.Join( ", ", p.InsuredWeapons )}]" : "")
+ $" · insured {KeepsWeapons( p )}"
+ $" · slots {(p.Inventory.IsValid() ? $"{p.Inventory.Count}/{p.Inventory.EffectiveMaxSlots}" : "?")}" );
Restore( p );
}
}