CareerSaveStore.cs

Save store for a career mode. Loads the main save and falls back to a verified backup, blocks saving when files are unreadable or from a newer version, and writes the main file only after a recovery copy is verified. File IO goes through the injected ICareerSaveFiles interface.

File Access
using System;
using System.Text.Json;

namespace Socer;

// Writes may fail partway through. The store verifies the recovery copy before
// writing the main file, so the previous valid career survives a torn write.
public interface ICareerSaveFiles
{
    bool Exists(string path);
    string Read(string path);
    void Write(string path,string text);
}

public sealed record CareerSaveLoadResult(ClubCareer Career,bool HadFiles,bool Recovered,bool Blocked,string Error);
public sealed record CareerSaveWriteResult(bool Success,string Error);

public sealed class CareerSaveStore
{
    readonly ICareerSaveFiles files;
    bool blocked;
    string blockedError="";

    public string MainPath {get;}
    public string BackupPath {get;}
    public string CorruptPath {get;}

    public CareerSaveStore(ICareerSaveFiles files,string mainPath="club-career-v1.json")
    {
        this.files=files??throw new ArgumentNullException(nameof(files));
        if(string.IsNullOrWhiteSpace(mainPath))throw new ArgumentException("A save path is required.",nameof(mainPath));
        MainPath=mainPath;
        BackupPath=mainPath+".backup";
        CorruptPath=mainPath+".corrupt";
    }

    // Loading never changes, restores or removes a file.
    public CareerSaveLoadResult Load()
    {
        var main=ReadCandidate(MainPath);
        if(main.FutureVersion)
        {
            blocked=true;blockedError=main.Error;
            return new(null,true,false,true,blockedError);
        }
        if(main.Valid)
        {
            blocked=false;blockedError="";
            return new(main.Career,true,false,false,"");
        }

        var backup=ReadCandidate(BackupPath);
        if(backup.Valid)
        {
            blocked=false;blockedError="";
            return new(backup.Career,true,true,false,main.Missing?"The main save is missing; the backup was loaded.":main.Error);
        }

        if(main.Missing && backup.Missing)
        {
            blocked=false;blockedError="";
            return new(null,false,false,false,"");
        }

        blocked=true;
        blockedError=JoinErrors(main,backup);
        // Unknown existence/read failures must never appear as a fresh install.
        return new(null,true,false,true,blockedError);
    }

    public CareerSaveWriteResult Save(ClubCareer career)
    {
        if(blocked)
        {
            var previousError=blockedError;
            var retry=Load();
            // A repaired valid copy permits Retry. Merely deleting both files
            // does not turn an already blocked session into a fresh install.
            if(retry.Career==null || retry.Blocked)
            {
                blocked=true;
                blockedError=string.IsNullOrEmpty(retry.Error)?previousError:retry.Error;
                return new(false,"Saving is blocked to preserve the existing files. "+blockedError);
            }
        }
        if(!PrepareWrite(career,out var text,out var error))return new(false,error);

        var main=ReadCandidate(MainPath);
        if(main.StorageFailure)return new(false,main.Error);
        if(main.FutureVersion)return new(false,main.Error);
        // A newer backup also belongs to the newer game and must be retained,
        // even when the current main file is readable by this version.
        var backup=ReadCandidate(BackupPath,!main.Valid);
        if(backup.StorageFailure || backup.FutureVersion)return new(false,backup.Error);

        if(main.Valid)
        {
            // Preserve the original text, including unknown fields and whitespace.
            // A normalized/reserialized old career would not be a faithful backup.
            if(!WriteVerified(BackupPath,main.Text,out error))return new(false,error);
        }
        else
        {
            if(backup.Valid)
            {
                // Keep the validated backup until a new main file is committed.
                if(!main.Missing && !WriteVerified(CorruptPath,main.Text,out error))return new(false,error);
            }
            else if(main.Missing && backup.Missing)
            {
                // Even the first commit has a validated recovery copy.
                if(!WriteVerified(BackupPath,text,out error))return new(false,error);
            }
            else
            {
                blocked=true;
                blockedError=JoinErrors(main,backup);
                return new(false,"Saving is blocked to preserve the existing files. "+blockedError);
            }
        }

        if(!WriteVerified(MainPath,text,out error))return new(false,error);
        return new(true,"");
    }

    static bool PrepareWrite(ClubCareer career,out string text,out string error)
    {
        text=null;error="";
        if(career==null){error="A career is required for saving.";return false;}
        try
        {
            var candidate=JsonSerializer.Serialize(career);
            if(!CareerSaveValidation.TryPrepare(candidate,out var prepared,out error))return false;
            // The private prepared object is already validated after migration.
            // Serialize it once; exact verification below preserves this text.
            text=JsonSerializer.Serialize(prepared);
            return true;
        }
        catch(Exception e)
        {
            text=null;error="Could not prepare the career for saving: "+e.Message;
            return false;
        }
    }

    Candidate ReadCandidate(string path,bool prepare=true)
    {
        var result=new Candidate();
        try
        {
            result.Exists=files.Exists(path);
            result.PresenceKnown=true;
            if(!result.Exists){result.Error=path+": file is missing.";return result;}
            result.Text=files.Read(path);
            if(result.Text==null)
            {
                result.StorageFailure=true;
                result.Error=path+": reading returned no file contents.";
                return result;
            }
        }
        catch(Exception e)
        {
            result.StorageFailure=true;
            result.Error=path+": could not read the save: "+e.Message;
            return result;
        }

        try
        {
            if(CareerSaveValidation.IsFutureVersion(result.Text))
            {
                result.FutureVersion=true;
                result.Error=path+": the save belongs to a newer version of Socer and cannot be overwritten.";
                return result;
            }
            // When a validated main is about to replace the backup, only the
            // backup's readability and future-version guard are needed.
            if(!prepare)return result;
            result.Valid=CareerSaveValidation.TryPrepare(result.Text,out var career,out var error);
            result.Career=result.Valid?career:null;
            result.Error=result.Valid?"":path+": "+error;
        }
        catch(Exception e)
        {
            result.Error=path+": could not validate the save: "+e.Message;
        }
        return result;
    }

    bool WriteVerified(string path,string text,out string error)
    {
        error="";
        try
        {
            files.Write(path,text);
            if(!files.Exists(path))
            {
                error=path+": the written file is missing.";
                return false;
            }
            var written=files.Read(path);
            if(!string.Equals(written,text,StringComparison.Ordinal))
            {
                error=path+": the written contents did not match the expected save.";
                return false;
            }
            // The intended main/backup text was prepared before writing. Exact
            // equality proves it is still that validated text without migrating
            // another career or allocating another complete object graph.
            return true;
        }
        catch(Exception e)
        {
            error=path+": could not write and verify the save: "+e.Message;
            return false;
        }
    }

    static string JoinErrors(Candidate main,Candidate backup)=>main.Error+" "+backup.Error;

    sealed class Candidate
    {
        public bool Exists;
        public bool PresenceKnown;
        public bool StorageFailure;
        public bool FutureVersion;
        public bool Valid;
        public string Text;
        public ClubCareer Career;
        public string Error="";
        public bool Missing=>PresenceKnown && !Exists && !StorageFailure;
    }
}