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.
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();
}
}