CareerSaveValidation.cs

Validates and migrates career save JSON before a loaded career is accepted. Parses the file, checks version, structure, collection sizes and cross references, runs personality and academy repairs, then revalidates the result.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Socer;

/// <summary>Validates a private candidate before any loaded career becomes live.</summary>
public static class CareerSaveValidation
{
    public const int CurrentVersion=6;
    const int MaxTextLength=16*1024*1024;
    public const int MaxCards=4096,MaxInventory=4096;
    const int MaxHistory=10000;
    const int MaxFixtures=512,MaxTournaments=32,MaxCupGames=64;
    const int MaxIdentity=int.MaxValue-100000;
    // Sandbox saves flags/enums as names; older verification/legacy files used numbers.
    static readonly JsonSerializerOptions Options=new()
    {PropertyNameCaseInsensitive=true,MaxDepth=32,Converters={new JsonStringEnumConverter()}};
    static readonly HashSet<string> Collections=new(StringComparer.OrdinalIgnoreCase)
    {
        "Cards","Lineup","Instructions","League","Cup","TrainingInventory","PackInventory",
        "OwnedKitIds","OwnedBallIds","SeenCatalogIds","PairFamiliarity","Tournaments","CupHistory",
        "Checkpoints","MatchHistory","LeagueHistory","Achievements","SeasonChallenges","LeagueClubIds",
        "WorldSquads","ClubDivisions","SkillLoadout","StatCaps","TrainingTargets","Games","ClubIds",
        "Players","HomePlayers","AwayPlayers","PenaltyKicks","Matches"
    };

    /// <summary>A newer primary must not silently roll back to an older backup.</summary>
    public static bool IsFutureVersion(string text)
    {
        if(string.IsNullOrWhiteSpace(text) || text.Length>MaxTextLength)return false;
        try
        {
            using var document=JsonDocument.Parse(text,new JsonDocumentOptions{MaxDepth=32});
            return document.RootElement.ValueKind==JsonValueKind.Object &&
                Property(document.RootElement,"Version",out var version) && version.ValueKind==JsonValueKind.Number &&
                version.TryGetDouble(out double value) && value>CurrentVersion;
        }
        catch{return false;}
    }

    public static bool TryPrepare(string text,out ClubCareer career,out string error)
    {
        career=null;error="";
        try
        {
            Require(!string.IsNullOrWhiteSpace(text),"The career file is empty.");
            Require(text.Length<=MaxTextLength,"The career file exceeds the 16 MiB safety limit.");
            using var document=JsonDocument.Parse(text,new JsonDocumentOptions{MaxDepth=32});
            var root=document.RootElement;
            Require(root.ValueKind==JsonValueKind.Object,"A career must be a JSON object.");
            int version=1; // Early saves without a version still need the legacy migrations.
            if(Property(root,"Version",out var savedVersion))
            {
                Require(savedVersion.ValueKind==JsonValueKind.Number && savedVersion.TryGetInt32(out version),"Invalid career version.");
                Require(version<=CurrentVersion,"This career was saved by a newer version of the game.");
                Require(version>=1,"Unsupported career version.");
            }
            foreach(string name in new[]{"Cards","Lineup","League"})
                Require(Property(root,name,out var core) && core.ValueKind==JsonValueKind.Array,"Missing career data: "+name+".");
            int nodes=0;
            ValidateJson(root,"career",ref nodes);
            var candidate=JsonSerializer.Deserialize<ClubCareer>(text,Options);
            Require(candidate!=null,"The career object is null.");
            candidate.Version=version;
            Validate(candidate,false);
            // Both migrations and their catalogue lookups operate on the private object only.
            candidate.EnsurePersonalities();
            // Loaded pending games restart at kickoff. Repair only the private roster;
            // live matches still prohibit roster changes and retain participating cards.
            candidate.EnsureAcademyReserves(true);
            candidate.NextCardId=Math.Max(candidate.NextCardId,candidate.Cards.Max(card=>card.Id)+1);
            Validate(candidate,true);
            career=candidate;
            return true;
        }
        catch(Exception exception)
        {
            // Never leak a partly deserialized or partly migrated object to the caller.
            career=null;error=exception.Message;return false;
        }
    }

    static bool Property(JsonElement element,string name,out JsonElement result)
    {
        foreach(var property in element.EnumerateObject())
            if(property.Name.Equals(name,StringComparison.OrdinalIgnoreCase)){result=property.Value;return true;}
        result=default;return false;
    }
    static void Require(bool condition,string error)
    {if(!condition)throw new InvalidOperationException(error);}

    static void ValidateJson(JsonElement element,string path,ref int nodes)
    {
        Require(++nodes<=1000000,"The career contains too many values.");
        switch(element.ValueKind)
        {
            case JsonValueKind.Object:
                var names=new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                foreach(var property in element.EnumerateObject())
                {
                    Require(names.Add(property.Name),"Duplicate JSON field: "+path+"."+property.Name);
                    Require(property.Name.Length<=256,"An oversized JSON field name was found.");
                    Require(!Collections.Contains(property.Name) || property.Value.ValueKind!=JsonValueKind.Null,
                        "Null collection: "+path+"."+property.Name);
                    ValidateJson(property.Value,path+"."+property.Name,ref nodes);
                }
                break;
            case JsonValueKind.Array:
                Require(element.GetArrayLength()<=MaxHistory,"An oversized array was found: "+path);
                foreach(var item in element.EnumerateArray())
                {
                    Require(item.ValueKind!=JsonValueKind.Null,"Null array entry: "+path);
                    ValidateJson(item,path+"[]",ref nodes);
                }
                break;
            case JsonValueKind.String:
                Require(element.GetString().Length<=16384,"An oversized string was found: "+path);
                break;
            case JsonValueKind.Number:
                Require(element.TryGetDouble(out double value) && double.IsFinite(value),"Non-finite number: "+path);
                break;
        }
    }

    static void List<T>(ICollection<T> values,int maximum,string name) where T:class
    {
        Require(values!=null && values.Count<=maximum,"Missing or oversized collection: "+name);
        Require(values.All(value=>value!=null),"Null entry: "+name);
    }
    static void Strings(ICollection<string> values,int maximum,string name)
    {List(values,maximum,name);Require(values.All(value=>value.Length<=256),"Oversized identifier: "+name);}
    static void Identity(int id,string name)=>Require(id>=0 && id<=MaxIdentity,"Invalid identity: "+name);
    static void FormationIndex(int index,string name)=>Require(index>=0 && index<Formation.All.Length,"Invalid formation: "+name);
    static void ArraySize(Array array,int maximum,string name)=>Require(array!=null && array.Length<=maximum,"Missing or oversized array: "+name);

    static void Validate(ClubCareer c,bool migrated)
    {
        Require(c.Version is >=1 and <=CurrentVersion,"Unsupported career version.");
        Require(c.Season is >=1 and <=1000000,"Invalid season.");
        Require(c.Division is >=1 and <=5,"Invalid division.");
        FormationIndex(c.FormationIndex,"career");
        Require(c.CoachTactic is >=0 and <=2 && float.IsFinite(c.CoachTempo) && c.CoachTempo is >=0 and <=1 &&
            float.IsFinite(c.CoachMentality) && (c.CoachMentality==-1 || c.CoachMentality is >=0 and <=1),"Invalid coach settings.");
        List(c.Cards,MaxCards,"Cards");
        // An empty, deliberately cleared roster is repairable by the academy.
        // Its lineup still cannot contain any dangling occupied slots below.
        if(migrated)Require(c.Cards.Count>0,"The career has no player cards.");
        Require(c.Lineup is {Length:11},"Lineup must contain exactly eleven slots.");
        Require(c.Instructions is {Length:11} && c.Instructions.All(order=>order>=0 && order<ClubCareer.Orders.Length),
            "Instructions must contain eleven valid orders.");
        var ids=new HashSet<int>();
        foreach(var card in c.Cards){ValidateCard(card,migrated);Require(ids.Add(card.Id),"Duplicate player card identity.");}
        var occupied=new HashSet<int>();
        foreach(int id in c.Lineup)
        {
            if(id==-1)continue;
            Require(ids.Contains(id),"Lineup references a missing player card.");
            Require(occupied.Add(id),"The same player card occupies more than one lineup slot.");
        }
        List(c.TrainingInventory,MaxInventory,"TrainingInventory");
        var trainingIds=new HashSet<int>();
        foreach(var item in c.TrainingInventory)
        {Identity(item.Id,"training card");Require(trainingIds.Add(item.Id),"Duplicate upgrade card identity.");}
        Identity(c.NextCardId,"NextCardId");Identity(c.NextTrainingCardId,"NextTrainingCardId");
        ArraySize(c.PackInventory,256,"PackInventory");
        Strings(c.OwnedKitIds,4096,"OwnedKitIds");Strings(c.OwnedBallIds,4096,"OwnedBallIds");
        Strings(c.SeenCatalogIds,MaxHistory,"SeenCatalogIds");
        Require(c.PairFamiliarity!=null && c.PairFamiliarity.Count<=100000,"Missing or oversized PairFamiliarity.");
        Require(c.PairFamiliarity.Keys.All(key=>key!=null && key.Length<=64),"Invalid familiarity key.");
        ValidateFixtures(c.League,MaxFixtures,false,"League");Require(c.League.Count>0 && c.League.Any(f=>f.Home==0 || f.Away==0),"The league has no club fixtures.");
        ValidateFixtures(c.Cup,MaxCupGames,true,"Cup");
        List(c.Tournaments,MaxTournaments,"Tournaments");
        var cupIds=new HashSet<string>(StringComparer.Ordinal);
        foreach(var cup in c.Tournaments)
        {
            Require(!string.IsNullOrWhiteSpace(cup.Id) && cup.Id.Length<=256 && cup.Name!=null && cup.Mark!=null,"Invalid cup identity.");
            Require(cupIds.Add(cup.Id),"Duplicate cup identity.");
            ValidateFixtures(cup.Games,MaxCupGames,true,"Cup "+cup.Id);
            Require(cup.Games.Count>0,"A cup has no fixtures.");
            if(migrated && !cup.Finished)
                Require(cup.Eliminated
                    ?cup.Games.Count(f=>f.Round==1)==4 && cup.Games.Any(f=>f.Played && (f.Home==0 || f.Away==0) && f.Winner!=0)
                    :cup.NextNorth!=null,"An unfinished cup has neither a playable club fixture nor a world bracket to complete.");
            ArraySize(cup.ClubIds,256,"Cup club identities");
            if(migrated)ValidateClubs(cup.ClubIds,"Cup "+cup.Id);
        }
        ArraySize(c.LeagueClubIds,256,"LeagueClubIds");ArraySize(c.ClubDivisions,4096,"ClubDivisions");
        if(migrated)
        {
            ValidateClubs(c.LeagueClubIds,"LeagueClubIds");
            Require(c.ClubDivisions.Length==ClubCatalog.All.Length && c.ClubDivisions.All(d=>d is >=1 and <=5),"Invalid club divisions.");
            Require(c.PackInventory.Length==ScoutPacks.All.Length,"Pack inventory migration failed.");
        }
        else if(c.ClubDivisions.Length==ClubCatalog.All.Length)
            Require(c.ClubDivisions.All(d=>d is >=1 and <=5),"Invalid club divisions.");
        List(c.WorldSquads,4096,"WorldSquads");
        var clubs=new HashSet<int>();
        foreach(var squad in c.WorldSquads)
        {
            Require(squad.ClubId>=0 && squad.ClubId<ClubCatalog.All.Length && clubs.Add(squad.ClubId),"Invalid or repeated world club.");
            FormationIndex(squad.FormationIndex,"world squad");Strings(squad.Players,64,"world squad players");
        }
        List(c.Checkpoints,128,"Checkpoints");
        Require(c.Checkpoints.Select(m=>m.Round).Distinct().Count()==c.Checkpoints.Count && c.Checkpoints.All(m=>m.Round>0 && m.Label!=null),"Invalid checkpoints.");
        List(c.CupHistory,MaxHistory,"CupHistory");List(c.LeagueHistory,MaxHistory,"LeagueHistory");
        foreach(var result in c.CupHistory)
        {
            Require(result.ChampionClubId>=-1 && result.ChampionClubId<ClubCatalog.All.Length && result.ChampionName!=null,
                "Invalid historical cup champion.");
            List(result.Matches,MaxCupGames,"Historical cup matches");
            Require(result.Matches.Select(f=>f.FixtureId).Distinct().Count()==result.Matches.Count,"Duplicate historical cup fixture.");
            foreach(var fixture in result.Matches)
                Require(fixture.Round is >=1 and <=3 && fixture.HomeClubId>=0 && fixture.HomeClubId<ClubCatalog.All.Length &&
                    fixture.AwayClubId>=0 && fixture.AwayClubId<ClubCatalog.All.Length && fixture.HomeClubId!=fixture.AwayClubId &&
                    (fixture.WinnerClubId==-1 || fixture.WinnerClubId==fixture.HomeClubId || fixture.WinnerClubId==fixture.AwayClubId) &&
                    fixture.HomeName!=null && fixture.AwayName!=null,"Invalid historical cup fixture.");
        }
        List(c.Achievements,4096,"Achievements");List(c.SeasonChallenges,MaxHistory,"SeasonChallenges");
        List(c.MatchHistory,MaxHistory,"MatchHistory");
        foreach(var report in c.MatchHistory)
        {
            Require(report.Season>0 && report.Division is >=1 and <=5 && report.OwnSide is >=0 and <=1,"Invalid match report.");
            List(report.HomePlayers,64,"report home players");List(report.AwayPlayers,64,"report away players");
            ValidateStatistics(report.Statistics);
        }
        foreach(var achievement in c.Achievements)Require(achievement.TrainingCount is >=0 and <=MaxInventory,"Oversized achievement reward.");
        foreach(var challenge in c.SeasonChallenges)Require(challenge.TrainingCount is >=0 and <=MaxInventory,"Oversized season reward.");
        Require(c.ActiveCupId!=null && c.LastResult!=null,"Missing career text.");
        ValidatePending(c,migrated);
        if(migrated)
        {
            Require(c.ActiveCupId=="" || c.ActiveCup!=null,"Invalid active cup.");
            Require(!c.HasRelegationChoice || c.Pending==null,"Relegation and an ordinary pending match overlap.");
            // Exercise getters used by the first club screens without simulating matches.
            _=c.Standings();_=c.PositionRating;_=c.CupResults;_=c.ClaimableCareerRewards;_=c.ClaimableMissions;
        }
    }

    static void ValidateCard(PlayerCard card,bool migrated)
    {
        Identity(card.Id,"player card");
        Require(card.Name!=null && card.Country!=null && card.CatalogId!=null && card.Position!=null && card.PreferredPosition!=null,
            "A player card has null identity text.");
        Require(card.IsAcademyLoan==card.CatalogId.StartsWith("ACADEMY-",StringComparison.Ordinal),
            "Academy player flag and identity do not match.");
        if(card.IsAcademyLoan)
        {
            Require(card.AcademyPortraitId!=null && card.AcademyPortraitId.Length<=256,"Invalid academy portrait identity.");
            Require(card.Edition!=CardRatings.Legendary,"An academy player cannot be legendary.");
        }
        Require(!string.IsNullOrWhiteSpace(card.Name) || !string.IsNullOrWhiteSpace(card.CatalogId),"A player card has no identity.");
        Require(new[]{card.Name,card.Country,card.CatalogId,card.Position,card.PreferredPosition}.All(text=>text.Length<=256),
            "A player card has oversized identity text.");
        Require(card.Position.Length>0,"A player card has no position.");
        foreach(int stat in new[]{card.Pace,card.Shooting,card.Passing,card.Dribbling,card.Defence,card.Physical,
            card.Endurance,card.Keeping,card.Aggression,card.Composure})
            Require(stat is >=0 and <=1000,"Invalid player attribute.");
        Require(card.HeightCm is >=0 and <=1000 && card.Age is >=0 and <=1000 && card.WeakFoot is >=0 and <=5 && card.SkillMoves is >=0 and <=5,
            "Invalid player appearance or ability data.");
        ArraySize(card.StatCaps,64,"StatCaps");ArraySize(card.TrainingTargets,64,"TrainingTargets");
        List(card.SkillLoadout,32,"SkillLoadout");
        if(migrated)
        {
            Require(!string.IsNullOrWhiteSpace(card.CatalogId) && PlayerPositions.Standard.Contains(PlayerPositions.Card(card)),"Player identity migration failed.");
            Require(card.Details!=null,"Player detail migration failed.");
            Require(card.SkillLoadout.Count<=UniqueSkills.MaxSlots(card) && card.SkillLoadout.All(s=>UniqueSkills.Get(s.Skill)!=null && s.Grade is >=1 and <=2),
                "Player skill migration failed.");
            if(card.IsAcademyLoan)
                Require(card.Overall<=45 && card.SkillLoadout.Count==0 && card.UniqueSkill==UniqueSkill.None,
                    "Academy player profile must remain weak and have no skills.");
        }
    }

    static void ValidateClubs(int[] ids,string name)=>Require(ids is {Length:8} && ids[0]==0 &&
        ids.Distinct().Count()==8 && ids.All(id=>id>=0 && id<ClubCatalog.All.Length),"Invalid club identities: "+name);

    static void ValidateFixtures(List<ClubFixture> fixtures,int maximum,bool cup,string name)
    {
        List(fixtures,maximum,name);var ids=new HashSet<int>();
        foreach(var fixture in fixtures)
        {
            Identity(fixture.Id,name+" fixture");Require(ids.Add(fixture.Id),"Duplicate fixture identity: "+name);
            Require(fixture.Home is >=0 and <8 && fixture.Away is >=0 and <8 && fixture.Home!=fixture.Away,"Invalid fixture teams: "+name);
            Require(fixture.Round>=1 && fixture.Round<=(cup?3:512),"Invalid fixture round: "+name);
            Require(fixture.Winner==-1 || fixture.Winner==fixture.Home || fixture.Winner==fixture.Away,"Invalid fixture winner: "+name);
            List(fixture.PenaltyKicks,1024,"PenaltyKicks");
            foreach(var kick in fixture.PenaltyKicks)
                Require(kick.Team is >=0 and <=1 && kick.Player is >=0 and <22 && Enum.IsDefined(kick.Outcome) &&
                    float.IsFinite(kick.TargetY) && float.IsFinite(kick.Height),"Invalid penalty kick data.");
            ValidateStatistics(fixture.Statistics);
        }
    }

    static void ValidateStatistics(ClubMatchStatistics statistics)
    {
        if(statistics==null)return; // Old match reports legitimately have no recorded statistics.
        foreach(var array in new[]{statistics.Shots,statistics.OnTarget,statistics.Passes,statistics.Completed,statistics.Losses,
            statistics.Tackles,statistics.Fouls,statistics.Yellow,statistics.Red,statistics.Corners,statistics.Combinations,
            statistics.WallPasses,statistics.Saves,statistics.ShotsWide,statistics.ShotsOver,statistics.Offsides,
            statistics.ThirdManPasses,statistics.DribbleAttempts,statistics.DribblesWon,statistics.CornerDeliveries})
            Require(array==null || array.Length==2,"Invalid match statistics pair.");
        Require(statistics.Possession==null || statistics.Possession.Length==2 && statistics.Possession.All(float.IsFinite),"Invalid possession statistics.");
    }

    static void ValidatePending(ClubCareer c,bool migrated)
    {
        if(c.Pending==null)return;
        Identity(c.Pending.FixtureId,"pending fixture");
        Require(c.Pending.CupId!=null,"Null pending cup identity.");
        ClubFixture fixture;
        if(!c.Pending.Cup)
        {
            Require(c.Pending.CupId=="","A league ticket names a cup.");
            fixture=c.League.FirstOrDefault(f=>f.Id==c.Pending.FixtureId);
        }
        else if(!migrated && c.Tournaments.Count==0 && c.Pending.CupId is "" or "open")
            fixture=c.Cup.FirstOrDefault(f=>f.Id==c.Pending.FixtureId);
        else
        {
            var tournament=c.Tournaments.FirstOrDefault(t=>t.Id==c.Pending.CupId);
            Require(tournament!=null && !tournament.Finished && !tournament.Eliminated,"Pending match references an unavailable cup.");
            fixture=tournament.Games.FirstOrDefault(f=>f.Id==c.Pending.FixtureId);
        }
        Require(fixture!=null && !fixture.Played && (fixture.Home==0 || fixture.Away==0),"Pending match references a missing, completed or foreign fixture.");
    }
}