Player/DeathAugments.cs

Augment definitions and runtime logic for the "Death Perception" perk and its nine augments. Exposes configurable multipliers, drop-rate scaling, boss-specific bonuses, escape-on-down teleport, temporary untargetability on headshot kills, X-ray outlining of zombies, and console commands for reporting and tuning.

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

namespace NZombies;

/// <summary>
/// Death Perception — the headshot perk, and the nine augments.
///
/// | | effect |
/// |---|---|
/// | base | the headshot **multiplier** is multiplied by **1.5** (3.75× on a stock 2.5× gun) |
/// | M1 **Boss Slayer** | **2×** damage against bosses — *wired; Brutus exists* |
/// | M2 **Escape Artist** | going down **teleports you to the nearest player** |
/// | M3 **Blind Spot** | headshot kills have a **10%** chance to make zombies ignore you **3s** (10s cd) |
/// | M4 **Bounty Hunter** | **2000** points per boss kill, **10** per boss hit — *catalogued* |
/// | m1 **Plated Instinct** | armor plates drop **1.2×** as often |
/// | m2 **Fortune's Sense** | power-ups drop **1.2×** as often |
/// | m3 **Weak Point** | headshot multiplier **×1.12** on top of the base |
/// | m4 **X-Ray Sense** | nearby zombies are **outlined through walls** |
/// | m5 **Executioner's Cut** | headshot kills pay **+25% points** |
///
/// ⛔ M1 AND M4 ARE CATALOGUED, NOT WIRED, AND THAT IS DELIBERATE. There are no bosses or elites
/// in this port — `SpecialEnemies.Names` is a one-element array holding the hellhound. Both
/// augments resolve their numbers, print them, and are marked `(no bosses yet)` everywhere they
/// are displayed, so the boss multiplier is ready to read the day a boss exists.
///
/// `PerkRegistry` and `WeaponTech` both took this shape first — a catalogue that says out loud
/// that it is a catalogue. The alternative is a stub that looks like a broken augment.
///
/// ⛔ FOUR ORIGINAL AUGMENTS WERE BOSS-ONLY AND TWO MORE WERE UNBUILDABLE. Boss Slayer, Big Game
/// Hunter, Bounty Hunter and Boss Bane all needed bosses; Long-Range X-Ray needed a wallhack the
/// base perk never had. Six of nine, on a perk whose base effect works fine. M1 and M4 keep their
/// boss identity as catalogue entries; the other four were respecified.
///
/// ⚠️ M2 CANNOT FIRE SOLO and that is inherent: "teleport to the nearest player" has no
/// destination when there is no other player. It is wired and reachable — `nz_aug_death` prints
/// how many candidates it can see — but it stays untested until there is a second client.
///
/// ⚠️ m4's OUTLINES ARE SCENE-WIDE, NOT PER-VIEWER. `HighlightOutline` is a component on the
/// zombie, so in multiplayer every client would see the outline one player paid for. Correct
/// today (one player) and a known debt, not an oversight — the fix is a client-only render pass,
/// which is a rendering change rather than an augment change.
/// </summary>
public static class DeathAugments
{
	const string Perk = "death";

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ EVERY ONE IS A NULLABLE-BACKED GETTER, not a plain initialised static. A field's VALUE
	// migrates across a hotload but its initialiser does not re-run, so `= 1.2f` written today is
	// not what a live session holds tomorrow. INSTRUCTIONS.md §1 records seven occurrences; this
	// project's single most expensive pattern.

	static float? _bossDamageScale;
	/// <summary>M1 Boss Slayer — damage multiplier against bosses. 2.0.
	///
	/// ⚠️ WAS 3.0, AND WAS ALSO DOCUMENTED AS "not wired" LONG AFTER IT WAS. `Health.Apply`
	/// multiplies through `BossScaleAgainst` in two places and has since Brutus landed — the
	/// catalogue language survived the thing it described. Both corrected together, because a
	/// doc that says a live multiplier does nothing is worse than no doc.</summary>
	public static float BossDamageScale { get => _bossDamageScale ?? 2f; set => _bossDamageScale = value; }

	static float? _ignoreChance;
	/// <summary>M3 Blind Spot — chance a headshot kill makes zombies drop you. 0.10.</summary>
	public static float IgnoreChance { get => _ignoreChance ?? 0.10f; set => _ignoreChance = value; }

	static float? _ignoreSeconds;
	/// <summary>M3 Blind Spot — how long zombies ignore you. 3s.</summary>
	public static float IgnoreSeconds { get => _ignoreSeconds ?? 3f; set => _ignoreSeconds = value; }

	static float? _ignoreCooldown;
	/// <summary>M3 Blind Spot — cooldown between procs. 10s.</summary>
	public static float IgnoreCooldown { get => _ignoreCooldown ?? 10f; set => _ignoreCooldown = value; }

	static int? _bossKillPoints;
	/// <summary>M4 Bounty Hunter — points for killing a boss. 2000. Not wired.</summary>
	public static int BossKillPoints { get => _bossKillPoints ?? 2000; set => _bossKillPoints = value; }

	static int? _bossHitPoints;
	/// <summary>M4 Bounty Hunter — points per boss hit, which normally pay nothing. 10. Not wired.</summary>
	public static int BossHitPoints { get => _bossHitPoints ?? 10; set => _bossHitPoints = value; }

	static float? _plateDropScale;
	/// <summary>
	/// m1 Plated Instinct — plate drop chance multiplier. 1.2.
	///
	/// ⚠️ RELATIVE, NOT PERCENTAGE POINTS. "20% more often" on a 5% base is 6%, not 25%. The
	/// absolute reading would be a five-fold increase from a minor augment, and Vulture Aid's
	/// Carrion already sets the house precedent with a ×1.5 multiplier on the same roll.
	/// </summary>
	public static float PlateDropScale { get => _plateDropScale ?? 1.2f; set => _plateDropScale = value; }

	static float? _powerupChanceScale;
	/// <summary>m2 Fortune's Sense — power-up drop chance multiplier. 1.2. Relative, as m1.</summary>
	public static float PowerupChanceScale { get => _powerupChanceScale ?? 1.2f; set => _powerupChanceScale = value; }

	static float? _headshotScale;
	/// <summary>
	/// m3 Weak Point — extra multiplier on the headshot multiplier. 1.12.
	///
	/// ⚠️ IT MULTIPLIES THE MULTIPLIER, matching the base perk rather than adding to it. The base
	/// is ×1.5 on the head scale, so on a stock 2.5× gun this augment moves 3.75× to 4.2×. Adding
	/// 0.12 instead would be worth less on exactly the guns the perk exists to reward.
	/// </summary>
	public static float HeadshotScale { get => _headshotScale ?? 1.12f; set => _headshotScale = value; }

	static float? _xrayRange;
	/// <summary>m4 X-Ray Sense — how far the outlines reach. 1200u.</summary>
	public static float XRayRange { get => _xrayRange ?? 1200f; set => _xrayRange = value; }

	static float? _xrayWidth;
	/// <summary>m4 X-Ray Sense — outline thickness. 0.3, matching the wall-buy chalk.</summary>
	public static float XRayWidth { get => _xrayWidth ?? 0.3f; set => _xrayWidth = value; }

	static float? _headshotPointsScale;
	/// <summary>m5 Executioner's Cut — points multiplier on headshot kills. 1.25.</summary>
	public static float HeadshotPointsScale { get => _headshotPointsScale ?? 1.25f; set => _headshotPointsScale = value; }

	/// <summary>Does this player hold the perk AND have this augment equipped.</summary>
	static bool Has( NZPlayer p, string augId )
		=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );

	// ══ M1 + M4 — the boss slots, catalogued ═════════════════════════════════

	/// <summary>
	/// M1 Boss Slayer — the damage multiplier a boss would take. 1 without the augment.
	///
	/// ✅ WIRED AS OF BRUTUS. This carried a note saying nothing called it and that the hook
	/// "belongs in Health.OnDamage, beside the other attacker-read scales, when a boss type lands".
	/// That is exactly where it went - see `BossScaleAgainst` below and the call in `Health.Apply`,
	/// beside Vigor Rush's and Victorious Tortoise's scales.
	///
	/// ⚠️ THIS OVERLOAD STILL TAKES ONLY THE PLAYER, so it answers "does this player own M1"
	/// and nothing about the victim. `BossDamageScale` is the one call sites should use.
	/// </summary>
	public static float BossDamageScaleFor( NZPlayer player )
		=> Has( player, "M1" ) ? MathF.Max( 0f, BossDamageScale ) : 1f;

	/// <summary>M4 Bounty Hunter — bonus points for a boss kill. 0 without the augment.</summary>
	public static int BossKillBonus( NZPlayer player )
		=> Has( player, "M4" ) ? Math.Max( 0, BossKillPoints ) : 0;

	/// <summary>
	/// M4 Bounty Hunter — points for a boss HIT, which normally pay nothing.
	///
	/// ⚠️ ORDINARY ZOMBIE HITS DO ALREADY PAY in this port (`ZombieAI.OnHurt`), so the "usually
	/// you gain no points per hit" in the request is specifically about bosses. Both halves are
	/// unwired for the same reason, so the distinction costs nothing today.
	/// </summary>
	public static int BossHitBonus( NZPlayer player )
		=> Has( player, "M4" ) ? Math.Max( 0, BossHitPoints ) : 0;

	// ══ the boss bridge — what finally connected M1 and M4 ═══════════════════

	/// <summary>
	/// Is this object a boss.
	///
	/// ⛔ IT READS `ZombieVariant.IsBoss`, NOT `SpecialEnemies.IsSpecial`, AND THAT DISTINCTION WAS
	/// SPELLED OUT FOR ME BEFORE I NEEDED IT. `IsSpecial` derives specialness from the asset
	/// FILENAME, and its own note says: "Drop rates are the only caller today. If anything
	/// gameplay-critical ever depends on this, give ZombieVariant an explicit flag instead."
	/// A boss damage multiplier is gameplay-critical, so the flag was added.
	///
	/// ⚠️ A HELLHOUND IS A SPECIAL AND NOT A BOSS. It keeps its loot rates and pays no Bounty
	/// Hunter points, which is what the two separate questions are for.
	/// </summary>
	public static bool IsBoss( GameObject victim )
	{
		if ( !victim.IsValid() ) return false;

		var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );

		return ai.IsValid() && (ai.Variant?.IsBoss ?? false);
	}

	/// <summary>
	/// M1 Boss Slayer — the damage multiplier for this attacker against this victim.
	///
	/// ⚠️ BOTH CONDITIONS OR NOTHING. Returns 1 unless the victim is a boss AND the attacker
	/// owns M1, so `Health.Apply` can multiply unconditionally and pay only a component lookup.
	///
	/// ⚠️ AND IT RESOLVES THE PLAYER FROM THE ATTACKER OBJECT, because `Health.Apply` has a
	/// `GameObject` rather than an `NZPlayer` — the same shape `PerkEffects.HeadshotScaleFor` and
	/// `TortoiseAugments.DamageScale` already take.
	/// </summary>
	public static float BossScaleAgainst( GameObject attacker, GameObject victim )
	{
		if ( !IsBoss( victim ) || !attacker.IsValid() ) return 1f;

		var p = attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );

		return p.IsValid() ? BossDamageScaleFor( p ) : 1f;
	}

	/// <summary>
	/// M4 Bounty Hunter — the points a boss hit or kill is worth on top of the normal award.
	///
	/// ⚠️ 0 FOR EVERYTHING THAT IS NOT A BOSS, so the award sites can add it unconditionally.
	/// </summary>
	public static int BossPoints( GameObject attacker, GameObject victim, bool killed )
	{
		if ( !IsBoss( victim ) || !attacker.IsValid() ) return 0;

		var p = attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
		if ( !p.IsValid() ) return 0;

		return killed ? BossKillBonus( p ) : BossHitBonus( p );
	}

	// ══ M2 — Escape Artist ═══════════════════════════════════════════════════

	/// <summary>
	/// Every other player still on their feet, nearest first.
	///
	/// ⚠️ STILL UP, not merely alive. Warping onto another downed player puts two bleeding players
	/// in one spot, which is how a co-op game ends rather than how it is saved.
	/// </summary>
	static List<NZPlayer> WarpTargets( NZPlayer player )
	{
		var scene = Game.ActiveScene;
		if ( !player.IsValid() || !scene.IsValid() ) return new List<NZPlayer>();

		var at = player.WorldPosition;

		return scene.GetAllComponents<NZPlayer>()
			.Where( p => p.IsValid() && p != player && !p.IsDown )
			.Where( p => p.Hp.IsValid() && !p.Hp.IsDead )
			.OrderBy( p => at.Distance( p.WorldPosition ) )
			.ToList();
	}

	/// <summary>
	/// M2 Escape Artist — going down drags you to the nearest player still standing.
	///
	/// ⚠️ CALLED FROM `GoDown`, ALONGSIDE QUICK REVIVE'S OWN HOOK. Going down is the only trigger,
	/// so there is nothing to tick.
	///
	/// ⛔ IT CANNOT FIRE SOLO. With no other player there is no destination, and it says so rather
	/// than teleporting you to the origin — the same failure mode Quick Revive's m5 Phase Shift
	/// guards against on a map with no special spawns.
	/// </summary>
	public static void OnDowned( NZPlayer player )
	{
		if ( !Has( player, "M2" ) ) return;

		var target = WarpTargets( player ).FirstOrDefault();

		if ( !target.IsValid() )
		{
			Log.Info( "[nz-aug] death M2 Escape Artist — nobody else is up, staying put" );
			return;
		}

		var from = player.WorldPosition;
		player.WorldPosition = target.WorldPosition;

		Log.Info( $"[nz-aug] death M2 Escape Artist — warped {from.Distance( target.WorldPosition ):0}u"
			+ $" to {target.GameObject.Name}" );
	}

	// ══ M3 — Blind Spot ══════════════════════════════════════════════════════

	/// <summary>
	/// M3 Blind Spot — a headshot kill may make every zombie forget you for a moment.
	///
	/// ⚠️ IT REUSES `NZPlayer.UntargetableUntil`, the primitive Vulture Aid's gas cloud and
	/// Timeslip's m2 already drive. One concept, one field — three separate timers would need
	/// three retarget triggers and would disagree about which one is in charge.
	///
	/// ⚠️ THE WRITE TAKES THE MAX, IT DOES NOT OVERWRITE. Landing this while standing in Vulture's
	/// gas must not cut the gas short. Whoever asks for the longest window gets it.
	///
	/// ⚠️ NO EXPLICIT RETARGET CALL IS NEEDED. `NZPlayer.TickTargetability` already fires
	/// `ZombieAI.ForceRetargetAll` on the falling edge of `IsUntargetable`, which is the fix that
	/// made the gas cloud work — zombies re-acquire when the window ends rather than staying
	/// frozen where they lost you.
	/// </summary>
	public static void OnZombieKilled( NZPlayer player, bool headshot )
	{
		if ( !headshot || !Has( player, "M3" ) ) return;
		if ( player.DeathIgnoreReady > 0f ) return;

		if ( Game.Random.Float() > MathF.Max( 0f, IgnoreChance ) ) return;

		var window = MathF.Max( 0f, IgnoreSeconds );

		// ⚠️ MAX, not assignment — see the note above.
		if ( window > player.UntargetableUntil )
			player.UntargetableUntil = window;

		player.DeathIgnoreReady = MathF.Max( 0f, IgnoreCooldown );

		Log.Info( $"[nz-aug] death M3 Blind Spot — zombies lose you for {window:0.#}s"
			+ $" · next roll in {IgnoreCooldown:0.#}s" );
	}

	// ══ m1 + m2 — the drop rates ═════════════════════════════════════════════

	/// <summary>
	/// m1 Plated Instinct — scales the armor plate roll.
	///
	/// ⚠️ TAKES AND RETURNS THE CHANCE, stacking with Vulture Aid's Carrion rather than replacing
	/// it. `PickupDrops.RollPlate` applies Vulture first and this second; multiplication commutes,
	/// so the order is presentational only.
	/// </summary>
	public static float PlateChance( NZPlayer player, float chance )
		=> chance * ScaleFor( player, PlateScaleLocal, p => p.PlateLuck );

	/// <summary>m1's multiplier read from the REAL loadout. Only meaningful on the owner.</summary>
	public static float PlateScaleLocal( NZPlayer player )
		=> Has( player, "m1" ) ? MathF.Max( 0f, PlateDropScale ) : 1f;

	/// <summary>
	/// A killer-side multiplier, from wherever the truth for this player is.
	///
	/// ⛔ THE ROLL HAPPENS ON THE HOST AND THE AUGMENT BELONGS TO THE KILLER. `PickupDrops` and
	/// `PowerupDrops` both run where the zombie died, and for a client's kill the killer they are
	/// handed is the host's proxy — `Has()` is false on it for everything, so these augments
	/// silently multiplied by one for every client, all game.
	///
	/// ⚠️ THE OWNER PUBLISHES THE NUMBER, NOT THE LOADOUT. See `NZPlayer.PlateLuck` for why the
	/// whole equipped list is deliberately NOT synced.
	/// </summary>
	static float ScaleFor( NZPlayer player, Func<NZPlayer, float> local,
		Func<NZPlayer, float> published )
	{
		if ( !player.IsValid() ) return 1f;

		return Networking.IsActive && PlayerPresence.Theirs( player.GameObject )
			? MathF.Max( 0f, published( player ) )
			: local( player );
	}

	/// <summary>
	/// m2 Fortune's Sense — scales the power-up roll.
	///
	/// ⛔ THE KILLER HAD TO BE THREADED IN. `PowerupDrops.RollOnDeath` took only a position, unlike
	/// the pickup roll beside it which already took the killer for Vulture Aid. A perk that
	/// changes YOUR drop rate cannot read a rate that belongs to nobody.
	/// </summary>
	public static float PowerupChance( GameObject killer, float chance )
		=> chance * ScaleFor( PlayerOf( killer ), PowerupScaleLocal, p => p.PowerupLuck );

	/// <summary>m2's multiplier read from the REAL loadout. Only meaningful on the owner.</summary>
	public static float PowerupScaleLocal( NZPlayer player )
		=> Has( player, "m2" ) ? MathF.Max( 0f, PowerupChanceScale ) : 1f;

	/// <summary>
	/// The player behind a killer GameObject, or null.
	///
	/// ⚠️ ANCESTORS TOO. The attacker recorded on a hit is usually the player root, but a weapon
	/// or controller child would otherwise resolve to nothing — the same trap
	/// `PerkEffects.HeadshotScaleFor` documents.
	/// </summary>
	static NZPlayer PlayerOf( GameObject killer )
		=> killer.IsValid()
			? killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;

	// ══ m3 — Weak Point ══════════════════════════════════════════════════════

	/// <summary>
	/// m3 Weak Point — the augment's own factor on the headshot multiplier.
	///
	/// ⚠️ READ BY `PerkEffects.HeadshotScaleMultiplier`, so it lands at the same single site the
	/// base perk uses. A second hook into the damage path would be a second author of the same
	/// number (§3), and these two would differ on whether they multiply or add.
	/// </summary>
	public static float HeadshotScaleFor( NZPlayer player )
		=> Has( player, "m3" ) ? MathF.Max( 0f, HeadshotScale ) : 1f;

	// ══ m4 — X-Ray Sense ═════════════════════════════════════════════════════

	/// <summary>
	/// Zombies we have put an outline on, so we only ever remove our own.
	///
	/// ⛔ A NULLABLE-BACKED SET, for the hotload reason every static in this file carries — and
	/// here it matters more than usual: a collection that survives with stale GameObject handles
	/// would try to destroy components on objects that no longer exist.
	///
	/// ⚠️ IT TRACKS WHAT WE CREATED, not what is outlined. Mystery Box and wall buys put
	/// `HighlightOutline` on their own objects; blindly destroying every outline in range would
	/// erase theirs. Nothing else outlines a zombie today, which is exactly why this needs to be
	/// written down before something does.
	/// </summary>
	static HashSet<GameObject> _lit;
	static HashSet<GameObject> Lit => _lit ??= new HashSet<GameObject>();

	/// <summary>
	/// m4 X-Ray Sense — outline every zombie within range, through walls.
	///
	/// ⚠️ A DIFF, NOT A REBUILD. Creating and destroying a component on every zombie every frame
	/// would be the obvious version and a terrible one; this adds outlines to zombies that entered
	/// range and removes them from ones that left.
	///
	/// ⚠️ `ObscuredColor` IS WHAT MAKES IT AN X-RAY. Without it the outline is hidden by geometry
	/// like any other renderer, and the augment becomes "zombies you can already see are shiny".
	/// Mystery Box sets the same field for the same reason.
	/// </summary>
	public static void TickXRay( NZPlayer player )
	{
		// ⛔ MY OWN PLAYER ONLY, AND `Lit` BEING STATIC IS WHY. `NZPlayer.OnUpdate` calls this for
		// EVERY body in the scene — mine and my copy of everyone else's — and the outline set is
		// one per MACHINE, not one per player. So on my screen the sequence each frame was:
		//
		//   TickXRay( me )     → outlines every zombie in range, records them in `Lit`
		//   TickXRay( proxy )  → `on` is false on a body with no augments, so the cleanup below
		//                        destroys every one of them and empties `Lit`
		//
		// ⚠️ WHICH BREAKS THE AUGMENT FOR THE HOST TOO, not only for clients — it needs nothing
		// more than a second player to exist. Solo there is one body, so it has always looked fine.
		//
		// ⚠️ THE GUARD BELONGS HERE, NOT AT THE CALL SITE. Moving the call below `OnUpdate`'s
		// my-body-only line would fix today's caller and leave the trap set for the next one — the
		// same conclusion `ReviveAugments.Tick` reached about reading `Input`. A method that owns a
		// per-machine static cannot be correct for somebody else's body.
		if ( Networking.IsActive && player.IsValid()
			&& !PlayerPresence.Mine( player.GameObject ) ) return;

		var on = Has( player, "m4" );

		// ⚠️ THE CLEANUP RUNS WHETHER OR NOT THE AUGMENT IS ON, so unequipping it — or going down
		// with it — takes the outlines with it. An early return here would leave the world lit up.
		var range = MathF.Max( 0f, XRayRange );
		var at = player.IsValid() ? player.WorldPosition : Vector3.Zero;

		foreach ( var go in Lit.ToList() )
		{
			var keep = on && go.IsValid()
				&& at.Distance( go.WorldPosition ) <= range;

			if ( keep ) continue;

			if ( go.IsValid() )
			{
				var old = go.Components.Get<HighlightOutline>( FindMode.EverythingInSelf );
				if ( old.IsValid() ) old.Destroy();
			}

			Lit.Remove( go );
		}

		if ( !on ) return;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( z.State == ZombieState.Dead ) continue;
			if ( at.Distance( z.WorldPosition ) > range ) continue;
			if ( Lit.Contains( z.GameObject ) ) continue;

			// ⚠️ SKIPPED, NOT OVERWRITTEN, if something else already outlined this object. We only
			// remove what we add, so we must only add where there is nothing.
			if ( z.Components.Get<HighlightOutline>( FindMode.EverythingInSelf ).IsValid() )
				continue;

			var o = z.Components.Create<HighlightOutline>();
			o.Color = Color.Transparent;
			o.InsideColor = Color.Transparent;
			o.ObscuredColor = XRayColor;
			o.InsideObscuredColor = Color.Transparent;
			o.Width = MathF.Max( 0.01f, XRayWidth );

			Lit.Add( z.GameObject );
		}
	}

	/// <summary>m4's outline colour. The perk's own accent red.</summary>
	public static Color XRayColor { get; set; } = new Color( 0.86f, 0.18f, 0.02f );

	// ══ m5 — Executioner's Cut ═══════════════════════════════════════════════

	/// <summary>
	/// m5 Executioner's Cut — headshot kills pay more.
	///
	/// ⚠️ APPLIED AT `ZombieAI.AwardKillPoints`, the ONE points chokepoint. Its own comment
	/// enumerates four award kinds — hit, kill, headshot, knife — three of which resolve inside
	/// it, so this is the only place that can see "this award was for a headshot" once.
	/// </summary>
	public static int KillPoints( NZPlayer player, int amount, bool headshot )
	{
		if ( !headshot ) return amount;

		return (int)MathF.Round( amount
			* ScaleFor( player, HeadshotPointScaleLocal, p => p.HeadshotPointLuck ) );
	}

	/// <summary>m5's multiplier read from the REAL loadout. Only meaningful on the owner.</summary>
	public static float HeadshotPointScaleLocal( NZPlayer player )
		=> Has( player, "m5" ) ? MathF.Max( 0f, HeadshotPointsScale ) : 1f;

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>The resolved state of the base and all nine.</summary>
	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] DEATH PERCEPTION {(has ? "owned" : "NOT OWNED — every line below is inert")}"
			+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );

		// ⚠️ THE RESOLVED HEAD SCALE, not just the factors. Two multipliers printed separately
		// still leave the reader doing the arithmetic the augment exists to change.
		var headScale = PerkEffects.HeadshotScaleMultiplier( player );

		Log.Info( $"[nz-aug]  base            head multiplier ×{PerkEffects.DeathPerceptionFactor:0.##}"
			+ $" · resolved ×{headScale:0.###} (2.5× gun → {2.5f * headScale:0.##}×)" );

		Log.Info( $"[nz-aug]  M1 Boss Slayer  {(Has( player, "M1" ) ? $"×{BossDamageScaleFor( player ):0.##} vs bosses" : "-")}"
			+ "   ⚠ NO BOSSES EXIST — catalogued, nothing reads it" );

		var warps = WarpTargets( player ).Count;

		Log.Info( $"[nz-aug]  M2 Escape Artist {(Has( player, "M2" ) ? "down warps you to the nearest player" : "-")}"
			+ $"   {warps} candidate(s){(warps == 0 ? " — cannot fire solo" : "")}" );

		Log.Info( $"[nz-aug]  M3 Blind Spot   {(Has( player, "M3" ) ? $"{IgnoreChance * 100f:0.#}% on headshot kills → {IgnoreSeconds:0.#}s, {IgnoreCooldown:0.#}s cd" : "-")}"
			+ $"   ready in {MathF.Max( 0f, player.DeathIgnoreReady ):0.0}s"
			+ $" · untargetable {(player.IsUntargetable ? "YES" : "no")}" );

		Log.Info( $"[nz-aug]  M4 Bounty Hunter {(Has( player, "M4" ) ? $"+{BossKillBonus( player )} per boss kill, +{BossHitBonus( player )} per hit" : "-")}"
			+ "   ⚠ NO BOSSES EXIST — catalogued" );

		var plate = ActiveConfig.Armor.PlateDropChance;

		Log.Info( $"[nz-aug]  m1 Plated Inst  {(Has( player, "m1" ) ? $"plates ×{PlateDropScale:0.##}" : "-")}"
			+ $"   {plate * 100f:0.#}% → {PlateChance( player, plate ) * 100f:0.#}% on normals" );

		Log.Info( $"[nz-aug]  m2 Fortune's    {(Has( player, "m2" ) ? $"power-ups ×{PowerupChanceScale:0.##}" : "-")}"
			+ $"   {PowerupDrops.Chance * 100f:0.#}% → {PowerupChance( player.GameObject, PowerupDrops.Chance ) * 100f:0.#}% per kill" );

		Log.Info( $"[nz-aug]  m3 Weak Point   {(Has( player, "m3" ) ? $"head multiplier ×{HeadshotScale:0.##} on top" : "-")}" );

		Log.Info( $"[nz-aug]  m4 X-Ray Sense  {(Has( player, "m4" ) ? $"outlines within {XRayRange:0}u" : "-")}"
			+ $"   {Lit.Count} zombie(s) lit of {ZombieAI.All.Count()} alive" );

		Log.Info( $"[nz-aug]  m5 Executioner  {(Has( player, "m5" ) ? $"headshot kills ×{HeadshotPointsScale:0.##} points" : "-")}"
			+ $"   a 100-point head kill pays {KillPoints( player, 100, true )}" );
	}

	/// <summary>`nz_aug_death` — the resolved state of the base and all nine.</summary>
	[ConCmd( "nz_aug_death" )]
	public static void ReportCmd()
		=> Report( Me() );

	/// <summary>
	/// `nz_death_xray` — how many zombies m4 currently has lit, and how far each is.
	///
	/// ⚠️ THE DISTANCES ARE PRINTED because "0 lit" has two causes that look identical from the
	/// HUD: the augment is off, or every zombie is out of range. One of those is a bug.
	/// </summary>
	[ConCmd( "nz_death_xray" )]
	public static void XRayCmd()
	{
		var p = Me();
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var at = p.WorldPosition;

		Log.Info( $"[nz-aug] X-RAY  {(Has( p, "m4" ) ? "ON" : "OFF (m4 not equipped)")}"
			+ $" · range {XRayRange:0}u · {Lit.Count} lit" );

		foreach ( var z in ZombieAI.All.Where( z => z.IsValid() )
			.OrderBy( z => at.Distance( z.WorldPosition ) ).Take( 12 ) )
		{
			var d = at.Distance( z.WorldPosition );

			Log.Info( $"[nz-aug]   {d,6:0}u  {(d <= XRayRange ? "in range" : "out     ")}"
				+ $"  {(Lit.Contains( z.GameObject ) ? "LIT" : "-")}  {z.State}" );
		}
	}

	/// <summary>
	/// `nz_death_ignore` — force M3's proc, skipping both the roll and the cooldown.
	///
	/// ⚠️ A 10% CHANCE BEHIND A 10-SECOND COOLDOWN IS NOT TESTABLE BY PLAYING. Ten headshot kills
	/// spread over two minutes is the expected wait for one proc, which is long enough that "it
	/// did nothing" and "I was unlucky" are indistinguishable.
	/// </summary>
	[ConCmd( "nz_death_ignore" )]
	public static void IgnoreCmd()
	{
		var p = Me();
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		if ( !Has( p, "M3" ) )
		{
			Log.Warning( "[nz-aug] refused: M3 Blind Spot is not equipped" );
			return;
		}

		var window = MathF.Max( 0f, IgnoreSeconds );

		if ( window > p.UntargetableUntil )
			p.UntargetableUntil = window;

		p.DeathIgnoreReady = MathF.Max( 0f, IgnoreCooldown );

		Log.Info( $"[nz-aug] death M3 Blind Spot FORCED — untargetable {window:0.#}s"
			+ $" · IsUntargetable {p.IsUntargetable}" );
	}

	/// <summary>`nz_death_set &lt;key&gt; &lt;value&gt;` — retune one number live.</summary>
	[ConCmd( "nz_death_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "boss": BossDamageScale = value; break;
			case "chance": IgnoreChance = value; break;
			case "seconds": IgnoreSeconds = value; break;
			case "cooldown": IgnoreCooldown = value; break;
			case "killpoints": BossKillPoints = (int)value; break;
			case "hitpoints": BossHitPoints = (int)value; break;
			case "plates": PlateDropScale = value; break;
			case "powerups": PowerupChanceScale = value; break;
			case "head": HeadshotScale = value; break;
			case "range": XRayRange = value; break;
			case "width": XRayWidth = value; break;
			case "points": HeadshotPointsScale = value; break;

			default:
				Log.Info( "[nz-aug] nz_death_set <boss|chance|seconds|cooldown|killpoints"
					+ "|hitpoints|plates|powerups|head|range|width|points> <value>" );
				return;
		}

		Log.Info( $"[nz-aug] death {key} = {value:0.###}" );
		Report( Me() );
	}

	static NZPlayer Me()
		=> NZPlayer.Local;
}