ArenaOpponent.cs
using Sandbox;
using System;

namespace PaintballBaddies;

/// <summary>Navigation and perception layer for an arena opponent.</summary>
public sealed partial class ArenaOpponent : Component
{
    [Property] public string Character { get; set; } = "imani";
    [Property] public bool CombatEnabled { get; set; }
    public bool SeesPlayer { get; private set; }
    public float DistanceTravelled { get; private set; }
    public int ObstructedMoves { get; private set; }
    public Vector3 LastKnownPlayerPosition { get; private set; }
    private NavMeshAgent agent;
    private SkinnedModelRenderer body;
    public SkinnedModelRenderer Body => body;
    public int CharacterIndex { get; private set; }
    private PaintballCombatant target;
    public bool ContestMode { get; set; }
    private Vector3 previous;
    private float decisionTime;
    private float lostSight;
    private int patrol;
    private Vector3[] patrolPoints;
    private PaintballCombatant combatant;
    private GameObject marker;
    public GameObject PresentationAnchor { get; set; }
    private float visibleTime;
    private float coveredAimPitch;
    private ReloadPresentationTrack reloadPresentation;
    private string presentationModel;
    private int shots;
    private bool reloadAudioActive;
    public bool UsingCover { get; private set; }
    public bool CoverPeeking { get; private set; }
    public bool CoverIsLow { get; private set; } = true;
    public bool CoverCrouched => ReviewCrouch || (UsingCover && CoverIsLow);
    public int CoverBursts { get; private set; }
    public int ShotsFromCover { get; private set; }
    public int ShotsWhileSheltered { get; private set; }
    private bool holdingCover;
    private float heldCoverTime;
    public bool HasCoverPlan => seekingCover && combatant?.Eliminated != true;
    public Vector3 CoverDestination { get; private set; }
    public CoverSurface ReservedCover { get; private set; }
    public int InvalidatedCoverPlans { get; private set; }
    public int CoverSelections { get; private set; }
    public int SpacingMoves { get; private set; }
    public int FlankSelections { get; private set; }
    public bool Flanking => flankRemaining>0;
    private Vector3 flankDestination;
    private float flankRemaining,flankCooldown;
    private bool seekingCover,hasSeenPlayer;
    private float coverTime,coverRest,cooldownCover;
    private int observedHits;
    private int reactedHits;
    private float evadeAfterHit;
    public int HitCoverReactions { get; private set; }
    public bool EvadingAfterHit => evadeAfterHit>0;
    private float impactLookTime;
    private Vector3 impactLookDirection;
    private float eliminatedTime;
    private bool hasOutSignal;
    private string outSignalClip="out_signal";
    internal bool ReviewCrouch { get; set; }
    public bool ShowingOutSignal => combatant?.Eliminated == true && hasOutSignal && eliminatedTime < 1.5f;
    public int ShotsFired => shots;
    public int ShotsAtPlayers { get; private set; }
    public string ShotBlockReason { get; private set; }
    public float MovementSpeed { get; private set; }
    public Vector3 NavigationVelocity => IsProxy && Components.Get<NetworkBot>() is {} net ? net.Velocity : agent.IsValid() && agent.UpdatePosition
        ? agent.Velocity : Vector3.Zero;
    public Vector3 NavigationWishVelocity => IsProxy && Components.Get<NetworkBot>() is {} net ? net.Velocity : agent.IsValid() && agent.UpdatePosition
        ? agent.WishVelocity : Vector3.Zero;
    private float locomotionSpeed;
    public float MovementFacingDot { get; private set; } = 1;
    public float MinimumShotAlignment { get; private set; } = 1;
    public OpponentMagazine Magazine { get; } = new();
    // Search both flanks and ends of the expanded arena without reading an unseen target.
    internal static Vector3[] CreatePatrolRoute() => new Vector3[] {
        new(-900,-540,0), new(-450,-520,0), new(450,-520,0), new(900,-540,0),
        new(900,540,0), new(650,1100,0), new(650,1400,0),
        new(-650,1400,0), new(-650,1100,0),
        new(450,520,0), new(-450,520,0), new(-900,540,0)
    };

    protected override void OnStart()
    {
        target = Scene.GetAllComponents<PlayerController>().FirstOrDefault()?.Components.Get<PaintballCombatant>();
        agent = Components.Get<NavMeshAgent>() ?? Components.Create<NavMeshAgent>();
        agent.Height = 67;
        agent.Radius = 17;
        agent.MaxSpeed = 166;
        agent.Acceleration = 600;
        agent.UpdatePosition = !IsProxy;
        agent.UpdateRotation = false;
        body = Components.Create<SkinnedModelRenderer>();
        CharacterIndex = Array.FindIndex(RosterSelection.Names, name => string.Equals(name, Character, StringComparison.OrdinalIgnoreCase));
        if (CharacterIndex < 0) CharacterIndex = 2;
        body.Model = Model.Load( RosterSelection.GetModelPath(CharacterIndex) );
        hasOutSignal = body.Model.AnimationNames.Contains( "out_signal" );
        Components.Create<RosterHelmet>();
        Components.Create<MovementFootsteps>();
        body.UseAnimGraph = false;
        body.Sequence.Name = "idle";
        body.Sequence.Looping = true;
        body.Sequence.Blending = true;
        previous = WorldPosition;
        if ( CombatEnabled )
        {
            combatant = Components.Get<PaintballCombatant>() ?? Components.Create<PaintballCombatant>();
            if(MultiplayerSession.Authority)
            {
                combatant.Team = MultiplayerSession.Online ? CharacterIndex+10 : ContestMode ? CharacterIndex+1 : 1;
                combatant.StayInMatch=ContestMode;
            }
            Magazine.UnlimitedAmmo=ContestMode || MultiplayerSession.Online;
            var collider = Components.Create<BoxCollider>();
            collider.Scale = new Vector3( 28, 28, 66 );
            collider.Center = new Vector3( 0, 0, 33 );
            collider.Static = false;
            marker = new GameObject( GameObject, true, "Opponent VX-9" ){NetworkMode=NetworkMode.Never};
            marker.Components.Create<ModelRenderer>().Model = Model.Load( "models/weapons/vx9_full_frame/vx9_full_frame.vmdl" );
        }
        patrolPoints = CreatePatrolRoute();
        patrol = (CharacterIndex * 3) % patrolPoints.Length;
        LastKnownPlayerPosition = patrolPoints[patrol];
        decisionTime=CharacterIndex*BotCombatTuning.DecisionSeconds/6;
    }

    protected override void OnUpdate()
    {
        if(IsProxy)return;
        // Review/practice actors may acquire combat after their presentation starts.
        if(!combatant.IsValid())combatant=Components.Get<PaintballCombatant>();
        // Match setup may create the player's combatant after this component starts.
        if(!target.IsValid())
            target=Scene.GetAllComponents<PlayerController>().FirstOrDefault()?.Components.Get<PaintballCombatant>();
        if ( !target.IsValid() || !agent.IsValid() || !body.IsValid() ) return;
        if(Components.Get<CloseCombat>() is {} contact && (contact.Incapacitated || contact.Attacking))
        {
            agent.Stop();MovementSpeed=0;previous=WorldPosition;return;
        }
        var markerVisual=marker?.Components.Get<ModelRenderer>();
        if(markerVisual.IsValid()) markerVisual.Enabled=!PresentationAnchor.IsValid();
        Magazine.ReloadDuration = CoveredRosterAssets.ReloadTrack( body.Model?.Name ) is not null ? 2.4f : 2.2f;
        if (combatant?.Eliminated != true && Magazine.ReloadRemaining > 0 && !reloadAudioActive)
        {
            Sound.Play("sounds/paintball/reload_start.sound",WorldPosition);
            reloadAudioActive=true;
        }
        if (combatant?.Eliminated == true)
        {
            Magazine.CancelReload();
            reloadAudioActive = false;
        }
        else Magazine.Advance( Time.Delta );
        if (reloadAudioActive && Magazine.ReloadRemaining <= 0)
        {
            if(combatant?.Eliminated != true) Sound.Play("sounds/paintball/reload_ready.sound",WorldPosition);
            reloadAudioActive=false;
        }
        if(agent.IsValid() && agent.MaxSpeed>0)agent.MaxSpeed=166;
        cooldownCover=MathF.Max(0,cooldownCover-Time.Delta);
        flankRemaining=MathF.Max(0,flankRemaining-Time.Delta);flankCooldown=MathF.Max(0,flankCooldown-Time.Delta);
        impactLookTime=MathF.Max(0,impactLookTime-Time.Delta);
        evadeAfterHit=MathF.Max(0,evadeAfterHit-Time.Delta);
        AdvanceCoverAwareness(Time.Delta);
        targetCommitment=MathF.Max(0,targetCommitment-Time.Delta);
        timeSinceVisual+=Time.Delta;
        shotLaneRetry=MathF.Max(0,shotLaneRetry-Time.Delta);
        AdvanceNavigationRecovery(Time.Delta);
        if(combatant is not null && combatant.PaintHits>reactedHits)
        {
            reactedHits=combatant.PaintHits;
            // React to the shot's observed origin immediately, including a hit
            // from behind. Do not wait for the next sight-cone decision.
            if(combatant.LastAttacker.IsValid())ChangeTarget(combatant.LastAttacker);
            LastKnownPlayerPosition=combatant.LastHitOrigin.WithZ(WorldPosition.z);
            hasSeenPlayer=true;lostSight=0;decisionTime=0;cooldownCover=0;
            evadeAfterHit=3;flankRemaining=0;coverRest=0;impactLookTime=0;
            HitCoverReactions++;
            if(seekingCover && !OpponentCoverPlanner.IsSheltered(GameObject,target.GameObject,LastKnownPlayerPosition,CoverDestination))
                ReleaseTacticalCover();
            cooldownCover=0;
        }
        if(seekingCover)coverTime+=Time.Delta;
        if(holdingCover){coverRest+=Time.Delta;heldCoverTime+=Time.Delta;}
        if ( combatant?.Eliminated == true )
        {
            agent.Stop();
            MovementSpeed = 0;
            if(eliminatedTime==0 && body.Sequence.Name=="crouch" && body.Model.AnimationNames.Contains("out_signal_crouch"))outSignalClip="out_signal_crouch";
            eliminatedTime += Time.Delta;
            body.Enabled = ShowingOutSignal && !PresentationAnchor.IsValid();
            if ( ShowingOutSignal && !PresentationAnchor.IsValid() )
            {
                body.ClearPhysicsBones();
                if ( body.Sequence.Name != outSignalClip ) body.Sequence.Name = outSignalClip;
                body.Sequence.Blending = false;
                body.Sequence.Looping = false; body.PlaybackRate = 0;
                body.Sequence.TimeNormalized = (eliminatedTime / 1.5f).Clamp( 0, 1 );
            }
            if ( marker.IsValid() ) marker.Enabled = false;
            var eliminatedCollider = Components.Get<BoxCollider>();
            if ( eliminatedCollider.IsValid() ) eliminatedCollider.Enabled = false;
            return;
        }
        if (ReviewCrouch)
        {
            agent.Stop();
            // Citizen presentation reads CoverCrouched through its native graph.
            // Only sample the legacy renderer when it actually has this clip.
            if (!PresentationAnchor.IsValid() && body.Model.AnimationNames.Contains("crouch"))
            {
                body.ClearPhysicsBones();
                body.Sequence.Name="crouch";
                body.Sequence.Blending=false;
                body.PlaybackRate=0;
                body.Sequence.TimeNormalized=.5f;
            }
            return;
        }
        if ( !PresentationAnchor.IsValid() && marker is not null && body.TryGetBoneTransform( "RightHand", out var hand ) )
        {
            marker.WorldPosition = hand.Position;
            if ( CoveredRosterAssets.TryMarkerOffset( body.Model?.Name, out var offset ) )
                marker.WorldPosition += body.WorldRotation * offset;
            marker.WorldRotation = Rotation.LookAt( CombatAimPoint - marker.WorldPosition );
            var targetPitch = Magazine.ReloadRemaining > 0 ? 0 : marker.WorldRotation.Angles().pitch;
            coveredAimPitch += (targetPitch.Clamp( -45, 45 ) - coveredAimPitch) * (1 - MathF.Exp( -12 * Time.Delta ));
            UpdateCoveredMarkerPose( false );
        }
        var delta = WorldPosition - previous;
        if ( delta.Length > .1f )
        {
            var clearance = Scene.Trace.Sphere( 12, previous + Vector3.Up * 30, WorldPosition + Vector3.Up * 30 )
                .IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "paintball_debris" ).Run();
            if ( clearance.Hit ) ObstructedMoves++;
        }
        DistanceTravelled += delta.Length;
        previous = WorldPosition;
        var speed = delta.WithZ( 0 ).Length / MathF.Max( Time.Delta, 0.001f );
        MovementSpeed = speed;
        // Forward locomotion follows the path; turn to engage once movement stops.
        var travel=NavigationVelocity.WithZ(0);
        var facing = travel.Length>20 ? travel : SeesPlayer ? (LastKnownPlayerPosition - WorldPosition).WithZ(0) : Vector3.Zero;
        // Citizen supports directional locomotion: back away or sidestep while
        // watching the recently observed threat, rather than turning our back
        // and immediately losing it from the sight cone.
        var rememberedThreat=(LastKnownPlayerPosition-WorldPosition).WithZ(0);
        // Citizen already supplies strafing and backwards locomotion. Keep a
        // visible opponent in front while crossing a firing lane; facing only
        // the path made us turn away and repeatedly reset visual acquisition.
        if(PresentationAnchor.IsValid() && CombatEnabled && SeesPlayer && rememberedThreat.Length<850
            && !RelocatingCover)
            facing=rememberedThreat;
        if(travel.Length<=20 && PresentationAnchor.IsValid() && CombatEnabled && hasSeenPlayer && lostSight<2
            && !seekingCover && rememberedThreat.Length<350)
            facing=rememberedThreat;
        if(holdingCover && travel.Length<=20 && rememberedThreat.Length>.5f)facing=rememberedThreat;
        if(speed<=5 && hasSeenPlayer && lostSight<2 && rememberedThreat.Length>.5f)facing=rememberedThreat;
        if(impactLookTime>0 && !SeesPlayer && speed<=5)facing=impactLookDirection;
        if ( facing.Length > .01f )
        {
            var yaw=WorldRotation.Angles().yaw;
            var turningWhileMoving=travel.Length>20;
            var step=MathF.IEEERemainder(Rotation.LookAt(facing).Angles().yaw-yaw,360)*(1-MathF.Exp(-(turningWhileMoving ? 25 : 12)*Time.Delta));
            var turnLimit=(turningWhileMoving ? 720 : 360)*Time.Delta;
            WorldRotation=Rotation.FromYaw(yaw+step.Clamp(-turnLimit,turnLimit));
        }
        MovementFacingDot = speed > 5 ? Vector3.Dot(WorldRotation.Forward,delta.WithZ(0).Normal) : 1;
        locomotionSpeed += (speed-locomotionSpeed)*(1-MathF.Exp(-12*Time.Delta));
        bool moving=locomotionSpeed>(body.Sequence.Name=="idle" ? 9 : 5);
        bool running=locomotionSpeed>(body.Sequence.Name=="run" ? 90 : 115);
        string clip = CoverCrouched ? "crouch" : running ? "run" : moving ? "walk" : "idle";
        // Authored reloads layer over idle as well as moving locomotion.
        if ( !UsingCover && !moving && Magazine.ReloadRemaining > 0 && CoveredRosterAssets.ReloadTrack( body.Model?.Name ) is null ) clip = "reload_standing";
        if(combatant is not null)
        {
            var shape=Components.Get<BoxCollider>();
            if(shape.IsValid())
            {
                shape.Scale=new Vector3(28,28,CoverCrouched ? 43 : 66);
                shape.Center=new Vector3(0,0,CoverCrouched ? 21.5f : 33);
            }
        }
        if (!PresentationAnchor.IsValid())
        {
            CoveredStrideTransition.SetSequence(body, clip);
            var strideScale=RosterSelection.GetMotionScale(CharacterIndex);
            body.PlaybackRate = clip == "run" ? (locomotionSpeed / (177*strideScale)).Clamp( .65f, 1.3f ) : clip == "walk" ? (locomotionSpeed / (85*strideScale)).Clamp( .6f, 1.5f ) : 1;
            body.Sequence.Looping = clip != "reload_standing";
            if ( clip == "reload_standing" )
            {
                body.PlaybackRate = 0;
                body.Sequence.TimeNormalized = 1 - Magazine.ReloadRemaining / Magazine.ActiveReloadDuration;
            }
        }
        visibleTime=SeesPlayer ? visibleTime+Time.Delta : 0;
        TryCombatShot();
        decisionTime -= Time.Delta;
        if ( decisionTime > 0 || Scene.NavMesh.IsGenerating ) return;
        decisionTime = BotCombatTuning.DecisionSeconds;
        if(ContestMode && !EvadingAfterHit && !RelocatingCover)SelectContestTarget();
        ObserveTarget();
        if(CombatEnabled && UpdateCoverTactic())return;
        if(blockedLaneTime>1 && flankCooldown<=0 && !EvadingAfterHit && TryStartFlank())blockedLaneTime=0;
        if(EvadingAfterHit)
        {
            // No reachable shelter: keep moving across the attack direction
            // while retrying nearby cover, rather than freezing to look back.
            var away=(WorldPosition-LastKnownPlayerPosition).WithZ(0).Normal;
            var side=Vector3.Cross(Vector3.Up,away)*(CharacterIndex%2==0 ? 1 : -1);
            foreach(var direction in new[]{(side+away*.4f).Normal,(-side+away*.4f).Normal,away})
            {
                var point=Scene.NavMesh.GetClosestPoint(WorldPosition+direction*120,40);
                if(point is not Vector3 escape || (escape-WorldPosition).Length<25)continue;
                var clear=Scene.Trace.Sphere(17,WorldPosition+Vector3.Up*32,escape+Vector3.Up*32).IgnoreGameObjectHierarchy(GameObject).Run();
                if(clear.Hit)continue;
                agent.MoveTo(escape);return;
            }
        }
        if(CombatEnabled && hasSeenPlayer && lostSight>1.4f && lostSight<5 && flankCooldown<=0)TryStartFlank();
        if(Flanking)
        {
            if((WorldPosition-flankDestination).WithZ(0).Length<35 || (SeesPlayer && visibleTime>.7f))flankRemaining=0;
            else {agent.MoveTo(flankDestination);return;}
        }
        if(impactLookTime>0 && !SeesPlayer) { agent.Stop(); return; }
        // Pursue the last observed location; never track the player through cover.
        if (SearchLastContact())return;
        if ( !hasSeenPlayer || lostSight > 10 )
        {
            if ( (WorldPosition - patrolPoints[patrol]).Length < 45 ) patrol = (patrol + 1) % patrolPoints.Length;
            LastKnownPlayerPosition = patrolPoints[patrol];
        }
        var threatDistance=(LastKnownPlayerPosition-WorldPosition).WithZ(0).Length;
        if (CombatEnabled && hasSeenPlayer && lostSight<2 && threatDistance<350 && TrySpreadFromAllies())return;
        if (SeesPlayer && threatDistance<100)
        {
            // Give the marker room instead of stopping inside the opponent's collider.
            var away=(WorldPosition-LastKnownPlayerPosition).WithZ(0).Normal;
            if(away.Length<.5f)away=-WorldRotation.Forward.WithZ(0).Normal;
            agent.MoveTo(LastKnownPlayerPosition+away*160);
        }
        else if ( SeesPlayer && threatDistance < 300 ) agent.Stop();
        else agent.MoveTo( LastKnownPlayerPosition );
    }

    private bool TrySpreadFromAllies()
    {
        var separation=Vector3.Zero;
        foreach(var ally in Scene.GetAllComponents<ArenaOpponent>())
        {
            if(ally==this || !ally.CombatEnabled || ally.combatant?.Eliminated==true)continue;
            var offset=(WorldPosition-ally.WorldPosition).WithZ(0);
            var distance=offset.Length;
            if(distance>=85)continue;
            var direction=distance>.1f ? offset/distance
                : Rotation.FromYaw(CharacterIndex*60).Forward;
            separation+=direction*(1-distance/85);
        }
        if(separation.Length<.15f)return false;
        // Only relocate to a locally clear spot; navigation remains responsible
        // for moving there, and existing cover tactics retain priority.
        var destination=WorldPosition+separation.Normal*90;
        var clearance=Scene.Trace.Sphere(17,WorldPosition+Vector3.Up*32,destination+Vector3.Up*32)
            .IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
        if(clearance.Hit)return false;
        agent.MoveTo(destination);
        SpacingMoves++;
        return true;
    }

    // Fire cadence advances every frame; path/perception decisions remain throttled.
    internal static bool InSightCone(Vector3 origin, Vector3 forward, Vector3 point)
    {
        var offset = point - origin;
        if (offset.Length >= 2200) return false;
        var horizontal = offset.WithZ(0);
        if (horizontal.Length < 1) return true;
        // 140 degree horizontal view; cover occlusion is checked separately.
        return Vector3.Dot(forward.WithZ(0).Normal, horizontal.Normal) >= .34202015f;
    }

    private void TryCombatShot()
    {
        if(Components.Get<CloseCombat>() is {} contact && (contact.Incapacitated || contact.Attacking))return;
        ShotBlockReason=UsingCover ? "sheltered" : !SeesPlayer ? "no sight" : visibleTime<BotCombatTuning.ReactionSeconds ? "acquiring" : "aim/obstruction";
        if ( CombatEnabled && !UsingCover && SeesPlayer && visibleTime >= BotCombatTuning.ReactionSeconds && target.Components.Get<PaintballCombatant>()?.Eliminated == false
            && Scene.GetAllComponents<OpponentPaintball>().Count() < 96 && marker is not null )
        {
            if(Magazine.PauseRemaining>0 || Magazine.ReloadRemaining>0 || shotLaneRetry>0)return;
            // Recheck at the firing instant: a target may duck between scans.
            if(!CanObserve(target,out var visibleAim))
            { BlockedShotsAvoided++;SeesPlayer=false;visibleTime=0;return; }
            var eye = WorldPosition + Vector3.Up * 60;
            UpdateCoveredMarkerPose( false );
            var muzzle = marker.WorldTransform.PointToWorld( PaintballMarker.MuzzleOffset );
            var obstruction = Scene.Trace.Sphere( PaintballFlight.Radius, eye, muzzle ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "paintball_debris" ).Run();
            ShotBlockReason=obstruction.Hit ? "muzzle: "+obstruction.GameObject?.Name : "aim alignment";
            // Seeing a helmet does not mean the barrel has cleared its cover.
            var lane=Scene.Trace.Sphere(PaintballFlight.Radius,muzzle,visibleAim).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
            if(lane.Hit && PaintballCombatant.Find(lane.GameObject)!=target)
            {
                // A lower chest lane can be blocked while the exposed helmet
                // is reachable. Aim at that visible point before relocating.
                var head=target.EyePosition;
                var sight=Scene.Trace.Ray(eye,head).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
                var highLane=Scene.Trace.Sphere(PaintballFlight.Radius,muzzle,head).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
                if((!sight.Hit || PaintballCombatant.Find(sight.GameObject)==target)
                    && (!highLane.Hit || PaintballCombatant.Find(highLane.GameObject)==target))
                { visibleAim=head;observedAimPoint=head;lane=highLane; }
            }
            if(lane.Hit && PaintballCombatant.Find(lane.GameObject)!=target)
            { ShotBlockReason="blocked firing lane";BlockedShotsAvoided++;shotLaneRetry=.12f;blockedLaneTime+=.12f;return; }
            if(obstruction.Hit){BlockedShotsAvoided++;shotLaneRetry=.12f;blockedLaneTime+=.12f;return;}
            var shotAlignment = Vector3.Dot(marker.WorldRotation.Forward,(visibleAim-muzzle).Normal);
            if ( !obstruction.Hit && shotAlignment >= .966f && Magazine.TryFire() )
            {
                ShotBlockReason="fired";
                blockedLaneTime=0;
                MinimumShotAlignment = MathF.Min(MinimumShotAlignment,shotAlignment);
                shots++;
                if(target.Components.Get<PlayerController>().IsValid())ShotsAtPlayers++;
                if(CoverPeeking)ShotsFromCover++;
                if(UsingCover)ShotsWhileSheltered++;
                if(MultiplayerSession.Online)NetworkEffects.Sound(CharacterMarkerSound.For(CharacterIndex),muzzle,System.Guid.Empty);
                else Sound.Play(CharacterMarkerSound.For(CharacterIndex),muzzle);
                ReportMarkerSound(Scene,GameObject,muzzle);
                float flightTime=MathF.Min(.22f,(visibleAim-muzzle).Length/PaintballFlight.Speed);
                var aim=visibleAim+observedVelocity*flightTime*.7f;
                var error = BotCombatTuning.AimError(muzzle,aim,shots,CharacterIndex,MovementSpeed>65);
                if(MultiplayerSession.Online)
                {
                    NetworkEffects.SpawnBall(GameObject,combatant.Team,muzzle,PaintballFlight.LaunchVelocity(muzzle,aim+error));
                    return;
                }
                var ball = new GameObject( true, "Opponent paintball" );
                ball.WorldPosition = muzzle;
                var projectile = ball.Components.Create<OpponentPaintball>();
                projectile.Shooter = GameObject;
                projectile.Team = combatant.Team;
                projectile.Velocity = PaintballFlight.LaunchVelocity(muzzle,aim + error);
            }
        }
    }

    protected override void OnPreRender()
    {
        if(PresentationAnchor.IsValid())
        {
            if(reloadPresentation.IsValid()) { reloadPresentation.Destroy();reloadPresentation=null; }
            UpdateCoveredMarkerPose(false);
            return;
        }
        CoveredSuitMotion.Apply( body );
        var trackPath = body.IsValid() ? CoveredRosterAssets.ReloadTrack( body.Model?.Name ) : null;
        if ( reloadPresentation.IsValid() && (trackPath is null || combatant?.Eliminated == true || presentationModel != body.Model?.Name) )
        {
            reloadPresentation.Destroy(); reloadPresentation = null;
        }
        if ( ShowingOutSignal )
        {
            body.ClearPhysicsBones();
            foreach ( var name in new[]{ "LeftGrip", "RightGrip", "LeftThumb", "RightThumb" } ) body.Morphs.Set( name, 0, 0 );
        }
        if ( trackPath is not null && combatant?.Eliminated != true && marker.IsValid() )
        {
            var visual = marker.Components.Get<ModelRenderer>();
            const string model = "models/weapons/vx9_speed_feed/vx9_speed_feed.vmdl";
            if ( visual.Model?.Name != model ) visual.Model = Model.Load( model );
            if ( !reloadPresentation.IsValid() )
            {
                reloadPresentation = Components.Create<ReloadPresentationTrack>();
                reloadPresentation.Body = body; reloadPresentation.TrackPath = trackPath; presentationModel = body.Model.Name;
            }
            reloadPresentation.Docked = Magazine.ReloadRemaining <= 0;
            reloadPresentation.Phase = 1 - Magazine.ReloadRemaining / Magazine.ActiveReloadDuration;
            reloadPresentation.BodyOffset=Vector3.Zero;
            if(!reloadPresentation.Docked && body.Sequence.Name!="reload_standing" && CoveredRosterAssets.TryMarkerOffset(body.Model.Name,out var reloadOffset)
                && ReloadUpperBodyData.Apply(body,reloadPresentation.Phase,reloadOffset,out _,out var translation,false))
                reloadPresentation.BodyOffset=translation;
            reloadPresentation.ApplyFrame();
        }
        UpdateCoveredMarkerPose( true );
    }

    private void UpdateCoveredMarkerPose( bool writeBones )
    {
        if(PresentationAnchor.IsValid() && marker.IsValid())
        {
            marker.WorldPosition=PresentationAnchor.WorldPosition;
            marker.WorldRotation=PresentationAnchor.WorldRotation;
            return;
        }
        if ( !body.IsValid() || !marker.IsValid() || combatant?.Eliminated == true
            || !CoveredRosterAssets.TryMarkerOffset( body.Model?.Name, out var offset ) ) return;
        var pitch = Magazine.ReloadRemaining > 0 ? 0 : coveredAimPitch;
        if(Magazine.ReloadRemaining>0 && body.Sequence.Name!="reload_standing"
            && ReloadUpperBodyData.Apply(body,1-Magazine.ReloadRemaining/Magazine.ActiveReloadDuration,offset,out var reloadPose,out _,writeBones))
        {
            marker.WorldPosition=reloadPose.Position;marker.WorldRotation=reloadPose.Rotation;return;
        }
        if ( CoveredAimPose.Apply( body, pitch, offset, out var pose, writeBones ) )
        {
            marker.WorldPosition = pose.Position;
            marker.WorldRotation = pose.Rotation;
        }
    }

    private bool UpdateCoverTactic()
    {
        if(!combatant.IsValid())return false;
        bool pressured=combatant.PaintHits>observedHits;
        observedHits=combatant.PaintHits;
        if(!seekingCover && hasSeenPlayer && cooldownCover<=0 && (EvadingAfterHit || pressured || visibleTime>BotCombatTuning.SeekCoverAfterSeconds || (Magazine.Ammo<=6 && Magazine.Reserve>0)))
        {
            if(!Flanking && OpponentCoverPlanner.TryFind(GameObject,target.GameObject,LastKnownPlayerPosition,out var point,requirePeek:!EvadingAfterHit))
            {
                CoverDestination=point;seekingCover=true;coverTime=coverRest=0;CoverSelections++;
                ReservedCover=OpponentCoverPlanner.ShelteringSurface(GameObject,target.GameObject,LastKnownPlayerPosition,point);
            }
            else cooldownCover=EvadingAfterHit ? .3f : 2;
        }
        if(!seekingCover)return false;
        if(OpponentCoverPlanner.IsOccupied(GameObject,ReservedCover)
            || !OpponentCoverPlanner.IsSheltered(GameObject,target.GameObject,LastKnownPlayerPosition,CoverDestination))
        {
            InvalidatedCoverPlans++;
            ReleaseTacticalCover();
            cooldownCover=0;
            return false;
        }
        if(!holdingCover && (WorldPosition-CoverDestination).WithZ(0).Length<12
            && OpponentCoverPlanner.IsSheltered(GameObject,target.GameObject,LastKnownPlayerPosition,WorldPosition))
        {
            holdingCover=true;heldCoverTime=coverRest=0;UsingCover=true;agent.Stop();Magazine.BeginReload();
            BeginCoverStay();
        }
        if(holdingCover)
        {
            if(UpdateRepositionDecision())return true;
            if(lostSight>6 && !RepositionPending){ReleaseTacticalCover();return false;}
            if(pressured)coverRest=0;
            bool hasPeek=OpponentCoverPlanner.TryPeek(GameObject,target.GameObject,LastKnownPlayerPosition,CoverDestination,out var peek,out var low);
            CoverIsLow=low;
            if(coverRest>coverCycleDuration){coverRest=0;VaryCoverCycle();}
            bool expose=hasPeek && !RepositionPending && Magazine.ReloadRemaining<=0 && coverRest>coverRestDuration;
            var destination=expose ? peek : CoverDestination;
            bool arrived=(WorldPosition-destination).WithZ(0).Length<8;
            var wasPeeking=CoverPeeking;
            CoverPeeking=expose&&arrived;
            UsingCover=!expose&&arrived && OpponentCoverPlanner.IsSheltered(GameObject,target.GameObject,LastKnownPlayerPosition,WorldPosition);
            if(CoverPeeking&&!wasPeeking)CoverBursts++;
            if(arrived)agent.Stop();else agent.MoveTo(destination);
            if(!hasPeek&&heldCoverTime>2.5f){ReleaseTacticalCover();return false;}
            return true;
        }
        if(coverTime>coverTravelLimit) { RememberCurrentCover();ReleaseTacticalCover();return false; }
        agent.MoveTo(CoverDestination);return true;
    }
    internal void ApplyNetwork(NetworkBot state)
    {
        UsingCover=state.Covered;CoverPeeking=state.Peek;CoverIsLow=state.Low;
        LastKnownPlayerPosition=state.Aim-Vector3.Up*40;SeesPlayer=state.Peek || !state.Covered;
        observedAimPoint=state.Aim;MovementSpeed=state.Velocity.Length;
        var shape=Components.Get<BoxCollider>();
        if(shape.IsValid()){shape.Scale=new(28,28,CoverCrouched ? 43 : 66);shape.Center=new(0,0,CoverCrouched ? 21.5f : 33);}
    }
    public void InterruptForContact()
    {
        ReleaseTacticalCover();
        agent?.Stop();MovementSpeed=0;flankRemaining=0;decisionTime=0;
    }
    private void ReleaseTacticalCover()
    {
        // Losing a firing lane or shelter must not immediately reselect the
        // same obstacle and restart its stay timer indefinitely.
        if(holdingCover)RememberCurrentCover();
        UsingCover=false;CoverPeeking=false;holdingCover=false;seekingCover=false;ReservedCover=null;cooldownCover=1f;
        RelocatingCover=false;RepositionPending=false;coverTravelLimit=4.5f;
        if(hasSeenPlayer && lostSight>1.4f && flankCooldown<=0)TryStartFlank();
    }
}