diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index d09a4c2..4bab440 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -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, registry path, schema version, generation version, and package version while 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. - [ ] Document persistence limitations. diff --git a/Docs/PersistenceDesignDocument.md b/Docs/PersistenceDesignDocument.md index 84bc91a..33b371a 100644 --- a/Docs/PersistenceDesignDocument.md +++ b/Docs/PersistenceDesignDocument.md @@ -467,6 +467,12 @@ GameMode registers the current MVP persistent actor classes, checks `DoesSaveExist`, and calls `LoadCurrentWorld` without clearing existing 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 Minimum persistence smoke test: diff --git a/Scripts/verify_backup_before_save.py b/Scripts/verify_backup_before_save.py new file mode 100644 index 0000000..11c4355 --- /dev/null +++ b/Scripts/verify_backup_before_save.py @@ -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() diff --git a/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp b/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp index 7b2ca25..fa780c1 100644 --- a/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp +++ b/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp @@ -10,6 +10,9 @@ #include "AgrarianSurvivalComponent.h" #include "EngineUtils.h" #include "Engine/World.h" +#include "HAL/FileManager.h" +#include "Misc/DateTime.h" +#include "Misc/Paths.h" #include "GameFramework/PlayerState.h" #include "Kismet/GameplayStatics.h" @@ -33,6 +36,11 @@ UAgrarianSaveGame* UAgrarianPersistenceSubsystem::LoadOrCreateSave() const bool UAgrarianPersistenceSubsystem::WriteSave(UAgrarianSaveGame* SaveGame) const { + if (SaveGame && bBackupBeforeSave) + { + BackupExistingSave(); + } + return SaveGame ? UGameplayStatics::SaveGameToSlot(SaveGame, DefaultSlotName, UserIndex) : false; } @@ -316,6 +324,25 @@ bool UAgrarianPersistenceSubsystem::LoadCurrentWorld(int32& RestoredPlayerCount, 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 { const UAgrarianSurvivalComponent* SurvivalComponent = Character ? Character->GetSurvivalComponent() : nullptr; diff --git a/Source/AgrarianGame/AgrarianPersistenceSubsystem.h b/Source/AgrarianGame/AgrarianPersistenceSubsystem.h index f72ea79..19686d5 100644 --- a/Source/AgrarianGame/AgrarianPersistenceSubsystem.h +++ b/Source/AgrarianGame/AgrarianPersistenceSubsystem.h @@ -23,6 +23,9 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence") int32 UserIndex = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence") + bool bBackupBeforeSave = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Persistence") TMap> WorldActorClassRegistry; @@ -78,6 +81,7 @@ public: bool LoadCurrentWorld(int32& RestoredPlayerCount, int32& RestoredWorldActorCount, bool bClearExistingActors = true) const; protected: + bool BackupExistingSave() const; bool CapturePlayerIntoSave(const AAgrarianGameCharacter* Character, UAgrarianSaveGame* SaveGame) const; bool RestorePlayerFromSave(AAgrarianGameCharacter* Character, const UAgrarianSaveGame* SaveGame) const; void FindPersistentComponents(TArray& OutComponents) const;