MatchPresentation.cs

A partial of MatchController, the football manager match presenter. Handles the pre-match preparation flow (lineup confirm, match mode choice), the intro/second half intro timers, and a text-broadcast 'quick match' that fast-forwards the simulation and generates commentary lines for goals, cards, corners, saves, injuries and substitutions. Also exposes a settlement record and a medical summary string.

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

public sealed partial class MatchController
{
    public bool ManualSetPieces=true;
    public bool PreviewManualSetPieces;
    // Presentation consumes the completed transaction; it never grants a reward.
    public sealed record MatchSettlement(MatchSimulation Match,int Coins,int StarterXp,bool Won,bool Draw,string Summary,int CupTokens=0);
    public MatchSettlement Settlement { get; private set; }
    public float MatchIntroUntil { get; private set; }
    public bool SecondHalfIntro {get;private set;}
    MatchSimulation observedPeriodMatch;
    int observedSecondHalfIntro;
    public const float IntroDuration=6.6f;
    public bool MatchIntroActive=>!StrategyLabOpen && Time.Now<MatchIntroUntil;
    public float MatchIntroAge=>IntroDuration-(MatchIntroUntil-Time.Now);
    public bool PreparingMatch {get;private set;}
    public bool MatchModeSelection {get;private set;}
    public bool QuickMatchRunning {get;private set;}
    public bool QuickBroadcastOpen {get;private set;}
    public bool QuickBroadcastCup {get;private set;}
    public bool QuickBroadcastCompetitive {get;private set;}
    public sealed record BroadcastLine(string Clock,string Kind,string Text,int Team);
    public List<BroadcastLine> BroadcastLines {get;}=new();
    public BroadcastLine FeaturedBroadcastLine {get;private set;}
    public int QuickBroadcastMinute=>Match.Finished?90:Match.Minute;
    int quickMatchSteps, quickRoutineMinute;
    float quickTickDebt, quickHoldUntil;
    string previewBroadcastNotice;
    bool quickSecondHalf;
    readonly int[] quickGoals=new int[2],quickShots=new int[2],quickSaves=new int[2],quickYellows=new int[2],quickReds=new int[2],quickCorners=new int[2];
    readonly int[] quickPlayerYellows=new int[22];
    readonly bool[] quickPlayerSentOff=new bool[22];
    Footballer quickMedicalPatient;

    // Keep the old entry point safe for callers: the choice now follows lineup preparation.
    public void StartQuickLeagueMatch()=>PrepareCareer(false);

    public bool CanFinishCurrentMatchQuickly=>Career!=null && !MediaLeagueActive && !IsRulesPreview && !FootballLabActive && !StrategyLabOpen &&
        !QuickMatchRunning && !Match.Finished && !Match.ShootoutActive && !InjurySubstitutionRequired && !SubstitutionOpen &&
        (careerMatch?Career.Pending!=null && Match.Seed==Career.Pending.Seed:Career.Pending==null);

    public void FinishCurrentMatchQuickly()
    {
        if(!CanFinishCurrentMatchQuickly || (Match.Seconds<=0 && !RequireCompleteLineup()))return;
        BeginQuickBroadcast(true);
    }

    public void FinishCurrentLeagueQuickly()=>FinishCurrentMatchQuickly();

    void BeginQuickBroadcast(bool remaining,bool preview=false)
    {
        if(MediaLeagueActive || Match.IsMediaLeague)return;
        if(!preview)enteredMatch=Match;
        if(!preview)EndRulesPreview();
        // Only presentation input is cancelled. Keep the live simulation, its
        // players, substitutions and already-issued tactical instructions.
        CloseSubstitutions();CloseCommandWheel();CancelDirectOrder();SetFieldDragSlow(false);
        fieldPings.Clear();manualCameraUntil=0;accumulator=0;
        Replay.Stop();replayAfterGoal=0;
        QuickBroadcastOpen=true;QuickMatchRunning=true;quickMatchSteps=0;quickTickDebt=0;quickHoldUntil=0;
        QuickBroadcastCompetitive=!preview && careerMatch;
        QuickBroadcastCup=QuickBroadcastCompetitive && Career.Pending?.Cup==true;
        quickRoutineMinute=Match.Minute;quickSecondHalf=Match.SecondHalf;
        for(int team=0;team<2;team++)
        {
            quickGoals[team]=Match.Goals[team];quickShots[team]=Match.Shots[team];quickSaves[team]=Match.Saves[team];
            quickYellows[team]=Match.YellowCards[team];quickReds[team]=Match.RedCards[team];quickCorners[team]=Match.Corners[team];
        }
        for(int player=0;player<Match.Players.Count;player++)
        {quickPlayerYellows[player]=Match.Players[player].Yellows;quickPlayerSentOff[player]=Match.Players[player].SentOff;}
        BroadcastLines.Clear();FeaturedBroadcastLine=null;quickMedicalPatient=null;
        SkipMatchIntro();observedSecondHalfIntro=Match.SecondHalfIntroSequence;
        Match.InteractiveSetPieces=false;
        if(Match.ManualRestartWaiting)Match.AutoReleaseSetPiece();
        ClubOpen=true;Paused=true;
        AddBroadcast(remaining?"LIVE TEXT":"KICK OFF",remaining?$"Live text coverage continues at {Match.ClockLabel}′. {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}.":"The whistle sounds. We are under way!",-1);
        CollectMedicalBroadcastEvents();
        ClubNotice="Live text commentary is on air.";
    }

    void AddBroadcast(string kind,string message,int team,bool headline=false)
    {
        var line=new BroadcastLine(Match.ClockLabel+"′",kind,message,team);
        BroadcastLines.Add(line);
        if(BroadcastLines.Count>100)BroadcastLines.RemoveAt(0);
        if(headline){FeaturedBroadcastLine=line;quickHoldUntil=Time.Now+(kind=="GOAL"?1.25f:.75f);}
    }

    void CollectBroadcastEvents()
    {
        CollectMedicalBroadcastEvents();
        if(!quickSecondHalf && Match.SecondHalf)
        {
            quickSecondHalf=true;
            AddBroadcast("HALF TIME",$"The first half ends with {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}.",-1);
            AddBroadcast("SECOND HALF","The teams return. Forty-five minutes remain.",-1,true);
        }
        for(int team=0;team<2;team++)
        {
            string name=Match.TeamName(team);
            if(Match.Shots[team]>quickShots[team])
            {
                quickShots[team]=Match.Shots[team];
                AddBroadcast("CHANCE",$"{name} take aim! The attack reaches a shooting position.",team);
            }
            if(Match.Saves[team]>quickSaves[team])
            {
                quickSaves[team]=Match.Saves[team];
                AddBroadcast("SAVE",$"A sharp stop by the {name} goalkeeper keeps it out.",team);
            }
            if(Match.Corners[team]>quickCorners[team])
            {
                quickCorners[team]=Match.Corners[team];
                AddBroadcast("CORNER",$"{name} win a corner. Players crowd the penalty area.",team);
            }
            if(Match.Goals[team]>quickGoals[team])
            {
                quickGoals[team]=Match.Goals[team];
                GameAudio.Goal();
                string scorer=Match.GoalScorerId>=0 && Match.GoalScorerId<Match.Players.Count?Match.Players[Match.GoalScorerId].Profile.Name:"The scorer";
                AddBroadcast("GOAL",$"GOAL! {scorer} scores for {name}. {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}!",team,true);
            }
            if(Match.YellowCards[team]>quickYellows[team])
            {
                quickYellows[team]=Match.YellowCards[team];
                GameAudio.Card(false);
                int player=Match.Players.FindIndex(p=>p.Team==team && p.Yellows>quickPlayerYellows[p.Id]);
                string booked=player>=0?Match.Players[player].Profile.Name:$"A {name} player";
                if(player>=0)quickPlayerYellows[player]=Match.Players[player].Yellows;
                AddBroadcast("YELLOW CARD",$"{booked} is booked. The referee reaches for a yellow card.",team,true);
            }
            if(Match.RedCards[team]>quickReds[team])
            {
                quickReds[team]=Match.RedCards[team];
                GameAudio.Card(true);
                int player=Match.Players.FindIndex(p=>p.Team==team && p.SentOff && !quickPlayerSentOff[p.Id]);
                string dismissed=player>=0?Match.Players[player].Profile.Name:name;
                if(player>=0)quickPlayerSentOff[player]=true;
                AddBroadcast("RED CARD",$"{dismissed} is sent off! {name} must play with fewer players.",team,true);
            }
        }
        if(Match.Minute>=quickRoutineMinute+6 && !Match.Finished)
        {
            quickRoutineMinute=Match.Minute;
            int team=Match.Owner>=0?Match.Players[Match.Owner].Team:Match.LastTouch;
            string name=Match.TeamName(team);
            string[] notes={
                $"{name} recycle possession and look for a route forward.",
                $"The tempo rises as {name} push into the opposition half.",
                $"A spell of midfield play. {name} keep the ball moving.",
                $"{name} probe down the flank, but the defence holds its shape."
            };
            AddBroadcast("ON THE PITCH",notes[(Match.Minute/6+Math.Abs(Match.Seed%notes.Length))%notes.Length],team);
        }
    }

    // A medical cycle retains its patient after removal. Observe that single
    // reference, rather than searching all footballers on every simulation step.
    void CollectMedicalBroadcastEvents()
    {
        if(Match.MedicalActive)
        {
            if(!ReferenceEquals(quickMedicalPatient,Match.MedicalPatient))
            {
                quickMedicalPatient=Match.MedicalPatient;
                AddBroadcast("INJURY",$"Injury: {quickMedicalPatient.Profile.Name} cannot continue. Medical staff bring the stretcher.",quickMedicalPatient.Team,true);
            }
            return;
        }
        if(quickMedicalPatient==null)return;
        var outgoing=quickMedicalPatient;quickMedicalPatient=null;
        var incoming=Match.Players[outgoing.Id];
        if(!ReferenceEquals(outgoing,incoming) && incoming.InPlay)
            AddBroadcast("SUBSTITUTION",$"Substitution: {incoming.Profile.Name} replaces injured {outgoing.Profile.Name}.",outgoing.Team,true);
        else
            AddBroadcast("INJURY",$"No substitute: {outgoing.Profile.Name} leaves the match. {Match.TeamName(outgoing.Team)}"+
                (Match.Abandoned?" have fewer than seven players: match abandoned.":" continue with fewer players."),outgoing.Team,true);
    }

    // Home substitutions also include the coach's ordinary changes. Opponent
    // replacements are tracked by the simulation's existing medical counter.
    public string MatchMedicalSummary
    {
        get
        {
            if(Match==null)return "";
            bool ru=GamePreferences.Russian;
            int injuriesHome=Match.InjuryKnocks[0]+Match.MinorInjuries[0]+Match.SeriousInjuries[0];
            int injuriesAway=Match.InjuryKnocks[1]+Match.MinorInjuries[1]+Match.SeriousInjuries[1];
            int substitutionsHome=Math.Max(SubstitutionsUsed,Match.InjurySubstitutionsUsed[0]);
            int substitutionsAway=Match.InjurySubstitutionsUsed[1];
            var parts=new List<string>(3);
            if(injuriesHome+injuriesAway>0)
                parts.Add($"{(ru?"Ушибы и травмы":"Knocks and injuries")}: {injuriesHome} : {injuriesAway}");
            if(Match.SeriousInjuries[0]+Match.SeriousInjuries[1]>0)
                parts.Add($"{(ru?"Не смогли продолжить":"Unable to continue")}: {Match.SeriousInjuries[0]} : {Match.SeriousInjuries[1]}");
            if(substitutionsHome+substitutionsAway>0)
                parts.Add($"{(ru?"Замены":"Substitutions")}: {substitutionsHome} : {substitutionsAway}");
            return string.Join(" · ",parts);
        }
    }

    void AdvanceQuickMatch()
    {
        if(!QuickMatchRunning || GameSettingsOpen || Time.Now<quickHoldUntil)return;
        FeaturedBroadcastLine=null;
        Match.InteractiveSetPieces=false;
        // 30 simulated seconds per real second gives a full match about 12–18 seconds of air time.
        quickTickDebt=Math.Min(quickTickDebt+Time.Delta*1800f,180f);
        int steps=Math.Min((int)quickTickDebt,180);
        quickTickDebt-=steps;
        for(int i=0;i<steps && !Match.Finished;i++)
        {
            if(Match.ManualRestartWaiting)Match.AutoReleaseSetPiece();
            Match.Step(1f/60f);quickMatchSteps++;
            EnsureInjuryControl();
            CollectBroadcastEvents();
            if(Match.ShootoutActive)break;
            if(Time.Now<quickHoldUntil)break;
        }
        if(Match.ShootoutActive)
        {
            // A tied cup match continues with the actual visible kicks. The
            // same match/ticket survives the transition from text to the field.
            QuickMatchRunning=false;QuickBroadcastOpen=false;ClubOpen=false;Paused=false;
            Replay.Reset();replayAfterGoal=0;accumulator=0;Director.Update(Match,0);
            ClubNotice=GamePreferences.Russian?"Ничья. Серия пенальти продолжается на поле.":"Draw. Watch the penalty shootout on the pitch.";
            return;
        }
        if(Match.Finished)
        {
            QuickMatchRunning=false;
            AddBroadcast("FULL TIME",$"The final whistle. {Match.HomeName} {Match.Goals[0]}–{Match.Goals[1]} {Match.AwayName}.",-1,true);
            ClubNotice="Full time. Match report and rewards are ready.";
        }
        else if(quickMatchSteps>40000)
        {
            QuickMatchRunning=false;QuickBroadcastOpen=false;ClubOpen=false;Paused=false;
            ClubNotice="Text commentary paused. Continue the match on the pitch.";
        }
    }

    public void OpenBroadcastReport()
    {
        if(QuickMatchRunning || !Match.Finished || Career.Pending!=null && !IsRulesPreview)return;
        QuickBroadcastOpen=false;ClubOpen=false;Paused=true;
    }

    // Editor verification uses an isolated match so the player's career and save are untouched.
    public void PreviewQuickBroadcast()
    {
        if(MediaLeagueActive)return;
        previewBroadcastNotice=ClubNotice;
        ShowRulesPreview("football-ai");
        BeginQuickBroadcast(false,true);
    }
    public void RestoreQuickBroadcastPreview()
    {
        if(!IsRulesPreview)
        {
            // A hotload can replace preview fields before the editor asks to restore.
            if(ClubNotice=="Full time. Match report and rewards are ready." && Career.Pending==null)ClubNotice="";
            return;
        }
        QuickMatchRunning=false;QuickBroadcastOpen=false;FeaturedBroadcastLine=null;
        EndRulesPreview();
        ClubNotice=previewBroadcastNotice??"";
        previewBroadcastNotice=null;
    }

    bool preparationCup;
    bool preparationFriendly;
    public void PrepareCareer(bool cup)
    {
        if(!RequireFinishedMatchForNavigation())return;
        if(!RequireRelegationResolved())return;
        if(Career.Pending!=null){ResumeMatch();return;}
        if(Career.Next(cup)==null)return;
        EndRulesPreview();preparationCup=cup;preparationFriendly=false;PreparingMatch=true;MatchModeSelection=false;ClubOpen=true;ClubPage="Состав";
    }
    public void PrepareFriendly()
    {
        if(!RequireFinishedMatchForNavigation())return;
        if(!RequireRelegationResolved())return;
        if(Career.Pending!=null){ResumeMatch();return;}
        if(Career.CupWindow)return;
        EndRulesPreview();preparationFriendly=true;PreparingMatch=true;MatchModeSelection=false;ClubOpen=true;ClubPage="Состав";
    }
    public void CancelPreparation(){PreparingMatch=false;MatchModeSelection=false;ClubPage="Главная";}
    public void ConfirmPreparation(){if(!PreparingMatch || !RequireCompleteLineup())return;PreparingMatch=false;MatchModeSelection=true;}
    public void ReturnToLineup(){if(!MatchModeSelection)return;MatchModeSelection=false;PreparingMatch=true;ClubPage="Состав";}
    public void ChooseMatchMode(bool textBroadcast)
    {
        if(!MatchModeSelection || !RequireCompleteLineup())return;
        MatchModeSelection=false;
        if(preparationFriendly){Restart(true);return;}
        StartCareer(preparationCup);
        if(textBroadcast && Career.Pending!=null)BeginQuickBroadcast(false);
    }
    public string PreparationOpponent
    {
        get {if(preparationFriendly)return "FRIENDLY MATCH";var f=Career.Next(preparationCup);return f==null?"":Career.FixtureTeamName(f,f.Home==0?f.Away:f.Home);}
    }
    public string PreparationTactics
    {
        get
        {
            if(preparationFriendly)return "";
            var f=Career.Next(preparationCup);if(f==null)return "";
            string style=MatchSimulation.TacticalName(Career.TacticalIdentity(f.Home==0?f.Away:f.Home,Career.TournamentFor(f)),GamePreferences.Russian);
            string stage=preparationCup?(GamePreferences.Russian?f.Round==3?"Финал":f.Round==2?"Полуфинал":"Четвертьфинал":
                f.Round==3?"Final":f.Round==2?"Semifinal":"Quarterfinal"):"";
            return stage==""?style:stage+" · "+style;
        }
    }
    public void BeginMatchIntro(){enteredMatch=Match;SecondHalfIntro=false;MatchIntroUntil=Time.Now+IntroDuration;}
    public void BeginSecondHalfIntro(){SecondHalfIntro=true;MatchIntroUntil=Time.Now+IntroDuration;}
    public void SkipMatchIntro() { MatchIntroUntil=0;SecondHalfIntro=false; }
}