CupBracketCompletion.cs

Part of ClubCareer that completes the rest of a world cup tournament in the background. It validates the saved bracket, plans the remaining fixtures and scorelines, simulates them on a worker task, then commits results and flags completion on the main thread with retry support.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Socer;

public sealed partial class ClubCareer
{
    sealed class CupWorldPlan
    {
        public string Stamp;
        public List<ClubFixture> Games;
        public readonly Dictionary<(int Round,int Home,int Away),MatchSimulation> Matches=new();
        public readonly List<(ClubFixture Fixture,MatchSimulation Match)> Results=new();
    }
    sealed class CupWorldJob
    {
        public CupWorldPlan Plan;
        public Task Task;
    }
    readonly Dictionary<string,CupWorldJob> cupWorldJobs=new();

    static bool FullCupDraw(CupTournament cup)
    {
        var first=cup.Games.Where(f=>f.Round==1).ToArray();
        return first.Length==4 && first.SelectMany(f=>new[]{f.Home,f.Away}).Order().SequenceEqual(Enumerable.Range(0,8)) &&
            cup.Games.Select(f=>f.Id).Distinct().Count()==cup.Games.Count &&
            cup.Games.Count(f=>f.Round==2)<=2 && cup.Games.Count(f=>f.Round==3)<=1;
    }

    void RestoreCupWorldCompletions()
    {
        foreach(var cup in Tournaments)
        {
            cup.WorldCompletionError??="";
            bool lost=cup.Games.Any(f=>f.Played && (f.Home==0 || f.Away==0) && f.Winner!=0);
            if(!lost || cup.Final is {Played:true} || !FullCupDraw(cup))continue;
            // Old versions marked the whole world cup finished at our exit.
            // Preserve the loss and draw; dispatch its repair only after the
            // controller installs a worker, never during save deserialization.
            cup.Eliminated=true;cup.Finished=false;
            if(ActiveCupId==cup.Id)ActiveCupId="";
        }
    }

    string CupWorldStamp(CupTournament cup)=>$"{ClubSeed}:{Season}:{cup.Id}:{string.Join(',',cup.ClubIds)}|"+
        string.Join('|',cup.Games.OrderBy(f=>f.Id).Select(f=>$"{f.Id}:{f.Round}:{f.Home}:{f.Away}:{f.Played}:{f.Winner}:{f.HomeGoals}:{f.AwayGoals}:{f.HomePenalties}:{f.AwayPenalties}"));

    static ClubFixture CupWorldCopy(ClubFixture f)=>new(){Id=f.Id,Round=f.Round,Home=f.Home,Away=f.Away,
        Played=f.Played,Winner=f.Winner,HomeGoals=f.HomeGoals,AwayGoals=f.AwayGoals,Penalties=f.Penalties,
        HomePenalties=f.HomePenalties,AwayPenalties=f.AwayPenalties,PlayedSequence=f.PlayedSequence};

    CupWorldPlan PrepareCupWorldPlan(CupTournament cup)
    {
        if(!FullCupDraw(cup))throw new InvalidOperationException("The saved cup draw is incomplete; its missing entrants cannot be invented.");
        var plan=new CupWorldPlan{Stamp=CupWorldStamp(cup),Games=cup.Games.OrderBy(f=>f.Round).ThenBy(f=>f.Id).Select(CupWorldCopy).ToList()};
        int[] Possibilities(ClubFixture f)
        {
            if(f.Played)
            {
                if(f.Winner!=f.Home && f.Winner!=f.Away)throw new InvalidOperationException("A played cup fixture has no winner.");
                return new[]{f.Winner};
            }
            AddSnapshot(f.Round,f.Home,f.Away);
            return new[]{f.Home,f.Away};
        }
        void AddSnapshot(int round,int home,int away)
        {
            if(home==0 || away==0)throw new InvalidOperationException("World cup completion cannot play another match for your club.");
            var key=(round,home,away);
            if(!plan.Matches.ContainsKey(key))plan.Matches.Add(key,CreateFixtureSimulation(new(){Round=round,Home=home,Away=away},cup));
        }
        var candidates=plan.Games.Where(f=>f.Round==1).Select(Possibilities).ToArray();
        for(int round=2;round<=3;round++)
        {
            var existing=plan.Games.Where(f=>f.Round==round).ToArray();
            var next=new List<int[]>();
            for(int i=0;i<candidates.Length;i+=2)
            {
                if(i/2<existing.Length)
                {
                    var f=existing[i/2];
                    if(!candidates[i].Contains(f.Home) || !candidates[i+1].Contains(f.Away))
                        throw new InvalidOperationException("The saved cup round disagrees with its preceding winners.");
                    next.Add(Possibilities(f));
                }
                else
                {
                    foreach(int home in candidates[i])foreach(int away in candidates[i+1])AddSnapshot(round,home,away);
                    next.Add(candidates[i].Concat(candidates[i+1]).ToArray());
                }
            }
            candidates=next.ToArray();
        }
        return plan;
    }

    static void RunCupWorldPlan(CupWorldPlan plan)
    {
        int nextId=plan.Games.Max(f=>f.Id)+1;
        var games=plan.Games.Where(f=>f.Round==1).ToArray();
        for(int round=1;round<=3;round++)
        {
            foreach(var f in games)
            {
                if(f.Played)continue;
                var match=plan.Matches[(f.Round,f.Home,f.Away)];
                RunFixture(match);
                f.Played=true;
                f.Winner=match.Goals[0]>match.Goals[1]?f.Home:match.Goals[0]<match.Goals[1]?f.Away:
                    match.ShootoutComplete?(match.ShootoutWinner==0?f.Home:f.Away):-1;
                if(f.Winner<0)throw new InvalidOperationException("A background cup match did not produce a winner.");
                plan.Results.Add((f,match));
            }
            if(round==3)break;
            var existing=plan.Games.Where(f=>f.Round==round+1).ToArray();
            var next=new List<ClubFixture>();
            for(int i=0;i<games.Length;i+=2)
            {
                ClubFixture f;
                if(i/2<existing.Length)
                {
                    f=existing[i/2];
                    if(f.Home!=games[i].Winner || f.Away!=games[i+1].Winner)
                        throw new InvalidOperationException("The saved cup round disagrees with its preceding winners.");
                }
                else f=new(){Id=nextId++,Round=round+1,Home=games[i].Winner,Away=games[i+1].Winner};
                next.Add(f);
            }
            games=next.ToArray();
        }
    }

    /// <summary>Poll/commit on the main thread. Workers own only pure match snapshots.</summary>
    public bool UpdateCupCompletions()
    {
        bool changed=false;
        foreach(var cup in Tournaments.Where(t=>t.WorldCompletionPending).ToArray())
        {
            if(!cupWorldJobs.TryGetValue(cup.Id,out var job))
            {
                if(RunBackground==null || !string.IsNullOrEmpty(cup.WorldCompletionError))continue;
                try
                {
                    var plan=PrepareCupWorldPlan(cup);
                    var task=RunBackground(()=>RunCupWorldPlan(plan));
                    if(task==null)throw new InvalidOperationException("The background cup calculation did not start.");
                    job=new(){Plan=plan,Task=task};cupWorldJobs.Add(cup.Id,job);
                }
                catch(Exception error){cup.WorldCompletionError=error.Message;changed=true;continue;}
            }
            if(!job.Task.IsCompleted)continue;
            try
            {
                if(job.Task.IsCanceled)throw new InvalidOperationException("The background cup calculation was canceled.");
                if(job.Task.IsFaulted)throw job.Task.Exception.GetBaseException();
                if(CupWorldStamp(cup)!=job.Plan.Stamp)throw new InvalidOperationException("The cup changed during calculation. Retry its remaining matches.");
                foreach(var (result,match) in job.Plan.Results)
                {
                    var fixture=cup.Games.FirstOrDefault(f=>f.Id==result.Id);
                    if(fixture==null){fixture=new(){Id=result.Id,Round=result.Round,Home=result.Home,Away=result.Away};cup.Games.Add(fixture);}
                    if(fixture.Played)continue;
                    fixture.HomeGoals=match.Goals[0];fixture.AwayGoals=match.Goals[1];
                    fixture.Statistics=ClubMatchStatistics.From(match);
                    FinishFixture(fixture,true,cup,match);
                }
                if(cup.Final is not {Played:true})throw new InvalidOperationException("The background cup calculation has no final result.");
                cup.Finished=true;cup.WorldCompletionError="";
                RecordCupResults();
            }
            catch(Exception error){cup.WorldCompletionError=error.Message;}
            cupWorldJobs.Remove(cup.Id);changed=true;
        }
        return changed;
    }

    public bool RetryCupCompletion(string cupId)
    {
        var cup=Tournaments.FirstOrDefault(t=>t.Id==cupId && t.WorldCompletionPending);
        if(cup==null || string.IsNullOrEmpty(cup.WorldCompletionError) || cupWorldJobs.TryGetValue(cupId,out var job) && !job.Task.IsCompleted)return false;
        cupWorldJobs.Remove(cupId);cup.WorldCompletionError="";
        UpdateCupCompletions();return true;
    }
}