Part of the club career system for a soccer game. It tops up a club's roster with generated academy loan players, retires surplus loans, and fills the starting XI with valid players so a match lineup stays legal.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace Socer;
public sealed partial class ClubCareer
{
[JsonIgnore] public int AcademyLoanCount => Cards.Count(c=>c.IsAcademyLoan);
// Keep a legal XI available, but never silently fill a deliberately emptied slot.
// Participating cards stay in the roster until the match has been settled.
public void EnsureAcademyReserves(bool beforeKickoff=false)
{
if(Pending!=null && !beforeKickoff)return;
NextCardId=Math.Max(1,Math.Max(NextCardId,Cards.Select(c=>c.Id).DefaultIfEmpty(0).Max()+1));
var keeper=Cards.Where(c=>c.Position=="ВР").OrderBy(c=>c.IsAcademyLoan)
.ThenByDescending(c=>Lineup.Contains(c.Id)).ThenByDescending(c=>c.Overall).FirstOrDefault();
if(keeper==null){keeper=CreateAcademyPlayer(0);Cards.Add(keeper);}
int keepKeepers=Cards.Any(c=>!c.IsAcademyLoan && c.Position=="ВР")?0:1;
int permanentOutfield=Cards.Where(c=>!c.IsAcademyLoan && c.Position!="ВР" && c.CatalogId!=keeper.CatalogId)
.Select(c=>c.CatalogId).Distinct(StringComparer.Ordinal).Count();
int keepOutfield=Math.Max(0,10-permanentOutfield);
foreach(var loan in Cards.Where(c=>c.IsAcademyLoan && !Lineup.Contains(c.Id)).OrderByDescending(c=>c.Id).ToArray())
{
int needed=loan.Position=="ВР"?keepKeepers:keepOutfield;
int available=Cards.Count(c=>c.IsAcademyLoan && (c.Position=="ВР")== (loan.Position=="ВР"));
if(available>needed)RetireAcademyPlayer(loan);
}
while(Cards.Where(c=>c.Position!="ВР" && c.CatalogId!=keeper.CatalogId).Select(c=>c.CatalogId).Distinct(StringComparer.Ordinal).Count()<10)
Cards.Add(CreateAcademyPlayer(NextAcademySlot()));
}
int NextAcademySlot()
{
var used=new HashSet<string>(StringComparer.Ordinal);
var occupied=new bool[11];
// Reserve every valid existing starter first. Otherwise an earlier hole
// could borrow a later starter and produce academy players for the wrong roles.
for(int slot=0;slot<11;slot++)
{
var current=Card(slot);
occupied[slot]=current!=null && (slot==0)==(current.Position=="ВР") && used.Add(current.CatalogId);
}
for(int slot=0;slot<11;slot++)
{
if(occupied[slot])continue;
var reserve=Cards.Where(c=>(slot==0)==(c.Position=="ВР") && !used.Contains(c.CatalogId))
.OrderBy(c=>c.IsAcademyLoan).ThenBy(c=>c.IsAcademyLoan && c.PreferredPosition!=PlayerPositions.Slot(Shape,slot))
.ThenByDescending(c=>c.IsAcademyLoan?Formation.Fit(c,Shape,slot):c.Overall*Formation.Fit(c,Shape,slot))
.ThenByDescending(c=>c.Overall).ThenBy(c=>c.Id).FirstOrDefault();
if(reserve==null)return slot;
used.Add(reserve.CatalogId);
}
return 10;
}
PlayerCard CreateAcademyPlayer(int slot)
{
int id=NextCardId++;
var rng=new Random(unchecked(ClubSeed+id*104729));
string role=Shape.Roles[slot];
var portraits=PlayerCatalog.All.Where(c=>c.Overall<CardRatings.Silver && (slot==0)==(c.Position=="ВР"))
.OrderBy(c=>c.Position==role?0:1).ThenBy(c=>c.CatalogId,StringComparer.Ordinal).ToArray();
var preferred=portraits.Where(c=>c.Position==role && !Cards.Any(owned=>owned.CatalogId==c.CatalogId || owned.AcademyPortraitId==c.CatalogId)).ToArray();
var pool=preferred.Length>0?preferred:portraits;
var source=pool.Length>0?pool[rng.Next(pool.Length)]:null;
var card=source==null?new PlayerCard{Name="Academy Youth",Country="England",CountryCode="gb-eng"}:PlayerCatalog.Clone(source);
card.Id=id;card.IsAcademyLoan=true;card.AcademyPortraitId=source?.CatalogId??"";
card.CatalogId=$"ACADEMY-{ClubSeed:X8}-{id}";
card.Position=role;card.PreferredPosition=PlayerPositions.Slot(Shape,slot);
card.Age=17+rng.Next(3);card.HeightCm=slot==0?183+rng.Next(9):169+rng.Next(20);
card.Pace=rng.Next(35,44);card.Passing=rng.Next(32,41);
card.Shooting=role=="НП"?rng.Next(36,44):rng.Next(25,36);
card.Dribbling=rng.Next(32,41);card.Defence=role is "ЦЗ" or "КЗ"?rng.Next(36,44):rng.Next(24,35);
card.Physical=rng.Next(31,41);card.Endurance=rng.Next(36,44);card.Keeping=slot==0?rng.Next(37,44):8;
card.Aggression=rng.Next(28,41);card.Composure=rng.Next(28,37);card.WeakFoot=2;card.SkillMoves=1;
card.Edition="BRONZE";card.PlayStyles=card.PlayStylesPlus=PlayStyle.None;card.UniqueSkill=UniqueSkill.None;
card.SkillLoadout=new();card.SkillLoadoutVersion=1;card.SkillRerolls=card.Level=card.Experience=0;
card.StatCaps=card.TrainingTargets=Array.Empty<int>();
card.Details=PlayerCardDetails.Generate(card);card.Potential=card.Overall;
CardDevelopment.Initialize(card);
return card;
}
void RetireAcademyPlayer(PlayerCard loan)
{
Cards.Remove(loan);
if(PairFamiliarity==null)return;
string id=loan.Id.ToString(System.Globalization.CultureInfo.InvariantCulture);
foreach(string key in PairFamiliarity.Keys.ToArray())
{
var pair=key.Split(':');
if(pair.Length==2 && (pair[0]==id || pair[1]==id))PairFamiliarity.Remove(key);
}
}
public bool CompleteAcademyLineup(bool beforeKickoff=false)
{
if(HasRelegationChoice || Pending!=null && !beforeKickoff || Lineup.Length!=11)return false;
EnsureAcademyReserves(beforeKickoff);
var used=new HashSet<string>(StringComparer.Ordinal);
for(int slot=0;slot<11;slot++)
{
var current=Card(slot);
if(current!=null && (slot==0)==(current.Position=="ВР") && used.Add(current.CatalogId))continue;
Lineup[slot]=-1;
}
for(int slot=0;slot<11;slot++)
{
if(Lineup[slot]>=0)continue;
var reserve=Cards.Where(c=>(slot==0)==(c.Position=="ВР") && !used.Contains(c.CatalogId))
.OrderBy(c=>c.IsAcademyLoan).ThenBy(c=>c.IsAcademyLoan && c.PreferredPosition!=PlayerPositions.Slot(Shape,slot))
.ThenByDescending(c=>c.IsAcademyLoan?Formation.Fit(c,Shape,slot):c.Overall*Formation.Fit(c,Shape,slot))
.ThenByDescending(c=>c.Overall).ThenBy(c=>c.Id).FirstOrDefault();
if(reserve==null)return false;
Lineup[slot]=reserve.Id;used.Add(reserve.CatalogId);
}
EnsureAcademyReserves(beforeKickoff);
return HasCompleteLineup;
}
}