Player/MuleKickAugments.cs

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.

File AccessNetworking
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 );
	}
}