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