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.
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 <key> <value>` — 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;
}