MatchSubstitutions.cs

Partial MatchController for a soccer match. Handles substitutions: a five-sub limit, goalkeeper-for-goalkeeper checks, available substitutes by catalog identity, pausing while the substitution menu is open, English and Russian notices, and saving/restoring substitution state around a preview.

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

public sealed partial class MatchController
{
    public const int SubstitutionLimit=5;
    public bool SubstitutionOpen { get; private set; }
    public int SubstitutionsUsed { get; private set; }
    public string SubstitutionNotice { get; private set; }="";
    string substitutionNoticeRussian="";
    public string LocalizedSubstitutionNotice=>GamePreferences.Russian?substitutionNoticeRussian:SubstitutionNotice;
    int[] matchLineup;
    readonly HashSet<int> substitutedOut=new();
    bool pausedBeforeSubstitution;
    int[] previewLineup;
    int previewSubstitutionsUsed;
    int[] previewSubstitutedOut;
    string previewSubstitutionNotice;
    string previewSubstitutionNoticeRussian;

    public int MatchCardId(int slot)
    {
        var lineup=matchLineup??ActiveMatchRoster?.Lineup;
        return slot>=0 && slot<11 && lineup?.Length==11?lineup[slot]:-1;
    }
    public PlayerCard MatchCard(int slot)=>ActiveMatchRoster?.Cards.FirstOrDefault(c=>c.Id==MatchCardId(slot));
    public IEnumerable<PlayerCard> AvailableSubstitutes
    {
        get
        {
            var roster=ActiveMatchRoster;
            if(roster==null)return Enumerable.Empty<PlayerCard>();
            var playedIds=(matchLineup??roster.Lineup).Concat(substitutedOut).ToHashSet();
            var playedPeople=roster.Cards.Where(c=>playedIds.Contains(c.Id)).Select(c=>c.CatalogId).ToHashSet();
            // A second card is another development path, not a fresh physical
            // copy of a footballer who is already playing or has been taken off.
            return roster.Cards.Where(c=>!playedPeople.Contains(c.CatalogId));
        }
    }
    public IReadOnlyCollection<int> MatchParticipants=>(matchLineup??ActiveMatchRoster.Lineup).Concat(substitutedOut).Distinct().ToArray();

    void ResetMatchSubstitutions()
    {
        SubstitutionOpen=false;SubstitutionsUsed=0;SubstitutionNotice="";substitutionNoticeRussian="";
        matchLineup=ActiveMatchRoster?.Lineup.ToArray();substitutedOut.Clear();
    }
    void SaveSubstitutionsBeforePreview()
    {
        previewLineup=matchLineup?.ToArray();previewSubstitutionsUsed=SubstitutionsUsed;
        previewSubstitutedOut=substitutedOut.ToArray();previewSubstitutionNotice=SubstitutionNotice;
        previewSubstitutionNoticeRussian=substitutionNoticeRussian;
        ResetMatchSubstitutions();
    }
    void RestoreSubstitutionsAfterPreview()
    {
        SubstitutionOpen=false;matchLineup=previewLineup;SubstitutionsUsed=previewSubstitutionsUsed;
        substitutedOut.Clear();foreach(var id in previewSubstitutedOut??System.Array.Empty<int>())substitutedOut.Add(id);
        SubstitutionNotice=previewSubstitutionNotice??"";previewLineup=null;previewSubstitutedOut=null;
        substitutionNoticeRussian=previewSubstitutionNoticeRussian??"";
    }
    public void OpenSubstitutions()
    {
        if(Match.MedicalActive || InjurySubstitutionRequired || MediaLeagueActive || SubstitutionOpen || ClubOpen || QuickMatchRunning || Match.Finished || Match.ShootoutActive || MatchIntroActive || Replay.Playing)return;
        matchLineup??=ActiveMatchRoster?.Lineup.ToArray();
        pausedBeforeSubstitution=Paused;Paused=true;accumulator=0;
        SubstitutionNotice="Choose a player on the pitch and an available substitute.";
        substitutionNoticeRussian="Выберите игрока на поле и доступного запасного.";
        SubstitutionOpen=true;
    }
    public void CloseSubstitutions()
    {
        if(!SubstitutionOpen)return;
        SubstitutionOpen=false;Paused=pausedBeforeSubstitution;
    }
    public bool CanSubstitute(int slot,int cardId)
    {
        if(Match.MedicalActive || !SubstitutionOpen || SubstitutionsUsed>=SubstitutionLimit || slot<0 || slot>=11 || !Match.Players[slot].InPlay)return false;
        var card=ActiveMatchRoster?.Cards.FirstOrDefault(c=>c.Id==cardId);
        return card!=null && (slot==0)==(card.Position=="ВР") && AvailableSubstitutes.Any(c=>c.Id==cardId);
    }
    public bool Substitute(int slot,int cardId)
    {
        if(!CanSubstitute(slot,cardId)){SubstitutionNotice="This substitution is unavailable. Goalkeepers must replace goalkeepers.";substitutionNoticeRussian="Замена недоступна. Вратаря может заменить только вратарь.";return false;}
        var outgoing=Match.Players[slot];var oldCard=MatchCard(slot);var incoming=ActiveMatchRoster.Cards.First(c=>c.Id==cardId);
        if(Match.Owner==slot){Match.Owner=-1;Match.Ball=outgoing.Position;Match.BallVelocity=new Point();}
        if(Match.Passer==slot)Match.Passer=-1;
        if(Match.PassReceiver==slot)Match.PassReceiver=-1;
        var player=new Footballer{Id=slot,Team=0,Number=outgoing.Number,Home=outgoing.Home,
            Position=outgoing.Position,Facing=outgoing.Facing,Instruction=outgoing.Instruction};
        ActiveMatchRoster.ApplyCard(player,incoming,slot);
        Match.Players[slot]=player;
        Match.NotifyFootballSubstitution(0,slot);
        substitutedOut.Add(oldCard.Id);matchLineup[slot]=cardId;SubstitutionsUsed++;
        Match.AddEvent($"{Match.HomeName} · {incoming.Name} on for {oldCard.Name} ({Match.Minute}′)");
        SubstitutionNotice=$"{incoming.Name} replaces {oldCard.Name}. {SubstitutionLimit-SubstitutionsUsed} substitutions left.";
        substitutionNoticeRussian=$"{incoming.Name} вместо {oldCard.Name}. Осталось замен: {SubstitutionLimit-SubstitutionsUsed}.";
        if(SelectedPlayer==slot || OrderedPlayer==slot){SelectedPlayer=OrderedPlayer=slot;AwaitingOrder=DirectOrderKind.None;}
        return true;
    }
}