StrategyPlan.cs

Data model for scripted football plays plus the MatchSimulation partial that executes them. StrategyPlan stores five attacking start spots, five defender spots, and up to 16 StrategyPhase entries (pass, shot or carry) with per-player routes, and can estimate a success percentage and validate itself. StrategyExecution holds live run state, and the MatchSimulation half binds actors to players, drives routes and passes, handles first-time shots, offside checks, pause boundaries, and a rehearsal and mannequin trial mode.

Rough Code
using System;
using System.Collections.Generic;
using System.Linq;
namespace Socer;

// Serializable intent, independent of panels and renderers. Coordinates are attacking-team metres.
public sealed class StrategySpot
{
    public float X {get;set;} public float Y {get;set;}
    public StrategySpot(){} public StrategySpot(float x,float y){X=x;Y=y;}
    public Point Point()=>new(X,Y);
    public StrategySpot Copy()=>new(X,Y);
}
public sealed class StrategyRoute
{
    public int Role {get;set;}
    public bool Sprint {get;set;}
    public float Delay {get;set;}
    public List<StrategySpot> Points {get;set;}=new();
    public StrategyRoute Copy()=>new(){Role=Role,Sprint=Sprint,Delay=Delay,Points=Points.Select(p=>p.Copy()).ToList()};
}
public sealed class StrategyPhase
{
    public int Actor {get;set;}
    public int Receiver {get;set;}=1;
    public int Alternative {get;set;}=-1;
    public int Action {get;set;} // 0 pass, 1 shot, 2 carry/wait
    public int Delivery {get;set;} // feet, through, loft, driven cross
    public StrategySpot PassZone {get;set;}
    public int Shot {get;set;} // low, power, curl, chip
    public bool FirstTime {get;set;}
    public bool OnRun {get;set;}
    public int Corner {get;set;}=1;
    public bool WaitForRun {get;set;}
    public float Wait {get;set;}=.4f;
    public float Timeout {get;set;}=6;
    public List<StrategyRoute> Routes {get;set;}=new();
    public StrategyPhase Copy()=>new(){Actor=Actor,Receiver=Receiver,Alternative=Alternative,Action=Action,Delivery=Delivery,PassZone=PassZone?.Copy(),Shot=Shot,FirstTime=FirstTime,OnRun=OnRun,Corner=Corner,WaitForRun=WaitForRun,Wait=Wait,Timeout=Timeout,Routes=Routes.Select(r=>r.Copy()).ToList()};
}
public sealed class StrategyPlan
{
    public string Name {get;set;}="New play";
    public List<StrategySpot> Start {get;set;}=new(){new(65,20),new(73,29),new(63,10),new(80,39),new(65,46)};
    public List<StrategySpot> Defence {get;set;}=new(){new(77,19),new(83,29),new(86,40),new(77,49),new(68,34)};
    public List<bool> DefenderEnabled {get;set;}=new(){true,true,true,true,true};
    public List<StrategyPhase> Phases {get;set;}=new();
    public bool DefenderActive(int role)=>DefenderEnabled==null || DefenderEnabled[role];
    public StrategyPlan Copy()=>new(){Name=Name,Start=Start.Select(p=>p.Copy()).ToList(),Defence=Defence.Select(p=>p.Copy()).ToList(),DefenderEnabled=DefenderEnabled?.ToList()??new(){true,true,true,true,true},Phases=Phases.Select(p=>p.Copy()).ToList()};
    public int EstimatedSuccessPercent()
    {
        if(Validate()!="")return 0;
        if(Phases.All(f=>f.Action==2 && f.Routes.Count==0))return 0;
        var players=Start.Select(p=>p.Point()).ToArray();
        var defenders=Enumerable.Range(0,5).Where(DefenderActive).Select(i=>Defence[i].Point()).ToArray();
        float chance=1;
        int previousReceiver=-1;
        foreach(var phase in Phases)
        {
            var next=players.ToArray();
            foreach(var route in phase.Routes)
                if(route.Points.Count>0)next[route.Role]=route.Points[^1].Point();
            Point actor=next[phase.Actor];
            if(previousReceiver>=0 && phase.Actor!=previousReceiver)chance*=.72f;
            if(phase.Action==0)
            {
                var aim=phase.PassZone?.Point()??next[phase.Receiver];
                float distance=(aim-actor).Length;
                float receiverRun=(aim-next[phase.Receiver]).Length;
                float lane=defenders.Length==0?10:defenders.Min(d=>MatchSimulation.DistanceToSegment(d,actor,aim));
                float pass=.97f*MathF.Exp(-Math.Max(0,distance-15)*.015f-Math.Max(0,receiverRun-3)*.025f);
                pass*=Math.Clamp(.55f+lane*.15f,.55f,1);
                if(phase.Delivery==2)pass*=distance>18?1.02f:.90f;
                if(phase.Delivery==3)pass*=.94f;
                if(defenders.Length>=2)
                {
                    float offsideLine=defenders.OrderByDescending(d=>d.X).Skip(1).First().X;
                    if(aim.X>Math.Max(actor.X,offsideLine)+1)pass*=.70f;
                }
                chance*=Math.Clamp(pass,.02f,.98f);
                next[phase.Receiver]=aim;
                previousReceiver=phase.Receiver;
            }
            else if(phase.Action==1)
            {
                float distance=(new Point(105,34)-actor).Length;
                float shot=Math.Clamp(.83f-distance*.022f-Math.Abs(actor.Y-34)*.005f,.04f,.78f);
                float pressure=defenders.Length==0?10:defenders.Min(d=>(d-actor).Length);
                if(pressure<3)shot*=Math.Clamp(.68f+pressure*.11f,.68f,1);
                if(phase.FirstTime)shot*=.82f;
                if(phase.OnRun)shot*=.90f;
                chance*=shot;
                previousReceiver=-1;
            }
            else
            {
                float run=(next[phase.Actor]-players[phase.Actor]).Length;
                chance*=Math.Clamp(.98f*MathF.Exp(-Math.Max(0,run-8)*.009f),.35f,.98f);
                previousReceiver=phase.Actor;
            }
            players=next;
        }
        return Math.Clamp((int)MathF.Round(chance*100),1,99);
    }
    public string Validate()
    {
        if(string.IsNullOrWhiteSpace(Name) || Name.Length>60)return "Play name must contain 1 to 60 characters";
        if(Start==null || Start.Count!=5 || Defence==null || Defence.Count!=5)return "Five players and five defender positions are required";
        if(DefenderEnabled!=null && DefenderEnabled.Count!=5)return "Check the training defenders";
        if(Phases==null || Phases.Count<1 || Phases.Count>16)return "Add 1 to 16 phases";
        foreach(var s in Start.Concat(Defence))if(!Valid(s))return "A position is outside the pitch";
        foreach(var f in Phases)
        {
            if(f==null || f.Actor<0 || f.Actor>4 || f.Action<0 || f.Action>2 || f.Receiver<0 || f.Receiver>4 || f.Alternative< -1 || f.Alternative>4)return "Invalid player or action";
            if(f.PassZone!=null && !Valid(f.PassZone))return "Pass target is outside the pitch";
            if(f.Delivery<0 || f.Delivery>3 || f.Shot<0 || f.Shot>3 || f.Corner<0 || f.Corner>2)return "Invalid shot settings";
            if(f.Action==0 && (f.Receiver==f.Actor || f.Alternative==f.Actor))return "A player cannot pass to themselves";
            if(!float.IsFinite(f.Wait) || !float.IsFinite(f.Timeout) || f.Wait<0 || f.Timeout<f.Wait+.1f || f.Timeout>20)return "Check the phase timing";
            if(f.Routes==null || f.Routes.Any(r=>r==null) || f.Routes.Select(r=>r.Role).Distinct().Count()!=f.Routes.Count)return "Each player can have one route per phase";
            foreach(var r in f.Routes)if(r.Role<0 || r.Role>4 || r.Points==null || r.Points.Count>32 || r.Points.Any(p=>!Valid(p)) || !float.IsFinite(r.Delay) || r.Delay<0 || r.Delay>10)return "Invalid player route";
        }
        return "";
    }
    static bool Valid(StrategySpot p)=>p!=null && float.IsFinite(p.X) && float.IsFinite(p.Y) && p.X>=0 && p.X<=105 && p.Y>=0 && p.Y<=68;
    public static StrategyPlan Example()=>new(){Name="Wing overlap and cutback",Phases=new(){
        new(){Actor=0,Receiver=1,Alternative=4,Routes=new(){new(){Role=2,Sprint=true,Points=new(){new(76,10),new(90,17)}}}},
        new(){Actor=1,Receiver=2,Alternative=0,Delivery=1,Wait=.8f,Routes=new(){new(){Role=2,Sprint=true,Points=new(){new(90,17)}},new(){Role=3,Sprint=true,Points=new(){new(94,33)}}}},
        new(){Actor=2,Receiver=3,Alternative=1,Delivery=3,Wait=.6f,Routes=new(){new(){Role=3,Sprint=true,Points=new(){new(94,33)}}}},
        new(){Actor=3,Action=1,Shot=0,Corner=2,Wait=.25f}}};
}
public sealed class StrategyExecution
{
    public StrategyPlan Plan;
    public int Team,Phase,Expected=-1;
    public int[] Actors;
    public int[] Waypoints=new int[5];
    public float Started,PhaseAt,EndedAt;
    public bool Sent,Done;
    public Point TrainingPassFrom,TrainingPassTarget;
    public float TrainingPassAt,TrainingPassDuration;
    public bool TrainingPassLoft;
    public string Status="Розыгрыш";
    public List<string> Events=new();
    public Point Offset;
}
public sealed partial class MatchSimulation
{
    public StrategyExecution Strategy {get;private set;}
    // Training-only boundary. Stop before any action from the next phase can execute.
    public int StrategyPauseAtPhase=-1;
    // Rehearsal keeps phase transitions deterministic; mannequin trials use physical shots against passive opposition.
    public bool StrategyWalkthrough;
    public bool StrategyDummyMode;
    public bool StrategyGuaranteedPasses;
    bool PassiveTrainingOpponent(Footballer p)=>p.Team==1 && (StrategyWalkthrough || StrategyDummyMode);
    bool RehearsingStrategy=>StrategyWalkthrough && Strategy!=null && !Strategy.Done;
    int firstTimeContact=-1;
    float firstTimeIncomingSpeed;
    public int FirstTimeShots;
    int FirstTimePhase(Footballer p)
    {
        if(p.IsAcrobaticCommitted(Seconds))return -1;
        var s=Strategy;if(s==null || s.Done || p.Team!=s.Team || p.Keeper)return -1;
        int index=s.Sent && s.Expected==p.Id?s.Phase+1:s.Phase;
        if(index>=s.Plan.Phases.Count || (s.Sent && s.Expected!=p.Id))return -1;
        var f=s.Plan.Phases[index];
        return f.Action==1 && f.FirstTime && s.Actors[f.Actor]==p.Id?index:-1;
    }
    bool TryStrategyFirstTimeContact(Point before,float beforeHeight)
    {
        if(shot || PickupDelay>0 || Passer<0)return false;
        var delta=Ball-before;float length2=Point.Dot(delta,delta);
        foreach(var p in ActivePlayers.Where(p=>p.Team==LastTouch && p.Id!=Passer && Seconds>=p.FallUntil && FirstTimePhase(p)>=0).OrderBy(p=>(p.Position-before).Length))
        {
            float t=length2<.0001f?0:Math.Clamp(Point.Dot(p.Position-before,delta)/length2,0,1);
            var contact=before+delta*t;float height=beforeHeight+(BallHeight-beforeHeight)*t;
            if((contact-p.Position).Length>DribbleFootReach || height>.55f)continue;
            if(!StrategyGuaranteedPasses && !RehearsingStrategy && CheckOffside(p))return true;
            int phase=FirstTimePhase(p);
            Completed[p.Team]++;ClearOffside();
            if(phase>Strategy.Phase){Strategy.Events.Add($"Фаза {Strategy.Phase+1} · пас под удар в касание");NextStrategyPhase();}
            Ball=contact;BallHeight=height;Owner=-1;LastTouch=p.Team;Passer=-1;
            firstTimeContact=p.Id;firstTimeIncomingSpeed=BallSpeed;
            Strategy.Status="Мяч у ударной ноги · в касание";
            if(!StrategyBoundaryReached)ReleaseStrategyFirstTime();
            return true;
        }
        return false;
    }
    void AimStrategyShot(Footballer p,StrategyPhase f,bool firstTime,float incomingSpeed=0)
    {
        Strike=(StrikeKind)Math.Clamp(f.Shot,0,3);PowerShot=Strike==StrikeKind.Power;
        float distance=(World(new Point(105,34),p.Team)-p.Position).Length;
        float spread=firstTime?4+(1-p.Profile.Finishing)*8+(1-p.Stamina)*2+incomingSpeed*.10f+distance*.07f:(1-p.Profile.Finishing)*5;
        float error=StrategyWalkthrough?0:(Next()-.5f)*spread;
        ShotTarget=World(new Point(106,31.5f+Math.Clamp(f.Corner,0,2)*2.5f+error),p.Team);
    }
    void ReleaseStrategyFirstTime()
    {
        int id=firstTimeContact;firstTimeContact=-1;
        if(id<0)return;
        var p=Players[id];int phase=FirstTimePhase(p);
        if(p.IsAcrobaticCommitted(Seconds))return;
        if(phase<0){TakeBall(p);return;} // Author changed the next action while paused at contact.
        AimStrategyShot(p,Strategy.Plan.Phases[phase],true,firstTimeIncomingSpeed);
        ReleaseShot(p);FirstTimeShots++;p.Action="Удар в касание";
        Emit("shot",p,"В КАСАНИЕ!",1.6f);
        EndStrategy("Выход на удар · в касание");
    }
    // A loose dribble touch is still part of the rehearsal, just like a pass.
    // Only its intended actor may recover it; physical flight and foot reach remain unchanged.
    bool RehearsalReceiver(Footballer p)=>!RehearsingStrategy || p.Id==(Strategy.Sent?Strategy.Expected:Strategy.Actors[Strategy.Plan.Phases[Strategy.Phase].Actor]);
    bool AdvanceGuaranteedStrategyPass(float dt,Point before,float beforeHeight)
    {
        var s=Strategy;
        if(!StrategyGuaranteedPasses || s is not {Sent:true,Done:false} || s.Expected<0)return false;
        var receiver=Players[s.Expected];
        if(receiver.IsAcrobaticCommitted(Seconds))
        {EndStrategy("Участник поднимается после акробатического удара");return false;}
        float t=Math.Clamp((Seconds-s.TrainingPassAt)/Math.Max(.1f,s.TrainingPassDuration),0,1);
        // The ball follows the drawn destination, then bends toward the moving receiver.
        // This is a training visual; the chance estimate carries the realism penalty.
        float correction=Math.Clamp((t-.6f)/.4f,0,1);
        var destination=s.TrainingPassTarget+(receiver.Position-s.TrainingPassTarget)*correction;
        Ball=s.TrainingPassFrom+(destination-s.TrainingPassFrom)*t;
        BallVelocity=(Ball-before)*(1/Math.Max(dt,.001f));
        BallHeight=s.TrainingPassLoft?MathF.Sin(MathF.PI*t)*1.5f:0;
        VerticalSpeed=(BallHeight-beforeHeight)/Math.Max(dt,.001f);
        BallSpin=0;flightAge+=dt;trailClock+=dt;
        if(trailClock>=.035f){trailClock=0;FlightTrail.Add(new FlightMark{Position=Ball,Height=BallHeight});if(FlightTrail.Count>12)FlightTrail.RemoveAt(0);}
        if(t>=1)
        {
            Ball=receiver.Position;BallHeight=0;VerticalSpeed=0;
            if(!TryStrategyFirstTimeContact(before,beforeHeight))TakeBall(receiver);
        }
        return true;
    }
    Point StrategyDummyTarget(Footballer p)
    {
        int role=p.Id-12;
        if(Strategy?.Plan==null || role<0 || role>=5)return p.Position;
        var home=Strategy.Plan.Defence[role].Point();var toward=Ball-home;
        return Clamp(home+toward.Unit*Math.Min(toward.Length,2.5f)+new Point(0,MathF.Sin((Seconds-Strategy.Started)*1.1f+role*1.7f)*.45f));
    }
    public bool StrategyBoundaryReached=>StrategyPauseAtPhase>=0 && Strategy!=null && !Strategy.Done && Strategy.Phase>=StrategyPauseAtPhase;
    public static MatchSimulation StrategyTrial(StrategyPlan plan,int seed,int defence=1,bool walkthrough=false,bool dummies=false)
    {
        var m=new MatchSimulation(seed){RestartRemaining=0,Seconds=35,StrategyWalkthrough=walkthrough,StrategyDummyMode=dummies,StrategyGuaranteedPasses=true};
        foreach(var p in m.Players){p.Velocity=new();p.Position=m.World(p.Home,p.Team);}
        for(int i=0;i<5;i++){m.Players[6+i].Position=plan.Start[i].Point();m.Players[12+i].Position=plan.Defence[i].Point();if(!plan.DefenderActive(i))m.Players[12+i].SentOff=true;}
        if(walkthrough || dummies)
        {
            for(int i=1;i<=5;i++)m.Players[i].SentOff=true;
            for(int i=17;i<=21;i++)m.Players[i].SentOff=true;
        }
        m.Tactics[1]=Math.Clamp(defence,0,2);m.Approach[1]=defence==2?3:0;
        m.Owner=6+plan.Phases[0].Actor;m.LastTouch=0;m.Ball=m.Players[m.Owner].Position;m.Players[m.Owner].Held=.5f;
        m.StartStrategy(plan,0,true);return m;
    }
    public bool StartStrategy(StrategyPlan plan,int team=0,bool exact=false)
    {
        if(plan==null || plan.Validate()!="" || team<0 || team>1 || Finished || RestartRemaining>0 || GoalHoldRemaining>0 || IncidentRemaining>0 || Owner<0 || Players[Owner].Team!=team || Players[Owner].Keeper || (Strategy!=null && !Strategy.Done))return false;
        if(!BindStrategyActors(plan,team,exact,out var actors,out var offset))return false;
        Strategy=new(){Plan=plan.Copy(),Team=team,Actors=actors,Started=Seconds,PhaseAt=Seconds,Offset=offset};
        strategyLastUsed[plan.Name]=Seconds;
        AutomaticStrategyReadyAt=Math.Max(AutomaticStrategyReadyAt,Seconds+24);
        Strategy.Events.Add("Начало · "+plan.Name);return true;
    }
    bool BindStrategyActors(StrategyPlan plan,int team,bool exact,out int[] actors,out Point offset)
    {
        actors=new int[5];var used=new List<int>();
        int first=plan.Phases[0].Actor;
        actors[first]=Owner;used.Add(Owner);
        offset=exact?new Point():World(Players[Owner].Position,team)-plan.Start[first].Point();
        var bindingOffset=offset;
        for(int i=0;i<5;i++)if(i!=first)
        {
            var p=ActivePlayers.Where(p=>p.Team==team && !p.Keeper && !used.Contains(p.Id) && Seconds>=p.FallUntil)
                .OrderBy(p=>(World(p.Position,team)-plan.Start[i].Point()-bindingOffset).Length).FirstOrDefault();
            if(p==null)return false;actors[i]=exact?team*11+6+i:p.Id;used.Add(actors[i]);
        }
        return true;
    }
    void EndStrategy(string reason)
    {if(Strategy==null || Strategy.Done)return;Strategy.Done=true;Strategy.EndedAt=Seconds;Strategy.Status=reason;Strategy.Events.Add(reason);AutomaticStrategyReadyAt=Math.Max(AutomaticStrategyReadyAt,Seconds+12);}
    void NextStrategyPhase()
    {
        var s=Strategy;s.Phase++;s.Sent=false;s.Expected=-1;s.PhaseAt=Seconds;Array.Clear(s.Waypoints,0,5);
        if(s.Phase>=s.Plan.Phases.Count)EndStrategy("Схема выполнена");
    }
    void UpdateStrategy()
    {
        var s=Strategy;if(s==null || s.Done)return;
        if(GoalHoldRemaining>0){EndStrategy("Гол");return;}
        if(RestartRemaining>0 || IncidentRemaining>0 || Finished){EndStrategy("Остановка игры");return;}
        if((Owner>=0 && Players[Owner].Team!=s.Team) || (Owner<0 && LastTouch!=s.Team)){EndStrategy("Потеря · соперник перехватил инициативу");return;}
        if(s.Actors.Any(id=>Players[id].SentOff || Seconds<Players[id].FallUntil)){EndStrategy("Участник выбыл из розыгрыша");return;}
        var f=s.Plan.Phases[s.Phase];
        if(!s.Sent || !StrategyGuaranteedPasses)
            if(Seconds-s.PhaseAt>(StrategyWalkthrough?Math.Max(20,f.Timeout):f.Timeout)){EndStrategy("Время вышло · "+s.Status);return;}
        if(s.Sent && Owner==s.Expected){s.Events.Add($"Фаза {s.Phase+1} · пас принят");NextStrategyPhase();}
        else if(s.Sent && Owner>=0 && Owner!=s.Actors[f.Actor])EndStrategy("Мяч принял другой партнёр · свободная игра");
    }
    Point StrategyTarget(Footballer p,Point fallback)
    {
        var s=Strategy;if(s==null || s.Done || p.Team!=s.Team || WindupPlayer==p.Id || DuelDefender==p.Id)return fallback;
        int role=Array.IndexOf(s.Actors,p.Id);if(role<0)return fallback;
        var f=s.Plan.Phases[s.Phase];
        if(s.Sent && p.Id==s.Expected)return StrategyWalkthrough && flightAge>1.5f && BallHeight<1?Ball:IntendedPassTarget;
        // Recover a lost touch before continuing a carry route, also in real trials.
        if(!s.Sent && Owner<0 && p.Id==s.Actors[f.Actor])
        {p.Action="Возвращается к мячу";return Clamp(Ball+BallVelocity*.12f);}
        var route=f.Routes.FirstOrDefault(r=>r.Role==role);
        if(StrategyWalkthrough && !s.Sent && f.Action==0 && role==f.Receiver && (route==null || route.Points.Count==0))return p.Position;
        if(route==null || route.Points.Count==0)return p.Id==Owner && p.Id==s.Actors[f.Actor]?p.Position:fallback;
        if(Seconds-s.PhaseAt<route.Delay)return p.Position;
        int index=s.Waypoints[role];
        var target=Clamp(World(route.Points[index].Point()+s.Offset,s.Team));
        if((p.Position-target).Length<1.2f && index<route.Points.Count-1){s.Waypoints[role]++;target=Clamp(World(route.Points[index+1].Point()+s.Offset,s.Team));}
        // Stay onside until the ball is released; paths are intentions, never teleports.
        if(p.Id!=Owner && Owner>=0 && Players[Owner].Team==p.Team)
        {
            int dir=Direction(p.Team);float line=ActivePlayers.Where(q=>q.Team!=p.Team).Select(q=>q.Position.X*dir).OrderByDescending(x=>x).Skip(1).FirstOrDefault();
            target.X=dir*Math.Min(target.X*dir,Math.Max(52.5f*dir,Math.Max(Ball.X*dir,line-.6f)));
        }
        if(route.Sprint && (target-p.Position).Length>2){p.RunUntil=Seconds+.2f;p.RunTarget=target;}
        p.Action=route.Sprint?"Рывок по схеме":"Маршрут по схеме";return target;
    }
    bool TryStrategyAction(Footballer p)
    {
        var s=Strategy;if(s==null || s.Done || p.Team!=s.Team)return false;
        var f=s.Plan.Phases[s.Phase];
        if(s.Sent)return true;
        if(p.Id!=s.Actors[f.Actor]){s.Status="Ожидаем мяч у участника "+(char)('A'+f.Actor);return true;}
        if(f.Action==1 && f.FirstTime)
        {
            if((Ball-p.Position).Length<=DribbleFootReach){firstTimeContact=p.Id;firstTimeIncomingSpeed=BallSpeed;ReleaseStrategyFirstTime();}
            return true;
        }
        if(Seconds-s.PhaseAt<f.Wait){s.Status="Синхронизация движений";return true;}
        if(f.Action==2)
        {
            var route=f.Routes.FirstOrDefault(r=>r.Role==f.Actor);
            if(route==null || route.Points.Count==0 || (p.Position-Clamp(World(route.Points[^1].Point()+s.Offset,s.Team))).Length<1.5f)NextStrategyPhase();
            return true;
        }
        if(f.Action==1)
        {
            float distance=(World(new Point(105,34),p.Team)-p.Position).Length;
            var shotRun=f.Routes.FirstOrDefault(r=>r.Role==f.Actor);
            if(f.OnRun && shotRun?.Points.Count>0 && (p.Position-Clamp(World(shotRun.Points[^1].Point()+s.Offset,s.Team))).Length>2)
            {s.Status="Выход на удар в движении";return true;}
            BeginShot(p,distance);AimStrategyShot(p,f,false);
            if(f.OnRun){p.RunningShot=true;WindupRemaining=Math.Min(WindupRemaining,.18f);}
            EndStrategy("Выход на удар · дальнейшая игра свободная");return true;
        }
        int receiver=f.Receiver;
        bool Available(int r)=>r>=0 && r<5 && (StrategyGuaranteedPasses || StrategyWalkthrough || (IsOnside(Players[s.Actors[r]],p.Position) && SeesPartner(p,Players[s.Actors[r]]) &&
            PerceivedLane(p,r==f.Receiver && f.PassZone!=null?Clamp(World(f.PassZone.Point()+s.Offset,s.Team)):Players[s.Actors[r]].Position)>(f.Delivery==2?.35f:1.2f)));
        if(!Available(receiver))
        {
            if(!Available(f.Alternative)){s.Status="Пас закрыт или партнёр вне поля зрения";return true;}
            receiver=f.Alternative;s.Events.Add($"Фаза {s.Phase+1} · запасной пас");
        }
        var q=Players[s.Actors[receiver]];
        var receiverRoute=f.Routes.FirstOrDefault(r=>r.Role==receiver);
        if(!StrategyGuaranteedPasses && f.WaitForRun && receiver==f.Receiver && receiverRoute?.Points.Count>0 && (q.Position-Clamp(World(receiverRoute.Points[^1].Point()+s.Offset,s.Team))).Length>2.5f)
        {s.Status="Ожидаем завершения забегания";return true;}
        var target=f.PassZone!=null && receiver==f.Receiver?Clamp(World(f.PassZone.Point()+s.Offset,s.Team)):Clamp(q.Position+q.Velocity*(f.Delivery==1?.8f:.2f));
        float dist=(target-p.Position).Length;
        if(!StrategyGuaranteedPasses && dist>55){s.Status="Партнёр слишком далеко";return true;}
        if(!StrategyGuaranteedPasses && !PrepareKickFacing(p,target)){s.Status="Разворот под передачу";return true;}
        if(!StrategyGuaranteedPasses && !StrategyWalkthrough)target+=new Point((Next()-.5f)*(1-p.Profile.Passing)*1.8f,(Next()-.5f)*(1-p.Profile.Passing)*1.8f);
        Kick(p,target,Math.Clamp(9+dist*.45f+(f.Delivery==3?5:0),10,26),false);
        if(f.Delivery==2){ShapeFlight(target,StrikeKind.Cross);LoftedPasses++;}
        Passes[p.Team]++;PassReceiver=q.Id;IntendedPassTarget=target;s.Sent=true;s.Expected=q.Id;s.Status="Передача в движении";
        if(StrategyGuaranteedPasses)
        {
            s.TrainingPassFrom=Ball;s.TrainingPassTarget=target;s.TrainingPassAt=Seconds;
            s.TrainingPassDuration=Math.Clamp(Math.Max(dist/22f,(q.Position-target).Length/8f),.45f,8f);
            s.TrainingPassLoft=f.Delivery==2;
        }
        // A fallback ends this branch after reception rather than forcing the wrong next actor.
        if(receiver!=f.Receiver && s.Phase+1<s.Plan.Phases.Count && s.Plan.Phases[s.Phase+1].Actor!=receiver)
            s.Plan.Phases.RemoveRange(s.Phase+1,s.Plan.Phases.Count-s.Phase-1);
        return true;
    }
}