From 5da545e000ed97da4a74ef3466f07524dfb39b2c Mon Sep 17 00:00:00 2001 From: nathan Date: Sun, 17 May 2026 17:08:05 -0700 Subject: [PATCH] Add resource node persistence --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 5 +- .../Maps/L_GroundZeroTerrain_Test.umap | 4 +- Docs/PersistenceDesignDocument.md | 9 +- Docs/TechnicalDesignDocument.md | 9 ++ Docs/Terrain/GroundZeroResourcePass.md | 9 ++ Scripts/setup_ground_zero_demo_map.py | 4 + Scripts/verify_ground_zero_resources.py | 4 + Scripts/verify_resource_node_persistence.py | 74 ++++++++++++++++ .../AgrarianPersistenceSubsystem.cpp | 88 +++++++++++++++++++ .../AgrarianPersistenceSubsystem.h | 8 ++ Source/AgrarianGame/AgrarianResourceNode.cpp | 35 ++++++++ Source/AgrarianGame/AgrarianResourceNode.h | 13 +++ Source/AgrarianGame/AgrarianSaveGame.h | 18 ++++ 13 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 Scripts/verify_resource_node_persistence.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 861574c..da39497 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -548,7 +548,10 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe current MVP nodes preserve bare-hand gathering, while a basic tool in inventory improves yields for wood, fiber, and stone. - [x] Add bare-hand gathering fallback. -- [ ] Add resource node persistence. +- [x] Add resource node persistence. Added save records for existing map/tile + resource nodes keyed by stable node IDs, persistence capture/restore paths, + and Ground Zero placement support so only loaded tiles with actual resource + nodes contribute depletion state. - [x] Add replicated gathering feedback. ## 0.1.G Primitive Crafting diff --git a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap index 617a7b0..fa5fcdf 100644 --- a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap +++ b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f207ef59e6a315690ba89c0aec9be6ab6e08c5902e971e9f2e144a2d0fd05cf6 -size 7491637 +oid sha256:5f5b0d02e44146ed1739f62a02b77e3a64b1e49e65b48112712f6cf49475fb48 +size 7492755 diff --git a/Docs/PersistenceDesignDocument.md b/Docs/PersistenceDesignDocument.md index d5158ba..418a09e 100644 --- a/Docs/PersistenceDesignDocument.md +++ b/Docs/PersistenceDesignDocument.md @@ -441,12 +441,19 @@ This document defines: Implementation work remains tracked separately in the roadmap. +The MVP resource-node implementation stores depletion as existing map/tile +actor state. Each `AAgrarianResourceNode` may define a stable +`PersistenceNodeId`; otherwise the actor name is used as a fallback. Save files +capture `RemainingHarvests` for resource nodes present in the loaded world and +restore only matching existing nodes. This deliberately avoids spawning resource +nodes from saves, keeping tile-authored resources owned by tile content while +letting persistence remember depletion for active tiles. + ## Open Questions - Should the first playable MVP use `USaveGame`, JSON, or a hybrid save backend? - What is the first stable `SaveFormatVersion` value? -- Which resource nodes should persist depletion in Ground Zero? - Should disconnected players remain in world physically or be removed? - How much inventory should death/respawn preserve? - When do saves move from file-based records to database-backed records? diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index b92f775..2f610aa 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -458,6 +458,15 @@ plants remain pure bare-hand gathering. Later nodes can disable `bAllowBareHandGathering` when the game has proper tool equipment, durability, and feedback UI. +Resource node persistence is map/tile state, not a spawned-actor replay path. +`UAgrarianSaveGame` stores `FAgrarianSavedResourceNode` records keyed by +`AAgrarianResourceNode::PersistenceNodeId`, with the actor name as a fallback. +`UAgrarianPersistenceSubsystem` captures only resource nodes that exist in the +currently loaded world and restores matching existing nodes by stable id. This +keeps the MVP compatible with later Earth-scale tiles: a tile contributes +resource depletion state only when its resource actors actually exist, and tile +generation/placement scripts should assign deterministic node ids. + ### Wildlife Navigation MVP wildlife movement is server authoritative. `AAgrarianWildlifeBase` uses an diff --git a/Docs/Terrain/GroundZeroResourcePass.md b/Docs/Terrain/GroundZeroResourcePass.md index 2d43a89..a6f4672 100644 --- a/Docs/Terrain/GroundZeroResourcePass.md +++ b/Docs/Terrain/GroundZeroResourcePass.md @@ -69,6 +69,15 @@ immediate benefit: The rule is inventory-based until equipment slots, durability, and explicit active-hand state are implemented. +## Persistence + +Ground Zero resource actors receive stable `PersistenceNodeId` values matching +their placement labels. The persistence subsystem captures and restores only +resource nodes that exist in the loaded world, so depletion records stay scoped +to active map/tile content. Future generated Earth-scale tiles should follow the +same rule: assign deterministic resource node IDs during placement and let the +save system record depletion only for tiles whose resource actors are present. + ## Follow-Up Future passes should replace the prototype meshes with real coastal scrub, diff --git a/Scripts/setup_ground_zero_demo_map.py b/Scripts/setup_ground_zero_demo_map.py index a3381e0..5363fdb 100644 --- a/Scripts/setup_ground_zero_demo_map.py +++ b/Scripts/setup_ground_zero_demo_map.py @@ -864,6 +864,10 @@ def spawn_demo_actor(spec, height_values, materials, safe_spawn_location_xy=None raise RuntimeError(f"Could not spawn {spec['label']}") set_actor_label(actor, spec["label"]) + try: + actor.set_editor_property("persistence_node_id", spec["label"]) + except Exception: + pass material_key = material_key_for_actor_label(spec["label"]) if material_key: apply_material_to_actor_meshes(actor, materials[material_key]) diff --git a/Scripts/verify_ground_zero_resources.py b/Scripts/verify_ground_zero_resources.py index ae34815..01fbea0 100644 --- a/Scripts/verify_ground_zero_resources.py +++ b/Scripts/verify_ground_zero_resources.py @@ -65,6 +65,10 @@ def main(): if actor.get_editor_property("remaining_harvests") <= 0: failures.append(f"{label} has no remaining harvests") + persistence_node_id = str(actor.get_editor_property("persistence_node_id")) + if persistence_node_id != label: + failures.append(f"{label} persistence node id expected {label}, got {persistence_node_id}") + if failures: raise RuntimeError("Ground Zero resource verification failed: " + "; ".join(failures)) diff --git a/Scripts/verify_resource_node_persistence.py b/Scripts/verify_resource_node_persistence.py new file mode 100644 index 0000000..e88581f --- /dev/null +++ b/Scripts/verify_resource_node_persistence.py @@ -0,0 +1,74 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +EXPECTED = { + ROOT / "Source" / "AgrarianGame" / "AgrarianSaveGame.h": [ + "struct FAgrarianSavedResourceNode", + "FName ResourceNodeId = NAME_None;", + "int32 RemainingHarvests = 0;", + "TArray ResourceNodes;", + ], + ROOT / "Source" / "AgrarianGame" / "AgrarianResourceNode.h": [ + "FName PersistenceNodeId = NAME_None;", + "FName GetResourcePersistenceId() const;", + "FAgrarianSavedResourceNode CaptureResourceSaveState() const;", + "void ApplyResourceSaveState(const FAgrarianSavedResourceNode& SavedNode);", + ], + ROOT / "Source" / "AgrarianGame" / "AgrarianResourceNode.cpp": [ + "AAgrarianResourceNode::GetResourcePersistenceId", + "PersistenceNodeId != NAME_None ? PersistenceNodeId : GetFName()", + "SavedNode.RemainingHarvests = RemainingHarvests;", + "RemainingHarvests = FMath::Clamp", + "ScheduleRespawnIfNeeded();", + ], + ROOT / "Source" / "AgrarianGame" / "AgrarianPersistenceSubsystem.h": [ + "int32 CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const;", + "int32 RestoreResourceNodes(const UAgrarianSaveGame* SaveGame) const;", + "void FindResourceNodes(TArray& OutResourceNodes) const;", + ], + ROOT / "Source" / "AgrarianGame" / "AgrarianPersistenceSubsystem.cpp": [ + "SaveGame->ResourceNodes.Reset();", + "SaveGame->ResourceNodes.Add(SavedNode);", + "RestoreResourceNodes(SaveGame);", + "TActorIterator", + "ResourceNode->ApplyResourceSaveState(*SavedNode);", + ], + ROOT / "Scripts" / "setup_ground_zero_demo_map.py": [ + 'actor.set_editor_property("persistence_node_id", spec["label"])', + ], + ROOT / "Scripts" / "verify_ground_zero_resources.py": [ + 'actor.get_editor_property("persistence_node_id")', + "persistence node id expected", + ], + ROOT / "Docs" / "TechnicalDesignDocument.md": [ + "Resource node persistence is map/tile state", + "captures only resource nodes that exist in the", + ], + ROOT / "Docs" / "Terrain" / "GroundZeroResourcePass.md": [ + "Ground Zero resource actors receive stable `PersistenceNodeId` values", + "resource nodes that exist in the loaded world", + ], + ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md": [ + "[x] Add resource node persistence.", + ], +} + + +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("Resource node persistence verification failed: " + "; ".join(missing)) + + print("PASS: resource node persistence captures loaded map/tile nodes by stable id and restores depletion state.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp b/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp index d076d0a..7ad693f 100644 --- a/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp +++ b/Source/AgrarianGame/AgrarianPersistenceSubsystem.cpp @@ -5,6 +5,7 @@ #include "AgrarianGameState.h" #include "AgrarianInventoryComponent.h" #include "AgrarianPersistentActorComponent.h" +#include "AgrarianResourceNode.h" #include "AgrarianSaveGame.h" #include "AgrarianSurvivalComponent.h" #include "EngineUtils.h" @@ -240,6 +241,71 @@ int32 UAgrarianPersistenceSubsystem::RestorePlayers(const UAgrarianSaveGame* Sav return RestoredCount; } +int32 UAgrarianPersistenceSubsystem::CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const +{ + if (!SaveGame) + { + return 0; + } + + TArray ResourceNodes; + FindResourceNodes(ResourceNodes); + + SaveGame->ResourceNodes.Reset(); + for (const AAgrarianResourceNode* ResourceNode : ResourceNodes) + { + if (!ResourceNode) + { + continue; + } + + const FAgrarianSavedResourceNode SavedNode = ResourceNode->CaptureResourceSaveState(); + if (SavedNode.ResourceNodeId != NAME_None) + { + SaveGame->ResourceNodes.Add(SavedNode); + } + } + + return SaveGame->ResourceNodes.Num(); +} + +int32 UAgrarianPersistenceSubsystem::RestoreResourceNodes(const UAgrarianSaveGame* SaveGame) const +{ + if (!SaveGame) + { + return 0; + } + + TArray ResourceNodes; + FindResourceNodes(ResourceNodes); + + int32 RestoredCount = 0; + for (AAgrarianResourceNode* ResourceNode : ResourceNodes) + { + if (!ResourceNode) + { + continue; + } + + const FName ResourceNodeId = ResourceNode->GetResourcePersistenceId(); + const FAgrarianSavedResourceNode* SavedNode = SaveGame->ResourceNodes.FindByPredicate( + [ResourceNodeId](const FAgrarianSavedResourceNode& Candidate) + { + return Candidate.ResourceNodeId == ResourceNodeId; + }); + + if (!SavedNode) + { + continue; + } + + ResourceNode->ApplyResourceSaveState(*SavedNode); + RestoredCount++; + } + + return RestoredCount; +} + bool UAgrarianPersistenceSubsystem::SaveCurrentWorld() const { UAgrarianSaveGame* SaveGame = LoadOrCreateSave(); @@ -251,6 +317,7 @@ bool UAgrarianPersistenceSubsystem::SaveCurrentWorld() const CaptureWorldState(SaveGame); CapturePlayers(SaveGame); CaptureWorldActors(SaveGame); + CaptureResourceNodes(SaveGame); return WriteSave(SaveGame); } @@ -268,6 +335,7 @@ bool UAgrarianPersistenceSubsystem::LoadCurrentWorld(int32& RestoredPlayerCount, const bool bRestoredWorldState = RestoreWorldState(SaveGame); RestoredPlayerCount = RestorePlayers(SaveGame); RestoredWorldActorCount = RestoreWorldActors(SaveGame, bClearExistingActors); + RestoreResourceNodes(SaveGame); return bRestoredWorldState; } @@ -316,6 +384,26 @@ void UAgrarianPersistenceSubsystem::FindAgrarianPlayers(TArray& OutResourceNodes) const +{ + OutResourceNodes.Reset(); + + UWorld* World = GetWorld(); + if (!World) + { + return; + } + + for (TActorIterator ActorIt(World); ActorIt; ++ActorIt) + { + AAgrarianResourceNode* ResourceNode = *ActorIt; + if (ResourceNode && !ResourceNode->IsPendingKillPending()) + { + OutResourceNodes.Add(ResourceNode); + } + } +} + FString UAgrarianPersistenceSubsystem::GetPlayerPersistenceId(const AAgrarianGameCharacter* Character) const { if (!Character) diff --git a/Source/AgrarianGame/AgrarianPersistenceSubsystem.h b/Source/AgrarianGame/AgrarianPersistenceSubsystem.h index 0ba9008..db98c8e 100644 --- a/Source/AgrarianGame/AgrarianPersistenceSubsystem.h +++ b/Source/AgrarianGame/AgrarianPersistenceSubsystem.h @@ -9,6 +9,7 @@ class UAgrarianSaveGame; class UAgrarianPersistentActorComponent; class AAgrarianGameCharacter; +class AAgrarianResourceNode; UCLASS() class UAgrarianPersistenceSubsystem : public UGameInstanceSubsystem @@ -58,6 +59,12 @@ public: UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence") int32 RestorePlayers(const UAgrarianSaveGame* SaveGame) const; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence") + int32 CaptureResourceNodes(UAgrarianSaveGame* SaveGame) const; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence") + int32 RestoreResourceNodes(const UAgrarianSaveGame* SaveGame) const; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Persistence") bool SaveCurrentWorld() const; @@ -67,5 +74,6 @@ public: protected: void FindPersistentComponents(TArray& OutComponents) const; void FindAgrarianPlayers(TArray& OutPlayers) const; + void FindResourceNodes(TArray& OutResourceNodes) const; FString GetPlayerPersistenceId(const AAgrarianGameCharacter* Character) const; }; diff --git a/Source/AgrarianGame/AgrarianResourceNode.cpp b/Source/AgrarianGame/AgrarianResourceNode.cpp index a196c4e..8cb96c9 100644 --- a/Source/AgrarianGame/AgrarianResourceNode.cpp +++ b/Source/AgrarianGame/AgrarianResourceNode.cpp @@ -4,6 +4,7 @@ #include "AgrarianGameCharacter.h" #include "AgrarianInventoryComponent.h" #include "AgrarianItemDefinitionAsset.h" +#include "AgrarianSaveGame.h" #include "Components/StaticMeshComponent.h" #include "TimerManager.h" #include "Net/UnrealNetwork.h" @@ -94,6 +95,40 @@ void AAgrarianResourceNode::OnRep_RemainingHarvests() UpdateDepletedState(); } +FName AAgrarianResourceNode::GetResourcePersistenceId() const +{ + return PersistenceNodeId != NAME_None ? PersistenceNodeId : GetFName(); +} + +FAgrarianSavedResourceNode AAgrarianResourceNode::CaptureResourceSaveState() const +{ + FAgrarianSavedResourceNode SavedNode; + SavedNode.ResourceNodeId = GetResourcePersistenceId(); + SavedNode.RemainingHarvests = RemainingHarvests; + SavedNode.bRespawnsForMvp = bRespawnsForMvp; + return SavedNode; +} + +void AAgrarianResourceNode::ApplyResourceSaveState(const FAgrarianSavedResourceNode& SavedNode) +{ + if (!HasAuthority() || SavedNode.ResourceNodeId == NAME_None || SavedNode.ResourceNodeId != GetResourcePersistenceId()) + { + return; + } + + RemainingHarvests = FMath::Clamp(SavedNode.RemainingHarvests, 0, FMath::Max(1, MaxHarvests)); + if (RemainingHarvests > 0) + { + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().ClearTimer(RespawnTimerHandle); + } + } + + UpdateDepletedState(); + ScheduleRespawnIfNeeded(); +} + bool AAgrarianResourceNode::HasRequiredTool(const AAgrarianGameCharacter* Interactor) const { if (RequiredToolItemId == NAME_None) diff --git a/Source/AgrarianGame/AgrarianResourceNode.h b/Source/AgrarianGame/AgrarianResourceNode.h index c9af2e2..7c9bea2 100644 --- a/Source/AgrarianGame/AgrarianResourceNode.h +++ b/Source/AgrarianGame/AgrarianResourceNode.h @@ -6,6 +6,7 @@ #include "GameFramework/Actor.h" #include "TimerManager.h" #include "AgrarianInteractable.h" +#include "AgrarianSaveGame.h" #include "AgrarianTypes.h" #include "AgrarianResourceNode.generated.h" @@ -32,6 +33,9 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Resource") TObjectPtr YieldItemDefinition; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Resource|Persistence") + FName PersistenceNodeId = NAME_None; + UPROPERTY(EditAnywhere, BlueprintReadWrite, ReplicatedUsing = OnRep_RemainingHarvests, Category = "Agrarian|Resource", meta = (ClampMin = "0")) int32 RemainingHarvests = 5; @@ -60,6 +64,15 @@ public: virtual bool CanInteract_Implementation(const AAgrarianGameCharacter* Interactor) const override; virtual void Interact_Implementation(AAgrarianGameCharacter* Interactor) override; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Resource|Persistence") + FName GetResourcePersistenceId() const; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Resource|Persistence") + FAgrarianSavedResourceNode CaptureResourceSaveState() const; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Resource|Persistence") + void ApplyResourceSaveState(const FAgrarianSavedResourceNode& SavedNode); + protected: UFUNCTION() void OnRep_RemainingHarvests(); diff --git a/Source/AgrarianGame/AgrarianSaveGame.h b/Source/AgrarianGame/AgrarianSaveGame.h index 755a481..a5b786c 100644 --- a/Source/AgrarianGame/AgrarianSaveGame.h +++ b/Source/AgrarianGame/AgrarianSaveGame.h @@ -46,6 +46,21 @@ struct FAgrarianSavedWorldActor TMap NumberState; }; +USTRUCT(BlueprintType) +struct FAgrarianSavedResourceNode +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Save") + FName ResourceNodeId = NAME_None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Save") + int32 RemainingHarvests = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Save") + bool bRespawnsForMvp = false; +}; + UCLASS() class UAgrarianSaveGame : public USaveGame { @@ -72,4 +87,7 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Save") TArray WorldActors; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Save") + TArray ResourceNodes; };