This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
AgrarianGameArchive/Source/AgrarianGame/AgrarianEditorAutomationLibrary.cpp
2026-05-16 00:56:11 -07:00

899 lines
30 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "AgrarianEditorAutomationLibrary.h"
#include "AgrarianBuildingPlacementComponent.h"
#include "AgrarianCraftingComponent.h"
#include "AgrarianGameCharacter.h"
#include "AgrarianGameState.h"
#include "AgrarianInteractable.h"
#include "AgrarianInventoryComponent.h"
#include "AgrarianPersistentActorComponent.h"
#include "AgrarianPersistenceSubsystem.h"
#include "AgrarianRecipeDataAsset.h"
#include "AgrarianResourceNode.h"
#include "AgrarianSaveGame.h"
#include "AgrarianWildlifeBase.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "Kismet/GameplayStatics.h"
#include "Misc/FileHelper.h"
#if WITH_EDITOR
#include "Editor.h"
#include "Landscape.h"
#endif
namespace
{
int32 GetIngredientQuantity(const UAgrarianRecipeDataAsset* RecipeAsset, const FName ItemId)
{
if (!RecipeAsset)
{
return 0;
}
for (const FAgrarianItemStack& Ingredient : RecipeAsset->Recipe.Ingredients)
{
if (Ingredient.ItemId == ItemId)
{
return Ingredient.Quantity;
}
}
return 0;
}
void AddNeededIngredient(TMap<FName, int32>& NeededItems, const FName ItemId, const int32 Quantity)
{
if (ItemId == NAME_None || Quantity <= 0)
{
return;
}
NeededItems.FindOrAdd(ItemId) += Quantity;
}
}
AActor* UAgrarianEditorAutomationLibrary::SpawnActorInEditorWorld(TSubclassOf<AActor> ActorClass, const FVector& Location, const FRotator& Rotation, const FString& ActorLabel)
{
#if WITH_EDITOR
if (!ActorClass)
{
return nullptr;
}
UWorld* EditorWorld = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
if (!EditorWorld)
{
return nullptr;
}
FActorSpawnParameters SpawnParameters;
SpawnParameters.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnParameters.ObjectFlags = RF_Transactional;
AActor* SpawnedActor = EditorWorld->SpawnActor<AActor>(ActorClass, Location, Rotation, SpawnParameters);
if (!SpawnedActor)
{
return nullptr;
}
if (!ActorLabel.IsEmpty())
{
SpawnedActor->SetActorLabel(ActorLabel, true);
}
SpawnedActor->MarkPackageDirty();
EditorWorld->MarkPackageDirty();
return SpawnedActor;
#else
return nullptr;
#endif
}
FString UAgrarianEditorAutomationLibrary::RunPlayableLoopSmokeTest(TSubclassOf<AAgrarianGameCharacter> CharacterClass, AAgrarianResourceNode* ResourceNode, UAgrarianRecipeDataAsset* ShelterRecipe, TSubclassOf<AActor> ShelterClass)
{
#if WITH_EDITOR
UWorld* EditorWorld = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
if (!EditorWorld)
{
return TEXT("FAIL: no editor world");
}
if (!CharacterClass || !ResourceNode || !ShelterRecipe || !ShelterClass)
{
return FString::Printf(
TEXT("FAIL: missing input CharacterClass=%s ResourceNode=%s ShelterRecipe=%s ShelterClass=%s"),
*GetNameSafe(CharacterClass.Get()),
*GetNameSafe(ResourceNode),
*GetNameSafe(ShelterRecipe),
*GetNameSafe(ShelterClass.Get()));
}
FActorSpawnParameters CharacterSpawnParams;
CharacterSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AAgrarianGameCharacter* TestCharacter = EditorWorld->SpawnActor<AAgrarianGameCharacter>(
CharacterClass,
FVector(700.0f, -450.0f, 180.0f),
FRotator::ZeroRotator,
CharacterSpawnParams);
if (!TestCharacter)
{
return TEXT("FAIL: could not spawn test character");
}
TestCharacter->SetActorLabel(TEXT("AGR_AutomationLoopCharacter"), false);
UAgrarianInventoryComponent* Inventory = TestCharacter->GetInventoryComponent();
UAgrarianCraftingComponent* Crafting = TestCharacter->GetCraftingComponent();
UAgrarianBuildingPlacementComponent* Placement = TestCharacter->GetBuildingPlacementComponent();
if (!Inventory || !Crafting || !Placement)
{
TestCharacter->Destroy();
return TEXT("FAIL: spawned character is missing inventory, crafting, or placement component");
}
const int32 StartingWood = Inventory->GetItemCount(TEXT("wood"));
if (!ResourceNode->GetClass()->ImplementsInterface(UAgrarianInteractable::StaticClass()))
{
TestCharacter->Destroy();
return TEXT("FAIL: resource node does not implement AgrarianInteractable");
}
if (!IAgrarianInteractable::Execute_CanInteract(ResourceNode, TestCharacter))
{
TestCharacter->Destroy();
return TEXT("FAIL: resource node cannot be gathered");
}
IAgrarianInteractable::Execute_Interact(ResourceNode, TestCharacter);
const int32 WoodAfterGather = Inventory->GetItemCount(TEXT("wood"));
if (WoodAfterGather <= StartingWood)
{
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: gather did not add wood, before=%d after=%d"), StartingWood, WoodAfterGather);
}
// The current shelter recipe depends on prototype structure parts that do not yet have world acquisition.
// Seed only the missing recipe inputs so this smoke test can exercise the connected craft/place/save/load path.
for (const FAgrarianItemStack& Ingredient : ShelterRecipe->Recipe.Ingredients)
{
const int32 CurrentCount = Inventory->GetItemCount(Ingredient.ItemId);
if (CurrentCount < Ingredient.Quantity)
{
FAgrarianItemStack MissingStack = Ingredient;
MissingStack.Quantity = Ingredient.Quantity - CurrentCount;
if (!Inventory->AddItem(MissingStack))
{
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: could not seed missing ingredient %s"), *Ingredient.ItemId.ToString());
}
}
}
if (!Crafting->AddKnownRecipe(ShelterRecipe->Recipe))
{
TestCharacter->Destroy();
return TEXT("FAIL: could not add primitive shelter recipe to test character");
}
if (!Crafting->Craft(ShelterRecipe->Recipe.RecipeId))
{
TestCharacter->Destroy();
return TEXT("FAIL: primitive shelter craft failed");
}
if (Inventory->GetItemCount(TEXT("primitive_shelter")) < 1)
{
TestCharacter->Destroy();
return TEXT("FAIL: crafting did not add primitive_shelter to inventory");
}
TArray<FAgrarianItemStack> PlacementCost;
FAgrarianItemStack ShelterCost = ShelterRecipe->Recipe.Result;
ShelterCost.Quantity = 1;
PlacementCost.Add(ShelterCost);
Placement->SetActiveBuildable(ShelterClass, PlacementCost);
Placement->PlacementDistance = 5000.0f;
Placement->PlacementProbeRadius = 1.0f;
const int32 ShelterCountBeforePlace = Inventory->GetItemCount(TEXT("primitive_shelter"));
int32 ShelterActorsBeforePlace = 0;
for (TActorIterator<AActor> ActorIt(EditorWorld, ShelterClass); ActorIt; ++ActorIt)
{
ShelterActorsBeforePlace++;
}
const FTransform PlacementTransform(FRotator::ZeroRotator, FVector(1500.0f, 550.0f, 300.0f));
Placement->ServerPlaceBuildable_Implementation(ShelterClass, PlacementTransform);
const int32 ShelterCountAfterPlace = Inventory->GetItemCount(TEXT("primitive_shelter"));
int32 ShelterActorsAfterPlace = 0;
AActor* PlacedShelter = nullptr;
for (TActorIterator<AActor> ActorIt(EditorWorld, ShelterClass); ActorIt; ++ActorIt)
{
ShelterActorsAfterPlace++;
PlacedShelter = *ActorIt;
}
if (ShelterActorsAfterPlace <= ShelterActorsBeforePlace)
{
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: placement did not spawn shelter, before=%d after=%d"), ShelterActorsBeforePlace, ShelterActorsAfterPlace);
}
if (ShelterCountAfterPlace >= ShelterCountBeforePlace)
{
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: placement did not consume shelter item, before=%d after=%d"), ShelterCountBeforePlace, ShelterCountAfterPlace);
}
if (!PlacedShelter || !PlacedShelter->FindComponentByClass<UAgrarianPersistentActorComponent>())
{
TestCharacter->Destroy();
return TEXT("FAIL: placed shelter is missing persistent actor component");
}
TArray<FAgrarianSavedWorldActor> SavedActors;
for (TActorIterator<AActor> ActorIt(EditorWorld); ActorIt; ++ActorIt)
{
if (UAgrarianPersistentActorComponent* PersistentComponent = ActorIt->FindComponentByClass<UAgrarianPersistentActorComponent>())
{
if (PersistentComponent->IsSaveable())
{
SavedActors.Add(PersistentComponent->CaptureSaveState());
}
}
}
if (SavedActors.IsEmpty())
{
TestCharacter->Destroy();
return TEXT("FAIL: save capture did not include any persistent world actors");
}
int32 RestoredActorCount = 0;
for (const FAgrarianSavedWorldActor& SavedActor : SavedActors)
{
if (SavedActor.ActorTypeId != TEXT("primitive_shelter"))
{
continue;
}
AActor* RestoredActor = EditorWorld->SpawnActor<AActor>(ShelterClass, SavedActor.Transform);
if (!RestoredActor)
{
continue;
}
if (UAgrarianPersistentActorComponent* PersistentComponent = RestoredActor->FindComponentByClass<UAgrarianPersistentActorComponent>())
{
PersistentComponent->ApplySaveState(SavedActor);
}
RestoredActorCount++;
}
if (RestoredActorCount <= 0)
{
TestCharacter->Destroy();
return TEXT("FAIL: restore did not spawn any saved primitive shelter actors");
}
TestCharacter->Destroy();
return FString::Printf(
TEXT("PASS: gathered wood %d->%d, crafted primitive_shelter, placed shelter, saved %d persistent actor(s), restored %d actor(s)"),
StartingWood,
WoodAfterGather,
SavedActors.Num(),
RestoredActorCount);
#else
return TEXT("FAIL: editor automation is only available in editor builds");
#endif
}
FString UAgrarianEditorAutomationLibrary::RunNaturalShelterLoopSmokeTest(
TSubclassOf<AAgrarianGameCharacter> CharacterClass,
AAgrarianResourceNode* WoodNode,
AAgrarianResourceNode* FiberNode,
AAgrarianWildlifeBase* Wildlife,
UAgrarianRecipeDataAsset* FrameRecipe,
UAgrarianRecipeDataAsset* WallPanelRecipe,
UAgrarianRecipeDataAsset* RoofPanelRecipe,
UAgrarianRecipeDataAsset* ShelterRecipe,
TSubclassOf<AActor> ShelterClass)
{
#if WITH_EDITOR
UWorld* EditorWorld = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
if (!EditorWorld)
{
return TEXT("FAIL: no editor world");
}
if (!CharacterClass || !WoodNode || !FiberNode || !Wildlife || !FrameRecipe || !WallPanelRecipe || !RoofPanelRecipe || !ShelterRecipe || !ShelterClass)
{
return FString::Printf(
TEXT("FAIL: missing input CharacterClass=%s WoodNode=%s FiberNode=%s Wildlife=%s FrameRecipe=%s WallPanelRecipe=%s RoofPanelRecipe=%s ShelterRecipe=%s ShelterClass=%s"),
*GetNameSafe(CharacterClass.Get()),
*GetNameSafe(WoodNode),
*GetNameSafe(FiberNode),
*GetNameSafe(Wildlife),
*GetNameSafe(FrameRecipe),
*GetNameSafe(WallPanelRecipe),
*GetNameSafe(RoofPanelRecipe),
*GetNameSafe(ShelterRecipe),
*GetNameSafe(ShelterClass.Get()));
}
FActorSpawnParameters CharacterSpawnParams;
CharacterSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AAgrarianGameCharacter* TestCharacter = EditorWorld->SpawnActor<AAgrarianGameCharacter>(
CharacterClass,
FVector(720.0f, -420.0f, 180.0f),
FRotator::ZeroRotator,
CharacterSpawnParams);
if (!TestCharacter)
{
return TEXT("FAIL: could not spawn test character");
}
TestCharacter->SetActorLabel(TEXT("AGR_AutomationNaturalShelterCharacter"), false);
UAgrarianInventoryComponent* Inventory = TestCharacter->GetInventoryComponent();
UAgrarianCraftingComponent* Crafting = TestCharacter->GetCraftingComponent();
UAgrarianBuildingPlacementComponent* Placement = TestCharacter->GetBuildingPlacementComponent();
if (!Inventory || !Crafting || !Placement)
{
TestCharacter->Destroy();
return TEXT("FAIL: spawned character is missing inventory, crafting, or placement component");
}
TMap<FName, int32> NeededItems;
for (const FAgrarianItemStack& ShelterIngredient : ShelterRecipe->Recipe.Ingredients)
{
if (ShelterIngredient.ItemId == TEXT("primitive_frame"))
{
for (const FAgrarianItemStack& Ingredient : FrameRecipe->Recipe.Ingredients)
{
AddNeededIngredient(NeededItems, Ingredient.ItemId, Ingredient.Quantity * ShelterIngredient.Quantity);
}
}
else if (ShelterIngredient.ItemId == TEXT("primitive_wall_panel"))
{
for (const FAgrarianItemStack& Ingredient : WallPanelRecipe->Recipe.Ingredients)
{
AddNeededIngredient(NeededItems, Ingredient.ItemId, Ingredient.Quantity * ShelterIngredient.Quantity);
}
}
else if (ShelterIngredient.ItemId == TEXT("primitive_roof_panel"))
{
for (const FAgrarianItemStack& Ingredient : RoofPanelRecipe->Recipe.Ingredients)
{
AddNeededIngredient(NeededItems, Ingredient.ItemId, Ingredient.Quantity * ShelterIngredient.Quantity);
}
}
else
{
AddNeededIngredient(NeededItems, ShelterIngredient.ItemId, ShelterIngredient.Quantity);
}
}
const int32 NeededWood = NeededItems.FindRef(TEXT("wood"));
const int32 NeededFiber = NeededItems.FindRef(TEXT("fiber"));
const int32 NeededHide = NeededItems.FindRef(TEXT("hide"));
auto GatherUntil = [TestCharacter, Inventory](AAgrarianResourceNode* Node, const FName ItemId, const int32 NeededCount) -> bool
{
while (Inventory->GetItemCount(ItemId) < NeededCount)
{
if (!Node || !Node->GetClass()->ImplementsInterface(UAgrarianInteractable::StaticClass()) || !IAgrarianInteractable::Execute_CanInteract(Node, TestCharacter))
{
return false;
}
IAgrarianInteractable::Execute_Interact(Node, TestCharacter);
}
return true;
};
if (!GatherUntil(WoodNode, TEXT("wood"), NeededWood))
{
const int32 WoodCount = Inventory->GetItemCount(TEXT("wood"));
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: could not gather enough wood, needed=%d gathered=%d"), NeededWood, WoodCount);
}
if (!GatherUntil(FiberNode, TEXT("fiber"), NeededFiber))
{
const int32 FiberCount = Inventory->GetItemCount(TEXT("fiber"));
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: could not gather enough fiber, needed=%d gathered=%d"), NeededFiber, FiberCount);
}
if (NeededHide > 0)
{
Wildlife->ApplyWildlifeDamage(Wildlife->MaxHealth + 10.0f, TestCharacter);
if (!IAgrarianInteractable::Execute_CanInteract(Wildlife, TestCharacter))
{
TestCharacter->Destroy();
return TEXT("FAIL: wildlife could not be harvested after lethal damage");
}
IAgrarianInteractable::Execute_Interact(Wildlife, TestCharacter);
if (Inventory->GetItemCount(TEXT("hide")) < NeededHide)
{
const int32 HideCount = Inventory->GetItemCount(TEXT("hide"));
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: could not harvest enough hide, needed=%d harvested=%d"), NeededHide, HideCount);
}
}
if (!Crafting->AddKnownRecipe(FrameRecipe->Recipe) || !Crafting->AddKnownRecipe(WallPanelRecipe->Recipe) || !Crafting->AddKnownRecipe(RoofPanelRecipe->Recipe) || !Crafting->AddKnownRecipe(ShelterRecipe->Recipe))
{
TestCharacter->Destroy();
return TEXT("FAIL: could not add shelter recipes to test character");
}
auto CraftRepeated = [TestCharacter, Crafting](const UAgrarianRecipeDataAsset* RecipeAsset, const int32 Count) -> bool
{
for (int32 Index = 0; Index < Count; ++Index)
{
if (!RecipeAsset || !Crafting->Craft(RecipeAsset->Recipe.RecipeId))
{
TestCharacter->Destroy();
return false;
}
}
return true;
};
const int32 NeededFrames = GetIngredientQuantity(ShelterRecipe, TEXT("primitive_frame"));
const int32 NeededWallPanels = GetIngredientQuantity(ShelterRecipe, TEXT("primitive_wall_panel"));
const int32 NeededRoofPanels = GetIngredientQuantity(ShelterRecipe, TEXT("primitive_roof_panel"));
if (!CraftRepeated(FrameRecipe, NeededFrames) || !CraftRepeated(WallPanelRecipe, NeededWallPanels) || !CraftRepeated(RoofPanelRecipe, NeededRoofPanels))
{
return TEXT("FAIL: could not craft primitive shelter parts from gathered materials");
}
if (!Crafting->Craft(ShelterRecipe->Recipe.RecipeId))
{
TestCharacter->Destroy();
return TEXT("FAIL: primitive shelter craft failed after natural gathering");
}
if (Inventory->GetItemCount(TEXT("primitive_shelter")) < 1)
{
TestCharacter->Destroy();
return TEXT("FAIL: crafting did not add primitive_shelter to inventory");
}
TArray<FAgrarianItemStack> PlacementCost;
FAgrarianItemStack ShelterCost = ShelterRecipe->Recipe.Result;
ShelterCost.Quantity = 1;
PlacementCost.Add(ShelterCost);
Placement->SetActiveBuildable(ShelterClass, PlacementCost);
Placement->PlacementDistance = 5000.0f;
Placement->PlacementProbeRadius = 1.0f;
const int32 ShelterCountBeforePlace = Inventory->GetItemCount(TEXT("primitive_shelter"));
int32 ShelterActorsBeforePlace = 0;
for (TActorIterator<AActor> ActorIt(EditorWorld, ShelterClass); ActorIt; ++ActorIt)
{
ShelterActorsBeforePlace++;
}
const FTransform PlacementTransform(FRotator::ZeroRotator, FVector(1500.0f, 550.0f, 300.0f));
Placement->ServerPlaceBuildable_Implementation(ShelterClass, PlacementTransform);
const int32 ShelterCountAfterPlace = Inventory->GetItemCount(TEXT("primitive_shelter"));
int32 ShelterActorsAfterPlace = 0;
AActor* PlacedShelter = nullptr;
for (TActorIterator<AActor> ActorIt(EditorWorld, ShelterClass); ActorIt; ++ActorIt)
{
ShelterActorsAfterPlace++;
PlacedShelter = *ActorIt;
}
if (ShelterActorsAfterPlace <= ShelterActorsBeforePlace)
{
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: placement did not spawn shelter, before=%d after=%d"), ShelterActorsBeforePlace, ShelterActorsAfterPlace);
}
if (ShelterCountAfterPlace >= ShelterCountBeforePlace)
{
TestCharacter->Destroy();
return FString::Printf(TEXT("FAIL: placement did not consume shelter item, before=%d after=%d"), ShelterCountBeforePlace, ShelterCountAfterPlace);
}
if (!PlacedShelter || !PlacedShelter->FindComponentByClass<UAgrarianPersistentActorComponent>())
{
TestCharacter->Destroy();
return TEXT("FAIL: placed shelter is missing persistent actor component");
}
TArray<FAgrarianSavedWorldActor> SavedActors;
for (TActorIterator<AActor> ActorIt(EditorWorld); ActorIt; ++ActorIt)
{
if (UAgrarianPersistentActorComponent* PersistentComponent = ActorIt->FindComponentByClass<UAgrarianPersistentActorComponent>())
{
if (PersistentComponent->IsSaveable())
{
SavedActors.Add(PersistentComponent->CaptureSaveState());
}
}
}
if (SavedActors.IsEmpty())
{
TestCharacter->Destroy();
return TEXT("FAIL: save capture did not include any persistent world actors");
}
int32 RestoredActorCount = 0;
for (const FAgrarianSavedWorldActor& SavedActor : SavedActors)
{
if (SavedActor.ActorTypeId != TEXT("primitive_shelter"))
{
continue;
}
AActor* RestoredActor = EditorWorld->SpawnActor<AActor>(ShelterClass, SavedActor.Transform);
if (!RestoredActor)
{
continue;
}
if (UAgrarianPersistentActorComponent* PersistentComponent = RestoredActor->FindComponentByClass<UAgrarianPersistentActorComponent>())
{
PersistentComponent->ApplySaveState(SavedActor);
}
RestoredActorCount++;
}
if (RestoredActorCount <= 0)
{
TestCharacter->Destroy();
return TEXT("FAIL: restore did not spawn any saved primitive shelter actors");
}
const int32 WoodGathered = Inventory->GetItemCount(TEXT("wood")) + NeededWood;
const int32 FiberGathered = Inventory->GetItemCount(TEXT("fiber")) + NeededFiber;
const int32 HideHarvested = Inventory->GetItemCount(TEXT("hide")) + NeededHide;
TestCharacter->Destroy();
return FString::Printf(
TEXT("PASS: naturally gathered wood=%d fiber=%d hide=%d, crafted parts %d/%d/%d, crafted and placed primitive_shelter, saved %d persistent actor(s), restored %d actor(s)"),
WoodGathered,
FiberGathered,
HideHarvested,
NeededFrames,
NeededWallPanels,
NeededRoofPanels,
SavedActors.Num(),
RestoredActorCount);
#else
return TEXT("FAIL: editor automation is only available in editor builds");
#endif
}
FString UAgrarianEditorAutomationLibrary::RunPersistenceSubsystemSmokeTest(TSubclassOf<AActor> ShelterClass, const FString& SlotName)
{
#if WITH_EDITOR
UWorld* TestWorld = nullptr;
if (GEditor)
{
TestWorld = GEditor->PlayWorld ? GEditor->PlayWorld.Get() : GEditor->GetEditorWorldContext().World();
}
if (!TestWorld)
{
return TEXT("FAIL: no editor or PIE world");
}
UGameInstance* GameInstance = TestWorld->GetGameInstance();
if (!GameInstance)
{
return TEXT("FAIL: test world has no live GameInstance");
}
UAgrarianPersistenceSubsystem* Persistence = GameInstance->GetSubsystem<UAgrarianPersistenceSubsystem>();
if (!Persistence)
{
return TEXT("FAIL: live GameInstance has no Agrarian persistence subsystem");
}
if (!ShelterClass)
{
return TEXT("FAIL: missing shelter class");
}
const FString PreviousSlotName = Persistence->DefaultSlotName;
const int32 PreviousUserIndex = Persistence->UserIndex;
const TMap<FName, TSubclassOf<AActor>> PreviousRegistry = Persistence->WorldActorClassRegistry;
const FString EffectiveSlotName = SlotName.IsEmpty() ? TEXT("AgrarianAutomationPersistence") : SlotName;
Persistence->DefaultSlotName = EffectiveSlotName;
Persistence->UserIndex = 0;
Persistence->WorldActorClassRegistry.Reset();
Persistence->RegisterWorldActorClass(TEXT("primitive_shelter"), ShelterClass);
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
AAgrarianGameState* GameState = TestWorld->GetGameState<AAgrarianGameState>();
if (!GameState)
{
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: no Agrarian game state found for persistence test");
}
FAgrarianMappedWeatherInputs SavedWeatherInputs;
SavedWeatherInputs.TileId = TEXT("automation_weather_tile");
SavedWeatherInputs.Latitude = 37.5925f;
SavedWeatherInputs.Longitude = -122.4995f;
SavedWeatherInputs.TemperatureC = 9.5f;
SavedWeatherInputs.DailyLowTemperatureC = 7.0f;
SavedWeatherInputs.DailyHighTemperatureC = 13.0f;
SavedWeatherInputs.PrecipitationMm = 4.0f;
SavedWeatherInputs.WindSpeedKmh = 18.0f;
SavedWeatherInputs.CloudCoverPercent = 85.0f;
SavedWeatherInputs.RelativeHumidityPercent = 92.0f;
SavedWeatherInputs.PressureMslHpa = 1007.0f;
SavedWeatherInputs.VisibilityMeters = 6000.0f;
SavedWeatherInputs.ProviderWeatherCode = 61;
SavedWeatherInputs.MappedWeather = EAgrarianWeatherType::Rain;
SavedWeatherInputs.Provider = TEXT("automation-weather");
SavedWeatherInputs.ProviderTimestamp = TEXT("2026-05-16T08:00:00Z");
SavedWeatherInputs.bHasProviderData = true;
GameState->WorldHours = 14.25f;
GameState->ApplyMappedWeatherInputs(SavedWeatherInputs);
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AActor* TestShelter = TestWorld->SpawnActor<AActor>(
ShelterClass,
FTransform(FRotator::ZeroRotator, FVector(1800.0f, 750.0f, 300.0f)),
SpawnParams);
if (!TestShelter)
{
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: could not spawn persistence test shelter");
}
TestShelter->SetActorLabel(TEXT("AGR_AutomationPersistenceShelter"), false);
if (!TestShelter->FindComponentByClass<UAgrarianPersistentActorComponent>())
{
TestShelter->Destroy();
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: test shelter is missing persistent actor component");
}
if (!Persistence->SaveCurrentWorld())
{
TestShelter->Destroy();
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: SaveCurrentWorld failed");
}
if (!Persistence->DoesSaveExist())
{
TestShelter->Destroy();
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: save slot was not written");
}
const UAgrarianSaveGame* LoadedSave = Persistence->LoadOrCreateSave();
const int32 SavedActorCount = LoadedSave ? LoadedSave->WorldActors.Num() : 0;
if (SavedActorCount <= 0)
{
TestShelter->Destroy();
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: loaded save did not include persistent world actors");
}
GameState->WorldHours = 3.5f;
GameState->SetWeather(EAgrarianWeatherType::Storm);
int32 RestoredPlayerCount = 0;
int32 RestoredActorCount = 0;
if (!Persistence->LoadCurrentWorld(RestoredPlayerCount, RestoredActorCount, true))
{
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: LoadCurrentWorld failed to restore world state");
}
if (RestoredActorCount != SavedActorCount)
{
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return FString::Printf(TEXT("FAIL: restored actor count mismatch, saved=%d restored=%d"), SavedActorCount, RestoredActorCount);
}
const FAgrarianWeatherDebugSnapshot RestoredWeather = GameState->GetWeatherDebugSnapshot();
const bool bWeatherRestored =
FMath::IsNearlyEqual(GameState->WorldHours, 14.25f, 0.01f) &&
GameState->Weather == EAgrarianWeatherType::Rain &&
RestoredWeather.TileId == SavedWeatherInputs.TileId &&
RestoredWeather.Provider == SavedWeatherInputs.Provider &&
RestoredWeather.ProviderTimestamp == SavedWeatherInputs.ProviderTimestamp &&
RestoredWeather.AppliedWeather == EAgrarianWeatherType::Rain;
if (!bWeatherRestored)
{
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return TEXT("FAIL: weather state did not survive save/load");
}
int32 PersistentActorCountAfterRestore = 0;
for (TActorIterator<AActor> ActorIt(TestWorld); ActorIt; ++ActorIt)
{
const AActor* Actor = *ActorIt;
const UAgrarianPersistentActorComponent* PersistentComponent = Actor ? Actor->FindComponentByClass<UAgrarianPersistentActorComponent>() : nullptr;
if (PersistentComponent && PersistentComponent->IsSaveable())
{
PersistentActorCountAfterRestore++;
}
}
UGameplayStatics::DeleteGameInSlot(EffectiveSlotName, 0);
Persistence->DefaultSlotName = PreviousSlotName;
Persistence->UserIndex = PreviousUserIndex;
Persistence->WorldActorClassRegistry = PreviousRegistry;
return FString::Printf(
TEXT("PASS: live persistence subsystem saved %d actor(s), restored %d actor(s), restored %d player(s), restored weather %s from %s, world now has %d persistent actor(s)"),
SavedActorCount,
RestoredActorCount,
RestoredPlayerCount,
*UEnum::GetValueAsString(GameState->Weather),
*RestoredWeather.Provider,
PersistentActorCountAfterRestore);
#else
return TEXT("FAIL: editor automation is only available in editor builds");
#endif
}
FString UAgrarianEditorAutomationLibrary::ImportLandscapeHeightmapIntoEditorWorld(
const FString& HeightmapFilename,
const int32 Width,
const int32 Height,
const float XScaleCm,
const float YScaleCm,
const float ZScaleCm,
const FString& ActorLabel)
{
#if WITH_EDITOR
if (Width <= 1 || Height <= 1 || Width != Height)
{
return FString::Printf(TEXT("FAIL: invalid landscape dimensions Width=%d Height=%d"), Width, Height);
}
if (FMath::IsNearlyZero(XScaleCm) || FMath::IsNearlyZero(YScaleCm) || FMath::IsNearlyZero(ZScaleCm))
{
return FString::Printf(TEXT("FAIL: invalid landscape scale X=%f Y=%f Z=%f"), XScaleCm, YScaleCm, ZScaleCm);
}
UWorld* EditorWorld = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
if (!EditorWorld)
{
return TEXT("FAIL: no editor world");
}
TArray<uint8> HeightmapBytes;
if (!FFileHelper::LoadFileToArray(HeightmapBytes, *HeightmapFilename))
{
return FString::Printf(TEXT("FAIL: could not read heightmap %s"), *HeightmapFilename);
}
const int64 ExpectedByteCount = static_cast<int64>(Width) * static_cast<int64>(Height) * sizeof(uint16);
if (HeightmapBytes.Num() != ExpectedByteCount)
{
return FString::Printf(
TEXT("FAIL: heightmap byte count mismatch, expected=%lld actual=%d"),
ExpectedByteCount,
HeightmapBytes.Num());
}
TArray<uint16> HeightData;
HeightData.SetNumUninitialized(Width * Height);
FMemory::Memcpy(HeightData.GetData(), HeightmapBytes.GetData(), HeightmapBytes.Num());
FActorSpawnParameters SpawnParameters;
SpawnParameters.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnParameters.ObjectFlags = RF_Transactional;
const float WorldSizeX = static_cast<float>(Width - 1) * XScaleCm;
const float WorldSizeY = static_cast<float>(Height - 1) * YScaleCm;
const FVector LandscapeLocation(-WorldSizeX * 0.5f, -WorldSizeY * 0.5f, 0.0f);
ALandscape* Landscape = EditorWorld->SpawnActor<ALandscape>(
ALandscape::StaticClass(),
LandscapeLocation,
FRotator::ZeroRotator,
SpawnParameters);
if (!Landscape)
{
return TEXT("FAIL: could not spawn landscape actor");
}
Landscape->SetActorScale3D(FVector(XScaleCm, YScaleCm, ZScaleCm));
if (!ActorLabel.IsEmpty())
{
Landscape->SetActorLabel(ActorLabel, true);
}
const FGuid LandscapeGuid = FGuid::NewGuid();
const FGuid FinalLayerGuid;
TMap<FGuid, TArray<uint16>> ImportHeightData;
ImportHeightData.Add(FinalLayerGuid, MoveTemp(HeightData));
TMap<FGuid, TArray<FLandscapeImportLayerInfo>> ImportMaterialLayerInfos;
ImportMaterialLayerInfos.Add(FinalLayerGuid, TArray<FLandscapeImportLayerInfo>());
TArray<FLandscapeLayer> ImportLayers;
Landscape->Import(
LandscapeGuid,
0,
0,
Width - 1,
Height - 1,
1,
63,
ImportHeightData,
*HeightmapFilename,
ImportMaterialLayerInfos,
ELandscapeImportAlphamapType::Additive,
MakeArrayView(ImportLayers));
Landscape->RegisterAllComponents();
Landscape->PostEditChange();
Landscape->MarkPackageDirty();
EditorWorld->MarkPackageDirty();
return FString::Printf(
TEXT("PASS: imported landscape %s size=%dx%d scale=(%.6f,%.6f,%.6f) world_size_cm=(%.2f,%.2f)"),
*GetNameSafe(Landscape),
Width,
Height,
XScaleCm,
YScaleCm,
ZScaleCm,
WorldSizeX,
WorldSizeY);
#else
return TEXT("FAIL: editor automation is only available in editor builds");
#endif
}