Back up save before overwrite

This commit is contained in:
2026-05-18 19:37:35 -07:00
parent 65599b9d2f
commit dfad8809c7
5 changed files with 89 additions and 1 deletions
+4 -1
View File
@@ -778,7 +778,10 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
`FAgrarianSavedTileRegistryState` with the active Ground Zero tile ID, `FAgrarianSavedTileRegistryState` with the active Ground Zero tile ID,
registry path, schema version, generation version, and package version while registry path, schema version, generation version, and package version while
keeping the authoritative registry in `Data/Tiles/ground_zero_tiles.json`. keeping the authoritative registry in `Data/Tiles/ground_zero_tiles.json`.
- [ ] Add backup-before-save option. - [x] Add backup-before-save option. `UAgrarianPersistenceSubsystem` now has
`bBackupBeforeSave` enabled by default; before overwriting an existing slot,
`WriteSave` copies the current `.sav` into `Saved/SaveGames/Backups` with a
UTC timestamp.
- [ ] Add recovery plan for corrupted save. - [ ] Add recovery plan for corrupted save.
- [ ] Document persistence limitations. - [ ] Document persistence limitations.
+6
View File
@@ -467,6 +467,12 @@ GameMode registers the current MVP persistent actor classes, checks
`DoesSaveExist`, and calls `LoadCurrentWorld` without clearing existing map `DoesSaveExist`, and calls `LoadCurrentWorld` without clearing existing map
actors so map-authored resources and startup content remain owned by the map. actors so map-authored resources and startup content remain owned by the map.
Backup-before-save is enabled by default through
`UAgrarianPersistenceSubsystem::bBackupBeforeSave`. Before overwriting an
existing save, `WriteSave` copies the current slot file from `Saved/SaveGames`
into `Saved/SaveGames/Backups` with a UTC timestamp. Missing save files simply
skip backup creation, which keeps the first save path simple.
## Testing Gates ## Testing Gates
Minimum persistence smoke test: Minimum persistence smoke test:
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXPECTED = {
ROOT / "Source" / "AgrarianGame" / "AgrarianPersistenceSubsystem.h": [
"bool bBackupBeforeSave = true;",
"bool BackupExistingSave() const;",
],
ROOT / "Source" / "AgrarianGame" / "AgrarianPersistenceSubsystem.cpp": [
"if (SaveGame && bBackupBeforeSave)",
"BackupExistingSave();",
"FPaths::ProjectSavedDir()",
"TEXT(\"SaveGames\")",
"TEXT(\"Backups\")",
"FDateTime::UtcNow().ToString",
"IFileManager::Get().Copy",
],
ROOT / "Docs" / "PersistenceDesignDocument.md": [
"`UAgrarianPersistenceSubsystem::bBackupBeforeSave`",
"`Saved/SaveGames/Backups`",
"UTC timestamp",
],
ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md": [
"[x] Add backup-before-save option.",
"`bBackupBeforeSave`",
"`Saved/SaveGames/Backups`",
],
}
def main() -> None:
missing = []
for path, snippets in EXPECTED.items():
text = path.read_text(encoding="utf-8")
for snippet in snippets:
if snippet not in text:
missing.append(f"{path.relative_to(ROOT)}: {snippet}")
if missing:
raise RuntimeError("Backup-before-save verification failed: " + "; ".join(missing))
print("PASS: backup-before-save is wired before slot overwrite.")
if __name__ == "__main__":
main()
@@ -10,6 +10,9 @@
#include "AgrarianSurvivalComponent.h" #include "AgrarianSurvivalComponent.h"
#include "EngineUtils.h" #include "EngineUtils.h"
#include "Engine/World.h" #include "Engine/World.h"
#include "HAL/FileManager.h"
#include "Misc/DateTime.h"
#include "Misc/Paths.h"
#include "GameFramework/PlayerState.h" #include "GameFramework/PlayerState.h"
#include "Kismet/GameplayStatics.h" #include "Kismet/GameplayStatics.h"
@@ -33,6 +36,11 @@ UAgrarianSaveGame* UAgrarianPersistenceSubsystem::LoadOrCreateSave() const
bool UAgrarianPersistenceSubsystem::WriteSave(UAgrarianSaveGame* SaveGame) const bool UAgrarianPersistenceSubsystem::WriteSave(UAgrarianSaveGame* SaveGame) const
{ {
if (SaveGame && bBackupBeforeSave)
{
BackupExistingSave();
}
return SaveGame ? UGameplayStatics::SaveGameToSlot(SaveGame, DefaultSlotName, UserIndex) : false; return SaveGame ? UGameplayStatics::SaveGameToSlot(SaveGame, DefaultSlotName, UserIndex) : false;
} }
@@ -316,6 +324,25 @@ bool UAgrarianPersistenceSubsystem::LoadCurrentWorld(int32& RestoredPlayerCount,
return bRestoredWorldState; return bRestoredWorldState;
} }
bool UAgrarianPersistenceSubsystem::BackupExistingSave() const
{
const FString SaveFilePath = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("SaveGames"), DefaultSlotName + TEXT(".sav"));
if (!FPaths::FileExists(SaveFilePath))
{
return false;
}
const FString BackupDirectory = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("SaveGames"), TEXT("Backups"));
IFileManager::Get().MakeDirectory(*BackupDirectory, true);
const FString Timestamp = FDateTime::UtcNow().ToString(TEXT("%Y%m%dT%H%M%SZ"));
const FString BackupFileName = FString::Printf(TEXT("%s-%s.sav"), *DefaultSlotName, *Timestamp);
const FString BackupFilePath = FPaths::Combine(BackupDirectory, BackupFileName);
const bool bCopied = IFileManager::Get().Copy(*BackupFilePath, *SaveFilePath, true, true) == COPY_OK;
UE_LOG(LogTemp, Log, TEXT("Agrarian save backup %s: %s"), bCopied ? TEXT("created") : TEXT("failed"), *BackupFilePath);
return bCopied;
}
bool UAgrarianPersistenceSubsystem::CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const bool UAgrarianPersistenceSubsystem::CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const
{ {
const UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr; const UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr;
@@ -23,6 +23,9 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence")
int32 UserIndex = 0; int32 UserIndex = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence")
bool bBackupBeforeSave = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence")
TMap<FName, TSubclassOf<AActor>> WorldActorClassRegistry; TMap<FName, TSubclassOf<AActor>> WorldActorClassRegistry;
@@ -78,6 +81,7 @@ public:
bool LoadCurrentWorld(int32& RestoredPlayerCount, int32& RestoredWorldActorCount, bool bClearExistingActors = true) const; bool LoadCurrentWorld(int32& RestoredPlayerCount, int32& RestoredWorldActorCount, bool bClearExistingActors = true) const;
protected: protected:
bool BackupExistingSave() const;
bool CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const; bool CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const;
bool RestorePlayerFromSave(AAgrarianGameCharacter* Character, const UAgrarianSaveGame* SaveGame) const; bool RestorePlayerFromSave(AAgrarianGameCharacter* Character, const UAgrarianSaveGame* SaveGame) const;
void FindPersistentComponents(TArray<UAgrarianPersistentActorComponent*>& OutComponents) const; void FindPersistentComponents(TArray<UAgrarianPersistentActorComponent*>& OutComponents) const;