Static augment manager for the Widow's Wine perk. Defines tuning values and runtime behavior for major/minor augments: web radius/duration, knife instakill or damage, cleave spreading damage and webs, scavenge ammo, lifedrain heal, spider power-up spawn and collection, diagnostic console commands and live tuning.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Widow's Wine's augments. Base perk: taking a hit spends a grenade, negates the damage, and
/// webs every zombie in radius with line of sight.
///
/// | id | name | effect |
/// |----|------|--------|
/// | M1 | **Web Blade** | knifing a zombie **webs it**, and the panic web radius **+50%** |
/// | M2 | **Assassin** | the knife **always instakills** |
/// | M3 | **Cleave** | melee hits **every zombie** in a radius, not just one |
/// | M4 | **Spider's Gift** | kills have a **3%** chance to drop a spider power-up: **+1 grenade** |
/// | m1 | **Sticky Webs** | snares last and reach **15% more** |
/// | m2 | **Heavy Hands** | knife damage **×10** |
/// | m3 | **Scavenge** | a melee kill gives **2% of max reserve** for the held weapon |
/// | m4 | **Lifedrain** | a melee kill heals **50 HP** |
/// | m5 | **Long Reach** | melee range **doubled** |
///
/// ⛔ M4 IS NOT PORTED FROM ANYTHING — THE GMOD SOURCE IS NOT IN THIS REPO. Asked to check the
/// original's Widow's Wine power-up drop; the lua tree is not here, only extracted assets and
/// reference docs, and `Docs/PERK_BASE_EFFECTS.md` says only "Replaces grenades with
/// `nz_bo3_semtex`" — no drop of any kind. So this is built to the request as stated (3%, one
/// grenade) and is NOT claimed to match the original. If the lua turns up, this is the augment
/// to re-check.
///
/// ⚠️ M2 MAKES m2 REDUNDANT AND THAT IS DELIBERATE. An instakill is strictly better than ×10
/// damage, so m2 is the cheap version of M2 for a player who cannot spare the major slot — the
/// same major/minor relationship Vigor Rush's M1 has with its own base. Owning both is not a
/// bug, it is just paying twice.
///
/// ⚠️ THE PERK'S COST IS GRENADES, WHICH IS WHY M4 MATTERS MORE THAN IT LOOKS. `WebSnare` spends
/// one grenade per save, so a Widow player runs dry exactly when they are being hit most. M4 is
/// the only augment here that feeds the base effect rather than adding a new one.
/// </summary>
public static class WidowAugments
{
const string Perk = "widowswine";
// ══ tuning ════════════════════════════════════════════════════════════════
//
// ⚠️ Nullable getters, not initialisers — a changed default has to survive a hotload.
static float? _snareRadiusBonus;
/// <summary>M1 Web Blade — multiplier on the panic web's radius. +50%.</summary>
public static float SnareRadiusBonus { get => _snareRadiusBonus ?? 1.5f; set => _snareRadiusBonus = value; }
static float? _knifeWebSeconds;
/// <summary>
/// M1 Web Blade — how long a knifed zombie stays webbed. 10s.
///
/// ⚠️ THE SAME 10s THE `web` STATUS RULE USES for every non-grenade call site, so a knife web
/// and a panic web last the same by default rather than by coincidence. m1 scales both.
/// </summary>
public static float KnifeWebSeconds { get => _knifeWebSeconds ?? 10f; set => _knifeWebSeconds = value; }
static float? _cleaveRadius;
/// <summary>M3 Cleave — how far a melee swing reaches sideways. 120u.</summary>
public static float CleaveRadius { get => _cleaveRadius ?? 120f; set => _cleaveRadius = value; }
static float? _spiderChance;
/// <summary>M4 Spider's Gift — chance per kill to drop the power-up. 3%.</summary>
public static float SpiderChance { get => _spiderChance ?? 0.03f; set => _spiderChance = value; }
static float? _stickyBonus;
/// <summary>m1 Sticky Webs — multiplier on snare duration AND radius. +15%.</summary>
public static float StickyBonus { get => _stickyBonus ?? 1.15f; set => _stickyBonus = value; }
static float? _heavyHands;
/// <summary>m2 Heavy Hands — knife damage multiplier. ×10.</summary>
public static float HeavyHands { get => _heavyHands ?? 10f; set => _heavyHands = value; }
static float? _scavengeShare;
/// <summary>m3 Scavenge — fraction of max reserve a melee kill gives. 2%.</summary>
public static float ScavengeShare { get => _scavengeShare ?? 0.02f; set => _scavengeShare = value; }
static float? _lifedrainHeal;
/// <summary>m4 Lifedrain — HP a melee kill restores. 50.</summary>
public static float LifedrainHeal { get => _lifedrainHeal ?? 50f; set => _lifedrainHeal = value; }
static float? _reachBonus;
/// <summary>m5 Long Reach — melee range multiplier. ×2.</summary>
public static float ReachBonus { get => _reachBonus ?? 2f; set => _reachBonus = value; }
static float? _instakillOverkill;
/// <summary>
/// M2 Assassin — how far past the target's max health the knife hits.
///
/// ⛔ ×10, NOT `Current`, AND NOT `float.MaxValue`. The damage goes through `Health.OnDamage`,
/// which applies hit-zone multipliers — and a limb multiplier BELOW one would leave a
/// "guaranteed instakill" failing to kill. Ten times max health survives any reduction the
/// zone table can apply. `MaxValue` would risk an overflow the moment anything multiplies it.
/// </summary>
public static float InstakillOverkill { get => _instakillOverkill ?? 10f; set => _instakillOverkill = value; }
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
static NZPlayer PlayerOf( GameObject go )
=> go.IsValid()
? go.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
// ══ M1 / m1 — the webs ════════════════════════════════════════════════════
/// <summary>
/// Multiplier on the panic web's radius. M1's +50% and m1's +15%, multiplied.
///
/// ⚠️ READ BY `PerkEffects.WebSnare`, which owns the base radius and the line-of-sight test.
/// The augments supply a factor; the perk still decides who can be webbed — one author for
/// "what counts as reachable".
/// </summary>
public static float WebRadiusScale( NZPlayer player )
{
var scale = 1f;
if ( Has( player, "M1" ) ) scale *= MathF.Max( 0f, SnareRadiusBonus );
if ( Has( player, "m1" ) ) scale *= MathF.Max( 0f, StickyBonus );
return scale;
}
/// <summary>
/// Multiplier on how long a snare lasts. m1's +15%.
///
/// ⚠️ M1 IS NOT IN HERE. It widens the radius and adds the knife trigger; it says nothing
/// about duration, and folding it in would hand the player 50% longer webs the augment never
/// advertised.
/// </summary>
public static float WebSecondsScale( NZPlayer player )
=> Has( player, "m1" ) ? MathF.Max( 0f, StickyBonus ) : 1f;
// ══ M2 / m2 / m5 — the knife ══════════════════════════════════════════════
/// <summary>Does the knife kill outright — M2.</summary>
public static bool Instakills( NZPlayer player ) => Has( player, "M2" );
/// <summary>
/// The knife's damage, given what `Knife.DamageFor` computed.
///
/// ⚠️ THE AUGMENTS TAKE THE BASE FIGURE AS AN ARGUMENT rather than reading the knife. That
/// keeps `Knife.DamageFor` the only thing that knows the round curve, and makes this a pure
/// function of a number — testable without a knife, a round or a target.
/// </summary>
public static float KnifeDamage( NZPlayer player, float baseDamage, float targetMax )
{
if ( Instakills( player ) )
return MathF.Max( 1f, targetMax ) * MathF.Max( 1f, InstakillOverkill );
return Has( player, "m2" ) ? baseDamage * MathF.Max( 0f, HeavyHands ) : baseDamage;
}
/// <summary>Melee range multiplier — m5.</summary>
public static float RangeScale( NZPlayer player )
=> Has( player, "m5" ) ? MathF.Max( 0.1f, ReachBonus ) : 1f;
// ══ M1 / M3 — what a knife hit does ═══════════════════════════════════════
/// <summary>
/// A knife swing connected. Runs M1's web and M3's cleave.
///
/// ⛔ THE CLEAVE EXCLUDES THE ZOMBIE ALREADY HIT, or the primary target takes the swing twice
/// and M3 becomes a stealth damage augment on top of being a multi-hit one.
///
/// ⚠️ THE CLEAVE USES THE SAME `damage` THE PRIMARY TOOK, so M2's instakill and m2's ×10 both
/// carry to every zombie in the arc without being recomputed. Recomputing per target would
/// mean `targetMax` differed and an instakill could under-shoot on a tougher one.
///
/// ⚠️ M1 WEBS THE CLEAVED ONES TOO. They were hit by the same swing; webbing only the centre
/// target would read as the augment misfiring.
/// </summary>
public static void OnKnifeHit( NZPlayer player, GameObject victim, Vector3 at, float damage )
{
if ( !player.IsValid() ) return;
var web = Has( player, "M1" );
var cleave = Has( player, "M3" );
if ( !web && !cleave ) return;
var seconds = KnifeWebSeconds * WebSecondsScale( player );
if ( web && victim.IsValid() )
StatusEffects.Apply( victim, "web", player.GameObject, seconds: seconds );
if ( !cleave ) return;
var hit = 0;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
if ( victim.IsValid() && z.GameObject == victim ) continue;
if ( at.Distance( z.WorldPosition + Vector3.Up * 32f ) > CleaveRadius ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) continue;
var info = new DamageInfo
{
Damage = damage,
Attacker = player.GameObject,
Position = z.WorldPosition + Vector3.Up * 32f,
Tags = new TagSet(),
};
// ⚠️ TAGGED "melee", like the primary hit, so the kill pays knife points and every
// melee-gated augment below fires for a cleaved zombie too.
info.Tags?.Add( "melee" );
hp.OnDamage( info );
if ( web )
StatusEffects.Apply( z.GameObject, "web", player.GameObject, seconds: seconds );
hit++;
}
if ( hit > 0 )
Log.Info( $"[nz-aug] widow M3 Cleave — {damage:0} to {hit} more within {CleaveRadius:0}u"
+ (web ? " (webbed)" : "") );
}
// ══ m3 / m4 / M4 — on a kill ══════════════════════════════════════════════
/// <summary>
/// A zombie died. Runs m3's ammo, m4's heal and M4's power-up roll.
///
/// ⚠️ m3 AND m4 ARE MELEE-ONLY, M4 IS NOT. "A melee kill" and "killing zombies" are different
/// claims and the augment text makes both — so the melee flag gates two of the three.
///
/// ⚠️ THE MELEE FLAG COMES FROM THE VICTIM'S OWN `Health.LastHitWasMelee`, which `Health`
/// latches as damage lands. Re-deriving it from the DamageInfo here would be a second answer
/// to "was that a knife", and `Health` already documents latching it for exactly this reason.
/// </summary>
public static void OnZombieKilled( NZPlayer killer, GameObject victim, Vector3 position )
{
if ( !killer.IsValid() || !killer.HasPerk( Perk ) ) return;
var hp = victim.IsValid()
? victim.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors )
: null;
var melee = hp.IsValid() && hp.LastHitWasMelee;
if ( melee )
{
if ( Has( killer, "m3" ) ) Scavenge( killer );
if ( Has( killer, "m4" ) ) Lifedrain( killer );
}
TrySpider( killer, position );
}
/// <summary>
/// m3 Scavenge — a melee kill tops up the held weapon's reserve.
///
/// ⚠️ `NZAmmo` ON THE HELD WEAPON, reached with `EverythingInSelf` — the same path
/// `AwardVultureAmmo` uses, and for the reason its note gives: a holstered weapon's components
/// are DISABLED and a plain `Get` skips them.
///
/// ⚠️ CEILING, NOT FLOOR, so 2% of a small magazine is at least one round. A melee kill that
/// awards zero ammo would read as the augment not working.
/// </summary>
static void Scavenge( NZPlayer player )
{
var wep = VultureAugments.HeldWeapon( player );
if ( !wep.IsValid() ) return;
var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
if ( !ammo.IsValid() || ammo.Reserve >= ammo.MaxReserve ) return;
var give = (int)MathF.Ceiling( ammo.MaxReserve * MathF.Max( 0f, ScavengeShare ) );
var before = ammo.Reserve;
ammo.Reserve = Math.Min( ammo.Reserve + give, ammo.MaxReserve );
Log.Info( $"[nz-aug] widow m3 Scavenge — reserve {before} -> {ammo.Reserve}"
+ $"/{ammo.MaxReserve} (+{ammo.Reserve - before})" );
}
/// <summary>
/// m4 Lifedrain — a melee kill heals.
///
/// ⚠️ `Health.Heal`, NOT `Current = ...`. That setter is inaccessible from outside and the
/// heal path is what clamps to max and fires whatever watches health — a lesson this project
/// paid a build failure for once already.
/// </summary>
static void Lifedrain( NZPlayer player )
{
var hp = player.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.Current >= hp.Max ) return;
var before = hp.Current;
hp.Heal( MathF.Max( 0f, LifedrainHeal ) );
Log.Info( $"[nz-aug] widow m4 Lifedrain — {before:0} -> {hp.Current:0}/{hp.Max:0}" );
}
/// <summary>
/// M4 Spider's Gift — roll for the grenade power-up.
///
/// ⚠️ A POWER-UP, NOT A DIRECT GRENADE, as asked — so it drops on the floor and has to be
/// walked over. That also means it goes through `Powerup`, which already owns the banner, the
/// announcer, the blink and the despawn; a bespoke pickup would be a second answer to all
/// four.
/// </summary>
static void TrySpider( NZPlayer player, Vector3 position )
{
if ( !Has( player, "M4" ) ) return;
if ( Game.Random.Float() > MathF.Max( 0f, SpiderChance ) ) return;
// ⚠️ `SpawnShared`, BECAUSE THIS NOW RUNS ON THE KILLER'S OWN MACHINE. Kill augments used
// to fire on the host against a proxy — so for a client this augment did nothing at all,
// and the moment that was fixed a client's spider would have existed on one screen only.
Powerup.SpawnShared( position, PowerupKind.Spider );
Log.Info( $"[nz-aug] widow M4 Spider's Gift — dropped at {position}" );
}
/// <summary>
/// What the spider power-up does when collected: one grenade back.
///
/// ⚠ CLAMPED TO `MaxCount`, WHICH IS WHERE MULE KICK'S GRENADE CAPACITY LIVES.
/// `MuleKickAugments.ApplyGrenadeCapacity` reconciles that field, so clamping to it respects
/// the augment without this method knowing the augment exists.
///
/// ⛔ NOT `Grenade.Give` — that is a static CONSOLE COMMAND and it SETS the count rather than
/// adding to it, so calling it would have handed over a full pouch instead of one grenade.
/// The name reads like an accessor and is not one.
/// </summary>
public static void CollectSpider( NZPlayer player )
{
if ( !player.IsValid() ) return;
var nades = player.Components.Get<Grenade>( FindMode.EverythingInSelfAndDescendants );
if ( !nades.IsValid() ) return;
var before = nades.Count;
nades.Count = Math.Min( nades.MaxCount, nades.Count + 1 );
var added = nades.Count - before;
Log.Info( $"[nz-aug] widow spider collected — +{added} grenade"
+ $" ({nades.Count}/{nades.MaxCount})" );
}
// ══ 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] WIDOW'S WINE {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );
var knife = player.Components.Get<Knife>();
var baseDmg = knife.IsValid() ? knife.Range : 0f;
// ⚠️ RESOLVED NUMBERS, not multipliers. "x10 of the round curve" cannot be checked
// against a zombie; "1540 damage" can.
Log.Info( $"[nz-aug] panic web {PerkEffects.WebRadius * WebRadiusScale( player ):0}u"
+ $" (base {PerkEffects.WebRadius:0} x{WebRadiusScale( player ):0.##})"
+ $" · costs 1 grenade" );
Log.Info( $"[nz-aug] M1 Web Blade {(Has( player, "M1" ) ? $"knife webs for {KnifeWebSeconds * WebSecondsScale( player ):0.#}s · panic radius x{SnareRadiusBonus:0.##}" : "-")}" );
Log.Info( $"[nz-aug] M2 Assassin {(Instakills( player ) ? "knife INSTAKILLS" : "-")}" );
Log.Info( $"[nz-aug] M3 Cleave {(Has( player, "M3" ) ? $"hits everything within {CleaveRadius:0}u" : "-")}" );
Log.Info( $"[nz-aug] M4 Spider {(Has( player, "M4" ) ? $"{SpiderChance * 100f:0.#}% per kill -> +1 grenade" : "-")}" );
Log.Info( $"[nz-aug] m1 Sticky Webs {(Has( player, "m1" ) ? $"x{StickyBonus:0.##} duration and radius" : "-")}" );
Log.Info( $"[nz-aug] m2 Heavy Hands {(Has( player, "m2" ) ? $"knife x{HeavyHands:0.#}" : "-")}"
+ (Instakills( player ) ? " (moot — M2 instakills)" : "") );
Log.Info( $"[nz-aug] m3 Scavenge {(Has( player, "m3" ) ? $"melee kill -> {ScavengeShare * 100f:0.#}% of max reserve" : "-")}" );
Log.Info( $"[nz-aug] m4 Lifedrain {(Has( player, "m4" ) ? $"melee kill -> +{LifedrainHeal:0} HP" : "-")}" );
Log.Info( $"[nz-aug] m5 Long Reach {(Has( player, "m5" ) ? $"range x{ReachBonus:0.##}" : "-")}"
+ (knife.IsValid() ? $" {knife.Range:0}u -> {knife.Range * RangeScale( player ):0}u" : "") );
if ( knife.IsValid() )
{
var hp = ZombieAI.All.FirstOrDefault( z => z.IsValid() )
?.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( hp.IsValid() )
Log.Info( $"[nz-aug] vs nearest base {knife.DamageFor( hp ):0}"
+ $" -> {KnifeDamage( player, knife.DamageFor( hp ), hp.Max ):0}"
+ $" (target has {hp.Current:0}/{hp.Max:0})" );
}
}
/// <summary>`nz_aug_widow` — the resolved state of all nine.</summary>
[ConCmd( "nz_aug_widow" )]
public static void ReportCmd()
=> Report( NZPlayer.Local );
/// <summary>
/// `nz_widow_spider` — drop a spider power-up in front of you, ignoring the 3%.
///
/// ⚠️ EXISTS BECAUSE 3% IS UNTESTABLE BY PLAYING. Thirty kills per attempt makes "the roll is
/// wrong" and "the power-up does nothing" impossible to tell apart.
/// </summary>
[ConCmd( "nz_widow_spider" )]
public static void SpiderCmd( float distance = 150f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;
var at = p.WorldPosition + rot.Forward.WithZ( 0f ).Normal * distance;
Log.Info( Powerup.Spawn( at, PowerupKind.Spider ) is null
? "[nz-aug] spider spawn failed"
: $"[nz-aug] spider power-up at {at} — walk over it for a grenade" );
}
/// <summary>`nz_widow_set` — retune live. Negative or omitted leaves a value alone.</summary>
[ConCmd( "nz_widow_set" )]
public static void SetCmd( float snareRadius = -1f, float knifeWeb = -1f, float cleave = -1f,
float spider = -1f, float sticky = -1f, float heavy = -1f, float scavenge = -1f,
float lifedrain = -1f, float reach = -1f, float overkill = -1f )
{
if ( snareRadius >= 0f ) SnareRadiusBonus = snareRadius;
if ( knifeWeb >= 0f ) KnifeWebSeconds = knifeWeb;
if ( cleave >= 0f ) CleaveRadius = cleave;
if ( spider >= 0f ) SpiderChance = spider;
if ( sticky >= 0f ) StickyBonus = sticky;
if ( heavy >= 0f ) HeavyHands = heavy;
if ( scavenge >= 0f ) ScavengeShare = scavenge;
if ( lifedrain >= 0f ) LifedrainHeal = lifedrain;
if ( reach >= 0f ) ReachBonus = reach;
if ( overkill >= 0f ) InstakillOverkill = overkill;
ReportCmd();
}
}