Player/VigorAugments.cs

Static class implementing Vigor Rush perk augments for a zombie game. It stores tuning constants, computes damage multipliers (including M1-M5/minors), handles events for player damaged and zombie killed (killstreak, vengeance window, cleave, last-round splash), provides ricochet probe and bounce budget, and exposes developer console commands to report and tweak values.

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

namespace NZombies;

/// <summary>
/// Vigor Rush's augments. Base perk: bullet damage **×1.2** — retuned down from ×2.
///
/// | id | effect | status |
/// |----|--------|--------|
/// | M1 Overkill    | the base becomes ×1.4 | ⚠ REPLACES the base, not a stack |
/// | M2 Executioner | **instantly kills** anything under 20% HP | ⚠ was ×3 damage |
/// | M3 Point Blank | ×2 at contact, falling to ×1 over 500u | ⚠ was ×1.5 |
/// | M4 Killstreak  | +1.5% per kill to +100%, reset when YOU take damage | ⚠ was +3% to +300% |
/// | m1 Spoils      | +20 points per kill | as the original |
/// | m2 **Ricochet** | bullets bounce off world surfaces, guaranteed | ⚠ NEW — replaced Overpenetration |
/// | m3 Cleave      | a kill deals 10% of its max HP to the nearest zombie | as the original |
/// | m4 Last Round  | the mag-emptying shot splashes its full damage, 160u | as the original |
/// | m5 **Vengeance** | taking a hit gives ×2 damage for 3s | ⚠ NEW — replaced Opening Shot |
///
/// ⛔ M1 REPLACES THE BASE MULTIPLIER RATHER THAN STACKING ON IT. The original stacked
/// ×1.65 onto a ×2 base for ×3.3; here the base is ×1.2 and M1 makes it ×1.4 outright. That
/// is one number answering "how much does Vigor Rush multiply bullet damage" instead of two
/// that have to be multiplied to find out — and it means the catalogue value and the live
/// value are the same figure.
///
/// ⛔ m2 REPLACED THE ORIGINAL'S "OVERPENETRATION" BECAUSE THAT IS DOUBLE TAP'S m2, same
/// name and nearly the same numbers (+2 pierce at 60% here, +8 at 80% there). Pierce is also
/// unwired for the `t5_deadeye` tech node — `Health.cs` records that node paying two thirds
/// of what it promises — so one pierce primitive is owed to three separate features and
/// building it for a minor augment would have been the wrong first customer.
///
/// ⚠️ m5 AND M4 ARE DELIBERATE OPPOSITES. Killstreak's ramp is wiped when you take damage;
/// Vengeance pays you for taking damage. Owning both is a coherent aggressive build — you
/// trade the slow ramp for the burst — and the report says so, because "M4 keeps resetting"
/// is otherwise a reasonable bug report.
/// </summary>
public static class VigorAugments
{
	const string Perk = "vigor";

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

	/// <summary>M1 Overkill — what the base bullet multiplier BECOMES. ×1.4.</summary>
	static float? _overkillDamage;

	/// <summary>
	/// M1 Overkill's replacement multiplier. 1.4.
	///
	/// ⚠ SAME NULLABLE-GETTER SHAPE AS `PerkEffects.VigorDamage`, and for the same reason:
	/// these two numbers were changed together, so an initialiser here would have carried the
	/// old value across a hotload exactly as that one did. See the note there.
	/// </summary>
	public static float OverkillDamage
	{
		get => _overkillDamage ?? 1.4f;
		set => _overkillDamage = value;
	}

	/// <summary>
	/// M2 Executioner — the health fraction below which a hit kills outright. 20%.
	///
	/// ⚠️ THE THRESHOLD IS TESTED BEFORE THE HIT LANDS, not after. "Below 20%" means the
	/// zombie was already there when you shot it — testing afterwards would make any hit
	/// that happened to leave it under 20% a kill, which is a far stronger augment and not
	/// the one described.
	/// </summary>
	public static float ExecutionerThreshold { get; set; } = 0.20f;

	/// <summary>M3 Point Blank — the multiplier at zero distance. ×2.</summary>
	public static float PointBlankMax { get; set; } = 2f;

	/// <summary>M3 Point Blank — the range at which the bonus has decayed to nothing.</summary>
	public static float PointBlankRange { get; set; } = 500f;

	/// <summary>M4 Killstreak — damage gained per kill. +1.5%.</summary>
	// ⚠️ HALVED FROM the original's +3% (2026-09-13). Deliberately NOT matching the
	// source game any more: the pair below is what makes a long clean streak the
	// single biggest damage source in the run, and the ceiling matters more than
	// the rate — see KillstreakMax.
	public static float KillstreakPerKill { get; set; } = 0.015f;

	/// <summary>M4 Killstreak — the ceiling. +100%, i.e. ×2 total.</summary>
	// ⛔ WAS +300% (×4). A quadrupling that costs nothing but not being hit dwarfs
	// every other damage source in the tree, and on a high round it is free — the
	// horde dies before it reaches you. ×2 keeps the streak worth holding without
	// making the rest of Vigor Rush's tier irrelevant.
	//
	// ⚠️ KillsToCap DERIVES FROM BOTH and needs no edit: 100 kills before, 67 now.
	// Halving the rate alone would have DOUBLED the climb to an unchanged ceiling,
	// which is a different change entirely — the ceiling is the nerf, the rate is
	// the pacing.
	public static float KillstreakMax { get; set; } = 1f;

	/// <summary>m1 Spoils — bonus points per kill.</summary>
	public static int SpoilsPoints { get; set; } = 20;

	/// <summary>
	/// m2 Ricochet — how many guaranteed world bounces a bullet gets.
	///
	/// ⚠️ THE SPEC WAS "RICOCHET ONCE OFF EVERY SURFACE", WHICH IS AMBIGUOUS, and this is
	/// the conservative reading: one bounce total, guaranteed. The other reading — a bounce
	/// per surface, chaining up to the authored cap of 3 — is one command away
	/// (`nz_aug_vigor_minor` third argument) rather than a rewrite, which is why the value
	/// is a knob instead of a literal.
	/// </summary>
	public static int RicochetBounces { get; set; } = 1;

	/// <summary>m3 Cleave — the fraction of the dead zombie's MAX health passed on.</summary>
	public static float CleaveShare { get; set; } = 0.10f;

	/// <summary>m3 Cleave — how far to look for the nearest zombie.</summary>
	public static float CleaveRange { get; set; } = 400f;

	/// <summary>m4 Last Round — the splash radius of the mag-emptying shot.</summary>
	public static float LastRoundRadius { get; set; } = 160f;

	/// <summary>m5 Vengeance — damage multiplier while the window is open. ×2.</summary>
	public static float VengeanceDamage { get; set; } = 2f;

	/// <summary>m5 Vengeance — how long the window lasts, seconds.</summary>
	public static float VengeanceSeconds { get; set; } = 3f;

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

	// ── M1 · the base multiplier ─────────────────────────────────────────────

	/// <summary>
	/// What Vigor Rush's bullet multiplier is for this player.
	///
	/// ⛔ RETURNS THE WHOLE ANSWER, NOT A FACTOR TO STACK. `PerkEffects.VigorDamage` holds
	/// the un-augmented 1.2 and this returns 1.4 instead when M1 is owned — so exactly one
	/// number is ever the answer to "how much does Vigor multiply bullet damage", and the
	/// catalogue text can quote it directly.
	/// </summary>
	public static float BulletDamage( NZPlayer player, float baseMultiplier )
		=> Has( player, "M1" ) ? OverkillDamage : baseMultiplier;

	// ── M2 · M3 · M4 · m5 · damage-time ──────────────────────────────────────

	/// <summary>
	/// Would M2 Executioner kill this victim outright.
	///
	/// ⚠️ TAKES THE VICTIM'S NUMBERS because the one caller is inside the victim's own
	/// Health component and already has them — and because they must be the PRE-HIT values.
	/// </summary>
	public static bool Executes( GameObject attacker, float current, float max )
	{
		if ( max <= 0f || current <= 0f ) return false;
		if ( !Has( PlayerOf( attacker ), "M2" ) ) return false;

		return current / max < MathX.Clamp( ExecutionerThreshold, 0f, 1f );
	}

	/// <summary>
	/// The combined damage multiplier from M3, M4 and m5.
	///
	/// ⛔ M3 AND M4 ARE BOTH MAJORS AND THEREFORE CANNOT BOTH BE OWNED IN NORMAL PLAY, but
	/// the Creative override lifts that — so they MULTIPLY here rather than being resolved.
	/// That is the opposite of Double Tap's M2/M3, and the difference is real: those two
	/// scaled one axis (fire rate) and produced a number no player could reach, whereas
	/// these are two different conditions on damage and their product is simply a strong
	/// hit. Nothing is being hidden from the test bench.
	///
	/// ⚠️ m5 IS A MINOR AND STACKS WITH WHICHEVER MAJOR IS HELD, deliberately.
	/// </summary>
	public static float DamageScale( GameObject attacker, Vector3 victimPosition )
	{
		var p = PlayerOf( attacker );
		if ( !p.IsValid() ) return 1f;

		var mult = CertainDamageScale( p );

		// ⚠️ M3 IS THE ONLY TERM THAT NEEDS A TARGET, which is why it is not in
		// `CertainDamageScale` below.
		if ( Has( p, "M3" ) ) mult *= PointBlankScale( p, victimPosition );

		return mult;
	}

	/// <summary>
	/// The damage terms that do not depend on the target — M4 Killstreak and m5 Vengeance.
	///
	/// ⛔ SPLIT OUT FOR THE STATS CARD, AND THE LINE IS "CERTAIN" VS "CONDITIONAL". These two
	/// are live state but they are FACTS: the streak is whatever it is, the window is open or
	/// shut, and both apply to the next shot with certainty whatever you shoot. M3 Point
	/// Blank is different in kind — it depends on how far away the thing you have not shot
	/// yet happens to be, so no number the card could print would be true of the next shot.
	///
	/// ⚠️ THE CARD ASKS THIS ONE; GAMEPLAY ASKS `DamageScale`. Keeping them as two methods
	/// rather than a flag means neither call site can accidentally get the other's answer,
	/// and the omission is visible in the signature — `DamageScale` needs a position and this
	/// does not.
	/// </summary>
	public static float CertainDamageScale( NZPlayer player )
	{
		if ( !player.IsValid() || !player.HasPerk( Perk ) ) return 1f;

		var mult = 1f;

		if ( Has( player, "M4" ) ) mult *= KillstreakScale( player );
		if ( Has( player, "m5" ) && player.VigorVengeance > 0f ) mult *= VengeanceDamage;

		return mult;
	}

	/// <summary>
	/// M3's distance falloff.
	///
	/// ⚠️ MEASURED FROM THE PLAYER TO THE VICTIM, not along the bullet's path. A ricochet or
	/// a penetrating shot can travel much further than the gap between the two, and "point
	/// blank" is about how close the enemy is, not how far the round flew.
	///
	/// ⚠️ LINEAR, so half the range is half the bonus. A curve would make the number
	/// unguessable from the distance and this augment is meant to be felt positionally.
	/// </summary>
	public static float PointBlankScale( NZPlayer player, Vector3 victimPosition )
	{
		if ( !player.IsValid() ) return 1f;

		var range = MathF.Max( 1f, PointBlankRange );
		var t = MathX.Clamp( player.WorldPosition.Distance( victimPosition ) / range, 0f, 1f );

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

	/// <summary>M4's current multiplier. 1 at no streak.</summary>
	public static float KillstreakScale( NZPlayer player )
	{
		if ( !player.IsValid() ) return 1f;

		return 1f + MathX.Clamp( player.VigorStreak * KillstreakPerKill,
			0f, MathF.Max( 0f, KillstreakMax ) );
	}

	/// <summary>How far along Killstreak is, 0-1. For the HUD bar.</summary>
	public static float KillstreakProgress( NZPlayer player )
	{
		var span = MathF.Max( 0.001f, KillstreakMax );
		return MathX.Clamp( (KillstreakScale( player ) - 1f) / span, 0f, 1f );
	}

	/// <summary>Kills needed to reach the ceiling from nothing.</summary>
	public static int KillsToCap
		=> KillstreakPerKill <= 0f
			? 0
			: (int)MathF.Ceiling( MathF.Max( 0f, KillstreakMax ) / KillstreakPerKill );

	// ── the player being hit ─────────────────────────────────────────────────

	/// <summary>
	/// M4's reset and m5's window, both triggered by the player taking damage.
	///
	/// ⛔ ONE CALL FOR BOTH, because they read the same event and are exact opposites —
	/// keeping them together is what makes the opposition visible in the code rather than
	/// something a reader has to notice across two files.
	/// </summary>
	public static void OnPlayerDamaged( NZPlayer player, GameObject from, float amount )
	{
		if ( !player.IsValid() || !player.HasPerk( Perk ) || amount <= 0f ) return;

		// ⛔ ONLY AN ENEMY BREAKS THE STREAK. It used to be wiped by ANY damage that reached
		// `Health.Apply` — a fall, a damage wall, an easter-egg trap, your own grenade — so a
		// 60-kill streak could be lost to a pit nothing was chasing you into. M4 is about not
		// being HIT; the world is not hitting you.
		//
		// ⚠️ THE SAME TEST GUARDS m5 VENGEANCE BELOW, and it has to. m5 opens a damage window
		// as retaliation for being hurt, and retaliating against a floor is not a thing —
		// letting it open on world damage would also let a player farm the window on purpose
		// by stepping into something harmless.
		if ( !ZombieAI.IsEnemyDamage( from ) ) return;

		if ( Has( player, "M4" ) && player.VigorStreak > 0 )
		{
			Log.Info( $"[nz-aug] vigor M4 Killstreak — streak of {player.VigorStreak} lost" );
			player.VigorStreak = 0;
		}

		if ( Has( player, "m5" ) )
			player.VigorVengeance = VengeanceSeconds;
	}

	// ── kills ────────────────────────────────────────────────────────────────

	/// <summary>
	/// M4's streak, m1's points and m3's cleave.
	///
	/// ⚠️ THE STREAK COUNTS ANY KILL, unlike Deadshot's Focus which needs a headshot. That
	/// is the whole difference between the two majors: this one is about volume and is taken
	/// away by being hit; that one is about precision and is taken away by a sloppy kill.
	/// </summary>
	public static void OnZombieKilled( NZPlayer player, Vector3 position, float victimMaxHealth,
		GameObject victim )
	{
		if ( !player.IsValid() || !player.HasPerk( Perk ) ) return;

		if ( Has( player, "M4" ) )
			player.VigorStreak = Math.Min( player.VigorStreak + 1, KillsToCap );

		// ⚠️ m1's spoils pay nothing while basalt's altar defense runs, when a kill pays only its 10 (`HexPlatforms.DefensePoints`)
		if ( Has( player, "m1" ) && !HexPlatforms.DefensePoints ) player.AddPoints( SpoilsPoints );

		if ( Has( player, "m3" ) ) Cleave( player, position, victimMaxHealth, victim );
	}

	/// <summary>
	/// m3 Cleave — pass a share of the dead zombie's MAX health to the nearest one.
	///
	/// ⛔ MAX HEALTH, NOT THE DAMAGE DEALT, which is what makes this scale with the ROUND
	/// rather than with the weapon. Deadshot's M3 does the opposite deliberately; these two
	/// splashes are meant to feel different, and a project with both should have one of each.
	///
	/// ⚠️ THE NEAREST ONE, NOT EVERYTHING IN RANGE. `CleaveRange` is a search radius, not a
	/// blast radius — the augment is a chain of one, so a crowded room does not turn a kill
	/// into an area attack.
	///
	/// ⚠️ NO RE-ENTRY GUARD NEEDED, unlike the original's `vigorAoE` flag: this calls
	/// `Health.OnDamage` with an empty TagSet, so it carries no bullet tag and none of the
	/// damage augments — Vigor's own base multiplier included — can see it.
	/// </summary>
	static void Cleave( NZPlayer player, Vector3 position, float victimMaxHealth, GameObject victim )
	{
		var splash = victimMaxHealth * MathF.Max( 0f, CleaveShare );
		if ( splash <= 0f ) return;

		ZombieAI nearest = null;
		var bestDist = float.MaxValue;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( victim.IsValid() && z.GameObject == victim ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			var d = position.Distance( z.WorldPosition + Vector3.Up * 32f );
			if ( d > CleaveRange || d >= bestDist ) continue;

			bestDist = d;
			nearest = z;
		}

		if ( !nearest.IsValid() ) return;

		nearest.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors )
			?.OnDamage( new DamageInfo
			{
				Damage = splash,
				Attacker = player.GameObject,
				Position = nearest.WorldPosition + Vector3.Up * 32f,
				Tags = new TagSet(),
			} );
	}

	// ── m4 Last Round ────────────────────────────────────────────────────────

	/// <summary>
	/// m4 Last Round — splash the mag-emptying shot.
	///
	/// ⛔ THE TEST IS `Ammo == 0` AFTER THE SHOT, which is what "the last bullet of each
	/// mag" means and needs no per-weapon state: the magazine strictly decreases while
	/// firing, so it passes through zero exactly once per cycle.
	///
	/// ⚠️ EVERY PELLET OF A SHOTGUN BLAST WOULD PASS THAT TEST, so the splash is capped to
	/// one per shot by the `_lastRoundShot` latch on the weapon. Without it a KS23 emptying
	/// its tube would splash sixteen times for one trigger pull.
	/// </summary>
	public static void TryLastRound( GameObject attacker, Vector3 position, float amount,
		GameObject victim )
	{
		var p = PlayerOf( attacker );
		if ( !Has( p, "m4" ) || amount <= 0f ) 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 != 0 ) return;
		if ( wep.LastRoundSplashed ) return;

		wep.LastRoundSplashed = true;

		var hit = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( victim.IsValid() && z.GameObject == victim ) continue;

			var target = z.WorldPosition + Vector3.Up * 32f;
			if ( position.Distance( target )
				> LastRoundRadius * FireAugments.AreaRadiusScale( attacker ) ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			hp.OnDamage( new DamageInfo
			{
				Damage = amount,
				Attacker = p.GameObject,
				Position = target,
				Tags = new TagSet(),
			} );

			hit++;
		}

		if ( hit > 0 )
			Log.Info( $"[nz-aug] vigor m4 Last Round — {amount:0.#} to {hit} zombie(s)" );
	}

	// ── m2 Ricochet ──────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_ricochet_probe` — why a bounce happens off the floor and not off a crate.
	///
	/// ⛔ IT PROBES BOTH SURFACES IN ONE CALL, forward and straight down, and prints the
	/// ricochet gate's sub-conditions for each. "Only the floor bounces" narrows to exactly two
	/// candidates that reading cannot separate — `hasImpact` (a prop whose material carries no
	/// Surface) and `target is null` (a prop that is an `IDamageable`) — and both look identical
	/// from the outside. Two rows side by side name the one that differs.
	///
	/// ⚠ IT CALLS `Weapon.TraceBullet`, THE SAME HELPER THE BULLET USES, with the same radius
	/// and ignore-tags. A probe that built its own `Scene.Trace.Ray` would be measuring a
	/// different trace than the one under test — §2, and the whole reason this reading is worth
	/// taking.
	/// </summary>
	[ConCmd( "nz_ricochet_probe" )]
	public static void ProbeCmd()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz-rico] no player" ); return; }

		// ⚠ `VultureAugments.HeldWeapon`, the one implementation of this lookup in the
		// project. A second copy here is the §3 shape, and it would be the copy that keeps
		// missing the holstered-weapon case.
		var wep = VultureAugments.HeldWeapon( player );
		if ( !wep.IsValid() ) { Log.Warning( "[nz-rico] no weapon held" ); return; }

		var bounces = BouncesFor( wep );
		var si = wep.Primary;

		Log.Info( $"[nz-rico] m2 {(Has( player, "m2" ) ? "ON" : "off")}"
			+ $" · budget {bounces}"
			+ $" · weapon Ricochet={(si?.Ricochet.ToString() ?? "?")}"
			+ $" MaxRicochets={(si?.MaxRicochets.ToString() ?? "?")}" );

		if ( bounces <= 0 )
			Log.Warning( "[nz-rico] ⛔ budget is 0 — the augment bypass is OFF, so the bounce falls"
				+ " back to the surface list + a 30° grazing angle + a 33% roll. THAT alone would"
				+ " explain floor-only: a floor shot grazes, a crate shot is head-on." );

		var eye = player.Components.Get<PlayerController>()?.EyeTransform
			?? new Transform( player.WorldPosition + Vector3.Up * 64f );

		Probe( "forward", wep, eye.Position, eye.Forward );
		Probe( "floor  ", wep, eye.Position, Vector3.Down );
	}

	static void Probe( string label, SWB.Base.Weapon wep, Vector3 from, Vector3 dir )
	{
		// ⛔ THE INSTANCE OVERLOAD, WHICH IGNORES `Owner.GameObject`. The static one ignores
		// only the object handed to it, and the weapon is a CHILD of the player - so passing
		// `wep.GameObject` ignored the gun and hit the player holding it. The first reading of
		// this probe said `hit 'Player Controller'` for BOTH directions, which is the probe
		// being wrong rather than the game.
		var tr = wep.TraceBullet( from, from + dir * 4096f );

		if ( !tr.Hit )
		{
			Log.Info( $"[nz-rico]   {label}: nothing within 4096u" );
			return;
		}

		// The three gate terms, each named so the reading needs no interpreting.
		var surfaceOk = tr.Surface is not null;
		var skybox = surfaceOk && SWB.Shared.SurfaceUtil.IsSkybox( tr.Surface );
		var posOk = tr.HitPosition != Vector3.Zero;
		var hasImpact = surfaceOk && !skybox && posOk;

		var dmg = tr.GameObject?.Components.GetInAncestorsOrSelf<Sandbox.Component.IDamageable>();

		// ⛔ THE GATE'S OWN TEST, not a copy of it. `IsBody` is what `BulletInfo.HitScan`
		// actually asks, so this row cannot disagree with the game.
		var isBody = SWB.Base.HitScanBulletInfo.IsBody( dmg );

		var graze = SWB.Shared.SurfaceUtil.GetGrazingAngle( dir, tr.Normal );

		Log.Info( $"[nz-rico]   {label}: hit '{tr.GameObject?.Name ?? "<none>"}'"
			+ $" · surface {(surfaceOk ? tr.Surface.ResourceName : "NULL")}"
			+ $" · hasImpact {hasImpact}"
			+ $" · IDamageable {(dmg is null ? "no" : dmg.GetType().Name)}"
			+ $" · counts as body {isBody}"
			+ $" · graze {graze:0}°" );

		if ( !hasImpact )
			Log.Warning( $"[nz-rico]   ⛔ {label}: hasImpact FALSE"
				+ (surfaceOk ? (skybox ? " (skybox)" : " (zero hit position)") : " (NO SURFACE on this collider)")
				+ " — the gate rejects it before the augment is ever consulted." );
		else if ( isBody )
			Log.Warning( $"[nz-rico]   ⛔ {label}: the gate treats this as a BODY and refuses to"
				+ " bounce - the \"not counting zombies\" rule." );
		else
			Log.Info( $"[nz-rico]   ✅ {label}: both gate terms pass — this surface can bounce." );
	}

	/// <summary>
	/// Guaranteed world bounces from m2, resolved from a weapon. 0 when not equipped.
	///
	/// ⛔ CONSUMED BY `BulletInfo.HitScan`'s EXISTING RICOCHET GATE, not by new trace code.
	/// That gate already bounces only when the trace found no `IDamageable` — so "not
	/// counting zombies" is the behaviour it has, not something this augment adds. All the
	/// augment supplies is a bounce budget and a bypass of the surface/angle/chance roll,
	/// which is exactly the shape `t5_ricochet` already established there.
	/// </summary>
	public static int BouncesFor( Component weapon )
	{
		var p = weapon.IsValid()
			? weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
			: null;

		return Has( p, "m2" ) ? Math.Max( 0, RicochetBounces ) : 0;
	}

	// ── 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] VIGOR RUSH {(has ? "owned" : "NOT OWNED — every line below is inert")}"
			+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );

		// ⚠️ ONE LINE FOR THE BASE AND M1 TOGETHER, because M1 REPLACES the base rather
		// than stacking — printing them separately would invite the reader to multiply.
		Log.Info( $"[nz-aug]  M1 Overkill     bullet damage x{BulletDamage( player, PerkEffects.VigorDamage ):0.##}"
			+ $"   (base x{PerkEffects.VigorDamage:0.##}, M1 replaces it with x{OverkillDamage:0.##})" );

		Log.Info( $"[nz-aug]  M2 Executioner  {(Has( player, "M2" ) ? $"instant kill under {ExecutionerThreshold * 100f:0}% HP" : "-")}" );

		// ⚠️ THE MULTIPLIER AT SEVERAL DISTANCES, not just the peak. A single "x2" says
		// nothing about a falloff, and where the bonus stops being worth closing the gap for
		// is the only question this augment raises.
		Log.Info( $"[nz-aug]  M3 Point Blank  {(Has( player, "M3" ) ? $"x{PointBlankMax:0.##} at contact → x1 at {PointBlankRange:0}u" : "-")}"
			+ (Has( player, "M3" )
				? $"   [0u x{PointBlankScale( player, player.WorldPosition ):0.##}"
					+ $" · {PointBlankRange * 0.5f:0}u x{MathX.Lerp( PointBlankMax, 1f, 0.5f ):0.##}"
					+ $" · {PointBlankRange:0}u x1.00]"
				: "") );

		Log.Info( $"[nz-aug]  M4 Killstreak   streak {player.VigorStreak}/{KillsToCap}"
			+ $" → x{KillstreakScale( player ):0.##} of x{1f + KillstreakMax:0.##}"
			+ $"   ({KillstreakProgress( player ) * 100f:0}% charged, +{KillstreakPerKill * 100f:0.#}% per kill)" );

		Log.Info( $"[nz-aug]  m1 Spoils       {(Has( player, "m1" ) ? $"+{SpoilsPoints} points per kill" : "-")}"
			+ $"   m3 Cleave {(Has( player, "m3" ) ? $"{CleaveShare * 100f:0.#}% of max HP within {CleaveRange:0}u" : "-")}" );

		Log.Info( $"[nz-aug]  m2 Ricochet     {(Has( player, "m2" ) ? $"{RicochetBounces} guaranteed world bounce(s)" : "-")}"
			+ $"   m4 Last Round {(Has( player, "m4" ) ? $"full damage in {LastRoundRadius:0}u" : "-")}" );

		Log.Info( $"[nz-aug]  m5 Vengeance    {(Has( player, "m5" ) ? $"x{VengeanceDamage:0.##} for {VengeanceSeconds:0.##}s after a hit" : "-")}"
			+ $"   window {(player.VigorVengeance > 0f ? $"{(float)player.VigorVengeance:0.##}s left" : "shut")}" );

		// ⚠️ THE M4/m5 OPPOSITION IS STATED, because "my streak keeps resetting" is an
		// entirely reasonable bug report from someone holding both.
		if ( Has( player, "M4" ) && Has( player, "m5" ) )
			Log.Info( "[nz-aug]  ⚠ M4 and m5 are opposites — being hit WIPES the killstreak"
				+ " and OPENS the vengeance window. That is a coherent aggressive build, not"
				+ " a bug." );
	}

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

	static NZPlayer Me()
		=> NZPlayer.Local;

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

	/// <summary>
	/// `nz_aug_vigor_set [overkill] [execThreshold] [pointBlank] [pbRange] [base]` — the
	/// majors, plus the perk's own base multiplier.
	///
	/// ⚠️ THE BASE IS TUNABLE FROM HERE TOO, because M1 replaces it — retuning one without
	/// seeing the other is how the pair ends up inverted.
	/// </summary>
	[ConCmd( "nz_aug_vigor_set" )]
	public static void SetCmd( float overkill = -1f, float execThreshold = -1f,
		float pointBlank = -1f, float pbRange = -1f, float baseDamage = -1f )
	{
		if ( overkill > 0f ) OverkillDamage = overkill;
		if ( execThreshold >= 0f ) ExecutionerThreshold = MathX.Clamp( execThreshold, 0f, 1f );
		if ( pointBlank > 0f ) PointBlankMax = pointBlank;
		if ( pbRange > 0f ) PointBlankRange = pbRange;
		if ( baseDamage > 0f ) PerkEffects.VigorDamage = baseDamage;

		Report( Me() );
	}

	/// <summary>
	/// `nz_aug_vigor_streak [perKill] [max] [streak]` — Killstreak, including its live count.
	///
	/// ⛔ THE STREAK ARGUMENT IS THE ONLY WAY TO SEE THE MIDDLE OF THE BAR. Reaching the cap
	/// legitimately is 67 kills without being touched (`KillsToCap`, which follows the two
	/// values rather than restating them — it was 100 before the 2026-09-13 nerf).
	/// </summary>
	[ConCmd( "nz_aug_vigor_streak" )]
	public static void StreakCmd( float perKill = -1f, float max = -1f, int streak = -1 )
	{
		if ( perKill >= 0f ) KillstreakPerKill = perKill;
		if ( max >= 0f ) KillstreakMax = max;

		var p = Me();
		if ( streak >= 0 && p.IsValid() ) p.VigorStreak = Math.Min( streak, KillsToCap );

		Report( p );
	}

	/// <summary>
	/// `nz_aug_vigor_minor [spoils] [cleave] [bounces] [lastRadius] [vengeance] [vengSecs]`.
	/// </summary>
	[ConCmd( "nz_aug_vigor_minor" )]
	public static void MinorCmd( int spoils = -1, float cleave = -1f, int bounces = -1,
		float lastRadius = -1f, float vengeance = -1f, float vengSecs = -1f )
	{
		if ( spoils >= 0 ) SpoilsPoints = spoils;
		if ( cleave >= 0f ) CleaveShare = cleave;
		if ( bounces >= 0 ) RicochetBounces = bounces;
		if ( lastRadius > 0f ) LastRoundRadius = lastRadius;
		if ( vengeance > 0f ) VengeanceDamage = vengeance;
		if ( vengSecs > 0f ) VengeanceSeconds = vengSecs;

		// ⚠️ OPENS THE VENGEANCE WINDOW so the buff can be felt without going and getting
		// hit — the same reason `nz_aug_jugg_adrenal` opens its own.
		var p = Me();
		if ( p.IsValid() && Has( p, "m5" ) ) p.VigorVengeance = VengeanceSeconds;

		Report( p );
	}
}