using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.Hideout; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Utils; using Path = System.IO.Path; namespace HideoutCraftQuestIdGenerator; [Injectable] public class HideoutCraftQuestIdGenerator( ISptLogger logger, FileUtil fileUtil, JsonUtil jsonUtil, DatabaseServer databaseServer, ItemHelper itemHelper, DatabaseImporter databaseImporter ) { private static readonly HashSet _blacklistedProductions = [ "6617cdb6b24b0ea24505f618", // Old event quest production "Radio Repeater" alt recipe "66140c4a9688754de10dac07", // Old event quest production "Documents with decrypted data" "661e6c26750e453380391f55", // Old event quest production "Documents with decrypted data" "660c2dbaa2a92e70cc074863", // Old event quest production "Decrypted flash drive" "67093210d514d26f8408612b", // Old event quest production "TG-Vi-24 true vaccine" ]; private static readonly Dictionary _forcedQuestToProductionAssociations = new() { // KEY = PRODUCTION, VALUE = QUEST { new MongoId("63a571802116d261d2336cd1"), new MongoId("625d6ffaf7308432be1d44c5") }, // Network Provider - Part 2) }; private readonly Dictionary _questProductionMap = new(); private readonly List _questProductionOutputList = []; public async Task Run() { await databaseImporter.OnLoad(); // Build up our dataset BuildQuestProductionList(); UpdateProductionQuests(); // Figure out our source and target directories var projectDir = Directory.GetParent("./").Parent.Parent.Parent.Parent.Parent; const string productionPath = "Libraries\\SPTarkov.Server.Assets\\SPT_Data\\database\\hideout\\production.json"; var productionFilePath = Path.Combine(projectDir.FullName, productionPath); var updatedProductionJson = jsonUtil.Serialize(databaseServer.GetTables().Hideout.Production, true); await fileUtil.WriteFileAsync(productionFilePath, updatedProductionJson); } // Build a list of all quests and what production they unlock private void BuildQuestProductionList() { foreach (var (questId, quest) in databaseServer.GetTables().Templates.Quests) { var combinedRewards = CombineRewards(quest.Rewards).Where(x => x.Type == RewardType.ProductionScheme).ToList(); foreach (var reward in combinedRewards) { // Assume all productions only output a single item template var output = new QuestProductionOutput { QuestId = questId, ItemTemplate = reward.Items[0].Template, Quantity = 0, QuestName = quest.QuestName, }; // Loop over root items only, ignore children foreach (var item in reward.Items.Where(x => x.ParentId is null)) { if (item.Template != output.ItemTemplate) { logger.Error($"Production scheme has multiple output items. " + $"{output.ItemTemplate} != {item.Template}"); continue; } output.Quantity += item.Upd.StackObjectsCount.Value; } _questProductionOutputList.Add(output); } } } private void UpdateProductionQuests() { // Loop through all productions, and try to associate any with a `QuestComplete` type with its quest foreach (var production in databaseServer.GetTables().Hideout.Production.Recipes) { // Skip blacklisted productions if (_blacklistedProductions.Contains(production.Id)) { logger.Debug($"Skipped blacklisted production: {production.Id}"); continue; } // Look for a 'quest completion' requirement var questCompleteRequirements = production.Requirements.Where(req => req.Type == "QuestComplete").ToList(); if (questCompleteRequirements.Count == 0) { // Production has no quest requirement continue; } if (questCompleteRequirements.Count > 1) { logger.Error($"Error, prodId: {production.Id} contains multiple QuestComplete requirements"); // Production has no multiple quest requirements continue; } // Check for forced ids if (_forcedQuestToProductionAssociations.TryGetValue(production.Id, out var associatedQuestIdToComplete)) { var enLocale = databaseServer.GetTables().Locales.Global["en"].Value; var questName = enLocale[$"{associatedQuestIdToComplete} name"]; // Found one, move to next production logger.Success( $"FORCED - Updated: prodId: {production.Id} endProd: {production.EndProduct} ({itemHelper.GetItemName(production.EndProduct)}) with quantity: {production.Count} to quest: {associatedQuestIdToComplete} {questName}" ); questCompleteRequirements[0].QuestId = associatedQuestIdToComplete; continue; } // Try to find the quest that matches this production var questProductionOutputs = _questProductionOutputList .Where(output => output.ItemTemplate == production.EndProduct && output.Quantity == production.Count) .ToList(); // Make sure we found valid data if (!IsValidQuestProduction(production, questProductionOutputs, questCompleteRequirements[0])) { continue; } // Update the production quest ID _questProductionMap[questProductionOutputs[0].QuestId] = production.Id; questCompleteRequirements[0].QuestId = questProductionOutputs[0].QuestId; logger.Success( $"Updated prodId: {production.Id}, endProd: {production.EndProduct} quantity: {production.Count} to quest: {questProductionOutputs[0].QuestId} {questProductionOutputs[0].QuestName}" ); } } private bool IsValidQuestProduction( HideoutProduction production, List questProductionOutputs, Requirement questComplete ) { // A lot of error handling for edge cases if (!questProductionOutputs.Any()) { logger.Error( $"Error: Unable to find matching quest for prodId: {production.Id}, endProduct: {production.EndProduct} ({itemHelper.GetItemName(production.EndProduct)}) quantity: {production.Count}. Potential new or removed quest?" ); return false; } if (questProductionOutputs.Count > 1) { var questNamesCSV = string.Join(",", questProductionOutputs.Select(x => x.QuestName)); logger.Error( $"Error: Multiple quests match prodId: {production.Id}, endProduct: {production.EndProduct} with quantity: {production.Count}, quests: {questNamesCSV}" ); return false; } if (questComplete.QuestId is not null && questComplete.QuestId != questProductionOutputs[0].QuestId) { logger.Error( $"Error: Multiple productions match quest. EndProduct: {production.EndProduct} with quantity {production.Count}, existing quest: {questComplete.QuestId} {questProductionOutputs[0].QuestName}" ); return false; } if (_questProductionMap.ContainsKey(questProductionOutputs[0].QuestId)) { var recipies = databaseServer.GetTables().Hideout.Production.Recipes; var prodId = _questProductionMap[questProductionOutputs[0].QuestId]; var prod = recipies.FirstOrDefault(x => x.Id == prodId); var prodItemName = itemHelper.GetItemName(prod.EndProduct); logger.Warning( $"Error: Quest {questProductionOutputs[0].QuestId} {questProductionOutputs[0].QuestName} already associated with production: {prodId} {prodItemName}. Potential conflict" ); } return true; } /// /// Merge all rewards together into one collection /// /// Rewards to merge /// IEnumerable protected IEnumerable CombineRewards(Dictionary>? questRewards) { return questRewards?.SelectMany(rewardKvP => rewardKvP.Value) ?? []; } } public class QuestProductionOutput { public MongoId QuestId { get; set; } public MongoId ItemTemplate { get; set; } public double Quantity { get; set; } public string QuestName { get; set; } }