Player/HealthRegen.cs

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.

NetworkingFile Access
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" );
	}
}