ArenaOpponent.Perception.cs
using Sandbox;
using System;

namespace PaintballBaddies;

public sealed partial class ArenaOpponent
{
    // View-cone/line-of-sight approach adapted from Facepunch's MIT NPC senses.
    // Pinned originals and license: source-assets/library-code/facepunch-npc/.
    private float targetCommitment, timeSinceVisual=100,shotLaneRetry,blockedLaneTime;
    private Vector3 observedVelocity, previousVisualPosition, observedAimPoint;
    private readonly List<Vector3> visibleThreats=new();
    public int TargetChanges { get; private set; }
    public int HeardShots { get; private set; }
    public int BlockedShotsAvoided { get; private set; }
    public int HumanTargetChoices {get;private set;}
    public int BotTargetChoices {get;private set;}
    public string TargetName => target?.GameObject.Name;
    public Vector3 CombatAimPoint => SeesPlayer ? observedAimPoint : LastKnownPlayerPosition+Vector3.Up*40;
    internal IReadOnlyList<Vector3> VisibleThreats => visibleThreats;

    internal bool CanObserve(PaintballCombatant candidate,out Vector3 aim)
    {
        aim=default;
        if(!candidate.IsValid() || !candidate.GameObject.Active || !candidate.AcceptHits || candidate.Eliminated)return false;
        var delta=candidate.WorldPosition-WorldPosition;
        // Nearby opponents are noticeable even just outside the forward cone,
        // but still require an unobstructed view.
        if(delta.Length>55 && !InSightCone(WorldPosition,WorldRotation.Forward,candidate.WorldPosition))return false;
        var eye=WorldPosition+Vector3.Up*(CoverCrouched ? 35 : 60);
        var head=candidate.EyePosition;
        var chest=candidate.WorldPosition+(head-candidate.WorldPosition)*.66f;
        foreach(var point in new[]{chest,head})
        {
            var sight=Scene.Trace.Ray(eye,point).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
            if(!sight.Hit || PaintballCombatant.Find(sight.GameObject)==candidate){aim=point;return true;}
        }
        return false;
    }

    private void ChangeTarget(PaintballCombatant next)
    {
        if(next==target)return;
        target=next;TargetChanges++;visibleTime=0;SeesPlayer=false;
        observedVelocity=Vector3.Zero;timeSinceVisual=100;targetCommitment=1.4f;blockedLaneTime=0;
    }

    private void ObserveTarget()
    {
        bool seen=CanObserve(target,out var point);
        if(seen)
        {
            // Estimate only from consecutive observations, never hidden velocity.
            var sample=timeSinceVisual<.65f ? (target.WorldPosition-previousVisualPosition)/MathF.Max(timeSinceVisual,.05f) : Vector3.Zero;
            if(sample.Length>260)sample=Vector3.Zero; // teleport/discontinuity
            observedVelocity=Vector3.Lerp(observedVelocity,sample,.45f);
            previousVisualPosition=target.WorldPosition;timeSinceVisual=0;
            observedAimPoint=point;LastKnownPlayerPosition=target.WorldPosition;
            lostSight=0;hasSeenPlayer=true;searchRemaining=0;
        }
        else { lostSight+=BotCombatTuning.DecisionSeconds;visibleTime=0;observedVelocity=Vector3.Zero; }
        SeesPlayer=seen;
    }

    private void SelectContestTarget()
    {
        PaintballCombatant best=null,bestHuman=null,bestBot=null;
        float bestCost=float.MaxValue,currentCost=float.MaxValue,humanCost=float.MaxValue,botCost=float.MaxValue;
        visibleThreats.Clear();
        foreach(var candidate in Scene.GetAllComponents<PaintballCombatant>())
        {
            if(candidate==combatant || candidate.Team==combatant.Team || !CanObserve(candidate,out _))continue;
            visibleThreats.Add(candidate.WorldPosition);
            float cost=(candidate.WorldPosition-WorldPosition).Length;
            if(candidate==target)cost*=.7f;
            if(combatant.HitFeedbackRemaining>0 && candidate==combatant.LastAttacker)cost*=.55f;
            if(candidate.Components.Get<CloseCombat>()?.Incapacitated==true)cost*=1.5f;
            if(candidate==target)currentCost=cost;
            if(cost<bestCost){best=candidate;bestCost=cost;}
            if(candidate.Components.Get<PlayerController>().IsValid())
            {if(cost<humanCost){bestHuman=candidate;humanCost=cost;}}
            else if(cost<botCost){bestBot=candidate;botCost=cost;}
        }
        // Commitment prevents jitter between similarly placed opponents. A
        // substantially closer visible danger can still interrupt immediately.
        // Finish moving to chosen shelter unless a newly visible opponent is
        // within elbow/close marker range. Actual hits interrupt separately.
        if(HasCoverPlan && !holdingCover && !CoverPeeking && best.IsValid() && (best.WorldPosition-WorldPosition).Length>100)return;
        if(!best.IsValid())return;
        bool urgent=bestCost<currentCost*.6f && currentCost<float.MaxValue;
        if(urgent){ChangeTarget(best);return;}
        if(targetCommitment>0 && !(currentCost==float.MaxValue && lostSight>.8f))return;
        // Pick the class first, so five bots do not dilute one human's share.
        // Roll only at a commitment boundary, never every decision or shot.
        bool human=BotCombatTuning.PreferHuman(bestHuman.IsValid(),bestBot.IsValid(),Game.Random.Float(0,1));
        var selected=human ? bestHuman : bestBot;
        ChangeTarget(selected);targetCommitment=Game.Random.Float(1.8f,2.4f);
        if(human)HumanTargetChoices++;else BotTargetChoices++;
    }

    internal void HearMarker(GameObject shooter,Vector3 origin)
    {
        if(!CombatEnabled || !combatant.IsValid() || combatant.Eliminated || timeSinceVisual<1.2f
            || EvadingAfterHit || HasCoverPlan || Components.Get<CloseCombat>()?.Incapacitated==true)return;
        var source=PaintballCombatant.Find(shooter);
        if(!source.IsValid() || source==combatant || source.Team==combatant.Team || (origin-WorldPosition).Length>650)return;
        var soundPath=Scene.Trace.Ray(origin,WorldPosition+Vector3.Up*45)
            .IgnoreGameObjectHierarchy(GameObject).IgnoreGameObjectHierarchy(shooter).WithoutTags("paintball_debris").Run();
        if(soundPath.Hit && (origin-WorldPosition).Length>340)return;
        if(impactLookTime>0)return;
        HeardShots++;ChangeTarget(source);
        // A sound gives an approximate launch location, not live tracking.
        LastKnownPlayerPosition=new Vector3(MathF.Round(origin.x/70)*70,MathF.Round(origin.y/70)*70,WorldPosition.z);
        hasSeenPlayer=true;lostSight=1.5f;
        impactLookDirection=(LastKnownPlayerPosition-WorldPosition).WithZ(0).Normal;
        impactLookTime=.6f;decisionTime=0;agent.Stop();
    }

    internal static void ReportMarkerSound(Scene scene,GameObject shooter,Vector3 origin)
    {
        foreach(var opponent in scene.GetAllComponents<ArenaOpponent>())
            if(opponent.Enabled && opponent.GameObject.Active)opponent.HearMarker(shooter,origin);
    }
}