MatchSimulation.cs

One file of a soccer match simulation, split across partials. Defines the Point vector struct, the per-footballer state class, and the MatchSimulation core: kickoff/restart and set-piece setup, per-frame Step loop, player movement and braking, ball flight and interception, keeper saves and dives, goal/corner/throw-in detection, plus event log and Russian commentary strings. Pure C#, deterministic from a seed.

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

namespace Socer;

// Metres and simulation seconds. Pure C#: the same engine runs in s&box and the verification harness.
public struct Point
{
    public float X, Y;
    public Point(float x, float y) { X = x; Y = y; }
    public float Length => MathF.Sqrt(X * X + Y * Y);
    public Point Unit => Length < .001f ? new Point() : this * (1 / Length);
    public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
    public static Point operator -(Point a, Point b) => new(a.X - b.X, a.Y - b.Y);
    public static Point operator *(Point a, float f) => new(a.X * f, a.Y * f);
    public static float Dot(Point a, Point b) => a.X * b.X + a.Y * b.Y;
}

public sealed class Footballer
{
    public int Id, Team, Number;
    public Point Position, Velocity, Home;
    public float Skill, Pace, Decision, Held, TackleCooldown;
    public bool ChasingTouch;
    public Point CarryLook,AttackProgressPoint,AttackEscapeTarget;
    public float AttackProgressAt,AttackEscapeUntil;
    public BallChoice PlanChoice;
    public int PlanReceiver=-1;
    public Point PlanTarget;
    public float PlanUntil;
    public string PlanReason="";
    public Point KickAim;
    public float KickSetupUntil;
    public string DecisionReason="";
    public BallDecision LastBallDecision;
    public PlayerProfile Profile = new();
    public float Stamina=1, BurstCharge=1, BurstRemaining, BurstCooldown, Facing;
    public float UniqueSkillReadyAt,UniqueSkillActiveUntil,UniqueSkillConsiderAt;
    public readonly float[] SkillReadyAt=new float[3];
    public float SkillActivationGateUntil;
    public float ActiveSkillRemaining;
    public UniqueSkill ActiveUniqueSkill;
    public int ActiveSkillGrade;
    public LegendaryAbility ActiveLegendaryAbility;
    public float LegendaryReadyAt,LegendaryActiveUntil,LegendaryActivatedAt=-10,LegendaryRunUntil;
    public float AcrobaticAt=-10,AcrobaticUntil;
    public Point AcrobaticVelocity;
    public bool IsAcrobaticCommitted(float seconds)=>seconds>=AcrobaticAt && seconds<AcrobaticUntil;
    public Point LegendaryTargetA,LegendaryTargetB,LegendaryRunTarget;
    public int LegendaryTargetPlayerA=-1,LegendaryTargetPlayerB=-1,LegendaryAbilityActivations;
    public float ActiveSkillPower=>ActiveSkillGrade==2?1.35f:1;
    public float SpeechUntil, SpeechCooldown, RunUntil;
    public string Speech="", Action="Позиция";
    public string SpeechEnglish="";
    public string SpeechCleanRussian="",SpeechCleanEnglish="",SpeechLanguage="";
    public PlayerSpeechCue SpeechCue;
    public SpeechAudience SpeechAudience;
    public SpeechTone SpeechTone;
    public int SpeechTargetId=-1,SpeechSerial,SpeechHistoryCursor;
    public float LastSpeechAt=-10;
    public readonly int[] RecentSpeechIds=Enumerable.Repeat(-1,64).ToArray();
    public Point RunTarget;
    public int Sprints;
    public float Recovery;
    public Point DiveOrigin, DiveTarget;
    public float DiveRemaining, DiveAge, DiveHeight;
    public bool DiveConnected;
    public float KeeperBlockStartedAt,KeeperBlockUntil;
    public int KeeperBlockKind;
    public Point KeeperBlockTarget;
    // A save keeps its incoming-ball orientation through impact and recovery.
    public Point KeeperSaveFacing;
    public int Instruction;
    public int Yellows, FoulsCommitted;
    public bool SentOff, EmergencyKeeper;
    public InjurySeverity InjurySeverity;
    public InjuryBodyPart InjuryPart;
    public float InjuryStartedAt=-10,InjuryUntil;
    public bool InjuryCannotContinue,InjuryRemovedFromField;
    public bool InPlay=>!SentOff && !InjuryCannotContinue;
    public float Frustration, NextOutburstAt, CalmUntil;
    // Presentation timestamps do not participate in football decisions or randomness.
    public float LastKickAt=-10, LastSlideAt=-10;
    public float StandingTackleAt=-10, StandingTackleResolvedAt=-10;
    public Point StandingTackleAim;
    public int StandingTackleFoot=1, StandingTackleResult;
    public int StandingTackleVariant;
    public float GaitPhase, VisualSpeed, TurnLean, WindupStartedAt, LastKickFacing;
    public Point VisualBefore, VisualVelocity, VisualAcceleration;
    // Presentation heading survives a stopped frame and turns smoothly into a cut.
    public Point VisualTravel;
    // Predicted aerial presentation cue; never used by football decisions.
    public float VisualHeaderAt=-10,VisualHeaderHeight,VisualHeaderLift;
    public Point VisualHeaderPoint;
    public bool LastKickWasShot;
    public StrikeKind LastKickKind;
    public float DiveEndedAt=-10, LastControlAt=-10;
    public float HeaderLeap,HeaderAt=-10,AerialContestAt=-10,HeaderContactHeight=1.72f,AerialReadyAt,ChestUntil,ChestHeight,FeintUntil,FeintReadyAt,FeintStartedAt,BlockUntil;
    public Point HeaderContactPoint;
    public Point FeintDirection;
    public bool KeeperRushing;
    public float ScoopUntil;
    public float FallAt=-10,FallUntil;
    public Point FallDirection;
    public Point FallVelocity;
    public float FallSeverity,FallSpeed,ImpactAt=-10,ImpactUntil;
    public Point ImpactPoint,ImpactDirection,ImpactVelocity;
    public bool ImpactSlide;
    public float LastPuntAt=-10;
    public float NutmeggedUntil;
    public DirectOrderKind DirectOrder;
    public int DirectTargetId=-1,AutoMarkId=-1;
    public Point RoleMovementTarget;
    public float RoleThinkingUntil;
    public int RolePossession=-2;
    public Point DirectPoint,SecondBallPoint;
    public float DirectOrderUntil,SecondBallUntil;
    public float LastScanAt=-10,NextScanAt,HesitateUntil,StumbleUntil;
    public int FallVariant,SlideVariant,FeintVariant;
    public SkillMoveKind SkillMove;
    public int SkillStage,SkillOpponent=-1,SkillSide;
    public Point SkillAxis;
    public Point SkillNutmegGate;
    public float SkillAirStartedAt;
    public Point ScissorsAxis;
    public int ScissorsSide,ScissorsStage,ScissorsOpponent=-1;
    public Point SlideDirection;
    public float SlideSpeed;
    public float RecoveryChaseUntil,PassCallUntil,NextPassCallAt;
    public float DefensiveRecoveryUntil,DefensiveRecoveryBurstReadyAt;
    public int IgnoredPassCalls,PassCallOwner=-1;
    public bool AngryPassCall;
    public bool RunningShot;
    public Point ControlPoint;
    public float LastDribbleTouch=-10,NextDribbleTouch;
    public int DribbleFoot=1;
    public Point DribbleContact;
    public Point DribbleContactOffset;
    public float BodyLeanUntil;
    public Point BodyLeanDirection;
    public bool AerialChasing,ReceiveOnMove,InterceptBody;
    public Point CarryAvoidTarget;
    public float CarryAvoidUntil;
    public float InterceptAt=-10,InterceptUntil,InterceptHeight;
    public Point InterceptAim;
    public int InterceptFoot=1;
    public float Confidence=.6f,ReceiveReadyUntil,ReceiveUntil,ShotSetupUntil,ShieldUntil,ContainUntil;
    public Point ReceiveDirection,ShotSetupTarget,ShieldDirection,FootballRunTarget;
    public FootballRun FootballRun;
    public float FootballRunUntil,FootballRunStarted;
    public int RunSequence,BoxRole,KickFoot=1;
    public int PreferredFoot, WeakFootRating=3;
    public int StrongFoot=>PreferredFoot==0?(Id%5==2?-1:1):PreferredFoot;
    public bool UsedWeakFoot,RushedShot;
    public bool Sprinting => BurstRemaining>0 && Stamina>.12f && BurstCharge>.03f;
    public bool Keeper => Number == 1 || EmergencyKeeper;
}

public sealed partial class MatchSimulation
{
    public List<Footballer> Players { get; } = new();
    public List<string> Events { get; } = new();
    public int[] Goals = new int[2], Shots = new int[2], Passes = new int[2], Completed = new int[2], Saves = new int[2], Tackles = new int[2];
    public int[] PossessionLosses = new int[2], WallPassesCompleted = new int[2], ThirdManPassesCompleted = new int[2];
    public int[] DribbleAttemptsByTeam = new int[2], DribblesWonByTeam = new int[2];
    public float[] Possession = new float[2];
    public float[] TeamCohesion = { .5f, .5f };
    public int[] Tactics = { 1, 1 }; // 0 counter, 1 balanced, 2 pressing
    public int[] Approach = new int[2]; // 0 patient, 1 wide, 2 direct, 3 aggressive press
    public static readonly string[] ApproachNames={"Контроль мяча","Игра через фланги","Вертикальные атаки","Агрессивный прессинг"};
    public Point Ball, BallVelocity;
    public int Owner = -1, LastTouch, Passer = -1, Seed;
    public float Seconds, RestartRemaining;
    public bool Finished => !MedicalActive && (Abandoned || finalWhistleComplete ||
        (!managedMatchClock && SecondHalf && SecondHalfAddedMinutes>=0 && Seconds>=360+Math.Max(0,FirstHalfAddedMinutes)*4+SecondHalfAddedMinutes*4 && (!ShootoutRequired || Goals[0]!=Goals[1] || ShootoutComplete)));
    public bool SecondHalf;
    public string Status = "Начало матча";
    public int Minute => !SecondHalf?Math.Min(45,(int)(Seconds/4)):
        Math.Min(90,45+(int)Math.Max(0,(Seconds-180-Math.Max(0,FirstHalfAddedMinutes)*4)/4));
    public float BallSpeed => BallVelocity.Length;
    Random random;
    int restartTeam;
    Point restartPoint;
    bool kickoff, shot;
    bool goalKickRestart;
    public bool GoalKickPossession;
    float flightAge;
    readonly Point[] nextVelocity=new Point[22];
    readonly List<ReplayFigure.Part> keeperHeldBallPose=new(24);
    int lastShooter=-1;
    static Random seedGenerator=new();
    public static int CreateSeed(int previous=-1)
    {
        int seed;
        do { seed=seedGenerator.Next(1,int.MaxValue); } while(seed==previous);
        return seed;
    }
    public static int FriendlyOpponent(int seed)=>new Random(unchecked(seed^0x5A10E5)).Next(1,8);
    public MatchSimulation() : this(CreateSeed()) { }
    public MatchSimulation(int seed)
    {
        Seed = seed;
        random = new Random(seed);
        Approach[0]=new Random(unchecked(seed*7919+17)).Next(ApproachNames.Length);
        Approach[1]=new Random(unchecked(seed*104729+31)).Next(ApproachNames.Length);
        Tactics[1]=Approach[1]==2?0:Approach[1]==3?2:1;
        Point[] homes = { new(5,34), new(23,8), new(20,25), new(20,43), new(23,60), new(43,10), new(39,27), new(39,41), new(43,58), new(61,25), new(61,43) };
        for (int t = 0; t < 2; t++)
        for (int i = 0; i < 11; i++)
        {
            var p=new Footballer { Id=t*11+i, Team=t, Number=i+1, Home=homes[i], Skill=.65f+Next()*.3f, Pace=5.1f+Next()*1.2f };
            p.Profile=CreateProfile(p);
            Players.Add(p);
        }
        Restart(0, new Point(52.5f,34), "Начальный свисток", true);
    }
    float Next() => (float)random.NextDouble();
    public int Direction(int team) => (team == 0 ? 1 : -1) * (SecondHalf ? -1 : 1);
    public string HomeName=ClubCareer.Teams[0], AwayName=ClubCareer.Teams[1];
    public string TeamName(int team) => team == 0 ? HomeName : AwayName;
    public Point World(Point p, int team) => Direction(team) == 1 ? p : new Point(105-p.X,68-p.Y);
    public void AddEvent(string message)
    {
        Status = message;
        Events.Insert(0, $"{ClockLabel}′  {message}");
        if (Events.Count > 7) Events.RemoveAt(7);
    }
    void Restart(int team, Point where, string message, bool centre = false, bool goalKick = false, RestartKind kind=RestartKind.ThrowIn)
    {
        ResetManualRestart();
        ResetFootballContext();
        RegisterShotContact("ОСТАНОВКА");ClearOffside();OffsideLineUntil=0;nextKickExempt=false;indirectFlight=false;restartKickPending=false;
        CancelWindup();
        KeeperKickPlayer=-1;
        DuelDefender=-1; DuelCarrier=-1; DuelRemaining=0; PickupDelay=0; BodyChallenge=false;
        DuelClosestContact=float.MaxValue;
        Owner=-1; Passer=-1; shot=false; BallVelocity=new();
        ResetFlight(); GoalHoldRemaining=0;ShotMissFlightRemaining=0;ResetGoalCelebration();
        foreach(var keeper in ActivePlayers.Where(p=>p.Keeper))
        {keeper.DiveRemaining=0;keeper.KeeperBlockUntil=0;keeper.KeeperSaveFacing=new();}
        foreach(var player in Players)
        {
            player.LegendaryRunUntil=0;player.LegendaryActiveUntil=0;
            player.AcrobaticAt=-10;player.AcrobaticUntil=0;player.AcrobaticVelocity=new Point();
        }
        restartTeam=team; restartPoint=where; kickoff=centre;
        goalKickRestart=goalKick; GoalKickPossession=false;
        SetPiece=centre?RestartKind.KickOff:goalKick?RestartKind.GoalKick:kind;
        SprayMarked=false;RefereeSpraying=false;sprayAnimationRemaining=0;sprayMarkUntil=0;SprayMarkForward=new();
        keeperWallCalled=false;
        SetPieceTaker=ChooseTaker().Id;
        PrepareSetPiece();
        RestartRemaining=SetPiece==RestartKind.Corner?Math.Max(3,(Players[SetPieceTaker].Position-restartPoint).Length/8+.3f):
            SetPiece is RestartKind.FreeKick or RestartKind.Indirect?NeedsFreeKickSpray
                ?Math.Max(6.2f,(RefereePosition-restartPoint).Length/12f+1.3f)
                :Math.Max(1.8f,(Players[SetPieceTaker].Position-SetPieceRunupStart).Length/8f+.65f):
            SetPiece==RestartKind.Penalty?3.4f:SetPiece==RestartKind.GoalKick?2.6f:centre?2.5f:1.3f;
        if(SetPiece==RestartKind.Corner) Corners[team]++;
        Ball=where;
        if (centre)
        {
            foreach(var p in ActivePlayers) { var h=p.Home; h.X=Math.Min(h.X,48); p.Position=World(h,p.Team); p.Velocity=new(); p.Facing=Direction(p.Team)>0?0:MathF.PI; p.RunUntil=0; p.BurstRemaining=0; }
        }
        AddEvent(message);
    }
    void TakeBall(Footballer p)
    {
        if(PassiveTrainingOpponent(p) || p.IsAcrobaticCommitted(Seconds))return;
        bool scriptedReceiver=StrategyGuaranteedPasses && Strategy is {Sent:true} script && script.Expected==p.Id;
        if(!p.InPlay || MedicalActive || (!scriptedReceiver && Seconds<p.FallUntil) || (!scriptedReceiver && !RehearsingStrategy && CheckOffside(p))) return;
        int previousPossessionTeam=Owner>=0?Players[Owner].Team:lastPossessionTeam>=0?lastPossessionTeam:LastTouch;
        p.ControlPoint=Ball;
        if(p.DirectOrder==DirectOrderKind.AttackRun)p.DirectOrder=DirectOrderKind.None;
        p.ReceiveOnMove=false;
        p.CarryAvoidUntil=0;
        int incomingPasser=Passer;float incomingSpeed=BallSpeed;var incomingVelocity=BallVelocity;
        p.LastControlAt=Seconds;p.PlanUntil=0;p.ChasingTouch=false;p.KickSetupUntil=0;p.AttackEscapeUntil=0;p.AttackProgressAt=Seconds;p.AttackProgressPoint=p.Position;
        if(Passer>=0 && Players[Passer].Team!=p.Team)
        {Players[Passer].Frustration=Math.Min(1,Players[Passer].Frustration+.16f);Players[Passer].Confidence=Math.Max(.15f,Players[Passer].Confidence-.045f);}
        RegisterShotContact(p.Keeper?"ПОЙМАЛ":"КАСАНИЕ");SnapshotOffside(p,false);indirectFlight=false;
        if (WindupPlayer>=0 && WindupPlayer!=p.Id) CancelWindup();
        if(!scriptedReceiver && !RehearsingStrategy && (MediaFirstTouch(p,incomingPasser,incomingSpeed,incomingVelocity) || FirstTouch(p,incomingPasser,incomingSpeed,incomingVelocity)))
        {return;}
        if (Passer >= 0 && Players[Passer].Team == p.Team && Passer != p.Id) Completed[p.Team]++;
        NoteDialoguePass(p,incomingPasser);
        if(Passer>=0 && Players[Passer].Team==p.Team)Players[Passer].Confidence=Math.Min(.95f,Players[Passer].Confidence+.018f);
        Owner=p.Id; LastTouch=p.Team; Passer=-1; shot=false;
        RecordLinkedReception(p,incomingPasser);
        RecordSkillCoverRecovery(p,previousPossessionTeam);
        CompleteCombinationReception(p,incomingPasser);
        GoalKickPossession=false;
        ResetFlight(); BallHeight=p.Keeper?1:0;
        p.Held=0; p.Decision=.12f+(1-p.Profile.Vision)*.32f+(1-p.Profile.Composure)*.12f; BallVelocity=p.Keeper?new Point():p.Velocity*.35f;
        if(IsMediaLeague)mediaPlanUntil[p.Id]=0;
        if(p.Keeper || (Ball-p.Position).Length>1.2f)Ball=p.Position;
        p.NextDribbleTouch=Seconds+.06f;
        if(p.ReceiveOnMove)
        {
            BallVelocity=LimitDribbleImpulse(p.ReceiveDirection*(2.4f+Math.Min(3,p.Velocity.Length*.55f)));
            p.NextDribbleTouch=Seconds+.16f;p.Decision=Math.Min(p.Decision,.12f);
            p.DribbleContact=Ball;p.DribbleContactOffset=Ball-p.Position;p.LastDribbleTouch=Seconds;p.Action="Первым касанием на ход";
        }
        ApplyCombinationReceptionTiming(p);
    }
    public void Step(float dt)
    {
        if(dt<=0)return;
        if(MedicalActive){AdvanceFrozenAcrobaticRecoveries(dt);AdvanceMedicalTreatment(dt);UpdateFootballEmotions(dt);return;}
        if(Finished || ManualRestartWaiting) return;
        AdvanceRecoverableInjuries();
        managedMatchClock=true;
        foreach(var p in ActivePlayers)
        {p.VisualBefore=p.Position;p.ActiveSkillRemaining=Math.Max(0,p.ActiveSkillRemaining-dt);}
        if(ShootoutActive)
        {
            AdvanceShootout(dt);
            foreach(var p in ActivePlayers)AdvanceGait(p,dt,false,Seconds);
            UpdateFootballEmotions(dt);
            return;
        }
        if(PeriodWalkRemaining>0)
        {
            AdvanceFrozenAcrobaticRecoveries(dt);
            AdvancePeriodWalk(dt);
            foreach(var p in ActivePlayers) AdvanceGait(p,dt,false,Seconds);
            UpdateFootballEmotions(dt);
            return;
        }
        UpdateStrategy();
        if(StrategyBoundaryReached)return;
        if(firstTimeContact>=0)ReleaseStrategyFirstTime();
        foreach(var p in Players)ReplayFigure.AdvanceHeaderPreparation(p,this);
        StepCore(dt);
        foreach(var p in ActivePlayers) AdvanceGait(p,dt,Owner==p.Id,Seconds);
        ObservePlayerDialogue();
        UpdateFootballEmotions(dt);
    }
    bool IsOnside(Footballer receiver,Point ball)
    {
        return receiver.InPlay && !OffsidePosition(receiver,OffsideLimit(receiver.Team,ball));
    }
    internal static void AdvanceGait(Footballer p,float dt,bool carrying=false,float seconds=0)
    {
        var delta=p.Position-p.VisualBefore;
        // Ignore kickoff teleports and referee repositioning, never turn them into a running cycle.
        bool locomotion=delta.Length<=12*dt+.02f && p.DiveRemaining<=0 && p.Action!="Подкат" && !p.IsAcrobaticCommitted(seconds);
        float speed=locomotion?delta.Length/dt:0;
        p.VisualSpeed+=(speed-p.VisualSpeed)*(1-MathF.Exp(-dt*12));
        var velocity=locomotion?delta*(1/dt):new Point();
        var acceleration=(velocity-p.VisualVelocity)*(1/Math.Max(dt,.001f));
        if(acceleration.Length>12)acceleration=acceleration.Unit*12;
        p.VisualAcceleration=locomotion?p.VisualAcceleration+(acceleration-p.VisualAcceleration)*(1-MathF.Exp(-dt*9)):new Point();
        p.VisualVelocity=velocity;
        if(locomotion && velocity.Length>.04f)
        {
            float desired=MathF.Atan2(velocity.Y,velocity.X);
            if(p.VisualTravel.Length>.08f)
            {
                float previous=MathF.Atan2(p.VisualTravel.Y,p.VisualTravel.X);
                float turn=MathF.Atan2(MathF.Sin(desired-previous),MathF.Cos(desired-previous));
                desired=previous+turn*(1-MathF.Exp(-dt*13));
            }
            p.VisualTravel=new Point(MathF.Cos(desired),MathF.Sin(desired));
        }
        else if(delta.Length>3)p.VisualTravel=new Point();
        bool active=!p.Keeper && (carrying || p.RunUntil>seconds || p.FootballRunUntil>seconds ||
            p.Action is "Прессинг" or "Рывок" or "Рывок за мячом" or "Открывание" or "Ведение");
        float stepLength=Math.Clamp(1.3f+speed*.28f,1.3f,3.2f);
        if((active || speed>1.8f) && speed>1.1f)stepLength*=.8f;
        float braking=Math.Clamp(-Point.Dot(p.VisualAcceleration,velocity.Unit)/8,0,1);
        stepLength*=1-braking*.22f;
        if(speed>.03f) p.GaitPhase=(p.GaitPhase+delta.Length/stepLength)%1;
    }
    void StepCore(float dt)
    {
        if(!CanFieldCall || (Owner>=0?Players[Owner].Team:LastTouch)!=0)FieldAttackUntil=FieldShootUntil=0;
        UpdateFieldPass();
        if (Finished || dt <= 0) return;
        if(GoalHoldRemaining>0)
        {
            float dialogueExtra=dt;
            float clockAdvanced=0;
            if(GoalNetMotionRemaining>0)
            {
                float motion=Math.Min(dt,GoalNetMotionRemaining);
                clockAdvanced=motion;
                Seconds+=motion;GoalNetMotionRemaining-=motion;
                dialogueExtra-=motion;
                AdvanceGoalNet(motion);
            }
            AdvanceGoalDialogue(dialogueExtra);
            // Extra presentation time must not inflate the football's added
            // time budget beyond the previous five-second goal stoppage.
            CountGoalStoppage(Math.Min(dt,Math.Max(0,5f-GoalCelebrationElapsed)));
            foreach(var effect in Effects)effect.Age+=dt;
            Effects.RemoveAll(effect=>effect.Age>effect.Duration);
            AdvanceGoalCelebration(dt,clockAdvanced);
            GoalHoldRemaining=Math.Max(0,GoalHoldRemaining-dt);
            if(GoalHoldRemaining==0 && !CheckPeriodEnd()) Restart(1-pendingGoalTeam,new Point(52.5f,34),$"ГОООЛ! {TeamName(pendingGoalTeam)} · {Goals[0]} : {Goals[1]}",true);
            return;
        }
        ShotCueRemaining=Math.Max(0,ShotCueRemaining-dt);
        PickupDelay=Math.Max(0,PickupDelay-dt);
        foreach(var effect in Effects) effect.Age+=dt;
        Effects.RemoveAll(e=>e.Age>e.Duration);
        CountStoppage(dt);
        Seconds+=dt;
        AdvanceAcrobaticRecoveries(dt);
        AdvanceFalls(dt);
        UpdateOfficials(dt);
        AnnounceAddedTime();
        if (CheckPeriodEnd()) return;
        if(IncidentRemaining>0) {UpdateIncident(dt);return;}
        if(ShotMissFlightRemaining>0) {AdvanceMissedShotFlight(dt);return;}
        if (RestartRemaining > 0)
        {
            if(SetPieceTaker<0 || !Players[SetPieceTaker].InPlay)SetPieceTaker=ChooseTaker().Id;
            ArrangeSetPiece(dt);
            foreach(var p in ActivePlayers) Recover(p,dt);
            // A fouled taker may still be on the ground. Do not release a
            // prepared penalty/pass through the fall pose before they get up.
            if(Seconds<Players[SetPieceTaker].FallUntil){RestartRemaining=Math.Max(.35f,RestartRemaining);return;}
            if(WaitForManualRestart())return;
            RestartRemaining-=dt;
            if (RestartRemaining <= 0)
            {
                var taker=Players[SetPieceTaker];if(!taker.InPlay) taker=ChooseTaker();
                taker.Position=restartPoint;
                foreach(var p in ActivePlayers.Where(p=>p.Team!=restartTeam))
                    if ((p.Position-restartPoint).Length < 5) p.Position=Clamp(restartPoint+(p.Position-restartPoint).Unit*5);
                TakeBall(taker);
                GoalKickPossession=goalKickRestart;
                if(GoalKickPossession) { BallHeight=0; Ball=restartPoint; taker.Action="Удар от ворот"; }
                ReleaseSetPiece(taker);
            }
            return;
        }
        int possessionTeam=Owner >= 0 ? Players[Owner].Team : LastTouch;
        UpdateTacticalCharacters();
        PrepareInterceptions();
        Possession[possessionTeam]+=dt;
        UpdateChasers();
        UpdateDefensiveCoordination();
        UpdateFootballThinking(dt);
        UpdateKeepers(dt);
        Array.Clear(nextVelocity);
        foreach(var p in ActivePlayers)
        {
            p.Decision-=dt; p.TackleCooldown-=dt;
            p.Recovery=Math.Max(0,p.Recovery-dt);
            if (Owner==p.Id) p.Held+=dt;
            p.SpeechCooldown-=dt; p.BurstCooldown-=dt;
            int dir=Direction(p.Team);
            p.Action="Позиция";
            if(p.IsAcrobaticCommitted(Seconds))
            {p.Velocity=new Point();p.Action="Поднимается после акробатического удара";continue;}
            if(Seconds<p.FallUntil){p.Action=p.InjurySeverity==InjurySeverity.Minor?"Держится за ушибленное место":p.InjurySeverity==InjurySeverity.Knock?"Приходит в себя после столкновения":"Поднимается после падения";p.Velocity=new();continue;}
            if(Seconds<p.ImpactUntil){p.Velocity=new();continue;}
            float slideAge=Seconds-p.LastSlideAt;
            if(slideAge>=0 && slideAge<2.1f)
            {
                nextVelocity[p.Id]=slideAge<.65f?p.SlideDirection*Math.Clamp(p.SlideSpeed,2.5f,6)*MathF.Exp(-slideAge*4):new Point();
                p.Facing=MathF.Atan2(p.SlideDirection.Y,p.SlideDirection.X);
                p.Action=slideAge<.85f?"Подкат":"Встаёт после подката";
                continue;
            }
            bool dummy=PassiveTrainingOpponent(p) && !p.Keeper;
            Point target=dummy?StrategyDummyTarget(p):BuildMovementIntent(p,possessionTeam,dt);
            if(IsMediaLeague && !dummy)target=MediaMovement(p,target,possessionTeam);
            if(!dummy)UpdateEffort(p,target,dt,possessionTeam);
            if(p.Team==0 && Seconds<CoachUntil && CoachOrder==3) p.BurstRemaining=0;
            float pace=dummy?1.8f:EffectivePace(p);
            if(dummy){p.BurstRemaining=0;p.RunUntil=0;p.Action="Training dummy";}
            if(p.Recovery>0) { pace*=.4f; p.Action="Возвращается в игру"; }
            if(DuelCarrier==p.Id)
            {pace*=BodyChallenge?1-BodyPressureStrength:.85f;p.Action=BodyChallenge?"Удерживает мяч под давлением":"Прикрывает мяч";}
            if(Owner==p.Id && Seconds<p.ShieldUntil)pace*=.55f;
            bool racing=UrgentLooseBall(p) || p.KeeperRushing || (Owner==p.Id && !p.Keeper && (Ball-p.Position).Length>DribbleFootReach);
            bool receivingPass=!RehearsingStrategy && Owner<0 && p.Id==PassReceiver && p.Team==LastTouch;
            // Arrive in the path, do not sprint through the meeting point and spend
            // two seconds turning back after the ball has already passed.
            float distanceToTarget=(target-p.Position).Length;
            float brakingLimit=MathF.Sqrt(2*(5+p.Profile.Acceleration*4)*distanceToTarget);
            var desired=(target-p.Position).Unit * Math.Min(Math.Min(pace,brakingLimit),distanceToTarget*(receivingPass?1.6f:racing?5:2));
            if(Owner==p.Id) desired*=p.Sprinting?.96f:.9f;
            if(Owner==p.Id && !p.Keeper && WindupPlayer!=p.Id && Seconds>=p.KickSetupUntil)
            {
                var forward=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
                // Plant and turn before accelerating sideways with the ball.
                float facingAlignment=Point.Dot(forward,desired.Unit);
                desired*=Math.Clamp((facingAlignment+.8f)/1.8f,.65f,1);
                // Do not run past a slowing touch; turn back to recover it physically.
                var toBall=Ball-p.Position;
                if(toBall.Length>.25f && p.Velocity.Length>1 && Point.Dot(toBall,p.Velocity.Unit)<-.1f)
                    desired*=.6f;
            }
            var separation=new Point();
            foreach(var q in ActivePlayers)
            {
                if(q.Id==p.Id) continue;
                var d=p.Position-q.Position;
                if(d.Length<.8f && d.Length>.01f) separation+=d.Unit*(.8f-d.Length)*4;
            }
            var change=(desired+separation-p.Velocity);
            float alignment=Point.Dot(desired.Unit,p.Velocity.Unit);
            // Brake before reversing, then push off. Do not crawl through a long U-turn.
            bool reversing=alignment<-.2f && p.Velocity.Length>2 && !receivingPass;
            if(reversing)change=separation-p.Velocity;
            float acceleration=(4+p.Profile.Acceleration*5)*(reversing?1.15f:1)*dt;
            nextVelocity[p.Id]=p.Velocity+change.Unit*Math.Min(change.Length,acceleration);
            UpdateFacing(p,target,dt);
        }
        foreach(var p in ActivePlayers)
        {
            if(p.IsAcrobaticCommitted(Seconds)){p.Velocity=new Point();continue;}
            p.Velocity=nextVelocity[p.Id];
            if(p.Keeper && p.DiveRemaining>0)
            {
                var reach=p.DiveTarget-p.DiveOrigin;
                p.Position=Clamp(p.DiveOrigin+reach.Unit*Math.Min(1.5f,reach.Length)*Math.Clamp((p.DiveAge-.1f)/.35f,0,1));
                p.Velocity=new(); p.Action=p.DiveConnected?"Поймал мяч":"Тянется к мячу";
            }
            else p.Position=Clamp(p.Position+p.Velocity*dt);
        }
        ObserveSkillCover(dt);
        if(Owner>=0 && !Players[Owner].Keeper)
        {
            AdvanceDribble(dt);
            if(Owner<0 || RestartRemaining>0 || GoalHoldRemaining>0)return;
        }
        if(KeeperKickPlayer>=0)
        {
            if(AdvanceKeeperKick())return;
        }
        else if(Owner>=0 && Players[Owner].Keeper && !GoalKickPossession)
            FollowKeeperHands(Players[Owner]);
        ResolveChallenge(dt);
        if(RestartRemaining>0 || IncidentRemaining>0 || MedicalActive || Finished) return;
        if(Owner>=0)
        {
            var carrier=Players[Owner];
            if(KeeperKickPlayer==carrier.Id)return;
            var defender=ActivePlayers.Where(p=>p.Team!=carrier.Team && !p.IsAcrobaticCommitted(Seconds)).OrderBy(p=>(p.Position-carrier.Position).Length).FirstOrDefault();
            // A keeper who caught the ball holds it in his hands until distribution.
            // Allowing a tackle here produced endless save/steal/shoot loops at the goal line.
            if(defender!=null && !carrier.Keeper && DuelDefender<0 && (defender.Position-Ball).Length<1.65f && defender.Recovery<=0 && defender.TackleCooldown<=0 && carrier.Held>.35f)
            {
                if(!PassiveTrainingOpponent(defender) && ShouldChallenge(defender,carrier))BeginChallenge(defender,carrier);
            }
            if(WindupPlayer==carrier.Id)
            {
                WindupRemaining-=dt;
                if(WindupRemaining<=0) ReleaseShot(carrier);
                return;
            }
            if(defender!=null && (defender.Position-carrier.Position).Length<3) Say(carrier,"Сзади!",false);
            if(carrier.Decision<=0 && (carrier.Keeper || BallAtFoot(carrier))) { carrier.Decision=(.24f+(1-carrier.Profile.Vision)*.2f+(1-carrier.Stamina)*.18f+Next()*.18f)*DecisionTempo(carrier.Team); Decide(carrier); }
        }
        else MoveBall(dt);
    }
    void Decide(Footballer p)
    {if(!DecideMediaLeague(p))ChooseFootballAction(p);}
    void Kick(Footballer p, Point target, float speed, bool isShot,bool preserveOppositionOffside=false)
    {
        PassReceiver=-1;
        if(!isShot)OpponentMemory.Observe(Players,p,AttackMemory.PassCue(p.Position,target),Seconds);
        p.LastKickAt=Seconds;p.PlanUntil=0;
        p.KickSetupUntil=0;
        p.LastKickWasShot=isShot;p.LastKickKind=isShot?Strike:StrikeKind.Driven;
        p.LastKickFacing=MathF.Atan2(target.Y-p.Position.Y,target.X-p.Position.X);
        if(p.Keeper || (Ball-p.Position).Length>1.1f)Ball=p.Position+(target-p.Position).Unit*.35f;
        SnapshotOffside(p,nextKickExempt,preserveOppositionOffside);nextKickExempt=false;
        indirectFlight=restartKickPending;restartKickPending=false;
        if(isShot) {ShotSequence++;ShotContactKind="";lastShooter=p.Id;}
        Owner=-1; LastTouch=p.Team; Passer=isShot?-1:p.Id;
        GoalKickPossession=false;
        ResetFlight();
        BallVelocity=(target-Ball).Unit*speed; shot=isShot; flightAge=0;
    }
    void MoveBall(float dt)
    {
        var before=Ball;
        float beforeHeight=BallHeight;
        if(AdvanceGuaranteedStrategyPass(dt,before,beforeHeight))return;
        AdvanceFlight(dt);
        flightAge+=dt;
        if(HitGoalFrame(before,beforeHeight)) return;
        if(TryStrategyFirstTimeContact(before,beforeHeight))return;
        if(TryOpenPlayFirstTime(before,beforeHeight))return;
        if(!(RehearsingStrategy && !shot) && TryAerialContact(before,beforeHeight))return;
        // Swept interception prevents fast shots tunnelling through players.
        foreach(var p in ActivePlayers.Where(p=>Seconds>=p.FallUntil && Seconds-p.LastSlideAt>=2.1f && !p.IsAcrobaticCommitted(Seconds)).OrderBy(p=>(p.Position-before).Length))
        {
            // Training opponents are visual position markers. The keeper may dive,
            // but neither gloves nor bodies alter the ball's flight.
            if(PassiveTrainingOpponent(p))continue;
            if(!RehearsalReceiver(p))continue;
            // The first-time finisher waits for actual foot contact, not the wider trapping radius.
            if(FirstTimePhase(p)>=0)continue;
            if(PickupDelay>0 || ((p.Id==Passer || shot && p.Id==lastShooter) && flightAge<.35f)) continue;
            if(p.Keeper)
            {
                if(CheckOffsideInterference(p,before,beforeHeight))return;
                if(!KeeperTouches(p,before,beforeHeight)) continue;
            }
            else if(p.Team!=LastTouch)
            {
                if(!TryBodyContact(p,before,beforeHeight,out var contact,out float contactHeight,out var normal))continue;
                if(CheckOffsideInterference(p,before,beforeHeight))return;
                if(!RehearsingStrategy && CheckOffside(p))return;
                Ball=contact;BallHeight=contactHeight;
                RecordSkillCoverContact(p);
                ReboundInterception(p,normal);return;
            }
            else if(shot)
            {
                if(!TryBodyContact(p,before,beforeHeight,out var contact,out float contactHeight,out var normal))continue;
                if(!RehearsingStrategy && CheckOffside(p))return;
                Ball=contact;BallHeight=contactHeight;ReboundInterception(p,normal);return;
            }
            else if(Math.Min(beforeHeight,BallHeight)>.85f || DistanceToSegment(p.Position,before,Ball)>.65f) continue;
            if(!RehearsingStrategy && CheckOffside(p)) return;
            if(shot && !p.Keeper && Next()>(Seconds<p.BlockUntil?.88f:.5f)) continue;
            if(shot && p.Keeper)
            {
                p.DiveConnected=true;
                Saves[p.Team]++;
                if(KeeperShouldParry(p,BallSpeed,BallHeight))
                {OnLegendaryKeeperParry(p);DeflectBall(p,true);AddEvent($"{TeamName(p.Team)} · вратарь отбил мяч!");return;}
                AddEvent($"{TeamName(p.Team)} · сейв вратаря!");
                Emit("save",p,"СЕЙВ!",2); Say(p,"Мой!",true);
            }
            else if(shot && !p.Keeper && (Seconds<p.BlockUntil || Next()<.55f)) {DeflectBall(p,false);return;}
            else if(p.Team!=LastTouch) { AddEvent($"{TeamName(p.Team)} · перехват #{p.Number}"); Emit("intercept",p,"ПЕРЕХВАТ",1.4f); }
            if(p.Keeper) p.DiveConnected=true;
            TakeBall(p);
            if(p.Keeper)
            {
                if(p.KeeperRushing){KeeperClaims++;p.ScoopUntil=Seconds+.55f;p.KeeperRushing=false;Emit("save",p,"ЗАБРАЛ!",1.2f);}
                FollowKeeperHands(p);
                p.Action="Поймал мяч";
            }
            return;
        }
        if(Ball.X<0 || Ball.X>105)
        {
            float goalX=Ball.X<0?0:105;
            float fraction=(goalX-before.X)/(Ball.X-before.X);
            float crossingY=before.Y+(Ball.Y-before.Y)*fraction;
            float crossingHeight=beforeHeight+(BallHeight-beforeHeight)*fraction;
            int attacker=Direction(0)==(Ball.X>105?1:-1)?0:1;
            if(Math.Abs(crossingY-34)<3.55f && crossingHeight<2.33f && !indirectFlight)
            {
                RegisterShotContact("ГОЛ");
                Goals[attacker]++;
                GoalScorerId=shot && lastShooter>=0 && Players[lastShooter].Team==attacker
                    ?lastShooter:ActivePlayers.Where(p=>p.Team==attacker && !p.Keeper).OrderBy(p=>(p.Position-Ball).Length).First().Id;
                GoalMinute=InAddedTime?ClockLabel:Math.Max(1,Minute+1).ToString();
                BeginGoalCelebration(Players[GoalScorerId]);
                CelebrateDialogue(Players[GoalScorerId]);
                Players[GoalScorerId].Confidence=Math.Min(.95f,Players[GoalScorerId].Confidence+.12f);
                foreach(var loser in ActivePlayers.Where(p=>p.Team!=attacker)) loser.Frustration=Math.Min(1,loser.Frustration+.16f);
                EmitAt("goal",Ball,"ГОООЛ!",4);
                pendingGoalTeam=attacker;GoalNetMotionRemaining=1.0f;
                foreach(var keeper in ActivePlayers.Where(p=>p.Keeper && p.Team!=attacker && p.DiveRemaining>0 && !p.DiveConnected))
                { KeeperMisses++; Emit("miss",keeper,"НЕ ДОТЯНУЛСЯ",1.5f); }
                Ball=new Point(goalX==0?-.08f:105.08f,crossingY); BallHeight=Math.Max(0,crossingHeight);
                BallVelocity*=.58f; VerticalSpeed*=.35f;
                AddEvent($"ГОООЛ! {TeamName(attacker)} · {Goals[0]} : {Goals[1]}");
            }
            else
            {
                if(shot && lastShooter>=0) {var shooter=Players[lastShooter];shooter.Frustration=Math.Min(1,shooter.Frustration+.2f);SpeakEmotion(shooter,"Да блин, мимо!");}
                if(LastTouch!=attacker) Restart(attacker,new Point(goalX==0?.7f:104.3f,Ball.Y<34?.7f:67.3f),"Угловой · "+TeamName(attacker),kind:RestartKind.Corner);
                else if(shot) BeginMissedShotFlight(attacker,goalX,crossingHeight);
                else Restart(1-attacker,new Point(goalX==0?6:99,34),"Удар от ворот · "+TeamName(1-attacker),goalKick:true);
            }
        }
        else if(Ball.Y<0 || Ball.Y>68) Restart(1-LastTouch,new Point(Math.Clamp(Ball.X,2,103),Ball.Y<0?1:67),"Аут · "+TeamName(1-LastTouch));
    }
    public static Point Clamp(Point p) => new(Math.Clamp(p.X, .7f,104.3f),Math.Clamp(p.Y,.7f,67.3f));
    public static float DistanceToSegment(Point p, Point a, Point b)
    {
        var d=b-a;
        float f=Math.Clamp(Point.Dot(p-a,d)/Math.Max(.0001f,Point.Dot(d,d)),0,1);
        return (p-(a+d*f)).Length;
    }
}