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