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();
}
}