// 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 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(); } } 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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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 Rows; Table->GetAllRows(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* 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& Out) const { if (const TArray* 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& OutRecipes) const { RecipesById.GenerateValueArray(OutRecipes); } void UItemDatabaseSubsystem::GetLootEntries(FName TableId, TArray& Out) const { if (const TArray* Found = LootByTable.Find(TableId)) { Out = *Found; } } void UItemDatabaseSubsystem::RollLoot(FName TableId, int32 RollCount, TArray& OutItems) const { const TArray* 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& 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* 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* 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& Out) const { if (const TArray* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId))) { Out = *Rows; } } void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, TArray& Out) const { if (const TArray* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId))) { Out = *Rows; } } void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, const FGameplayTag& Feature, TArray& Out) const { if (const TArray* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId))) { for (const FItemFeatureRow& Row : *Rows) { if (Row.Feature == Feature) { Out.Add(Row); } } } } void UItemDatabaseSubsystem::GetInteractionRows(FName ItemId, TArray& Out) const { if (const TArray* Rows = InteractionsByItem.Find(ResolveRedirect(ItemId))) { Out = *Rows; } } void UItemDatabaseSubsystem::ValidateData(TArray& 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& 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* 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* 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& 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); } } }