Player/WidowAugments.cs

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.

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