Career-mode relegation consequences for a soccer game. Stores a persisted relegation record, computes the fine from the division dropped, builds a preview of the coins plus spare cards to confiscate while protecting the starting XI, applies the charge, and resolves it either by paying or by playing an exhibition media match.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace Socer;
/// <summary>A single persistent choice attached to an actual division drop.</summary>
public sealed class RelegationConsequence
{
public int FromDivision {get;set;}
public int ToDivision {get;set;}
public int Season {get;set;}
public int Seed {get;set;}
public int Fine {get;set;}
public bool MediaStarted {get;set;}
public bool Resolved {get;set;}
public string Resolution {get;set;}="";
}
public sealed class RelegationCardCharge
{
public int Id {get;set;}
public string Name {get;set;}="";
public int Overall {get;set;}
public int Value {get;set;}
}
/// <summary>The exact cost shown before acceptance. Card valuation uses normal resale value.</summary>
public sealed class RelegationFinePlan
{
public int Fine {get;set;}
public int MoneyCharged {get;set;}
public int CardValue {get;set;}
public int ChangeReturned {get;set;}
public int ShortfallWaived {get;set;}
public int EffectiveFine {get;set;}
public string PreviewKey {get;set;}="";
public List<RelegationCardCharge> Cards {get;set;}=new();
}
public sealed partial class ClubCareer
{
public RelegationConsequence Relegation {get;set;}
[JsonIgnore] public bool HasRelegationChoice=>Relegation is {Resolved:false} &&
Relegation.FromDivision is >=2 and <=5 && Relegation.ToDivision==Relegation.FromDivision-1 &&
Relegation.Season==Season && Relegation.ToDivision==Division && Relegation.Seed!=0;
public static int RelegationFineFor(int fromDivision)=>fromDivision switch
{2=>600,3=>1000,4=>1800,5=>2800,_=>0};
void CreateRelegationConsequence(int fromDivision)
{
if(Division>=fromDivision)return;
uint hash=PlayerCatalog.Hash($"media-relegation:{ClubSeed}:{Season}:{fromDivision}:{Division}");
Relegation=new(){FromDivision=fromDivision,ToDivision=Division,Season=Season,
Seed=(int)(hash%int.MaxValue)+1,Fine=RelegationFineFor(fromDivision)};
}
// Existing saves have no obligation. A malformed/stale serialized record
// cannot invent a historic punishment, bypass a real fresh record or charge twice.
void NormalizeRelegationConsequence()
{
if(Relegation==null)return;
if(Relegation.FromDivision is <2 or >5 || Relegation.ToDivision!=Relegation.FromDivision-1 ||
Relegation.Season<1 || Relegation.Seed==0)
{Relegation=null;return;}
Relegation.Fine=RelegationFineFor(Relegation.FromDivision);
Relegation.Resolution??="";
if(!Relegation.Resolved && (Relegation.Season!=Season || Relegation.ToDivision!=Division))
{Relegation.Resolved=true;Relegation.Resolution="stale";}
}
HashSet<int> RelegationProtectedCore()
{
// Free academy reserves are temporary cover, never economic assets or
// substitutes for the permanent core protected from confiscation.
var roster=(Cards??new()).Where(c=>c!=null && !c.IsAcademyLoan).GroupBy(c=>c.Id).Select(g=>g.First()).ToArray();
var protectedIds=new HashSet<int>((Lineup??Array.Empty<int>()).Where(id=>roster.Any(c=>c.Id==id)));
// Keep the user's selected XI, including its unique editions/skills.
// If it was incomplete, also protect enough distinct identities to rebuild it.
var keeper=roster.Where(c=>c.Position=="ВР" && !string.IsNullOrEmpty(c.CatalogId))
.OrderByDescending(c=>protectedIds.Contains(c.Id)).ThenByDescending(c=>c.Overall).ThenBy(c=>c.Id).FirstOrDefault();
if(keeper==null)return roster.Select(c=>c.Id).ToHashSet();
var outfield=roster.Where(c=>c.Position!="ВР" && !string.IsNullOrEmpty(c.CatalogId) && c.CatalogId!=keeper.CatalogId)
.OrderByDescending(c=>protectedIds.Contains(c.Id)).ThenByDescending(c=>c.Overall).ThenBy(c=>c.Id)
.GroupBy(c=>c.CatalogId,StringComparer.Ordinal).Select(g=>g.First()).Take(10).ToArray();
if(outfield.Length<10)return roster.Select(c=>c.Id).ToHashSet();
protectedIds.Add(keeper.Id);
foreach(var card in outfield)protectedIds.Add(card.Id);
return protectedIds;
}
public RelegationFinePlan PreviewRelegationFine()
{
if(!HasRelegationChoice)return null;
var plan=new RelegationFinePlan{Fine=RelegationFineFor(Relegation.FromDivision)};
plan.MoneyCharged=Math.Min(Math.Max(0,Coins),plan.Fine);
int remaining=plan.Fine-plan.MoneyCharged;
var roster=(Cards??new()).Where(c=>c!=null && !c.IsAcademyLoan).GroupBy(c=>c.Id).Select(g=>g.First()).ToArray();
var protectedIds=RelegationProtectedCore();
var duplicateKeys=roster.GroupBy(c=>c.CardKey,StringComparer.Ordinal).Where(g=>g.Count()>1)
.Select(g=>g.Key).ToHashSet(StringComparer.Ordinal);
var candidates=roster.Where(c=>!protectedIds.Contains(c.Id) && CardEconomy.SellValue(c)>0).ToList();
// Spare duplicates go first among cards that fit the outstanding sum.
// If every card overshoots, take the cheapest one and return its change,
// rather than taking an expensive legend while cheap reserves remain.
while(remaining>0 && candidates.Count>0)
{
var card=candidates.Where(c=>CardEconomy.SellValue(c)<=remaining)
.OrderBy(c=>duplicateKeys.Contains(c.CardKey)?0:1).ThenBy(CardEconomy.SellValue).ThenBy(c=>c.Id)
.FirstOrDefault()??candidates.OrderBy(CardEconomy.SellValue)
.ThenBy(c=>duplicateKeys.Contains(c.CardKey)?0:1).ThenBy(c=>c.Id).First();
int value=CardEconomy.SellValue(card);
plan.Cards.Add(new(){Id=card.Id,Name=card.Name,Overall=card.Overall,Value=value});
plan.CardValue+=value;remaining-=value;candidates.Remove(card);
}
// Never charge beyond a whole card's proportional resale value. Its
// excess is paid back. Preserve a playable club instead of creating debt.
plan.ChangeReturned=Math.Max(0,-remaining);
plan.ShortfallWaived=Math.Max(0,remaining);
plan.EffectiveFine=plan.Fine-plan.ShortfallWaived;
string signature=$"{Relegation.Seed}:{Coins}:{plan.Fine}:{plan.MoneyCharged}:{plan.CardValue}:"+
string.Join(",",plan.Cards.Select(c=>$"{c.Id}={c.Value}"))+":"+
string.Join(",",protectedIds.OrderBy(id=>id));
plan.PreviewKey=PlayerCatalog.Hash(signature).ToString("X8");
return plan;
}
public bool ApplyRelegationFine(string previewKey=null)
{
if(!HasRelegationChoice || Pending!=null)return false;
var plan=PreviewRelegationFine();
if(plan==null || (previewKey!=null && previewKey!=plan.PreviewKey))return false;
var removed=plan.Cards.Select(c=>c.Id).ToHashSet();
Cards.RemoveAll(c=>c!=null && removed.Contains(c.Id));
if(Lineup!=null)for(int slot=0;slot<Lineup.Length;slot++)if(removed.Contains(Lineup[slot]))Lineup[slot]=-1;
Coins=(int)Math.Min(int.MaxValue,(long)Math.Max(0,Coins)-plan.MoneyCharged+plan.ChangeReturned);
if(PairFamiliarity!=null)
{
foreach(string pair in PairFamiliarity.Keys.ToArray())
{
var ids=pair.Split(':');
if(ids.Length==2 && ((int.TryParse(ids[0],out int a) && removed.Contains(a)) ||
(int.TryParse(ids[1],out int b) && removed.Contains(b))))PairFamiliarity.Remove(pair);
}
}
Relegation.Resolved=true;Relegation.Resolution="fine";
EnsureAcademyReserves();
LastResult=$"Relegation fine paid: {plan.MoneyCharged} coins, {plan.Cards.Count} spare cards."+
(plan.ChangeReturned>0?$" Change returned: {plan.ChangeReturned} coins.":"")+
(plan.ShortfallWaived>0?$" Protected starter core: {plan.ShortfallWaived} coins waived.":"");
return true;
}
public int BeginRelegationMediaMatch()
{
if(!HasRelegationChoice || Pending!=null)return 0;
Relegation.MediaStarted=true;
return Relegation.Seed;
}
public bool ResolveRelegationMediaMatch(MatchSimulation match)
{
if(!HasRelegationChoice || !Relegation.MediaStarted || Pending!=null ||
match==null || !match.IsMediaLeague || !match.Finished || match.Abandoned || match.Seed!=Relegation.Seed)return false;
// This is an exhibition: no fixture, rewards, familiarity, training,
// match history, standings or career achievements are touched.
Relegation.Resolved=true;Relegation.Resolution="media";
LastResult="Media League match completed. The relegation fine is cancelled.";
return true;
}
}