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