899 lines
30 KiB
C++
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
|
|
}
|