Server-authoritative, data-driven item/inventory/interaction/carry/equipment/ world-slot/placement/crafting framework. 8 modules, compiles against UE 5.7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
556 lines
15 KiB
C++
556 lines
15 KiB
C++
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
|
|
|
#include "ItemDatabaseSubsystem.h"
|
|
#include "ItemEcosystemSettings.h"
|
|
#include "ItemDatabaseLog.h"
|
|
#include "ItemNativeTags.h"
|
|
#include "Engine/DataTable.h"
|
|
#include "Engine/GameInstance.h"
|
|
|
|
void UItemDatabaseSubsystem::Initialize(FSubsystemCollectionBase& Collection)
|
|
{
|
|
Super::Initialize(Collection);
|
|
RebuildCache();
|
|
|
|
#if !UE_BUILD_SHIPPING
|
|
TArray<FItemValidationIssue> Issues;
|
|
ValidateData(Issues);
|
|
for (const FItemValidationIssue& Issue : Issues)
|
|
{
|
|
if (Issue.bIsError)
|
|
{
|
|
UE_LOG(LogItemDatabase, Error, TEXT("[Validate] %s: %s"), *Issue.ItemId.ToString(), *Issue.Message);
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogItemDatabase, Warning, TEXT("[Validate] %s: %s"), *Issue.ItemId.ToString(), *Issue.Message);
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::Deinitialize()
|
|
{
|
|
Definitions.Empty();
|
|
PropertiesByItem.Empty();
|
|
FeaturesByItem.Empty();
|
|
InteractionsByItem.Empty();
|
|
Redirects.Empty();
|
|
ContainerDefs.Empty();
|
|
WorldSlotDefs.Empty();
|
|
LootByTable.Empty();
|
|
RecipesById.Empty();
|
|
bCacheBuilt = false;
|
|
Super::Deinitialize();
|
|
}
|
|
|
|
UItemDatabaseSubsystem* UItemDatabaseSubsystem::Get(const UObject* WorldContext)
|
|
{
|
|
if (!WorldContext)
|
|
{
|
|
return nullptr;
|
|
}
|
|
if (const UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull) : nullptr)
|
|
{
|
|
if (UGameInstance* GI = World->GetGameInstance())
|
|
{
|
|
return GI->GetSubsystem<UItemDatabaseSubsystem>();
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::RebuildCache()
|
|
{
|
|
Definitions.Empty();
|
|
PropertiesByItem.Empty();
|
|
FeaturesByItem.Empty();
|
|
InteractionsByItem.Empty();
|
|
Redirects.Empty();
|
|
|
|
const UItemEcosystemSettings* Settings = UItemEcosystemSettings::Get();
|
|
if (!Settings)
|
|
{
|
|
UE_LOG(LogItemDatabase, Warning, TEXT("No ItemEcosystem settings found."));
|
|
return;
|
|
}
|
|
|
|
LoadDefinitions(Settings->ItemsTable.LoadSynchronous());
|
|
LoadProperties(Settings->PropertiesTable.LoadSynchronous());
|
|
LoadFeatures(Settings->FeaturesTable.LoadSynchronous());
|
|
LoadInteractions(Settings->InteractionsTable.LoadSynchronous());
|
|
LoadRedirects(Settings->RedirectsTable.LoadSynchronous());
|
|
LoadContainers(Settings->ContainersTable.LoadSynchronous());
|
|
LoadWorldSlots(Settings->WorldSlotsTable.LoadSynchronous());
|
|
LoadLoot(Settings->LootTable.LoadSynchronous());
|
|
LoadRecipes(Settings->RecipesTable.LoadSynchronous());
|
|
|
|
bCacheBuilt = true;
|
|
UE_LOG(LogItemDatabase, Log, TEXT("Item database built: %d items, %d redirects."),
|
|
Definitions.Num(), Redirects.Num());
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadDefinitions(UDataTable* Table)
|
|
{
|
|
if (!Table)
|
|
{
|
|
UE_LOG(LogItemDatabase, Warning, TEXT("DT_Items not assigned in Project Settings -> Item Ecosystem."));
|
|
return;
|
|
}
|
|
|
|
TArray<FItemDefinitionRow*> Rows;
|
|
Table->GetAllRows<FItemDefinitionRow>(TEXT("ItemDatabase.LoadDefinitions"), Rows);
|
|
for (const FItemDefinitionRow* Row : Rows)
|
|
{
|
|
if (!Row || Row->ItemId.IsNone())
|
|
{
|
|
continue;
|
|
}
|
|
Definitions.Add(Row->ItemId, *Row);
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadProperties(UDataTable* Table)
|
|
{
|
|
if (!Table)
|
|
{
|
|
return;
|
|
}
|
|
TArray<FItemPropertyRow*> Rows;
|
|
Table->GetAllRows<FItemPropertyRow>(TEXT("ItemDatabase.LoadProperties"), Rows);
|
|
for (const FItemPropertyRow* Row : Rows)
|
|
{
|
|
if (Row && !Row->ItemId.IsNone())
|
|
{
|
|
PropertiesByItem.FindOrAdd(Row->ItemId).Add(*Row);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadFeatures(UDataTable* Table)
|
|
{
|
|
if (!Table)
|
|
{
|
|
return;
|
|
}
|
|
TArray<FItemFeatureRow*> Rows;
|
|
Table->GetAllRows<FItemFeatureRow>(TEXT("ItemDatabase.LoadFeatures"), Rows);
|
|
for (const FItemFeatureRow* Row : Rows)
|
|
{
|
|
if (Row && !Row->ItemId.IsNone())
|
|
{
|
|
FeaturesByItem.FindOrAdd(Row->ItemId).Add(*Row);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadInteractions(UDataTable* Table)
|
|
{
|
|
if (!Table)
|
|
{
|
|
return;
|
|
}
|
|
TArray<FItemInteractionRow*> Rows;
|
|
Table->GetAllRows<FItemInteractionRow>(TEXT("ItemDatabase.LoadInteractions"), Rows);
|
|
for (const FItemInteractionRow* Row : Rows)
|
|
{
|
|
if (Row && !Row->ItemId.IsNone())
|
|
{
|
|
InteractionsByItem.FindOrAdd(Row->ItemId).Add(*Row);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadRedirects(UDataTable* Table)
|
|
{
|
|
if (!Table)
|
|
{
|
|
return;
|
|
}
|
|
TArray<FItemRedirectRow*> Rows;
|
|
Table->GetAllRows<FItemRedirectRow>(TEXT("ItemDatabase.LoadRedirects"), Rows);
|
|
for (const FItemRedirectRow* Row : Rows)
|
|
{
|
|
if (Row && !Row->OldItemId.IsNone() && !Row->NewItemId.IsNone())
|
|
{
|
|
Redirects.Add(Row->OldItemId, Row->NewItemId);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadContainers(UDataTable* Table)
|
|
{
|
|
if (!Table) { return; }
|
|
TArray<FContainerDefRow*> Rows;
|
|
Table->GetAllRows<FContainerDefRow>(TEXT("ItemDatabase.LoadContainers"), Rows);
|
|
for (const FContainerDefRow* Row : Rows)
|
|
{
|
|
if (Row && Row->ContainerTag.IsValid()) { ContainerDefs.Add(Row->ContainerTag, *Row); }
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadWorldSlots(UDataTable* Table)
|
|
{
|
|
if (!Table) { return; }
|
|
TArray<FWorldSlotDefRow*> Rows;
|
|
Table->GetAllRows<FWorldSlotDefRow>(TEXT("ItemDatabase.LoadWorldSlots"), Rows);
|
|
for (const FWorldSlotDefRow* Row : Rows)
|
|
{
|
|
if (Row && Row->SlotTag.IsValid()) { WorldSlotDefs.Add(Row->SlotTag, *Row); }
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadLoot(UDataTable* Table)
|
|
{
|
|
if (!Table) { return; }
|
|
TArray<FLootEntryRow*> Rows;
|
|
Table->GetAllRows<FLootEntryRow>(TEXT("ItemDatabase.LoadLoot"), Rows);
|
|
for (const FLootEntryRow* Row : Rows)
|
|
{
|
|
if (Row && !Row->TableId.IsNone()) { LootByTable.FindOrAdd(Row->TableId).Add(*Row); }
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::LoadRecipes(UDataTable* Table)
|
|
{
|
|
if (!Table) { return; }
|
|
TArray<FCraftingRecipeRow*> Rows;
|
|
Table->GetAllRows<FCraftingRecipeRow>(TEXT("ItemDatabase.LoadRecipes"), Rows);
|
|
for (const FCraftingRecipeRow* Row : Rows)
|
|
{
|
|
if (Row && !Row->RecipeId.IsNone()) { RecipesById.Add(Row->RecipeId, *Row); }
|
|
}
|
|
}
|
|
|
|
EItemPropertyReplicationPolicy UItemDatabaseSubsystem::GetPropertyPolicy(FName ItemId, const FGameplayTag& PropertyTag) const
|
|
{
|
|
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
for (const FItemPropertyRow& R : *Rows)
|
|
{
|
|
if (R.PropertyTag == PropertyTag) { return R.ReplicationPolicy; }
|
|
}
|
|
}
|
|
return EItemPropertyReplicationPolicy::None;
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetServerOnlyProperties(FName ItemId, TArray<FGameplayTag>& Out) const
|
|
{
|
|
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
for (const FItemPropertyRow& R : *Rows)
|
|
{
|
|
if (R.ReplicationPolicy == EItemPropertyReplicationPolicy::ServerOnly) { Out.Add(R.PropertyTag); }
|
|
}
|
|
}
|
|
}
|
|
|
|
bool UItemDatabaseSubsystem::FindContainerDef(FGameplayTag ContainerTag, FContainerDefRow& OutDef) const
|
|
{
|
|
if (const FContainerDefRow* Found = ContainerDefs.Find(ContainerTag)) { OutDef = *Found; return true; }
|
|
return false;
|
|
}
|
|
|
|
bool UItemDatabaseSubsystem::FindWorldSlotDef(FGameplayTag SlotTag, FWorldSlotDefRow& OutDef) const
|
|
{
|
|
if (const FWorldSlotDefRow* Found = WorldSlotDefs.Find(SlotTag)) { OutDef = *Found; return true; }
|
|
return false;
|
|
}
|
|
|
|
bool UItemDatabaseSubsystem::FindRecipe(FName RecipeId, FCraftingRecipeRow& OutRecipe) const
|
|
{
|
|
if (const FCraftingRecipeRow* Found = RecipesById.Find(RecipeId)) { OutRecipe = *Found; return true; }
|
|
return false;
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetAllRecipes(TArray<FCraftingRecipeRow>& OutRecipes) const
|
|
{
|
|
RecipesById.GenerateValueArray(OutRecipes);
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetLootEntries(FName TableId, TArray<FLootEntryRow>& Out) const
|
|
{
|
|
if (const TArray<FLootEntryRow>* Found = LootByTable.Find(TableId)) { Out = *Found; }
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::RollLoot(FName TableId, int32 RollCount, TArray<FItemInstanceData>& OutItems) const
|
|
{
|
|
const TArray<FLootEntryRow>* Entries = LootByTable.Find(TableId);
|
|
if (!Entries || Entries->Num() == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float TotalWeight = 0.f;
|
|
for (const FLootEntryRow& E : *Entries) { TotalWeight += FMath::Max(0.f, E.Weight); }
|
|
if (TotalWeight <= 0.f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int32 Roll = 0; Roll < FMath::Max(1, RollCount); ++Roll)
|
|
{
|
|
float Pick = FMath::FRandRange(0.f, TotalWeight);
|
|
for (const FLootEntryRow& E : *Entries)
|
|
{
|
|
Pick -= FMath::Max(0.f, E.Weight);
|
|
if (Pick <= 0.f)
|
|
{
|
|
if (FMath::FRand() <= E.Chance && HasItem(E.ItemId))
|
|
{
|
|
const int32 Qty = FMath::RandRange(FMath::Max(1, E.MinQuantity), FMath::Max(E.MinQuantity, E.MaxQuantity));
|
|
OutItems.Add(MakeItemInstance(E.ItemId, Qty));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
FName UItemDatabaseSubsystem::ResolveRedirect(FName ItemId) const
|
|
{
|
|
// Follow the chain with a cycle guard.
|
|
FName Current = ItemId;
|
|
int32 Guard = 0;
|
|
while (const FName* Next = Redirects.Find(Current))
|
|
{
|
|
Current = *Next;
|
|
if (++Guard > 64)
|
|
{
|
|
UE_LOG(LogItemDatabase, Error, TEXT("Redirect cycle detected starting at %s."), *ItemId.ToString());
|
|
break;
|
|
}
|
|
}
|
|
return Current;
|
|
}
|
|
|
|
const FItemDefinitionRow* UItemDatabaseSubsystem::GetItemDefinition(FName ItemId) const
|
|
{
|
|
return Definitions.Find(ResolveRedirect(ItemId));
|
|
}
|
|
|
|
bool UItemDatabaseSubsystem::FindItemDefinition(FName ItemId, FItemDefinitionRow& OutDefinition) const
|
|
{
|
|
if (const FItemDefinitionRow* Def = GetItemDefinition(ItemId))
|
|
{
|
|
OutDefinition = *Def;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UItemDatabaseSubsystem::HasItem(FName ItemId) const
|
|
{
|
|
return Definitions.Contains(ResolveRedirect(ItemId));
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetAllItemIds(TArray<FName>& OutItemIds) const
|
|
{
|
|
Definitions.GenerateKeyArray(OutItemIds);
|
|
}
|
|
|
|
int32 UItemDatabaseSubsystem::GetMaxStack(FName ItemId) const
|
|
{
|
|
const FItemDefinitionRow* Def = GetItemDefinition(ItemId);
|
|
return Def ? FMath::Max(1, Def->MaxStack) : 1;
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::ApplyDefaultProperties(FName ItemId, FItemRuntimeProperties& Out) const
|
|
{
|
|
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
for (const FItemPropertyRow& Row : *Rows)
|
|
{
|
|
if (Row.PropertyTag.IsValid())
|
|
{
|
|
Out.SetFloat(Row.PropertyTag, FMath::Clamp(Row.DefaultValue, Row.Min, Row.Max));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::ApplyMissingDefaultProperties(FName ItemId, FItemRuntimeProperties& Out) const
|
|
{
|
|
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
for (const FItemPropertyRow& Row : *Rows)
|
|
{
|
|
if (Row.PropertyTag.IsValid() && !Out.HasFloat(Row.PropertyTag))
|
|
{
|
|
Out.SetFloat(Row.PropertyTag, FMath::Clamp(Row.DefaultValue, Row.Min, Row.Max));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::MigrateLoadedItem(FItemInstanceData& Item) const
|
|
{
|
|
// 1) Redirect retired ids to their replacement.
|
|
const FName Resolved = ResolveRedirect(Item.ItemId);
|
|
if (Resolved != Item.ItemId)
|
|
{
|
|
Item.ItemId = Resolved;
|
|
}
|
|
// 2) Fill in properties introduced after this item was saved.
|
|
if (Item.ItemDataVersion < CurrentItemDataVersion)
|
|
{
|
|
ApplyMissingDefaultProperties(Item.ItemId, Item.RuntimeProperties);
|
|
Item.ItemDataVersion = CurrentItemDataVersion;
|
|
}
|
|
Item.EnsureInstanceId();
|
|
}
|
|
|
|
FItemInstanceData UItemDatabaseSubsystem::MakeItemInstance(FName ItemId, int32 Quantity) const
|
|
{
|
|
const FName Resolved = ResolveRedirect(ItemId);
|
|
FItemInstanceData Instance(Resolved, FMath::Max(1, Quantity));
|
|
Instance.LocationType = EItemLocationType::None;
|
|
ApplyDefaultProperties(Resolved, Instance.RuntimeProperties);
|
|
return Instance;
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetPropertyRows(FName ItemId, TArray<FItemPropertyRow>& Out) const
|
|
{
|
|
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
Out = *Rows;
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, TArray<FItemFeatureRow>& Out) const
|
|
{
|
|
if (const TArray<FItemFeatureRow>* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
Out = *Rows;
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, const FGameplayTag& Feature, TArray<FItemFeatureRow>& Out) const
|
|
{
|
|
if (const TArray<FItemFeatureRow>* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
for (const FItemFeatureRow& Row : *Rows)
|
|
{
|
|
if (Row.Feature == Feature)
|
|
{
|
|
Out.Add(Row);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::GetInteractionRows(FName ItemId, TArray<FItemInteractionRow>& Out) const
|
|
{
|
|
if (const TArray<FItemInteractionRow>* Rows = InteractionsByItem.Find(ResolveRedirect(ItemId)))
|
|
{
|
|
Out = *Rows;
|
|
}
|
|
}
|
|
|
|
void UItemDatabaseSubsystem::ValidateData(TArray<FItemValidationIssue>& OutIssues) const
|
|
{
|
|
auto AddIssue = [&OutIssues](FName Id, const FString& Msg, bool bError)
|
|
{
|
|
FItemValidationIssue Issue;
|
|
Issue.ItemId = Id;
|
|
Issue.Message = Msg;
|
|
Issue.bIsError = bError;
|
|
OutIssues.Add(Issue);
|
|
};
|
|
|
|
for (const TPair<FName, FItemDefinitionRow>& Pair : Definitions)
|
|
{
|
|
const FName Id = Pair.Key;
|
|
const FItemDefinitionRow& Def = Pair.Value;
|
|
|
|
if (Def.ItemId.IsNone())
|
|
{
|
|
AddIssue(Id, TEXT("ItemId is empty."), true);
|
|
}
|
|
if (Def.MaxStack <= 0)
|
|
{
|
|
AddIssue(Id, TEXT("MaxStack <= 0."), true);
|
|
}
|
|
if (Def.GridSize.X <= 0 || Def.GridSize.Y <= 0)
|
|
{
|
|
AddIssue(Id, TEXT("GridSize must be >= 1 in both axes."), true);
|
|
}
|
|
if (Def.bCanInstantPickup && !Def.bCanStoreInInventory)
|
|
{
|
|
AddIssue(Id, TEXT("CanInstantPickup=true but CanStoreInInventory=false."), true);
|
|
}
|
|
if (Def.bCanEquip && Def.EquippedActorClass.IsNull())
|
|
{
|
|
AddIssue(Id, TEXT("CanEquip=true but EquippedActorClass is empty."), true);
|
|
}
|
|
if (Def.bCanWorldCarry && Def.WorldMesh.IsNull())
|
|
{
|
|
AddIssue(Id, TEXT("CanWorldCarry=true but WorldMesh is empty."), true);
|
|
}
|
|
if (Def.bCanInstallIntoWorldSlot && Def.CompatibleWorldSlots.IsEmpty())
|
|
{
|
|
AddIssue(Id, TEXT("CanInstallIntoWorldSlot=true but CompatibleWorldSlots is empty."), true);
|
|
}
|
|
if (Def.bCanPlace && !Def.PlacementRules.bCanPlaceOnGround
|
|
&& !Def.PlacementRules.bCanPlaceOnWall && !Def.PlacementRules.bCanPlaceOnVehicle
|
|
&& !Def.PlacementRules.bCanPlaceInWater)
|
|
{
|
|
AddIssue(Id, TEXT("CanPlace=true but PlacementRules forbid every surface."), true);
|
|
}
|
|
if (Def.bIsQuestCritical && Def.bCanDrop)
|
|
{
|
|
AddIssue(Id, TEXT("QuestCritical item is droppable (CanDrop=true)."), false);
|
|
}
|
|
|
|
// Feature <-> property consistency (section 28).
|
|
auto HasDefaultProperty = [this, Id](const FGameplayTag& Tag) -> bool
|
|
{
|
|
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(Id))
|
|
{
|
|
for (const FItemPropertyRow& R : *Rows)
|
|
{
|
|
if (R.PropertyTag == Tag) { return true; }
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (Def.HasFeature(ItemEcosystemTags::Feature_Battery) && !HasDefaultProperty(ItemEcosystemTags::Property_BatteryCharge))
|
|
{
|
|
AddIssue(Id, TEXT("Battery feature present but no Property.BatteryCharge default."), false);
|
|
}
|
|
if (Def.HasFeature(ItemEcosystemTags::Feature_Fuel) && !HasDefaultProperty(ItemEcosystemTags::Property_FuelAmount))
|
|
{
|
|
AddIssue(Id, TEXT("Fuel feature present but no Property.FuelAmount default."), false);
|
|
}
|
|
if (Def.HasFeature(ItemEcosystemTags::Feature_Rotting))
|
|
{
|
|
bool bHasRottenId = false;
|
|
if (const TArray<FItemFeatureRow>* Rows = FeaturesByItem.Find(Id))
|
|
{
|
|
for (const FItemFeatureRow& R : *Rows)
|
|
{
|
|
if (R.Feature == ItemEcosystemTags::Feature_Rotting && R.Param == FName("RottenItemId") && !R.Value.IsEmpty())
|
|
{
|
|
bHasRottenId = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!bHasRottenId)
|
|
{
|
|
AddIssue(Id, TEXT("Rotting feature present but no RottenItemId param."), false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Redirect targets must exist.
|
|
for (const TPair<FName, FName>& Pair : Redirects)
|
|
{
|
|
if (!Definitions.Contains(ResolveRedirect(Pair.Value)))
|
|
{
|
|
AddIssue(Pair.Key, FString::Printf(TEXT("Redirect target '%s' not found in DT_Items."), *Pair.Value.ToString()), true);
|
|
}
|
|
}
|
|
}
|