Npcs/Combat/CombatNpc.cs
using Sandbox.Npcs.Schedules;

namespace Sandbox.Npcs.CombatNpc;

/// <summary>
/// A combat NPC that searches for players, advances on them, fires in bursts, and repositions.
/// When friendly, follows players and engages hostile NPCs instead.
/// </summary>
public class CombatNpc : Npc, Component.IDamageable
{
	private static readonly string[] PainLines =
	{
		"Argh!",
		"They got me!",
		"I'm hit!",
		"Taking fire!",
		"Ugh!",
	};

	private static readonly string[] DeathLines =
	{
		"Tell them... I fought...",
		"Not like this...",
		"I can't...",
	};

	/// <summary>
	/// When true, this NPC is friendly to players and will follow them, engaging hostile NPCs.
	/// When false, this NPC targets players and friendly NPCs.
	/// </summary>
	[Property, ClientEditable, Sync]
	public bool Friendly { get; set; } = false;

	[Property, ClientEditable, Range( 1, 250 ), Sync]
	public float Health { get; set; } = 100f;

	/// <summary>
	/// The weapon this NPC uses to attack.
	/// </summary>
	[Property]
	public BaseWeapon Weapon { get; set; }

	[Property, Group( "Balance" ), Range( 512, 4096 ), Step( 1 ), ClientEditable, Sync]
	public float AttackRange { get; set; } = 1024f;

	[Property, Group( "Balance" ), Range( 90, 250f ), Step( 1 ), ClientEditable, Sync]
	public float EngageSpeed { get; set; } = 180f;

	/// <summary>
	/// How long after losing sight of a player to keep searching their last known position.
	/// </summary>
	[Property, Group( "Balance" )]
	public float SearchTimeout { get; set; } = 8f;

	[Property, Group( "Balance" )]
	public float PatrolRadius { get; set; } = 400f;

	[Property, Group( "Balance" )]
	public float BurstDuration { get; set; } = 1.5f;

	[Property, Group( "Balance" )]
	public float BurstPause { get; set; } = 0.8f;

	/// <summary>
	/// How far a friendly NPC will follow a player before stopping.
	/// </summary>
	[Property, Group( "Balance" )]
	public float FollowDistance { get; set; } = 150f;

	/// <summary>
	/// When true, the weapon never dry-fires from running out of ammo. NPCs have no ammo
	/// inventory to seed a reserve pool, so without this a BaseWeapon's TakeAmmo() fails and
	/// the NPC silently never shoots. Leave ON for cops; control rate-of-fire via the weapon's
	/// FireRate and the engage schedule's BurstDuration / BurstPause instead.
	/// </summary>
	[Property, Group( "Balance" )]
	public bool InfiniteAmmo { get; set; } = true;

	/// <summary>
	/// A specific target this NPC prioritises hunting (e.g. the wanted criminal set by
	/// <c>PoliceDispatcher</c>). If visible it is engaged ahead of nearer hostiles; the NPC
	/// still engages anyone else it encounters. Set at runtime, not in the inspector.
	/// </summary>
	public GameObject PriorityTarget { get; set; }

	/// <summary>
	/// When true, an out-of-sight <see cref="PriorityTarget"/>'s live position is fed in as
	/// fresh "last known" intel each tick, so the NPC actively closes on the suspect
	/// ("dispatch radioing the location"). Turn off for a less omniscient hunt.
	/// </summary>
	[Property, Group( "Balance" )]
	public bool DispatchTracksPriority { get; set; } = true;

	private Vector3? _lastKnownPosition;
	private TimeSince _timeSinceLastSeen;

	protected override void OnStart()
	{
		base.OnStart();

		if ( !IsProxy )
		{
			Senses.ScanTags = new TagSet { "player", "friendly_npc", "hostile_npc", "culprit" };

			if ( Friendly )
			{
				GameObject.Tags.Add( "friendly_npc" );
				Senses.TargetTags = new TagSet { "hostile_npc" };
			}
			else
			{
				GameObject.Tags.Add( "hostile_npc" );
				// "culprit" lets a non-player suspect (e.g. the rhino) become a valid target too.
				Senses.TargetTags = new TagSet { "player", "friendly_npc", "culprit" };
			}
		}

		if ( Weapon.IsValid() && Renderer.IsValid() )
		{
			Weapon.CreateWorldModel( Renderer );

			// NPCs have no ammo inventory, so stop the weapon gating on an empty reserve pool.
			if ( !IsProxy && InfiniteAmmo )
				Weapon.UsesAmmo = false;

			if ( !IsProxy )
				Animation.SetHoldType( Weapon.HoldType );
		}
	}

	public override ScheduleBase GetSchedule()
	{
		var visible = GetPreferredTarget();

		if ( visible.IsValid() )
		{
			_lastKnownPosition = visible.WorldPosition;
			_timeSinceLastSeen = 0;

			var engage = GetSchedule<CombatEngageSchedule>();
			engage.Target = visible;
			engage.Weapon = Weapon;
			engage.AttackRange = AttackRange;
			engage.EngageSpeed = EngageSpeed;
			engage.BurstDuration = BurstDuration;
			engage.BurstPause = BurstPause;
			return engage;
		}

		// Out of sight, but dispatch has flagged a suspect: treat their live position as fresh
		// intel so we keep closing on them rather than giving up at SearchTimeout.
		if ( PriorityTarget.IsValid() && DispatchTracksPriority )
		{
			_lastKnownPosition = PriorityTarget.WorldPosition;
			_timeSinceLastSeen = 0;
		}

		// Search last known position if recent enough
		if ( _lastKnownPosition.HasValue && _timeSinceLastSeen < SearchTimeout )
		{
			var search = GetSchedule<ScientistSearchSchedule>();
			search.Target = _lastKnownPosition.Value;
			return search;
		}

		// Friendly NPCs follow the nearest player when idle
		if ( Friendly )
		{
			var follow = GetSchedule<CombatFollowSchedule>();
			follow.FollowDistance = FollowDistance;
			return follow;
		}

		// No intel — patrol
		var patrol = GetSchedule<CombatPatrolSchedule>();
		patrol.PatrolRadius = PatrolRadius;
		return patrol;
	}

	/// <summary>
	/// Pick who to engage: a visible target flagged "culprit" (the wanted suspect) takes
	/// priority, otherwise the nearest visible hostile. Keeps cops focused on the suspect
	/// while still defending themselves against anyone else in view.
	/// </summary>
	private GameObject GetPreferredTarget()
	{
		GameObject nearestCulprit = null;
		float bestDist = float.MaxValue;

		foreach ( var t in Senses.VisibleTargets )
		{
			if ( !t.IsValid() || !t.Tags.Has( "culprit" ) ) continue;

			var d = WorldPosition.Distance( t.WorldPosition );
			if ( d < bestDist )
			{
				bestDist = d;
				nearestCulprit = t;
			}
		}

		return nearestCulprit.IsValid() ? nearestCulprit : Senses.GetNearestVisible();
	}

	void IDamageable.OnDamage( in DamageInfo damage )
	{
		if ( IsProxy )
			return;

		Health -= damage.Damage;

		// If we can hear the attacker, treat their position as the last known location
		if ( damage.Attacker.IsValid() )
		{
			var dist = WorldPosition.Distance( damage.Attacker.WorldPosition );
			if ( dist <= Senses.HearingRange )
			{
				_lastKnownPosition = damage.Attacker.WorldPosition;
				_timeSinceLastSeen = 0;
			}
		}

		if ( Health < 1f )
		{
			if ( Speech.CanSpeak )
				Speech.Say( Game.Random.FromArray( DeathLines ), 2f );

			Die( damage );
			return;
		}

		if ( Speech.CanSpeak && Game.Random.Float() < 0.5f )
			Speech.Say( Game.Random.FromArray( PainLines ), 1.5f );

		// Interrupt current schedule so we react immediately
		EndCurrentSchedule();
	}
}