BallDecision.cs

Ball decision logic for the soccer match simulation. Picks carry, pass or shoot by scoring finishing lanes, pressure, open space and teammate pass value.

using System;
using System.Linq;
namespace Socer;

public enum BallChoice { Carry, Pass, Shoot }
public readonly record struct BallDecision(BallChoice Choice,int Receiver,float Carry,float Pass,float Shot,string Reason);

public sealed partial class MatchSimulation
{
    (Point Target,float Lane) FinishingWindow(Footballer p,float? contactTechnique=null)
    {
        Point best=World(new Point(105,34),p.Team);float bestValue=-100,bestLane=0;
        float technique=Math.Clamp(contactTechnique??p.Profile.Finishing,0,1);
        // The reliable finishing corridor comes from technique, not OVR. Less
        // precise contacts favour an inboard corner; excellent technique can
        // address the space beside the post without gaining impossible reach.
        float edge=1.8f+technique*1.42f;
        foreach(float y in new[]{34-edge,34-edge*.72f,32.6f,34f,35.4f,34+edge*.72f,34+edge})
        {
            var target=World(new Point(105,y),p.Team);var direction=(target-p.Position).Unit;
            float distance=(target-p.Position).Length,lane=5,keeperCover=0;
            foreach(var q in ActivePlayers.Where(q=>q.Team!=p.Team))
            {
                var seen=Observed(p,q);if(!seen.HasValue)continue;
                float along=Point.Dot(seen.Value-p.Position,direction);
                // A pursuer behind the striker pressures him, but cannot close every shot lane.
                if(along<.35f || along>distance+.3f)continue;
                float clearance=DistanceToSegment(seen.Value,p.Position,target);
                if(q.Keeper)
                {
                    // Judge the keeper where the ball crosses his line, not by his
                    // distance to the shooter. A displaced keeper exposes the far corner.
                    float atKeeper=distance>.1f?Math.Clamp(along/distance,0,1):0;
                    float flight=Math.Clamp(distance/29,.1f,.8f);
                    float read=p.Profile.Composure*.55f+p.Profile.Vision*.45f;
                    float keeperY=seen.Value.Y+Math.Clamp(q.Velocity.Y*flight*read,-.55f,.55f);
                    float lateral=Math.Abs(keeperY-(p.Position.Y+(target.Y-p.Position.Y)*atKeeper));
                    keeperCover=Math.Max(0,2.15f-lateral)*1.65f-Math.Min(lateral,5)*.14f;
                }
                else lane=Math.Min(lane,clearance);
            }
            float value=Math.Min(lane,2.5f)-keeperCover-Math.Abs(y-34)*.075f-Math.Max(0,.6f-lane)*8;
            if(value>bestValue){bestValue=value;best=target;bestLane=lane;}
        }
        return(best,bestLane);
    }
    // Training can reproduce an authored route; live routines yield to a clear finish.
    bool ClearFinishingChance(Footballer p)
    {
        var goal=World(new Point(105,34),p.Team);
        float distance=(goal-p.Position).Length;
        if(distance>23 || Math.Abs(p.Position.Y-34)>10 || World(p.Position,p.Team).X>104)return false;
        var direction=(goal-p.Position).Unit;
        if(FinishingWindow(p).Lane<.85f)return false;
        if(distance<16)return true;
        var keeper=ActivePlayers.FirstOrDefault(q=>q.Team!=p.Team && q.Keeper);
        var known=keeper==null?null:Observed(p,keeper);
        return known.HasValue && (Point.Dot(known.Value-p.Position,direction)<0 || DistanceToSegment(known.Value,p.Position,goal)>4);
    }
    Point PassMeetingPoint(Footballer p,Footballer q)
    {
        float distance=(q.Position-p.Position).Length;
        bool run=q.RunUntil>Seconds || (q.FootballRunUntil>Seconds && q.FootballRun is FootballRun.Behind or FootballRun.Overlap) || q.BoxRole is 1 or 2;
        float time=distance/GroundPassSpeed(p,distance);
        float synergy=run?PassingRunLink(p,q):0;
        // A linked passer aims where the partner will arrive, with a bounded lead.
        return Clamp(q.Position+q.Velocity*Math.Clamp(time*(run?.85f+synergy*.07f:.45f),0,run?1.15f:.65f));
    }
    Point ReceptionMeeting(Footballer p)
    {
        var at=Ball;var velocity=BallVelocity;float height=BallHeight,vertical=VerticalSpeed,spin=BallSpin;
        float pace=Math.Max(1,EffectivePace(p));var best=at;float bestMiss=float.MaxValue;
        const float dt=.06f;
        for(float time=dt;time<=4;time+=dt)
        {
            float angle=spin*dt,c=MathF.Cos(angle),s=MathF.Sin(angle);
            velocity=new Point(velocity.X*c-velocity.Y*s,velocity.X*s+velocity.Y*c);spin*=MathF.Exp(-dt*.4f);
            at+=velocity*dt;
            if(height>0 || vertical>0)
            {
                height+=vertical*dt-4.905f*dt*dt;vertical-=9.81f*dt;
                if(height<=0){height=0;vertical=-vertical*.32f;if(vertical<.7f)vertical=0;velocity*=.83f;}
            }
            velocity*=MathF.Exp(-dt*(height>.05f?.08f:.28f));
            if(at.X<0 || at.X>105 || at.Y<0 || at.Y>68)break;
            if(height>1.5f || (height>.85f && vertical>0))continue;
            var delta=at-p.Position;
            float turnDelay=.10f+Math.Max(0,pace-Point.Dot(p.Velocity,delta.Unit))/(4+p.Profile.Acceleration*5)*.35f;
            float miss=delta.Length-Math.Max(0,time-turnDelay)*pace;
            if(miss<bestMiss){bestMiss=miss;best=at;}
            if(miss<=.45f)return at;
        }
        return Clamp(best);
    }
    float PassSafety(Footballer p,Footballer q,Point target)
    {
        var delta=target-p.Position;float distance=delta.Length;
        float speed=GroundPassSpeed(p,distance);
        float margin=6;
        foreach(var rival in ActivePlayers.Where(r=>r.Team!=p.Team))
        {
            var observed=Observed(p,rival);if(!observed.HasValue)continue;
            for(int sample=1;sample<=4;sample++)
            {
                float t=sample*.25f;
                float arrival=distance*t/speed;
                var intercept=p.Position+delta*t;
                // Reaction and acceleration matter: compare arrival envelopes, not just
                // whether somebody happens to stand on the drawn line right now.
                float reach=.55f+Math.Max(0,arrival-.22f)*rival.Pace*.65f;
                margin=Math.Min(margin,(observed.Value-intercept).Length-reach);
            }
        }
        return Math.Clamp(.5f+margin*.22f,.02f,1);
    }
    float OpenCarryDistance(Footballer p)
    {
        var forward=(World(new Point(100,34),p.Team)-p.Position).Unit;
        float open=12;
        foreach(var q in ActivePlayers.Where(q=>q.Team!=p.Team && !q.Keeper))
        {
            var seen=Observed(p,q);if(!seen.HasValue)continue;
            var delta=seen.Value-p.Position;float along=Point.Dot(delta,forward);
            if(along>0 && along<open && (delta-forward*along).Length<2.8f)open=Math.Max(0,along-1.5f);
        }
        return open;
    }
    BallDecision EvaluateBallDecision(Footballer p,float pressure)
    {
        var goal=World(new Point(105,34),p.Team);float distance=(goal-p.Position).Length;
        float lane=FinishingWindow(p).Lane,open=OpenCarryDistance(p);
        float danger=Math.Clamp((3.4f-pressure)/3.4f,0,1);
        float longQuality=p.Profile.LongShots*.55f+p.Profile.ShotPower*.3f+p.Profile.Composure*.15f;
        float carry=3+Math.Min(pressure,6)*1.1f+open*.9f+(distance<38 && Math.Abs(p.Position.Y-34)<20?3:0);
        float shot=distance<40?25-distance*.85f+(distance>22?longQuality:p.Profile.Finishing)*5-
            Math.Max(0,2-lane)*7-Math.Abs(p.Position.Y-34)*.22f:-100;
        if(distance<13 && lane>1.5f)shot+=9;
        if(distance<18 && lane>1 && Seconds-p.LastControlAt<1.2f)shot+=2.2f*SkillStrength(p,UniqueSkill.Poacher);
        if(distance<23 && lane>1 && Math.Abs(p.Position.Y-34)<12)shot+=10;
        if(distance>=18 && distance<34 && lane>1.2f && Math.Abs(p.Position.Y-34)<14)
            shot+=longQuality*6+AttackUrgency(p.Team)*9;
        // A good lane at the edge of the box is temporary. If a defender blocks
        // further progress, finish before he closes it instead of recycling a
        // safe sideways pass. An unchallenged carrier still advances closer.
        if(distance is >15 and <23 && Math.Abs(p.Position.Y-34)<12 && lane>1.05f &&
            open<5.5f && pressure<4.5f)
            shot+=6+p.Profile.Finishing*3;
        // Keep progressing into a better shooting window when there is an open
        // route. Distant attempts remain possible for a specialist under pressure
        // or when chasing the score late in the match.
        if(distance>21 && pressure>3.2f && open>4.5f)
            shot-=11+Math.Min(open,10)*.8f;
        if(distance>24 && (longQuality<.68f || lane<1.15f))shot-=15;
        if(distance>29 && danger<.2f && AttackUrgency(p.Team)<.8f)shot-=30;
        if(distance>18 && pressure>2.8f && open>5 && AttackUrgency(p.Team)<.65f)carry+=5;
        float stale=p.Held>1.5f && p.AttackProgressAt>0?Math.Clamp(Seconds-p.AttackProgressAt-1,0,3):0;
        carry-=stale*2.5f;
        if(distance<38 && Math.Abs(p.Position.Y-34)<18 && lane>.5f)
            shot+=stale*(1.5f+p.Profile.Finishing)+Math.Max(0,3-pressure)*(.6f+p.Frustration);
        if(BoundaryMargin(p)<1.6f)carry-=4;
        if(ClearFinishingChance(p))shot=70;
        var best=ActivePlayers.Where(q=>q.Team==p.Team && q.Id!=p.Id && !q.Keeper)
            .Select(q=>new{Player=q,Value=PassValue(p,q,pressure)})
            .OrderByDescending(q=>q.Value).FirstOrDefault();
        float pass=best?.Value??-100;
        // A small advantage is not enough to give away a clean run towards goal.
        var choice=shot>Math.Max(carry,pass)?BallChoice.Shoot:pass>carry+1?BallChoice.Pass:BallChoice.Carry;
        string reason=choice==BallChoice.Shoot?"Есть коридор для удара":choice==BallChoice.Pass?"Свободный адресат и безопасная линия":open>6?"Свободный путь к воротам":"Продвигается с мячом";
        return new(choice,best?.Player.Id??-1,carry,pass,shot,reason);
    }
    bool PrepareKickFacing(Footballer p,Point target)
    {
        if(p.Keeper || Owner!=p.Id)return true;
        var aim=(target-p.Position).Unit;
        if(Point.Dot(new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing)),aim)>=.68f)
        {p.KickSetupUntil=0;return true;}
        p.KickAim=target;p.KickSetupUntil=Seconds+1.8f;p.Decision=.09f;
        p.Action="Разворачивается для передачи";return false;
    }
}