TeamCombinations.cs

Partial MatchSimulation class that plans and tracks two-player and third-man passing combinations. Chooses receivers, coordinates supporting runs into open pockets, and records combination stats while leaving ball travel to the normal passing system.

using System;
using System.Linq;
namespace Socer;

// A pass and its supporting runs are one interruptible team decision. The ball
// still travels through normal passing, reception, offside and interception.
public sealed partial class MatchSimulation
{
    sealed class Combination
    {
        public int Starter=-1,Link=-1,Third=-1,Final=-1,Stage;
        public bool Linked,ThirdLinked;
        public float Until,WaitingUntil;
        public Point StarterTarget,ThirdTarget;
        public void Clear(){Starter=Link=Third=Final=-1;Stage=0;Until=WaitingUntil=0;Linked=ThirdLinked=false;}
    }
    readonly Combination[] combinations={new(),new()};
    readonly float[] nextCombinationAt=new float[2];
    readonly float[] awarenessCache=new float[2];
    float awarenessUntil=-1;
    public int[] CombinationsStarted=new int[2],CoordinatedRuns=new int[2];
    public int[] ThirdManRunsStarted=new int[2],ThirdManPassesAttempted=new int[2];

    float ThirdCombinationCue(Footballer starter,Footballer link,Footballer runner)=>
        Math.Max(Math.Max(WallRunLink(starter,runner),WallRunLink(link,runner)),PassingRunLink(link,runner));

    float TeamAwareness(int team)
    {
        if(Seconds>=awarenessUntil)
        {
            awarenessUntil=Seconds+1;
            for(int side=0;side<2;side++)
            {
                var field=ActivePlayers.Where(q=>q.Team==side && !q.Keeper).ToArray();
                if(field.Length==0){awarenessCache[side]=0;continue;}
                float individual=field.Average(q=>q.Profile.Vision*.30f+q.Profile.OffBall*.27f+
                    q.Profile.Passing*.23f+q.Profile.Composure*.20f);
                float fit=field.Average(q=>q.Profile.RoleFamiliarity);
                float rating=field.Average(q=>q.Profile.Overall/99f*q.Profile.RoleFamiliarity);
                awarenessCache[side]=individual*.52f+TeamCohesion[side]*fit*.28f+rating*.20f;
            }
        }
        return awarenessCache[team];
    }

    bool CombinationPassOpen(Footballer passer,Footballer receiver,float minimum)
    {
        if(receiver.SentOff || !SeesPartner(passer,receiver) || !IsOnside(receiver,passer.Position))return false;
        float distance=(receiver.Position-passer.Position).Length;
        return distance is >3.5f and <23 &&
            PassSafety(passer,receiver,PassMeetingPoint(passer,receiver))>=minimum;
    }

    int ChooseCombinationReceiver(Footballer p,float pressure,out int third)
    {
        third=-1;
        int team=p.Team;var play=combinations[team];
        if(play.Stage>0 && Seconds>play.Until)play.Clear();
        if(play.Stage==2)return -1;
        if(play.Stage==1)
        {
            if(p.Id!=play.Link || p.Held<.12f-.025f*SkillStrength(p,UniqueSkill.WallPass))return -1;
            var starter=Players[play.Starter];
            var wide=play.Third>=0?Players[play.Third]:null;
            bool back=CombinationPassOpen(p,starter,.48f);
            bool forward=wide!=null && CombinationPassOpen(p,wide,.52f);
            if(!back && !forward)
            {
                // Planning uses an open receiving pocket; at first touch the
                // runner may still be a fraction of a second short of it. Keep
                // only an imminent, safe neighbouring route alive, and never
                // pause under a close press or in place of a clear finish.
                if(wide!=null && play.ThirdLinked && p.Held<.7f && pressure>3 &&
                    p.Profile.Composure>.5f && wide.Stamina>.3f &&
                    (play.ThirdTarget-wide.Position).Length/Math.Max(2,EffectivePace(wide)*.75f)<.8f-p.Held &&
                    PassSafety(p,wide,play.ThirdTarget)>=.6f && PerceivedSpace(p,play.ThirdTarget)>=3)
                {play.WaitingUntil=Seconds+.12f;return -1;}
                play.Clear();return -1;
            }
            play.WaitingUntil=0;
            if(!forward)return starter.Id;
            if(!back)return wide.Id;
            // The third runner is a planned outlet, not an accidental alternative
            // to the return pass. Judge both actual reception corridors. The old
            // rule returned the ball before that runner had opened the triangle.
            var thirdMeeting=PassMeetingPoint(p,wide);var returnMeeting=PassMeetingPoint(p,starter);
            float thirdSafety=PassSafety(p,wide,thirdMeeting);
            float thirdSpace=PerceivedSpace(p,thirdMeeting);
            float thirdGain=(thirdMeeting.X-p.Position.X)*Direction(team);
            float returnSafety=PassSafety(p,starter,returnMeeting);
            float returnSpace=PerceivedSpace(p,returnMeeting);
            float returnGain=(returnMeeting.X-p.Position.X)*Direction(team);
            float thirdValue=thirdSafety*5+Math.Min(thirdSpace,6)*.55f+Math.Clamp(thirdGain,-4,14)*.25f;
            float returnValue=returnSafety*5+Math.Min(returnSpace,6)*.55f+Math.Clamp(returnGain,-4,14)*.25f;
            if(play.ThirdLinked && thirdSafety>=.58f && thirdSpace>=2.6f && thirdGain>2)
                thirdValue+=1.8f; // anticipation of the neighbouring runner's cue
            return thirdValue>returnValue+.15f?wide.Id:starter.Id;
        }
        if(play.Stage==0 && play.Starter==p.Id && Seconds<play.Until)
        {
            if(CombinationPassOpen(p,Players[play.Link],.52f)){third=play.Third;return play.Link;}
            play.Clear();
        }
        if(play.Stage!=0 || Seconds<nextCombinationAt[team] || RehearsingStrategy || !AttackUnit(p) ||
            p.Held<.32f || p.Stamina<.34f || pressure<1.7f || pressure>12 ||
            World(p.Position,team).X is <32 or >83)return -1;
        float awareness=TeamAwareness(team);
        if(awareness<.43f)return -1;
        var options=ActivePlayers.Where(q=>q.Team==team && q.Id!=p.Id && AttackUnit(q) &&
                Math.Abs(q.Position.Y-p.Position.Y)>2 &&
                (q.Position.X-p.Position.X)*Direction(team) is >-4 and <13 &&
                CombinationPassOpen(p,q,.55f))
            .OrderByDescending(q=>PassSafety(p,q,PassMeetingPoint(p,q))*5+
                q.Profile.Vision+q.Profile.Passing-(q.Position-p.Position).Length*.08f+
                (WallRunLink(p,q)+WallRunLink(q,p))*1.6f).ToArray();
        if(options.Length==0){nextCombinationAt[team]=Seconds+1;return -1;}
        var link=options[0];Footballer thirdPlayer=null;float bestPlay=float.MinValue;
        // Pick an intermediary and a deliverable third route together. Picking
        // only the highest-rated intermediary often left no usable second lane.
        for(int option=0;option<Math.Min(4,options.Length);option++)
        {
            var candidate=options[option];
            float value=PassSafety(p,candidate,PassMeetingPoint(p,candidate))*5+
                candidate.Profile.Vision+candidate.Profile.Passing-(candidate.Position-p.Position).Length*.08f+
                (WallRunLink(p,candidate)+WallRunLink(candidate,p))*1.6f;
            var runner=ChooseCombinationThird(p,candidate,out float thirdValue);
            if(runner!=null)value+=thirdValue;
            if(value>bestPlay){bestPlay=value;link=candidate;thirdPlayer=runner;}
        }
        float wall=SkillStrength(p,UniqueSkill.WallPass);
        float partner=Math.Max(WallRunLink(p,link),WallRunLink(link,p));
        if(thirdPlayer!=null)partner=Math.Max(partner,ThirdCombinationCue(p,link,thirdPlayer));
        // A complementary neighbour cue makes a deliverable triangle easier to
        // recognise. Every contact still uses the normal ball physics.
        float chance=.04f+MathF.Pow(Math.Clamp((awareness-.45f)/.42f,0,1),1.3f)*.65f+.10f*wall+
            (thirdPlayer!=null?.28f:.10f)*partner;
        if(TacticalCharacters[team]==TacticalCharacter.Possession)chance*=1.35f;
        if(team==0 && (CoachOrder==2 && CoachUntil>Seconds || FieldAttackUntil>Seconds))chance*=1.4f;
        if(Next()>chance){nextCombinationAt[team]=Seconds+1.2f;return -1;}
        play.Starter=p.Id;play.Link=link.Id;play.Third=thirdPlayer?.Id??-1;
        play.Linked=partner>0;
        play.Stage=0;play.Until=Seconds+1.3f;
        nextCombinationAt[team]=Seconds+(thirdPlayer!=null && partner>0?3.8f:5.5f);
        third=play.Third;
        return link.Id;
    }

    Footballer ChooseCombinationThird(Footballer starter,Footballer link,out float bonus)
    {
        Footballer best=null;bonus=0;float score=float.MinValue;
        float dir=Direction(starter.Team),lateral=starter.Position.Y<link.Position.Y?-2.2f:2.2f;
        var returnTarget=OnsideRunTarget(starter,Clamp(starter.Position+new Point(dir*10,lateral)));
        for(int i=0;i<Players.Count;i++)
        {
            var q=Players[i];
            if(q.Team!=starter.Team || q.SentOff || q.Id==starter.Id || q.Id==link.Id || !AttackUnit(q) ||
                q.Stamina<.3f || q.DirectOrderUntil>Seconds || Seconds<q.FallUntil ||
                (q.Position-link.Position).Length is <4 or >22 ||
                !IsOnside(q,link.Position) || !SeesPartner(link,q))continue;
            float cue=ThirdCombinationCue(starter,link,q);
            // A coordinated runner may start just behind the carrier and cross
            // into the next line while the first pass travels.
            if((q.Position.X-starter.Position.X)*dir<(cue>0?-4:1))continue;
            if(!ThirdCombinationPocket(starter,link,q,returnTarget,cue>0,out var pocket))continue;
            if((pocket.X-starter.Position.X)*dir<2)continue;
            float value=PassSafety(link,q,pocket)*3+q.Profile.OffBall*2+q.Profile.Vision+
                Math.Min((pocket.X-link.Position.X)*dir,14)*.10f-(q.Position-link.Position).Length*.08f+cue*2;
            if(value>score){score=value;best=q;bonus=1.3f+cue*1.1f;}
        }
        return best;
    }

    bool CombinationReceiverReady(Footballer p)=>combinations[p.Team] is {Stage:1} play &&
        play.Link==p.Id && Seconds<play.Until && p.Held>=.12f;

    bool WaitingForCombination(Footballer p)=>combinations[p.Team] is {Stage:1} play &&
        play.Link==p.Id && Owner==p.Id && Seconds<play.WaitingUntil && Seconds<play.Until;

    Point CombinationControl(Footballer p,Point target)
    {
        if(!WaitingForCombination(p) || p.DirectOrderUntil>Seconds || WindupPlayer==p.Id || DuelCarrier==p.Id)return target;
        p.Action="Выжидает открывание третьего";
        return p.Position; // locomotion brakes normally; the ball stays physical
    }

    bool ThirdCombinationPocket(Footballer starter,Footballer link,Footballer third,Point starterTarget,bool linked,out Point target)
    {
        target=third.Position;float best=float.MinValue;
        float side=third.Position.Y<link.Position.Y?-1:1,dir=Direction(starter.Team);
        // Leave room inside the normal 23 m return-pass range. Both ordinary
        // and linked plays use deliverable pockets; a link adds alternatives,
        // rather than pushing its runner beyond the intermediary's reach.
        for(int option=0;option<(linked?7:3);option++)
        {
            float depth=option switch{0=>7,1=>5,2=>3,3=>8.5f,4=>5,_=>2};
            float width=option switch{0=>3,1=>5,2=>0,3=>-3,4=>-6,5=>4,_=>-4};
            var at=third.Position+new Point(dir*depth,side*width);
            var fromLink=at-link.Position;
            if(fromLink.Length>20.5f)at=link.Position+fromLink.Unit*20.5f;
            at=OnsideRunTarget(third,Clamp(at));
            float progress=(at.X-third.Position.X)*dir;
            if(progress<1 || (at-link.Position).Length>21 || (at-starterTarget).Length<3)continue;
            float space=PerceivedSpace(third,at),lane=PerceivedLane(link,at),safety=PassSafety(link,third,at);
            if(space<2.6f || lane<1.35f || safety<.56f)continue;
            bool occupied=false;
            for(int i=0;i<Players.Count;i++)
            {
                var teammate=Players[i];
                if(teammate.Team!=third.Team || teammate.SentOff || teammate.Id==starter.Id ||
                    teammate.Id==link.Id || teammate.Id==third.Id)continue;
                var pocket=teammate.RunUntil>Seconds?teammate.RunTarget:
                    teammate.FootballRunUntil>Seconds?teammate.FootballRunTarget:teammate.Position;
                if((teammate.Position-at).Length<2.4f || (pocket-at).Length<3){occupied=true;break;}
            }
            if(occupied)continue;
            float value=Math.Min(space,6)+Math.Min(lane,4)*1.2f+safety*3+
                Math.Min(progress,8)*.18f-(at-third.Position).Length*.10f;
            if(value>best){best=value;target=at;}
        }
        return best>float.MinValue;
    }

    void CommitCombinationPass(Footballer p,Footballer receiver)
    {
        var play=combinations[p.Team];
        if(play.Stage==1 && play.Link==p.Id)
        {
            play.Final=receiver.Id;play.Stage=2;play.Until=Seconds+2.5f;
            if(receiver.Id==play.Third)ThirdManPassesAttempted[p.Team]++;
            p.Action=receiver.Id==play.Third?"Передача третьему":"Пас в ответ";
            return;
        }
        if(play.Stage!=0 || play.Starter!=p.Id || play.Link!=receiver.Id)return;
        int dir=Direction(p.Team);
        float lateral=p.Position.Y<receiver.Position.Y?-2.2f:2.2f;
        float wall=SkillStrength(p,UniqueSkill.WallPass)+SkillStrength(receiver,UniqueSkill.WallPass)*.5f;
        // Choose a return corridor that is still open, rather than running straight
        // into the defender who just pressured the first pass.
        play.StarterTarget=OnsideRunTarget(p,Clamp(p.Position+new Point(dir*(10+wall*1.2f),lateral)));
        if(play.Third>=0)
        {
            var other=Players[play.Third];
            bool thirdLinked=ThirdCombinationCue(p,receiver,other)>0;
            if(ThirdCombinationPocket(p,receiver,other,play.StarterTarget,thirdLinked,out var pocket))
            {play.ThirdTarget=pocket;play.ThirdLinked=thirdLinked;}
            else
            {
                play.Third=-1;play.ThirdLinked=false;
                play.Linked=Math.Max(WallRunLink(p,receiver),WallRunLink(receiver,p))>0;
            }
        }
        if(play.Linked)
        {
            var alternative=OnsideRunTarget(p,Clamp(p.Position+new Point(dir*(8+wall),-lateral*1.7f)));
            if((play.Third<0 || (alternative-play.ThirdTarget).Length>=3) &&
                PassSafety(receiver,p,alternative)>PassSafety(receiver,p,play.StarterTarget)+.08f)
                play.StarterTarget=alternative;
        }
        p.RunTarget=play.StarterTarget;p.RunUntil=Seconds+3.3f;
        linkedRunUntil[p.Id]=Seconds;
        p.FootballRun=FootballRun.Behind;p.FootballRunTarget=play.StarterTarget;
        p.FootballRunUntil=Seconds+3.3f;
        if(play.Third>=0)
        {
            var other=Players[play.Third];
            other.RunTarget=play.ThirdTarget;other.RunUntil=Seconds+3.3f;
            linkedRunUntil[other.Id]=Seconds;
            other.FootballRun=FootballRun.BetweenLines;other.FootballRunTarget=play.ThirdTarget;
            other.FootballRunUntil=Seconds+3.3f;
            ThirdManRunsStarted[p.Team]++;
            CoordinatedRuns[p.Team]+=2;
        }
        else CoordinatedRuns[p.Team]++;
        play.Stage=1;play.Until=Seconds+3.5f;
        if(wall>0)receiver.Decision=Math.Min(receiver.Decision,.08f);
        CombinationsStarted[p.Team]++;
        if(play.Linked)SkillLinkCombinations[p.Team]++;
        p.Action="Пас и рывок";receiver.Action="Играет в стенку";
    }

    void CompleteCombinationReception(Footballer receiver,int passer)
    {
        var play=combinations[receiver.Team];
        if(play.Stage!=2 || play.Final!=receiver.Id || play.Link!=passer || Seconds>play.Until)return;
        if(receiver.Id==play.Starter)WallPassesCompleted[receiver.Team]++;
        else if(receiver.Id==play.Third)ThirdManPassesCompleted[receiver.Team]++;
        if(play.Linked)SkillLinkCompletions[receiver.Team]++;
        receiver.Action=receiver.Id==play.Starter?"Получил ответный пас":"Получил пас третьим";
        play.Clear();
    }

    Point CombinationMovement(Footballer p,Point target,int possessionTeam)
    {
        var play=combinations[p.Team];
        if(possessionTeam!=p.Team || play.Stage is not (1 or 2) || Seconds>play.Until || Owner==p.Id)return target;
        if(p.Id==play.Starter){p.Action="Открывается под ответный пас";return OnsideRunTarget(p,play.StarterTarget);}
        if(p.Id==play.Third){p.Action="Третий игрок в комбинации";return OnsideRunTarget(p,play.ThirdTarget);}
        return target;
    }

    Point PreserveCombinationRun(Footballer p,Point target,int possessionTeam)
    {
        var play=combinations[p.Team];
        if(possessionTeam!=p.Team || play.Stage is not (1 or 2) || Seconds>=play.Until ||
            p.SentOff || Seconds<p.FallUntil || Seconds-p.LastSlideAt<2.1f ||
            p.DirectOrderUntil>Seconds || DuelDefender==p.Id || DuelCarrier==p.Id || WindupPlayer==p.Id ||
            Owner==p.Id || p.Id!=play.Starter && p.Id!=play.Third)return target;
        if(Owner<0)
        {
            // Supporting players must not abandon the triangle to chase the
            // first lofted delivery addressed to the intermediary. Actual
            // receivers and loose/deflected balls retain their interception AI.
            bool addressed=LastTouch==p.Team && !ShotInFlight &&
                (play.Stage==1 && Passer==play.Starter && PassReceiver==play.Link ||
                 play.Stage==2 && Passer==play.Link && PassReceiver==play.Final);
            if(!addressed || PassReceiver==p.Id)return target;
        }
        else if(Owner!=play.Link)return target;
        var run=p.Id==play.Starter?play.StarterTarget:play.ThirdTarget;
        p.RunTarget=run;p.RunUntil=Math.Max(p.RunUntil,play.Until);
        p.Action=p.Id==play.Starter?"Открывается под ответный пас":"Третий игрок в комбинации";
        return OnsideRunTarget(p,run);
    }

    void ResetCombinations()
    {
        foreach(var play in combinations)play.Clear();
    }
}