ClubCareer.cs

Data and rules layer for a football club career mode. Holds card ratings, pack offers and odds, coin/cup-token economy, formations and position mapping, fixtures, league standings, pack opening and save migration logic. ClubCareer is a partial class whose match simulation, cup bracket and achievements live in other files.

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

namespace Socer;

public static class CardRatings
{
    public const int Max=99;
    public const int Silver=65;
    public const int Gold=75;
    public const int Elite=87;
    public const string Legendary="LEGENDARY";
    // Reveal order follows card material; the authored red editions have their
    // own player ratings and remain outside the ordinary pack rating pool.
    public static int RevealRank(PlayerCard card)=>card.Rarity==Legendary?4:card.Overall>=Elite?3:card.Overall>=Gold?2:card.Overall>=Silver?1:0;
    public static int TypeRank(PlayerCard card)=>card.Rarity switch{"LEGENDARY"=>4,"ELITE"=>3,"GOLD"=>2,"SILVER"=>1,_=>0};
    public static int FromLegacy(int value)=>value<=0?0:Math.Clamp((int)Math.Round(value*Max/120d,MidpointRounding.AwayFromZero),1,Max);
}

public sealed record ScoutPackOffer(string Name,int Cost,int UnlockDivision,int Bronze,int Silver,int Gold,int Elite,int Legendary=0,bool UsesCupTokens=false,bool UpgradeOnly=false)
{
    public int Weight(int tier)=>tier switch{0=>Bronze,1=>Silver,2=>Gold,3=>Elite,4=>Legendary,_=>0};
}

public static class ScoutPacks
{
    public static readonly ScoutPackOffer[] All={
        new("Silver",400,1,79,20,1,0),
        new("Gold",1200,3,0,25,70,5),
        new("Diamond",3000,5,0,0,40,60),
        new("Cup3",8,1,0,0,85,15,0,true),
        new("Cup2",12,2,0,0,70,30,0,true),
        new("Cup1",20,3,0,0,50,50,0,true),
        new("CupNational",32,4,0,0,20,75,5,true),
        new("CupPremier",48,5,0,0,5,83,12,true),
        // Append offers so every existing saved pack keeps its original index.
        new("UpgradeSilver",180,1,80,17,3,0,UpgradeOnly:true),
        new("UpgradeGold",550,3,15,60,25,0,UpgradeOnly:true)
    };
    public static int SecondPlayerChance(int index)=>index>=0 && index<All.Length && All[index].UsesCupTokens?85:index switch{0=>35,1=>60,_=>80};
    public static int ExtraCount(Random rng,int index)
    {
        if(index>=0 && index<All.Length && All[index].UsesCupTokens)
        {
            int roll=rng.Next(100);return roll<15?1:roll<60?2:3;
        }
        return index==0?rng.Next(1,4):index==1?rng.Next(100)<25?1:rng.Next(2,4):rng.Next(100)<65?3:2;
    }
}

public static class CupEconomy
{
    // Cup tokens are spendable currency, separate from the lifetime trophies won.
    public static int MatchWinReward(int division)=>Math.Clamp(division,1,5) switch{1=>2,2=>3,3=>5,4=>8,_=>12};
    public static int LeagueTitleReward(int division)=>Math.Clamp(division,1,5) switch{1=>8,2=>12,3=>20,4=>32,_=>48};
}

public static class CardEconomy
{
    // Pack resale stays below purchase value on average; rare discoveries retain value.
    public static int SellValue(PlayerCard card)
    {
        if(card==null || card.IsAcademyLoan)return 0;
        int o=Math.Clamp(card.Overall,40,99);
        int value=card.Rarity==CardRatings.Legendary?6000+(o-87)*250:
            o>=87?1000+(o-87)*160:o>=75?300+(o-75)*45:o>=65?125+(o-65)*12:35+(o-40)*3;
        return value;
    }
    public static int DuplicateGrade(PlayerCard card)=>Math.Clamp(CardRatings.TypeRank(card)+1,1,3);
    public static int CosmeticRefund(int price)=>Math.Max(20,price/10);
}

public sealed class TrainingCardItem
{
    public int Id { get; set; }
    public int Focus { get; set; }
    public int Grade { get; set; }
    public int Xp { get; set; }
    [JsonIgnore] public string Name => Math.Clamp(Grade,1,3) switch{1=>"Bronze upgrade",2=>"Silver upgrade",_=>"Gold upgrade"};
}
public sealed class PackReward
{
    public bool Duplicate { get; set; }
    public string Kind { get; set; }="";
    public string Name { get; set; }="";
    public string Detail { get; set; }="";
    public PlayerCard Player { get; set; }
    public TrainingCardItem Training { get; set; }
    public string CosmeticId { get; set; }="";
    public int Coins { get; set; }
    [JsonIgnore] public int RevealValue => Kind switch
    {
        "player" when Player!=null => CardRatings.RevealRank(Player)*1000+Player.Overall*10,
        "training" when Training!=null => Math.Clamp(Training.Grade,1,3)*1000+Training.Xp,
        "kit" => (ClubCosmetics.Kit(CosmeticId).PackTier+1)*1000+ClubCosmetics.Kit(CosmeticId).Price/10,
        "ball" => (ClubCosmetics.Ball(CosmeticId).PackTier+1)*1000+ClubCosmetics.Ball(CosmeticId).Price/10,
        "coins" => Coins/10,
        _ => 0
    };
}
public sealed class PackOpening
{
    public int PackIndex { get; set; }
    public List<PackReward> Items { get; set; }=new();
    [JsonIgnore] public IReadOnlyList<PlayerCard> Players => Items.Where(i=>i.Player!=null).Select(i=>i.Player).ToArray();
    [JsonIgnore] public IReadOnlyList<PackReward> RevealOrder => Items.OrderBy(i=>i.RevealValue).ThenBy(i=>i.Name,StringComparer.Ordinal).ToArray();
}

public sealed class PlayerCard
{
    public int Id { get; set; }
    public string CatalogId { get; set; }="";
    public bool IsAcademyLoan { get; set; }
    public string AcademyPortraitId { get; set; }="";
    public string Name { get; set; }="";
    public string Country { get; set; }="";
    public string CountryCode { get; set; }="";
    public string Flag { get; set; }="";
    public string Foot { get; set; }="Right";
    public int WeakFoot { get; set; }=3;
    public int SkillMoves { get; set; }=3;
    public int Age { get; set; }=22;
    public int HeightCm { get; set; }=180;
    public int PortraitSkin { get; set; }=-1;
    public int PortraitHair { get; set; }=-1;
    public int PortraitStyle { get; set; }=-1;
    public int PortraitBeard { get; set; }=-1;
    public string Position { get; set; }="ЦП";
    public string PreferredPosition { get; set; }="";
    public int Pace { get; set; }
    public int Shooting { get; set; }
    public int Passing { get; set; }
    public int Dribbling { get; set; }
    public int Defence { get; set; }
    public int Physical { get; set; }
    public int Endurance { get; set; }
    public int Keeping { get; set; }
    public int Aggression { get; set; }
    public int Composure { get; set; }
    public PlayerDetailStats Details { get; set; }=new();
    public PlayStyle PlayStyles { get; set; }
    public PlayStyle PlayStylesPlus { get; set; }
    public int Level { get; set; }
    public UniqueSkill UniqueSkill { get; set; }
    public List<SkillInstance> SkillLoadout { get; set; }=new();
    public int SkillLoadoutVersion { get; set; }
    public int SkillRerolls { get; set; }
    public int Experience { get; set; }
    public int Potential { get; set; }
    public int[] StatCaps { get; set; }=Array.Empty<int>();
    // Legacy development data remains readable; upgrade cards only change skills.
    public int[] TrainingTargets { get; set; }=Array.Empty<int>();
    public string Edition { get; set; }="";
    // Base material is retained as the player develops; a red edition adds a
    // catalogue suffix while keeping the same football identity CatalogId.
    [JsonIgnore] public string CardKey => Edition==CardRatings.Legendary?CatalogId+"@"+Edition:CatalogId;
    public int Overall => CardOverall.Calculate(this);
    public string Rarity => !string.IsNullOrEmpty(Edition)?Edition:Overall>=CardRatings.Elite?"ELITE":Overall>=CardRatings.Gold?"GOLD":Overall>=CardRatings.Silver?"SILVER":"BRONZE";
}

public sealed class PlayerDetailStats
{
    public int Acceleration { get; set; } public int SprintSpeed { get; set; }
    public int Positioning { get; set; } public int Finishing { get; set; } public int ShotPower { get; set; } public int LongShots { get; set; } public int Volleys { get; set; } public int Penalties { get; set; }
    public int Vision { get; set; } public int Crossing { get; set; } public int FreeKickAccuracy { get; set; } public int ShortPassing { get; set; } public int LongPassing { get; set; } public int Curve { get; set; }
    public int Agility { get; set; } public int Balance { get; set; } public int Reactions { get; set; } public int BallControl { get; set; } public int Dribbling { get; set; } public int Composure { get; set; }
    public int Interceptions { get; set; } public int HeadingAccuracy { get; set; } public int DefensiveAwareness { get; set; } public int StandingTackle { get; set; } public int SlidingTackle { get; set; }
    public int Jumping { get; set; } public int Stamina { get; set; } public int Strength { get; set; } public int Aggression { get; set; }
    public int GkDiving { get; set; } public int GkHandling { get; set; } public int GkKicking { get; set; } public int GkPositioning { get; set; } public int GkReflexes { get; set; }
}

public static class CardOverall
{
    // The six face ratings describe different abilities. OVR measures suitability for
    // the player's actual position, so a striker's defence never raises their OVR.
    public static int Calculate(PlayerCard p)
    {
        if(LegendaryAbilities.Get(p) is { } legend)return legend.OverallRating;
        int dri=PlayerCardDetails.Dribbling(p),phy=PlayerCardDetails.Physical(p);
        string role=PlayerPositions.Card(p);
        double value=role switch
        {
            "GK" => p.Keeping*.85+p.Passing*.08+phy*.05+p.Pace*.02,
            "CB" => p.Defence*.82+phy*.10+p.Pace*.05+p.Passing*.03,
            "LB" or "RB" or "LWB" or "RWB" => p.Defence*.50+p.Pace*.25+p.Passing*.13+dri*.07+phy*.05,
            "CDM" => p.Defence*.40+p.Passing*.32+phy*.17+dri*.07+p.Pace*.04,
            "CAM" => p.Passing*.35+dri*.28+p.Shooting*.22+p.Pace*.15,
            "LM" or "RM" => p.Pace*.28+p.Passing*.27+dri*.23+p.Shooting*.12+phy*.10,
            "LW" or "RW" => p.Pace*.32+dri*.30+p.Shooting*.24+p.Passing*.14,
            "ST" => p.Shooting*.78+p.Pace*.10+dri*.06+phy*.06,
            _ => p.Passing*.45+dri*.18+p.Defence*.17+phy*.10+p.Pace*.10
        };
        return Math.Clamp((int)Math.Round(value,MidpointRounding.AwayFromZero),1,CardRatings.Max);
    }

    public static int Legacy(PlayerCard p)=>Math.Clamp((int)Math.Round(p.Position=="ВР" ? p.Keeping*.6+p.Passing*.15+p.Endurance*.15+p.Pace*.1 :
        p.Position=="ЦЗ" || p.Position=="КЗ" ? p.Defence*.4+p.Pace*.2+p.Passing*.2+p.Endurance*.2 :
        p.Position=="НП" ? p.Shooting*.45+p.Pace*.25+p.Passing*.15+p.Endurance*.15 :
        p.Passing*.35+p.Pace*.2+p.Shooting*.2+p.Defence*.1+p.Endurance*.15),0,CardRatings.Max);
}

public static class PlayerPositions
{
    public static readonly string[] Standard={"GK","LB","LWB","CB","RB","RWB","CDM","CM","CAM","LM","RM","LW","RW","ST"};

    public static string Card(PlayerCard card)
    {
        if(!string.IsNullOrWhiteSpace(card.PreferredPosition))return card.PreferredPosition;
        string identity=string.IsNullOrWhiteSpace(card.CatalogId)?card.Name:card.CatalogId;
        return ForRole(card.Position,PlayerCatalog.Hash(identity));
    }

    public static string ForRole(string role,uint hash)=>role switch
    {
        "ВР"=>"GK", "КЗ"=>hash%2==0?"LB":"RB", "ЦЗ"=>"CB",
        "ЦП"=>hash%5==0?"CDM":hash%5==1?"CAM":"CM",
        "КП"=>(hash%4) switch {0=>"LM",1=>"RM",2=>"LW",_=>"RW"},
        "НП"=>"ST", _=>role
    };

    public static string Slot(Formation formation,int index)
    {
        if(index<0 || index>=formation.Roles.Length)return "";
        string role=formation.Roles[index];bool left=formation.Homes[index].Y<34;
        return role switch
        {
            "ВР"=>"GK", "КЗ"=>left?"LB":"RB", "ЦЗ"=>"CB",
            "ЦП"=>formation.Name=="4–2–3–1"?(index==8?"CAM":"CDM"):
                formation.Name=="4–3–3" && index==5?"CDM":
                formation.Name=="3–5–2" && index==6?"CAM":"CM",
            "КП"=>formation.Name=="3–5–2"?(left?"LWB":"RWB"):
                formation.Name=="4–4–2"?(left?"LM":"RM"):(left?"LW":"RW"),
            "НП"=>"ST",_=>role
        };
    }

    public static string FullName(string position)=>position switch
    {
        "GK"=>"Goalkeeper", "LB"=>"Left back", "LWB"=>"Left wing-back", "CB"=>"Centre back",
        "RB"=>"Right back", "RWB"=>"Right wing-back", "CDM"=>"Defensive midfielder",
        "CM"=>"Central midfielder", "CAM"=>"Attacking midfielder", "LM"=>"Left midfielder",
        "RM"=>"Right midfielder", "LW"=>"Left winger", "RW"=>"Right winger", "ST"=>"Striker",_=>position
    };
}

public sealed class Formation
{
    public string Name { get; }
    public Point[] Homes { get; }
    public string[] Roles { get; }
    Formation(string name,Point[] homes,string[] roles) { Name=name; Homes=homes; Roles=roles; }
    public static readonly Formation[] All={
        new("4–4–2",new Point[]{new(5,34),new(23,8),new(20,25),new(20,43),new(23,60),new(43,10),new(39,27),new(39,41),new(43,58),new(61,25),new(61,43)},new[]{"ВР","КЗ","ЦЗ","ЦЗ","КЗ","КП","ЦП","ЦП","КП","НП","НП"}),
        new("4–3–3",new Point[]{new(5,34),new(23,8),new(20,25),new(20,43),new(23,60),new(36,34),new(45,22),new(45,46),new(59,9),new(65,34),new(59,59)},new[]{"ВР","КЗ","ЦЗ","ЦЗ","КЗ","ЦП","ЦП","ЦП","КП","НП","КП"}),
        new("3–5–2",new Point[]{new(5,34),new(21,16),new(18,34),new(21,52),new(39,6),new(36,25),new(44,34),new(36,43),new(39,62),new(62,25),new(62,43)},new[]{"ВР","ЦЗ","ЦЗ","ЦЗ","КП","ЦП","ЦП","ЦП","КП","НП","НП"}),
        new("4–2–3–1",new Point[]{new(5,34),new(23,8),new(20,25),new(20,43),new(23,60),new(35,25),new(35,43),new(51,13),new(49,34),new(51,55),new(65,34)},new[]{"ВР","КЗ","ЦЗ","ЦЗ","КЗ","ЦП","ЦП","КП","ЦП","КП","НП"})
    };
    public static float Fit(string card,string slot) => card==slot?1:card=="ВР" || slot=="ВР"?.4f:
        (card=="КЗ" && slot=="ЦЗ") || (card=="ЦЗ" && slot=="КЗ")?.90f:
        (card=="КП" && slot=="ЦП") || (card=="ЦП" && slot=="КП")?.90f:
        (card=="КП" && slot=="НП") || (card=="НП" && slot=="КП")?.86f:
        (card=="ЦП" && slot=="НП") || (card=="НП" && slot=="ЦП")?.78f:
        (card=="ЦП" && slot=="ЦЗ") || (card=="ЦЗ" && slot=="ЦП")?.78f:.62f;
    public static float Fit(PlayerCard card,Formation formation,int slot)
    {
        string role=PlayerPositions.Slot(formation,slot);
        if(PlayerPositions.Card(card)==role)return 1;
        if(role is "LWB" or "RWB")
        {
            if(card.Position=="КЗ")return .96f;
            if(card.Position=="КП")return .86f;
        }
        return Fit(card.Position,formation.Roles[slot]);
    }
}

public sealed class ClubFixture
{
    public int PlayedSequence { get; set; }
    public int Id { get; set; }
    public int Round { get; set; }
    public int Home { get; set; }
    public int Away { get; set; }
    public int HomeGoals { get; set; }
    public int AwayGoals { get; set; }
    public bool Played { get; set; }
    public int Winner { get; set; }=-1;
    public bool Penalties { get; set; }
    public int HomePenalties { get; set; }
    public int AwayPenalties { get; set; }
    public List<PenaltyKickResult> PenaltyKicks {get;set;}=new();
    public ClubMatchStatistics Statistics { get; set; }
    public int ResultFor(int team)
    {
        if(!Played || (Home!=team && Away!=team))return 0;
        // A tied cup score can still be a win or a loss after the shootout.
        int result=HomeGoals.CompareTo(AwayGoals);
        if(Penalties && result==0)
            result=HomePenalties!=AwayPenalties?HomePenalties.CompareTo(AwayPenalties):Winner==Home?1:Winner==Away?-1:0;
        return Home==team?result:-result;
    }
}
public sealed class MatchTicket
{
    // Attempt identity exists only in memory. Legacy JSON seeds are ignored on load.
    int runtimeSeed=MatchSimulation.CreateSeed();
    [JsonIgnore] public int Seed { get=>runtimeSeed; set=>runtimeSeed=value; }
    public bool Cup { get; set; }
    public string CupId { get; set; }="";
    public int FixtureId { get; set; }
}
public sealed class LeagueRow
{
    public int Team, Played, Wins, Draws, Losses, For, Against, Points;
}

public sealed class OpponentPlayer
{
    public string Name { get; set; }="";
    public string Position { get; set; }="ЦП";
    public int Overall { get; set; }
    public PlayerCard Card {get;set;}
}

public sealed class ClubProfile
{
    public string Name { get; set; }="";
    public string Formation { get; set; }="4–4–2";
    public int Overall { get; set; }
    public List<OpponentPlayer> Players { get; set; }=new();
}

public sealed partial class ClubCareer
{
    public const int ReserveLimit=14;
    public const int RosterLimit=18;
    public const int MaxReserveLimit=30;
    public int Version { get; set; }=6;
    public int BalanceRevision {get;set;}
    public int LegendaryRevision {get;set;}
    public int ClubSeed { get; set; }
    public int Coins { get; set; }=1200;
    public int CupTokens { get; set; }
    [JsonIgnore] public int LastCupTokensEarned { get; private set; }
    public int Season { get; set; }=1;
    public int Division { get; set; }=1;
    public int FormationIndex { get; set; }
    public int PacksOpened { get; set; }
    // A settled pack receipt survives sale/recycling. Old saves without this
    // evidence stay false; owning a debug or migrated card does not prove a pack.
    public bool LegendaryPackObtained { get; set; }
    public int[] PackInventory { get; set; }=new int[ScoutPacks.All.Length];
    public int ReserveBonus { get; set; }
    public int NextCardId { get; set; }=1;
    public int NextTrainingCardId { get; set; }=1;
    public List<TrainingCardItem> TrainingInventory { get; set; }=new();
    public List<string> OwnedKitIds { get; set; }=new(){"north"};
    public List<string> OwnedBallIds { get; set; }=new(){"classic"};
    public string EquippedKitId { get; set; }="north";
    public string EquippedBallId { get; set; }="classic";
    public List<string> SeenCatalogIds { get; set; }=new();
    public int Wins { get; set; }
    public int Cups { get; set; }
    public bool LeaguePrizePaid { get; set; }
    public bool CupPrizePaid { get; set; }
    public List<PlayerCard> Cards { get; set; }=new();
    public int[] Lineup { get; set; }=new int[11];
    // Shared appearances make familiar groups coordinate their off-ball runs.
    public Dictionary<string,int> PairFamiliarity { get; set; }=new();
    public int[] Instructions { get; set; }=new int[11];
    public int CoachTactic {get;set;}=1;
    public float CoachTempo {get;set;}=.5f;
    public float CoachMentality {get;set;}=-1;
    public List<ClubFixture> League { get; set; }=new();
    public List<ClubFixture> Cup { get; set; }=new();
    public MatchTicket Pending { get; set; }
    public string LastResult { get; set; }="Welcome to the club. Build your squad and start the season.";
    public static string[] Teams=>new[]{"FC BARCELUNA","MANCASTER UNITED","ROYAL MADRID","CHELFORD FC","LIVERPORT FC","BAVARIA MUNICH","PARIS SAINT-LUNE","JUVENTRA FC"};
    public static string[] Orders=>new[]{"Balanced","Hold position","Push forward","Stay wide"};
    [JsonIgnore] public Formation Shape => Formation.All[Math.Clamp(FormationIndex,0,Formation.All.Length-1)];
    [JsonIgnore] public int ReserveCount => Cards.Count(c=>!c.IsAcademyLoan && !Lineup.Contains(c.Id));
    [JsonIgnore] public int FilledSlots => Lineup.Count(id=>Cards.Any(c=>c.Id==id));
    [JsonIgnore] public bool HasCompleteLineup => Lineup.Length==11 && FilledSlots==11 && Lineup.Distinct().Count()==11 && Lineup.Select(id=>Cards.First(c=>c.Id==id).CatalogId).Distinct().Count()==11 && Card(0)?.Position=="ВР" && Lineup.Skip(1).All(id=>Cards.Any(c=>c.Id==id && c.Position!="ВР"));
    [JsonIgnore] public int SquadRating => (int)Math.Round(Lineup.Sum(id=>Cards.FirstOrDefault(c=>c.Id==id)?.Overall??0)/11d);
    [JsonIgnore] public int PositionRating => (int)Math.Round(Enumerable.Range(0,11).Sum(slot=>
        Card(slot) is PlayerCard card?card.Overall*Formation.Fit(card,Shape,slot):0)/11d);
    [JsonIgnore] public float SquadCohesion
    {
        get
        {
            if(!HasCompleteLineup)return .45f;
            int pairs=0;float familiarity=0;
            for(int a=0;a<11;a++)for(int b=a+1;b<11;b++)
            {
                pairs++;
                if(PairFamiliarity!=null && PairFamiliarity.TryGetValue(PairKey(Lineup[a],Lineup[b]),out int games))
                    familiarity+=Math.Min(1,games/12f);
            }
            return .45f+.48f*familiarity/Math.Max(1,pairs);
        }
    }
    static string PairKey(int a,int b)=>a<b?$"{a}:{b}":$"{b}:{a}";
    [JsonIgnore] public int ReserveCapacity => Math.Min(MaxReserveLimit,ReserveLimit+ReserveBonus);
    [JsonIgnore] public int ReserveSlotsLeft => Math.Max(0,ReserveCapacity-ReserveCount);
    public static readonly string[] DivisionNames={"Third League","Second League","First League","National League","Premier League"};
    [JsonIgnore] public string DivisionName => DivisionNames[Math.Clamp(Division-1,0,4)];
    public bool LeagueComplete => League.Count>0 && League.All(f=>f.Played);
    [JsonIgnore] public bool CupCompleteLegacy => Cup.Count==7 && Cup[6].Played;
    [JsonIgnore] public bool CupComplete => Tournaments.Count>0 && Tournaments.All(t=>t.Finished);
    public static ClubCareer Create(int seed)
    {
        var c=new ClubCareer {ClubSeed=seed,BalanceRevision=1,LegendaryRevision=1}; var rng=new Random(seed);
        string[] roles={"ВР","КЗ","ЦЗ","ЦЗ","КЗ","КП","ЦП","ЦП","КП","НП","НП","ВР","ЦЗ","ЦП","НП"};
        foreach(var role in roles) c.Cards.Add(c.Generate(rng,role,CardRatings.FromLegacy(rng.Next(66,77))));
        c.Lineup=c.Cards.Take(11).Select(p=>p.Id).ToArray(); c.EnsureClubWorld();c.BuildSeason(); return c;
    }
    PlayerCard Generate(Random rng,string role,int quality)
    {
        var excluded=(SeenCatalogIds??new()).Concat(Cards.Select(c=>c.CatalogId));
        var card=PlayerCatalog.Draw(rng,excluded,role,quality);
        if(card==null) return null;
        card.Id=NextCardId++;
        SeenCatalogIds??=new();SeenCatalogIds.Add(card.CatalogId);
        return card;
    }
    public PlayerCard Card(int slot)=>slot>=0 && slot<Lineup.Length?Cards.FirstOrDefault(p=>p.Id==Lineup[slot]):null;
    public bool RemoveStarter(int slot,bool beforeKickoff=false)
    {
        if((Pending!=null && !beforeKickoff) || slot<0 || slot>=Lineup.Length || Lineup[slot]<0)return false;
        Lineup[slot]=-1;return true;
    }
    public bool Assign(int slot,int cardId,bool beforeKickoff=false)
    {
        if((Pending!=null && !beforeKickoff) || slot<0 || slot>10) return false;
        var card=Cards.FirstOrDefault(p=>p.Id==cardId); if(card==null || (slot==0)!=(card.Position=="ВР")) return false;
        if(Lineup.Where((id,i)=>i!=slot && id!=cardId).Any(id=>Cards.Any(p=>p.Id==id && p.CatalogId==card.CatalogId)))return false;
        int old=Array.IndexOf(Lineup,cardId);
        if(old>=0) Lineup[old]=Lineup[slot];
        Lineup[slot]=cardId;EnsureAcademyReserves(beforeKickoff);return true;
    }
    public bool SetFormation(int index,bool beforeKickoff=false)
    {
        if((Pending!=null && !beforeKickoff) || index<0 || index>=Formation.All.Length)return false;
        FormationIndex=index;EnsureAcademyReserves(beforeKickoff);return true;
    }
    public int[] PackOdds(int packIndex)
    {
        if(packIndex<0 || packIndex>=ScoutPacks.All.Length)return new int[5];
        var offer=ScoutPacks.All[packIndex];
        if(offer.UpgradeOnly)return new[]{offer.Bronze,offer.Silver,offer.Gold,0,0};
        var available=new bool[5];
        foreach(var card in PlayerCatalog.All)
        {
            int tier=CardTier(card);available[tier]=true;
        }
        available[4]=PlayerCatalog.LegendaryCards.Count>0;
        return Enumerable.Range(0,5).Select(tier=>available[tier]?offer.Weight(tier):0).ToArray();
    }
    public int CurrencyBalance(int packIndex)=>packIndex>=0 && packIndex<ScoutPacks.All.Length?ScoutPacks.All[packIndex].UsesCupTokens?CupTokens:Coins:0;
    public static string PackCurrencyName(int packIndex)=>packIndex>=0 && packIndex<ScoutPacks.All.Length && ScoutPacks.All[packIndex].UsesCupTokens?"cup tokens":"coins";
    void SpendPackCurrency(int packIndex,int amount)
    {
        if(ScoutPacks.All[packIndex].UsesCupTokens)CupTokens-=amount;else Coins-=amount;
    }
    public bool CanOpenPack(int packIndex)
    {
        if(packIndex<0 || packIndex>=ScoutPacks.All.Length)return false;
        var offer=ScoutPacks.All[packIndex];
        return !HasRelegationChoice && Pending==null && !CupWindow && Division>=offer.UnlockDivision && CurrencyBalance(packIndex)>=offer.Cost && PackOdds(packIndex).Sum()>0 && PackCapacityFailure(packIndex)=="";
    }
    public int OwnedPacks(int packIndex)=>PackInventory!=null && packIndex>=0 && packIndex<PackInventory.Length?PackInventory[packIndex]:0;
    public bool CanBuyPacks(int packIndex,int quantity=1)
    {
        if(quantity<1 || quantity>10 || packIndex<0 || packIndex>=ScoutPacks.All.Length)return false;
        var offer=ScoutPacks.All[packIndex];
        return !HasRelegationChoice && Pending==null && !CupWindow && Division>=offer.UnlockDivision && CurrencyBalance(packIndex)>=(long)offer.Cost*quantity && PackInventory!=null && PackInventory.Length==ScoutPacks.All.Length && OwnedPacks(packIndex)<=int.MaxValue-quantity && PackOdds(packIndex).Sum()>0;
    }
    public bool BuyPacks(int packIndex,int quantity=1)
    {
        if(!CanBuyPacks(packIndex,quantity))return false;
        SpendPackCurrency(packIndex,ScoutPacks.All[packIndex].Cost*quantity);PackInventory[packIndex]+=quantity;return true;
    }
    public bool CanOpenOwnedPack(int packIndex)
    {
        if(HasRelegationChoice || packIndex<0 || packIndex>=ScoutPacks.All.Length || OwnedPacks(packIndex)<=0 || Pending!=null || CupWindow)return false;
        return PackOdds(packIndex).Sum()>0 && PackCapacityFailure(packIndex)=="";
    }
    public PackOpening OpenOwnedPack(int packIndex=0)
    {
        if(!CanOpenOwnedPack(packIndex))return null;
        var opening=RollPack(packIndex);
        if(opening!=null)PackInventory[packIndex]--;
        return opening;
    }
    static int CardTier(PlayerCard card)=>card.Rarity==CardRatings.Legendary?4:card.Overall>=CardRatings.Elite?3:card.Overall>=CardRatings.Gold?2:card.Overall>=CardRatings.Silver?1:0;
    public PackOpening OpenPack(int packIndex=0)
    {
        if(!CanOpenPack(packIndex))return null;
        var opening=RollPack(packIndex);
        if(opening!=null)SpendPackCurrency(packIndex,ScoutPacks.All[packIndex].Cost);
        return opening;
    }
    PackOpening RollPack(int packIndex)
    {
        var opening=PreparePackOpening(packIndex);
        if(opening==null || PackCapacityFailure(opening)!="")return null;
        // Commit only after the complete deterministic roll fits. A refused
        // opening must not consume a pack, money, identities, or discoveries.
        PacksOpened++;SeenCatalogIds??=new();TrainingInventory??=new();OwnedKitIds??=new();OwnedBallIds??=new();
        foreach(var item in opening.Items)
        {
            if(item.Player is {} card)
            {
                card.Id=NextCardId++;Cards.Add(card);
                if(!SeenCatalogIds.Contains(card.CatalogId))SeenCatalogIds.Add(card.CatalogId);
            }
            else if(item.Training is {} training)
            {training.Id=NextTrainingCardId++;TrainingInventory.Add(training);}
            else if(item.Kind=="kit" && !item.Duplicate)OwnedKitIds.Add(item.CosmeticId);
            else if(item.Kind=="ball" && !item.Duplicate)OwnedBallIds.Add(item.CosmeticId);
            Coins+=item.Coins;
        }
        EnsureAcademyReserves();
        if(opening.Items.Any(item=>item.Kind=="player" && item.Player?.Edition==CardRatings.Legendary))
            LegendaryPackObtained=true;
        return opening;
    }
    public bool CanReceiveTrainingCards(int count=1)=>count>=0 && (long)(TrainingInventory?.Count??0)+count<=CareerSaveValidation.MaxInventory;
    public string PackCapacityFailure(int packIndex)
    {
        if(packIndex<0 || packIndex>=ScoutPacks.All.Length)return "";
        // Ordinary collections need no preview allocation when every possible
        // roll fits. At the boundary inspect the actual next roll, not a worse one.
        if(Cards.Count<=CareerSaveValidation.MaxCards-2 &&
            (TrainingInventory?.Count??0)<=CareerSaveValidation.MaxInventory-3)return "";
        return PackCapacityFailure(PreparePackOpening(packIndex));
    }
    string PackCapacityFailure(PackOpening opening)
    {
        if(opening==null)return "";
        if((long)Cards.Count+opening.Items.Count(item=>item.Player!=null)>CareerSaveValidation.MaxCards)return "players";
        if(!CanReceiveTrainingCards(opening.Items.Count(item=>item.Training!=null)))return "training";
        return "";
    }
    PackOpening PreparePackOpening(int packIndex)
    {
        var offer=ScoutPacks.All[packIndex];
        var rng=new Random(unchecked(ClubSeed+7919*(PacksOpened+1)));
        if(offer.UpgradeOnly)
        {
            int roll=rng.Next(offer.Bronze+offer.Silver+offer.Gold);
            int grade=roll<offer.Bronze?1:roll<offer.Bronze+offer.Silver?2:3;
            var card=new TrainingCardItem{Grade=grade,Xp=new[]{45,90,150}[grade-1]};
            return new PackOpening{PackIndex=packIndex,Items=new(){new PackReward{Kind="training",Name=card.Name,Training=card}}};
        }
        int playerCount=rng.Next(100)<ScoutPacks.SecondPlayerChance(packIndex)?2:1;
        int extraCount=ScoutPacks.ExtraCount(rng,packIndex);
        var opening=new PackOpening{PackIndex=packIndex};
        int[] weights=PackOdds(packIndex);int total=weights.Sum();if(total<=0)return null;
        var players=new List<PlayerCard>();
        for(int i=0;i<playerCount;i++)
        {
            int roll=rng.Next(total),tier=0;
            while(roll>=weights[tier])roll-=weights[tier++];
            var pool=(tier==4?PlayerCatalog.LegendaryCards:PlayerCatalog.All).Where(c=>CardTier(c)==tier).ToArray();
            players.Add(PlayerCatalog.Clone(pool[rng.Next(pool.Length)]));
        }
        var playerKeys=Cards.Select(card=>card.CardKey).ToHashSet(StringComparer.Ordinal);
        foreach(var card in players)
        {
            bool duplicate=!playerKeys.Add(card.CardKey);
            opening.Items.Add(new PackReward{Kind="player",Name=card.Name,Player=card,Duplicate=duplicate,
                Detail=$"{PlayerPositions.Card(card)} · {card.Overall} OVR"});
        }
        var ownedKits=new HashSet<string>(OwnedKitIds??new(),StringComparer.Ordinal);
        var ownedBalls=new HashSet<string>(OwnedBallIds??new(),StringComparer.Ordinal);
        int minimumCosmeticTier=offer.UsesCupTokens?(offer.UnlockDivision<=2?1:2):0;
        var kitPool=ClubCosmetics.Kits.Where(k=>k.Price>0 && (offer.UsesCupTokens?k.PackTier>=minimumCosmeticTier:k.PackTier<=packIndex)).ToArray();
        var ballPool=ClubCosmetics.Balls.Where(b=>b.Price>0 && (offer.UsesCupTokens?b.PackTier>=minimumCosmeticTier:b.PackTier<=packIndex)).ToArray();
        for(int i=0;i<extraCount;i++)
        {
            // Every pack guarantees an upgrade. Cup packs guarantee gold upgrades
            // and only draw the more valuable club equipment.
            int kind=i==0?0:rng.Next(100)<45?0:rng.Next(2)+1;
            if((kind==1 && kitPool.Length==0) || (kind==2 && ballPool.Length==0))kind=0;
            PackReward reward;
            if(kind==0)
            {
                int gradeRoll=rng.Next(100);
                int grade=offer.UsesCupTokens?3:packIndex==0?(gradeRoll<85?1:2):packIndex==1?(gradeRoll<20?1:gradeRoll<80?2:3):(gradeRoll<25?2:3);
                var training=new TrainingCardItem{Grade=grade,Xp=new[]{45,90,150}[grade-1]};
                reward=new PackReward{Kind="training",Name=training.Name,Training=training,Detail="Apply to improve one player"};
            }
            else if(kind==1)
            {
                var kit=kitPool[rng.Next(kitPool.Length)];bool duplicate=!ownedKits.Add(kit.Id);
                int refund=duplicate?CardEconomy.CosmeticRefund(kit.Price):0;
                reward=new PackReward{Kind="kit",Name=kit.Name,CosmeticId=kit.Id,Duplicate=duplicate,Coins=refund,Detail="Club kit"};
            }
            else
            {
                var ball=ballPool[rng.Next(ballPool.Length)];bool duplicate=!ownedBalls.Add(ball.Id);
                int refund=duplicate?CardEconomy.CosmeticRefund(ball.Price):0;
                reward=new PackReward{Kind="ball",Name=ball.Name,CosmeticId=ball.Id,Duplicate=duplicate,Coins=refund,Detail="Match ball"};
            }
            opening.Items.Add(reward);
        }
        return opening;
    }
    [JsonIgnore] public SkillUpgradeResult LastSkillUpgrade { get; private set; }
    public bool CanUseTrainingCard(int trainingId,int playerId)
    {
        if(HasRelegationChoice || Pending!=null)return false;
        var item=TrainingInventory?.FirstOrDefault(c=>c.Id==trainingId);
        var player=Cards.FirstOrDefault(c=>c.Id==playerId);
        if(item==null || player==null || player.IsAcademyLoan || CupWindow)return false;
        return UniqueSkills.CanReroll(player);
    }
    public bool UseTrainingCard(int trainingId,int playerId)
    {
        LastSkillUpgrade=null;
        if(!CanUseTrainingCard(trainingId,playerId))return false;
        var item=TrainingInventory.First(c=>c.Id==trainingId);
        var player=Cards.First(c=>c.Id==playerId);
        LastSkillUpgrade=UniqueSkills.Reroll(player,item);
        if(LastSkillUpgrade==null)return false;
        TrainingInventory.Remove(item);
        return true;
    }
    public bool CanDiscardTrainingCard(int trainingId)=>!HasRelegationChoice && Pending==null && !CupWindow &&
        TrainingInventory?.Any(item=>item.Id==trainingId)==true;
    public bool DiscardTrainingCard(int trainingId)
    {
        if(!CanDiscardTrainingCard(trainingId))return false;
        // Explicit inventory housekeeping, with no sale value or hidden award.
        // Academy-only rosters must be able to free a slot for a new pack.
        TrainingInventory.Remove(TrainingInventory.First(item=>item.Id==trainingId));return true;
    }
    public bool BuyKit(string id)
    {
        var kit=ClubCosmetics.Kits.FirstOrDefault(k=>k.Id==id);
        if(HasRelegationChoice || Pending!=null || CupWindow || kit==null || OwnedKitIds.Contains(id) || Division<kit.UnlockDivision || Coins<kit.Price)return false;
        Coins-=kit.Price;OwnedKitIds.Add(id);return true;
    }
    public bool BuyBall(string id)
    {
        var ball=ClubCosmetics.Balls.FirstOrDefault(b=>b.Id==id);
        if(HasRelegationChoice || Pending!=null || CupWindow || ball==null || OwnedBallIds.Contains(id) || Division<ball.UnlockDivision || Coins<ball.Price)return false;
        Coins-=ball.Price;OwnedBallIds.Add(id);return true;
    }
    public bool EquipCosmetics(string kitId,string ballId)
    {
        if(HasRelegationChoice || Pending!=null || CupWindow || !OwnedKitIds.Contains(kitId) || !OwnedBallIds.Contains(ballId))return false;
        EquippedKitId=kitId;EquippedBallId=ballId;return true;
    }
    public int ExpansionCost => 500+ReserveBonus*350;
    public bool ExpandReserve()
    {
        if(HasRelegationChoice || Pending!=null || CupWindow || ReserveCapacity>=MaxReserveLimit || Coins<ExpansionCost) return false;
        Coins-=ExpansionCost; ReserveBonus++; return true;
    }
    public int Sell(int id)
    {
        if(HasRelegationChoice || Pending!=null || CupWindow || Lineup.Contains(id)) return 0;
        var p=Cards.FirstOrDefault(c=>c.Id==id); if(p==null || p.IsAcademyLoan) return 0;
        int value=CardEconomy.SellValue(p); Cards.Remove(p); Coins+=value;EnsureAcademyReserves();return value;
    }
    public bool CanRecycleDuplicate(int id)=>!HasRelegationChoice && Pending==null && !CupWindow && !Lineup.Contains(id) && CanReceiveTrainingCards() &&
        Cards.FirstOrDefault(p=>p.Id==id) is {} card && !card.IsAcademyLoan && Cards.Count(p=>p.CardKey==card.CardKey)>1;
    public TrainingCardItem RecycleDuplicate(int id)
    {
        if(!CanRecycleDuplicate(id))return null;
        var card=Cards.First(p=>p.Id==id);int grade=CardEconomy.DuplicateGrade(card);
        var drill=new TrainingCardItem{Id=NextTrainingCardId++,Focus=0,Grade=grade,Xp=new[]{45,90,150}[grade-1]};
        Cards.Remove(card);TrainingInventory.Add(drill);EnsureAcademyReserves();return drill;
    }
    public ClubProfile Club(int team,CupTournament tournament=null)
    {
        team=Math.Clamp(team,0,7);
        var players=OpponentCards(team,tournament).Take(11).ToArray();
        return new ClubProfile{Name=TeamName(team,tournament),Formation=team==0?Shape.Name:Formation.All[SquadFor(team,tournament).FormationIndex].Name,
            Overall=(int)Math.Round(players.Select(c=>(double)c.Overall).DefaultIfEmpty().Average()),
            Players=players.Select(c=>new OpponentPlayer{Name=c.Name,Position=c.Position,Overall=c.Overall,Card=c}).ToList()};
    }
    public static string[] TrainingNames=>new[]{"Skill upgrade"};
    static int PrimaryTrainingStat(PlayerCard p)=>PlayerPositions.Card(p) switch
    {
        "GK"=>5,"CB" or "LB" or "RB" or "LWB" or "RWB" or "CDM"=>3,
        "CM" or "CAM"=>2,"LM" or "RM" or "LW" or "RW"=>0,"ST"=>1,_=>2
    };
    public static int[] TrainingCardGains(PlayerCard player,TrainingCardItem item)
    {
        return CardDevelopment.TrainingPlan(player,item);
    }
    void BuildSeason()
    {
        League=new();Cup=new();LeaguePrizePaid=false;CupPrizePaid=false;
        // Eight clubs, home and away: fourteen league matches for NORTH.
        int[] ring={0,1,2,3,4,5,6,7};
        for(int round=0;round<7;round++)
        {
            for(int i=0;i<4;i++)
            {
                int h=ring[i],a=ring[7-i]; if(round%2==1) (h,a)=(a,h);
                League.Add(new ClubFixture {Id=League.Count,Round=round+1,Home=h,Away=a});
            }
            int last=ring[7];for(int i=7;i>1;i--)ring[i]=ring[i-1];ring[1]=last;
        }
        foreach(var f in League.ToArray())League.Add(new ClubFixture{Id=League.Count,Round=f.Round+7,Home=f.Away,Away=f.Home});
        BuildCompetitions();
    }
    public ClubFixture Next(bool cup)
    {
        if(HasRelegationChoice)return null;
        if(Pending!=null)return Pending.Cup==cup?ActiveFixture:null;
        if(cup)return DueCup?.NextNorth;
        if(DueCup!=null)return null;
        return League.FirstOrDefault(f=>!f.Played && (f.Home==0 || f.Away==0));
    }
    public MatchTicket Begin(bool cup)
    {
        if(HasRelegationChoice)return null;
        if(!HasCompleteLineup)return null;
        if(Pending!=null) {PrepareRoundResults();return Pending;}
        var fixture=Next(cup); if(fixture==null) return null;
        if(cup)
        {
            var tournament=DueCup;
            if(tournament==null)return null;
            tournament.Started=true;ActiveCupId=tournament.Id;
        }
        Pending=new MatchTicket{Cup=cup,CupId=cup?ActiveCupId:"",FixtureId=fixture.Id};
        PrepareRoundResults();return Pending;
    }
    [JsonIgnore] public ClubFixture ActiveFixture=>Pending==null?null:(Pending.Cup?MatchCup?.Games:League)?.FirstOrDefault(f=>f.Id==Pending.FixtureId);
    void Simulate(ClubFixture f,bool cup,CupTournament tournament=null)
    {
        var m=PreparedFixture(f,tournament);
        f.HomeGoals=m.Goals[0];f.AwayGoals=m.Goals[1];
        f.Statistics=ClubMatchStatistics.From(m);
        FinishFixture(f,cup,tournament,m);
    }
    void FinishFixture(ClubFixture f,bool cup,CupTournament tournament=null,MatchSimulation match=null,int homeSide=0)
    {
        f.PlayedSequence=League.Concat(Tournaments.SelectMany(t=>t.Games)).Select(g=>g.PlayedSequence).DefaultIfEmpty().Max()+1;
        f.Played=true; f.Penalties=cup && f.HomeGoals==f.AwayGoals;
        if(f.Penalties)
        {
            if(match?.ShootoutComplete!=true)throw new InvalidOperationException("A tied cup fixture must finish its on-field penalty shootout before settlement.");
            f.HomePenalties=match.ShootoutScores[homeSide];f.AwayPenalties=match.ShootoutScores[1-homeSide];
            f.PenaltyKicks=match.ShootoutKicks.Select(k=>k with {Team=k.Team==homeSide?0:1,Player=(k.Team==homeSide?0:11)+k.Player%11}).ToList();
        }
        f.Winner=f.HomeGoals>f.AwayGoals?f.Home:f.HomeGoals<f.AwayGoals?f.Away:f.Penalties?(f.HomePenalties>f.AwayPenalties?f.Home:f.Away):-1;
        RecordPlayedMatch(f,tournament,match,homeSide);
    }
    public bool Complete(MatchSimulation match,IReadOnlyCollection<int> appeared=null)
    {
        if(Pending==null || !match.Finished || match.Seed!=Pending.Seed) return false;
        if(!RoundResultsReady)return false;
        var f=ActiveFixture; if(f.Played) { Pending=null; return false; }
        bool cup=Pending.Cup;
        var tournament=cup?MatchCup:null;
        if(cup && match.Goals[0]==match.Goals[1] && !match.ShootoutComplete)return false;
        f.Statistics=ClubMatchStatistics.From(match,f.Home==0?0:1);
        f.HomeGoals=match.Goals[f.Home==0?0:1]; f.AwayGoals=match.Goals[f.Away==0?0:1]; FinishFixture(f,cup,tournament,match,f.Home==0?0:1);
        bool won=f.Winner==0,draw=f.Winner<0;
        int winReward=cup?tournament?.RoundPrize??400:300;
        int reward=won?winReward:draw?winReward/4:0;
        Coins+=reward;if(won) Wins++;
        LastCupTokensEarned=0;
        if(cup && won)AwardCupTokens(CupEconomy.MatchWinReward(Division));
        // Match experience belongs to the retired level system. Skills are
        // assigned by consumed upgrade cards, and match rewards remain separate.
        PairFamiliarity??=new();
        var participants=(appeared??Lineup).Distinct().Where(id=>Cards.Any(c=>c.Id==id && !c.IsAcademyLoan)).ToArray();
        for(int a=0;a<participants.Length;a++)for(int b=a+1;b<participants.Length;b++)
        {
            string pair=PairKey(participants[a],participants[b]);
            PairFamiliarity[pair]=Math.Min(12,PairFamiliarity.GetValueOrDefault(pair)+1);
        }
        LastResult=$"{TeamName(f.Home,tournament)} {f.HomeGoals}:{f.AwayGoals} {TeamName(f.Away,tournament)}"+(f.Penalties?$" · penalties {f.HomePenalties}:{f.AwayPenalties}, {TeamName(f.Winner,tournament)} through":"")+$" · +{reward} coins";
        Pending=null;
        if(!cup)
        {
            foreach(var other in League.Where(q=>q.Round==f.Round && !q.Played)) Simulate(other,false);
            ResolveCheckpoints();
            if(LeagueComplete && !LeaguePrizePaid)
            {
                int place=Standings().FindIndex(r=>r.Team==0)+1;int prize=Math.Max(150,1000-place*100);Coins+=prize;LeaguePrizePaid=true;
                LastResult+=$" · league finish: place {place}, +{prize} coins";
                if(place==1)AwardCupTokens(CupEconomy.LeagueTitleReward(Division));
            }
        }
        else if(tournament!=null){AdvanceCup(tournament);RecordCupResults();}
        if(EnsureFinalCupUnlocked())LastResult+=" · Champions Cup unlocked: the final challenge of your career.";
        if(tournament?.Id==FinalCupId && tournament.Won && CareerWon)
            LastResult+=" · Career complete: Champions Cup winners! Your club has reached the top.";
        bool hadPremierTitle=PremierChampion;
        ResolveCareerAchievements();
        if(!hadPremierTitle && PremierChampion)LastResult+=" · First Premier League title! Your championship reward is ready in Missions.";
        if(LastCupTokensEarned>0)LastResult+=$" · +{LastCupTokensEarned} cup tokens";
        ClearRoundResults();
        EnsureAcademyReserves();
        return true;
    }
    void AwardCupTokens(int amount)
    {
        // Settlement is guarded by the pending fixture and LeaguePrizePaid.
        // Replayed completions and loaded historical results never reach here.
        int previous=Math.Max(0,CupTokens);
        CupTokens=(int)Math.Min(int.MaxValue,(long)previous+Math.Max(0,amount));
        LastCupTokensEarned+=CupTokens-previous;
    }
    void AdvanceCup(CupTournament tournament)
    {
        int round=tournament.Games.Max(f=>f.Round);
        foreach(var f in tournament.Games.Where(f=>f.Round==round && !f.Played && f.Home!=0 && f.Away!=0))Simulate(f,true,tournament);
        if(tournament.Games.Any(f=>!f.Played))return;
        bool lost=tournament.Games.Any(f=>f.Played && (f.Home==0 || f.Away==0) && f.Winner!=0);
        if(lost)
        {
            tournament.Eliminated=true;ActiveCupId="";
            tournament.Finished=tournament.Final is {Played:true};
            if(tournament.WorldCompletionPending)UpdateCupCompletions();
            return;
        }
        if(round==3)
        {
            tournament.Finished=true;ActiveCupId="";
            if(!tournament.TrophyPaid && tournament.Final?.Winner==0)
            {Coins+=tournament.TrophyPrize;Cups++;LastResult+=$" · {tournament.Name} won! +{tournament.TrophyPrize} coins";}
            tournament.TrophyPaid=true;return;
        }
        var winners=tournament.Games.Where(f=>f.Round==round).OrderBy(f=>f.Id).Select(f=>f.Winner).ToArray();
        for(int i=0;i<winners.Length;i+=2)tournament.Games.Add(new ClubFixture{Id=tournament.Games.Max(f=>f.Id)+1,Round=round+1,Home=winners[i],Away=winners[i+1]});
    }
    public List<LeagueRow> Standings()
    {
        var rows=Enumerable.Range(0,8).Select(i=>new LeagueRow {Team=i}).ToArray();
        foreach(var f in League.Where(f=>f.Played))
        {
            var h=rows[f.Home];var a=rows[f.Away];h.Played++;a.Played++;h.For+=f.HomeGoals;h.Against+=f.AwayGoals;a.For+=f.AwayGoals;a.Against+=f.HomeGoals;
            if(f.HomeGoals==f.AwayGoals) {h.Draws++;a.Draws++;h.Points++;a.Points++;}
            else {var w=f.HomeGoals>f.AwayGoals?h:a;var l=w==h?a:h;w.Wins++;w.Points+=3;l.Losses++;}
        }
        return rows.OrderByDescending(r=>r.Points).ThenByDescending(r=>r.For-r.Against).ThenByDescending(r=>r.For).ThenBy(r=>r.Team).ToList();
    }
    public bool NextSeason()
    {
        EnsureFinalCupUnlocked();
        if(HasRelegationChoice || Pending!=null || !SeasonComplete || ClaimableMissions>0)return false;
        RecordCupResults();
        ResolveCareerAchievements();
        InitializeMatchHistory();
        int place=Standings().FindIndex(r=>r.Team==0)+1;
        int oldDivision=Division;
        Division=Math.Clamp(Division+(place<=2?1:place==Standings().Count?-1:0),1,5);
        Season++;RotateClubs(oldDivision);BuildSeason();
        CreateRelegationConsequence(oldDivision);
        EnsureSeasonChallenge();
        LastResult=$"Season {Season} · {DivisionName}. Top two clubs are promoted; last place is relegated.";return true;
    }
    public void Apply(MatchSimulation m,int opponent=1)
    {
        if(!HasCompleteLineup)return;
        EnsurePersonalities();
        var tournament=Pending?.Cup==true?MatchCup:null;
        m.HomeName=TeamName(0,tournament);m.AwayName=TeamName(Math.Clamp(opponent,1,7),tournament);
        ConfigureMatchSide(m,0,0,tournament);
        ConfigureMatchSide(m,1,Math.Clamp(opponent,1,7),tournament);
        m.CupStage=Pending?.Cup==true?ActiveFixture?.Round??0:0;
        m.ShootoutRequired=tournament!=null;
    }
    public void ApplyCard(Footballer p,PlayerCard c,int slot,Formation formation=null)
    {
        float fit=Formation.Fit(c,formation??Shape,slot);
        p.Profile.SlotRole=PlayerPositions.Slot(formation??Shape,slot);
        p.Profile.RoleFamiliarity=fit;
        p.RolePossession=-2;p.RoleThinkingUntil=0;
        var details=PlayerCardDetails.Get(c);
        p.Profile.Name=c.Name;p.Profile.Id=$"card-{c.Id}";p.Profile.CatalogId=c.CatalogId;p.Profile.CountryCode=c.CountryCode;p.Profile.Edition=c.Edition;p.Profile.Overall=c.Overall;p.Profile.PlayStyles=PlayerPlayStyles.Get(c);p.Profile.PlayStylesPlus=PlayStyle.None;p.Profile.UniqueSkill=c.UniqueSkill;
        p.Profile.SkillLoadout=UniqueSkills.CloneLoadout(c.SkillLoadout);
        p.Profile.SkinTone=c.PortraitSkin;p.Profile.HairTone=c.PortraitHair;
        p.Profile.Aggression=c.Aggression/(float)CardRatings.Max;p.Profile.Composure=c.Composure/(float)CardRatings.Max;
        p.Profile.Style=c.Position=="ВР"?"Goalkeeper":c.Position=="НП"?"Finisher":c.Position=="КП"?"Runner":c.Position=="ЦП"?"Playmaker":"Anchor";
        p.Profile.Passing=details.ShortPassing/(float)CardRatings.Max*fit;p.Profile.Finishing=details.Finishing/(float)CardRatings.Max*fit;
        p.Profile.Volleys=details.Volleys/(float)CardRatings.Max*fit;
        p.Profile.ShotPower=details.ShotPower/(float)CardRatings.Max*fit;
        p.Profile.Penalties=details.Penalties/(float)CardRatings.Max*fit;
        p.Profile.LongShots=details.LongShots/(float)CardRatings.Max*fit;
        p.Profile.Curve=details.Curve/(float)CardRatings.Max*fit;
        p.Profile.Crossing=details.Crossing/(float)CardRatings.Max*fit;
        p.Profile.FreeKickAccuracy=details.FreeKickAccuracy/(float)CardRatings.Max*fit;
        p.Profile.LongPassing=details.LongPassing/(float)CardRatings.Max*fit;
        p.Profile.SkillMoves=c.SkillMoves;
        p.Profile.Vision=details.Vision/(float)CardRatings.Max*fit;p.Profile.Endurance=details.Stamina/(float)CardRatings.Max;
        p.Profile.Physical=PlayerCardDetails.Physical(c)/(float)CardRatings.Max;
        p.Profile.HeadingAccuracy=details.HeadingAccuracy/(float)CardRatings.Max*fit;
        p.Profile.Jumping=details.Jumping/(float)CardRatings.Max;
        p.Profile.Acceleration=Math.Min(1,details.Acceleration/(float)CardRatings.Max+(PlayerPlayStyles.Has(c,PlayStyle.QuickStep)?.06f:0));
        p.Profile.Defence=details.DefensiveAwareness/(float)CardRatings.Max*fit;
        p.Profile.Interceptions=details.Interceptions/(float)CardRatings.Max*fit;
        p.Profile.StandingTackle=details.StandingTackle/(float)CardRatings.Max*fit;
        p.Profile.SlidingTackle=details.SlidingTackle/(float)CardRatings.Max*fit;
        p.Profile.Goalkeeping=c.Keeping/(float)CardRatings.Max;
        p.Profile.GkDiving=details.GkDiving/99f;p.Profile.GkHandling=details.GkHandling/99f;
        p.Profile.GkReflexes=details.GkReflexes/99f;p.Profile.GkPositioning=details.GkPositioning/99f;
        p.Profile.GkKicking=details.GkKicking/99f;
        p.Profile.OffBall=details.Positioning/(float)CardRatings.Max*fit;p.Skill=(details.BallControl+details.Dribbling)/(2f*CardRatings.Max)*fit;p.Pace=4.5f+details.SprintSpeed/(float)CardRatings.Max*2.5f;
        p.PreferredFoot=c.Foot=="Left"?-1:1;p.WeakFootRating=c.WeakFoot;
        UniqueSkills.ApplyProfileBonuses(p);
        // Substitution replaces the identity in the same match slot. A previous
        // legend's pending command/effect must not be inherited by the newcomer.
        p.ActiveLegendaryAbility=LegendaryAbility.None;p.LegendaryActiveUntil=0;p.LegendaryReadyAt=0;
        p.LegendaryTargetPlayerA=p.LegendaryTargetPlayerB=-1;p.LegendaryRunUntil=0;
    }
    public void EnsurePersonalities()
    {
        NormalizeRelegationConsequence();
        PairFamiliarity??=new();
        CupTokens=Math.Max(0,CupTokens);
        if(PackInventory==null || PackInventory.Length!=ScoutPacks.All.Length)
        {
            var inventory=new int[ScoutPacks.All.Length];
            if(PackInventory!=null)Array.Copy(PackInventory,inventory,Math.Min(PackInventory.Length,inventory.Length));
            PackInventory=inventory;
        }
        for(int i=0;i<PackInventory.Length;i++)PackInventory[i]=Math.Max(0,PackInventory[i]);
        UpgradeRatings();EnsureClubWorld();
        TrainingInventory??=new();
        foreach(var item in TrainingInventory){item.Focus=0;item.Grade=Math.Clamp(item.Grade,1,3);}
        OwnedKitIds??=new();OwnedBallIds??=new();
        if(!OwnedKitIds.Contains("north"))OwnedKitIds.Insert(0,"north");
        if(!OwnedBallIds.Contains("classic"))OwnedBallIds.Insert(0,"classic");
        if(!OwnedKitIds.Contains(EquippedKitId))EquippedKitId="north";
        if(!OwnedBallIds.Contains(EquippedBallId))EquippedBallId="classic";
        NextTrainingCardId=Math.Max(NextTrainingCardId,TrainingInventory.Select(i=>i.Id).DefaultIfEmpty().Max()+1);
        SeenCatalogIds??=new();
        var used=new HashSet<string>(SeenCatalogIds.Where(id=>!string.IsNullOrEmpty(id)),StringComparer.Ordinal);
        foreach(var card in Cards.Where(c=>!c.IsAcademyLoan && !string.IsNullOrEmpty(c.CatalogId))) used.Add(card.CatalogId);
        foreach(var card in Cards)
        {
            if(card.IsAcademyLoan)continue;
            var rng=new Random(unchecked(ClubSeed+card.Id*137));
            if(card.Aggression<=0) card.Aggression=CardRatings.FromLegacy(rng.Next(35,116));
            if(card.Composure<=0) card.Composure=CardRatings.FromLegacy(rng.Next(25,116));
            if(string.IsNullOrEmpty(card.CatalogId) || PlayerCatalog.Find(card.CatalogId)==null)
            {
                var identity=PlayerCatalog.Draw(rng,used,card.Position,Version<3?CardOverall.Legacy(card):card.Overall);
                if(identity!=null)
                {
                    card.CatalogId=identity.CatalogId;card.Name=identity.Name;card.Country=identity.Country;
                    card.CountryCode=identity.CountryCode;card.Flag=identity.Flag;card.Foot=identity.Foot;
                    card.PreferredPosition=identity.PreferredPosition;
                    card.WeakFoot=identity.WeakFoot;card.SkillMoves=identity.SkillMoves;
                    card.Age=identity.Age;card.HeightCm=identity.HeightCm;
                    card.PortraitSkin=identity.PortraitSkin;card.PortraitHair=identity.PortraitHair;
                    card.PortraitStyle=identity.PortraitStyle;card.PortraitBeard=identity.PortraitBeard;
                    used.Add(identity.CatalogId);
                }
            }
            if(string.IsNullOrWhiteSpace(card.PreferredPosition))
                card.PreferredPosition=PlayerCatalog.Find(card.CatalogId)?.PreferredPosition??PlayerPositions.Card(card);
            // Professional portraits use the catalog appearance even in older saves.
            if(card.CatalogId?.StartsWith("PRO-",StringComparison.Ordinal)==true)
                card.PortraitBeard=PlayerCatalog.Find(card.CatalogId)?.PortraitBeard??card.PortraitBeard;
            if(!string.IsNullOrEmpty(card.CatalogId) && !SeenCatalogIds.Contains(card.CatalogId))SeenCatalogIds.Add(card.CatalogId);
        }
        if(Version<3)
        {
            foreach(var card in Cards)
            {
                int previousOverall=CardOverall.Legacy(card);
                var original=PlayerCatalog.Find(card.CatalogId);
                if(original==null)
                {
                    card.Dribbling=PlayerCardDetails.Dribbling(card);
                    card.Physical=PlayerCardDetails.Physical(card);
                    continue;
                }
                // Preserve earned value, identity, level and XP while replacing the
                // old all-round profile with a position-specific one exactly once.
                card.Pace=original.Pace;card.Shooting=original.Shooting;card.Passing=original.Passing;
                card.Dribbling=original.Dribbling;card.Defence=original.Defence;card.Physical=original.Physical;
                card.Endurance=original.Endurance;card.Keeping=original.Keeping;
                card.Aggression=original.Aggression;card.Composure=Math.Max(original.Composure,card.Composure);
                PlayerCatalog.TuneOverall(card,Math.Max(original.Overall,previousOverall));
                card.Details=PlayerCardDetails.Generate(card);
            }
            Version=3;
        }
        foreach(var card in Cards)
            if(card.Details is not {Acceleration:>0})card.Details=PlayerCardDetails.Generate(card);
        if(Version<4)
        {
            // Snapshot the abilities shown on existing cards once. Future cards only
            // receive styles through explicit card data, not their numeric ratings.
            foreach(var card in Cards)card.PlayStyles=PlayerPlayStyles.LegacyDerived(card);
            Version=4;
        }
        if(Version<5)
        {
            // Apply hand-authored reference data to cards whose identity is known.
            // Other owned cards retain their previous PlayStyles.
            foreach(var card in Cards)
            {
                var reference=PlayerCatalog.Find(card.CatalogId);
                if(reference?.PlayStyles is { } styles && styles!=PlayStyle.None)
                {
                    card.PlayStyles=styles;
                    card.PlayStylesPlus=reference.PlayStylesPlus;
                }
            }
            Version=5;
        }
        foreach(var card in Cards)
        {
            var reference=PlayerCatalog.FindVariant(card.CardKey);
            if(card.Potential<=0 && reference!=null)card.Potential=Math.Max(card.Overall,reference.Potential);
            if(string.IsNullOrEmpty(card.Edition) && reference!=null)card.Edition=reference.Edition;
            if(card.TrainingTargets?.Length!=CardDevelopment.TrainingStatCount && reference?.TrainingTargets?.Length==CardDevelopment.TrainingStatCount)
            {
                // Copy the new immutable catalogue endpoint to old saves.
                // Previously earned gains above it are retained and normalized.
                card.TrainingTargets=reference.TrainingTargets.ToArray();
                card.StatCaps=reference.StatCaps.ToArray();
                card.Potential=Math.Max(card.Overall,reference.Potential);
            }
            CardDevelopment.Initialize(card);
            UniqueSkills.Normalize(card);
        }
        if(BalanceRevision<1)
        {
            foreach(var card in Cards)
            {
                var reference=PlayerCatalog.Find(card.CatalogId);if(reference==null)continue;
                card.Pace=reference.Pace;card.Shooting=reference.Shooting;card.Passing=reference.Passing;
                card.Dribbling=reference.Dribbling;card.Defence=reference.Defence;card.Physical=reference.Physical;
                card.Endurance=reference.Endurance;card.Keeping=reference.Keeping;
                card.Aggression=reference.Aggression;card.Composure=reference.Composure;
                card.Age=reference.Age;card.HeightCm=reference.HeightCm;
                if(string.IsNullOrEmpty(card.Edition))card.Edition=reference.Edition;
                card.Potential=Math.Max(reference.Potential,card.Overall);card.StatCaps=Array.Empty<int>();card.TrainingTargets=Array.Empty<int>();
                CardDevelopment.Initialize(card);
                card.Details=PlayerCardDetails.Generate(card);
            }
            BalanceRevision=1;
        }
        if(LegendaryRevision<1)
        {
            foreach(var card in Cards.Where(c=>c.Edition==CardRatings.Legendary))
                PlayerCatalog.ApplyLegendaryProfile(card);
            LegendaryRevision=1;
        }
        MigrateCompetitions();
        NormalizeCareerAchievements();
        if(Version<6)Version=6;
        InitializeMatchHistory();
        EnsureAcademyReserves();
    }
    public void UpgradeRatings()
    {
        if(Version>=2)return;
        foreach(var card in Cards)
        {
            card.Pace=CardRatings.FromLegacy(card.Pace);card.Shooting=CardRatings.FromLegacy(card.Shooting);
            card.Passing=CardRatings.FromLegacy(card.Passing);card.Defence=CardRatings.FromLegacy(card.Defence);
            card.Endurance=CardRatings.FromLegacy(card.Endurance);card.Keeping=CardRatings.FromLegacy(card.Keeping);
            card.Aggression=CardRatings.FromLegacy(card.Aggression);card.Composure=CardRatings.FromLegacy(card.Composure);
        }
        Version=2;
    }
}