Player/DtapAugments.cs

Static class defining Double Tap perk augments for players. It exposes configurable tuning values, helper queries (fire rate, shot multiplier, twin spread, recoil, piercing and overpressure behavior), weapon-facing wrappers, console commands for diagnostics and live tweaking, and an Overpressure roll that charges extra ammo and doubles damage sometimes.

File Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// Double Tap's augments. Base perk: fire rate ×1.2.
///
/// All nine are wired. **m5 Overpressure Round** was the last — a 10% chance to fire a ×2 round
/// costing 2 from the magazine. It is a damage-and-ammo mechanic and shares nothing with the pierce
/// pair it used to be grouped with in this file's report, which is why it outlived them.
///
/// ⚠️ m2 AND m3 DO NOT LIVE HERE, THEY LIVE IN `NZPlayer.ApplyTech`. Both write `ShootInfo` fields
/// that the tech applier ASSIGNS on every deploy, so this file resolves the values and that one
/// expression applies them. See `PierceDepthBonus` for why splitting them would not work.
///
/// ⛔ M1 IS A REAL SECOND PROJECTILE HERE, WHERE THE ORIGINAL GAVE UP ON THAT. Its own
/// comment records the retreat: *"hitscan Double Fire no longer doubles the pellet COUNT.
/// Two hitscan pellets fired down the (near-)identical line frequently resolve as a SINGLE
/// hit, so the damage 'double' only landed sometimes."* It fell back to a flat ×2 damage on
/// hitscan and kept true doubling only for weapons that spawn a projectile entity.
///
/// ⚠️ THAT PROBLEM DOES NOT EXIST IN THIS ENGINE, and the reason is one line in ZombieAI:
/// `_hp.ImmunityAfterHit = 0f;  // zombies get no mercy window`. Two traces landing on one
/// zombie in one frame each run `Health.Apply` in full. In GMod the second hit was being
/// swallowed; here it is not. So the honest implementation — fire twice — is available, and
/// a ×2 damage stand-in would be a worse version of it for no reason.
///
/// ⚠️ IT ALSO NEEDS NO PER-BULLET-TYPE BRANCH. `HitScanBulletInfo` and
/// `PhysicalBulletInfo` are both reached through one `BulletType.Shoot( weapon, isPrimary,
/// spreadOffset )` call inside one loop, so doubling the loop count doubles hitscan traces
/// AND spawns a genuine second travelling projectile with its own tracer. The original
/// needed two separate primitives (`EnableDoubleProjectile` for entities, a damage
/// multiplier for hitscan) precisely because it had no such single seam.
/// </summary>
public static class DtapAugments
{
	const string Perk = "dtap";

	// ── tuning ───────────────────────────────────────────────────────────────

	/// <summary>
	/// M1 Double Fire — how many times the shot's bullets are multiplied.
	///
	/// ⚠️ A MULTIPLIER ON THE PELLET COUNT, so it doubles a shotgun's 8 pellets to 16 as
	/// readily as a rifle's 1 to 2. The original's description says "the
	/// projectiles/pellets fired, on ANY weapon", which is a multiply and not an add.
	/// </summary>
	public static int DoubleFireMultiplier { get; set; } = 2;

	/// <summary>
	/// Extra angular spread applied ONLY to the added bullets, in the same units as
	/// `ShootInfo.Spread`.
	///
	/// ⛔ ADDED TO THE COPIES, NEVER TO THE ORIGINALS, and that split is the whole point.
	/// An augment must not make your aimed shot less accurate — so bullet 0 of a rifle
	/// still goes exactly where the weapon's own spread would have put it, and only the
	/// twin fans out. Applying the fan to both would turn a damage augment into an
	/// accuracy penalty.
	///
	/// ⚠️ AND IT MUST NOT BE ZERO. While aiming, `GetRealSpread` on most of these prefabs
	/// returns something very near zero — the guns author `Spread` 0.0 and get their real
	/// inaccuracy from `SpreadAddHipFire`, which is not added at all when aimed. Two
	/// perfectly collinear projectiles are invisible as two, and on a physical-bullet
	/// weapon the second tracer would be hidden inside the first.
	///
	/// ⚠️ 0.012 IS A GUESS AND HAS A COMMAND FOR EXACTLY THAT REASON. It cannot be
	/// derived from anything — `Spread` is a unitless multiplier on a random vector — and
	/// this project has been bitten three times by shipping an underivable number with no
	/// way to tune it live (ParticleEffect.Scale, LineRenderer.Width, the cherry burst).
	/// `nz_aug_dtap_set` exists from the first commit.
	/// </summary>
	public static float TwinSpread { get; set; } = 0.012f;

	/// <summary>M2 Rapid Fire — fire-rate multiplier. +30%.</summary>
	public static float RapidFireRate { get; set; } = 1.30f;

	/// <summary>
	/// M3 Trigger Discipline — the DAMAGE multiplier at a full charge.
	///
	/// ⛔ THIS REPLACED "REV UP", A FIRE-RATE RAMP TIED TO MAGAZINE EMPTINESS. Three things
	/// were wrong with that and only one was a number: it was invisible while it happened,
	/// it peaked immediately before you were forced to reload, and it sat on M2 Rapid Fire's
	/// axis — which is the only reason those two ever needed a `MathF.Max` tie-break.
	///
	/// ⛔ AND IT REPLACED A FIRST ATTEMPT THAT RAMPED WHILE THE TRIGGER WAS *HELD*. That
	/// version rewarded holding fire, which is the opposite of what a thing called Trigger
	/// Discipline should reward, and it made the augment strongest exactly when you were
	/// already spraying. This one builds while you are NOT shooting and drains while you
	/// are, so the payoff belongs to restraint and target selection.
	///
	/// ⚠️ DAMAGE, NOT RATE. Damage numbers already render in the world, so the charge is
	/// legible where it is spent — and it keeps M3 off M2's axis so the two stack honestly.
	/// </summary>
	public static float TriggerDisciplineMax { get; set; } = 5f;

	/// <summary>
	/// M3 Trigger Discipline — seconds of NOT shooting to charge from empty to full.
	///
	/// ⚠️ THE CHARGE IS NORMALISED 0..1 AND THESE TWO DURATIONS DRIVE IT DIRECTLY, so
	/// changing <see cref="TriggerDisciplineMax"/> does not change how long anything takes.
	/// The alternative — rates expressed in multiplier units per second — would couple the
	/// three values so that retuning the ceiling silently retuned the timings.
	/// </summary>
	public static float TriggerBuildSeconds { get; set; } = 10f;

	/// <summary>
	/// M3 Trigger Discipline — seconds of continuous fire to drain from full back to 1×.
	///
	/// ⚠️ THE 5:1 RATIO AGAINST <see cref="TriggerBuildSeconds"/> IS THE MECHANIC — ten
	/// seconds to earn it, two to spend it — and it is a COINCIDENCE that the ceiling is
	/// also 5×. Do not "simplify" the three numbers into two; they are independent and the
	/// ratio is meant to be tunable without moving the ceiling.
	/// </summary>
	public static float TriggerDrainSeconds { get; set; } = 2f;

	/// <summary>m1 Rapid Rounds — fire-rate multiplier. +12% (`RPMMult = 1.12`).</summary>
	public static float RapidRoundsRate { get; set; } = 1.12f;

	/// <summary>
	/// m4 Steady Barrel — recoil multiplier. 0.5 = half.
	///
	/// ⛔ IT TOOK OVER DEADSHOT'S HALVING. Owning Deadshot Daiquiri used to halve recoil on
	/// every weapon before any augment was picked, which made it the largest recoil reduction in
	/// the game and made it free. The perk now keeps only spread and ADS time; this augment is
	/// where "half the recoil" lives, and it has to be chosen.
	///
	/// ⚠️ STILL RECOIL ONLY, deliberately kept out of `PerkEffects.HandlingMultiplier` — that
	/// one is read by `GetRealSpread` as well, so folding this in would hand the player accuracy
	/// the augment does not advertise.
	/// </summary>
	public static float SteadyBarrelRecoil { get; set; } = 0.5f;

	// ── the pierce pair (m2, m3) ─────────────────────────────────────────────
	//
	// ⛔ NULLABLE-BACKED GETTERS FOR THESE TWO, unlike the auto-properties above. A static's
	// VALUE survives a hotload but its initialiser does not re-run (INSTRUCTIONS.md §1). The
	// older knobs in this file predate that rule and are left alone rather than churned.

	static float? _pierceBodies;
	/// <summary>
	/// m2 Overpenetration — how many extra zombies a bullet crosses. 2, by request.
	///
	/// ⛔ IT WAS 8 AND IT NEVER DELIVERED 8 — IT DELIVERED THE CEILING, ALWAYS. The bonus is
	/// `PierceBodies * BodyDepth`, and BodyDepth was 11 on the belief that the budget charged a
	/// torso's thickness per body. It charges 1. So the augment added 88 to a budget where one
	/// unit is one zombie, and every weapon that bought it jumped straight to
	/// `MaxPenetrations = 10` no matter what it started at. A shotgun and a sniper came out
	/// identical, which is the opposite of what a +N augment is for.
	///
	/// ⚠️ SO 2 IS A REAL +2 NOW, AND IT IS THE FIRST TIME THIS AUGMENT HAS HAD A SLOPE. With
	/// the class averages stamped at 2-10 zombies, a shotgun goes 2 -> 4 and a battle rifle
	/// 7 -> 9, so the augment is worth buying on everything and decisive on nothing.
	///
	/// ⚠️ `MaxPenetrations = 10` STILL CAPS IT, and now that actually bites in the right
	/// place: snipers sit at 10 already, so m2 is worth nothing on them and the player can see
	/// why. That is a real choice rather than a hidden no-op.
	///
	/// ⚠️ AND THE CEILING COUNTS WORLD GEOMETRY TOO — a bullet that clipped a crate on the way
	/// in has fewer bodies left, so the delivered count is a maximum rather than a guarantee.
	/// </summary>
	public static float PierceBodies { get => _pierceBodies ?? 2f; set => _pierceBodies = value; }

	static float? _bodyDepth;
	/// <summary>
	/// What one zombie costs from the penetration budget. 1 — so `PenetrationDepth` IS a body
	/// count, and `ceil(depth)` is how many zombies a bullet crosses.
	///
	/// ⛔ IT WAS 11 AND THE 11 WAS DERIVED FROM A MISREADING. The reasoning was: the catalogue
	/// says depth is "9.97 on 20 weapons (about ten inches, roughly one body)", the spend line
	/// adds `+ 1.0f`, therefore ten for the torso plus one is eleven. Every step is sound except
	/// the premise — the ten-inch term is `EndPosition.Distance( HitPosition )`, which for a
	/// trace that hits is the swept shape's ORIGIN to its contact point, i.e. approximately the
	/// TRACE RADIUS. That radius ships at 0, so the term was always zero and the real charge was
	/// always the surcharge alone. The 20 weapons at 9.97 were not crossing one body; they were
	/// crossing ten, which is the engine ceiling.
	///
	/// ⛔ AND THE STATS CARD WAS PRINTING THE CONSEQUENCE. `PenDepth` divided by this, so those
	/// 20 weapons read "Under 1 zombie" while delivering the maximum the engine allows — a stat
	/// that was not merely imprecise but inverted.
	///
	/// ⚠️ IT IS NOW EXACT RATHER THAN AN AVERAGE. The old note apologised that "a torso and a
	/// forearm do not cost the same"; with a flat per-body charge they do, and the promise a
	/// weapon makes is the number it delivers.
	///
	/// ⚠️ THREE READERS, ONE AUTHOR: the bullet loop's spend, `PierceDepthBonus`'s
	/// bodies-to-budget conversion, and `WeaponStatsPanel.PenDepth`'s conversion back. Changing
	/// this changes all three together, which is the point of it being a field.
	/// </summary>
	public static float BodyDepth { get => _bodyDepth ?? 1f; set => _bodyDepth = value; }

	static float? _armorPiercingKeep;
	/// <summary>
	/// m3 Armor Piercing — the share of damage a bullet keeps per body crossed. 0.92.
	///
	/// ⚠️ IT REPLACES AN AUTHORED 0.75, WHICH IS THE SAME ON ALL 31 PREFABS. The catalogue
	/// text is "penetrating shots keep more damage", so this is the field it means:
	/// `HitScanBulletInfo` multiplies the running damage by
	/// `shootInfo.PenetrationDamageMult.Clamp( 0f, 1f )` once per body.
	///
	/// ⛔ IT COMPOUNDS, SO SMALL CHANGES MATTER MORE THAN THEY LOOK. Across three bodies
	/// 0.75 keeps 42% and 0.92 keeps 78% — the augment nearly doubles the damage of the third
	/// zombie in a line, which is exactly the case it is sold for.
	///
	/// ⛔ AND THE ENGINE CLAMPS IT AT 1, so there is no point setting this above 1.0 and no
	/// way to make pierced shots gain damage. 0.92 rather than 1.0 deliberately keeps a
	/// penalty in place: a bullet that loses nothing through flesh makes the base weapon's
	/// authored 0.75 meaningless rather than improved.
	/// </summary>
	public static float ArmorPiercingKeep
	{
		get => _armorPiercingKeep ?? 0.92f;
		set => _armorPiercingKeep = value;
	}

	/// <summary>
	/// m2 — extra penetration depth in world units, or 0 when the augment is not held.
	///
	/// ⛔ CONSUMED BY `NZPlayer`'s TECH APPLIER, NOT WRITTEN FROM HERE, AND THAT IS DELIBERATE.
	/// `ApplyTech` assigns `PenetrationDepth` outright on EVERY deploy — `basePen * penFactor`
	/// — so an augment that wrote the field itself would be silently reverted by the next
	/// weapon switch. That applier's own comment already warns that "two nodes writing the same
	/// two fields is two chances for one to undo the other". One author, one expression.
	/// </summary>
	public static float PierceDepthBonus( NZPlayer p )
		=> Has( p, "m2" ) ? MathF.Max( 0f, PierceBodies ) * MathF.Max( 0f, BodyDepth ) : 0f;

	/// <summary>
	/// m3 — the damage kept per body crossed, or 0 when the augment is not held.
	///
	/// ⚠️ 0 MEANS "NOT HELD, LEAVE THE AUTHORED VALUE ALONE" rather than "keep no damage".
	/// Zero is not a sensible setting for this field — it would make any pierced hit deal
	/// nothing — so it is free to be the sentinel, and the applier tests `> 0f`.
	/// </summary>
	public static float PierceDamageKeep( NZPlayer p )
		=> Has( p, "m3" ) ? MathF.Max( 0f, ArmorPiercingKeep ) : 0f;

	// ── helpers ──────────────────────────────────────────────────────────────

	static bool Has( NZPlayer p, string augId )
		=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );

	/// <summary>
	/// The combined fire-rate multiplier from every rate augment. 1 when none apply.
	///
	/// ⛔ M2 AND M3 DO NOT STACK WITH EACH OTHER — `MathF.Max`, not a product. Both scale
	/// the same axis, and normal play cannot hold two majors so the original never had to
	/// say what happens; our Creative override lifts that limit, which is exactly where
	/// fire rate gets tested. Multiplying them would give +95% — a number no real player
	/// can reach, so the test bench would disagree with the game for reasons nobody could
	/// see. This is the same argument, and the same resolution shape, as
	/// `EffectiveFiringType`'s documented tie-break for four tier-5 mode nodes.
	///
	/// ⛔ MAX RATHER THAN A PRIORITY ORDER, deliberately. A priority would depend on
	/// purchase order — `Loadout.Majors` is a list in the order you bought them — which is
	/// invisible state deciding a number. `Max` is order-independent.
	///
	/// ⚠️ m1 IS A MINOR AND DOES STACK, multiplicatively, on top of whichever major won.
	/// A major and a minor spent on one axis is a legitimate build; two majors is not a
	/// state the game can produce.
	///
	/// ⚠️ M1 Double Fire is NOT here. It is on the projectile-count axis, so it neither
	/// competes with nor stacks against these — it multiplies shots, not rate.
	/// </summary>
	public static float FireRateMultiplier( NZPlayer player, SWB.Base.Weapon weapon )
	{
		if ( !player.IsValid() || !player.HasPerk( Perk ) ) return 1f;

		// ⚠️ M3 IS NO LONGER ON THIS AXIS. Trigger Discipline replaced Rev Up and scales
		// DAMAGE, so the `MathF.Max` that used to resolve M2 against M3 is gone — there is
		// nothing left to resolve. That collision was the main argument against Rev Up.
		var major = Has( player, "M2" ) ? RapidFireRate : 1f;
		var minor = Has( player, "m1" ) ? RapidRoundsRate : 1f;

		return major * minor;
	}

	/// <summary>
	/// M3 Trigger Discipline's damage multiplier for a given charge. 1 when not equipped.
	///
	/// ⛔ TAKES THE CHARGE RATHER THAN READING IT, because only the weapon knows it. A
	/// player-side timer could not tell "not shooting" from "holding a trigger on an empty
	/// gun", and under Mule Kick it could not tell which of three weapons was firing.
	///
	/// ⚠️ LINEAR, NOT EASED. The player has to feel where they are on the charge from the
	/// stats row and the damage numbers alone, and a curve makes "half charged" mean
	/// something other than half the bonus.
	/// </summary>
	public static float TriggerDamage( NZPlayer player, float charge )
	{
		if ( !Has( player, "M3" ) ) return 1f;

		var t = MathX.Clamp( charge, 0f, 1f );

		return MathX.Lerp( 1f, System.MathF.Max( 1f, TriggerDisciplineMax ), t );
	}

	/// <summary>
	/// Advance a weapon's charge by one frame. Returns the new normalised charge.
	///
	/// ⛔ THE RATES LIVE HERE, NOT ON THE WEAPON, so both durations are tunable from one
	/// console command and the weapon holds nothing but the number. The weapon would
	/// otherwise need its own copies of two statics — §3, and the copy that got the fix
	/// would not be the one that ran.
	///
	/// ⚠️ IT ADVANCES WHETHER OR NOT M3 IS EQUIPPED, deliberately. Gating the tick on
	/// ownership means buying the augment mid-fight hands you an empty charge and a ten
	/// second wait, and the augment reads as broken for exactly as long as it takes to give
	/// up on it. Tracking a float nobody reads costs nothing.
	///
	/// ⚠️ DRAINS ON `IsShooting`, WHICH IS RATE-AWARE. That accessor is "a shot landed
	/// within one shot-interval", so a 600 RPM weapon held down drains continuously and a
	/// bolt-action counts as firing for its whole long interval. Draining on the trigger
	/// BEING HELD instead would drain on a dry gun and would never drain on a semi-auto,
	/// where holding the trigger fires nothing.
	/// </summary>
	public static float AdvanceCharge( float charge, bool shooting, float dt )
	{
		var build = System.MathF.Max( 0.01f, TriggerBuildSeconds );
		var drain = System.MathF.Max( 0.01f, TriggerDrainSeconds );

		charge += shooting
			? -dt / drain
			: dt / build;

		return MathX.Clamp( charge, 0f, 1f );
	}

	// ⛔ `Emptiness` WAS HERE AND IS DELETED, NOT KEPT "IN CASE". It existed only for
	// Rev Up's magazine ramp, and Trigger Discipline does not read the magazine at all — a
	// helper nobody calls is the §12 shape that produced a dead `BonusMaxHealth` in
	// PerkEffects and a dead `DeathSequences` on the hound. If a later augment wants
	// magazine emptiness it can be written then, against whatever that augment actually
	// needs.

	/// <summary>
	/// M4 Full Auto — does this player convert semi-auto weapons to automatic.
	///
	/// ⛔ ANSWERED HERE AND CONSUMED IN `EffectiveFiringType`, which is the switchboard
	/// every fire-mode reader already goes through — including the stats panel's Fire mode
	/// row, which asks that method precisely so a copy of the precedence cannot drift. So
	/// this augment shows up on the card for free.
	///
	/// ⚠️ It only has anything to do on a weapon the prefab authored as `semi`. Sixteen of
	/// the 31 are already `auto`, so on those M4 is a genuine no-op and the panel's
	/// "(was Semi)" qualifier is the only thing that can say which.
	/// </summary>
	public static bool FullAuto( NZPlayer player ) => Has( player, "M4" );

	/// <summary>
	/// m4 Steady Barrel's recoil multiplier. 1 when not equipped.
	///
	/// ⚠️ BELOW 1 MEANS LESS RECOIL, matching the original's `RecoilMult = 0.65`
	/// convention rather than inverting it into a "reduction" figure that would then have
	/// to be subtracted somewhere.
	/// </summary>
	public static float RecoilMultiplier( NZPlayer player )
		=> Has( player, "m4" ) ? SteadyBarrelRecoil : 1f;

	/// <summary>
	/// How many times to run a shot's bullet loop. 1 when Double Fire is not held.
	///
	/// ⛔ RETURNS A LOOP COUNT RATHER THAN WRITING `shootInfo.Bullets`, and writing it
	/// would have been the obvious mistake. Two separate things read that field and both
	/// would break:
	///   • `GetRealSpread` does `if ( IsAiming &amp;&amp; Primary.Bullets == 1 )` before
	///     applying the ADS spread bonus — so setting it to 2 would silently switch the
	///     aim bonus OFF on every single-pellet weapon in the game;
	///   • `WeaponTuning.Apply` runs on every deploy and reverts saved overrides of
	///     `Bullets`, which TechEffects already documents having been bitten by twice
	///     (Scattergun's pellets, Slug Loader's single slug).
	///
	/// A local count touches neither.
	/// </summary>
	public static int ShotMultiplier( NZPlayer player )
		=> Has( player, "M1" ) ? System.Math.Max( 1, DoubleFireMultiplier ) : 1;

	/// <summary>
	/// Extra spread for a duplicated bullet. 0 for the weapon's own bullets.
	///
	/// ⚠️ `pass` IS THE COPY INDEX — 0 is the real shot, 1+ are the twins. Passing the
	/// bullet index instead would fan out a shotgun's later pellets and leave the first
	/// twin perfectly collinear, which is backwards.
	/// </summary>
	public static float TwinSpreadFor( int pass )
		=> pass <= 0 ? 0f : TwinSpread;

	// ── weapon-side entry points ─────────────────────────────────────────────
	//
	// ⚠️ THESE TAKE THE WEAPON AND FIND THE OWNER, mirroring `PerkEffects.*For` exactly.
	// Its `OwnerOf` note records that a hand-written copy of the lookup had already
	// appeared four times before it was centralised, so these go through it rather than
	// adding a fifth.

	/// <summary>Fire rate multiplier from a weapon. Folded into GetRealRPM's chain.</summary>
	public static float FireRateMultiplierFor( SWB.Base.Weapon weapon )
		=> weapon.IsValid()
			? FireRateMultiplier(
				weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ),
				weapon )
			: 1f;

	/// <summary>
	/// Trigger Discipline's damage multiplier from a weapon.
	///
	/// ⚠️ THE WEAPON SUPPLIES THE HOLD TIME, so a player carrying two guns under Mule Kick
	/// charges each independently and swapping does not inherit the other's ramp.
	/// </summary>
	// ── m5 OVERPRESSURE ROUND ─────────────────────────────────────────

	/// <summary>m5 — how often a shot comes out overpressured. 10%.</summary>
	public static float OverpressureChance { get => _opChance ?? 0.10f; set => _opChance = value; }
	static float? _opChance;

	/// <summary>m5 — what an overpressured round hits for. ×2.</summary>
	public static float OverpressureDamage { get => _opDamage ?? 2f; set => _opDamage = value; }
	static float? _opDamage;

	/// <summary>m5 — rounds the magazine pays for one. 2.</summary>
	public static int OverpressureCost { get => _opCost ?? 2; set => _opCost = value; }
	static int? _opCost;

	/// <summary>How many overpressured rounds have gone out, for `nz_aug_dtap`.</summary>
	public static int OverpressureFired;

	/// <summary>
	/// Does THIS trigger pull come out overpressured.
	///
	/// ⛔ ROLLED BEFORE THE AMMO IS TAKEN, because it changes what the shot costs. `Weapon.Shoot`
	/// asks once, at the top, and carries the answer down to the damage seam — the same "read once
	/// per trigger pull, never per bullet" rule the burst and charged-trigger factors already
	/// follow. A shotgun's eight pellets are ONE shot and must all be the same round.
	///
	/// ⛔ IT WILL NOT FIRE A ROUND THE MAGAZINE CANNOT PAY FOR. "Costs 2 from the magazine" with
	/// one round left would either go negative or silently cost 1, and a ×2 round for the price of
	/// one is the augment paying the player. So the roll simply does not happen on the last round —
	/// which also means the last shot in a magazine is never the big one, and that is a fair
	/// reading of a cartridge that needs a double charge behind it.
	///
	/// ⚠️ INFINITE-CLIP WEAPONS STILL ROLL. There is no magazine to empty, so there is nothing
	/// to refuse; the wonder weapons that use that flag are not balanced against this anyway.
	/// </summary>
	public static bool RollOverpressure( SWB.Base.Weapon weapon, SWB.Base.ShootInfo shootInfo )
	{
		if ( !weapon.IsValid() || shootInfo is null ) return false;

		var player = weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
		if ( !Has( player, "m5" ) ) return false;

		// ⚠️ THE WHOLE COST, NOT JUST THIS AUGMENT'S SHARE. The shot already pays `AmmoPerShot`
		// (ARC9 weapons author more than 1), and the extra round is on top of that — checking only
		// `OverpressureCost` would let a 2-round-per-shot weapon fire its last two and go to -1.
		var free = shootInfo.InfiniteAmmo == SWB.Base.InfiniteAmmoType.clip;
		var cost = Math.Max( 1, shootInfo.AmmoPerShot ) + OverpressureExtraAmmo();

		if ( !free && shootInfo.Ammo < cost ) return false;

		if ( Game.Random.Float() > MathF.Max( 0f, OverpressureChance ) ) return false;

		OverpressureFired++;
		return true;
	}

	/// <summary>The extra rounds an overpressured shot takes, beyond the one it already pays.</summary>
	public static int OverpressureExtraAmmo() => Math.Max( 0, OverpressureCost - 1 );

	/// <summary>
	/// Force the next shots overpressured: `nz_aug_dtap_overpressure [chance]`.
	///
	/// ⚠️ A CHANCE, NOT A SWITCH, so the real thing is what gets tested. Called bare it flips
	/// between certain and the authored 10%.
	/// </summary>
	[ConCmd( "nz_aug_dtap_overpressure" )]
	public static void OverpressureCmd( float chance = -1f )
	{
		if ( chance < 0f ) OverpressureChance = OverpressureChance >= 0.999f ? 0.10f : 1f;
		else OverpressureChance = chance;

		Log.Info( $"[nz] dtap m5 overpressure: {OverpressureChance * 100f:0.#}% chance"
			+ $" ×{OverpressureDamage:0.##} for {OverpressureCost} round(s)"
			+ $" · {OverpressureFired} fired so far" );
	}

	public static float TriggerDamageFor( SWB.Base.Weapon weapon )
		=> weapon.IsValid()
			? TriggerDamage(
				weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ),
				weapon.TriggerCharge )
			: 1f;

	/// <summary>m4's recoil multiplier from a weapon. Read by FinishRecoil.</summary>
	public static float RecoilMultiplierFor( Component weapon )
		=> weapon.IsValid()
			? RecoilMultiplier(
				weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ) )
			: 1f;

	/// <summary>Does this weapon's holder convert semi to auto. Read by EffectiveFiringType.</summary>
	public static bool FullAutoFor( Component weapon )
		=> weapon.IsValid()
			&& FullAuto(
				weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ) );

	/// <summary>Shot multiplier from a weapon. Read by the stats panel's damage row.</summary>
	public static int ShotMultiplierFor( Component weapon )
		=> weapon.IsValid()
			? ShotMultiplier(
				weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled ) )
			: 1;

	// ── 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 );

		Log.Info( $"[nz-aug] DOUBLE TAP {(has ? "owned" : "NOT OWNED — every line below is inert")}"
			+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]"
			+ $" · base fire rate x{PerkEffects.DoubleTapFireRate:0.##}" );

		// ⚠️ REPORTS THE RESOLVED PELLET COUNT ON THE HELD WEAPON, not just the
		// multiplier. "x2" says nothing about a shotgun, and the whole question with this
		// augment is how many things actually leave the barrel.
		var wep = player.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
		var mult = ShotMultiplier( player );
		var authored = wep.IsValid() && wep.Primary is not null ? wep.Primary.Bullets : 0;

		Log.Info( $"[nz-aug]  M1 Double Fire  shots x{mult}"
			+ (wep.IsValid()
				? $"   {wep.DisplayName}: {authored} → {authored * mult} per trigger pull"
					+ $" ({wep.Primary?.BulletType?.GetType().Name ?? "no bullet type"})"
				: "   no weapon held") );

		Log.Info( $"[nz-aug]                  twin spread +{TwinSpread:0.####}"
			+ " (added to the COPIES only — your aimed first shot is unaffected)" );

		// ⚠️ THE RESOLVED INTERVAL FROM THE WEAPON ITSELF, not a recomputation of the
		// chain. `GetRealRPM` is the documented chokepoint and the stats panel reads it for
		// the same reason — a second copy of the arithmetic here is exactly how the panel
		// came to promise 720 RPM on a gun that fired 600 for months.
		var interval = wep.IsValid() && wep.Primary is not null
			? wep.GetRealRPM( wep.Primary.RPM )
			: 0f;

		Log.Info( $"[nz-aug]  M2 Rapid Fire   rate x{(Has( player, "M2" ) ? RapidFireRate : 1f):0.##}" );

		// ⚠️ PRINTS THE CHARGE AS WELL AS THE MULTIPLIER. A console command is by
		// definition read while not shooting, so the charge is nearly always full when you
		// look — and a multiplier reported at its ceiling is indistinguishable from one that
		// is stuck there.
		var charge = wep.IsValid() ? wep.TriggerCharge : 0f;

		Log.Info( $"[nz-aug]  M3 Trigger Disc up to x{TriggerDisciplineMax:0.##}"
			+ $", +{TriggerBuildSeconds:0.##}s idle to fill"
			+ $", -{TriggerDrainSeconds:0.##}s firing to empty"
			+ $" (spends {TriggerBuildSeconds / System.MathF.Max( 0.01f, TriggerDrainSeconds ):0.#}x faster than it fills)" );

		Log.Info( $"[nz-aug]                  charge {charge * 100f:0}%"
			+ $" → x{TriggerDamage( player, charge ):0.##}"
			+ (Has( player, "M3" ) ? "" : "   (not equipped)") );

		Log.Info( $"[nz-aug]  m1 Rapid Rounds rate x{(Has( player, "m1" ) ? RapidRoundsRate : 1f):0.##}"
			+ $"   combined x{FireRateMultiplier( player, wep ):0.###}"
			+ (interval > 0f ? $"   → {60f / interval:0} RPM" : "") );

		Log.Info( $"[nz-aug]  M4 Full Auto    {(FullAuto( player ) ? "semi → auto" : "-")}"
			+ (wep.IsValid() && wep.Primary is not null
				? $"   {wep.DisplayName}: authored {wep.Primary.FiringType}"
					+ $" → firing {wep.EffectiveFiringType( wep.Primary )}"
				: "") );

		Log.Info( $"[nz-aug]  m4 Steady Barr  recoil x{RecoilMultiplier( player ):0.##}" );

		// ⚠️ THE LIVE FIELDS ARE PRINTED, NOT JUST THE INTENDED ONES. `PenetrationDepth` is assigned
		// by `NZPlayer.ApplyTech` on every deploy from `basePen * penFactor + bonus`, so the only
		// way to know the augment actually landed is to read the value off the weapon in hand. A
		// report that echoed `PierceBodies` back would agree with itself while the gun disagreed.
		var si = wep.IsValid() ? wep.Primary : null;

		var bonus = PierceDepthBonus( player );
		var keep = PierceDamageKeep( player );

		Log.Info( $"[nz-aug]  m2 Overpenetr.  {(Has( player, "m2" ) ? $"+{PierceBodies:0.#} bodies" : "-")}"
			+ $"   = +{bonus:0.#}u at {BodyDepth:0.#}u each"
			+ (si is not null ? $"   live depth {si.PenetrationDepth:0.#}u" : "")
			+ (si is not null ? $"  (~{si.PenetrationDepth / MathF.Max( 1f, BodyDepth ):0.#} bodies," : "")
			+ " engine cap 10)" );

		Log.Info( $"[nz-aug]  m3 Armor Pierc. {(Has( player, "m3" ) ? $"keep x{ArmorPiercingKeep:0.##}" : "-")}"
			+ (si is not null ? $"   live x{si.PenetrationDamageMult:0.##}" : "")
			+ (keep > 0f
				? $"   3 bodies deep: {MathF.Pow( keep, 3f ) * 100f:0}% vs 42% authored"
				: "   authored 0.75 keeps 42% at 3 bodies deep") );

		// ⛔ NOT `Penetration` ITSELF — it is TRUE on all 31 prefabs, so neither augment grants
		// piercing, both deepen it. `t4_solidslug` is the one thing that turns it off, and it beats
		// both of these outright: a weapon that cannot penetrate spends no budget at all.
		Log.Info( $"[nz-aug]                  penetration enabled: "
			+ (si is not null ? $"{si.Penetration}" : "no weapon")
			+ (si is not null && !si.Penetration ? "  ⛔ Solid Slug is off — both augments are dead" : "") );

		Log.Info( $"[nz-aug]  m5 Overpressure — {(Has( player, "m5" ) ? "OWNED" : "not owned")}"
			+ $" · {OverpressureChance * 100f:0.#}% ×{OverpressureDamage:0.##}"
			+ $" for {OverpressureCost} rounds · {OverpressureFired} fired"
			+ "   (nz_aug_dtap_overpressure to force)" );
	}

	// ── commands ─────────────────────────────────────────────────────────────

	static NZPlayer Me()
		=> NZPlayer.Local;

	/// <summary>`nz_aug_dtap` — the report.</summary>
	[ConCmd( "nz_aug_dtap" )]
	public static void DtapCmd() => Report( Me() );

	/// <summary>
	/// `nz_aug_dtap_set [multiplier] [twinSpread]` — how many shots, and how far the
	/// copies fan out.
	///
	/// ⚠️ THE MULTIPLIER GOES ABOVE 2 ON PURPOSE. Setting it to 8 is how you confirm the
	/// extra projectiles are real rather than a damage multiplier wearing a costume: eight
	/// tracers are unmistakable, and eight times the damage on a single trace is not.
	/// </summary>
	[ConCmd( "nz_aug_dtap_set" )]
	public static void SetCmd( int multiplier = -1, float twinSpread = -1f )
	{
		if ( multiplier >= 1 ) DoubleFireMultiplier = multiplier;
		if ( twinSpread >= 0f ) TwinSpread = twinSpread;

		Report( Me() );
	}

	/// <summary>
	/// `nz_aug_dtap_rate [rapidFire] [revUpMax] [rapidRounds] [recoil]` — the four
	/// stat augments.
	///
	/// ⚠️ SEPARATE FROM `nz_aug_dtap_set`, which owns Double Fire. Six positional floats
	/// on one command is more than anyone will remember, and these four move one axis each
	/// while that one changes how many things leave the barrel.
	/// </summary>
	[ConCmd( "nz_aug_dtap_rate" )]
	public static void RateCmd( float rapidFire = -1f, float rapidRounds = -1f,
		float recoil = -1f )
	{
		if ( rapidFire >= 0f ) RapidFireRate = rapidFire;
		if ( rapidRounds >= 0f ) RapidRoundsRate = rapidRounds;
		if ( recoil >= 0f ) SteadyBarrelRecoil = recoil;

		Report( Me() );
	}

	/// <summary>
	/// `nz_aug_dtap_trigger [max] [seconds]` — Trigger Discipline's ceiling and how long
	/// it takes to get there.
	///
	/// ⚠️ ITS OWN COMMAND, not two more floats on the rate one. Trigger Discipline is a
	/// DAMAGE augment now; grouping it with the three rate knobs is how someone later
	/// concludes it must be on the rate axis after all.
	/// </summary>
	[ConCmd( "nz_aug_dtap_trigger" )]
	public static void TriggerCmd( float max = -1f, float build = -1f, float drain = -1f )
	{
		if ( max >= 0f ) TriggerDisciplineMax = max;
		if ( build >= 0f ) TriggerBuildSeconds = build;
		if ( drain >= 0f ) TriggerDrainSeconds = drain;

		Report( Me() );
	}

	/// <summary>
	/// `nz_aug_dtap_charge [0-1]` — set the held weapon's charge directly.
	///
	/// ⛔ THE ONLY WAY TO SEE THE TOP OF THE RAMP FROM A CONSOLE. Reading the report always
	/// shows a full charge (you are not shooting while you type) and reading it mid-fight is
	/// impossible, so the interesting states — half charged, nearly spent — are otherwise
	/// unreachable except by feel. This sets one and the stats card shows the result.
	/// </summary>
	[ConCmd( "nz_aug_dtap_charge" )]
	public static void ChargeCmd( float charge = -1f )
	{
		var p = Me();
		var wep = p?.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );

		if ( !wep.IsValid() ) { Log.Warning( "[nz-aug] no weapon held" ); return; }

		if ( charge >= 0f ) wep.SetTriggerCharge( charge );

		Report( p );
	}
}