using SptCommon.Annotations; using Core.Generators; using Core.Helpers; using Core.Models.Common; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Eft.Hideout; using Core.Models.Eft.Inventory; using Core.Models.Eft.ItemEvent; using Core.Models.Enums; using Core.Models.Enums.Hideout; using Core.Models.Spt.Config; using Core.Models.Utils; using Core.Routers; using Core.Servers; using Core.Services; using Core.Utils; using Core.Utils.Cloners; namespace Core.Controllers; [Injectable] public class HideoutController( ISptLogger _logger, HashUtil _hashUtil, TimeUtil _timeUtil, DatabaseService _databaseService, RandomUtil _randomUtil, InventoryHelper _inventoryHelper, ItemHelper _itemHelper, SaveServer _saveServer, PlayerService _playerService, PresetHelper _presetHelper, PaymentHelper _paymentHelper, EventOutputHolder _eventOutputHolder, HttpResponseUtil _httpResponseUtil, ProfileHelper _profileHelper, HideoutHelper _hideoutHelper, ScavCaseRewardGenerator _scavCaseRewardGenerator, LocalisationService _localisationService, ProfileActivityService _profileActivityService, FenceService _fenceService, CircleOfCultistService _circleOfCultistService, ICloner _cloner, ConfigServer _configServer ) { protected HideoutConfig _hideoutConfig = _configServer.GetConfig(); public const string NameTaskConditionCountersCraftingId = "673f5d6fdd6ed700c703afdc"; protected List _hideoutAreas = [ HideoutAreas.AIR_FILTERING, HideoutAreas.WATER_COLLECTOR, HideoutAreas.GENERATOR, HideoutAreas.BITCOIN_FARM, ]; public void StartUpgrade(PmcData pmcData, HideoutUpgradeRequestData request, string sessionID, ItemEventRouterResponse output) { var items = request.Items.Select( reqItem => { var item = pmcData.Inventory.Items.FirstOrDefault(invItem => invItem.Id == reqItem.Id); return new { inventoryItem = item, requestedItem = reqItem }; } ) .ToList(); // If it's not money, its construction / barter items foreach (var item in items) { if (item.inventoryItem is null) { _logger.Error( _localisationService.GetText("hideout-unable_to_find_item_in_inventory", item.requestedItem.Id) ); _httpResponseUtil.AppendErrorToOutput(output); return; } if ( _paymentHelper.IsMoneyTpl(item.inventoryItem.Template) && item.inventoryItem.Upd is not null && item.inventoryItem.Upd.StackObjectsCount is not null && item.inventoryItem.Upd.StackObjectsCount > item.requestedItem.Count ) { item.inventoryItem.Upd.StackObjectsCount -= item.requestedItem.Count; } else { _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionID, output); } } // Construction time management var profileHideoutArea = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == request.AreaType); if (profileHideoutArea is null) { _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); _httpResponseUtil.AppendErrorToOutput(output); return; } var hideoutDataDb = _databaseService .GetTables() .Hideout.Areas.FirstOrDefault(area => area.Type == request.AreaType); if (hideoutDataDb is null) { _logger.Error( _localisationService.GetText("hideout-unable_to_find_area_in_database", request.AreaType) ); _httpResponseUtil.AppendErrorToOutput(output); return; } var ctime = hideoutDataDb.Stages[(profileHideoutArea.Level + 1).ToString()].ConstructionTime; if (ctime > 0) { if (_profileHelper.IsDeveloperAccount(sessionID)) { ctime = 40; } var timestamp = _timeUtil.GetTimeStamp(); profileHideoutArea.CompleteTime = Math.Round((double)(timestamp - ctime)); profileHideoutArea.Constructing = true; } } public void UpgradeComplete(PmcData pmcData, HideoutUpgradeCompleteRequestData request, string sessionID, ItemEventRouterResponse output) { var hideout = _databaseService.GetHideout(); var globals = _databaseService.GetGlobals(); var profileHideoutArea = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == request.AreaType); if (profileHideoutArea is null) { _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); _httpResponseUtil.AppendErrorToOutput(output); return; } // Upgrade profile values profileHideoutArea.Level++; profileHideoutArea.CompleteTime = 0; profileHideoutArea.Constructing = false; var hideoutData = hideout.Areas.FirstOrDefault(area => area.Type == profileHideoutArea.Type); if (hideoutData is null) { _logger.Error( _localisationService.GetText("hideout-unable_to_find_area_in_database", request.AreaType) ); _httpResponseUtil.AppendErrorToOutput(output); return; } // Apply bonuses var hideoutStage = hideoutData.Stages[profileHideoutArea.Level.ToString()]; var bonuses = hideoutStage.Bonuses; if (bonuses?.Count > 0) { foreach (var bonus in bonuses) { _hideoutHelper.ApplyPlayerUpgradesBonuses(pmcData, bonus); } } // Upgrade includes a container improvement/addition if (hideoutStage?.Container is not null) { AddContainerImprovementToProfile( output, sessionID, pmcData, profileHideoutArea, hideoutData, hideoutStage ); } // Upgrading water collector / med station if ( profileHideoutArea.Type == HideoutAreas.WATER_COLLECTOR || profileHideoutArea.Type == HideoutAreas.MEDSTATION ) { SetWallVisibleIfPrereqsMet(pmcData); } // Cleanup temporary buffs/debuffs from wall if complete if (profileHideoutArea.Type == HideoutAreas.EMERGENCY_WALL && profileHideoutArea.Level == 6) { _hideoutHelper.RemoveHideoutWallBuffsAndDebuffs(hideoutData, pmcData); } // Add Skill Points Per Area Upgrade _profileHelper.AddSkillPointsToPlayer( pmcData, SkillTypes.HideoutManagement, globals.Configuration.SkillsSettings.HideoutManagement.SkillPointsPerAreaUpgrade ); } private void SetWallVisibleIfPrereqsMet(PmcData pmcData) { var medStation = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.MEDSTATION); var waterCollector = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.WATER_COLLECTOR); if (medStation?.Level >= 1 && waterCollector?.Level >= 1) { var wall = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.EMERGENCY_WALL); if (wall?.Level == 0) { wall.Level = 3; } } } private void AddContainerImprovementToProfile(ItemEventRouterResponse output, string sessionID, PmcData pmcData, BotHideoutArea profileParentHideoutArea, HideoutArea dbHideoutArea, Stage hideoutStage) { // Add key/value to `hideoutAreaStashes` dictionary - used to link hideout area to inventory stash by its id if (pmcData.Inventory.HideoutAreaStashes.GetValueOrDefault(dbHideoutArea.Type.ToString()) is null) { pmcData.Inventory.HideoutAreaStashes[dbHideoutArea.Type.ToString()] = dbHideoutArea.Id; } // Add/upgrade stash item in player inventory AddUpdateInventoryItemToProfile(sessionID, pmcData, dbHideoutArea, hideoutStage); // Edge case, add/update `stand1/stand2/stand3` children if (dbHideoutArea.Type == HideoutAreas.EQUIPMENT_PRESETS_STAND) { // Can have multiple 'standx' children depending on upgrade level AddMissingPresetStandItemsToProfile(sessionID, hideoutStage, pmcData, dbHideoutArea, output); } // Dont inform client when upgraded area is hall of fame or equipment stand, BSG doesn't inform client this specifc upgrade has occurred // will break client if sent List check = [HideoutAreas.PLACE_OF_FAME]; if (!check.Contains(dbHideoutArea.Type ?? HideoutAreas.NOTSET)) { AddContainerUpgradeToClientOutput(sessionID, dbHideoutArea.Type, dbHideoutArea, hideoutStage, output); } // Some hideout areas (Gun stand) have child areas linked to it var childDbArea = _databaseService .GetHideout() .Areas.FirstOrDefault(area => area.ParentArea == dbHideoutArea.Id); if (childDbArea is not null) { // Add key/value to `hideoutAreaStashes` dictionary - used to link hideout area to inventory stash by its id if (pmcData.Inventory.HideoutAreaStashes.GetValueOrDefault(childDbArea.Type.ToString()) is null) { pmcData.Inventory.HideoutAreaStashes[childDbArea.Type.ToString()] = childDbArea.Id; } // Set child area level to same as parent area pmcData.Hideout.Areas.FirstOrDefault((hideoutArea) => hideoutArea.Type == childDbArea.Type).Level = pmcData.Hideout.Areas.FirstOrDefault((x) => x.Type == profileParentHideoutArea.Type).Level; // Add/upgrade stash item in player inventory var childDbAreaStage = childDbArea.Stages[profileParentHideoutArea.Level.ToString()]; AddUpdateInventoryItemToProfile(sessionID, pmcData, childDbArea, childDbAreaStage); // Inform client of the changes AddContainerUpgradeToClientOutput(sessionID, childDbArea.Type, childDbArea, childDbAreaStage, output); } } private void AddUpdateInventoryItemToProfile(string sessionId, PmcData pmcData, HideoutArea dbHideoutArea, Stage hideoutStage) { var existingInventoryItem = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == dbHideoutArea.Id); if (existingInventoryItem is not null) { // Update existing items container tpl to point to new id (tpl) existingInventoryItem.Template = hideoutStage.Container; return; } // Add new item as none exists (don't inform client of newContainerItem, will be done in `profileChanges.changedHideoutStashes`) var newContainerItem = new Item { Id = dbHideoutArea.Id, Template = hideoutStage.Container }; pmcData.Inventory.Items.Add(newContainerItem); } private void AddContainerUpgradeToClientOutput(string sessionID, HideoutAreas? areaType, HideoutArea hideoutDbData, Stage hideoutStage, ItemEventRouterResponse output) { if (output.ProfileChanges[sessionID].ChangedHideoutStashes is null) { output.ProfileChanges[sessionID].ChangedHideoutStashes = new Dictionary(); } // Inform client of changes output.ProfileChanges[sessionID].ChangedHideoutStashes[areaType.ToString()] = new HideoutStashItem { Id = hideoutDbData.Id, Template = hideoutStage.Container, }; } public ItemEventRouterResponse PutItemsInAreaSlots(PmcData pmcData, HideoutPutItemInRequestData addItemToHideoutRequest, string sessionID) { var output = _eventOutputHolder.GetOutput(sessionID); var itemsToAdd = addItemToHideoutRequest.Items.Select( kvp => { var item = pmcData.Inventory.Items.FirstOrDefault((invItem) => invItem.Id == kvp.Value.Id); return new { inventoryItem = item, requestedItem = kvp.Value, slot = kvp.Key }; } ); var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == addItemToHideoutRequest.AreaType); if (hideoutArea is null) { _logger.Error( _localisationService.GetText( "hideout-unable_to_find_area_in_database", addItemToHideoutRequest.AreaType ) ); return _httpResponseUtil.AppendErrorToOutput(output); } foreach (var item in itemsToAdd) { if (item.inventoryItem is null) { _logger.Error( _localisationService.GetText( "hideout-unable_to_find_item_in_inventory", new { itemId = item.requestedItem.Id, area = hideoutArea.Type } ) ); return _httpResponseUtil.AppendErrorToOutput(output); } // Add item to area.slots var destinationLocationIndex = int.Parse(item.slot); var hideoutSlotIndex = hideoutArea.Slots.FindIndex( (slot) => slot.LocationIndex == destinationLocationIndex ); if (hideoutSlotIndex == -1) { _logger.Error( $"Unable to put item: {item.requestedItem.Id} into slot as slot cannot be found for area: {addItemToHideoutRequest.AreaType}, skipping" ); continue; } hideoutArea.Slots[hideoutSlotIndex].Items = [ new HideoutItem() { Id = item.inventoryItem.Id, Template = item.inventoryItem.Template, Upd = item.inventoryItem.Upd }, ]; _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionID, output); } // Trigger a forced update _hideoutHelper.UpdatePlayerHideout(sessionID); return output; } public ItemEventRouterResponse TakeItemsFromAreaSlots(PmcData pmcData, HideoutTakeItemOutRequestData request, string sessionID) { var output = _eventOutputHolder.GetOutput(sessionID); var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == request.AreaType); if (hideoutArea is null) { _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); return _httpResponseUtil.AppendErrorToOutput(output); } if (hideoutArea.Slots is null || hideoutArea.Slots.Count == 0) { _logger.Error( _localisationService.GetText("hideout-unable_to_find_item_to_remove_from_area", hideoutArea.Type) ); return _httpResponseUtil.AppendErrorToOutput(output); } // Handle areas that have resources that can be placed in/taken out of slots from the area if ( _hideoutAreas.Contains(hideoutArea.Type ?? HideoutAreas.NOTSET) ) { var response = RemoveResourceFromArea(sessionID, pmcData, request, output, hideoutArea); // Force a refresh of productions/hideout areas with resources _hideoutHelper.UpdatePlayerHideout(sessionID); return response; } throw new Exception( _localisationService.GetText("hideout-unhandled_remove_item_from_area_request", hideoutArea.Type) ); } private ItemEventRouterResponse RemoveResourceFromArea(string sessionID, PmcData pmcData, HideoutTakeItemOutRequestData removeResourceRequest, ItemEventRouterResponse output, BotHideoutArea hideoutArea) { var slotIndexToRemove = removeResourceRequest?.Slots.FirstOrDefault(); if (slotIndexToRemove is null) { _logger.Warning( $"Unable to remove resource from area: {removeResourceRequest.AreaType} slot as no slots found in request, RESTART CLIENT IMMEDIATELY" ); return output; } // Assume only one item in slot var itemToReturn = hideoutArea.Slots.FirstOrDefault(slot => slot.LocationIndex == slotIndexToRemove)?.Items.FirstOrDefault(); if (itemToReturn is null) { _logger.Warning($"Unable to remove resource from area: {removeResourceRequest.AreaType} slot as no item found, RESTART CLIENT IMMEDIATELY"); return output; } AddItemDirectRequest request = new AddItemDirectRequest { ItemWithModsToAdd = [itemToReturn], FoundInRaid = itemToReturn.Upd?.SpawnedInSession, Callback = null, UseSortingTable = false, }; _inventoryHelper.AddItemToStash(sessionID, request, pmcData, output); if (output.Warnings?.Count > 0) { // Adding to stash failed, drop out - dont remove item from hideout area slot return output; } // Remove items from slot, locationIndex remains var hideoutSlotIndex = hideoutArea.Slots.FindIndex((slot) => slot.LocationIndex == slotIndexToRemove); hideoutArea.Slots[hideoutSlotIndex].Items = null; return output; } public ItemEventRouterResponse ToggleArea(PmcData pmcData, HideoutToggleAreaRequestData request, string sessionID) { var output = _eventOutputHolder.GetOutput(sessionID); // Force a production update (occur before area is toggled as it could be generator and doing it after generator enabled would cause incorrect calculaton of production progress) _hideoutHelper.UpdatePlayerHideout(sessionID); var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == request.AreaType); if (hideoutArea is null) { _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); return _httpResponseUtil.AppendErrorToOutput(output); } hideoutArea.Active = request.Enabled; return output; } public ItemEventRouterResponse SingleProductionStart(PmcData pmcData, HideoutSingleProductionStartRequestData body, string sessionID) { // Start production _hideoutHelper.RegisterProduction(pmcData, body, sessionID); // Find the recipe of the production var recipe = _databaseService .GetHideout() .Production.Recipes.FirstOrDefault(production => production.Id == body.RecipeId); // Find the actual amount of items we need to remove because body can send weird data var recipeRequirementsClone = _cloner.Clone( recipe.Requirements.Where((r) => r.Type == "Item" || r.Type == "Tool") ); List itemsToDelete = new List(); var output = _eventOutputHolder.GetOutput(sessionID); itemsToDelete.AddRange(body.Tools); itemsToDelete.AddRange(body.Items); foreach (var itemToDelete in itemsToDelete) { var itemToCheck = pmcData.Inventory.Items.FirstOrDefault(i => i.Id == itemToDelete.Id); var requirement = recipeRequirementsClone.FirstOrDefault( requirement => requirement.TemplateId == itemToCheck.Template ); // Handle tools not having a `count`, but always only requiring 1 var requiredCount = requirement.Count ?? 1; if (requiredCount <= 0) { continue; } _inventoryHelper.RemoveItemByCount(pmcData, itemToDelete.Id, requiredCount, sessionID, output); // Tools don't have a count if (requirement.Type != "Tool") { requirement.Count -= (int)itemToDelete.Count; } } return output; } public ItemEventRouterResponse ScavCaseProductionStart(PmcData pmcData, HideoutScavCaseStartRequestData body, string sessionID) { var output = _eventOutputHolder.GetOutput(sessionID); foreach (var requestedItem in body.Items) { var inventoryItem = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == requestedItem.Id); if (inventoryItem is null) { _logger.Error( _localisationService.GetText( "hideout-unable_to_find_scavcase_requested_item_in_profile_inventory", requestedItem.Id ) ); return _httpResponseUtil.AppendErrorToOutput(output); } if (inventoryItem.Upd?.StackObjectsCount is not null && inventoryItem.Upd.StackObjectsCount > requestedItem.Count) { inventoryItem.Upd.StackObjectsCount -= requestedItem.Count; } else { _inventoryHelper.RemoveItem(pmcData, requestedItem.Id, sessionID, output); } } var recipe = _databaseService.GetHideout().Production.ScavRecipes.FirstOrDefault(r => r.Id == body.RecipeId); if (recipe is null) { _logger.Error( _localisationService.GetText("hideout-unable_to_find_scav_case_recipie_in_database", body.RecipeId) ); return _httpResponseUtil.AppendErrorToOutput(output); } // @Important: Here we need to be very exact: // - normal recipe: Production time value is stored in attribute "productionTime" with small "p" // - scav case recipe: Production time value is stored in attribute "ProductionTime" with capital "P" var adjustedCraftTime = recipe.ProductionTime - _hideoutHelper.GetSkillProductionTimeReduction( pmcData, recipe.ProductionTime ?? 0, SkillTypes.Crafting, _databaseService.GetGlobals().Configuration.SkillsSettings.Crafting.CraftTimeReductionPerLevel ?? 0 ); var modifiedScavCaseTime = GetScavCaseTime(pmcData, adjustedCraftTime); pmcData.Hideout.Production[body.RecipeId] = _hideoutHelper.InitProduction( body.RecipeId, (int)(_profileHelper.IsDeveloperAccount(sessionID) ? 40 : modifiedScavCaseTime), false ); pmcData.Hideout.Production[body.RecipeId].SptIsScavCase = true; return output; } private double? GetScavCaseTime(PmcData pmcData, double? productionTime) { var fenceLevel = _fenceService.GetFenceInfo(pmcData); if (fenceLevel is null) { return productionTime; } return productionTime * fenceLevel.ScavCaseTimeModifier; } public void AddScavCaseRewardsToProfile(PmcData pmcData, List rewards, string recipeId) { pmcData.Hideout.Production[$"ScavCase{recipeId}"] = new Production { Products = rewards, RecipeId = recipeId }; } public ItemEventRouterResponse ContinuousProductionStart(PmcData pmcData, HideoutContinuousProductionStartRequestData request, string sessionID) { _hideoutHelper.RegisterProduction(pmcData, request, sessionID); return _eventOutputHolder.GetOutput(sessionID); } public ItemEventRouterResponse TakeProduction(PmcData pmcData, HideoutTakeProductionRequestData request, string sessionID) { var output = _eventOutputHolder.GetOutput(sessionID); var hideoutDb = _databaseService.GetHideout(); if (request.RecipeId == HideoutHelper.BitcoinFarm) { // Ensure server and client are in-sync when player presses 'get items' on farm _hideoutHelper.UpdatePlayerHideout(sessionID); _hideoutHelper.GetBTC(pmcData, request, sessionID, output); return output; } var recipe = hideoutDb.Production.Recipes.FirstOrDefault(r => r.Id == request.RecipeId); if (recipe is not null) { HandleRecipe(sessionID, recipe, pmcData, request, output); return output; } var scavCase = hideoutDb.Production.ScavRecipes.FirstOrDefault(r => r.Id == request.RecipeId); if (scavCase is not null) { HandleScavCase(sessionID, pmcData, request, output); return output; } _logger.Error( _localisationService.GetText( "hideout-unable_to_find_production_in_profile_by_recipie_id", request.RecipeId ) ); return _httpResponseUtil.AppendErrorToOutput(output); } private void HandleRecipe(string sessionID, HideoutProduction recipe, PmcData pmcData, HideoutTakeProductionRequestData request, ItemEventRouterResponse output) { // Validate that we have a matching production var productionDict = pmcData.Hideout.Production; string? prodId = null; foreach (var production in productionDict) { // Skip undefined production objects if (production.Value is null) { continue; } if (_hideoutHelper.IsProductionType(production.Value)) { // Production or ScavCase if (production.Value.RecipeId == request.RecipeId) { prodId = production.Key; // Set to objects key break; } } } // If we're unable to find the production, send an error to the client if (prodId is null) { _logger.Error( _localisationService.GetText( "hideout-unable_to_find_production_in_profile_by_recipie_id", request.RecipeId ) ); _httpResponseUtil.AppendErrorToOutput( output, _localisationService.GetText( "hideout-unable_to_find_production_in_profile_by_recipie_id", request.RecipeId ) ); return; } // Variables for managemnet of skill var craftingExpAmount = 0; var counterHoursCrafting = GetHoursCraftingTaskConditionCounter(pmcData, recipe); var hoursCrafting = counterHoursCrafting.Value; // Array of arrays of item + children List> itemAndChildrenToSendToPlayer = []; // Reward is weapon/armor preset, handle differently compared to 'normal' items var rewardIsPreset = _presetHelper.HasPreset(recipe.EndProduct); if (rewardIsPreset) { var defaultPreset = _presetHelper.GetDefaultPreset(recipe.EndProduct); // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory List presetAndMods = _itemHelper.ReplaceIDs(defaultPreset.Items); _itemHelper.RemapRootItemId(presetAndMods); // Store preset items in array itemAndChildrenToSendToPlayer = [presetAndMods]; } var rewardIsStackable = _itemHelper.IsItemTplStackable(recipe.EndProduct); if (rewardIsStackable ?? false) { // Create root item Item rewardToAdd = new Item { Id = _hashUtil.Generate(), Template = recipe.EndProduct, Upd = new Upd { StackObjectsCount = recipe.Count }, }; // Split item into separate items with acceptable stack sizes var splitReward = _itemHelper.SplitStackIntoSeparateItems(rewardToAdd); itemAndChildrenToSendToPlayer.AddRange(splitReward); } else { // Not stackable, may have to send send multiple of reward // Add the first reward item to array when not a preset (first preset added above earlier) if (!rewardIsPreset) { itemAndChildrenToSendToPlayer.Add([new Item { Id = _hashUtil.Generate(), Template = recipe.EndProduct }]); } // Add multiple of item if recipe requests it // Start index at one so we ignore first item in array var countOfItemsToReward = recipe.Count; for (var index = 1; index < countOfItemsToReward; index++) { List itemAndMods = _itemHelper.ReplaceIDs(itemAndChildrenToSendToPlayer.FirstOrDefault()); itemAndChildrenToSendToPlayer.AddRange([itemAndMods]); } } // Recipe has an `isEncoded` requirement for reward(s), Add `RecodableComponent` property if (recipe.IsEncoded ?? false) { foreach (var reward in itemAndChildrenToSendToPlayer) { _itemHelper.AddUpdObjectToItem(reward.FirstOrDefault()); reward.FirstOrDefault().Upd.RecodableComponent = new UpdRecodableComponent { IsEncoded = true }; } } // Build an array of the tools that need to be returned to the player List> toolsToSendToPlayer = []; var hideoutProduction = pmcData.Hideout.Production[prodId]; if (hideoutProduction.SptRequiredTools?.Count > 0) { foreach (var tool in hideoutProduction.SptRequiredTools) { toolsToSendToPlayer.AddRange([tool]); } } // Check if the recipe is the same as the last one - get bonus when crafting same thing multiple times var area = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == recipe.AreaType); if (area is not null && request.RecipeId != area.LastRecipe) { // 1 point per craft upon the end of production for alternating between 2 different crafting recipes in the same module craftingExpAmount += _hideoutConfig.ExpCraftAmount; // Default is 10 } // Update variable with time spent crafting item(s) // 1 point per 8 hours of crafting hoursCrafting += recipe.ProductionTime; if (hoursCrafting / _hideoutConfig.HoursForSkillCrafting >= 1) { // Spent enough time crafting to get a bonus xp multipler var multiplierCrafting = Math.Floor((double)hoursCrafting / _hideoutConfig.HoursForSkillCrafting); craftingExpAmount += (int)(1 * multiplierCrafting); hoursCrafting -= _hideoutConfig.HoursForSkillCrafting * multiplierCrafting; } // Make sure we can fit both the craft result and tools in the stash var totalResultItems = new List>(); totalResultItems.AddRange(itemAndChildrenToSendToPlayer); totalResultItems.AddRange(toolsToSendToPlayer); if (!_inventoryHelper.CanPlaceItemsInInventory(sessionID, totalResultItems)) { _httpResponseUtil.AppendErrorToOutput( output, _localisationService.GetText("inventory-no_stash_space"), BackendErrorCodes.NotEnoughSpace ); return; } // Add the tools to the stash, we have to do this individually due to FiR state potentially being different foreach (var toolItem in toolsToSendToPlayer) { // Note: FIR state will be based on the first item's SpawnedInSession property per item group AddItemsDirectRequest addToolsRequest = new AddItemsDirectRequest { ItemsWithModsToAdd = [toolItem], FoundInRaid = toolItem[0].Upd?.SpawnedInSession ?? false, UseSortingTable = false, Callback = null, }; _inventoryHelper.AddItemsToStash(sessionID, addToolsRequest, pmcData, output); if (output.Warnings?.Count > 0) { return; } } // Add the crafting result to the stash, marked as FiR AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest { ItemsWithModsToAdd = itemAndChildrenToSendToPlayer, FoundInRaid = true, UseSortingTable = false, Callback = null, }; _inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output); if (output.Warnings?.Count > 0) { return; } // - increment skill point for crafting // - delete the production in profile Hideout.Production // Hideout Management skill // ? use a configuration variable for the value? var globals = _databaseService.GetGlobals(); _profileHelper.AddSkillPointsToPlayer( pmcData, SkillTypes.HideoutManagement, globals.Configuration.SkillsSettings.HideoutManagement.SkillPointsPerCraft, true ); // Add Crafting skill to player profile if (craftingExpAmount > 0) { _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.Crafting, craftingExpAmount); var intellectAmountToGive = 0.5 * Math.Round((double)(craftingExpAmount / 15)); if (intellectAmountToGive > 0) { _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.Intellect, intellectAmountToGive); } } area.LastRecipe = request.RecipeId; // Update profiles hours crafting value counterHoursCrafting.Value = hoursCrafting; // Continuous crafts have special handling in EventOutputHolder.updateOutputProperties() pmcData.Hideout.Production[prodId].SptIsComplete = true; pmcData.Hideout.Production[prodId].SptIsContinuous = recipe.Continuous; // Continious recipies need the craft time refreshed as it gets created once on initial craft and stays the same regardless of what // production.json is set to if (recipe.Continuous ?? false) { pmcData.Hideout.Production[prodId].ProductionTime = _hideoutHelper.GetAdjustedCraftTimeWithSkills( pmcData, recipe.Id, true ); } // Flag normal (non continious) crafts as complete if (!recipe.Continuous ?? false) { pmcData.Hideout.Production[prodId].InProgress = false; } } private TaskConditionCounter GetHoursCraftingTaskConditionCounter(PmcData pmcData, HideoutProduction recipe) { var counterHoursCrafting = pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId]; if (counterHoursCrafting is null) { // Doesn't exist, create pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId] = new TaskConditionCounter { Id = recipe.Id, Type = HideoutController.NameTaskConditionCountersCraftingId, SourceId = "CounterCrafting", Value = 0, }; counterHoursCrafting = pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId]; } return counterHoursCrafting; } private void HandleScavCase(string sessionID, PmcData pmcData, HideoutTakeProductionRequestData request, ItemEventRouterResponse output) { var ongoingProductions = pmcData.Hideout.Production; string? prodId = null; foreach (var production in ongoingProductions) { if (_hideoutHelper.IsProductionType(production.Value)) { // Production or ScavCase if ((production.Value).RecipeId == request.RecipeId) { prodId = production.Key; // Set to objects key break; } } } if (prodId == null) { _logger.Error( _localisationService.GetText( "hideout-unable_to_find_production_in_profile_by_recipie_id", request.RecipeId ) ); _httpResponseUtil.AppendErrorToOutput(output); return; } // Create rewards for scav case var scavCaseRewards = _scavCaseRewardGenerator.Generate(request.RecipeId); AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest { ItemsWithModsToAdd = scavCaseRewards, FoundInRaid = true, Callback = null, UseSortingTable = false, }; _inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output); if (output.Warnings?.Count > 0) { return; } // Remove the old production from output object before its sent to client output.ProfileChanges[sessionID].Production.Remove(request.RecipeId); // Flag as complete - will be cleaned up later by hideoutController.update() pmcData.Hideout.Production[prodId].SptIsComplete = true; // Crafting complete, flag pmcData.Hideout.Production[prodId].InProgress = false; } public void HandleQTEEventOutcome(string sessionId, PmcData pmcData, HandleQTEEventRequestData request, ItemEventRouterResponse output) { // { // "Action": "HideoutQuickTimeEvent", // "results": [true, false, true, true, true, true, true, true, true, false, false, false, false, false, false], // "id": "63b16feb5d012c402c01f6ef", // "timestamp": 1672585349 // } // Skill changes are done in // /client/hideout/workout (applyWorkoutChanges). var qteDb = _databaseService.GetHideout().Qte; var relevantQte = qteDb.FirstOrDefault(qte => qte.Id == request.Id); foreach (var outcome in request.Results) { if (outcome) { // Success pmcData.Health.Energy.Current += relevantQte.Results[QteEffectType.singleSuccessEffect].Energy; pmcData.Health.Hydration.Current += relevantQte.Results[QteEffectType.singleSuccessEffect].Hydration; } else { // Failed pmcData.Health.Energy.Current += relevantQte.Results[QteEffectType.singleFailEffect].Energy; pmcData.Health.Hydration.Current += relevantQte.Results[QteEffectType.singleFailEffect].Hydration; } } if (pmcData.Health.Energy.Current < 1) { pmcData.Health.Energy.Current = 1; } if (pmcData.Health.Hydration.Current < 1) { pmcData.Health.Hydration.Current = 1; } HandleMusclePain(pmcData, relevantQte.Results[QteEffectType.finishEffect]); } private void HandleMusclePain(PmcData pmcData, QteResult finishEffect) { var hasMildPain = pmcData.Health.BodyParts["Chest"].Effects?["MildMusclePain"]; var hasSeverePain = pmcData.Health.BodyParts["Chest"].Effects?["SevereMusclePain"]; // Has no muscle pain at all, add mild if (hasMildPain is null && hasSeverePain is null) { // nullguard pmcData.Health.BodyParts["Chest"].Effects ??= new Dictionary(); pmcData.Health.BodyParts["Chest"].Effects["MildMusclePain"] = new BodyPartEffectProperties { Time = finishEffect.RewardEffects.FirstOrDefault().Time, // TODO - remove hard coded access, get value properly }; return; } if (hasMildPain is not null) { // Already has mild pain, remove mild and add severe pmcData.Health.BodyParts["Chest"].Effects.Remove("MildMusclePain"); pmcData.Health.BodyParts["Chest"].Effects["SevereMusclePain"] = new BodyPartEffectProperties { Time = finishEffect.RewardEffects.FirstOrDefault().Time, }; } } public void RecordShootingRangePoints(string sessionId, PmcData pmcData, RecordShootingRangePoints request) { var shootingRangeKey = "ShootingRangePoints"; var overallCounterItems = pmcData.Stats.Eft.OverallCounters.Items; // Find counter by key var shootingRangeHighScore = overallCounterItems.FirstOrDefault((counter) => counter.Key.Contains(shootingRangeKey)); if (shootingRangeHighScore is null) { // Counter not found, add blank one overallCounterItems.Add(new CounterKeyValue { Key = [shootingRangeKey], Value = 0 }); shootingRangeHighScore = overallCounterItems.FirstOrDefault((counter) => counter.Key.Contains(shootingRangeKey)); } shootingRangeHighScore.Value = request.Points; } public ItemEventRouterResponse ImproveArea(string sessionId, PmcData pmcData, HideoutImproveAreaRequestData request) { var output = _eventOutputHolder.GetOutput(sessionId); // Create mapping of required item with corrisponding item from player inventory var items = request.Items.Select( (reqItem) => { var item = pmcData.Inventory.Items.FirstOrDefault(invItem => invItem.Id == reqItem.Id); return new { inventoryItem = item, requestedItem = reqItem }; } ); // If it's not money, its construction / barter items foreach (var item in items) { if (item.inventoryItem is null) { _logger.Error( _localisationService.GetText("hideout-unable_to_find_item_in_inventory", item.requestedItem.Id) ); return _httpResponseUtil.AppendErrorToOutput(output); } if ( _paymentHelper.IsMoneyTpl(item.inventoryItem.Template) && item.inventoryItem.Upd is not null && item.inventoryItem.Upd.StackObjectsCount is not null && item.inventoryItem.Upd.StackObjectsCount > item.requestedItem.Count ) { item.inventoryItem.Upd.StackObjectsCount -= item.requestedItem.Count; } else { _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionId, output); } } var profileHideoutArea = pmcData.Hideout.Areas.FirstOrDefault(x => x.Type == request.AreaType); if (profileHideoutArea is null) { _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); return _httpResponseUtil.AppendErrorToOutput(output); } var hideoutDbData = _databaseService.GetHideout().Areas.FirstOrDefault((area) => area.Type == request.AreaType); if (hideoutDbData is null) { _logger.Error( _localisationService.GetText("hideout-unable_to_find_area_in_database", request.AreaType) ); return _httpResponseUtil.AppendErrorToOutput(output); } // Add all improvemets to output object var improvements = hideoutDbData.Stages[profileHideoutArea.Level.ToString()].Improvements; var timestamp = _timeUtil.GetTimeStamp(); if (output.ProfileChanges[sessionId].Improvements is null) { output.ProfileChanges[sessionId].Improvements = new Dictionary(); } foreach (var improvement in improvements) { var improvementDetails = new HideoutImprovement { Completed = false, ImproveCompleteTimestamp = (long)(timestamp + improvement.ImprovementTime), }; output.ProfileChanges[sessionId].Improvements[improvement.Id] = improvementDetails; pmcData.Hideout.Improvements ??= new Dictionary(); pmcData.Hideout.Improvements[improvement.Id] = improvementDetails; } return output; } public ItemEventRouterResponse CancelProduction(string sessionId, PmcData pmcData, HideoutCancelProductionRequestData request) { var output = _eventOutputHolder.GetOutput(sessionId); var craftToCancel = pmcData.Hideout.Production[request.RecipeId]; if (craftToCancel is null) { var errorMessage = $"Unable to find craft {request.RecipeId} to cancel"; _logger.Error(errorMessage); return _httpResponseUtil.AppendErrorToOutput(output, errorMessage); } // Null out production data so client gets informed when response send back pmcData.Hideout.Production[request.RecipeId] = null; // TODO - handle timestamp somehow? return output; } public ItemEventRouterResponse CicleOfCultistProductionStart(string sessionId, PmcData pmcData, HideoutCircleOfCultistProductionStartRequestData request) { return _circleOfCultistService.StartSacrifice(sessionId, pmcData, request); } public ItemEventRouterResponse HideoutDeleteProductionCommand(string sessionId, PmcData pmcData, HideoutDeleteProductionRequestData request) { var output = _eventOutputHolder.GetOutput(sessionId); pmcData.Hideout.Production[request.RecipeId] = null; output.ProfileChanges[sessionId].Production = null; return output; } public ItemEventRouterResponse HideoutCustomizationApply(string sessionId, PmcData pmcData, HideoutCustomizationApplyRequestData request) { var output = _eventOutputHolder.GetOutput(sessionId); var itemDetails = _databaseService .GetHideout() .Customisation.Globals.FirstOrDefault((cust) => cust.Id == request.OfferId); if (itemDetails is null) { _logger.Error($"Unable to find customisation: {request.OfferId} in db, cannot apply to hideout"); return output; } // pmcData.Hideout.Customization[GetHideoutCustomisationType(itemDetails.Type)]; // this is in the Node server, doesnt do anything return output; } private string? GetHideoutCustomisationType(string? type) { switch (type) { case "wall": return "Wall"; case "floor": return "Floor"; case "light": return "Light"; case "ceiling": return "Ceiling"; case "shootingRangeMark": return "ShootingRangeMark"; default: _logger.Warning($"Unknown {type}, unable to map"); return type; } } private void AddMissingPresetStandItemsToProfile(string sessionId, Stage equipmentPresetStage, PmcData pmcData, HideoutArea equipmentPresetHideoutArea, ItemEventRouterResponse output) { // Each slot is a single Mannequin var slots = _itemHelper.GetItem(equipmentPresetStage.Container).Value.Properties.Slots; foreach (var mannequinSlot in slots) { // Chek if we've already added this manniquin var existingMannequin = pmcData.Inventory.Items.FirstOrDefault( (item) => item.ParentId == equipmentPresetHideoutArea.Id && item.SlotId == mannequinSlot.Name ); // No child, add it if (existingMannequin is null) { var standId = _hashUtil.Generate(); var mannequinToAdd = new Item { Id = standId, Template = ItemTpl.INVENTORY_DEFAULT, ParentId = equipmentPresetHideoutArea.Id, SlotId = mannequinSlot.Name, }; pmcData.Inventory.Items.Add(mannequinToAdd); // Add pocket child item var mannequinPocketItemToAdd = new Item { Id = _hashUtil.Generate(), Template = pmcData.Inventory.Items.FirstOrDefault( item => item.SlotId == "Pockets" && item.ParentId == pmcData.Inventory.Equipment ) .Template, // Same pocket tpl as players profile (unheard get bigger, matching pockets etc) ParentId = standId, SlotId = "Pockets", }; pmcData.Inventory.Items.Add(mannequinPocketItemToAdd); output.ProfileChanges[sessionId].Items.NewItems.Add(mannequinToAdd); output.ProfileChanges[sessionId].Items.NewItems.Add(mannequinPocketItemToAdd); } } } /// /// Handle HideoutCustomizationSetMannequinPose event /// /// Session id /// Player profile /// Client request /// public ItemEventRouterResponse HideoutCustomizationSetMannequinPose(string sessionId, PmcData pmcData, HideoutCustomizationSetMannequinPoseRequest request) { foreach (var poseKvP in request.Poses) { // Nullguard pmcData.Hideout.MannequinPoses ??= new Dictionary(); pmcData.Hideout.MannequinPoses[poseKvP.Key] = poseKvP.Value; } return _eventOutputHolder.GetOutput(sessionId); } public List GetQteList(string sessionId) { return _databaseService.GetHideout().Qte; } }