ClubAcademy.cs

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;
    }
}