Files
SPT-Server-Build/Libraries/Core/Controllers/HideoutController.cs
T

1306 lines
50 KiB
C#

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<HideoutController> _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<HideoutConfig>();
public const string NameTaskConditionCountersCraftingId = "673f5d6fdd6ed700c703afdc";
protected List<HideoutAreas> _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 (!string.IsNullOrEmpty(hideoutStage?.Container))
{
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<HideoutAreas> 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<string, HideoutStashItem>();
}
// 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.ConvertToItem()],
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<IdWithCount> itemsToDelete = [];
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<Item> 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;
}
// 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<List<Item>> 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<Item> presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(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<Item> itemAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(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<List<Item>> toolsToSendToPlayer = [];
var hideoutProduction = pmcData.Hideout.Production[prodId];
if (hideoutProduction.SptRequiredTools?.Count > 0)
{
foreach (var tool in hideoutProduction.SptRequiredTools)
{
toolsToSendToPlayer.Add([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 multiplier
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<List<Item>>();
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)
{
if (!pmcData.TaskConditionCounters.TryGetValue(HideoutController.NameTaskConditionCountersCraftingId, out var _))
{
// Doesn't exist, create
pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId] = new TaskConditionCounter
{
Id = recipe.Id,
Type = HideoutController.NameTaskConditionCountersCraftingId,
SourceId = "CounterCrafting",
Value = 0,
};
}
return pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId];
}
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 (production.Value.GetType() == typeof(HideoutProduction))
{
// 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?.ContainsKey("MildMusclePain");
var hasSeverePain = pmcData.Health.BodyParts["Chest"].Effects?.ContainsKey("SevereMusclePain");
// Has no muscle pain at all, add mild
if (hasMildPain is null && hasSeverePain is null)
{
// nullguard
pmcData.Health.BodyParts["Chest"].Effects ??= new Dictionary<string, BodyPartEffectProperties>();
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<string, HideoutImprovement>();
}
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<string, HideoutImprovement>();
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)] = itemDetails.ItemId;
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);
}
}
}
/// <summary>
/// Handle HideoutCustomizationSetMannequinPose event
/// </summary>
/// <param name="sessionId">Session id</param>
/// <param name="pmcData">Player profile</param>
/// <param name="request">Client request</param>
/// <returns></returns>
public ItemEventRouterResponse HideoutCustomizationSetMannequinPose(string sessionId, PmcData pmcData, HideoutCustomizationSetMannequinPoseRequest request)
{
if (request.Poses is null)
{
_logger.Warning("this really shouldnt be possible, but a request has come in with a pose change without poses");
return _eventOutputHolder.GetOutput(sessionId);
}
foreach (var poseKvP in request.Poses)
{
// Nullguard
pmcData.Hideout.MannequinPoses ??= new Dictionary<string, string>();
pmcData.Hideout.MannequinPoses[poseKvP.Key] = poseKvP.Value;
}
return _eventOutputHolder.GetOutput(sessionId);
}
public List<QteData> GetQteList(string sessionId)
{
return _databaseService.GetHideout().Qte;
}
/**
* Function called every `hideoutConfig.runIntervalSeconds` seconds as part of onUpdate event
*/
public void Update() {
foreach (var sessionID in _saveServer.GetProfiles()) {
if (sessionID.Value.CharacterData.PmcData.Hideout is not null &&
_profileActivityService.ActiveWithinLastMinutes(
sessionID.Key,
_hideoutConfig.UpdateProfileHideoutWhenActiveWithinMinutes
)
) {
_hideoutHelper.UpdatePlayerHideout(sessionID.Key);
}
}
}
}