CareerCompetitions.cs

Career data model for cup tournaments and season checkpoints in a soccer career mode. Stores brackets, fixtures, cup results and history, mission rewards, and migration of older save data into the current cup format.

Rough Code
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;

namespace Socer;

public sealed class CupTournament
{
    public string Id { get; set; }="";
    public string Name { get; set; }="";
    public string Mark { get; set; }="";
    public int OpensAfterRound { get; set; }
    public int StrengthOffset { get; set; }
    public int[] ClubIds {get;set;}=Array.Empty<int>();
    public int EntrantsRevision {get;set;}
    public int RoundPrize { get; set; }
    public int TrophyPrize { get; set; }
    public bool Started { get; set; }
    public bool Finished { get; set; }
    public bool Eliminated { get; set; }
    public bool TrophyPaid { get; set; }
    public string WorldCompletionError { get; set; }="";
    public List<ClubFixture> Games { get; set; }=new();
    [JsonIgnore] public bool WorldCompletionPending=>Eliminated && !Finished;
    [JsonIgnore] public int CurrentRound=>Games.Count==0?1:Games.Max(f=>f.Round);
    [JsonIgnore] public ClubFixture NextNorth=>Finished?null:Games.FirstOrDefault(f=>!f.Played && (f.Home==0 || f.Away==0));
    [JsonIgnore] public ClubFixture Final=>Games.FirstOrDefault(f=>f.Round==3);
    [JsonIgnore] public bool Won=>Finished && Final?.Winner==0;
    [JsonIgnore] public int ChampionClubId=>Finished && Final is {Played:true} final &&
        final.Winner>=0 && final.Winner<ClubIds.Length?ClubIds[final.Winner]:-1;
}

public sealed class SeasonCheckpoint
{
    public int Round { get; set; }
    public int RequiredPoints { get; set; }
    public int CoinPrize { get; set; }
    public bool Resolved { get; set; }
    public bool Earned { get; set; }
    public bool Claimed { get; set; }
    public string Label { get; set; }="";
}

public sealed class CupMatchResult
{
    public int FixtureId {get;set;}
    public int Round {get;set;}
    public int HomeClubId {get;set;}
    public int AwayClubId {get;set;}
    public string HomeName {get;set;}="";
    public string AwayName {get;set;}="";
    public int HomeGoals {get;set;}
    public int AwayGoals {get;set;}
    public bool Penalties {get;set;}
    public int HomePenalties {get;set;}
    public int AwayPenalties {get;set;}
    public int WinnerClubId {get;set;}=-1;
}

public sealed class CupSeasonResult
{
    public int Season { get; set; }
    public int Division { get; set; }
    public string CupId { get; set; }="";
    public string CupName { get; set; }="";
    public int Round { get; set; }
    public int PlaceFrom { get; set; }
    public int PlaceTo { get; set; }
    public int PlayedSequence { get; set; }
    public int ChampionClubId {get;set;}=-1;
    public string ChampionName {get;set;}="";
    public List<CupMatchResult> Matches {get;set;}=new();
}

public sealed partial class ClubCareer
{
    public List<CupTournament> Tournaments { get; set; }=new();
    public List<CupSeasonResult> CupHistory { get; set; }=new();
    public List<SeasonCheckpoint> Checkpoints { get; set; }=new();
    public string ActiveCupId { get; set; }="";
    public int MissionRewardVersion {get;set;}
    [JsonIgnore] public int ClaimableMissions=>Checkpoints.Count(c=>c.Earned && !c.Claimed);
    public bool ClaimMission(int round)
    {
        var mission=Checkpoints.FirstOrDefault(c=>c.Round==round);
        if(mission==null || !mission.Earned || mission.Claimed)return false;
        mission.Claimed=true;Coins+=mission.CoinPrize;return true;
    }
    [JsonIgnore] public int LeagueRoundCount=>League.Select(f=>f.Round).DefaultIfEmpty().Max();
    [JsonIgnore] public int PlayedLeagueRounds=>League.Count(f=>f.Played && (f.Home==0 || f.Away==0));
    [JsonIgnore] public CupTournament ActiveCup=>Tournaments.FirstOrDefault(t=>t.Id==ActiveCupId && !t.Finished && !t.Eliminated);
    [JsonIgnore] public CupTournament DueCup=>ActiveCup??Tournaments.OrderBy(t=>t.OpensAfterRound).FirstOrDefault(t=>!t.Finished && !t.Eliminated && PlayedLeagueRounds>=t.OpensAfterRound);
    [JsonIgnore] public bool CupWindow=>DueCup!=null;
    [JsonIgnore] public bool SeasonComplete=>LeagueComplete && (!FinalCupRequired || FinalCup!=null) && Tournaments.All(t=>t.Finished);
    [JsonIgnore] public CupTournament MatchCup=>Pending?.Cup==true?Tournaments.FirstOrDefault(t=>t.Id==Pending.CupId)??ActiveCup:null;
    [JsonIgnore] public IReadOnlyList<CupSeasonResult> CupResults=>(CupHistory??new()).Concat(
        (Tournaments??new()).Select(CupResult).Where(r=>r!=null))
        .GroupBy(r=>(r.Season,r.CupId)).Select(g=>g.First())
        .OrderByDescending(r=>r.Season).ThenByDescending(r=>r.PlayedSequence).ThenBy(r=>r.CupName).ToArray();

    CupSeasonResult CupResult(CupTournament cup)
    {
        if(!cup.Finished && !cup.Eliminated)return null;
        var last=cup.Games.Where(f=>f.Played && (f.Home==0 || f.Away==0)).OrderByDescending(f=>f.Round).FirstOrDefault();
        if(last==null || last.Round is <1 or >3)return null;
        int outcome=last.ResultFor(0);
        if(outcome==0 || (outcome>0 && last.Round!=3))return null;
        // This bracket has no placement playoffs: defeated semifinalists share
        // 3rd–4th and defeated quarterfinalists share 5th–8th.
        int first=outcome>0?1:last.Round==3?2:last.Round==2?3:5;
        int final=last.Round==3?first:last.Round==2?4:8;
        return new(){Season=Season,Division=Division,CupId=cup.Id,CupName=cup.Name,Round=last.Round,
            PlaceFrom=first,PlaceTo=final,PlayedSequence=last.PlayedSequence,
            ChampionClubId=cup.ChampionClubId,ChampionName=cup.ChampionClubId>=0?TeamName(cup.Final.Winner,cup):"",
            Matches=cup.Games.Where(f=>f.Played).OrderBy(f=>f.Round).ThenBy(f=>f.Id).Select(f=>new CupMatchResult{
                FixtureId=f.Id,Round=f.Round,HomeClubId=ClubId(f.Home,cup),AwayClubId=ClubId(f.Away,cup),
                HomeName=TeamName(f.Home,cup),AwayName=TeamName(f.Away,cup),HomeGoals=f.HomeGoals,AwayGoals=f.AwayGoals,
                Penalties=f.Penalties,HomePenalties=f.HomePenalties,AwayPenalties=f.AwayPenalties,
                WinnerClubId=f.Winner>=0?ClubId(f.Winner,cup):-1}).ToList()};
    }

    void RecordCupResults()
    {
        CupHistory??=new();
        foreach(var cup in Tournaments??new())
        {
            var result=CupResult(cup);
            if(result==null)continue;
            var saved=CupHistory.FirstOrDefault(r=>r.Season==result.Season && r.CupId==result.CupId);
            if(saved==null)CupHistory.Add(result);
            else if(result.Matches.Count>saved.Matches.Count || saved.ChampionClubId<0 && result.ChampionClubId>=0)
            {
                // The club's finishing place is already settled. Complete only
                // its world bracket snapshot; never replay personal rewards.
                saved.Matches=result.Matches;saved.ChampionClubId=result.ChampionClubId;saved.ChampionName=result.ChampionName;
            }
        }
    }

    public int OpponentRating(int opponent,int playerIndex,CupTournament cup=null)
    {
        if(opponent==0)return Card(Math.Clamp(playerIndex,0,10))?.Overall??SquadRating;
        var squad=SquadFor(opponent,cup);
        return PlayerCatalog.Find(squad.Players[Math.Clamp(playerIndex,0,10)]).Overall;
    }

    void BuildCompetitions()
    {
        Tournaments=new();ActiveCupId="";MissionRewardVersion=1;
        int early=Math.Max(2,LeagueRoundCount/3),late=Math.Max(early+2,LeagueRoundCount*2/3);
        Tournaments.Add(CreateTournament("open","Open Cup","✦",early,4,400,1200));
        Tournaments.Add(Division<=2
            ?CreateTournament("county","County Cup","◇",late,-3,260,800)
            :CreateTournament("league","League Cup","◆",late,2,480,1500));
        int rounds=LeagueRoundCount;
        Checkpoints=new()
        {
            new(){Round=Math.Max(2,rounds/4),RequiredPoints=Math.Max(4,rounds/3),CoinPrize=150,Label="Opening form"},
            new(){Round=Math.Max(4,rounds/2),RequiredPoints=Math.Max(8,rounds*2/3),CoinPrize=250,Label="Halfway push"},
            new(){Round=Math.Max(6,rounds*3/4),RequiredPoints=Math.Max(12,rounds),CoinPrize=350,Label="Final stretch"}
        };
    }

    CupTournament CreateTournament(string id,string name,string mark,int openRound,int strength,int roundPrize,int trophyPrize)
    {
        var cup=new CupTournament{Id=id,Name=name,Mark=mark,OpensAfterRound=openRound,
            StrengthOffset=0,RoundPrize=roundPrize,TrophyPrize=trophyPrize};
        DrawCupEntrants(cup);
        // A seeded draw keeps the bracket stable after save/load. NORTH is always in it.
        var entrants=Enumerable.Range(0,8).ToArray();
        var rng=new Random(unchecked(ClubSeed+Season*1543+id.Sum(c=>c)*97));
        for(int i=entrants.Length-1;i>0;i--){int j=rng.Next(i+1);(entrants[i],entrants[j])=(entrants[j],entrants[i]);}
        for(int i=0;i<4;i++)cup.Games.Add(new ClubFixture{Id=i,Round=1,Home=entrants[i*2],Away=entrants[i*2+1]});
        return cup;
    }

    void ResolveCheckpoints()
    {
        if(Checkpoints==null)return;
        int points=Standings().First(r=>r.Team==0).Points;
        foreach(var checkpoint in Checkpoints.Where(c=>!c.Resolved))
        {
            if(points<checkpoint.RequiredPoints && PlayedLeagueRounds<checkpoint.Round)continue;
            checkpoint.Resolved=true;
            checkpoint.Earned=points>=checkpoint.RequiredPoints && PlayedLeagueRounds<=checkpoint.Round;

        }
    }

    void MigrateCompetitions()
    {
        Tournaments??=new();Checkpoints??=new();
        if(MissionRewardVersion<1)
        {
            foreach(var mission in Checkpoints.Where(c=>c.Earned))mission.Claimed=true;
            MissionRewardVersion=1;
        }
        if(Tournaments.Count==0)
        {
            // Existing saves keep their eight-team bracket and finished results.
            var oldGames=Cup?.Count>0?Cup:new List<ClubFixture>();
            var legacy=new CupTournament{Id="open",Name="Open Cup",Mark="✦",OpensAfterRound=Math.Min(4,Math.Max(1,LeagueRoundCount/3)),
                StrengthOffset=4,RoundPrize=400,TrophyPrize=1200,Games=oldGames.Count>0?oldGames:CreateTournament("open","Open Cup","✦",0,4,400,1200).Games,
                Started=oldGames.Any(f=>f.Played),Finished=CupCompleteLegacy || (oldGames.Any(f=>f.Played && f.Winner!=0 && (f.Home==0 || f.Away==0))),
                TrophyPaid=CupPrizePaid};
            legacy.Eliminated=legacy.Finished && !legacy.Won;
            Tournaments.Add(legacy);
            if(Pending?.Cup==true){ActiveCupId="open";Pending.CupId="open";legacy.Started=true;legacy.Finished=false;legacy.Eliminated=false;}
            else if(legacy.Started && !legacy.Finished && legacy.NextNorth!=null)ActiveCupId="open";
        }
        if(Checkpoints.Count==0)
        {
            int played=PlayedLeagueRounds;
            Checkpoints=new(){
                new(){Round=Math.Max(2,LeagueRoundCount/4),RequiredPoints=Math.Max(4,LeagueRoundCount/3),CoinPrize=150,Label="Opening form"},
                new(){Round=Math.Max(4,LeagueRoundCount/2),RequiredPoints=Math.Max(8,LeagueRoundCount*2/3),CoinPrize=250,Label="Halfway push"},
                new(){Round=Math.Max(6,LeagueRoundCount*3/4),RequiredPoints=Math.Max(12,LeagueRoundCount),CoinPrize=350,Label="Final stretch"}
            };
            // Do not retroactively grant rewards on a loaded career.
            foreach(var checkpoint in Checkpoints.Where(c=>c.Round<=played))checkpoint.Resolved=true;
        }
        if(ActiveCupId!="" && ActiveCup==null)ActiveCupId="";
        foreach(var cup in Tournaments)EnsureCupEntrants(cup);
        RestoreCupWorldCompletions();
        // Old saves retain their current completed bracket. The aggregate Cups
        // counter cannot identify previous seasons, so it creates no history.
        RecordCupResults();
        EnsureFinalCupUnlocked();
    }
}