SkillDefensiveSupport.cs

Defensive support logic for the soccer match simulation. Finds a linked presser, calculates cover positions and pass-lane interceptions, and records support arrivals, regains and contacts.

using System;

namespace Socer;

public sealed partial class MatchSimulation
{
    // These are completed support episodes, not frames spent displaying an action.
    public int[] SkillCoverArrivals=new int[2],SkillCoverRegains=new int[2],SkillCoverInterceptions=new int[2];
    public int[] SkillCoverLaneOccupations=new int[2],SkillCoverPassInterceptions=new int[2];
    struct SkillCoverSupport
    {
        public bool Present,Arrived,Regained,FollowingPass,Contacted,SpecificLane,LaneOccupied;
        public int Presser,Source,PossessionTeam,LaneReceiver;
        public Point Aim,Meeting,LaneStart,LaneEnd;
        public float Started,Until,NextDecision,AtAim,RecoveryUntil,InitialDistance,ContactOutcomeUntil,AtLane;
    }
    readonly SkillCoverSupport[] skillCoverSupport=new SkillCoverSupport[22];

    Footballer FindLinkedCoverPresser(Footballer cover,Footballer carrier,out float synergy)
    {
        synergy=0;Footballer chosen=null;float best=float.MaxValue;
        if(!Observed(cover,carrier).HasValue)return null;
        for(int i=0;i<Players.Count;i++)
        {
            var candidate=Players[i];
            if(candidate.Keeper || candidate.Id==cover.Id || !SkillNeighbors(cover,candidate) ||
                candidate.Profile.RoleFamiliarity<.7f || Seconds<candidate.FallUntil ||
                !SeesPartner(cover,candidate) || !CanObserve(candidate,carrier))continue;
            float distance=(candidate.Position-carrier.Position).Length;
            if(distance>8 || (candidate.Position-cover.Position).Length>22)continue;
            bool duel=DuelDefender==candidate.Id && DuelCarrier==carrier.Id;
            bool ordered=candidate.DirectOrderUntil>Seconds && candidate.DirectTargetId==carrier.Id &&
                candidate.DirectOrder is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer;
            bool chasing=candidate.Id==chasers[cover.Team] ||
                candidate.Id==secondChasers[cover.Team] &&
                (counterPressUntil[cover.Team]>Seconds || Tactics[cover.Team]==2);
            // A skill-equipped neighbour already within challenge range is a real
            // pressure participant even while the global chaser is another player.
            // A distant marked player or a merely nearby idle teammate is not.
            if(!duel && !ordered && !chasing && distance>2.7f)continue;
            if(!duel && !ordered && !chasing && carrier.Velocity.Length>2 &&
                Point.Dot((candidate.Position-carrier.Position).Unit,carrier.Velocity.Unit)<-.55f)continue;
            float power=Math.Max(SkillStrength(candidate,UniqueSkill.SteelPress),SkillStrength(candidate,UniqueSkill.Interceptor));
            if(power<=0)continue;
            float score=distance-(duel?1.2f:ordered?.8f:chasing?.6f:0)-power*.25f;
            if(score<best){best=score;chosen=candidate;synergy=power;}
        }
        return chosen;
    }

    bool DangerousCoverMark(Footballer p,out Footballer runner,out Point known)
    {
        runner=null;known=new();
        if(p.AutoMarkId<0 || p.AutoMarkId>=Players.Count || p.AutoMarkId==Owner)return false;
        var marked=Players[p.AutoMarkId];
        if(marked.SentOff || marked.Team==p.Team || marked.Keeper)return false;
        var observed=Observed(p,marked);
        if(!observed.HasValue || (observed.Value-World(new Point(0,34),p.Team)).Length>=32)return false;
        runner=marked;known=observed.Value;return true;
    }

    Point LinkedCoverTarget(Footballer p,Point ordinary,Footballer presser,Point source,int sourceId,
        int possessionTeam,float cover,float synergy,bool interception=false)
    {
        ref var support=ref skillCoverSupport[p.Id];
        bool changed=!support.Present || support.Presser!=presser.Id || support.Source!=sourceId ||
            support.PossessionTeam!=possessionTeam || Seconds>support.Until;
        if(changed)
        {
            support=new SkillCoverSupport{Present=true,Presser=presser.Id,Source=sourceId,
                PossessionTeam=possessionTeam,Started=Seconds};
        }
        if(changed || Seconds>=support.NextDecision)
        {
            support.NextDecision=Seconds+.25f;
            var goal=World(new Point(0,34),p.Team);
            var goalward=(goal-source).Unit;
            int previousReceiver=support.SpecificLane?support.LaneReceiver:-1;
            support.SpecificLane=false;
            Point aim;
            if(DangerousCoverMark(p,out var marked,out var observed))
            {
                // A partner's pressure never licenses abandoning an already dangerous runner.
                var anticipated=observed+(CanObserve(p,marked)?marked.Velocity:new Point())*
                    (.15f+.14f*Math.Clamp(p.Profile.Interceptions*synergy,0,1.35f));
                // The cover player's existing runner remains their responsibility.
                // Orient the support on the runner's goal-side receiving shoulder,
                // rather than blending back to a stale generic formation target.
                aim=Clamp(anticipated+(goal-anticipated).Unit*1.05f);
            }
            else
            {
                var predictedSource=source;
                if(sourceId>=0 && sourceId<Players.Count && CanObserve(p,Players[sourceId]))
                    predictedSource+=Players[sourceId].Velocity*(.12f+.11f*p.Profile.Defence);
                aim=interception?presser.Position+(goal-presser.Position).Unit*(3.8f-.5f*cover):
                    predictedSource+(goal-predictedSource).Unit*(3.5f-cover*.45f)+
                    new Point(0,source.Y<34?.65f:-.65f);
                float best=float.MinValue;
                float sourceDepth=World(source,p.Team).X;
                // A bounded scan of observed threats; no allocations, random draws or omniscient positions.
                for(int i=0;i<Players.Count;i++)
                {
                    var threat=Players[i];
                    if(threat.Team==p.Team || threat.Keeper || threat.SentOff || threat.Id==sourceId ||
                        Seconds<threat.FallUntil)continue;
                    var known=Observed(p,threat);if(!known.HasValue)continue;
                    var local=World(known.Value,p.Team);
                    float laneLength=(known.Value-source).Length;
                    if(local.X>sourceDepth+2 || local.X>56 || Math.Abs(local.Y-34)>23 ||
                        laneLength<3.5f || laneLength>26)continue;
                    var anticipated=known.Value+(CanObserve(p,threat)?threat.Velocity:new Point())*
                        (.15f+.13f*Math.Clamp(p.Profile.Interceptions*synergy,0,1.35f));
                    var passLine=anticipated-source;
                    // Close the nearest useful point in the lane. Choosing only
                    // the ordinary formation target's depth made a cover player
                    // run along an already reachable passing line as the ball passed.
                    float fraction=Math.Clamp(Point.Dot(p.Position-source,passLine)/
                        Math.Max(.01f,Point.Dot(passLine,passLine)),.38f,.82f);
                    var lane=source+passLine*fraction;
                    float travel=(lane-p.Position).Length;
                    if(travel>12)continue;
                    bool covered=false;
                    for(int j=0;j<Players.Count;j++)
                    {
                        var teammate=Players[j];
                        if(teammate.Team!=p.Team || teammate.Id==p.Id || teammate.Id==presser.Id ||
                            teammate.Keeper || teammate.SentOff || Seconds<teammate.FallUntil)continue;
                        if(teammate.AutoMarkId==threat.Id && (teammate.Position-known.Value).Length<2.3f ||
                            (teammate.Position-lane).Length<1.8f){covered=true;break;}
                        ref var teammateSupport=ref skillCoverSupport[teammate.Id];
                        if(teammateSupport.Present && teammateSupport.SpecificLane && teammateSupport.Until>Seconds &&
                            teammateSupport.LaneReceiver==threat.Id && (teammateSupport.Aim-lane).Length<2.4f)
                        {covered=true;break;}
                    }
                    if(covered)continue;
                    float danger=(sourceDepth-local.X)*.20f+(56-local.X)*.10f-
                        Math.Abs(local.Y-34)*.06f-travel*.24f+(threat.Id==PassReceiver?3:0)+
                        (threat.Id==previousReceiver?1.1f:0);
                    if(danger>best)
                    {
                        best=danger;aim=lane;support.SpecificLane=true;
                        support.LaneStart=source;support.LaneEnd=anticipated;support.LaneReceiver=threat.Id;
                    }
                }
                // The linked player commits to the uncovered lane instead of crowding the ball presser.
                if(!support.SpecificLane)
                {
                    float commitment=Math.Clamp(.57f*cover+.16f*synergy,.50f,.88f);
                    if(World(source,p.Team).X<36)commitment=Math.Max(commitment,.9f);
                    aim=ordinary*(1-commitment)+aim*commitment;
                }
                aim=Clamp(aim);
            }
            if(!support.SpecificLane || previousReceiver!=support.LaneReceiver)support.AtLane=0;
            support.Aim=aim;
            if(changed)support.InitialDistance=(aim-p.Position).Length;
        }
        support.Until=Seconds+.75f;
        float distance=(support.Aim-p.Position).Length;
        if(distance>2.2f && distance<11 && World(source,p.Team).X<56)
            support.RecoveryUntil=Seconds+.35f;
        p.Action=interception?"Страхует выход партнёра на перехват":"Закрывает проход за прессингом";
        p.DecisionReason="Связка скиллов · партнёр идёт на мяч, подстраховка закрывает оставленную линию";
        return support.Aim;
    }

    internal bool LinkedCoverRecovery(Footballer p)
    {
        if(p.Id<0 || p.Id>=skillCoverSupport.Length || p.Keeper || p.SentOff ||
            Seconds<p.FallUntil || p.DirectOrderUntil>Seconds)return false;
        ref var support=ref skillCoverSupport[p.Id];
        return support.Present && support.Until>Seconds && support.RecoveryUntil>Seconds &&
            (Owner>=0?Players[Owner].Team:lastPossessionTeam>=0?lastPossessionTeam:LastTouch)!=p.Team &&
            ((support.FollowingPass?support.Meeting:support.Aim)-p.Position).Length is >1.8f and <11 &&
            SkillNeighbors(p,Players[support.Presser]);
    }

    bool FollowLinkedCoverPass(Footballer p,Point ordinary,out Point target)
    {
        target=ordinary;
        if(Owner>=0 || ShotInFlight || p.Id<0 || p.Id>=skillCoverSupport.Length ||
            p.Id==PassReceiver && LastTouch==p.Team || Seconds<p.InterceptUntil || BallHeight>1.4f ||
            BallSpeed<.5f)return false;
        ref var support=ref skillCoverSupport[p.Id];
        int possessionTeam=lastPossessionTeam>=0?lastPossessionTeam:LastTouch;
        if(!support.Present || Seconds>support.Until || possessionTeam==p.Team ||
            possessionTeam!=support.PossessionTeam || !SkillNeighbors(p,Players[support.Presser]))return false;
        // First commit only to a nearby opponent pass through the lane already being covered.
        // A rebound off our own body remains recoverable during this same bounded episode.
        if(!support.FollowingPass && (LastTouch==p.Team || Passer<0 || Passer!=support.Source))return false;
        var flight=Ball+BallVelocity*1.1f;
        if(DistanceToSegment(p.Position,Ball,flight)>2.5f &&
            DistanceToSegment(support.Aim,Ball,flight)>2.5f)return false;
        if(!support.FollowingPass)
        {
            // Recognise the approaching lane before the ball enters immediate contest range.
            // Holding the cover episode does not itself override marking or start a chase.
            support.FollowingPass=true;
            support.Meeting=support.Aim;
            support.Until=Seconds+1.6f;
        }
        if((Ball-p.Position).Length>9)
        {
            if(DangerousCoverMark(p,out var waitingMark,out var waitingKnown) &&
                (waitingMark.Id!=PassReceiver || World(support.Aim,p.Team).X>World(waitingKnown,p.Team).X+3.5f))return false;
            if((support.Aim-p.Position).Length>4 || World(support.Aim,p.Team).X<2)return false;
            p.Action="Держит линию паса за прессингом";
            p.DecisionReason="Связка скиллов · сохраняет перекрытый проход до подхода мяча";
            target=support.Aim;return true;
        }
        float crossingTime=Point.Dot(p.Position-Ball,BallVelocity)/
            Math.Max(.01f,Point.Dot(BallVelocity,BallVelocity));
        var crossing=Ball+BallVelocity*Math.Clamp(crossingTime,0,.9f);
        float lateralTravel=(crossing-p.Position).Length;
        float arrive=lateralTravel/Math.Max(1,EffectivePace(p))+.10f;
        // Cut across the pass to the closest reachable point in its lane first.
        // Chasing a far future meeting point turns a short interception into a race after the receiver.
        var meeting=crossingTime is >.04f and <.9f && lateralTravel<3 && arrive<=crossingTime+.08f?
            Clamp(crossing):LooseBallMeeting(p);
        if((meeting-p.Position).Length>8.5f)
        {
            float time=Math.Clamp(Point.Dot(p.Position-Ball,BallVelocity)/
                Math.Max(.01f,Point.Dot(BallVelocity,BallVelocity)),0,.55f);
            meeting=Clamp(Ball+BallVelocity*time);
        }
        if((meeting-p.Position).Length>8.5f || World(meeting,p.Team).X<2)return false;
        if(DangerousCoverMark(p,out var marked,out var known))
        {
            // Keep the goal-side mark unless this is the marked player's actual pass.
            // Then a short step into that pass is the purpose of the cover, not abandoning another runner.
            if(marked.Id!=PassReceiver || World(meeting,p.Team).X>World(known,p.Team).X+3.5f)return false;
        }
        support.Meeting=meeting;
        if((meeting-p.Position).Length>1.8f)support.RecoveryUntil=Seconds+.35f;
        p.Action="Встречает пас за прессингом";
        p.DecisionReason="Связка скиллов · закрытая линия стала передачей, идёт к доступному мячу";
        target=meeting;return true;
    }

    public void ObserveSkillCover(float dt)
    {
        if(dt<=0)return;
        int possessionTeam=Owner>=0?Players[Owner].Team:lastPossessionTeam>=0?lastPossessionTeam:LastTouch;
        for(int i=0;i<Players.Count && i<skillCoverSupport.Length;i++)
        {
            ref var support=ref skillCoverSupport[i];
            if(!support.Present)continue;
            var p=Players[i];
            if(Seconds>support.Until && (!support.Contacted || Seconds>support.ContactOutcomeUntil) ||
                p.SentOff || p.Team==possessionTeam || Seconds<p.FallUntil ||
                RestartRemaining>0 || GoalHoldRemaining>0 || !SkillNeighbors(p,Players[support.Presser]) ||
                p.Profile.RoleFamiliarity<.7f || Players[support.Presser].Profile.RoleFamiliarity<.7f)
            {support.Present=false;continue;}
            // Keep only the outcome record while a real deflected ball is being recovered.
            // This does not prolong movement intent, the recovery sprint, or the skill itself.
            if(Seconds>support.Until)continue;
            if(!support.Arrived && support.InitialDistance>=1.8f)
            {
                if((support.Aim-p.Position).Length<1.7f && Seconds-support.Started>=.18f)
                    support.AtAim+=dt;
                else support.AtAim=0;
                if(support.AtAim>=.18f){support.Arrived=true;SkillCoverArrivals[p.Team]++;}
            }
            if(support.SpecificLane && !support.LaneOccupied)
            {
                var passLine=support.LaneEnd-support.LaneStart;
                float fraction=Point.Dot(p.Position-support.LaneStart,passLine)/
                    Math.Max(.01f,Point.Dot(passLine,passLine));
                float physicalReach=.59f+Math.Clamp(p.Profile.Interceptions,0,1)*.36f;
                if(fraction is >.22f and <.92f && Seconds-support.Started>=.18f &&
                    DistanceToSegment(p.Position,support.LaneStart,support.LaneEnd)<=physicalReach)
                    support.AtLane+=dt;
                else support.AtLane=0;
                if(support.AtLane>=.18f){support.LaneOccupied=true;SkillCoverLaneOccupations[p.Team]++;}
            }
        }
    }

    internal void RecordSkillCoverRecovery(Footballer p,int previousPossessionTeam)
    {
        if(p.Id<0 || p.Id>=skillCoverSupport.Length || Owner!=p.Id || previousPossessionTeam<0 ||
            previousPossessionTeam==p.Team)return;
        bool recorded=false;
        for(int i=0;i<Players.Count && i<skillCoverSupport.Length;i++)
        {
            ref var support=ref skillCoverSupport[i];var cover=Players[i];
            if(!support.Present || support.Regained ||
                Seconds>support.Until && (!support.Contacted || Seconds>support.ContactOutcomeUntil) || cover.Team!=p.Team ||
                support.PossessionTeam!=previousPossessionTeam || !SkillNeighbors(cover,Players[support.Presser]) ||
                cover.Profile.RoleFamiliarity<.7f || Players[support.Presser].Profile.RoleFamiliarity<.7f)continue;
            bool coverRegain=i==p.Id && (support.Arrived || support.Contacted ||
                (support.Aim-p.Position).Length<=2.1f ||
                support.FollowingPass && (support.Meeting-p.Position).Length<=2.1f);
            bool pressRegain=p.Id==support.Presser && (support.Arrived || support.Contacted) &&
                (p.Position-cover.Position).Length<12;
            if(!coverRegain && !pressRegain)continue;
            support.Regained=true;
            if(!recorded){SkillCoverRegains[p.Team]++;recorded=true;}
        }
    }

    internal void RecordSkillCoverContact(Footballer p)
    {
        if(p.Id<0 || p.Id>=skillCoverSupport.Length || p.Team==LastTouch)return;
        ref var support=ref skillCoverSupport[p.Id];
        if(!support.Present || support.Contacted || Seconds>support.Until ||
            LastTouch!=support.PossessionTeam || !SkillNeighbors(p,Players[support.Presser]) ||
            (support.Aim-p.Position).Length>2.5f &&
                (!support.FollowingPass || (support.Meeting-p.Position).Length>2.5f))return;
        support.Contacted=true;
        support.ContactOutcomeUntil=Seconds+3.3f;
        support.Until=Math.Max(support.Until,Seconds+.75f);
        SkillCoverInterceptions[p.Team]++;
        if(!ShotInFlight && Passer>=0 && Players[Passer].Team!=p.Team)SkillCoverPassInterceptions[p.Team]++;
    }
}