Component attached to player bodies that implements delayed health regeneration. It finds the correct Health to heal (player-owned when present), enforces delay, prevents regen while down or during NoRegenUntil, and heals a percentage of max health on ticks; includes a console diagnostic command.
using Sandbox;
using System;
namespace NZombies;
/// <summary>
/// Heal back up after not being hit for a while.
///
/// ⚠️ THE HEALING IS FAST; THE WAIT IS LONG. This is a RATIO of max health per
/// tick, not HP per second — taken from BO2's _zm_playerhealth.gsc via the
/// original's sv_healthregen.lua. At the defaults (0.1 per 0.05s tick after a
/// 5s delay) a player goes from nearly dead to full in about half a second,
/// once the five seconds have elapsed.
///
/// Modelling it as "HP per second" would produce a slow drip and change how the
/// game plays: the tension in CoD zombies is surviving the delay, not watching
/// a bar creep up.
/// </summary>
public sealed class HealthRegen : Component
{
static PlayerSettings Settings => ActiveConfig.Player;
Health _health;
NZPlayer _player;
TimeSince _sinceTick;
/// <summary>
/// ⛔ IT NO LONGER CHAINS `OnDamaged`, AND THE CHAIN IS WHY A CLONED PLAYER WAS IMMORTAL.
/// `OnDamaged` is an Action PROPERTY: `NZPlayer.OnStart` ASSIGNS it and this wrapped whatever
/// was already there, so the pair only worked if NZPlayer ran first. On the scene's own player
/// it does, because NZPlayer is what creates this component. On a body made with
/// `GameObject.Clone()` every component already exists and the start order is whatever the
/// serialisation happened to be — so this wrapper was installed and then thrown away by
/// NZPlayer's assignment a moment later.
///
/// ⚠️ THE FAILURE WAS SILENT AND LOOKED LIKE A NETWORKING BUG. With the chain cut,
/// `_health.SinceLastDamage` never reset: regen believed the player had been calm all round and ticked 10%
/// every 0.05s, undoing every hit within half a second. The host's log read
/// `hit for 30 — 120/150` forty times in a row while its OWN body died to the same zombies.
///
/// ⚠️ `Health.SinceLastDamage` IS NOW A FACT ON THE COMPONENT, pulled rather than pushed.
/// A reader cannot lose a value it pulls, and there is no start order left to get wrong.
/// </summary>
protected override void OnStart()
{
_health = Components.Get<Health>();
_player = Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
_sinceStart = 0f;
}
/// <summary>Since this component started — how long a missing Health went unnoticed, for the warning above.</summary>
TimeSince _sinceStart;
/// <summary>
/// The Health to heal: the player's own (`NZPlayer.Hp`), which every hit lands on; this object's when it has no player.
/// </summary>
Health HealthToHeal()
=> _player.IsValid() && _player.Hp.IsValid() ? _player.Hp : Components.Get<Health>( FindMode.EverythingInSelf );
protected override void OnUpdate()
{
// ⚠️ Re-resolve rather than trusting the OnStart cache. NZPlayer can be
// added after this component starts, and a null cached there would mean
// Quick Revive silently never shortened the wait — the exact ordering
// trap INSTRUCTIONS.md §11 records twice.
if ( !_player.IsValid() )
_player = Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
// ⛔ AND THE HEALTH TOO, WHICH WAS CACHED ONCE IN OnStart AND NEVER LOOKED FOR AGAIN (2026-10-05). `Components.Get`
// passes over a component that is not enabled, so a Health it missed at start left this returning on its first line every
// frame for the rest of the game: a body that never healed and said nothing. The user: *"sometimes clients have unlimited
// stamina, wich seems to also corelate with being unable to recover health"* — Pu4DinFL7 on gm_defocus (2026-10-05 00:38)
// went 150 → 120 → 90 → 60 → 30 → 8 with gaps of 12 to 87 seconds and not one tick; `Stamina` cached its controller the
// same way. Asked every frame now, and it is NZPlayer's own `Hp` — the Health every hit on this body lands on
// (`NZNet.HurtPlayer` applies to `NZPlayer.Local.Hp`) — so the one hurt and the one healed cannot be two.
var hp = HealthToHeal();
if ( hp != _health )
{
if ( hp.IsValid() )
Log.Warning( $"[nz-regen] '{GameObject.Name}' {(_health.IsValid() ? "was healing a different Health from the one hits land on" : $"had no Health to heal for {(float)_sinceStart:0}s")} — fixed, regen works again" );
_health = hp;
}
if ( !_health.IsValid() ) return;
if ( _health.Current <= 0f ) return; // no healing while down
if ( _health.Current >= _health.Max ) return;
// Quick Revive shortens the WAIT, not the heal — see RegenDelayMultiplier.
// Derived per read, never written onto the shared PlayerSettings, so
// losing the perk needs no undo.
// ⚠ QUICK REVIVE'S m1 MULTIPLIES THE PERK'S OWN DELAY FACTOR rather than replacing it, so
// the base perk's reduction and the augment's compose instead of one winning.
// ⚠️ FROM THE MATCH'S RECOVERY DELAY (the lobby's Difficulty, 2026-10-05), the gamemode's own unless the host changed it
var delay = Difficulty.RegenDelay
* PerkEffects.RegenDelayMultiplier( _player )
* ReviveAugments.DelayScale( _player );
if ( _health.SinceLastDamage < delay ) return;
// ⛔ A BURN HOLDS IT OFF OUTRIGHT (2026-10-06, the Fire Margwa's line): `Health.NoRegenUntil`, which a burn held at 1 HP
// still sets though it no longer moves the damage clock
if ( Time.Now < _health.NoRegenUntil ) return;
// ⚠ m2 FAST METABOLISM SHRINKS THE GAP BETWEEN TICKS, which is what "20% faster" means
// for a rate expressed as an interval — 0.8, not 1.2. Inverting it here would slow regen
// down, the same trap `Weapon.Reload` records for Fast Hands.
if ( _sinceTick < Settings.HealthRegenRate * ReviveAugments.RateScale( _player ) ) return;
_sinceTick = 0f;
// A percentage of MAX per tick — so the heal takes the same time from any
// starting health, which is what makes it feel like a reset rather than
// a reward for being nearly dead. /100 because the setting is a percent
// (10) and the original's number is a fraction (0.1).
var amount = _health.Max * (Settings.HealthRegenPercent / 100f);
_health.Heal( amount );
Ticks++;
LastHealed = amount;
}
// ── diagnostics ───────────────────────────────────────────────────────────────
/// <summary>How many times this body has actually healed, and by how much.</summary>
public static int Ticks;
public static float LastHealed;
/// <summary>
/// `nz_regen` — why is health not coming back.
///
/// ⛔ IT PRINTS EVERY GATE IN ORDER, NOT A VERDICT. `OnUpdate` returns early in five different
/// places and on the HUD all five look identical: a bar that does not move. Guessing which one
/// it is has already cost more time than writing this.
///
/// ⚠️ IT REPORTS THE LOCAL PLAYER'S OWN BODY. Regen runs on whichever machine owns the body,
/// so run it on the machine that is not healing — a host reporting healthy gates says nothing
/// about a client.
/// </summary>
[ConCmd( "nz_regen" )]
public static void RegenCmd()
{
var me = NZPlayer.Local;
if ( !me.IsValid() ) { Log.Warning( "[nz-regen] no local player" ); return; }
var regen = me.Components.Get<HealthRegen>( FindMode.EverythingInSelfAndDescendants );
var hp = me.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( !regen.IsValid() )
{
Log.Warning( "[nz-regen] ⛔ NO HealthRegen COMPONENT on this body — nothing can heal it."
+ " NZPlayer.OnStart is what creates it." );
return;
}
if ( !hp.IsValid() ) { Log.Warning( "[nz-regen] ⛔ no Health component" ); return; }
var delay = Difficulty.RegenDelay
* PerkEffects.RegenDelayMultiplier( me )
* ReviveAugments.DelayScale( me );
var rate = Settings.HealthRegenRate * ReviveAugments.RateScale( me );
var amount = hp.Max * (Settings.HealthRegenPercent / 100f);
Log.Info( $"[nz-regen] {hp.Current:0}/{hp.Max:0} hp · healed {Ticks} time(s)"
+ $" · last {LastHealed:0.#}" );
Log.Info( $"[nz-regen] gate 1 health valid {(hp.IsValid() ? "ok" : "⛔ NO")}" );
Log.Info( $"[nz-regen] gate 2 not downed {(hp.Current > 0f ? "ok" : "⛔ AT ZERO — downed, regen is off by design")}" );
Log.Info( $"[nz-regen] gate 3 not full {(hp.Current < hp.Max ? "ok" : "⛔ ALREADY FULL")}" );
// ⚠️ THE CLOCK AND WHAT LAST TOUCHED IT, ON ONE LINE. "Waiting" is the answer that leads
// somewhere only if it also says WHAT is restarting the wait — a clock that keeps resetting
// while nothing appears to be hitting you is a completely different bug from a long delay,
// and on the HUD the two are the same motionless bar.
Log.Info( $"[nz-regen] gate 4 delay elapsed {hp.SinceLastDamage:0.0}s of {delay:0.0}s"
+ $" {(hp.SinceLastDamage >= delay ? "ok" : "⛔ WAITING")}" );
Log.Info( $"[nz-regen] last hit: {hp.LastDamage:0.#} dmg"
+ $" from '{(hp.LastAttacker.IsValid() ? hp.LastAttacker.Name : "nothing recorded")}'"
+ (hp.SinceLastDamage < 1f
? " ⛔ LESS THAN A SECOND AGO — run this twice; if it stays under a second while"
+ " nothing is attacking you, something is calling Health.Apply on a loop"
: "") );
Log.Info( $"[nz-regen] gate 5 tick cadence every {rate:0.###}s, heals {amount:0.#} hp"
+ (amount <= 0f ? " ⛔ ZERO PER TICK — HealthRegenPercent is 0" : "") );
Log.Info( $"[nz-regen] tuning: delay {Settings.HealthRegenDelay:0.#}s"
+ $" × perk {PerkEffects.RegenDelayMultiplier( me ):0.##}"
+ $" × aug {ReviveAugments.DelayScale( me ):0.##}"
+ $" · rate {Settings.HealthRegenRate:0.###}s × aug {ReviveAugments.RateScale( me ):0.##}"
+ $" · {Settings.HealthRegenPercent:0.#}% of max" );
}
}