PlayerHealth.cs
using Sandbox;
using System;
using System.Linq;

public sealed class PlayerHealth : Component, Component.IDamageable
{
	private OMRCombatTuning Tuning =>
		OMRCombatResources.DefaultTuning;

	/// <summary>
	/// The host snapshots the configured maximum health whenever the player is
	/// reset for a round/respawn. Keeping this synchronized means HUD and timeout
	/// rules agree on the same value even if the tuning asset changes later.
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public float MaxHealth { get; private set; } = 180f;

	[Sync( SyncFlags.FromHost )]
	public float Health { get; private set; } = 180f;

	/// <summary>
	/// During the special full-health sudden death phase,
	/// any hit caused by a BaseCombatWeapon is lethal.
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public bool WeaponOneShotMode { get; private set; }

	/// <summary>
	/// Modular round rule: ordinary weapon hits outside the resolved head
	/// hitgroup are ignored. Non-weapon damage paths are left untouched.
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public bool WeaponHeadshotOnlyMode { get; private set; }

	// Victim-side combat feedback intentionally rides on PlayerHealth's proven
	// host-authoritative sync path. Health/MaxHealth already replicate correctly
	// to the owning client in OMR, so keeping the transient payload here avoids
	// depending on RPC routing for a client-owned player object. The sequence is
	// the event edge; the remaining properties are the latest payload.
	[Sync( SyncFlags.FromHost )]
	public float DamageFeedbackAmount { get; private set; }

	[Sync( SyncFlags.FromHost )]
	public bool DamageFeedbackKilled { get; private set; }

	[Sync( SyncFlags.FromHost )]
	public Vector3 DamageFeedbackSourcePosition { get; private set; }

	[Sync( SyncFlags.FromHost )]
	public bool DamageFeedbackHasDirectionalSource { get; private set; }

	[Sync( SyncFlags.FromHost ), Change( nameof(OnDamageFeedbackSequenceChanged) )]
	public int DamageFeedbackSequence { get; private set; }

	private int _pendingDamageFeedbackSequence = -1;
	private int _lastConsumedDamageFeedbackSequence;

	public bool IsDead =>
		Health <= 0f;

	protected override void OnStart()
	{
		_lastConsumedDamageFeedbackSequence =
			DamageFeedbackSequence;

		_pendingDamageFeedbackSequence =
			-1;

		// The Citizen model contains authored model hitboxes, but a
		// SkinnedModelRenderer does not make them traceable by itself in the
		// Scene system. Every peer that can resolve a client-side weapon trace
		// needs a ModelHitboxes component bound to the animated body renderer.
		// Keep the trace target on the player root so BaseCombatWeapon damage
		// resolves to this PlayerHealth/IDamageable rather than the visual Body
		// child.
		EnsureCombatHitboxes();

		if ( !Networking.IsHost )
			return;

		ResetHealth();
	}

	/// <summary>
	/// Materializes the hitboxes authored in citizen.vmdl into scene hitboxes
	/// that SceneTrace.UseHitboxes() can actually hit. Movement capsule/box
	/// collision stays separate and continues to be filtered from firearm traces
	/// through the "playercontroller" tag.
	/// </summary>
	private void EnsureCombatHitboxes()
	{
		PlayerController controller =
			Components.Get<PlayerController>();

		SkinnedModelRenderer renderer =
			controller?.Renderer;

		if (
			renderer is null ||
			!renderer.IsValid ||
			renderer.Model is null ||
			!renderer.Model.IsValid
		)
		{
			Log.Warning(
				"[OMR HITBOX] Player body renderer/model is unavailable; combat hitboxes could not be built."
			);
			return;
		}

		int authoredHitboxCount =
			renderer.Model.HitboxSet?.All?.Count ?? 0;

		if ( authoredHitboxCount <= 0 )
		{
			Log.Warning(
				$"[OMR HITBOX] Model '{renderer.Model.Name}' exposes no authored hitboxes; firearm traces would pass through once movement colliders are filtered."
			);
			return;
		}

		ModelHitboxes hitboxes =
			Components.GetOrCreate<ModelHitboxes>();

		hitboxes.Renderer =
			renderer;

		hitboxes.Target =
			GameObject;

		hitboxes.Rebuild();
	}

	protected override void OnUpdate()
	{
		if ( !Networking.IsHost )
		{
			// [Change] is the normal edge trigger. Polling the same sequence here is
			// an intentional safety net: even if a hotload/codegen edge ever misses
			// the callback, the replicated value itself still drives presentation.
			if (
				!IsProxy &&
				DamageFeedbackSequence != _lastConsumedDamageFeedbackSequence &&
				_pendingDamageFeedbackSequence < 0
			)
			{
				_pendingDamageFeedbackSequence =
					DamageFeedbackSequence;
			}

			ConsumePendingDamageFeedback();
			return;
		}

		// Gameplay tuning lives in a resource specifically so it can be changed
		// during development without hot-reloading the network player prefab.
		// Preserve health percentage if MaxHealth is edited while a player exists.
		float configuredMaxHealth =
			MathF.Max(
				Tuning?.MaxHealth ?? 180f,
				1f
			);

		if ( MathF.Abs( configuredMaxHealth - MaxHealth ) < 0.01f )
			return;

		float healthFraction =
			MaxHealth > 0f
				? MathX.Clamp( Health / MaxHealth, 0f, 1f )
				: 1f;

		MaxHealth =
			configuredMaxHealth;

		if ( !IsDead )
		{
			Health =
				MaxHealth * healthFraction;
		}
	}

	public void ResetHealth()
	{
		if ( !Networking.IsHost )
			return;

		MaxHealth =
			MathF.Max(
				Tuning?.MaxHealth ?? 180f,
				1f
			);

		Health =
			MaxHealth;

		WeaponOneShotMode =
			false;

		WeaponHeadshotOnlyMode =
			false;

		DamageFeedbackAmount = 0f;
		DamageFeedbackKilled = false;
		DamageFeedbackSourcePosition = Vector3.Zero;
		DamageFeedbackHasDirectionalSource = false;

		PlayerState state =
			Components.Get<PlayerState>();

		if ( state is not null )
		{
			state.IsAlive =
				true;
		}
	}

	/// <summary>
	/// Host-authoritative elimination used by round rules whose winner is
	/// resolved outside ordinary damage, such as High Noon reaction timing.
	/// </summary>
	public void EliminateForRoundRule()
	{
		if (
			!Networking.IsHost ||
			IsDead
		)
			return;

		Health = 0f;

		PlayerState state =
			Components.Get<PlayerState>();

		if ( state is not null )
		{
			state.IsAlive = false;
		}
	}

	/// <summary>
	/// Enables or disables the weapon-only sudden death rule.
	/// </summary>
	public void SetWeaponOneShotMode(
		bool enabled
	)
	{
		if ( !Networking.IsHost )
			return;

		WeaponOneShotMode =
			enabled;
	}

	/// <summary>
	/// Enables/disables the modular Headshot Only damage gate for this player.
	/// </summary>
	public void SetWeaponHeadshotOnlyMode(
		bool enabled
	)
	{
		if ( !Networking.IsHost )
			return;

		WeaponHeadshotOnlyMode =
			enabled;
	}

	void Component.IDamageable.OnDamage(
		in DamageInfo damage
	)
	{
		if ( !Networking.IsHost )
			return;

		if ( IsDead )
			return;

		GameFlowManager flow =
			Scene?
				.GetAllComponents<GameFlowManager>()
				.FirstOrDefault();

		if (
			flow is not null &&
			flow.OnlineState ==
				OnlineFlowState.Lobby
		)
			return;

		float appliedDamage =
			damage.Damage;

		bool isWeaponHit =
			damage.Weapon is not null &&
			damage.Weapon.Components
				.Get<BaseCombatWeapon>() is not null;

		// Prefer the engine-provided hitgroup tag. Client shot claims intentionally
		// carry hitgroups as damage tags (for example "head") rather than a Hitbox
		// object. Normal OMR firearm traces now exclude the PlayerController movement
		// colliders and resolve against model hitboxes; the height fallback remains a
		// conservative compatibility path for any other weapon/damage path that does
		// not provide a hitgroup tag.
		bool taggedHeadshot;
		bool heightFallbackHeadshot;
		float normalizedHitHeight;

		bool isHeadshot =
			ResolveHeadshot(
				damage,
				isWeaponHit,
				out taggedHeadshot,
				out heightFallbackHeadshot,
				out normalizedHitHeight
			);

		if (
			WeaponHeadshotOnlyMode &&
			isWeaponHit &&
			!isHeadshot
		)
		{
			if ( Tuning?.LogDamageResolution == true )
			{
				Log.Info(
					$"[OMR DAMAGE] {GameObject.Name} | HEADSHOT ONLY IGNORED | Tags {damage.Tags} | Origin {damage.Origin} | Hit {damage.Position}"
				);
			}

			return;
		}

		if ( isHeadshot )
		{
			appliedDamage *=
				MathF.Max(
					Tuning?.HeadshotDamageMultiplier ?? 1.5f,
					1f
				);
		}

		if (
			WeaponOneShotMode &&
			isWeaponHit
		)
		{
			appliedDamage =
				MathF.Max(
					MaxHealth,
					Health
				);
		}

		Health =
			MathF.Max(
				0f,
				Health - appliedDamage
			);

		bool killed =
			Health <= 0f;

		NotifyCombatFeedback(
			damage,
			appliedDamage,
			killed,
			isHeadshot
		);

		if ( Tuning?.LogDamageResolution == true )
		{
			Log.Info(
				$"[OMR DAMAGE] {GameObject.Name} | Damage {appliedDamage:0.##} | Health {Health:0.##}/{MaxHealth:0.##} | Weapon {isWeaponHit} | Head {isHeadshot} | TaggedHead {taggedHeadshot} | HeightHead {heightFallbackHeadshot} | HitHeight {normalizedHitHeight:0.###} | Tags {damage.Tags} | Origin {damage.Origin} | Hit {damage.Position}" +
				(WeaponOneShotMode && isWeaponHit ? " | SUDDEN DEATH" : "")
			);
		}

		if ( killed )
		{
			Die(
				damage
			);
		}
	}

	/// <summary>
	/// Resolves headshots without making OMR dependent on one specific trace
	/// implementation.
	///
	/// The engine's preferred path is the "head" damage tag generated from a
	/// model hitgroup. OMR firearm traces are configured to use those model
	/// hitboxes instead of locomotion colliders. A conservative vertical fallback
	/// remains for compatibility with non-standard damage paths that omit hitgroup
	/// tags.
	/// </summary>
	private bool ResolveHeadshot(
		in DamageInfo damage,
		bool isWeaponHit,
		out bool taggedHeadshot,
		out bool heightFallbackHeadshot,
		out float normalizedHitHeight
	)
	{
		taggedHeadshot =
			isWeaponHit &&
			damage.Tags is not null &&
			damage.Tags.Contains( "head" );

		heightFallbackHeadshot = false;
		normalizedHitHeight = -1f;

		if ( !isWeaponHit )
			return false;

		if ( taggedHeadshot )
			return true;

		if ( Tuning?.EnableHeadshotHeightFallback == false )
			return false;

		PlayerController controller =
			Components.Get<PlayerController>();

		if ( controller is null )
			return false;

		// Use the same standing/ducking dimensions already configured on our
		// PlayerController instead of depending on a newer convenience property.
		// This keeps the fallback compatible with the engine API version this
		// project is actually compiling against.
		float currentHeight =
			MathF.Max(
				controller.IsDucking
					? controller.DuckedHeight
					: controller.BodyHeight,
				1f
			);

		float verticalHitOffset =
			damage.Position.z -
			controller.GameObject.WorldPosition.z;

		normalizedHitHeight =
			verticalHitOffset / currentHeight;

		float threshold =
			MathX.Clamp(
				Tuning?.HeadshotHeightFraction ?? 0.78f,
				0.60f,
				0.95f
			);

		// Ignore obviously invalid positions. The small allowance above 1.0 keeps
		// the fallback tolerant of hit-radius/contact-point differences at the top
		// of the controller without letting arbitrary world damage become a headshot.
		heightFallbackHeadshot =
			normalizedHitHeight >= threshold &&
			normalizedHitHeight <= 1.20f;

		return heightFallbackHeadshot;
	}

	private void PublishVictimDamageFeedback(
		PlayerCombatFeedback feedback,
		float damage,
		bool killed,
		Vector3 sourcePosition,
		bool hasDirectionalSource
	)
	{
		if ( !Networking.IsHost )
			return;

		DamageFeedbackAmount =
			MathF.Max( 0f, damage );

		DamageFeedbackKilled =
			killed;

		DamageFeedbackSourcePosition =
			sourcePosition;

		DamageFeedbackHasDirectionalSource =
			hasDirectionalSource;

		// Sequence is deliberately written last so the receiver can treat its
		// change as the commit marker for the payload above. The client defers
		// consumption until OnUpdate, after the network update has completed.
		DamageFeedbackSequence =
			DamageFeedbackSequence == int.MaxValue
				? 1
				: DamageFeedbackSequence + 1;

		Connection owner =
			GameObject.Network.OwnerConnection;

		if ( Tuning?.LogCombatFeedbackRpc == true )
		{
			Log.Info(
				$"[OMR FEEDBACK] Publish health-sync victim feedback | Player {GameObject.Name} | Owner {(owner is null ? "<host/unowned>" : owner.DisplayName)} | Sequence {DamageFeedbackSequence} | Damage {DamageFeedbackAmount:0.##} | Kill {killed} | Directional {hasDirectionalSource}"
			);
		}

		// The host-owned player needs immediate local presentation. A remote
		// client's player is a proxy on the host, so it waits for replication.
		if (
			!IsProxy &&
			Components.Get<PlayerState>()?.IsBot != true
		)
		{
			feedback?.ReceiveLocalDamageFeedback(
				DamageFeedbackAmount,
				DamageFeedbackKilled,
				DamageFeedbackSourcePosition,
				DamageFeedbackHasDirectionalSource
			);

			_lastConsumedDamageFeedbackSequence =
				DamageFeedbackSequence;
		}
	}

	private void OnDamageFeedbackSequenceChanged(
		int oldValue,
		int newValue
	)
	{
		if ( Networking.IsHost )
			return;

		if ( IsProxy )
			return;

		if ( newValue == oldValue )
			return;

		_pendingDamageFeedbackSequence =
			newValue;

		if ( Tuning?.LogCombatFeedbackRpc == true )
		{
			Log.Info(
				$"[OMR FEEDBACK] Observe health-sync victim feedback | Player {GameObject.Name} | Sequence {newValue} | Damage {DamageFeedbackAmount:0.##} | Kill {DamageFeedbackKilled} | Directional {DamageFeedbackHasDirectionalSource}"
			);
		}
	}

	private void ConsumePendingDamageFeedback()
	{
		if ( IsProxy )
			return;

		if ( _pendingDamageFeedbackSequence < 0 )
			return;

		if (
			_pendingDamageFeedbackSequence ==
			_lastConsumedDamageFeedbackSequence
		)
		{
			_pendingDamageFeedbackSequence = -1;
			return;
		}

		// Initial snapshots or round resets may carry an old sequence with an
		// intentionally cleared payload. Never turn that into a minimum-strength
		// damage cue.
		if ( DamageFeedbackAmount <= 0f )
		{
			_lastConsumedDamageFeedbackSequence =
				_pendingDamageFeedbackSequence;

			_pendingDamageFeedbackSequence = -1;
			return;
		}

		PlayerCombatFeedback feedback =
			Components.Get<PlayerCombatFeedback>();

		if ( feedback is null )
		{
			if ( Tuning?.LogCombatFeedbackRpc == true )
			{
				Log.Warning(
					"[OMR FEEDBACK] Health-sync event reached the local player but PlayerCombatFeedback was missing."
				);
			}

			return;
		}

		feedback.ReceiveLocalDamageFeedback(
			DamageFeedbackAmount,
			DamageFeedbackKilled,
			DamageFeedbackSourcePosition,
			DamageFeedbackHasDirectionalSource
		);

		_lastConsumedDamageFeedbackSequence =
			_pendingDamageFeedbackSequence;

		_pendingDamageFeedbackSequence =
			-1;

		if ( Tuning?.LogCombatFeedbackRpc == true )
		{
			Log.Info(
				$"[OMR FEEDBACK] Consume health-sync victim feedback | Player {GameObject.Name} | Sequence {_lastConsumedDamageFeedbackSequence} | Damage {DamageFeedbackAmount:0.##} | Kill {DamageFeedbackKilled} | Directional {DamageFeedbackHasDirectionalSource}"
			);
		}
	}

	private void NotifyCombatFeedback(
		in DamageInfo damage,
		float appliedDamage,
		bool killed,
		bool headshot
	)
	{
		PlayerCombatFeedback victimFeedback =
			Components.Get<PlayerCombatFeedback>();

		PublishVictimDamageFeedback(
			victimFeedback,
			appliedDamage,
			killed,
			damage.Origin,
			damage.Attacker is not null
		);

		if ( damage.Attacker is null )
			return;

		PlayerCombatFeedback attackerFeedback =
			damage.Attacker.Components
				.Get<PlayerCombatFeedback>(
					FindMode.InSelf |
					FindMode.InDescendants
				);

		attackerFeedback ??=
			damage.Attacker.Components
				.Get<PlayerCombatFeedback>(
					FindMode.InAncestors
				);

		if ( attackerFeedback is null )
			return;

		if ( attackerFeedback == victimFeedback )
			return;

		attackerFeedback.NotifyHitConfirmed(
			appliedDamage,
			killed,
			headshot
		);
	}

	private void Die(
		in DamageInfo damage
	)
	{
		PlayerState state =
			Components.Get<PlayerState>();

		if ( state is not null )
		{
			state.IsAlive =
				false;
		}
	}
}