Player/AugmentEffects.cs

Dispatch layer for perk augments. It routes game events (damage, kills, round start, augment gain/loss) to each perk-specific augment handler, recomputes stat-based augments, exposes console commands to inspect/toggle augment effects, and contains a wired-perk whitelist.

NetworkingFile Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Where augments actually do something — the dispatch layer between the game's events
/// and each perk's augment file.
///
/// ⛔ EVENTS ARE FANNED OUT FROM HERE, NOT SUBSCRIBED PER PERK. The original does the
/// same and says why in its own header: *"Effects live here, gated on
/// ply:HasAugment(perkid, augid) at the moment the relevant event fires — NOT by mutating
/// the base perk defs."* Mutating a perk's stats on purchase is what forces a matching
/// un-mutate on loss, and 162 augments would be 162 chances to forget one.
///
/// ⚠️ THE HOOK SITES ARE THE SCARCE THING, not the effect code. `Health.Apply`,
/// `Armor.Absorb`, the zombie death site and `BeginRound` each get exactly ONE augment
/// call, which then asks every perk. Letting each perk's file reach into `Health.Apply`
/// itself would mean eighteen edits to one method, and the order they ran in would be
/// whatever the file order happened to be.
///
/// ⚠️ Two shapes of augment, per the original's note:
///   • EVENT-BASED (damage, kill, round) — a call at the moment it happens.
///   • STAT-BASED (max health, armor cap) — recomputed from what is owned, and
///     re-recomputed on augment-gain, perk-gain and spawn. Never `+=`.
/// </summary>
public static class AugmentEffects
{
	/// <summary>
	/// Master switch, so a suspected augment bug can be ruled out in one command.
	///
	/// ⚠️ Checked at the DISPATCH level rather than inside each effect, so it genuinely
	/// covers all of them — including ones added later, which is the failure mode a
	/// per-effect check has.
	/// </summary>
	public static bool Enabled { get; set; } = true;

	/// <summary>
	/// Perk ids whose augments actually DO something.
	///
	/// ⛔ A HAND-MAINTAINED LIST AND IT HAS TO BE, because "is this wired" is not
	/// derivable: an unwired perk's augments are indistinguishable from a wired one's at
	/// runtime — both are rows in a menu that take salvage. The audit prints this, so the
	/// cost of forgetting to add a perk here is that the audit under-reports, which is the
	/// safe direction.
	///
	/// ⚠️ ADD A PERK HERE IN THE SAME COMMIT THAT WIRES IT. The alternative is the
	/// original's position — ship 162 augments, wire none, and say so nowhere — which is
	/// exactly the thing this project decided to improve on.
	/// </summary>
	/// ⚠️ "pop" AND "banana" ADDED 2026-10-03, long after both were wired (PopAugments, BananaAugments: every augment of each is
	/// read by gameplay). Missing here, the Wunderfizz told players their augments "do nothing yet" — the round-88 game spent
	/// salvage on Banana Colada's anyway, and its Banana Stand was the strongest thing in it.
	public static string[] WiredPerks()
		=> new[] { "jugg", "dtap", "staminup", "speed", "deadshot", "mulekick", "vigor",
			"vulture", "phd", "time", "tortoise", "widowswine", "fire", "revive",
			"death", "pop", "banana" };

	/// <summary>Are this perk's augments wired to anything.</summary>
	public static bool IsWired( string perkId ) => WiredPerks().Contains( perkId );

	// ── stat-based: recompute ────────────────────────────────────────────────

	/// <summary>
	/// Re-derive every stat an augment can move, for one player.
	///
	/// ⛔ CALL THIS AFTER ANYTHING THAT CHANGES WHAT A PLAYER OWNS. Perk gained, perk
	/// lost, augment gained, augment cleared, spawn. A stat-based augment is invisible
	/// until this runs, and "the augment did nothing" is indistinguishable from "the
	/// refresh was not called".
	/// </summary>
	public static void Refresh( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		JuggAugments.RefreshHealth( player );

		// ⚠️ MULE KICK'S REFRESH ALSO RESTORES INSURANCE'S ESCROW, which is why it runs on
		// perk gain rather than only on augment gain — re-buying the perk is the event that
		// makes the slot available again.
		MuleKickAugments.Refresh( player );
	}

	/// <summary>An augment was just equipped, by purchase or by grant.</summary>
	public static void OnGained( NZPlayer player, string perkId, string augId )
	{
		if ( !player.IsValid() ) return;

		Refresh( player );

		// ⚠️ VULTURE AID'S WILDCARD IS THE ONE AUGMENT THAT HAS TO ACT ON GAIN. It fires off
		// weapon-slot changes, and a player holding a single weapon cannot change slot at all
		// — so equipping it has to put a second gun in their hands or the augment is inert.
		if ( perkId == "vulture" ) VultureAugments.OnGained( player, augId );

		Log.Info( $"[nz-aug] {perkId}/{augId} equipped" );
	}

	/// <summary>
	/// An augment was just taken off (`PerkAugments.TryRemove`).
	///
	/// ⚠️ ONLY THE STAT-BASED ONES NEED THIS. The event-based ones ask `Has` when their event
	/// fires, so they stop by themselves; max health, grenades, clip and reserve were computed
	/// with the augment and have to be computed again without it.
	/// </summary>
	public static void OnLost( NZPlayer player, string perkId, string augId )
	{
		if ( !player.IsValid() ) return;

		Refresh( player );

		Log.Info( $"[nz-aug] {perkId}/{augId} removed" );
	}

	// ── event-based: damage ──────────────────────────────────────────────────

	/// <summary>
	/// Incoming damage on a player, after the base perks and before armor.
	/// Returns the possibly-reduced amount.
	///
	/// ⛔ RETURNS THE AMOUNT RATHER THAN MUTATING A REF, so a caller cannot forget to
	/// use the result — `Health.Apply` already reads `TortoiseScale` the same way and the
	/// two now sit on adjacent lines.
	///
	/// ⚠️ RUNS BEFORE ARMOR, deliberately. Bulwark cutting the hit first means armor is
	/// billed only for what got through, which is the same ordering argument
	/// `Health.Apply` already documents for Tortoise. Reversed, a Bulwark player would
	/// burn plates at the full rate while taking less damage.
	///
	/// ⚠️ `from` MAY BE NULL — scripted and area damage has no attacker. Retaliate needs
	/// one; Bulwark and Adrenal Surge do not, so they must not be gated on it.
	/// </summary>
	public static float OnPlayerDamaged( NZPlayer victim, GameObject from, float amount, bool clawed = true )
	{
		// ⚠️ `clawed` IS FALSE FOR AN ENEMY'S AREA HIT (`Health.Apply`'s `blast`, Oberon's bombs): Vigor still answers it —
		// being hit is being hit — and only Juggernog's Retaliate, which answers a claw, does not.
		if ( !Enabled || !victim.IsValid() || amount <= 0f ) return amount;

		// ⚠️ VIGOR'S HOOK RUNS FIRST AND CHANGES NOTHING ABOUT THE AMOUNT — it wipes the
		// killstreak and opens the vengeance window. Ordered ahead of Juggernog's so a
		// Bulwark player's REDUCED figure is not what decides whether they "took damage":
		// being hit is being hit, whatever the armour did about it.
		// ⚠️ `from` IS PASSED NOW — Vigor's M4 and m5 only answer to enemy damage, so the hook
		// needs the attacker it used to be given without. See ZombieAI.IsEnemyDamage.
		VigorAugments.OnPlayerDamaged( victim, from, amount );

		// ⚠ PhD's TWO HIT-TRIGGERED AUGMENTS SIT BESIDE VIGOR'S FOR THE SAME REASON: they read
		// the hit, they do not change it. Ordered before Juggernog so M4's "below 30% HP" test
		// sees the health the player actually had when the blow landed, not what armour left of
		// it — the augment text describes the player's state, not the damage's.
		PhdAugments.OnPlayerDamaged( victim, amount );

		// ⚠ TORTOISE'S RING DEFENCE IS A REDUCTION, so unlike Phd's and Vigor's hooks it changes
		// the amount. Applied before Juggernog's, matching how the base perk's own reduction runs
		// ahead of armor in `Health.Apply` — a Dig In player should not burn armor on damage the
		// ring already prevented.
		amount *= TortoiseAugments.IncomingScale( victim );

		return JuggAugments.OnPlayerDamaged( victim, from, amount, clawed );
	}

	// ── event-based: kills ───────────────────────────────────────────────────

	/// <summary>
	/// A zombie died. `killer` is whoever landed the blow, and may be null or not a
	/// player.
	///
	/// ⚠️ CALLED FROM THE ZOMBIE'S OWN DEATH, not from the weapon path, so a kill by
	/// any means pays out — shot, knifed, grenaded, nuked. That is the same reasoning
	/// `PowerupDrops.RollOnDeath` and `PickupDrops.RollOnDeath` sit there for.
	/// </summary>
	/// <remarks>
	/// ⚠️ THE SIGNATURE GREW FOR DEADSHOT'S M3, which splashes a fraction of the KILLING
	/// HIT and therefore needs the amount, the place and the corpse to exclude. None of
	/// those are derivable at this layer — only the death site knows them — so they are
	/// passed rather than looked up.
	/// </remarks>
	public static void OnZombieKilled( GameObject killer, bool headshot,
		Vector3 position = default, float damage = 0f, GameObject victim = null, string mod = "" )
	{
		if ( !Enabled ) return;

		var player = killer.IsValid()
			? killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;

		if ( !player.IsValid() ) return;

		// ⛔ THE KILL HAPPENS ON THE HOST AND THE AUGMENTS BELONG TO THE KILLER — WHO IS USUALLY
		// SOMEBODY ELSE. `ZombieAI` fires this where the zombie dies, so for a client's kill
		// `player` is the host's PROXY copy of them: no perks, no augments, and any armour or
		// health it writes lands on a body nobody can see. Juggernog's M4 Bloodthirst and m1
		// Hardplate, Deadshot's kill hooks, Tortoise M4's stacks and Widow's three melee augments
		// all did nothing whatever for a client.
		//
		// ⚠️ RELAY, NOT RECORD-AND-PUBLISH — the same shape as `PlayerStats.Record*` and
		// `AddPoints`. The machine that owns the augments performs their effect, because it is the
		// only one that knows what is equipped and the only one whose health and armour are real.
		//
		// ⚠️ THE VICTIM TRAVELS AS AN ID because Deadshot and Widow both read it — the corpse,
		// its position, whether the killing blow was melee. A network-spawned zombie keeps its
		// `GameObject.Id` on every machine.
		if ( Networking.IsActive && PlayerPresence.Theirs( player.GameObject ) )
		{
			var owner = NZPlayers.OwnerOf( player.GameObject );

			if ( !string.IsNullOrEmpty( owner ) )
			{
				NZNet.AugmentKill( owner, headshot, position, damage,
					victim.IsValid() ? victim.Id : System.Guid.Empty, mod ?? "" );
				return;
			}
		}

		// ⚠️ THESE TWO USED TO BE CALLED FROM `ZombieAI`'s DEATH SITE, beside the call that reaches
		// this method — and only this one relayed. Blast Furnace (through `AmmoMods`) and Banana
		// Colada's entire charge meter were therefore dead for every client, because both resolve
		// the killer's HELD WEAPON and a proxy holds nothing.
		//
		// ⚠️ THEY TAKE THE KILLER AS A GameObject, which is what the relayed path already
		// reconstructs — `NZNet.AugmentKill` passes the receiving machine's own body.
		if ( killer.IsValid() )
		{
			// ⚠️ WHERE IT DIED GOES WITH IT. A relayed kill's corpse may be gone already (`NZNet.AugmentKill`), and
			// Blast Furnace — and Basalt's Color Rings, listening to it — can go off without one.
			// ⚠️ AND THE KILL MODS' INPUTS (2026-10-04): headshot, the killing hit, and the mod of the gun that shot it last.
			AmmoMods.OnZombieKilled( killer, victim, position == default ? (Vector3?)null : position,
				headshot, damage, mod );
			BananaAugments.OnZombieKilled( killer, victim );

			// ⚠️ VULTURE'S GAS ROLL JOINS THEM, and it is the one that could not have been fixed
			// with a synced scalar: it spawns a cloud that Vulture m3 Gas Feed then QUERIES on its
			// owner's machine, so the roll and its product have to happen there together.
			PickupDrops.RollGas( position, killer );
		}

		JuggAugments.OnZombieKilled( player, headshot );
		DeadshotAugments.OnZombieKilled( player, headshot, position, damage, victim );

		// ⚠ TORTOISE M4's STACKS. Credited to the RING the killer is standing in, not to the
		// killer — the augment is explicitly shared ("you or any player who is also inside"), and
		// one counter per ring is that sentence.
		TortoiseAugments.OnZombieKilled( player, position );

		// ⚠ WIDOW'S m3, m4 AND M4. Two of the three are melee-only and that gate lives inside —
		// the melee flag comes from the victim's own `Health.LastHitWasMelee`, which is why this
		// takes the victim rather than a bool.
		WidowAugments.OnZombieKilled( player, victim, position );

		// ⚠ Napalm Nectar's M4 Chain Reaction goes LAST, because it can kill more zombies and each
		// of those deaths comes back through this method — running it earlier would have the
		// chain's kills processed by hooks that had not finished with the original yet.
		FireAugments.OnZombieKilled( player, position );

		// ⚠ Quick Revive's M4 Last Stand. Gated on the killer being DOWN, which only M4 makes
		// possible — without it a downed player holds a pistol and rarely kills anything.
		ReviveAugments.OnZombieKilled( player );

		// ⚠️ Death Perception's M3 Blind Spot. Headshot-only, and it takes the
		// flag rather than resolving it — this chain is the only place that knows
		// where the fatal shot landed.
		DeathAugments.OnZombieKilled( player, headshot );
		MuleKickAugments.OnZombieKilled( player );

		// ⚠️ THE VICTIM'S MAX HEALTH IS RESOLVED HERE rather than passed in, because only
		// Vigor's m3 Cleave wants it and widening the shared signature again for one consumer
		// is how it ends up with six parameters nobody can order correctly.
		var victimMax = victim.IsValid()
			? victim.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors )?.Max ?? 0f
			: 0f;

		VigorAugments.OnZombieKilled( player, position, victimMax, victim );
	}

	// ── event-based: rounds ──────────────────────────────────────────────────

	/// <summary>
	/// A round just began.
	///
	/// ⚠️ EVERY PLAYER, not the local one. Round starts are the one event that is
	/// unambiguously global, and a per-player loop here is the shape co-op will need
	/// everywhere else too.
	/// </summary>
	public static void OnRoundStart()
	{
		if ( !Enabled ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		// ⛔ MY OWN PLAYER ONLY, AND EVERY MACHINE CALLS THIS FOR ITSELF. `RoundManager.BeginRound`
		// runs on the host alone — a client is told the round NUMBER through `RoundNow` and never
		// executes the round loop — so this used to sweep every body on the host, including proxy
		// copies with no augments, and never ran on a client at all. Juggernog's M2 Plated Up
		// (armour refilled each round) and Mule Kick's round hook were dead for every client.
		//
		// ⚠️ THE CLIENT'S CALL COMES FROM `RoundManager.ApplyMirror`, on the frame the mirrored
		// round number goes up. One trigger per machine, each acting only on the body it owns.
		foreach ( var player in scene.GetAllComponents<NZPlayer>() )
		{
			if ( !player.IsValid() ) continue;
			if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) ) continue;

			JuggAugments.OnRoundStart( player );
			MuleKickAugments.OnRoundStart( player );
			Refresh( player );
		}
	}

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

	static NZPlayer Me()
		=> NZPlayer.Local;

	/// <summary>
	/// `nz_aug_effects [0/1]` — report every wired augment's live contribution, or
	/// switch the whole layer off.
	///
	/// ⛔ REPORTS THE NUMBER, NOT WHETHER IT IS EQUIPPED. "Is m3 equipped" is already
	/// answerable from `nz_augments jugg`; what that cannot tell you is whether the
	/// equipped augment is REACHING the system it is supposed to move. This prints the
	/// armor cap, the max health and the damage scale as they actually are.
	/// </summary>
	[ConCmd( "nz_aug_effects" )]
	public static void EffectsCmd( int on = -1 )
	{
		if ( on >= 0 ) Enabled = on != 0;

		var p = Me();
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		Log.Info( $"[nz-aug] effects {(Enabled ? "on" : "OFF")}"
			+ $" · limits {(!PerkAugments.Unlimited ? "enforced" : HexPlatforms.EggComplete ? "LIFTED (basalt's Easter egg)" : "LIFTED (creative)")}"
			+ $" · {p.Salvage:N0} salvage" );

		JuggAugments.Report( p );
		DtapAugments.Report( p );
		StaminUpAugments.Report( p );
		SpeedColaAugments.Report( p );
		DeadshotAugments.Report( p );
		MuleKickAugments.Report( p );
		VigorAugments.Report( p );
	}

	/// <summary>
	/// `nz_aug_creative [0/1]` — the Creative no-limit switch.
	///
	/// ⚠️ Prints the RESOLVED limits, not just the flag, because the flag alone does not
	/// say whether it is in effect — it only applies in Creative, and forgetting which
	/// mode you are in is the obvious way to misread this.
	/// </summary>
	[ConCmd( "nz_aug_creative" )]
	public static void CreativeCmd( int on = -1 )
	{
		if ( on >= 0 ) PerkAugments.UnlimitedInCreative = on != 0;

		Log.Info( $"[nz-aug] unlimited-in-creative {(PerkAugments.UnlimitedInCreative ? "on" : "off")}"
			+ $" · mode {NZGame.Mode}"
			+ $" · in effect {PerkAugments.Unlimited}"
			+ $" · limits now {PerkAugments.LimitOf( PerkAugments.AugmentTier.Major )} major"
			+ $" / {PerkAugments.LimitOf( PerkAugments.AugmentTier.Minor )} minor" );
	}

	/// <summary>
	/// `nz_aug_all <perk>` — equip every augment a perk has, free.
	///
	/// ⛔ THE POINT OF THE CREATIVE OVERRIDE, as one command. Nine `nz_augment` calls to
	/// set up one test is the friction the override exists to remove, and doing it by
	/// hand is also nine chances to typo an id and test eight.
	///
	/// ⚠️ Refuses outside Creative rather than partially succeeding. With the real limits
	/// it would equip 1 major and 2 minors and then log six refusals, which reads as the
	/// command being broken rather than as the mode being wrong.
	/// </summary>
	[ConCmd( "nz_aug_all" )]
	public static void AllCmd( string perk = "jugg" )
	{
		var p = Me();
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		if ( !PerkAugments.Unlimited )
		{
			Log.Warning( "[nz-aug] limits are enforced — this only works in Creative with"
				+ " nz_aug_creative 1, or with basalt's Easter egg complete. Use nz_augment <perk> <id> free one at a time." );
			return;
		}

		if ( !p.HasPerk( perk ) )
		{
			// ⚠️ Granted rather than refused. The command exists to get to a test state
			// in one line, and "you do not own jugg" is a stop with an obvious next step
			// that the command may as well take.
			p.GivePerk( perk );
			Log.Info( $"[nz-aug] granted the {perk} perk first" );
		}

		var pool = PerkAugments.PoolFor( perk );
		if ( pool is null ) { Log.Warning( $"[nz-aug] no pool for '{perk}'" ); return; }

		var n = 0;
		foreach ( var aug in pool.Major.Concat( pool.Minor ) )
		{
			var refusal = PerkAugments.Grant( p, perk, aug.Id );
			if ( refusal is null ) n++;
			else Log.Warning( $"[nz-aug] {aug.Id}: {refusal}" );
		}

		Log.Info( $"[nz-aug] {perk} — equipped {n} augment(s)" );

		// ⚠️ ONLY THE REPORT FOR THE PERK THAT WAS TOUCHED. `nz_aug_effects` prints every
		// wired perk; here the other one is noise that pushes the interesting lines off
		// the top of a console with no clear command.
		if ( perk == "dtap" ) DtapAugments.Report( p );
		else if ( perk == "staminup" ) StaminUpAugments.Report( p );
		else if ( perk == "speed" ) SpeedColaAugments.Report( p );
		else if ( perk == "deadshot" ) DeadshotAugments.Report( p );
		else if ( perk == "mulekick" ) MuleKickAugments.Report( p );
		else if ( perk == "vigor" ) VigorAugments.Report( p );
		else JuggAugments.Report( p );
	}
}