ClubWorld.cs

Partial ClubCareer class that generates and maintains the world club leagues: choosing the eight clubs in the player's division, building squads for AI clubs from the player catalog, and handling promotion and relegation between divisions each season.

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

namespace Socer;
public sealed class ClubSquad
{
    public int ClubId {get;set;}
    public int FormationIndex {get;set;}
    public List<string> Players {get;set;}=new();
}
public sealed partial class ClubCareer
{
    public int[] LeagueClubIds {get;set;}=Array.Empty<int>();
    public List<ClubSquad> WorldSquads {get;set;}=new();
    public int WorldDivision {get;set;}
    public int ClubWorldRevision {get;set;}
    public int[] ClubDivisions {get;set;}=Array.Empty<int>();
    int[] legacyLeagueClubs;
    public string TeamName(int slot,CupTournament cup=null)=>ClubCatalog.Get(ClubId(slot,cup)).Name;
    public int ClubId(int slot,CupTournament cup=null)
    {
        EnsureClubWorld();
        if(cup!=null){EnsureCupEntrants(cup);return cup.ClubIds[Math.Clamp(slot,0,7)];}
        return LeagueClubIds[Math.Clamp(slot,0,7)];
    }
    public string TeamCrest(int slot,CupTournament cup=null)=>ClubCatalog.Get(ClubId(slot,cup)).Crest;
    public int ClubDivision(int id)=>ClubDivisions?.Length==ClubCatalog.All.Length?ClubDivisions[Math.Clamp(id,0,ClubDivisions.Length-1)]:ClubCatalog.Get(id).Division;
    public void EnsureClubWorld()
    {
        WorldSquads??=new();
        if(ClubDivisions?.Length!=ClubCatalog.All.Length)ClubDivisions=ClubCatalog.All.Select(c=>c.Division).ToArray();
        bool valid=LeagueClubIds is {Length:8} && LeagueClubIds.Distinct().Count()==8 && LeagueClubIds.All(id=>id>=0 && id<ClubCatalog.All.Length);
        if(valid && ClubWorldRevision>=2)return;
        if(valid)legacyLeagueClubs=LeagueClubIds.ToArray();
        else if(League.Any(f=>f.Played) || Pending!=null)legacyLeagueClubs=Enumerable.Range(0,8).ToArray();
        var retained=valid?LeagueClubIds.Skip(1).Where(id=>ClubCatalog.Get(id).Division==Division):Array.Empty<int>();
        LeagueClubIds=ChooseClubs(retained);
        // Old hard-coded top clubs had been generated with the starter league's
        // ratings. Their identities now keep a squad appropriate to their tier.
        WorldSquads.RemoveAll(s=>s.ClubId is >0 and <8 && s.Players.Count>0 && s.Players.Select(PlayerCatalog.Find).Where(p=>p!=null).Select(p=>p.Overall).DefaultIfEmpty(0).Average()<76);
        ClubWorldRevision=2;
        WorldDivision=Division;
    }
    int[] ChooseClubs(IEnumerable<int> retained)
    {
        var result=new List<int>{0};result.AddRange(retained.Where(id=>id!=0).Distinct().Take(7));
        var rng=new Random(unchecked((int)PlayerCatalog.Hash($"club-draw-{ClubSeed}-{Season}")));
        foreach(var club in ClubCatalog.All.Where(c=>c.Id!=0 && ClubDivision(c.Id)==Division && !result.Contains(c.Id))
            .OrderBy(c=>rng.Next()))
        {if(result.Count==8)break;result.Add(club.Id);}
        return result.ToArray();
    }
    void RotateClubs(int oldDivision)
    {
        EnsureClubWorld();var old=LeagueClubIds.ToArray();
        var table=Standings();
        var exchanged=new HashSet<int>();
        var rng=new Random(unchecked((int)PlayerCatalog.Hash($"club-exchange-{ClubSeed}-{Season}")));
        void Exchange(int destination,bool promoted)
        {
            // Neighbouring leagues send one club in the opposite direction.
            // Otherwise a long stay in the bottom league drains its whole pool.
            var candidates=ClubCatalog.All.Where(c=>c.Id!=0 && ClubDivision(c.Id)==destination && !old.Contains(c.Id) && !exchanged.Contains(c.Id) && (c.Division!=5 || oldDivision>=4));
            var incoming=promoted?candidates.OrderBy(c=>WorldClubStrength(c.Id)).ThenBy(c=>rng.Next()).FirstOrDefault():
                candidates.OrderByDescending(c=>WorldClubStrength(c.Id)).ThenBy(c=>rng.Next()).FirstOrDefault();
            if(incoming==null)return;
            ClubDivisions[incoming.Id]=oldDivision;exchanged.Add(incoming.Id);
        }
        foreach(var row in table.Where(r=>r.Team!=0))
        {
            int place=table.IndexOf(row)+1,id=old[row.Team];
            if(place<=2 && oldDivision<5){ClubDivisions[id]=oldDivision+1;Exchange(oldDivision+1,true);}
            else if(place==8 && oldDivision>1){int destination=Math.Max(ClubCatalog.Get(id).Division==5?4:1,oldDivision-1);ClubDivisions[id]=destination;if(destination!=oldDivision)Exchange(destination,false);}
        }
        var retained=oldDivision==Division?old.Skip(1).Where(id=>ClubDivision(id)==Division).ToArray():Array.Empty<int>();
        // At the floor/ceiling, rotate the weakest regional challenger so each
        // new season still introduces a club without redrawing retained squads.
        if(oldDivision==Division && retained.Length==7)retained=retained.Where(id=>id!=old[table.Last(r=>r.Team!=0).Team]).ToArray();
        LeagueClubIds=ChooseClubs(retained);
        WorldDivision=Division;
    }
    float WorldClubStrength(int id)
    {
        var squad=WorldSquads.FirstOrDefault(s=>s.ClubId==id);
        if(squad?.Players.Count>0)return (float)squad.Players.Select(PlayerCatalog.Find).Where(c=>c!=null).Select(c=>c.Overall).DefaultIfEmpty(0).Average();
        int division=ClubCatalog.Get(id).Division;
        return new[]{0,56,66,75,80,86}[division]+(int)(PlayerCatalog.Hash($"strength-{id}")%(division<=2?9:13))-(division<=2?4:6);
    }
    public ClubSquad SquadFor(int slot,CupTournament cup=null)=>SquadForClub(ClubId(slot,cup));
    public ClubSquad SquadForClub(int id)
    {
        EnsureClubWorld();
        var existing=WorldSquads.FirstOrDefault(s=>s.ClubId==id);
        if(existing!=null && existing.Players.Count>=11 && existing.Players.All(p=>PlayerCatalog.Find(p)!=null))return existing;
        var definition=ClubCatalog.Get(id);
        var rng=new Random(unchecked((int)PlayerCatalog.Hash($"squad-{ClubSeed}-{id}")));
        int formation=rng.Next(Formation.All.Length);var shape=Formation.All[formation];
        int ratingDivision=definition.Division;
        int baseRating=new[]{0,56,66,75,80,86}[ratingDivision]+(int)(PlayerCatalog.Hash($"strength-{id}")%(ratingDivision<=2?9:13))-(ratingDivision<=2?4:6);
        var squad=new ClubSquad{ClubId=id,FormationIndex=formation};
        var roles=shape.Roles.Concat(new[]{"ВР","ЦЗ","ЦП","НП"}).ToArray();
        for(int slotIndex=0;slotIndex<roles.Length;slotIndex++)
        {
            string role=roles[slotIndex];
            int target=baseRating+rng.Next(-4,5);
            string desired=slotIndex<11?PlayerPositions.Slot(shape,slotIndex):role switch {"ВР"=>"GK","ЦЗ"=>"CB","ЦП"=>"CM",_=>"ST"};
            bool FitsSlot(PlayerCard c)=>c.PreferredPosition==desired ||
                (desired=="LWB" && c.PreferredPosition=="LB") || (desired=="RWB" && c.PreferredPosition=="RB");
            var candidates=PlayerCatalog.All.Where(c=>(c.Position==role ||
                (desired is "LWB" or "RWB" && c.Position=="КЗ")) && !squad.Players.Contains(c.CatalogId)).ToArray();
            int nearest=candidates.Min(c=>Math.Abs(c.Overall-target));
            var specialists=candidates.Where(FitsSlot).ToArray();
            if(specialists.Length>0 && specialists.Min(c=>Math.Abs(c.Overall-target))<=nearest+8)candidates=specialists;
            nearest=candidates.Min(c=>Math.Abs(c.Overall-target));
            var shortlist=candidates.Where(c=>Math.Abs(c.Overall-target)<=nearest+3).ToArray();
            squad.Players.Add(shortlist[rng.Next(shortlist.Length)].CatalogId);
        }
        WorldSquads.RemoveAll(s=>s.ClubId==id);WorldSquads.Add(squad);return squad;
    }
    public PlayerCard[] OpponentCards(int slot,CupTournament cup=null)
    {
        if(slot==0)return Enumerable.Range(0,11).Select(Card).Where(c=>c!=null).ToArray();
        var squad=SquadFor(slot,cup);
        return squad.Players.Select((id,i)=>{
            var card=PlayerCatalog.Clone(PlayerCatalog.Find(id));card.Id=10000+squad.ClubId*100+i;return card;
        }).ToArray();
    }
}