MatchController.cs

The main game component for a football management game. Owns the MatchSimulation, career and club screens, broadcast camera, goal replay, direct player orders, and the per-frame loop that steps the simulation and builds the HUD components (MatchHud, ClubHud, StrategyEditor, GameSettingsHud). Also handles career saves, pack opening, injuries/substitutions and preview/test scenarios.

Rough Code
using Sandbox;
using Sandbox.UI;
using Socer;
using System.Collections.Generic;
using System.Linq;

public sealed partial class MatchController : Component
{
    // Technical-area presentation is updated alongside the crowd, outside simulation ticks.
    public MatchSimulation Match { get; private set; } = new();
    public MatchAtmosphere Atmosphere {get;}=new();
    public bool Paused { get; set; }
    public MatchDirector Director { get; private set; } = new();
    public float Speed { get => Director.ManualSpeed; set { Director.ManualSpeed=System.Math.Clamp(value,.5f,1.5f); Director.Automatic=false; Director.Update(Match,0); } }
    public BroadcastCamera Broadcast { get; private set; } = new();
    public const float ManualCameraHoldSeconds=2.5f;
    float manualCameraUntil;
    MatchSimulation manualCameraMatch;
    public bool ManualCameraActive=>Time.Now<manualCameraUntil;
    public float ManualCameraRemaining=>System.Math.Clamp(manualCameraUntil-Time.Now,0,ManualCameraHoldSeconds);
    public float ManualCameraProgress=>ManualCameraRemaining/ManualCameraHoldSeconds;
    public bool FieldDragSlow {get;private set;}
    public float PlaybackRate=>(FieldDragSlow || CommandWheelOpen || AwaitingOrder!=DirectOrderKind.None)?.2f:MediaLeagueActive?1.5f:FootballLabActive?Director.Rate:Director.PlaybackRate;
    public void SetFieldDragSlow(bool active)
    {
        active&=!ClubOpen && !StrategyLabOpen && !Match.Finished && !Replay.Playing && !Paused;
        if(FieldDragSlow==active)return;
        FieldDragSlow=active;
        accumulator=0;
    }
    public void PanBroadcastCamera(Point delta)
    {
        if(ClubOpen || StrategyLabOpen || Match.Finished || Replay.Playing)return;
        Broadcast.Center=new Point(System.Math.Clamp(Broadcast.Center.X+delta.X,-8,113),
            System.Math.Clamp(Broadcast.Center.Y+delta.Y,-9,77));
        Broadcast.Enabled=true;
        Broadcast.ResumeFollow();
        manualCameraUntil=Time.Now+ManualCameraHoldSeconds;
    }
    public void ZoomBroadcastCamera(float wheel)
    {
        if(ClubOpen || StrategyLabOpen || Match.Finished || Replay.Playing || System.Math.Abs(wheel)<.001f)return;
        float baseWidth=System.Math.Max(113,76*ViewAspect);
        float zoom=System.Math.Clamp(baseWidth/System.Math.Max(20,Broadcast.ViewWidth),1,5);
        // Panel wheel values are positive when scrolling down.
        zoom=System.Math.Clamp(zoom*System.MathF.Exp(-wheel*.13f),1,5);
        Broadcast.Zoom=zoom;
        Broadcast.ViewWidth=baseWidth/zoom;
        Broadcast.ViewHeight=Broadcast.ViewWidth/System.Math.Max(.5f,ViewAspect);
        Broadcast.Enabled=true;
        Broadcast.ResumeFollow();
        manualCameraUntil=Time.Now+ManualCameraHoldSeconds;
    }
    public void ReturnToBroadcastCamera(){manualCameraUntil=0;Broadcast.Enabled=true;}
    public void ToggleBroadcastCamera()
    {
        if(ManualCameraActive)ReturnToBroadcastCamera();
        else Broadcast.Enabled=!Broadcast.Enabled;
    }
    public GoalReplay Replay { get; } = new();
    public float ViewAspect { get; set; } = 1.8f;
    public float PreviewCameraWidth {get;set;}
    public bool FootballLabActive {get;private set;}
    public string FootballLabScenario {get;private set;}="dribble";
    public int FootballLabSeed {get;private set;}=2026;
    public int FootballLabAttackerRating {get;private set;}=84;
    public int FootballLabDefenderRating {get;private set;}=78;
    public bool FootballLabTrialComplete {get;private set;}
    public float FootballLabStartedAt {get;private set;}
    public float FootballLabDuration=>FootballLabScenario switch
    {
        "nutmeg"=>2.0f,"tackle" or "tackle-front" or "tackle-side" or "tackle-recovery" or "tackle-slide" or "shoulder"=>4.5f,"sprint"=>6f,
        "dribble" or "one-on-one" or "cross"=>8f,
        "free-kick" or "corner" or "penalty"=>12f,
        "match"=>float.PositiveInfinity,_=>FootballLabScenario.StartsWith("skill-")?3.5f:7f
    };
    public bool ShowVision { get; set; } = true;
    public bool ShowSpeech { get; set; } = true;
    public int SelectedPlayer { get; set; } = 9;
    public int OrderedPlayer { get; private set; }=9;
    public DirectOrderKind AwaitingOrder { get; private set; }
    readonly List<FieldPing> fieldPings=new();
    int nextFieldPing;
    MatchSimulation pingMatch;
    public IReadOnlyList<FieldPing> FieldPings=>fieldPings;
    public void EmitFieldPing(FieldPingKind kind,Point point,int playerId=-1)
    {
        if(pingMatch!=Match){fieldPings.Clear();pingMatch=Match;}
        fieldPings.RemoveAll(p=>Time.Now-p.StartedAt>1.55f);
        if(fieldPings.Count>=5)fieldPings.RemoveAt(0);
        fieldPings.Add(new FieldPing(++nextFieldPing,kind,point,playerId,Time.Now));
        TechnicalArea.Command(this,kind,point,playerId);
        ObserveMatchTutorialPing(kind,point,playerId);
    }
    public string OrderPrompt=>AwaitingOrder switch
    {
        DirectOrderKind.PressPlayer=>CommandText("Выберите соперника для прессинга","Select an opponent to press"),
        DirectOrderKind.MarkPlayer=>CommandText("Выберите соперника для опеки","Select an opponent to mark"),
        DirectOrderKind.GuardZone=>CommandText("Выберите зону на поле","Choose a defensive zone on the pitch"),
        DirectOrderKind.AttackRun=>CommandText("Выберите точку для забегания","Choose a point for the attacking run"),
        _=>"Select your player and give an instruction"
    };
    public void SelectPlayer(int id,bool showPing=true)
    {
        if(id<0 || id>=Match.Players.Count || Match.Players[id].SentOff)return;
        SelectedPlayer=id;
        if(id<11)
        {
            OrderedPlayer=id;manualSelectionMatch=Match;AwaitingOrder=DirectOrderKind.None;
            if(showPing)EmitFieldPing(FieldPingKind.Select,Match.Players[id].Position,id);
            return;
        }
        // Roster and set-piece panels select opponents for inspection. The
        // field surface issues default marking explicitly through CallMarkTarget.
        if(AwaitingOrder is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer)CallMarkTarget(id);
        else if(showPing)EmitFieldPing(FieldPingKind.Select,Match.Players[id].Position,id);
    }
    int ManualCommandActor=>manualSelectionMatch==Match && OrderedPlayer>=0 && OrderedPlayer<11 &&
        !Match.Players[OrderedPlayer].SentOff && !Match.Players[OrderedPlayer].Keeper && Match.Seconds>=Match.Players[OrderedPlayer].FallUntil?OrderedPlayer:-1;
    public bool CallMarkTarget(int targetId,int actor=-1)
    {
        var kind=actor<0 && AwaitingOrder is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer?AwaitingOrder:DirectOrderKind.MarkPlayer;
        if(actor<0)actor=AwaitingOrder==kind && AwaitingOrder!=DirectOrderKind.None?OrderedPlayer:ManualCommandActor;
        return CallOpponentTarget(targetId,kind,actor);
    }
    bool CallOpponentTarget(int targetId,DirectOrderKind kind,int actor)
    {
        string reason=FieldCallContextFailure();
        if(reason!=""){NotifyCommand(reason);return false;}
        if(actor>=0)
        {
            reason=Match.FieldOrderFailure(actor,kind,targetId);
            if(reason!=""){NotifyCommand(FieldCommandFailureText(reason));return false;}
        }
        else
        {
            if(targetId<11 || targetId>=Match.Players.Count || Match.Players[targetId].SentOff)
            {NotifyCommand(FieldCommandFailureText("opponent"));return false;}
            if(Match.Players[targetId].Keeper){NotifyCommand(FieldCommandFailureText("target-keeper"));return false;}
        }
        int accepted=Match.CallOpponentOrder(targetId,kind,actor);
        if(accepted<0){NotifyCommand(FieldCommandFailureText("available-actor"));return false;}
        OrderedPlayer=accepted;SelectedPlayer=targetId;AwaitingOrder=DirectOrderKind.None;
        EmitFieldPing(kind==DirectOrderKind.PressPlayer?FieldPingKind.Press:FieldPingKind.Mark,Match.Players[targetId].Position,targetId);
        var player=Match.Players[accepted];var target=Match.Players[targetId];
        string action=kind==DirectOrderKind.PressPlayer?CommandText("Прессинг","Press"):CommandText("Опека","Mark");
        NotifyCommand($"#{player.Number} · {action} #{target.Number}"+(Match.DefensiveOrderPending(player)?CommandText(" при потере мяча"," after possession is lost"):""));
        return true;
    }
    public bool CallAttackTarget(bool force=false)
    {
        string reason=FieldCallContextFailure();
        if(reason=="")reason=FieldCommandFailureText(Match.FieldAttackFailure());
        if(reason!=""){NotifyCommand(reason);return false;}
        if(!Match.CallAttack(force)){NotifyCommand(FieldCommandFailureText(Match.FieldAttackFailure()));return false;}
        EmitFieldPing(FieldPingKind.Attack,Match.HomeAttackGoal);
        NotifyCommand(force?CommandText("Подготовить удар сейчас","Prepare a shot now"):CommandText("Обострить и искать момент для удара","Attack and look for a shooting chance"));
        return true;
    }
    public bool CallPassTarget(int targetId)
    {
        string reason=FieldCallContextFailure();
        if(reason=="")reason=FieldCommandFailureText(Match.FieldPassFailure(targetId));
        if(reason!=""){NotifyCommand(reason);return false;}
        if(!Match.CallPass(targetId)){NotifyCommand(FieldCommandFailureText(Match.FieldPassFailure(targetId)));return false;}
        EmitFieldPing(FieldPingKind.Pass,Match.Players[targetId].Position,targetId);
        NotifyCommand(CommandText($"Пас на #{Match.Players[targetId].Number}",$"Pass to #{Match.Players[targetId].Number}"));
        return true;
    }
    public bool CallMoveTarget(int actor,Point point)
    {
        string reason=FieldCallContextFailure();
        var kind=(Match.Owner>=0?Match.Players[Match.Owner].Team:Match.LastTouch)==0?DirectOrderKind.AttackRun:DirectOrderKind.GuardZone;
        if(reason=="")reason=FieldCommandFailureText(Match.FieldOrderFailure(actor,kind,point:point));
        if(reason!=""){NotifyCommand(reason);return false;}
        if(!Match.CallMovePlayer(actor,point)){NotifyCommand(FieldCommandFailureText(Match.FieldOrderFailure(actor,kind,point:point)));return false;}
        OrderedPlayer=actor;
        EmitFieldPing(kind==DirectOrderKind.AttackRun?FieldPingKind.Run:FieldPingKind.Zone,point);
        NotifyCommand(CommandText($"#{Match.Players[actor].Number} · "+(kind==DirectOrderKind.AttackRun?"Забегание":"Держать зону"),
            $"#{Match.Players[actor].Number} · "+(kind==DirectOrderKind.AttackRun?"Make a run":"Hold zone")));
        return true;
    }
    public void BeginDirectOrder(DirectOrderKind kind)
    {
        string reason=FieldCallContextFailure();
        if(reason=="" && (OrderedPlayer<0 || OrderedPlayer>=11 || Match.Players[OrderedPlayer].SentOff))reason=FieldCommandFailureText("actor");
        if(reason=="" && Match.Players[OrderedPlayer].Keeper)reason=FieldCommandFailureText("actor-keeper");
        if(reason=="" && Match.Seconds<Match.Players[OrderedPlayer].FallUntil)reason=FieldCommandFailureText("fallen");
        if(reason!=""){NotifyCommand(reason);return;}
        if(kind is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer && SelectedPlayer>=11 && SelectedPlayer<Match.Players.Count && !Match.Players[SelectedPlayer].SentOff)
        {
            CallOpponentTarget(SelectedPlayer,kind,OrderedPlayer);return;
        }
        commandWheelMatch=Match;AwaitingOrder=kind;
        NotifyCommand(OrderPrompt);
    }
    public bool AssignFieldOrder(Point where)
    {
        if(AwaitingOrder is not (DirectOrderKind.GuardZone or DirectOrderKind.AttackRun))return false;
        string reason=FieldCallContextFailure();
        if(reason=="")reason=FieldCommandFailureText(Match.FieldOrderFailure(OrderedPlayer,AwaitingOrder,point:where));
        if(reason!=""){NotifyCommand(reason);return false;}
        bool done=Match.GiveDirectOrder(OrderedPlayer,AwaitingOrder,point:where);
        if(done)
        {
            EmitFieldPing(AwaitingOrder==DirectOrderKind.AttackRun?FieldPingKind.Run:FieldPingKind.Zone,where);
            NotifyCommand(CommandText($"#{Match.Players[OrderedPlayer].Number} · приказ принят",$"#{Match.Players[OrderedPlayer].Number} · instruction received"));
            AwaitingOrder=DirectOrderKind.None;
        }
        else NotifyCommand(FieldCommandFailureText(Match.FieldOrderFailure(OrderedPlayer,AwaitingOrder,point:where)));
        return done;
    }
    public void CancelDirectOrder()=>AwaitingOrder=DirectOrderKind.None;
    public void ClearDirectOrder()
    {
        if(!Match.GiveDirectOrder(OrderedPlayer,DirectOrderKind.None))
        {NotifyCommand(FieldCommandFailureText(Match.FieldOrderFailure(OrderedPlayer,DirectOrderKind.None)));return;}
        if(OrderedPlayer==Match.FieldPassReceiver)Match.CancelFieldPass();
        if(OrderedPlayer==Match.Owner)Match.FieldAttackUntil=Match.FieldShootUntil=0;
        AwaitingOrder=DirectOrderKind.None;
        EmitFieldPing(FieldPingKind.Clear,Match.Players[OrderedPlayer].Position,OrderedPlayer);
        NotifyCommand(CommandText($"#{Match.Players[OrderedPlayer].Number} · приказ снят",$"#{Match.Players[OrderedPlayer].Number} · order cleared"));
    }
    public int Revision { get; private set; }
    public ClubCareer Career { get; private set; }
    public bool ClubOpen { get; private set; }=true;
    public bool GameSettingsOpen {get;private set;}
    bool settingsWasPaused;
    public int TutorialStep {get;private set;}=-1;
    public string ClubPage { get; set; }="Состав";
    public string ClubNotice { get; private set; }="";
    public int SelectedClub { get; set; }=1;
    public PlayerCard RevealedCard { get; private set; }
    public PackOpening RevealedPack { get; private set; }
    bool saveBlocked;
    bool careerMatch;
    bool attemptBound;
    string lastRoundError;
    MatchSimulation appliedClubPlan;
    int friendlyOpponent=1;
    MatchSimulation previewOriginal;
    MatchDirector previewDirector;
    BroadcastCamera previewCamera;
    bool previewWasPaused, previewClubOpen;
    int previewSelection;
    float accumulator;
    int observedGoals;
    float replayAfterGoal;
    public void EnableAutomatic() { Director.Automatic=true; Director.Update(Match,0); }
    protected override void OnStart()
    {
        GamePreferences.Load();
        GameAudio.Prepare();
        PlayerCatalog.Reload();
        LoadCareerForStart();
        Career.EnsurePersonalities();
        if(Career.Pending!=null)
        {
            if(Career.HasCompleteLineup){StartCareer(Career.Pending.Cup);ClubNotice="Unfinished match restarted. Rewards are paid after full time.";}
            else
            {
                // A saved empty XI is a valid preparation state. Keep its
                // fixture editable after reload, without starting simulation.
                Match=new MatchSimulation(Career.Pending.Seed);careerMatch=true;Paused=true;
                ClubNotice="Fill all 11 positions before starting a match.";
            }
        }
        else {friendlyOpponent=MatchSimulation.FriendlyOpponent(Match.Seed);Career.Apply(Match,friendlyOpponent);Paused=true;}
        ResetMatchSubstitutions();
        observedGoals=Match.Goals[0]+Match.Goals[1];Replay.Capture(Match);
        ClubPage="Главная";
        ClubOpen=Career.Pending==null || !Career.HasCompleteLineup;
        ClubTour.Reload();
        TutorialStep=ClubOpen && !GamePreferences.TutorialSeen?0:-1;
        var ui = new GameObject(true, "Match interface");
        ui.Components.Create<ScreenPanel>();
        ui.Components.Create<MatchHud>();
        ui.Components.Create<ClubHud>();
        ui.Components.Create<StrategyEditor>();
        ui.Components.Create<GameSettingsHud>();
        careerSaveHud=ui.Components.Create<CareerSaveHud>();
        relegationHud=ui.Components.Create<RelegationChoiceHud>();
        LoadStrategies();
        SaveCareer();
        if(Career.HasRelegationChoice)OpenRelegationChoice();
        Log.Info($"Socer: automatic match ready, seed {Match.Seed}");
    }
    protected override void OnUpdate()
    {
        long performanceStart=PerformanceDiagnostics.Begin();
        TickGameHotkeys();
        EnsureCareerSaveInterface();
        TickCareerSaveRetry();
        TickCupWorldCompletion();
        TickPlatformAchievements();
        EnsureRelegationInterface();
        if(RelegationChoiceOpen){Paused=true;accumulator=0;}
        double simulationMilliseconds=0;
        if(observedPeriodMatch!=Match){observedPeriodMatch=Match;observedSecondHalfIntro=0;}
        Match.HomeName=ClubIdentity.DisplayName(Match.HomeName);Match.AwayName=ClubIdentity.DisplayName(Match.AwayName);
        if(careerMatch && !IsRulesPreview && Career.Pending!=null && Career.HasCompleteLineup)
        {
            if(appliedClubPlan!=Match){Match.SetBroadcastPlan(Career.CoachTempo,Career.CoachMentality<0?Career.CoachTactic*.5f:Career.CoachMentality);appliedClubPlan=Match;}
            Career.RunBackground??=action=>GameTask.RunInThreadAsync(action);
            Career.PrepareRoundResults();
        }
        if(manualCameraMatch!=Match){manualCameraMatch=Match;manualCameraUntil=0;FieldDragSlow=false;}
        if(ClubOpen || StrategyLabOpen || Match.Finished || Replay.Playing || Paused)SetFieldDragSlow(false);
        if(SubstitutionOpen){Paused=true;accumulator=0;}
        EnsureInjuryControl();
        SyncAutomaticStrategies();
        Match.InteractiveSetPieces=ManualSetPieces && !Match.ShootoutActive && !QuickMatchRunning && !StrategyLabOpen && (!IsRulesPreview || PreviewManualSetPieces);
        if(Match.ManualRestartWaiting)
        {
            if(!Match.InteractiveSetPieces)Match.AutoReleaseSetPiece();
            else if(!ClubOpen && !Paused && !Replay.Playing)Match.AdvanceManualRestartWait(Time.Delta);
        }
        // One-time migration for a career match already running when this version hotloads.
        // Preview/friendly simulations must never bind or settle a career ticket.
        if(careerMatch && !IsRulesPreview && !attemptBound && Career?.Pending!=null)
        {Career.Pending.Seed=Match.Seed;attemptBound=true;}

        if(Replay.Playing && !Paused)Replay.Advance(Time.Delta);
        AdvanceQuickMatch();
        ObserveShootoutAudio();
        if (!ClubOpen && !Paused && !Match.Finished && !Replay.Playing && !Match.ManualRestartWaiting && !MatchIntroActive)
        {
            long simulationStart=PerformanceDiagnostics.Begin();
            accumulator += System.Math.Min(Time.Delta, .1f);
            // Spend real time per simulation tick, rechecking danger after every tick.
            while (true)
            {
                float cost=Match.PeriodWalkRemaining>0?1f/60f:(1f/60f)/PlaybackRate;
                if(accumulator<cost) break;
                accumulator-=cost;
                Match.Step(1f/60f);
                EnsureInjuryControl();
                TickMatchTutorial();
                if(Paused){accumulator=0;break;}
                if(Match.SecondHalfIntroSequence>observedSecondHalfIntro && !StrategyLabOpen)
                {
                    observedSecondHalfIntro=Match.SecondHalfIntroSequence;
                    BeginSecondHalfIntro();accumulator=0;break;
                }
                if(FootballLabActive && !FootballLabTrialComplete &&
                    (Match.Finished || Match.Seconds-FootballLabStartedAt>=FootballLabDuration))
                {FootballLabTrialComplete=true;Paused=true;accumulator=0;break;}
                if(Match.ManualRestartWaiting){accumulator=0;break;}
                if(StrategyLabOpen && StrategySeeking && StrategySeekCanFinish)
                {FinishStrategySeek();accumulator=0;break;}
                Director.Update(Match,cost);
                if(!Match.MedicalActive)Replay.Capture(Match,1f/60f);
                int goals=Match.Goals[0]+Match.Goals[1];
                if(goals>observedGoals)
                {
                    observedGoals=goals;
                    GameAudio.Goal();
                    if(!IsRulesPreview) {Replay.MarkGoal(Match);replayAfterGoal=4.1f;}
                }
                if(replayAfterGoal>0 && Match.GoalHoldRemaining>0)
                {
                    replayAfterGoal-=1f/60f;
                    if(replayAfterGoal<=0 && Replay.Start()) {accumulator=0;break;}
                }
            }
            simulationMilliseconds=PerformanceDiagnostics.Milliseconds(simulationStart);
        }
        SettleFinishedCareerMatch();
        if(!GameSettingsOpen)TickStrategyLab();
        Atmosphere.Update(this,Time.Delta);
        TechnicalArea.Update(this,Time.Delta);
        if(pingMatch!=Match){fieldPings.Clear();pingMatch=Match;}
        fieldPings.RemoveAll(p=>Time.Now-p.StartedAt>1.55f);
        TickCommandWheel();
        Revision++;
        bool fixedPreview=IsRulesPreview && !StrategyLabOpen && PreviewCameraWidth>0;
        if((!StrategyLabOpen || (!StrategyCameraManual && (StrategyTesting || StrategySeeking))) && !ManualCameraActive && !fixedPreview)
            Broadcast.Update(Match,System.Math.Min(Time.Delta,.05f),ViewAspect);
        if(IsRulesPreview && !StrategyLabOpen && PreviewCameraWidth>0 && !ManualCameraActive)
        {
            // Keep the athlete and the ball in frame after a shot or a turnover.
            // Following only the ball used to leave completed drills showing an empty goal.
            var focus=Match.Owner>=0?Match.Players[Match.Owner].Position:Match.Ball;
            if(FootballLabActive && FootballLabScenario is not ("corner" or "free-kick" or "penalty" or "cross" or "pass" or "first-touch"))
            {
                var athlete=Match.Players[9].Position;
                // A short drill can finish with the ball at a distant corner
                // restart. Keep the duel in view instead of centring empty grass.
                focus=(Match.Ball-athlete).Length<16?athlete*.65f+Match.Ball*.35f:athlete;
            }
            Broadcast.FollowPreview(Match,focus,PreviewCameraWidth,Time.Delta,ViewAspect);
        }
        PerformanceDiagnostics.RecordFrame(Time.Delta,performanceStart,simulationMilliseconds);
    }
    // Shared by field and text presentation. The career ticket is consumed once.
    void SettleFinishedCareerMatch()
    {
        if(careerMatch && !IsRulesPreview && Match.Finished)
        {
            if(!Career.RoundResultsReady)
                ClubNotice=GamePreferences.Russian?"Считаем остальные матчи тура…":"Finishing the other matches…";
            if(!string.IsNullOrEmpty(Career.RoundResultsError) && lastRoundError!=Career.RoundResultsError)
            {lastRoundError=Career.RoundResultsError;Log.Error(lastRoundError);}
            int coins=Career.Coins,xp=Career.Card(0).Experience,cupTokens=Career.CupTokens;
            var fixture=Career.ActiveFixture;
            if(Career.Complete(Match,MatchParticipants))
            {
                Settlement=new MatchSettlement(Match,Career.Coins-coins,Career.Card(0).Experience-xp,fixture.Winner==0,fixture.Winner<0,Career.LastResult,Career.CupTokens-cupTokens);
                careerMatch=false;ClubNotice=Career.LastResult;SaveCareer();
            }
        }
    }
    public void Restart(bool newSeed)
    {
        if(!RequireFinishedMatchForNavigation())return;
        if(!RequireRelegationResolved())return;
        if(!RequireCompleteLineup())return;
        EndRulesPreview();
        if(Career?.Pending!=null) {ClubNotice="Finish the current competitive match first.";return;}
        var tactics=Match.Tactics;
        Match=new MatchSimulation(MatchSimulation.CreateSeed(Match.Seed));
        AwaitingOrder=DirectOrderKind.None;SelectedPlayer=OrderedPlayer=9;
        if(newSeed)friendlyOpponent=MatchSimulation.FriendlyOpponent(Match.Seed);
        Replay.Reset();observedGoals=0;replayAfterGoal=0;
        Match.Tactics=tactics;
        Career?.Apply(Match,friendlyOpponent);
        ResetMatchSubstitutions();
        var automatic=Director.Automatic; var manual=Director.ManualSpeed;
        Director=new MatchDirector { Automatic=automatic, ManualSpeed=manual };
        Broadcast=new BroadcastCamera { Enabled=Broadcast.Enabled };
        Paused=false; accumulator=0;
        careerMatch=false;ClubOpen=false;
        BeginMatchIntro();
    }
    public void SaveCareer()
    {
        if(saveBlocked || careerLoadBlocked || Career==null)return;
        try
        {
            Career.EnsurePersonalities();
            var result=SaveStore.Save(Career);
            if(result.Success){CareerWriteSucceeded();platformAchievementsDirty=true;RefreshPlatformAchievementEvidence();}
            else CareerWriteFailed(result.Error);
        }
        catch(System.Exception e){CareerWriteFailed(e.Message);}
    }
    public void OpenClub()
    {
        if(InjurySubstitutionRequired){EnsureInjuryControl();return;}
        if(MediaLeagueActive){if(CanLeaveMediaLeague)CompleteMediaLeagueMatch();else OpenRelegationChoice();return;}
        if(!RequireFinishedMatchForNavigation())return;
        CloseSubstitutions();EndRulesPreview();Replay.Stop();replayAfterGoal=0;ClubOpen=true;
        RevealedCard=null;RevealedPack=null;Career?.EnsurePersonalities();SaveCareer();
    }
    public void StartTutorial()
    {
        if(Career==null || !ClubOpen || PreparingMatch || MatchModeSelection || QuickBroadcastOpen)return;
        ClubTour.Reload();
        TutorialStep=0;ClubPage=ClubTour.Steps[0].Page;
    }
    public void NextTutorialStep()
    {
        if(TutorialStep<0)return;
        if(TutorialStep>=ClubTour.Steps.Length-1){FinishTutorial();return;}
        TutorialStep++;ClubPage=ClubTour.Steps[TutorialStep].Page;
    }
    public void PreviousTutorialStep()
    {
        if(TutorialStep<=0)return;
        TutorialStep--;ClubPage=ClubTour.Steps[TutorialStep].Page;
    }
    public void FinishTutorial(bool remember=true)
    {
        if(TutorialStep<0)return;
        TutorialStep=-1;ClubPage="Главная";if(remember)GamePreferences.MarkTutorialSeen();
    }
    public void ToggleGameSettings()
    {
        if(MatchExitPromptOpen)return;
        if(GameSettingsOpen)
        {
            GameHotkeys.CancelCapture();
            GameSettingsOpen=false;
            Paused=settingsWasPaused || SubstitutionOpen || InjuryChoiceOpen;
        }
        else
        {
            InputFocus.Clear();
            settingsWasPaused=Paused;
            GameSettingsOpen=true;
            Paused=true;
        }
        GameAudio.Tap();
    }
    protected override void OnDestroy()
    {
        GameAudio.StopCrowd();
        base.OnDestroy();
    }
    public void SkipGoalReplay(){Replay.Stop();accumulator=0;}
    public void ExitFinishedMatch()
    {
        if(!Match.Finished) return;
        OpenClub();
        ClubPage="Главная";
    }
    public bool RequireCompleteLineup()
    {
        if(Career.HasCompleteLineup)return true;
        ClubOpen=true;ClubPage="Состав";ClubNotice="Fill all 11 positions before starting a match.";return false;
    }
    public void ResumeMatch() {if(InjurySubstitutionRequired){EnsureInjuryControl();return;}if(!RequireRelegationResolved() || QuickMatchRunning || (Match.Seconds<=0 && !RequireCompleteLineup()))return;ClubOpen=false;Paused=false;if(Match.Seconds==0)BeginMatchIntro();}
    public void StartCareer(bool cup)
    {
        if(!RequireFinishedMatchForNavigation())return;
        if(!RequireRelegationResolved())return;
        if(!RequireCompleteLineup())return;
        EndRulesPreview();
        Career.RunBackground=action=>GameTask.RunInThreadAsync(action);
        var ticket=Career.Begin(cup); if(ticket==null) return;
        var fixture=Career.ActiveFixture;
        ticket.Seed=MatchSimulation.CreateSeed(Match.Seed);
        attemptBound=true;
        Match=new MatchSimulation(ticket.Seed);Career.Apply(Match,fixture.Home==0?fixture.Away:fixture.Home);
        ResetMatchSubstitutions();
        Replay.Reset();observedGoals=0;replayAfterGoal=0;
        Director=new();Broadcast=new();accumulator=0;Paused=false;ClubOpen=false;careerMatch=true;
        BeginMatchIntro();
        SaveCareer();
    }
    public void SetFormation(int index)
    {
        if(!CanEditStartingLineup || !Career.SetFormation(index,BeforeKickoff)) {ClubNotice="Formation changes resume after full time.";return;}
        RefreshStartingLineup();SaveCareer();
    }
    bool BeforeKickoff=>careerMatch && !IsRulesPreview && ClubOpen && !QuickMatchRunning &&
        Career.Pending!=null && Career.Pending.Seed==Match.Seed && Match.Seconds<=0 && !Match.Finished;
    public bool CanEditStartingLineup=>Career!=null && (Career.Pending==null || BeforeKickoff);
    void RefreshStartingLineup()
    {
        if(!BeforeKickoff || !Career.HasCompleteLineup)return;
        var fixture=Career.ActiveFixture;
        Career.Apply(Match,fixture.Home==0?fixture.Away:fixture.Home);
        ResetMatchSubstitutions();Replay.Reset();Replay.Capture(Match);
    }
    public void AssignCard(int slot,int id)
    {
        bool assigned=CanEditStartingLineup && Career.Assign(slot,id,BeforeKickoff);
        bool repeated=Career.Cards.FirstOrDefault(c=>c.Id==id) is {} chosen && Career.Lineup.Where((other,i)=>i!=slot && other!=id).Any(other=>Career.Cards.Any(c=>c.Id==other && c.CatalogId==chosen.CatalogId));
        ClubNotice=assigned?"Lineup updated.":repeated?(GamePreferences.Russian?"Этот футболист уже находится в составе. Замените его карточку на той же позиции.":"This footballer is already in the lineup. Replace their card in the same slot."):"Cannot assign: goalkeepers play in goal, and the current match must be finished.";
        if(assigned)RefreshStartingLineup();SaveCareer();
    }
    public void RemoveStartingCard(int slot)
    {
        if(!CanEditStartingLineup || !Career.RemoveStarter(slot,BeforeKickoff))return;
        ClubNotice="Fill all 11 positions before starting a match.";SaveCareer();
    }
    public void Instruction(int slot,int order)
    {
        if(slot<0 || slot>=11) return;
        if(MediaLeagueActive){Match.Players[slot].Instruction=System.Math.Clamp(order,0,3);return;}
        order=System.Math.Clamp(order,0,3);Career.Instructions[slot]=order;
        Match.Players[slot].Instruction=order;SaveCareer();
    }
    public void SetClubTactic(int plan)
    {
        plan=System.Math.Clamp(plan,0,2);
        if(MediaLeagueActive){Match.SetBroadcastPlan(Match.TeamTempo[0],plan*.5f);return;}
        Career.CoachTactic=plan;Career.CoachMentality=plan*.5f;Match.SetBroadcastPlan(Career.CoachTempo,Career.CoachMentality);
        Match.AddEvent($"{Match.HomeName} · tactics changed");SaveCareer();
    }
    public void SetBroadcastPlan(float tempo,float mentality,bool commit=false)
    {
        if(Match.Finished || Replay.Playing || MatchIntroActive)return;
        Match.SetBroadcastPlan(tempo,mentality);
        TickMatchTutorial();
        if(!IsRulesPreview)
        {
            Career.CoachTempo=Match.TeamTempo[0];Career.CoachMentality=Match.TeamMentality[0];Career.CoachTactic=Match.Tactics[0];
            if(commit)SaveCareer();
        }
    }
    public void Pack(int packIndex=0)
    {
        RevealedPack=Career.OpenOwnedPack(packIndex);
        RevealedCard=RevealedPack?.Players.FirstOrDefault();
        ClubNotice=RevealedPack==null?"This pack is unavailable.":GamePreferences.Russian?$"Предметов добавлено: {RevealedPack.Items.Count}":$"Items added: {RevealedPack.Items.Count}";SaveCareer();
    }
    public bool BuyPacks(int packIndex,int quantity)
    {
        bool bought=Career.BuyPacks(packIndex,quantity);
        ClubNotice=bought?"Packs added to your club.":"This purchase is unavailable.";SaveCareer();return bought;
    }
    public void ShowPackPreview(PackOpening opening)
    {
        RevealedPack=opening;RevealedCard=opening?.Players.FirstOrDefault();ClubOpen=true;ClubPage="Карточки";
    }
    public bool UseTrainingItem(int itemId,int playerId)
    {
        bool used=Career.UseTrainingCard(itemId,playerId);
        ClubNotice=GamePreferences.Russian?(used?"Карточка улучшения применена.":"Выберите игрока и карточку улучшения между матчами."):
            (used?"Upgrade card applied.":"Choose a player and an upgrade card between matches.");SaveCareer();return used;
    }
    public bool DiscardTrainingItem(int itemId)
    {
        bool discarded=Career.DiscardTrainingCard(itemId);
        ClubNotice=GamePreferences.Russian
            ?discarded?"Карточка улучшения списана без возврата монет.":"Карточку можно списать только между матчами и кубками."
            :discarded?"Upgrade card discarded. No coins refunded.":"Discard cards between matches and cup runs.";
        if(discarded)SaveCareer();return discarded;
    }
    public bool BuyCosmetic(string kind,string id)
    {
        bool bought=kind=="kit"?Career.BuyKit(id):kind=="ball" && Career.BuyBall(id);
        ClubNotice=bought?"New equipment added to your collection.":"This item is locked, owned, or too expensive.";SaveCareer();return bought;
    }
    public bool SaveCosmetics(string kitId,string ballId)
    {
        bool equipped=Career.EquipCosmetics(kitId,ballId);
        ClubNotice=equipped?"Match look saved.":"Own both items before saving this look.";SaveCareer();return equipped;
    }
    public void SellCard(int id)
    {
        int value=Career.Sell(id);
        ClubNotice=value>0?$"Player sold for {value} coins.":"Only reserve players can be sold between matches.";
        SaveCareer();
    }
    public void RecycleDuplicateCard(int id)
    {
        bool exchanged=Career.RecycleDuplicate(id)!=null;
        ClubNotice=GamePreferences.Russian?(exchanged?"Дубль обменян на карточку улучшения.":"Можно обменять только запасную копию между матчами."):
            (exchanged?"Duplicate exchanged for an upgrade card.":"Only reserve duplicates can be exchanged between matches.");
        SaveCareer();
    }
    public void ExpandReserve()
    {
        int cost=Career.ExpansionCost;
        ClubNotice=Career.ExpandReserve()?$"Bench expanded to {Career.ReserveCapacity} places.":Career.ReserveCapacity>=ClubCareer.MaxReserveLimit?"Bench is already at maximum capacity.":$"You need {cost} coins and no active competitive match.";
        SaveCareer();
    }
    public void NewSeason() {if(!RequireRelegationResolved())return;if(Career.ClaimableMissions>0){ClubPage="Missions";ClubNotice="Claim rewards before starting the next season.";return;}if(Career.NextSeason()) {ClubNotice=Career.LastResult;SaveCareer();if(Career.HasRelegationChoice)OpenRelegationChoice();}}
    public void ShowRulesPreview(string kind)
    {
        if(MediaLeagueActive)return;
        EndRulesPreview();previewOriginal=Match;previewDirector=Director;previewCamera=Broadcast;previewWasPaused=Paused;previewClubOpen=ClubOpen;previewSelection=SelectedPlayer;
        PreviewCameraWidth=0;
        SaveSubstitutionsBeforePreview();
        Replay.Reset();replayAfterGoal=0;
        Match=kind.StartsWith("manual-")?MatchSimulation.InteractiveRestartPreview(kind switch {"manual-penalty"=>RestartKind.Penalty,"manual-corner"=>RestartKind.Corner,"manual-indirect"=>RestartKind.Indirect,_=>RestartKind.FreeKick}):MatchSimulation.RulesPreview(kind);Director=new();Director.Update(Match,.02f);Broadcast=new();
        Paused=true;ClubOpen=false;SelectedPlayer=Match.ShownPlayer>=0?Match.ShownPlayer:9;
    }
    public void RunFootballLab(string scenario,int attackerRating=-1,int defenderRating=-1,bool nextSeed=false)
    {
        if(MediaLeagueActive)return;
        if(!FootballLabScenarios.Contains(scenario))return;
        if(attackerRating>0)FootballLabAttackerRating=System.Math.Clamp(attackerRating,50,99);
        if(defenderRating>0)FootballLabDefenderRating=System.Math.Clamp(defenderRating,50,99);
        if(nextSeed)FootballLabSeed=MatchSimulation.CreateSeed(FootballLabSeed);
        ShowRulesPreview("football-ai");
        FootballLabActive=true;FootballLabScenario=scenario;
        Match=MatchSimulation.Laboratory(scenario,FootballLabSeed,FootballLabAttackerRating,FootballLabDefenderRating);
        FootballLabStartedAt=Match.Seconds;FootballLabTrialComplete=false;
        Director=new MatchDirector {Automatic=false,ManualSpeed=1f};
        Director.Update(Match,0);
        PreviewCameraWidth=scenario switch
        {
            "match"=>0,"corner" or "free-kick"=>51,
            "cross" or "pass" or "first-touch"=>38,
            "penalty" or "one-on-one" or "shot"=>30,
            "sprint"=>36,_=>22
        };
        SelectedPlayer=9;Paused=false;ClubOpen=false;
        Replay.Reset();Replay.Capture(Match);
        observedGoals=0;replayAfterGoal=0;accumulator=0;
        SkipMatchIntro();
    }
    public void RestartFootballLab(bool changeSeed=false)
    {
        if(FootballLabActive)RunFootballLab(FootballLabScenario,-1,-1,changeSeed);
    }
    public void CloseFootballLab()
    {
        if(FootballLabActive)EndRulesPreview();
    }
    // An isolated exhibition uses the real club XI and the strongest distinct
    // catalogue identities for each position. Leaving the match restores the
    // career simulation without recording a result or changing the saved club.
    public IReadOnlyList<PlayerCard> StartAllStarsExhibition()
    {
        if(MediaLeagueActive)return null;
        if(Career==null || !Career.HasCompleteLineup)return null;
        ShowRulesPreview("football-ai");
        Match=new MatchSimulation(MatchSimulation.CreateSeed(previewOriginal.Seed));
        Career.Apply(Match,1);
        var shape=Formation.All[0];
        var used=new HashSet<string>();
        var lineup=new List<PlayerCard>();
        for(int slot=0;slot<11;slot++)
        {
            var role=shape.Roles[slot];
            var card=PlayerCatalog.CardVariants
                .Where(c=>c.Position==role && !used.Contains(c.CatalogId))
                .OrderByDescending(c=>c.Overall)
                .ThenByDescending(c=>c.Edition==CardRatings.Legendary)
                .ThenBy(c=>c.Name)
                .First();
            used.Add(card.CatalogId);lineup.Add(card);
            var player=Match.Players[11+slot];
            player.Home=shape.Homes[slot];
            player.Position=Match.World(player.Home,1);
            player.Velocity=new Point();
            Career.ApplyCard(player,card,slot,shape);
        }
        Match.AwayName="WORLD ALL-STARS";
        Match.TeamCohesion[1]=.93f;
        ResetMatchSubstitutions();
        Replay.Reset();Replay.Capture(Match);
        observedGoals=0;replayAfterGoal=0;accumulator=0;
        Director.Update(Match,0);
        SelectedPlayer=9;PreviewCameraWidth=0;
        SkipMatchIntro();ClubOpen=false;Paused=false;
        return lineup;
    }
    public void EndRulesPreview()
    {
        if(MediaLeagueActive && !mediaExitAllowed)return;
        if(previewOriginal==null) return;
        FootballLabActive=false;
        FootballLabTrialComplete=false;
        PreviewManualSetPieces=false;PreviewCameraWidth=0;
        Match=previewOriginal;Director=previewDirector;Broadcast=previewCamera;Paused=previewWasPaused;ClubOpen=previewClubOpen;SelectedPlayer=previewSelection;previewOriginal=null;
        RestoreSubstitutionsAfterPreview();
        Replay.Reset();replayAfterGoal=0;observedGoals=Match.Goals[0]+Match.Goals[1];
        RestoreMatchTutorialSession();
    }
    public bool IsRulesPreview=>previewOriginal!=null;
}