Armor static class for NZombies. Computes armor bar sizes, caps and depletion behavior, handles plate pickup/use, integrates with augments and round-based bleed-through scaling, provides console commands and tests.
using Sandbox;
using System;
using System.Linq;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// The BO6 plate armor system.
///
/// ⛔ ARMOR IS A DAMAGE REDUCTION THAT DEPLETES, NOT A SECOND HEALTH BAR. Ported
/// from the original's extra/sv_nz_armor_system.lua:115-139, which does both of
/// these to the same hit: subtracts the FULL damage from armor, and then scales
/// the damage the player takes to 30%.
///
/// So a 100-damage hit against 300 armor leaves 200 armor AND costs 30 health.
/// Modelling it as a pool that soaks damage would make a full vest worth 450 extra
/// health instead of a temporary 70% cut, which is a different game.
///
/// ⚠️ OURS FLOORS AT 0, the original at 1. The original also has a PostThink that
/// forces armor back up to 1 whenever a tier is owned, so "empty" is unreachable
/// there and the 30% reduction never fully stops. That floor is deliberately NOT
/// ported (user's call, 2026-08-19): broken armor is a real, visible state here.
/// </summary>
public static class Armor
{
static ArmorSettings Cfg => ActiveConfig.Armor;
/// <summary>
/// Armor cap for this player.
///
/// ⚠️ ITS OWN METHOD rather than reading NZPlayer.ArmorMax at every call site,
/// because Juggernog's m3 "Dense Plating" augment raises the cap and the
/// original routes every cap read through one nzArmor:GetArmorCap(ply) for
/// exactly that reason. Nothing modifies it yet; the seam is the point.
/// </summary>
/// <remarks>
/// ⚠️ THE SEAM IS NOW LOAD-BEARING. Juggernog's m3 "Dense Plating" multiplies the
/// cap here, and everything that asks about the ceiling — plates, Hardplate, Plated
/// Up, the HUD bar — goes through this one method, so the augment reaches all of them
/// without any of them knowing it exists.
/// </remarks>
/// <summary>
/// Fallback bar sizes, used only if the config's list is missing or empty.
///
/// ⚠️ NOT A SECOND SOURCE OF TRUTH -- it is a guard. ArmorSettings.BarHits is a LIST, and a
/// list can come back null across a hotload or from a config saved before the field existed.
/// Returning 0 hits there would silently mean "armor absorbs nothing", which looks exactly
/// like armor being broken rather than like a config that needs re-saving.
/// </summary>
static readonly int[] FallbackBarHits = { 10, 15, 20 };
/// <summary>
/// What one zombie hit costs, at the round the durability is quoted against.
///
/// ⛔️ THE ARMOR NUMBERS ARE DERIVED FROM THIS, NOT AUTHORED BESIDE IT. Durability is
/// specified in HITS; the armor pool is hits x damage. Retune AttackDamageForRound and armor
/// follows automatically, instead of the two silently drifting until a bar lasts three hits
/// again.
/// </summary>
public static float HitDamage
=> MathF.Max( 1f, ZombieStats.AttackDamageForRound( Math.Max( 1, Cfg.BarHitsRound ) ) );
/// <summary>
/// Hits one bar of this tier absorbs, including Juggernog m3.
///
/// ⚠️ THE AUGMENT ADDS HITS PER BAR, so it compounds with the bar count exactly as the tiers
/// do: +5 on tier 3 is +5 on each of three bars, +15 hits of vest. A flat multiplier on the
/// total would have been worth the same on every tier, which is not what a tier is for.
/// </summary>
public static int BarHits( NZPlayer player, int tier )
{
if ( tier <= 0 ) return 0;
var list = Cfg.BarHits is { Count: > 0 } ? Cfg.BarHits : (IList<int>)FallbackBarHits;
var hits = list[Math.Clamp( tier, 1, list.Count ) - 1];
return Math.Max( 0, hits + JuggAugments.ArmorBarHits( player ) );
}
/// <summary>Armor points in one bar of this tier.</summary>
public static float BarSize( NZPlayer player, int tier ) => BarHits( player, tier ) * HitDamage;
/// <summary>Armor points in one bar of the tier this player owns.</summary>
public static float BarSize( NZPlayer player )
=> player.IsValid() ? BarSize( player, player.ArmorTier ) : 0f;
/// <summary>
/// How many whole armor bars this player still has.
///
/// ⚠️ CEILING, NOT FLOOR: 1 point of armor is still a bar you have not finished breaking, and
/// the break should fire when it reaches zero rather than when it drops below full.
/// </summary>
public static int BarsRemaining( NZPlayer player )
{
if ( !player.IsValid() ) return 0;
var per = MathF.Max( 1f, BarSize( player ) );
return (int)MathF.Ceiling( MathF.Max( 0f, player.Armor ) / per );
}
/// <summary>
/// The full armor ceiling for a tier: that many bars, each of that tier's size.
///
/// ⛔️ THE TIER IS BOTH FACTORS. Tier N grants N bars AND a bigger bar, so the totals are 10 /
/// 30 / 60 hits rather than 10 / 20 / 30 -- buying up is worth more than the extra bar alone.
/// </summary>
public static float CapForTier( NZPlayer player, int tier )
{
tier = Math.Clamp( tier, 0, Math.Max( 1, Cfg.MaxTier ) );
return tier <= 0 ? 0f : tier * BarSize( player, tier );
}
public static float CapFor( NZPlayer player )
=> player.IsValid() ? CapForTier( player, player.ArmorTier ) : 0f;
// ── tiers ────────────────────────────────────────────────────────────────
/// <summary>
/// Grant an armor tier.
///
/// ⚠️ Armor is NOT filled on purchase. Plates are the intended way to fill it,
/// and handing out a free 150 with the vest makes the first plate worthless.
/// Buy the vest, then plate it.
/// </summary>
public static bool SetTier( NZPlayer player, int tier )
{
if ( !player.IsValid() ) return false;
player.ArmorTier = Math.Clamp( tier, 0, Math.Max( 0, Cfg.MaxTier ) );
// ⚠️ Dropping a tier has to pull current armor down with it, or a tier-3
// player demoted to tier 1 keeps 450 armor in a 150 vest.
player.Armor = MathF.Min( player.Armor, CapFor( player ) );
return true;
}
// ── plates ───────────────────────────────────────────────────────────────
/// <summary>
/// Pick a plate up.
///
/// ⚠️ ALLOWED AT TIER 0. You can hoard plates before owning a vest — the
/// original does the same, and refusing the pickup would mean walking past
/// plates you cannot yet use with nothing on screen explaining why.
/// </summary>
/// <summary>
/// `nz_armor_why` — EVERY REASON A PLATE COULD BE REFUSED, FOR EVERY PLAYER, ON BOTH MACHINES.
///
/// ⛔ "I CANNOT PICK UP ARMOR" HAS FIVE CAUSES THAT LOOK IDENTICAL: armor disabled in the
/// config, plates already at the carry cap, no tier owned (so plates can never be SPENT and the
/// cap fills permanently), the pickup out of reach, or the drop never reaching this machine at
/// all. From a keyboard every one of them is a plate on the floor that does nothing when you
/// stand on it.
///
/// ⚠️ THE TIER IS THE ONE THAT TRAPS. `UsePlate` refuses with no tier owned — so a player
/// who never bought armor picks up plates until they hit the cap and then silently stops being
/// able to, having never seen an error. It reads exactly like "pickups broke".
///
/// Client output routes to the host, so one capture holds both sides.
/// </summary>
[ConCmd( "nz_armor_why" )]
public static void WhyCmd()
{
void Tell( string line )
{
if ( NZGame.IsHost || !Networking.IsActive ) Log.Info( line );
else NZNet.Say( line );
}
var who = NZGame.IsHost ? "HOST " : "CLIENT";
Tell( $"[nz-armor] {who} armor {(Cfg.Enabled ? "enabled" : "⛔ DISABLED IN CONFIG — nothing can be picked up")}"
+ $" · maxPlates {Cfg.MaxPlates} · maxTier {Cfg.MaxTier}" );
foreach ( var go in PlayerSpawner.AllBodies() )
{
var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !p.IsValid() ) continue;
var canAdd = AddPlateWould( p );
Tell( $"[nz-armor] {who} '{go.Name}' mine={PlayerPresence.Mine( go ),-5}"
+ $" tier={p.ArmorTier} plates={p.ArmorPlates}/{Cfg.MaxPlates}"
+ $" armor={p.Armor:0}/{CapFor( p ):0}"
+ $" · pick up a plate? {(canAdd ?? "YES")}"
+ $" · spend one? {(WhyCannotPlate( p ) ?? "YES")}" );
}
// ⚠️ THE REACH IS PER PLAYER because Vulture's m5 Long Arms triples it, and a pickup
// sitting just outside it is the one cause the columns above cannot show.
var me = NZPlayer.Local;
if ( me.IsValid() )
Tell( $"[nz-armor] {who} my pickup reach {VultureAugments.ReachFor( me, 32f ):0.#}u"
+ " (a plate must be within this of your BODY, not your feet)" );
}
/// <summary>Why `AddPlate` would refuse, or null when it would succeed.</summary>
static string AddPlateWould( NZPlayer player )
{
if ( !player.IsValid() ) return "no player";
if ( !Cfg.Enabled ) return "⛔ armor disabled";
if ( player.ArmorPlates >= Cfg.MaxPlates )
return player.ArmorTier <= 0
? "⛔ AT THE CARRY CAP AND NO TIER OWNED — they can never be spent, so this is permanent"
: "⛔ at the carry cap — spend one first";
return null;
}
public static bool AddPlate( NZPlayer player )
{
if ( !player.IsValid() || !Cfg.Enabled ) return false;
if ( player.ArmorPlates >= Cfg.MaxPlates ) return false;
player.ArmorPlates++;
return true;
}
/// <summary>Why UsePlate would refuse, for the log and a future prompt. Null
/// when it would succeed.</summary>
public static string WhyCannotPlate( NZPlayer player )
{
if ( !player.IsValid() ) return "no player";
if ( !Cfg.Enabled ) return "armor is disabled";
if ( player.ArmorPlates <= 0 ) return "no plates carried";
if ( player.ArmorTier <= 0 ) return "no armor tier owned";
if ( player.Armor >= CapFor( player ) ) return "armor already full";
return null;
}
/// <summary>
/// Spend one plate to refill armor.
///
/// ⚠️ Refuses when already full rather than wasting the plate — the original
/// returns early on the same condition.
/// </summary>
public static bool UsePlate( NZPlayer player )
{
if ( WhyCannotPlate( player ) is not null ) return false;
player.ArmorPlates--;
player.Armor = MathF.Min( NextBarTop( player ), CapFor( player ) );
return true;
}
/// <summary>
/// Armor value at the top of the bar the player is currently in.
///
/// ⛔ ONE PLATE FILLS ONE BAR, NOT A FIXED NUMBER OF POINTS. This used to add a
/// separate `PerPlate` figure, which only ever looked right because that figure and
/// the bar size were both 150 — change either and a plate would fill part of a bar or
/// spill into the next one, and the three-segment HUD could not show it honestly.
///
/// ⚠️ IT FILLS THE BAR YOU ARE IN, so a plate applied at 75/450 takes you to 150
/// and is "worth" only 75 armor. Deliberate, and what BO6 does: a plate restores a
/// SEGMENT, so the efficient moment to use one is just after a segment breaks.
///
/// ⚠️ The +0.01 nudge is what makes an exact boundary advance instead of standing
/// still — at exactly 150 the ceiling of 150/150 is still 150, and the plate would
/// be consumed for nothing.
/// </summary>
static float NextBarTop( NZPlayer player )
{
var per = BarSize( player );
if ( per <= 0f ) return CapFor( player );
return MathF.Ceiling( (player.Armor + 0.01f) / per ) * per;
}
// ── damage ───────────────────────────────────────────────────────────────
/// <summary>
/// Run a hit through armor and return what the player should actually take.
///
/// ⛔ ARMOR LOSES THE FULL AMOUNT, THE PLAYER TAKES A FRACTION — both, from the
/// same hit. See the class note. Subtracting only the bled-through part would
/// make a vest last three times too long.
///
/// ⚠️ NO PARTIAL BREAK. A hit bigger than the armor left still only costs the
/// player BleedThrough of it; the overflow is not billed to health. That is the
/// original's behaviour, and it is what lets a full vest survive one enormous
/// hit. Carrying the remainder into health would quietly turn armor back into a
/// pool.
/// </summary>
public static float Absorb( NZPlayer player, float amount )
{
if ( !Cfg.Enabled ) return amount;
if ( !player.IsValid() ) return amount;
if ( player.ArmorTier <= 0 || player.Armor <= 0f ) return amount;
if ( amount <= 0f ) return amount;
// ⚠️ ARMOR LOSES THE FULL AMOUNT. The original's Juggernog m2 halved this, but
// the requested behaviour for that augment is less damage taken rather than a
// longer-lasting vest — so the depletion is untouched and m2 scales the RETURN
// instead, immediately below.
// ⚠ THE BAR COUNT IS TAKEN BEFORE AND AFTER, because "an armor bar broke" is a
// CROSSING and not a value. A tier-3 player has three bars, so this fires up to three
// times per vest rather than once — which is what makes Tortoise's m1 scale with the
// armor you paid for.
var barsBefore = BarsRemaining( player );
player.Armor = MathF.Max( 0f, player.Armor - amount );
if ( BarsRemaining( player ) < barsBefore )
TortoiseAugments.OnArmorBarBroken( player );
// ⛔ m2 "EFFICIENT WEAVE" SCALES THE BLEED-THROUGH, which is why it lands here
// rather than on the line above. Applying it to the depletion would make armor
// last twice as long and change nothing about the damage; applying it here cuts
// the damage and leaves the vest's lifetime alone.
//
// ⚠️ Distinct from M3 Bulwark despite both being damage cuts: this multiplies only
// the fraction that survives armor, so every early return above — no tier, empty
// vest, bypassing damage — switches it off. Bulwark applies to every hit.
return amount
* BleedThroughNow()
* JuggAugments.ArmorBleedScale( player );
}
/// <summary>
/// The share of each hit that gets through armor THIS ROUND: `BleedThrough` (30%) up to round `BleedRampFrom` (30), rising
/// in a straight line to `BleedThroughLate` (60%) at round `BleedRampTo` (90), and held there. 45% at round 60.
///
/// ⛔ ADDED 2026-10-03 — see `ArmorSettings.BleedThroughLate`. Zombie damage stops growing at round 31, so a vest that never
/// weakened made the rest of the game a stroll: a round-60+ hit cost a Juggernog-and-Weave player 13.5 health.
///
/// ⚠️ EVERY READER OF THE BLEED GOES THROUGH HERE — `Absorb`, the status and test lines below and Juggernog's report — so
/// none of them quotes a flat 30% that is no longer true after round 30.
/// </summary>
public static float BleedThroughNow()
{
var early = MathX.Clamp( Cfg.BleedThrough, 0f, 1f );
var late = MathX.Clamp( Cfg.BleedThroughLate, 0f, 1f );
if ( Cfg.BleedRampTo <= Cfg.BleedRampFrom ) return early;
var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;
var t = MathX.Clamp( (round - Cfg.BleedRampFrom) / (float)(Cfg.BleedRampTo - Cfg.BleedRampFrom), 0f, 1f );
return MathX.Lerp( early, late, t );
}
/// <summary>
/// Does this damage bypass armor entirely.
///
/// ⚠️ The original excludes fall, drown, poison and radiation damage
/// (sv_nz_armor_system.lua:115). s&box has no DMG_ bitflags, so this reads the
/// tag set the same way Health.IsMelee does.
///
/// ⛔ THE "fall" CASE IS LIVE AND ITS TAG IS STILL UNVERIFIED. This said
/// "nothing in this project deals fall... damage to a player yet, so none of these
/// strings has ever been observed arriving" — and that premise is FALSE. Fall
/// damage does reach the player; it comes from the engine's own controller rather
/// than any C# in this project, which is why grepping for it here finds nothing.
/// Confirmed 2026-08-19 by the user testing PhD Flopper, which blocks it correctly
/// (PhD asks "is the attacker a zombie" and never looks at a tag, so it is immune
/// to this whole question).
///
/// What is NOT known is whether the engine stamps that damage with the literal tag
/// `"fall"`. If it does not, armor is absorbing fall damage right now, contrary to
/// the original.
///
/// ⚠️ ONE TEST SETTLES IT: with a tier and full armor, take a survivable fall
/// and watch the HUD armor bar. Armor dropping means the tag does not match and
/// this list needs the real name. `drown`, `poison` and `radiation` remain
/// genuinely unreachable and untested.
/// </summary>
public static bool Bypasses( in DamageInfo damage )
{
// ⚠️ THE "ONLY AN ENEMY SPENDS PLATES" TEST IS NOT HERE, DELIBERATELY. It was written
// here first and that was the wrong chokepoint: `Bypasses` only ever runs on the
// DamageInfo route, and `ignoreArmor` defaults to FALSE for every direct `Health.Apply`
// caller — which is how a damage wall and an easter-egg trap reach a player. The test
// lives in `Health.Apply` beside the armor call, where all damage passes.
//
// ⚠️ These tags still stand: they exempt a hit that DID come from an enemy.
var tags = damage.Tags;
if ( tags is null ) return false;
return tags.Has( "fall" )
|| tags.Has( "drown" )
|| tags.Has( "poison" )
|| tags.Has( "radiation" );
}
// ── lifecycle ────────────────────────────────────────────────────────────
/// <summary>
/// Wipe armor back to nothing.
///
/// ⚠️ The TIER goes too. An armor tier is a within-game purchase like perks and
/// bought perk slots, both of which RoundManager already clears on a new game.
/// </summary>
public static void Reset( NZPlayer player )
{
if ( !player.IsValid() ) return;
player.ArmorTier = 0;
player.Armor = 0f;
player.ArmorPlates = 0;
}
// ── commands ─────────────────────────────────────────────────────────────
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>Report armor state: nz_armor</summary>
[ConCmd( "nz_armor" )]
public static void Status()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-armor] no player" ); return; }
Log.Info( $"[nz-armor] tier {p.ArmorTier}/{Cfg.MaxTier}"
+ $" · armor {p.Armor:0}/{CapFor( p ):0}"
+ $" · plates {p.ArmorPlates}/{Cfg.MaxPlates}"
+ $" · {(Cfg.Enabled ? "enabled" : "DISABLED")}" );
Log.Info( p.HasArmor
? $"[nz-armor] absorbing — you take {BleedThroughNow() * JuggAugments.ArmorBleedScale( p ) * 100f:0.#}% of each hit this round,"
+ $" armor pays 100%"
: $"[nz-armor] NOT absorbing ({(p.ArmorTier <= 0 ? "no tier owned" : "armor empty")})"
+ " — you take full damage" );
}
/// <summary>Grant a tier: nz_armor_tier 0-3</summary>
[ConCmd( "nz_armor_tier" )]
public static void TierCmd( int tier = -1 )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-armor] no player" ); return; }
if ( tier >= 0 )
{
SetTier( p, tier );
Log.Info( $"[nz-armor] tier -> {p.ArmorTier}, cap {CapFor( p ):0}" );
}
Status();
}
/// <summary>Give plates: nz_armor_plate [count]</summary>
[ConCmd( "nz_armor_plate" )]
public static void PlateCmd( int count = 1 )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-armor] no player" ); return; }
var added = 0;
for ( var i = 0; i < count; i++ )
if ( AddPlate( p ) ) added++;
Log.Info( $"[nz-armor] +{added} plate(s) (asked {count})"
+ $" — carrying {p.ArmorPlates}/{Cfg.MaxPlates}" );
}
/// <summary>Apply a carried plate: nz_armor_use</summary>
[ConCmd( "nz_armor_use" )]
public static void UseCmd()
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-armor] no player" ); return; }
var why = WhyCannotPlate( p );
if ( why is not null ) { Log.Info( $"[nz-armor] cannot plate — {why}" ); return; }
UsePlate( p );
Log.Info( $"[nz-armor] plated — armor {p.Armor:0}/{CapFor( p ):0},"
+ $" {p.ArmorPlates} plate(s) left" );
}
/// <summary>
/// Prove the split: nz_armor_test [damage]
///
/// ⚠️ THIS IS THE ONE THAT MATTERS. It must show armor falling by the FULL
/// amount while health falls by only BleedThrough of it. Reading the two numbers
/// separately is the only way to tell this apart from a plain absorb, which
/// would show armor down and health untouched.
///
/// ⚠️ Goes through Health.Apply — the real path — rather than calling Absorb
/// directly. A test that skips the integration proves the arithmetic and nothing
/// about whether the hook is wired.
/// </summary>
[ConCmd( "nz_armor_test" )]
public static void TestCmd( float damage = 100f )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-armor] no player" ); return; }
var hp = p.Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() ) { Log.Warning( "[nz-armor] no Health component" ); return; }
var armored = p.ArmorTier > 0 && p.Armor > 0f;
var a0 = p.Armor;
var h0 = hp.Current;
// ⛔ IT HAS TO COME FROM AN ENEMY NOW, OR THIS TEST PROVES THE OPPOSITE OF WHAT IT
// CLAIMS. Since armor only answers to enemy damage, a bare `hp.Apply( damage )` has no
// attacker, skips armor entirely, and prints "armor unchanged" — which reads as the
// armor system being broken by the very change that is working correctly.
//
// ⚠️ A REAL TAGGED OBJECT, not a null and not a flag. Passing something the production
// test actually accepts is what keeps this an integration test; special-casing the
// command inside `Health.Apply` would make it prove a path no zombie takes.
var faker = Game.ActiveScene.CreateObject();
faker.Name = "nz_armor_test attacker";
faker.Tags.Add( "zombie" );
faker.Flags |= GameObjectFlags.NotSaved;
try
{
hp.Apply( damage, false, faker );
}
finally
{
faker.Destroy();
}
Log.Info( $"[nz-armor] test {damage:0.#} damage through Health.Apply:" );
Log.Info( $"[nz-armor] armor {a0:0.#} -> {p.Armor:0.#} (-{a0 - p.Armor:0.#})" );
Log.Info( $"[nz-armor] health {h0:0.#} -> {hp.Current:0.#} (-{h0 - hp.Current:0.#})" );
Log.Info( $"[nz-armor] expected: armor -{(armored ? MathF.Min( damage, a0 ) : 0f):0.#},"
+ $" health -{(armored ? damage * BleedThroughNow() * JuggAugments.ArmorBleedScale( p ) : damage):0.#}" );
}
/// <summary>Tune it live: nz_armor_set [bleedThrough] [bleedThroughLate] (the share let through to round 30, and by round 90)
///
/// ⚠️ No perPlate any more — a plate fills one bar, so the bar size IS the plate
/// size. See NextBarTop.</summary>
/// <summary>
/// `nz_armor_bars [tier1] [tier2] [tier3] [round]` — durability, IN HITS PER BAR.
///
/// ⛔️ HITS, NOT ARMOR POINTS, matching how the config authors it. Typing a points value here
/// would set a tier to survive that many hits and read as the command being off by 50x.
/// </summary>
[ConCmd( "nz_armor_bars" )]
public static void BarsCmd( int tier1 = -1, int tier2 = -1, int tier3 = -1, int round = -1 )
{
if ( Cfg.BarHits is null || Cfg.BarHits.Count < 3 )
Cfg.BarHits = new List<int>( FallbackBarHits );
if ( tier1 >= 0 ) Cfg.BarHits[0] = tier1;
if ( tier2 >= 0 ) Cfg.BarHits[1] = tier2;
if ( tier3 >= 0 ) Cfg.BarHits[2] = tier3;
if ( round >= 1 ) Cfg.BarHitsRound = round;
// ⚠️ SetCmd's report, not Status(). Status describes a LIVE PLAYER and says "no player"
// outside a game -- which is exactly when durability gets tuned. This prints the table.
SetCmd();
}
[ConCmd( "nz_armor_set" )]
public static void SetCmd( float bleedThrough = -1f, float bleedThroughLate = -1f )
{
if ( bleedThrough >= 0f ) Cfg.BleedThrough = MathX.Clamp( bleedThrough, 0f, 1f );
if ( bleedThroughLate >= 0f ) Cfg.BleedThroughLate = MathX.Clamp( bleedThroughLate, 0f, 1f );
// ⚠️ The live config was edited in place, so anything already holding a copy
// has to be told — see ActiveConfig.NotifyChanged's own warning about exactly this.
ActiveConfig.NotifyChanged();
// ⚠️ THE RAMP AND THIS ROUND'S VALUE, not just the base: after round 30 the base is not what anyone takes (2026-10-03).
Log.Info( $"[nz-armor] bleed-through {Cfg.BleedThrough * 100f:0}% to round {Cfg.BleedRampFrom},"
+ $" rising to {Cfg.BleedThroughLate * 100f:0}% by round {Cfg.BleedRampTo} · this round {BleedThroughNow() * 100f:0.#}%"
+ $" · durability quoted at round {Cfg.BarHitsRound}"
+ $" ({HitDamage:0} damage a hit)" );
// ⚠️ PRINTED PER TIER, IN HITS AND IN POINTS. Hits are what the design is stated in and
// points are what the HUD and every other system sees; showing one without the other is
// how "a bar goes down in three hits" survived unnoticed.
var you = NZPlayer.Local;
for ( int t = 1; t <= Math.Max( 1, Cfg.MaxTier ); t++ )
Log.Info( $"[nz-armor] tier {t}: {t} bar(s) x {BarHits( you, t )} hits"
+ $" = {BarHits( you, t ) * t} hits total"
+ $" ({BarSize( you, t ):0} armor a bar, {CapForTier( you, t ):0} full)" );
if ( you.IsValid() && JuggAugments.ArmorBarHits( you ) > 0 )
Log.Info( $"[nz-armor] includes Jugg m3: +{JuggAugments.ArmorBarHits( you )} hits per bar" );
}
}