diff --git a/Libraries/Core/Callbacks/BotCallbacks.cs b/Libraries/Core/Callbacks/BotCallbacks.cs
index 0b9570f2..d4e13670 100644
--- a/Libraries/Core/Callbacks/BotCallbacks.cs
+++ b/Libraries/Core/Callbacks/BotCallbacks.cs
@@ -23,7 +23,6 @@ public class BotCallbacks(
///
///
///
- ///
public string GetBotLimit(string url, EmptyRequestData info, string sessionID)
{
var splitUrl = url.Split('/');
diff --git a/Libraries/Core/Callbacks/HideoutCallbacks.cs b/Libraries/Core/Callbacks/HideoutCallbacks.cs
index d385a14d..9d3838e1 100644
--- a/Libraries/Core/Callbacks/HideoutCallbacks.cs
+++ b/Libraries/Core/Callbacks/HideoutCallbacks.cs
@@ -165,8 +165,7 @@ public class HideoutCallbacks(
{
if (timeSinceLastRun > _hideoutConfig.RunIntervalSeconds)
{
- // TODO
- // _hideoutController.Update();
+ _hideoutController.Update();
return true;
}
diff --git a/Libraries/Core/Callbacks/ItemEventCallbacks.cs b/Libraries/Core/Callbacks/ItemEventCallbacks.cs
index 946ce142..30aa0c63 100644
--- a/Libraries/Core/Callbacks/ItemEventCallbacks.cs
+++ b/Libraries/Core/Callbacks/ItemEventCallbacks.cs
@@ -48,9 +48,9 @@ public class ItemEventCallbacks(HttpResponseUtil _httpResponseUtil, ItemEventRou
public int GetErrorCode(List warnings)
{
- // TODO: dont think this actually works
+ // Cast int to string to get the error code of 220 for Unknown Error.
return int.Parse((warnings[0].Code is null || warnings[0].Code == "None"
- ? (BackendErrorCodes.UnknownError).ToString()
+ ? ((int) BackendErrorCodes.UnknownError).ToString()
: warnings.FirstOrDefault()?.Code) ?? string.Empty);
}
}
diff --git a/Libraries/Core/Callbacks/MatchCallbacks.cs b/Libraries/Core/Callbacks/MatchCallbacks.cs
index b6913773..f547be19 100644
--- a/Libraries/Core/Callbacks/MatchCallbacks.cs
+++ b/Libraries/Core/Callbacks/MatchCallbacks.cs
@@ -303,6 +303,7 @@ public class MatchCallbacks(
///
public string GetRaidConfiguration(string url, GetRaidConfigurationRequestData info, string sessionID)
{
+ _matchController.ConfigureOfflineRaid(info, sessionID);
return _httpResponseUtil.NullResponse();
}
diff --git a/Libraries/Core/Controllers/BotController.cs b/Libraries/Core/Controllers/BotController.cs
index 8688e392..220fb5a7 100644
--- a/Libraries/Core/Controllers/BotController.cs
+++ b/Libraries/Core/Controllers/BotController.cs
@@ -132,15 +132,13 @@ public class BotController(
public List Generate(string sessionId, GenerateBotsRequestData info)
{
- // var pmcProfile = _profileHelper.GetPmcProfile(sessionId);
- //
- // // Use this opportunity to create and cache bots for later retrieval
- // var multipleBotTypesRequested = info.Conditions?.Count > 1;
- // return multipleBotTypesRequested
- // ? GenerateMultipleBotsAndCache(info, pmcProfile, sessionId)
- // : ReturnSingleBotFromCache(sessionId, info);
-
- return new List();
+ var pmcProfile = _profileHelper.GetPmcProfile(sessionId);
+
+ // Use this opportunity to create and cache bots for later retrieval
+ var multipleBotTypesRequested = info.Conditions?.Count > 1;
+ return multipleBotTypesRequested
+ ? GenerateMultipleBotsAndCache(info, pmcProfile, sessionId)
+ : ReturnSingleBotFromCache(sessionId, info);
}
private List GenerateMultipleBotsAndCache(GenerateBotsRequestData request, PmcData? pmcProfile, string sessionId)
@@ -205,16 +203,16 @@ public class BotController(
for (var i = 0; i < botsToGenerate; i++)
{
- try
- {
+ // try
+ // {
var detailsClone = _cloner.Clone(botGenerationDetails);
GenerateSingleBotAndStoreInCache(detailsClone, sessionId, cacheKey);
progressWriter.Increment();
- }
- catch (Exception e)
- {
- _logger.Error($"Failed to generate bot #{i + 1}: {e.Message}");
- }
+ // }
+ // catch (Exception e)
+ // {
+ // _logger.Error($"Failed to generate bot #{i + 1}: {e.Message}");
+ // }
}
_logger.Debug(
@@ -229,12 +227,6 @@ public class BotController(
var requestedBot = request.Conditions?.FirstOrDefault();
var raidSettings = GetMostRecentRaidSettings();
-
- if (raidSettings is null)
- {
- _logger.Error($"Unable to get raid settings for session {sessionId}");
- return [];
- }
// Create generation request for when cache is empty
var condition = new GenerateCondition
@@ -266,7 +258,7 @@ public class BotController(
// Does non pmc bot have a chance of being converted into a pmc
var convertIntoPmcChanceMinMax = GetPmcConversionMinMaxForLocation(
requestedBot?.Role,
- raidSettings.Location
+ raidSettings?.Location
);
if (convertIntoPmcChanceMinMax is not null && !botGenerationDetails.IsPmc.GetValueOrDefault(false))
{
@@ -356,10 +348,9 @@ public class BotController(
private MinMax? GetPmcConversionMinMaxForLocation(string? requestedBotRole, string? location)
{
- var mapSpecificConversionValues = _pmcConfig.ConvertIntoPmcChance!.GetValueOrDefault(location?.ToLower(), null);
- return mapSpecificConversionValues is null
- ? _pmcConfig.ConvertIntoPmcChance.GetByJsonProp>("default").GetByJsonProp(requestedBotRole)
- : mapSpecificConversionValues.GetByJsonProp(requestedBotRole?.ToLower());
+ return _pmcConfig.ConvertIntoPmcChance!.TryGetValue(location?.ToLower() ?? "", out var mapSpecificConversionValues)
+ ? mapSpecificConversionValues.GetByJsonProp(requestedBotRole?.ToLower())
+ : _pmcConfig.ConvertIntoPmcChance.GetValueOrDefault("default")?.GetValueOrDefault(requestedBotRole);
}
private GetRaidConfigurationRequestData? GetMostRecentRaidSettings()
@@ -408,7 +399,7 @@ public class BotController(
public int GetBotCap(string location)
{
- var botCap = _botConfig.MaxBotCap[location.ToLower()];
+ var botCap = _botConfig.MaxBotCap.FirstOrDefault(x => x.Key.ToLower() == location.ToLower());
if (location == "default")
{
_logger.Warning(
@@ -416,7 +407,7 @@ public class BotController(
);
}
- return botCap;
+ return botCap.Value;
}
public object GetAiBotBrainTypes()
diff --git a/Libraries/Core/Controllers/HideoutController.cs b/Libraries/Core/Controllers/HideoutController.cs
index 629c07c8..f2f4f604 100644
--- a/Libraries/Core/Controllers/HideoutController.cs
+++ b/Libraries/Core/Controllers/HideoutController.cs
@@ -176,7 +176,7 @@ public class HideoutController(
}
// Upgrade includes a container improvement/addition
- if (hideoutStage?.Container is not null)
+ if (!string.IsNullOrEmpty(hideoutStage?.Container))
{
AddContainerImprovementToProfile(
output,
@@ -365,7 +365,7 @@ public class HideoutController(
hideoutArea.Slots[hideoutSlotIndex].Items =
[
- new HideoutItem()
+ new HideoutItem
{
Id = item.inventoryItem.Id,
Template = item.inventoryItem.Template,
@@ -442,7 +442,7 @@ public class HideoutController(
AddItemDirectRequest request = new AddItemDirectRequest
{
- ItemWithModsToAdd = [itemToReturn],
+ ItemWithModsToAdd = [itemToReturn.ConvertToItem()],
FoundInRaid = itemToReturn.Upd?.SpawnedInSession,
Callback = null,
UseSortingTable = false,
@@ -666,7 +666,7 @@ public class HideoutController(
continue;
}
- if (_hideoutHelper.IsProductionType(production.Value))
+ if (production.Value.GetType() == typeof(HideoutProduction))
{
// Production or ScavCase
if (production.Value.RecipeId == request.RecipeId)
@@ -714,7 +714,7 @@ public class HideoutController(
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);
+ List
- presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(defaultPreset.Items));
_itemHelper.RemapRootItemId(presetAndMods);
@@ -752,7 +752,7 @@ public class HideoutController(
var countOfItemsToReward = recipe.Count;
for (var index = 1; index < countOfItemsToReward; index++)
{
- List
- itemAndMods = _itemHelper.ReplaceIDs(itemAndChildrenToSendToPlayer.FirstOrDefault());
+ List
- itemAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(itemAndChildrenToSendToPlayer.FirstOrDefault()));
itemAndChildrenToSendToPlayer.AddRange([itemAndMods]);
}
}
@@ -922,7 +922,7 @@ public class HideoutController(
string? prodId = null;
foreach (var production in ongoingProductions)
{
- if (_hideoutHelper.IsProductionType(production.Value))
+ if (production.Value.GetType() == typeof(HideoutProduction))
{
// Production or ScavCase
if ((production.Value).RecipeId == request.RecipeId)
@@ -1286,4 +1286,20 @@ public class HideoutController(
{
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);
+ }
+ }
+ }
}
diff --git a/Libraries/Core/Controllers/InRaidController.cs b/Libraries/Core/Controllers/InRaidController.cs
index d10f72e2..26e77c28 100644
--- a/Libraries/Core/Controllers/InRaidController.cs
+++ b/Libraries/Core/Controllers/InRaidController.cs
@@ -5,17 +5,13 @@ using Core.Models.Eft.InRaid;
using Core.Models.Spt.Config;
using Core.Models.Utils;
using Core.Servers;
-using Core.Services;
-
namespace Core.Controllers;
[Injectable]
public class InRaidController(
ISptLogger _logger,
- SaveServer _saveServer,
ProfileHelper _profileHelper,
- LocalisationService _localisationService,
ApplicationContext _applicationContext,
ConfigServer _configServer
)
@@ -30,7 +26,7 @@ public class InRaidController(
/// Register player request
public void AddPlayer(string sessionId, RegisterPlayerRequestData info)
{
- throw new NotImplementedException();
+ _applicationContext.AddValue(ContextVariableType.REGISTER_PLAYER_REQUEST, info);
}
///
@@ -42,7 +38,14 @@ public class InRaidController(
///
public void SavePostRaidProfileForScav(ScavSaveRequestData offRaidProfileData, string sessionId)
{
- throw new NotImplementedException();
+ var serverScavProfile = _profileHelper.GetScavProfile(sessionId);
+
+ // If equipment match overwrite existing data from update to date raid data for scavenger screen to work correctly.
+ // otherwise Scav inventory will be overwritten and break scav regeneration, breaking profile.
+ if (serverScavProfile.Inventory.Equipment == offRaidProfileData.Inventory.Equipment)
+ {
+ serverScavProfile.Inventory.Items = offRaidProfileData.Inventory.Items;
+ }
}
///
@@ -61,7 +64,7 @@ public class InRaidController(
///
public double GetTraitorScavHostileChance(string url, string sessionId)
{
- throw new NotImplementedException();
+ return _inRaidConfig.PlayerScavHostileChancePercent;
}
///
diff --git a/Libraries/Core/Controllers/InsuranceController.cs b/Libraries/Core/Controllers/InsuranceController.cs
index b95d36ed..1e269cd3 100644
--- a/Libraries/Core/Controllers/InsuranceController.cs
+++ b/Libraries/Core/Controllers/InsuranceController.cs
@@ -1,4 +1,3 @@
-using System.Runtime.InteropServices.JavaScript;
using Core.Helpers;
using Core.Models.Common;
using Core.Models.Eft.Common;
diff --git a/Libraries/Core/Controllers/InventoryController.cs b/Libraries/Core/Controllers/InventoryController.cs
index fd6ca60f..370b060d 100644
--- a/Libraries/Core/Controllers/InventoryController.cs
+++ b/Libraries/Core/Controllers/InventoryController.cs
@@ -293,7 +293,7 @@ public class InventoryController(
UseSortingTable = true
};
_inventoryHelper.AddItemsToStash(sessionId, addItemsRequest, pmcData, output);
- if (output.Warnings.Count > 0) return;
+ if (output.Warnings?.Count > 0) return;
}
// Find and delete opened container item from player inventory
@@ -362,7 +362,7 @@ public class InventoryController(
public void ExamineItem(PmcData pmcData, InventoryExamineRequestData request, string sessionId,
ItemEventRouterResponse output)
{
- var itemId = "";
+ string? itemId = null;
if (request.FromOwner is not null)
{
try
@@ -375,17 +375,29 @@ public class InventoryController(
}
// get hideout item
- if (request.FromOwner.Type == "HideoutProduction") itemId = request.Item;
+ if (request.FromOwner.Type == "HideoutProduction")
+ {
+ itemId = request.Item;
+ }
}
if (itemId is null)
{
// item template
- if (_databaseService.GetItems().ContainsKey(request.Item)) itemId = request.Item;
+ if (_databaseService.GetItems().ContainsKey(request.Item))
+ {
+ itemId = request.Item;
+ }
+ }
+ if (itemId is null)
+ {
// Player inventory
var target = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == request.Item);
- if (target is not null) itemId = target.Template;
+ if (target is not null)
+ {
+ itemId = target.Template;
+ }
}
if (itemId is not null)
@@ -440,14 +452,14 @@ public class InventoryController(
// Remove kvp from requested fast panel index
// TODO - does this work
- pmcData.Inventory.FastPanel.Remove(request.Index.ToString());
+ pmcData.Inventory.FastPanel.Remove(request.Index);
}
public void BindItem(PmcData pmcData, InventoryBindRequestData bindRequest, string sessionId,
ItemEventRouterResponse output)
{
foreach (var kvp in pmcData.Inventory.FastPanel
- .Where(kvp => kvp.Value == bindRequest.Index.Value.ToString()))
+ .Where(kvp => kvp.Value == bindRequest.Index))
{
pmcData.Inventory.FastPanel.Remove(kvp.Key);
@@ -455,7 +467,7 @@ public class InventoryController(
}
// Create link between fast panel slot and requested item
- pmcData.Inventory.FastPanel[bindRequest.Index.ToString()] = bindRequest.Item;
+ pmcData.Inventory.FastPanel[bindRequest.Index] = bindRequest.Item;
}
public ItemEventRouterResponse TagItem(PmcData pmcData, InventoryTagRequestData request, string sessionId)
@@ -764,7 +776,7 @@ public class InventoryController(
return;
}
- var profileToRemoveItemFrom = request?.FromOwner.Id == pmcData.Id
+ var profileToRemoveItemFrom = request.FromOwner is null || request.FromOwner?.Id == pmcData.Id
? pmcData
: _profileHelper.GetFullProfile(sessionId).CharacterData.ScavData;
diff --git a/Libraries/Core/Controllers/LocationController.cs b/Libraries/Core/Controllers/LocationController.cs
index 030e6eac..4b8a0342 100644
--- a/Libraries/Core/Controllers/LocationController.cs
+++ b/Libraries/Core/Controllers/LocationController.cs
@@ -59,9 +59,9 @@ public class LocationController(
///
///
///
- public GetAirdropLootResponse GetAirDropLoot(GetAirdropLootRequest request)
+ public GetAirdropLootResponse? GetAirDropLoot(GetAirdropLootRequest? request)
{
- if (request.ContainerId is not null)
+ if (request?.ContainerId is not null)
{
return _airdropService.GenerateCustomAirdropLoot(request);
}
diff --git a/Libraries/Core/Controllers/NoteController.cs b/Libraries/Core/Controllers/NoteController.cs
index 2345718c..bcabb0f0 100644
--- a/Libraries/Core/Controllers/NoteController.cs
+++ b/Libraries/Core/Controllers/NoteController.cs
@@ -23,6 +23,9 @@ public class NoteController(
NoteActionData body,
string sessionId)
{
+ Note newNote = new Note { Time = body.Note.Time, Text = body.Note.Text };
+ pmcData.Notes.DataNotes.Add(newNote);
+
return _eventOutputHolder.GetOutput(sessionId);
}
@@ -38,6 +41,10 @@ public class NoteController(
NoteActionData body,
string sessionId)
{
+ Note noteToEdit = pmcData.Notes.DataNotes[body.Index!.Value];
+ noteToEdit.Time = body.Note.Time;
+ noteToEdit.Text = body.Note.Text;
+
return _eventOutputHolder.GetOutput(sessionId);
}
diff --git a/Libraries/Core/Controllers/NotifierController.cs b/Libraries/Core/Controllers/NotifierController.cs
index b10ee4e5..52c55f7f 100644
--- a/Libraries/Core/Controllers/NotifierController.cs
+++ b/Libraries/Core/Controllers/NotifierController.cs
@@ -1,6 +1,9 @@
using SptCommon.Annotations;
using Core.Helpers;
using Core.Models.Eft.Notifier;
+using static System.Runtime.InteropServices.JavaScript.JSType;
+using Core.Services;
+using System.Diagnostics.Tracing;
namespace Core.Controllers;
@@ -8,7 +11,7 @@ namespace Core.Controllers;
public class NotifierController(
HttpServerHelper _httpServerHelper,
NotifierHelper _notifierHelper
-)
+ )
{
///
/// Resolve an array of session notifications.
@@ -20,7 +23,47 @@ public class NotifierController(
///
public async Task NotifyAsync(string sessionId)
{
- throw new NotImplementedException();
+ // TODO: Finish implementation of the NotifyAsync method
+ //
+ //return new Promise((resolve) => {
+ // // keep track of our timeout
+ // let counter = 0;
+
+ // /**
+ // * Check for notifications, resolve if any, otherwise poll
+ // * intermittently for a period of time.
+ // */
+ // var checkNotifications = () => {
+ // /**
+ // * If there are no pending messages we should either check again later
+ // * or timeout now with a default response.
+ // */
+ // if (!_notificationService.Has(sessionID)) {
+ // // have we exceeded timeout? if so reply with default ping message
+ // if (counter > _timeout) {
+ // return resolve([_notifierHelper.getDefaultNotification()]);
+ // }
+
+ // // check again
+ // setTimeout(checkNotifications, _pollInterval);
+
+ // // update our timeout counter
+ // counter += _pollInterval;
+ // return;
+ // }
+
+ // /**
+ // * Maintaining array reference is not necessary, so we can just copy and reinitialize
+ // */
+ // var messages = _notificationService.Get(sessionID);
+
+ // _notificationService.UpdateMessageOnQueue(sessionID, []);
+ // resolve(messages);
+ //};
+
+ // immediately check
+ // checkNotifications();
+ //});
}
///
diff --git a/Libraries/Core/Controllers/QuestController.cs b/Libraries/Core/Controllers/QuestController.cs
index f34b7964..592ddad9 100644
--- a/Libraries/Core/Controllers/QuestController.cs
+++ b/Libraries/Core/Controllers/QuestController.cs
@@ -1,4 +1,4 @@
-using System.Runtime.InteropServices.JavaScript;
+using System.Text.Json;
using SptCommon.Annotations;
using Core.Helpers;
using Core.Models.Eft.Common;
@@ -13,6 +13,7 @@ using Core.Servers;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;
+using SptCommon.Extensions;
namespace Core.Controllers;
@@ -218,7 +219,7 @@ public class QuestController(
{
if (condition.Id == handoverQuestRequest.ConditionId && handoverQuestTypes.Contains(condition.ConditionType))
{
- handedInCount = int.Parse((string)condition.Value);
+ handedInCount = int.Parse(condition.Value.ToString());
isItemHandoverQuest = condition.ConditionType == handoverQuestTypes.FirstOrDefault();
handoverRequirements = condition;
diff --git a/Libraries/Core/Controllers/RagfairController.cs b/Libraries/Core/Controllers/RagfairController.cs
index 8febe3a6..017bbf5c 100644
--- a/Libraries/Core/Controllers/RagfairController.cs
+++ b/Libraries/Core/Controllers/RagfairController.cs
@@ -15,6 +15,9 @@ using Core.Models.Spt.Config;
using Core.Models.Common;
using Core.Models.Eft.Trade;
using Core.Generators;
+using System.Xml.Linq;
+using System;
+using Core.Models.Spt.Services;
namespace Core.Controllers;
@@ -512,9 +515,95 @@ public class RagfairController
* @param output Response to send to client
* @returns IItemEventRouterResponse
*/
- private ItemEventRouterResponse CreateMultiOffer(string sessionId, AddOfferRequestData offerRequest, SptProfile fullProfile, ItemEventRouterResponse output)
+ private ItemEventRouterResponse CreateMultiOffer(string sessionID, AddOfferRequestData offerRequest, SptProfile fullProfile, ItemEventRouterResponse output)
{
- throw new NotImplementedException();
+ var pmcData = fullProfile.CharacterData.PmcData;
+ var itemsToListCount = offerRequest.Items.Count; // Does not count stack size, only items
+
+ // multi-offers are all the same item,
+ // Get first item and its children and use as template
+ var firstListingAndChidren = _itemHelper.FindAndReturnChildrenAsItems(
+ pmcData.Inventory.Items,
+ offerRequest.Items[0]);
+
+ // Find items to be listed on flea (+ children) from player inventory
+ var result = GetItemsToListOnFleaFromInventory(pmcData, offerRequest.Items);
+ if (result.Items is null || !string.IsNullOrEmpty(result.ErrorMessage))
+ {
+ _httpResponseUtil.AppendErrorToOutput(output, result.ErrorMessage);
+ }
+
+ // Total count of items summed using their stack counts
+ var stackCountTotal = _ragfairOfferHelper.GetTotalStackCountSize(result.Items);
+
+ // When listing identical items on flea, condense separate items into one stack with a merged stack count
+ // e.g. 2 ammo items, stackObjectCount = 3 for each, will result in 1 stack of 6
+
+ firstListingAndChidren[0].Upd ??= new Upd{ };
+
+ firstListingAndChidren[0].Upd.StackObjectsCount = stackCountTotal;
+
+ // Create flea object
+ var offer = CreatePlayerOffer(sessionID, offerRequest.Requirements, firstListingAndChidren, false);
+
+ // This is the item that will be listed on flea, has merged stackObjectCount
+ var newRootOfferItem = offer.Items[0];
+
+ // Average offer price for single item (or whole weapon)
+ var averages = GetItemMinAvgMaxFleaPriceValues(new GetMarketPriceRequestData{ TemplateId = offer.Items[0].Template });
+ var averageOfferPrice = averages.Avg;
+
+ // Check for and apply item price modifer if it exists in config
+ if (_ragfairConfig.Dynamic.ItemPriceMultiplier.TryGetValue(newRootOfferItem.Template, out var itemPriceModifer))
+ {
+ averageOfferPrice *= itemPriceModifer;
+ }
+
+ // Get average of item+children quality
+ var qualityMultiplier = _itemHelper.GetItemQualityModifierForItems(offer.Items, true);
+
+ // Multiply single item price by quality
+ averageOfferPrice *= qualityMultiplier;
+
+ // Get price player listed items for in roubles
+ var playerListedPriceInRub = CalculateRequirementsPriceInRub(offerRequest.Requirements);
+
+ // Roll sale chance
+ var sellChancePercent = _ragfairSellHelper.CalculateSellChance(
+ averageOfferPrice.Value,
+ playerListedPriceInRub,
+ qualityMultiplier);
+
+ // Create array of sell times for items listed
+ offer.SellResults = _ragfairSellHelper.RollForSale(sellChancePercent, (int)stackCountTotal);
+
+ // Subtract flea market fee from stash
+ if (_ragfairConfig.Sell.Fees)
+ {
+ var taxFeeChargeFailed = ChargePlayerTaxFee(
+ sessionID,
+ newRootOfferItem,
+ pmcData,
+ playerListedPriceInRub,
+ (int)stackCountTotal,
+ offerRequest,
+ output);
+ if (taxFeeChargeFailed)
+ {
+ return output;
+ }
+ }
+
+ // Add offer to players profile + add to client response
+ fullProfile.CharacterData.PmcData.RagfairInfo.Offers.Add(offer);
+ output.ProfileChanges[sessionID].RagFairOffers.Add(offer);
+
+ // Remove items from inventory after creating offer
+ foreach (var itemToRemove in offerRequest.Items) {
+ _inventoryHelper.RemoveItem(pmcData, itemToRemove, sessionID, output);
+ }
+
+ return output;
}
/**
@@ -527,9 +616,94 @@ public class RagfairController
* @param output Response to send to client
* @returns IItemEventRouterResponse
*/
- private ItemEventRouterResponse CreatePackOffer(string sessionId, AddOfferRequestData offerRequest, SptProfile fullProfile, ItemEventRouterResponse output)
+ private ItemEventRouterResponse CreatePackOffer(string sessionID, AddOfferRequestData offerRequest, SptProfile fullProfile, ItemEventRouterResponse output)
{
- throw new NotImplementedException();
+ var pmcData = fullProfile.CharacterData.PmcData;
+ var itemsToListCount = offerRequest.Items.Count; // Does not count stack size, only items
+
+ // multi-offers are all the same item,
+ // Get first item and its children and use as template
+ var firstListingAndChidren = _itemHelper.FindAndReturnChildrenAsItems(
+ pmcData.Inventory.Items,
+ offerRequest.Items[0]);
+
+ // Find items to be listed on flea (+ children) from player inventory
+ var result = GetItemsToListOnFleaFromInventory(pmcData, offerRequest.Items);
+ if (result.Items is null || result.ErrorMessage is not null)
+ {
+ _httpResponseUtil.AppendErrorToOutput(output, result.ErrorMessage);
+ }
+
+ // Total count of items summed using their stack counts
+ var stackCountTotal = _ragfairOfferHelper.GetTotalStackCountSize(result.Items);
+
+ // When listing identical items on flea, condense separate items into one stack with a merged stack count
+ // e.g. 2 ammo items, stackObjectCount = 3 for each, will result in 1 stack of 6
+ firstListingAndChidren[0].Upd ??= new Upd { };
+
+ firstListingAndChidren[0].Upd.StackObjectsCount = stackCountTotal;
+
+ // Create flea object
+ var offer = CreatePlayerOffer(sessionID, offerRequest.Requirements, firstListingAndChidren, true);
+
+ // This is the item that will be listed on flea, has merged stackObjectCount
+ var newRootOfferItem = offer.Items[0];
+
+ // Single price for an item
+ var averages = GetItemMinAvgMaxFleaPriceValues( new GetMarketPriceRequestData{ TemplateId = firstListingAndChidren[0].Template });
+ var singleItemPrice = averages.Avg;
+
+ // Check for and apply item price modifer if it exists in config
+ if (_ragfairConfig.Dynamic.ItemPriceMultiplier.TryGetValue(newRootOfferItem.Template, out double itemPriceModifer))
+ {
+ singleItemPrice *= itemPriceModifer;
+ }
+
+ // Get average of item+children quality
+ var qualityMultiplier = _itemHelper.GetItemQualityModifierForItems(offer.Items, true);
+
+ // Multiply single item price by quality
+ singleItemPrice *= qualityMultiplier;
+
+ // Get price player listed items for in roubles
+ var playerListedPriceInRub = CalculateRequirementsPriceInRub(offerRequest.Requirements);
+
+ // Roll sale chance
+ var sellChancePercent = _ragfairSellHelper.CalculateSellChance(
+ singleItemPrice.Value * stackCountTotal,
+ playerListedPriceInRub,
+ qualityMultiplier);
+
+ // Create array of sell times for items listed + sell all at once as its a pack
+ offer.SellResults = _ragfairSellHelper.RollForSale(sellChancePercent, (int)stackCountTotal, true);
+
+ // Subtract flea market fee from stash
+ if (_ragfairConfig.Sell.Fees)
+ {
+ var taxFeeChargeFailed = ChargePlayerTaxFee(
+ sessionID,
+ newRootOfferItem,
+ pmcData,
+ playerListedPriceInRub,
+ (int)stackCountTotal,
+ offerRequest,
+ output);
+ if (taxFeeChargeFailed)
+ {
+ return output;
+ }
+ }
+
+ // Add offer to players profile + add to client response
+ fullProfile.CharacterData.PmcData.RagfairInfo.Offers.Add(offer);
+ output.ProfileChanges[sessionID].RagFairOffers.Add(offer);
+
+ // Remove items from inventory after creating offer
+ foreach (var itemToRemove in offerRequest.Items) {
+ _inventoryHelper.RemoveItem(pmcData, itemToRemove, sessionID, output);
+ }
+
+ return output;
}
/**
@@ -549,9 +723,9 @@ public class RagfairController
// Find items to be listed on flea from player inventory
var result = GetItemsToListOnFleaFromInventory(pmcData, offerRequest.Items);
- if (result.Items is null || result.error is not null)
+ if (result.Items is null || result.ErrorMessage is not null)
{
- _httpResponseUtil.AppendErrorToOutput(output, result.errorMessage);
+ _httpResponseUtil.AppendErrorToOutput(output, result.ErrorMessage);
}
// Total count of items summed using their stack counts
@@ -589,7 +763,7 @@ public class RagfairController
playerListedPriceInRub,
qualityMultiplier
);
- offer.SellResult = _ragfairSellHelper.RollForSale(sellChancePercent, stackCountTotal);
+ offer.SellResults = _ragfairSellHelper.RollForSale(sellChancePercent, (int)stackCountTotal);
// Subtract flea market fee from stash
if (_ragfairConfig.Sell.Fees)
@@ -599,7 +773,7 @@ public class RagfairController
rootItem,
pmcData,
playerListedPriceInRub,
- stackCountTotal,
+ (int)stackCountTotal,
offerRequest,
output
);
@@ -736,7 +910,7 @@ public class RagfairController
return requirementsPriceInRub;
}
- private dynamic GetItemsToListOnFleaFromInventory(PmcData pmcData, List itemIdsFromFleaOfferRequest)
+ private GetItemsToListOnFleaFromInventoryResult GetItemsToListOnFleaFromInventory(PmcData pmcData, List itemIdsFromFleaOfferRequest)
{
List
> itemsToReturn = [];
var errorMessage = string.Empty;
@@ -750,7 +924,7 @@ public class RagfairController
errorMessage = _localisationService.GetText("ragfair-unable_to_find_item_in_inventory", new { id = itemId });
_logger.Error(errorMessage);
- return new { itemsToReturn, errorMessage };
+ return new GetItemsToListOnFleaFromInventoryResult { Items = itemsToReturn, ErrorMessage = errorMessage };
}
item = _itemHelper.FixItemStackCount(item);
@@ -762,10 +936,16 @@ public class RagfairController
errorMessage = _localisationService.GetText("ragfair-unable_to_find_requested_items_in_inventory");
_logger.Error(errorMessage);
- return new { ErrorMessage = errorMessage };
+ return new GetItemsToListOnFleaFromInventoryResult { ErrorMessage = errorMessage };
}
- return new { Items = itemsToReturn, ErrorMessage = errorMessage };
+ return new GetItemsToListOnFleaFromInventoryResult { Items = itemsToReturn, ErrorMessage = errorMessage };
+ }
+
+ public record GetItemsToListOnFleaFromInventoryResult
+ {
+ public List>? Items { get; set; }
+ public string? ErrorMessage { get; set; }
}
public ItemEventRouterResponse RemoveOffer(RemoveOfferRequestData removeRequest, string sessionId)
diff --git a/Libraries/Core/Controllers/RepairController.cs b/Libraries/Core/Controllers/RepairController.cs
index d99eb056..02b3a1a2 100644
--- a/Libraries/Core/Controllers/RepairController.cs
+++ b/Libraries/Core/Controllers/RepairController.cs
@@ -69,6 +69,25 @@ public class RepairController(
RepairActionDataRequest body,
PmcData pmcData)
{
- throw new NotImplementedException();
+ var output = _eventOutputHolder.GetOutput(sessionId);
+
+ // repair item
+ var repairDetails = _repairService.RepairItemByKit(
+ sessionId,
+ pmcData,
+ body.RepairKitsInfo,
+ body.Target,
+ output
+ );
+
+ _repairService.AddBuffToItem(repairDetails, pmcData);
+
+ // add repaired item to send to client
+ output.ProfileChanges[sessionId].Items.ChangedItems.Add(repairDetails.RepairedItem);
+
+ // Add skill points for repairing items
+ _repairService.AddRepairSkillPoints(sessionId, repairDetails, pmcData);
+
+ return output;
}
}
diff --git a/Libraries/Core/Controllers/RepeatableQuestController.cs b/Libraries/Core/Controllers/RepeatableQuestController.cs
index 8dd7436d..bf8b573f 100644
--- a/Libraries/Core/Controllers/RepeatableQuestController.cs
+++ b/Libraries/Core/Controllers/RepeatableQuestController.cs
@@ -3,9 +3,11 @@ using Core.Helpers;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Eft.ItemEvent;
+using Core.Models.Eft.Profile;
using Core.Models.Eft.Quests;
using Core.Models.Enums;
using Core.Models.Spt.Config;
+using Core.Models.Spt.Quests;
using Core.Models.Spt.Repeatable;
using Core.Models.Utils;
using Core.Routers;
@@ -23,7 +25,7 @@ public class RepeatableQuestController(
TimeUtil _timeUtil,
HashUtil _hashUtil,
RandomUtil _randomUtil,
- HttpResponseUtil _responseUtil,
+ HttpResponseUtil _httpResponseUtil,
ProfileHelper _profileHelper,
ProfileFixerService _profileFixerService,
LocalisationService _localisationService,
@@ -39,10 +41,270 @@ public class RepeatableQuestController(
{
protected QuestConfig _questConfig = _configServer.GetConfig();
- public ItemEventRouterResponse ChangeRepeatableQuest(PmcData pmcData, RepeatableQuestChangeRequest info,
- string sessionId)
+ public ItemEventRouterResponse ChangeRepeatableQuest(PmcData pmcData, RepeatableQuestChangeRequest changeRequest,
+ string sessionID)
{
- throw new NotImplementedException();
+ var output = _eventOutputHolder.GetOutput(sessionID);
+
+ var fullProfile = _profileHelper.GetFullProfile(sessionID);
+
+ // Check for existing quest in (daily/weekly/scav arrays)
+ var repeatables = GetRepeatableById(changeRequest.QuestId, pmcData);
+ var questToReplace = repeatables.Quest;
+ var repeatablesOfTypeInProfile = repeatables.RepeatableType;
+ if (repeatables.RepeatableType is null || repeatables.Quest is null)
+ {
+ // Unable to find quest being replaced
+ var message = _localisationService.GetText("quest-unable_to_find_repeatable_to_replace");
+ _logger.Error(message);
+
+ return _httpResponseUtil.AppendErrorToOutput(output, message);
+ }
+
+ // Subtype name of quest - daily/weekly/scav
+ var repeatableTypeLower = repeatablesOfTypeInProfile.Name.ToLower();
+
+ // Save for later standing loss calculation
+ var replacedQuestTraderId = questToReplace.TraderId;
+
+ // Update active quests to exclude the quest we're replacing
+ repeatablesOfTypeInProfile.ActiveQuests = repeatablesOfTypeInProfile.ActiveQuests.Where(
+ quest => quest.Id != changeRequest.QuestId
+ )
+ .ToList();
+
+ // Save for later cost calculations
+ var previousChangeRequirement = _cloner.Clone(
+ repeatablesOfTypeInProfile.ChangeRequirement[changeRequest.QuestId]
+ );
+
+ // Delete the replaced quest change requirement data as we're going to add new data below
+ repeatablesOfTypeInProfile.ChangeRequirement.Remove(changeRequest.QuestId);
+
+ // Get config for this repeatable sub-type (daily/weekly/scav)
+ var repeatableConfig = _questConfig.RepeatableQuests.FirstOrDefault(
+ config => config.Name == repeatablesOfTypeInProfile.Name
+ );
+
+ // If the configuration dictates to replace with the same quest type, adjust the available quest types
+ if (repeatableConfig?.KeepDailyQuestTypeOnReplacement is not null)
+ {
+ repeatableConfig.Types = [questToReplace.Type.ToString()];
+ }
+
+ // Generate meta-data for what type/levelrange of quests can be generated for player
+ var allowedQuestTypes = GenerateQuestPool(repeatableConfig, pmcData.Info.Level);
+ var newRepeatableQuest = AttemptToGenerateRepeatableQuest(
+ sessionID,
+ pmcData,
+ allowedQuestTypes,
+ repeatableConfig
+ );
+ if (newRepeatableQuest is null)
+ {
+ // Unable to find quest being replaced
+ var message =
+ $"Unable to generate repeatable quest of type: {repeatableTypeLower} to replace trader: ${replacedQuestTraderId} quest ${changeRequest.QuestId}";
+ _logger.Error(message);
+
+ return _httpResponseUtil.AppendErrorToOutput(output, message);
+ }
+
+ // Add newly generated quest to daily/weekly/scav type array
+ newRepeatableQuest.Side = repeatableConfig.Side;
+ repeatablesOfTypeInProfile.ActiveQuests.Add(newRepeatableQuest);
+
+ _logger.Debug(
+ $"Removing: {repeatableConfig.Name} quest: {questToReplace.Id} from trader: {questToReplace.TraderId} as its been replaced"
+ );
+
+ RemoveQuestFromProfile(fullProfile, questToReplace.Id);
+
+ // Delete the replaced quest change requirement from profile
+ CleanUpRepeatableChangeRequirements(repeatablesOfTypeInProfile, questToReplace.Id);
+
+ // Add replacement quests change requirement data to profile
+ repeatablesOfTypeInProfile.ChangeRequirement[newRepeatableQuest.Id] = new ChangeRequirement
+ {
+ ChangeCost = newRepeatableQuest.ChangeCost,
+ ChangeStandingCost = _randomUtil.GetArrayValue([0, 0.01])
+ };
+
+ // Check if we should charge player for replacing quest
+ var isFreeToReplace = UseFreeRefreshIfAvailable(
+ fullProfile,
+ repeatablesOfTypeInProfile,
+ repeatableTypeLower
+ );
+ if (!isFreeToReplace)
+ {
+ // Reduce standing with trader for not doing their quest
+ var traderOfReplacedQuest = pmcData.TradersInfo[replacedQuestTraderId];
+ traderOfReplacedQuest.Standing -= previousChangeRequirement.ChangeStandingCost;
+
+ var charismaBonus = _profileHelper.GetSkillFromProfile(pmcData, SkillTypes.Charisma)?.Progress ?? 0;
+ foreach (var cost in previousChangeRequirement.ChangeCost)
+ {
+ // Not free, Charge player + appy charisma bonus to cost of replacement
+ cost.Count = (int)Math.Truncate(cost.Count.Value * (1 - Math.Truncate(charismaBonus / 100) * 0.001));
+ _paymentService.AddPaymentToOutput(pmcData, cost.TemplateId, cost.Count.Value, sessionID, output);
+ if (output.Warnings.Count > 0)
+ {
+ return output;
+ }
+ }
+ }
+
+ // Clone data before we send it to client
+ var repeatableToChangeClone = _cloner.Clone(repeatablesOfTypeInProfile);
+
+ // Purge inactive repeatables
+ repeatableToChangeClone.InactiveQuests = [];
+
+ // Nullguard
+ output.ProfileChanges[sessionID].RepeatableQuests ??= [];
+
+ // Update client output with new repeatable
+ output.ProfileChanges[sessionID].RepeatableQuests.Add(repeatableToChangeClone);
+
+ return output;
+ }
+
+ /**
+ * Some accounts have access to free repeatable quest refreshes
+ * Track the usage of them inside players profile
+ * @param fullProfile Player profile
+ * @param repeatableSubType Can be daily / weekly / scav repeatable
+ * @param repeatableTypeName Subtype of repeatable quest: daily / weekly / scav
+ * @returns Is the repeatable being replaced for free
+ */
+ protected bool UseFreeRefreshIfAvailable(SptProfile? fullProfile, PmcDataRepeatableQuest repeatableSubType,
+ string repeatableTypeName)
+ {
+ // No free refreshes, exit early
+ if (repeatableSubType.FreeChangesAvailable <= 0)
+ {
+ // Reset counter to 0
+ repeatableSubType.FreeChangesAvailable = 0;
+
+ return false;
+ }
+
+ // Only certain game versions have access to free refreshes
+ var hasAccessToFreeRefreshSystem = _profileHelper.HasAccessToRepeatableFreeRefreshSystem(
+ fullProfile.CharacterData.PmcData
+ );
+
+ // If the player has access and available refreshes:
+ if (hasAccessToFreeRefreshSystem)
+ {
+ // Initialize/retrieve free refresh count for the desired subtype: daily/weekly
+ fullProfile.SptData.FreeRepeatableRefreshUsedCount ??= new Dictionary();
+ var repeatableRefreshCounts = fullProfile.SptData.FreeRepeatableRefreshUsedCount;
+ repeatableRefreshCounts.TryAdd(repeatableTypeName, 0); // Set to 0 if undefined
+
+ // Increment the used count and decrement the available count.
+ repeatableRefreshCounts[repeatableTypeName]++;
+ repeatableSubType.FreeChangesAvailable--;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Clean up the repeatables `changeRequirement` dictionary of expired data
+ * @param repeatablesOfTypeInProfile The repeatables that have the replaced and new quest
+ * @param replacedQuestId Id of the replaced quest
+ */
+ private void CleanUpRepeatableChangeRequirements(PmcDataRepeatableQuest repeatablesOfTypeInProfile,
+ string replacedQuestId)
+ {
+ if (repeatablesOfTypeInProfile.ActiveQuests.Count == 1)
+ {
+ // Only one repeatable quest being replaced (e.g. scav_daily), remove everything ready for new quest requirement to be added
+ // Will assist in cleanup of existing profiles data
+ repeatablesOfTypeInProfile.ChangeRequirement.Clear();
+ }
+ else
+ {
+ // Multiple active quests of this type (e.g. daily or weekly) are active, just remove the single replaced quest
+ repeatablesOfTypeInProfile.ChangeRequirement.Remove(replacedQuestId);
+ }
+ }
+
+ private RepeatableQuest AttemptToGenerateRepeatableQuest(string sessionId, PmcData pmcData,
+ QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig)
+ {
+ const int maxAttempts = 10;
+ RepeatableQuest newRepeatableQuest = null;
+ var attempts = 0;
+ while (attempts < maxAttempts && questTypePool.Types.Count > 0)
+ {
+ newRepeatableQuest = _repeatableQuestGenerator.GenerateRepeatableQuest(
+ sessionId,
+ pmcData.Info.Level.Value,
+ pmcData.TradersInfo,
+ questTypePool,
+ repeatableConfig
+ );
+
+ if (newRepeatableQuest is not null)
+ {
+ // Successfully generated a quest, exit loop
+ break;
+ }
+
+ attempts++;
+ }
+
+ if (attempts > maxAttempts)
+ {
+ _logger.Debug("We were stuck in repeatable quest generation. This should never happen. Please report");
+ }
+
+ return newRepeatableQuest;
+ }
+
+ private void RemoveQuestFromProfile(SptProfile? fullProfile, string questToReplaceId)
+ {
+ // Find quest we're replacing in pmc profile quests array and remove it
+ _questHelper.FindAndRemoveQuestFromArrayIfExists(questToReplaceId, fullProfile.CharacterData.PmcData.Quests);
+
+ // Find quest we're replacing in scav profile quests array and remove it
+ if (fullProfile.CharacterData.ScavData is not null)
+ {
+ _questHelper.FindAndRemoveQuestFromArrayIfExists(
+ questToReplaceId,
+ fullProfile.CharacterData.ScavData.Quests
+ );
+ }
+ }
+
+ /**
+ * Find a repeatable (daily/weekly/scav) from a players profile by its id
+ * @param questId Id of quest to find
+ * @param pmcData Profile that contains quests to look through
+ * @returns IGetRepeatableByIdResult
+ */
+ protected GetRepeatableByIdResult GetRepeatableById(string questId, PmcData pmcData)
+ {
+ foreach (var repeatablesInProfile in pmcData.RepeatableQuests)
+ {
+ // Check for existing quest in (daily/weekly/scav arrays)
+ var questToReplace =
+ repeatablesInProfile.ActiveQuests.FirstOrDefault(repeatable => repeatable.Id == questId);
+ if (questToReplace is null)
+ {
+ // Not found, skip to next repeatable sub-type
+ continue;
+ }
+
+ return new GetRepeatableByIdResult { Quest = questToReplace, RepeatableType = repeatablesInProfile };
+ }
+
+ return null;
}
public List GetClientRepeatableQuests(string sessionID)
@@ -135,7 +397,7 @@ public class RepeatableQuestController(
fullProfile.SptData.FreeRepeatableRefreshUsedCount[repeatableTypeLower] = 0;
// Create stupid redundant change requirements from quest data
- generatedRepeatables.ChangeRequirement = new();
+ generatedRepeatables.ChangeRequirement = new Dictionary();
foreach (var quest in generatedRepeatables.ActiveQuests)
generatedRepeatables.ChangeRequirement.TryAdd(
quest.Id,
diff --git a/Libraries/Core/Controllers/TraderController.cs b/Libraries/Core/Controllers/TraderController.cs
index 0d556ad3..166dcb65 100644
--- a/Libraries/Core/Controllers/TraderController.cs
+++ b/Libraries/Core/Controllers/TraderController.cs
@@ -72,7 +72,7 @@ public class TraderController(
}
// Create dict of pristine trader assorts on server start
- if (_traderAssortService.GetPristineTraderAssort(trader.Key) != null)
+ if (_traderAssortService.GetPristineTraderAssort(trader.Key) == null)
{
var assortsClone = _cloner.Clone(trader.Value.Assort);
_traderAssortService.SetPristineTraderAssort(trader.Key, assortsClone);
diff --git a/Libraries/Core/Core.csproj b/Libraries/Core/Core.csproj
index b3ff2b83..f23d1684 100644
--- a/Libraries/Core/Core.csproj
+++ b/Libraries/Core/Core.csproj
@@ -7,10 +7,6 @@
Library
-
-
-
-
diff --git a/Libraries/Core/Generators/BotEquipmentModGenerator.cs b/Libraries/Core/Generators/BotEquipmentModGenerator.cs
index 67cdb9a7..cd993657 100644
--- a/Libraries/Core/Generators/BotEquipmentModGenerator.cs
+++ b/Libraries/Core/Generators/BotEquipmentModGenerator.cs
@@ -6,12 +6,12 @@ using Core.Models.Spt.Bots;
using Core.Models.Spt.Config;
using Core.Models.Utils;
using Core.Helpers;
+using Core.Models.Common;
using Core.Servers;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;
using Core.Utils.Collections;
-
namespace Core.Generators;
[Injectable]
@@ -222,7 +222,156 @@ public class BotEquipmentModGenerator(
public FilterPlateModsForSlotByLevelResult FilterPlateModsForSlotByLevel(GenerateEquipmentProperties settings, string modSlot,
HashSet existingPlateTplPool, TemplateItem armorItem)
{
- throw new NotImplementedException();
+ var result = new FilterPlateModsForSlotByLevelResult
+ {
+ Result = Result.UNKNOWN_FAILURE,
+ PlateModTemplates = null,
+ };
+
+ // Not pmc or not a plate slot, return original mod pool array
+ if (!_itemHelper.IsRemovablePlateSlot(modSlot))
+ {
+ result.Result = Result.NOT_PLATE_HOLDING_SLOT;
+ result.PlateModTemplates = existingPlateTplPool;
+
+ return result;
+ }
+
+ // Get the front/back/side weights based on bots level
+ var plateSlotWeights = settings.BotEquipmentConfig?.ArmorPlateWeighting.FirstOrDefault(
+ (armorWeight) =>
+ settings.BotData.Level >= armorWeight.LevelRange.Min &&
+ settings.BotData.Level <= armorWeight.LevelRange.Max
+ );
+
+ if (plateSlotWeights is null)
+ {
+ // No weights, return original array of plate tpls
+ result.Result = Result.LACKS_PLATE_WEIGHTS;
+ result.PlateModTemplates = existingPlateTplPool;
+
+ return result;
+ }
+
+ // Get the specific plate slot weights (front/back/side)
+ if (!plateSlotWeights.Values.TryGetValue(modSlot, out var plateWeights))
+ {
+ // No weights, return original array of plate tpls
+ result.Result = Result.LACKS_PLATE_WEIGHTS;
+ result.PlateModTemplates = existingPlateTplPool;
+
+ return result;
+ }
+
+ // Choose a plate level based on weighting
+ var chosenArmorPlateLevelString = _weightedRandomHelper.GetWeightedValue(plateWeights);
+
+ // Convert the array of ids into database items
+ var platesFromDb = existingPlateTplPool.Select((plateTpl) => _itemHelper.GetItem(plateTpl).Value);
+
+ // Filter plates to the chosen level based on its armorClass property
+ var platesOfDesiredLevel = platesFromDb.Where((item) => item.Properties.ArmorClass.Value == double.Parse(chosenArmorPlateLevelString));
+ if (platesOfDesiredLevel.Any())
+ {
+ // Plates found
+ result.Result = Result.SUCCESS;
+ result.PlateModTemplates = platesOfDesiredLevel.Select((item) => item.Id).ToHashSet();
+
+ return result;
+ }
+
+ // no plates found that fit requirements, lets get creative
+
+ // Get lowest and highest plate classes available for this armor
+ var minMaxArmorPlateClass = GetMinMaxArmorPlateClass(platesFromDb.ToList());
+
+ // Increment plate class level in attempt to get useable plate
+ var findCompatiblePlateAttempts = 0;
+ var maxAttempts = 3;
+ for (var i = 0; i < maxAttempts; i++)
+ {
+ var chosenArmorPlateLevelDouble = int.Parse(chosenArmorPlateLevelString) + 1;
+ chosenArmorPlateLevelString = chosenArmorPlateLevelDouble.ToString();
+
+ // New chosen plate class is higher than max, then set to min and check if valid
+ if (chosenArmorPlateLevelDouble > minMaxArmorPlateClass.Max)
+ {
+ chosenArmorPlateLevelString = minMaxArmorPlateClass.Min.ToString();
+ }
+
+ findCompatiblePlateAttempts++;
+
+ platesOfDesiredLevel = platesFromDb.Where((item) => item.Properties.ArmorClass == chosenArmorPlateLevelDouble);
+ // Valid plates found, exit
+ if (platesOfDesiredLevel.Any())
+ {
+ break;
+ }
+
+ // No valid plate class found in 3 tries, attempt default plates
+ if (findCompatiblePlateAttempts >= maxAttempts)
+ {
+ _logger.Debug(
+ $"Plate filter too restrictive for armor: {armorItem.Name} {armorItem.Id}, unable to find plates of level: {chosenArmorPlateLevelString}, using items default plate"
+ );
+
+ var defaultPlate = GetDefaultPlateTpl(armorItem, modSlot);
+ if (defaultPlate is not null)
+ {
+ // Return Default Plates cause couldn't get lowest level available from original selection
+ result.Result = Result.SUCCESS;
+ result.PlateModTemplates = [defaultPlate];
+
+ return result;
+ }
+
+ // No plate found after filtering AND no default plate
+
+ // Last attempt, get default preset and see if it has a plate default
+ var defaultPresetPlateSlot = GetDefaultPresetArmorSlot(armorItem.Id, modSlot);
+ if (defaultPresetPlateSlot is not null)
+ {
+ // Found a plate, exit
+ var plateItem = _itemHelper.GetItem(defaultPresetPlateSlot.Template);
+ platesOfDesiredLevel = [plateItem.Value];
+
+ break;
+ }
+
+ // Everything failed, no default plate or no default preset armor plate
+ result.Result = Result.NO_DEFAULT_FILTER;
+
+ return result;
+ }
+ }
+
+ // Only return the items ids
+ result.Result = Result.SUCCESS;
+ result.PlateModTemplates = platesOfDesiredLevel.Select(item => item.Id).ToHashSet();
+
+ return result;
+ }
+
+ private MinMax GetMinMaxArmorPlateClass(List platePool)
+ {
+ platePool.Sort((x, y) => {
+ if (x.Properties.ArmorClass < y.Properties.ArmorClass)
+ {
+ return -1;
+ }
+
+ if (x.Properties.ArmorClass > y.Properties.ArmorClass)
+ {
+ return 1;
+ }
+
+ return 0;
+ });
+
+ return new MinMax {
+ Min = (platePool[0].Properties.ArmorClass),
+ Max = (platePool[platePool.Count - 1].Properties.ArmorClass),
+ };
}
/**
@@ -231,7 +380,7 @@ public class BotEquipmentModGenerator(
* @param modSlot front/back
* @returns Tpl of plate
*/
- protected string GetDefaultPlateTpl(TemplateItem armorItem, string modSlot)
+ protected string? GetDefaultPlateTpl(TemplateItem armorItem, string modSlot)
{
var relatedItemDbModSlot = armorItem.Properties.Slots?.FirstOrDefault(slot => slot.Name.ToLower() == modSlot);
@@ -291,7 +440,7 @@ public class BotEquipmentModGenerator(
_botConfig.Equipment.TryGetValue(request.BotData.EquipmentRole, out var botEquipConfig);
var botEquipBlacklist = _botEquipmentFilterService.GetBotEquipmentBlacklist(
request.BotData.EquipmentRole,
- pmcProfile.Info.Level ?? 0
+ pmcProfile?.Info?.Level ?? 0
);
var botWeaponSightWhitelist = _botEquipmentFilterService.GetBotWeaponSightWhitelist(
request.BotData.EquipmentRole
@@ -360,7 +509,7 @@ public class BotEquipmentModGenerator(
}
if (!IsModValidForSlot(modToAdd, modsParentSlot, modSlot, request.ParentTemplate, request.BotData.Role)
- )
+ )
{
continue;
}
@@ -511,7 +660,6 @@ public class BotEquipmentModGenerator(
request.ModPool[modToAddTemplate.Value.Id] = modFromService;
containsModInPool = true;
}
-
}
if (containsModInPool)
@@ -936,10 +1084,13 @@ public class BotEquipmentModGenerator(
var weaponTpl = modSpawnRequest.Weapon[0].Template;
modSpawnRequest.RandomisationSettings.MinimumMagazineSize.TryGetValue(weaponTpl, out var minMagSizeFromSettings);
var minMagazineSize = minMagSizeFromSettings;
- var desiredMagazineTpls = modPool.Where((magTpl) => {
- var magazineDb = _itemHelper.GetItem(magTpl).Value;
- return magazineDb.Properties is not null && magazineDb.Properties.Cartridges.FirstOrDefault().MaxCount >= minMagazineSize;
- });
+ var desiredMagazineTpls = modPool.Where(
+ (magTpl) =>
+ {
+ var magazineDb = _itemHelper.GetItem(magTpl).Value;
+ return magazineDb.Properties is not null && magazineDb.Properties.Cartridges.FirstOrDefault().MaxCount >= minMagazineSize;
+ }
+ );
if (!desiredMagazineTpls.Any())
{
@@ -1370,7 +1521,8 @@ public class BotEquipmentModGenerator(
/// db object for modItem we get compatible mods from
/// Pool of mods we are adding to
/// A blacklist of items that cannot be picked
- public void AddCompatibleModsForProvidedMod(string desiredSlotName, TemplateItem modTemplate, Dictionary>> modPool,
+ public void AddCompatibleModsForProvidedMod(string desiredSlotName, TemplateItem modTemplate,
+ Dictionary>> modPool,
EquipmentFilterDetails botEquipBlacklist)
{
var desiredSlotObject = modTemplate.Properties.Slots?.FirstOrDefault((slot) => slot.Name.Contains(desiredSlotName));
@@ -1573,7 +1725,9 @@ public class BotEquipmentModGenerator(
var whitelistedSightTypes = botWeaponSightWhitelist[weaponDetails.Value.Parent];
if (whitelistedSightTypes is null)
{
- _logger.Debug($"Unable to find whitelist for weapon type: {weaponDetails.Value.Parent} {weaponDetails.Value.Name}, skipping sight filtering");
+ _logger.Debug(
+ $"Unable to find whitelist for weapon type: {weaponDetails.Value.Parent} {weaponDetails.Value.Name}, skipping sight filtering"
+ );
return scopes;
}
diff --git a/Libraries/Core/Generators/BotGenerator.cs b/Libraries/Core/Generators/BotGenerator.cs
index 3514c13e..d773f119 100644
--- a/Libraries/Core/Generators/BotGenerator.cs
+++ b/Libraries/Core/Generators/BotGenerator.cs
@@ -78,7 +78,7 @@ public class BotGenerator(
Id = bot.Id,
Aid = bot.Aid,
SessionId = bot.SessionId,
- Savage = bot.Savage,
+ Savage = null,
KarmaValue = bot.KarmaValue,
Info = bot.Info,
Customization = bot.Customization,
@@ -199,7 +199,11 @@ public class BotGenerator(
botRoleLowercase,
_botConfig.BotRolesThatMustHaveUniqueName
);
- bot.Info.LowerNickname = bot.Info.Nickname.ToLower();
+
+ // Only Pmcs should have a lower nickname
+ bot.Info.LowerNickname = botGenerationDetails.IsPmc.GetValueOrDefault(false)
+ ? bot.Info.Nickname.ToLower()
+ : string.Empty;
// Only run when generating a 'fake' playerscav, not actual player scav
if (!botGenerationDetails.IsPlayerScav.GetValueOrDefault(false) && ShouldSimulatePlayerScav(botRoleLowercase))
@@ -230,7 +234,7 @@ public class BotGenerator(
bot.Info.Experience = botLevel.Exp;
bot.Info.Level = botLevel.Level;
- bot.Info.Settings.Experience = GetExperienceRewardForKillByDifficulty(
+ bot.Info.Settings.Experience = (int)GetExperienceRewardForKillByDifficulty(
botJsonTemplate.BotExperience.Reward,
botGenerationDetails.BotDifficulty,
botGenerationDetails.Role
@@ -249,6 +253,7 @@ public class BotGenerator(
bot.Info.Voice = _weightedRandomHelper.GetWeightedValue(botJsonTemplate.BotAppearance.Voice);
bot.Health = GenerateHealth(botJsonTemplate.BotHealth, botGenerationDetails.IsPlayerScav.GetValueOrDefault(false));
bot.Skills = GenerateSkills(botJsonTemplate.BotSkills); // TODO: fix bad type, bot jsons store skills in dict, output needs to be array
+ bot.Info.PrestigeLevel = 0;
if (botGenerationDetails.IsPmc.GetValueOrDefault(false))
{
@@ -281,7 +286,7 @@ public class BotGenerator(
}
// Generate new bot ID
- AddIdsToBot(bot);
+ AddIdsToBot(bot, botGenerationDetails);
// Generate new inventory ID
GenerateInventoryId(bot);
@@ -579,7 +584,7 @@ public class BotGenerator(
}
}
},
- UpdateTime = _timeUtil.GetTimeStamp(),
+ UpdateTime = 0, // 0 for pscav too
Immortal = false
};
@@ -683,13 +688,14 @@ public class BotGenerator(
/// Generate an id+aid for a bot and apply
///
/// bot to update
+ ///
///
- public void AddIdsToBot(BotBase bot)
+ public void AddIdsToBot(BotBase bot, BotGenerationDetails botGenerationDetails)
{
var botId = _hashUtil.Generate();
bot.Id = botId;
- bot.Aid = _hashUtil.GenerateAccountId();
+ bot.Aid = botGenerationDetails.IsPmc.GetValueOrDefault(false) ? _hashUtil.GenerateAccountId() : 0;
}
///
@@ -742,7 +748,7 @@ public class BotGenerator(
if (botInfo.Nickname?.ToLower() == "nikita")
{
botInfo.GameVersion = GameEditions.UNHEARD;
- botInfo.MemberCategory = MemberCategory.DEVELOPER;
+ botInfo.MemberCategory = MemberCategory.Developer;
return botInfo.GameVersion;
}
@@ -754,10 +760,10 @@ public class BotGenerator(
switch (botInfo.GameVersion)
{
case GameEditions.EDGE_OF_DARKNESS:
- botInfo.MemberCategory = MemberCategory.UNIQUE_ID;
+ botInfo.MemberCategory = MemberCategory.UniqueId;
break;
case GameEditions.UNHEARD:
- botInfo.MemberCategory = MemberCategory.UNHEARD;
+ botInfo.MemberCategory = MemberCategory.Unheard;
break;
default:
// Everyone else gets a weighted randomised category
diff --git a/Libraries/Core/Generators/BotInventoryGenerator.cs b/Libraries/Core/Generators/BotInventoryGenerator.cs
index a885043c..625c8747 100644
--- a/Libraries/Core/Generators/BotInventoryGenerator.cs
+++ b/Libraries/Core/Generators/BotInventoryGenerator.cs
@@ -102,6 +102,7 @@ public class BotInventoryGenerator(
var questRaidItemsId = _hashUtil.Generate();
var questStashItemsId = _hashUtil.Generate();
var sortingTableId = _hashUtil.Generate();
+ var hideoutCustomizationStashId = _hashUtil.Generate();
return new BotBaseInventory
{
@@ -111,17 +112,18 @@ public class BotInventoryGenerator(
new() { Id = stashId, Template = ItemTpl.STASH_STANDARD_STASH_10X30 },
new() { Id = questRaidItemsId, Template = ItemTpl.STASH_QUESTRAID },
new() { Id = questStashItemsId, Template = ItemTpl.STASH_QUESTOFFLINE },
- new() { Id = sortingTableId, Template = ItemTpl.SORTINGTABLE_SORTING_TABLE }
+ new() { Id = sortingTableId, Template = ItemTpl.SORTINGTABLE_SORTING_TABLE },
+ new() { Id = hideoutCustomizationStashId, Template = ItemTpl.HIDEOUTAREACONTAINER_CUSTOMIZATION }
],
Equipment = equipmentId,
Stash = stashId,
QuestRaidItems = questRaidItemsId,
QuestStashItems = questStashItemsId,
SortingTable = sortingTableId,
- HideoutAreaStashes = { },
- FastPanel = { },
+ HideoutAreaStashes = new Dictionary(),
+ FastPanel = new Dictionary(),
FavoriteItems = [],
- HideoutCustomizationStashId = "",
+ HideoutCustomizationStashId = hideoutCustomizationStashId,
};
}
@@ -201,7 +203,7 @@ public class BotInventoryGenerator(
Inventory = botInventory,
BotEquipmentConfig = botEquipConfig,
RandomisationDetails = randomistionDetails,
- GeneratingPlayerLevel = pmcProfile.Info.Level
+ GeneratingPlayerLevel = pmcProfile?.Info?.Level ?? 1
}
);
}
@@ -223,7 +225,7 @@ public class BotInventoryGenerator(
BotEquipmentConfig = botEquipConfig,
RandomisationDetails = randomistionDetails,
GenerateModsBlacklist = [ItemTpl.POCKETS_1X4_TUE, ItemTpl.POCKETS_LARGE],
- GeneratingPlayerLevel = pmcProfile.Info.Level,
+ GeneratingPlayerLevel = pmcProfile?.Info?.Level ?? 1,
}
);
@@ -238,7 +240,7 @@ public class BotInventoryGenerator(
Inventory = botInventory,
BotEquipmentConfig = botEquipConfig,
RandomisationDetails = randomistionDetails,
- GeneratingPlayerLevel = pmcProfile.Info.Level,
+ GeneratingPlayerLevel = pmcProfile?.Info?.Level ?? 1,
}
);
@@ -253,7 +255,7 @@ public class BotInventoryGenerator(
Inventory = botInventory,
BotEquipmentConfig = botEquipConfig,
RandomisationDetails = randomistionDetails,
- GeneratingPlayerLevel = pmcProfile.Info.Level,
+ GeneratingPlayerLevel = pmcProfile?.Info?.Level ?? 1,
}
);
@@ -268,7 +270,7 @@ public class BotInventoryGenerator(
Inventory = botInventory,
BotEquipmentConfig = botEquipConfig,
RandomisationDetails = randomistionDetails,
- GeneratingPlayerLevel = pmcProfile.Info.Level,
+ GeneratingPlayerLevel = pmcProfile?.Info?.Level ?? 1,
}
);
@@ -283,7 +285,7 @@ public class BotInventoryGenerator(
Inventory = botInventory,
BotEquipmentConfig = botEquipConfig,
RandomisationDetails = randomistionDetails,
- GeneratingPlayerLevel = pmcProfile.Info.Level,
+ GeneratingPlayerLevel = pmcProfile?.Info?.Level ?? 1,
}
);
@@ -310,15 +312,15 @@ public class BotInventoryGenerator(
GenerateEquipment(
new GenerateEquipmentProperties
{
- RootEquipmentSlot = EquipmentSlots.Earpiece,
- RootEquipmentPool = templateInventory.Equipment[EquipmentSlots.Earpiece],
+ RootEquipmentSlot = EquipmentSlots.TacticalVest,
+ RootEquipmentPool = templateInventory.Equipment[EquipmentSlots.TacticalVest],
ModPool = templateInventory.Mods,
SpawnChances = wornItemChances,
BotData = new BotData { Role = botRole, Level = botLevel, EquipmentRole = botEquipmentRole },
Inventory = botInventory,
BotEquipmentConfig = botEquipConfig,
RandomisationDetails = randomistionDetails,
- GeneratingPlayerLevel = pmcProfile.Info.Level,
+ GeneratingPlayerLevel = pmcProfile?.Info?.Level ?? 1,
}
);
}
@@ -395,7 +397,7 @@ public class BotInventoryGenerator(
var shouldSpawn = _randomUtil.GetChance100(spawnChance ?? 0);
if (shouldSpawn && settings.RootEquipmentPool.Any())
{
- var pickedItemDb = new TemplateItem();
+ TemplateItem pickedItemDb = null;
var found = false;
// Limit attempts to find a compatible item as it's expensive to check them all
@@ -465,7 +467,7 @@ public class BotInventoryGenerator(
var botEquipBlacklist = _botEquipmentFilterService.GetBotEquipmentBlacklist(
settings.BotData.EquipmentRole,
- (double)settings.GeneratingPlayerLevel
+ settings.GeneratingPlayerLevel.Value
);
// Edge case: Filter the armor items mod pool if bot exists in config dict + config has armor slot
@@ -479,11 +481,10 @@ public class BotInventoryGenerator(
botEquipBlacklist.Equipment
);
}
-
+ var itemIsOnGenerateModBlacklist = settings.GenerateModsBlacklist != null && settings.GenerateModsBlacklist.Contains(pickedItemDb.Id);
// Does item have slots for sub-mods to be inserted into
if (pickedItemDb.Properties?.Slots?.Count > 0
- && settings?.GenerateModsBlacklist is not null
- && !settings.GenerateModsBlacklist.Contains(pickedItemDb.Id))
+ && !itemIsOnGenerateModBlacklist)
{
var childItemsToAdd = _botEquipmentModGenerator.GenerateModsForEquipment(
[item],
diff --git a/Libraries/Core/Generators/BotLevelGenerator.cs b/Libraries/Core/Generators/BotLevelGenerator.cs
index 5899bf76..9ad65e16 100644
--- a/Libraries/Core/Generators/BotLevelGenerator.cs
+++ b/Libraries/Core/Generators/BotLevelGenerator.cs
@@ -27,6 +27,11 @@ public class BotLevelGenerator(
/// IRandomisedBotLevelResult object
public RandomisedBotLevelResult GenerateBotLevel(MinMax levelDetails, BotGenerationDetails botGenerationDetails, BotBase bot)
{
+ if (!botGenerationDetails.IsPmc.GetValueOrDefault(false))
+ {
+ return new RandomisedBotLevelResult() { Exp = 0, Level = 1 };
+ }
+
var expTable = _databaseService.GetGlobals().Configuration.Exp.Level.ExperienceTable;
var botLevelRange = GetRelativeBotLevelRange(botGenerationDetails, levelDetails, expTable.Length);
@@ -74,6 +79,9 @@ public class BotLevelGenerator(
)
: Math.Min(levelDetails.Min.Value, maxAvailableLevel); // Not pmc with override or non-pmc
+ // Force min level to be 1
+ minPossibleLevel = Math.Max(1, minPossibleLevel);
+
var maxPossibleLevel = isPmc && pmcOverride is not null
? Math.Min(pmcOverride.Max.Value, maxAvailableLevel) // Was a PMC and they have a level override
: Math.Min(levelDetails.Max.Value, maxAvailableLevel); // Not pmc with override or non-pmc
diff --git a/Libraries/Core/Generators/BotWeaponGenerator.cs b/Libraries/Core/Generators/BotWeaponGenerator.cs
index 7a625bda..7746a9dd 100644
--- a/Libraries/Core/Generators/BotWeaponGenerator.cs
+++ b/Libraries/Core/Generators/BotWeaponGenerator.cs
@@ -33,7 +33,7 @@ public class BotWeaponGenerator(
IEnumerable inventoryMagGenComponents
)
{
- protected List _inventoryMagGenComponents = MagGenSetUp(inventoryMagGenComponents);
+ protected IEnumerable _inventoryMagGenComponents = MagGenSetUp(inventoryMagGenComponents);
protected BotConfig _botConfig = _configServer.GetConfig();
protected PmcConfig _pmcConfig = _configServer.GetConfig();
protected RepairConfig _repairConfig = _configServer.GetConfig();
@@ -42,13 +42,8 @@ public class BotWeaponGenerator(
private static List MagGenSetUp(IEnumerable components)
{
var inventoryMagGens = components.ToList();
- inventoryMagGens.ToList()
- .Sort(
- (a, b) =>
- a.GetPriority() -
- b.GetPriority()
- );
- return inventoryMagGens.ToList();
+ inventoryMagGens.Sort((a, b) => a.GetPriority() - b.GetPriority());
+ return inventoryMagGens;
}
///
@@ -398,7 +393,7 @@ public class BotWeaponGenerator(
return;
}
-
+ var isInternalMag = magTemplate.Properties.ReloadMagType == "InternalMagazine";
var ammoTemplate = _itemHelper.GetItem(generatedWeaponResult.ChosenAmmoTemplate).Value;
if (ammoTemplate is null)
{
@@ -722,7 +717,6 @@ public class BotWeaponGenerator(
/// Weapon items list to amend
/// Magazine item details we're adding cartridges to
/// Cartridge to put into the magazine
- /// How many cartridges should go into the magazine
/// Magazines db template
protected void AddOrUpdateMagazinesChildWithAmmo(List- weaponWithMods, Item magazine, string chosenAmmoTpl, TemplateItem magazineTemplate)
{
@@ -736,8 +730,7 @@ public class BotWeaponGenerator(
}
// Create array with just magazine
- List
- magazineWithCartridges = new();
- magazineWithCartridges.AddRange(magazine);
+ List
- magazineWithCartridges = [magazine];
// Add full cartridge child items to above array
_itemHelper.FillMagazineWithCartridge(magazineWithCartridges, magazineTemplate, chosenAmmoTpl, 1);
diff --git a/Libraries/Core/Generators/FenceBaseAssortGenerator.cs b/Libraries/Core/Generators/FenceBaseAssortGenerator.cs
index 9bf1a7e4..d1c9980f 100644
--- a/Libraries/Core/Generators/FenceBaseAssortGenerator.cs
+++ b/Libraries/Core/Generators/FenceBaseAssortGenerator.cs
@@ -7,6 +7,7 @@ using Core.Models.Utils;
using Core.Servers;
using Core.Services;
using Core.Utils;
+using Core.Utils.Cloners;
namespace Core.Generators;
@@ -22,7 +23,8 @@ public class FenceBaseAssortGenerator(
SeasonalEventService seasonalEventService,
LocalisationService localisationService,
ConfigServer configServer,
- FenceService fenceService
+ FenceService fenceService,
+ ICloner _cloner
)
{
protected TraderConfig traderConfig = configServer.GetConfig();
@@ -147,7 +149,7 @@ public class FenceBaseAssortGenerator(
}
// Construct preset + mods
- var itemAndChildren = itemHelper.ReplaceIDs(defaultPreset.Items);
+ var itemAndChildren = itemHelper.ReplaceIDs(_cloner.Clone(defaultPreset.Items));
// Find root item and add some properties to it
for (var i = 0; i < itemAndChildren.Count; i++)
diff --git a/Libraries/Core/Generators/LocationGenerator.cs b/Libraries/Core/Generators/LocationGenerator.cs
deleted file mode 100644
index 47190feb..00000000
--- a/Libraries/Core/Generators/LocationGenerator.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using SptCommon.Annotations;
-using Core.Models.Eft.Common;
-
-namespace Core.Generators;
-
-[Injectable]
-public class LocationGenerator
-{
- public StaticContainerProps GenerateContainerLoot(StaticContainerProps containerIn, List staticForced,
- Dictionary staticLootDist, Dictionary> staticAmmoDist, string locationName)
- {
- throw new NotImplementedException();
- }
-
- public List GenerateDynamicLoot(LooseLoot dynamicLootDist, Dictionary> staticAmmoDist,
- string locationName)
- {
- throw new NotImplementedException();
- }
-}
diff --git a/Libraries/Core/Generators/LocationLootGenerator.cs b/Libraries/Core/Generators/LocationLootGenerator.cs
index 564013e8..be5f9019 100644
--- a/Libraries/Core/Generators/LocationLootGenerator.cs
+++ b/Libraries/Core/Generators/LocationLootGenerator.cs
@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
using Core.Helpers;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
+using Core.Models.Enums;
using Core.Models.Spt.Config;
using Core.Models.Utils;
using Core.Servers;
@@ -18,9 +19,12 @@ public class LocationLootGenerator(
ISptLogger _logger,
RandomUtil _randomUtil,
MathUtil _mathUtil,
+ HashUtil _hashUtil,
ItemHelper _itemHelper,
InventoryHelper _inventoryHelper,
DatabaseService _databaseService,
+ ContainerHelper _containerHelper,
+ PresetHelper _presetHelper,
LocalisationService _localisationService,
SeasonalEventService _seasonalEventService,
ItemFilterService _itemFilterService,
@@ -294,7 +298,7 @@ public class LocationLootGenerator(
containerDistribution.Add(new ProbabilityObject(x, value, value));
}
- chosenContainerIds.AddRange(containerDistribution.Draw(containerData.ChosenCount));
+ chosenContainerIds.AddRange(containerDistribution.Draw((int)containerData.ChosenCount));
return chosenContainerIds;
}
@@ -379,12 +383,97 @@ public class LocationLootGenerator(
/// Name of the map to generate static loot for
/// StaticContainerData
protected StaticContainerData AddLootToContainer(StaticContainerData staticContainer,
- List staticForced,
+ List? staticForced,
Dictionary staticLootDist,
Dictionary> staticAmmoDist, string locationName
)
{
- throw new NotImplementedException();
+ var containerClone = _cloner.Clone(staticContainer);
+ var containerTpl = containerClone.Template.Items[0].Template;
+
+ // Create new unique parent id to prevent any collisions
+ var parentId = _hashUtil.Generate();
+ containerClone.Template.Root = parentId;
+ containerClone.Template.Items[0].Id = parentId;
+
+ var containerMap = GetContainerMapping(containerTpl);
+
+ // Choose count of items to add to container
+ var itemCountToAdd = GetWeightedCountOfContainerItems(containerTpl, staticLootDist, locationName);
+
+ // Get all possible loot items for container
+ var containerLootPool = GetPossibleLootItemsForContainer(containerTpl, staticLootDist);
+
+ // Some containers need to have items forced into it (quest keys etc)
+ var tplsForced = staticForced
+ .Where((forcedStaticProp) => forcedStaticProp.ContainerId == containerClone.Template.Id)
+ .Select((x) => x.ItemTpl);
+
+ // Draw random loot
+ // Allow money to spawn more than once in container
+ var failedToFitCount = 0;
+ var locklist = _itemHelper.GetMoneyTpls();
+
+ // Choose items to add to container, factor in weighting + lock money down
+ // Filter out items picked that're already in the above `tplsForced` array
+ var chosenTpls = containerLootPool
+ .Draw(itemCountToAdd, _locationConfig.AllowDuplicateItemsInStaticContainers, locklist)
+ .Where((tpl) => !tplsForced.Contains(tpl));
+
+ // Add forced loot to chosen item pool
+ var tplsToAddToContainer = tplsForced.Concat(chosenTpls);
+ foreach (var tplToAdd in tplsToAddToContainer)
+ {
+ var chosenItemWithChildren = CreateStaticLootItem(tplToAdd, staticAmmoDist, parentId);
+ if (chosenItemWithChildren is null)
+ {
+ continue;
+ }
+
+ var items = _locationConfig.TplsToStripChildItemsFrom.Contains(tplToAdd)
+ ? [chosenItemWithChildren.Items[0]] // Strip children from parent
+ : chosenItemWithChildren.Items;
+ var width = chosenItemWithChildren.Width;
+ var height = chosenItemWithChildren.Height;
+
+ // look for open slot to put chosen item into
+ var result = _containerHelper.FindSlotForItem(containerMap, (int)width, (int)height);
+ if (!result.Success.GetValueOrDefault(false))
+ {
+ if (failedToFitCount >= _locationConfig.FitLootIntoContainerAttempts)
+ {
+ // x attempts to fit an item, container is probably full, stop trying to add more
+ break;
+ }
+
+ // Can't fit item, skip
+ failedToFitCount++;
+
+ continue;
+ }
+
+ _containerHelper.FillContainerMapWithItem(
+ containerMap,
+ result.X.Value,
+ result.Y.Value,
+ (int)width,
+ (int)height,
+ result.Rotation.GetValueOrDefault(false)
+ );
+
+ var rotation = result.Rotation.GetValueOrDefault(false) ? 1 : 0;
+
+ items[0].SlotId = "main";
+ items[0].Location = new ItemLocation{ X = result.X, Y = result.Y, R = rotation };
+
+ // Add loot to container before returning
+ foreach (var item in items)
+ {
+ containerClone.Template.Items.Add(item);
+ }
+ }
+
+ return containerClone;
}
///
@@ -452,7 +541,7 @@ public class LocationLootGenerator(
/// Container to get possible loot for
/// staticLoot.json
/// ProbabilityObjectArray of item tpls + probabilty
- protected object GetPossibleLootItemsForContainer(string containerTypeId,
+ protected ProbabilityObjectArray, string, float?> GetPossibleLootItemsForContainer(string containerTypeId,
Dictionary staticLootDist) // TODO: Type Fuckery, return type was ProbabilityObjectArray
{
var seasonalEventActive = _seasonalEventService.SeasonalEventEnabled();
@@ -510,7 +599,189 @@ public class LocationLootGenerator(
Dictionary> staticAmmoDist,
string locationName)
{
- throw new NotImplementedException();
+ List loot = [];
+ List dynamicForcedSpawnPoints = [];
+
+ // Remove christmas items from loot data
+ if (!_seasonalEventService.ChristmasEventEnabled())
+ {
+ dynamicLootDist.Spawnpoints = dynamicLootDist.Spawnpoints.Where(
+ (point) => !point.Template.Id.StartsWith("christmas")
+ )
+ .ToList();
+ dynamicLootDist.SpawnpointsForced = dynamicLootDist.SpawnpointsForced.Where(
+ (point) => !point.Template.Id.StartsWith("christmas")
+ )
+ .ToList();
+ }
+
+ // Build the list of forced loot from both `spawnpointsForced` and any point marked `IsAlwaysSpawn`
+ dynamicForcedSpawnPoints.AddRange(dynamicLootDist.SpawnpointsForced);
+ dynamicForcedSpawnPoints.AddRange(dynamicLootDist.Spawnpoints.Where((point) => point.Template.IsAlwaysSpawn ?? false));
+
+ // Add forced loot
+ AddForcedLoot(loot, dynamicForcedSpawnPoints, locationName, staticAmmoDist);
+
+ var allDynamicSpawnpoints = dynamicLootDist.Spawnpoints;
+
+ // Draw from random distribution
+ var desiredSpawnpointCount = Math.Round(
+ GetLooseLootMultiplerForLocation(locationName) *
+ _randomUtil.GetNormallyDistributedRandomNumber(
+ (double)dynamicLootDist.SpawnpointCount.Mean,
+ (double)dynamicLootDist.SpawnpointCount.Std
+ )
+ );
+
+ // Positions not in forced but have 100% chance to spawn
+ List guaranteedLoosePoints = [];
+
+ var blacklistedSpawnpoints = _locationConfig.LooseLootBlacklist.GetValueOrDefault(locationName);
+ var spawnpointArray = new ProbabilityObjectArray, string, Spawnpoint>(_mathUtil, _cloner, []);
+
+ foreach (var spawnpoint in allDynamicSpawnpoints)
+ {
+ // Point is blacklsited, skip
+ if (blacklistedSpawnpoints?.Contains(spawnpoint.Template.Id) ?? false)
+ {
+ _logger.Debug($"Ignoring loose loot location: {spawnpoint.Template.Id}");
+ continue;
+ }
+
+ // We've handled IsAlwaysSpawn above, so skip them
+ if (spawnpoint.Template.IsAlwaysSpawn ?? false)
+ {
+ continue;
+ }
+
+ // 100%, add it to guaranteed
+ if (spawnpoint.Probability == 1)
+ {
+ guaranteedLoosePoints.Add(spawnpoint);
+ continue;
+ }
+
+ spawnpointArray.Add(new ProbabilityObject(spawnpoint.Template.Id, spawnpoint.Probability ?? 0, spawnpoint));
+ }
+
+ // Select a number of spawn points to add loot to
+ // Add ALL loose loot with 100% chance to pool
+ List chosenSpawnpoints = [];
+ chosenSpawnpoints.AddRange(guaranteedLoosePoints);
+
+ var randomSpawnpointCount = desiredSpawnpointCount - chosenSpawnpoints.Count;
+ // Only draw random spawn points if needed
+ if (randomSpawnpointCount > 0 && spawnpointArray.Count > 0)
+ {
+ // Add randomly chosen spawn points
+ foreach (var si in spawnpointArray.Draw((int)randomSpawnpointCount, false))
+ {
+ chosenSpawnpoints.Add(spawnpointArray.Data(si));
+ }
+ }
+
+ // Filter out duplicate locationIds // prob can be done better
+ chosenSpawnpoints = chosenSpawnpoints.GroupBy(spawnpoint => spawnpoint.LocationId).Select(group => group.First()).ToList();
+
+ // Do we have enough items in pool to fulfill requirement
+ var tooManySpawnPointsRequested = desiredSpawnpointCount - chosenSpawnpoints.Count > 0;
+ if (tooManySpawnPointsRequested)
+ {
+ _logger.Debug(
+ _localisationService.GetText(
+ "location-spawn_point_count_requested_vs_found",
+ new
+ {
+ requested = desiredSpawnpointCount + guaranteedLoosePoints.Count,
+ found = chosenSpawnpoints.Count,
+ mapName = locationName,
+ }
+ )
+ );
+ }
+
+ // Iterate over spawnpoints
+ var seasonalEventActive = _seasonalEventService.SeasonalEventEnabled();
+ var seasonalItemTplBlacklist = _seasonalEventService.GetInactiveSeasonalEventItems();
+ foreach (var spawnPoint in chosenSpawnpoints)
+ {
+ // Spawnpoint is invalid, skip it
+ if (spawnPoint.Template is null)
+ {
+ _logger.Warning(
+ _localisationService.GetText("location-missing_dynamic_template", spawnPoint.LocationId)
+ );
+
+ continue;
+ }
+
+ // Ensure no blacklisted lootable items are in pool
+ spawnPoint.Template.Items = spawnPoint.Template.Items.Where(
+ (item) => !_itemFilterService.IsLootableItemBlacklisted(item.Template)
+ )
+ .ToList();
+
+ // Ensure no seasonal items are in pool if not in-season
+ if (!seasonalEventActive)
+ {
+ spawnPoint.Template.Items = spawnPoint.Template.Items.Where(
+ (item) => !seasonalItemTplBlacklist.Contains(item.Template)
+ )
+ .ToList();
+ }
+
+ // Spawn point has no items after filtering, skip
+ if (spawnPoint.Template.Items is null || spawnPoint.Template.Items.Count == 0)
+ {
+ _logger.Warning(
+ _localisationService.GetText("location-spawnpoint_missing_items", spawnPoint.Template.Id)
+ );
+
+ continue;
+ }
+
+ // Get an array of allowed IDs after above filtering has occured
+ var validItemIds = spawnPoint.Template.Items.Select((item) => item.Id).ToList();
+
+ // Construct container to hold above filtered items, letting us pick an item for the spot
+ var itemArray = new ProbabilityObjectArray, string, double?>(_mathUtil, _cloner, []);
+ foreach (var itemDist in spawnPoint.ItemDistribution)
+ {
+ if (!validItemIds.Contains(itemDist.ComposedKey.Key))
+ {
+ continue;
+ }
+
+ itemArray.Add(new ProbabilityObject(itemDist.ComposedKey.Key, itemDist.RelativeProbability ?? 0, null));
+ }
+
+ if (itemArray.Count == 0)
+ {
+ _logger.Warning(
+ _localisationService.GetText("location-loot_pool_is_empty_skipping", spawnPoint.Template.Id)
+ );
+
+ continue;
+ }
+
+ // Draw a random item from spawn points possible items
+ var chosenComposedKey = itemArray.Draw(1).FirstOrDefault();
+ var createItemResult = CreateDynamicLootItem(
+ chosenComposedKey,
+ spawnPoint.Template.Items,
+ staticAmmoDist
+ );
+
+ // Root id can change when generating a weapon, ensure ids match
+ spawnPoint.Template.Root = createItemResult.Items.FirstOrDefault().Id;
+
+ // Overwrite entire pool with chosen item
+ spawnPoint.Template.Items = createItemResult.Items;
+
+ loot.Add(spawnPoint.Template);
+ }
+
+ return loot;
}
///
@@ -519,39 +790,406 @@ public class LocationLootGenerator(
/// List to add forced loot spawn locations to
/// Forced loot locations that must be added
/// Name of map currently having force loot created for
- protected void addForcedLoot(List lootLocationTemplates,
- List forcedSpawnPoints, string locationName,
+ protected void AddForcedLoot(List lootLocationTemplates,
+ List forcedSpawnPoints, string locationName,
Dictionary> staticAmmoDist)
{
- throw new NotImplementedException();
+ var lootToForceSingleAmountOnMap = _locationConfig.ForcedLootSingleSpawnById.GetValueOrDefault(locationName);
+ if (lootToForceSingleAmountOnMap is not null)
+ {
+ // Process loot items defined as requiring only 1 spawn position as they appear in multiple positions on the map
+ foreach (var itemTpl in lootToForceSingleAmountOnMap)
+ {
+ // Get all spawn positions for item tpl in forced loot array
+ var items = forcedSpawnPoints.Where(
+ (forcedSpawnPoint) => forcedSpawnPoint.Template.Items[0].Template == itemTpl
+ );
+ if (items is null || !items.Any())
+ {
+ _logger.Debug($"Unable to adjust loot item {itemTpl} as it does not exist inside {locationName} forced loot.");
+ continue;
+ }
+
+ // Create probability array of all spawn positions for this spawn id
+ var spawnpointArray = new ProbabilityObjectArray, string, Spawnpoint>(_mathUtil, _cloner, []);
+ foreach (var si in items)
+ {
+ // use locationId as template.Id is the same across all items
+ spawnpointArray.Add(new ProbabilityObject(si.LocationId, si.Probability ?? 0, si));
+ }
+
+ // Choose 1 out of all found spawn positions for spawn id and add to loot array
+ foreach (var spawnPointLocationId in spawnpointArray.Draw(1, false))
+ {
+ var itemToAdd = items.FirstOrDefault((item) => item.LocationId == spawnPointLocationId);
+ var lootItem = itemToAdd?.Template;
+ if (lootItem is null)
+ {
+ _logger.Warning($"Item with spawn point id {spawnPointLocationId} could not be found, skipping");
+ continue;
+ }
+
+ var createItemResult = CreateDynamicLootItem(
+ lootItem.Items.FirstOrDefault().Id,
+ lootItem.Items,
+ staticAmmoDist
+ );
+
+ // Update root ID with the dynamically generated ID
+ lootItem.Root = createItemResult.Items.FirstOrDefault().Id;
+ lootItem.Items = createItemResult.Items;
+ lootLocationTemplates.Add(lootItem);
+ }
+ }
+ }
+
+ var seasonalEventActive = _seasonalEventService.SeasonalEventEnabled();
+ var seasonalItemTplBlacklist = _seasonalEventService.GetInactiveSeasonalEventItems();
+
+ // Add remaining forced loot to array
+ foreach (var forcedLootLocation in forcedSpawnPoints)
+ {
+ var firstLootItemTpl = forcedLootLocation.Template.Items.FirstOrDefault().Template;
+
+ // Skip spawn positions processed already
+ if (lootToForceSingleAmountOnMap?.Contains(firstLootItemTpl) ?? false)
+ {
+ continue;
+ }
+
+ // Skip adding seasonal items when seasonal event is not active
+ if (!seasonalEventActive && seasonalItemTplBlacklist.Contains(firstLootItemTpl))
+ {
+ continue;
+ }
+
+ var locationTemplateToAdd = forcedLootLocation.Template;
+ var createItemResult = CreateDynamicLootItem(
+ locationTemplateToAdd.Items.FirstOrDefault().Id,
+ forcedLootLocation.Template.Items,
+ staticAmmoDist
+ );
+
+ // Update root ID with the dynamically generated ID
+ forcedLootLocation.Template.Root = createItemResult.Items.FirstOrDefault().Id;
+ forcedLootLocation.Template.Items = createItemResult.Items;
+
+ // Push forced location into array as long as it doesnt exist already
+ var existingLocation = lootLocationTemplates.Any(
+ (spawnPoint) => spawnPoint.Id == locationTemplateToAdd.Id
+ );
+ if (!existingLocation)
+ {
+ lootLocationTemplates.Add(locationTemplateToAdd);
+ }
+ else
+ {
+ _logger.Debug(
+ $"Attempted to add a forced loot location with Id: {locationTemplateToAdd.Id} to map {locationName} that already has that id in use, skipping"
+ );
+ }
+ }
}
- // TODO: rewrite, BIG yikes
- protected ContainerItem CreateStaticLootItem(string chosenTemplate,
- Dictionary> staticAmmoDistribution,
- string? parentIdentifier = null)
+ private ContainerItem CreateDynamicLootItem(string? chosenComposedKey, List
- items, Dictionary> staticAmmoDist)
{
- throw new NotImplementedException();
+ var chosenItem = items.FirstOrDefault((item) => item.Id == chosenComposedKey);
+ var chosenTpl = chosenItem?.Template;
+ if (chosenTpl is null) {
+ throw new Exception($"Item for tpl {chosenComposedKey} was not found in the spawn point");
+ }
+ var itemTemplate = _itemHelper.GetItem(chosenTpl).Value;
+ if (itemTemplate is null) {
+ _logger.Error($"Item tpl: {chosenTpl} cannot be found in database");
+ }
+
+ // Item array to return
+ List
- itemWithMods = [];
+
+ // Money/Ammo - don't rely on items in spawnPoint.template.Items so we can randomise it ourselves
+ if (_itemHelper.IsOfBaseclasses(chosenTpl, [BaseClasses.MONEY, BaseClasses.AMMO])) {
+ var stackCount =
+ itemTemplate.Properties.StackMaxSize == 1
+ ? 1
+ : _randomUtil.GetInt((int)itemTemplate.Properties.StackMinRandom, (int)itemTemplate.Properties.StackMaxRandom);
+
+ itemWithMods.Add(new Item {
+ Id = _hashUtil.Generate(),
+ Template = chosenTpl,
+ Upd = new Upd { StackObjectsCount = stackCount }
+ });
+ } else if (_itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.AMMO_BOX)) {
+ // Fill with cartridges
+ List
- ammoBoxItem = [ new Item { Id = _hashUtil.Generate(), Template = chosenTpl }];
+ _itemHelper.AddCartridgesToAmmoBox(ammoBoxItem, itemTemplate);
+ itemWithMods.AddRange(ammoBoxItem);
+ } else if (_itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.MAGAZINE)) {
+ // Create array with just magazine
+ List
- magazineItem = [new Item { Id = _hashUtil.Generate(), Template = chosenTpl }];
+
+ if (_randomUtil.GetChance100(_locationConfig.StaticMagazineLootHasAmmoChancePercent)) {
+ // Add randomised amount of cartridges
+ _itemHelper.FillMagazineWithRandomCartridge(
+ magazineItem,
+ itemTemplate, // Magazine template
+ staticAmmoDist,
+ null,
+ _locationConfig.MinFillLooseMagazinePercent / 100
+ );
+ }
+
+ itemWithMods.AddRange(magazineItem);
+ } else {
+ // Also used by armors to get child mods
+ // Get item + children and add into array we return
+ var itemWithChildren = _itemHelper.FindAndReturnChildrenAsItems(items, chosenItem.Id);
+
+ // Ensure all IDs are unique
+ itemWithChildren = _itemHelper.ReplaceIDs(_cloner.Clone(itemWithChildren));
+
+ if (_locationConfig.TplsToStripChildItemsFrom.Contains(chosenItem.Template)) {
+ // Strip children from parent before adding
+ itemWithChildren = [itemWithChildren[0]];
+ }
+
+ itemWithMods.AddRange(itemWithChildren);
+ }
+
+ // Get inventory size of item
+ var size = _itemHelper.GetItemSize(itemWithMods, itemWithMods[0].Id);
+
+ return new ContainerItem { Items = itemWithMods, Width = size.Width, Height = size.Height };
+ }
+
+ private double GetLooseLootMultiplerForLocation(string location)
+ {
+ return _locationConfig.LooseLootMultiplier[location];
+ }
+
+ protected double getStaticLootMultiplerForLocation(string location) {
+ return _locationConfig.StaticLootMultiplier[location];
+ }
+
+
+ // TODO: rewrite, BIG yikes
+ protected ContainerItem? CreateStaticLootItem(
+ string chosenTpl,
+ Dictionary> staticAmmoDist,
+ string? parentId = null)
+ {
+ var itemTemplate = _itemHelper.GetItem(chosenTpl).Value;
+ if (itemTemplate.Properties is null)
+ {
+ _logger.Error($"Unable to process item: ${{chosenTpl}}. it lacks _props");
+
+ return null;
+ }
+
+ var width = itemTemplate.Properties.Width;
+ var height = itemTemplate.Properties.Height;
+ List
- items = [new Item { Id = _hashUtil.Generate(), Template = chosenTpl }];
+ var rootItem = items.FirstOrDefault();
+
+ // Use passed in parentId as override for new item
+ if (!string.IsNullOrEmpty(parentId))
+ {
+ rootItem.ParentId = parentId;
+ }
+
+ if (
+ _itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.MONEY) ||
+ _itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.AMMO)
+ )
+ {
+ // Edge case - some ammos e.g. flares or M406 grenades shouldn't be stacked
+ var stackCount = itemTemplate.Properties.StackMaxSize == 1
+ ? 1
+ : _randomUtil.GetInt((int)(itemTemplate.Properties.StackMinRandom), (int)(itemTemplate.Properties.StackMaxRandom));
+
+ rootItem.Upd = new Upd { StackObjectsCount = stackCount };
+ }
+ // No spawn point, use default template
+ else if (_itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.WEAPON))
+ {
+ List
- children = [];
+ var defaultPreset = _cloner.Clone(_presetHelper.GetDefaultPreset(chosenTpl));
+ if (defaultPreset?.Items is not null)
+ {
+ try
+ {
+ children = _itemHelper.ReparentItemAndChildren(defaultPreset.Items[0], defaultPreset.Items);
+ }
+ catch (Exception e)
+ {
+ // this item already broke it once without being reproducible tpl = "5839a40f24597726f856b511"; AKS-74UB Default
+ // 5ea03f7400685063ec28bfa8 // ppsh default
+ // 5ba26383d4351e00334c93d9 //mp7_devgru
+ _logger.Error(
+ _localisationService.GetText(
+ "location-preset_not_found",
+ new
+ {
+ tpl = chosenTpl,
+ defaultId = defaultPreset.Id,
+ defaultName = defaultPreset.Name,
+ parentId,
+ }
+ )
+ );
+
+ throw;
+ }
+ }
+ else
+ {
+ // RSP30 (62178be9d0050232da3485d9/624c0b3340357b5f566e8766/6217726288ed9f0845317459) doesn't have any default presets and kills this code below as it has no chidren to reparent
+ _logger.Debug($"createStaticLootItem() No preset found for weapon: {chosenTpl}");
+ }
+
+ rootItem = items[0];
+ if (rootItem is null)
+ {
+ _logger.Error(
+ _localisationService.GetText(
+ "location-missing_root_item",
+ new
+ {
+ tpl = chosenTpl,
+ parentId,
+ }
+ )
+ );
+
+ throw new Exception(_localisationService.GetText("location-critical_error_see_log"));
+ }
+
+ try
+ {
+ if (children?.Count > 0)
+ {
+ items = _itemHelper.ReparentItemAndChildren(rootItem, children);
+ }
+ }
+ catch (Exception e)
+ {
+ _logger.Error(
+ _localisationService.GetText(
+ "location-unable_to_reparent_item",
+ new
+ {
+ tpl = chosenTpl,
+ parentId = parentId,
+ }
+ )
+ );
+
+ throw;
+ }
+
+ // Here we should use generalized BotGenerators functions e.g. fillExistingMagazines in the future since
+ // it can handle revolver ammo (it's not restructured to be used here yet.)
+ // General: Make a WeaponController for Ragfair preset stuff and the generating weapons and ammo stuff from
+ // BotGenerator
+ var magazine = items.FirstOrDefault(item => item.SlotId == "mod_magazine");
+ // some weapon presets come without magazine; only fill the mag if it exists
+ if (magazine is not null)
+ {
+ var magTemplate = _itemHelper.GetItem(magazine.Template).Value;
+ var weaponTemplate = _itemHelper.GetItem(chosenTpl).Value;
+
+ // Create array with just magazine
+ var defaultWeapon = _itemHelper.GetItem(rootItem.Template).Value;
+ List
- magazineWithCartridges = [magazine];
+ _itemHelper.FillMagazineWithRandomCartridge(
+ magazineWithCartridges,
+ magTemplate,
+ staticAmmoDist,
+ weaponTemplate.Properties.AmmoCaliber,
+ 0.25,
+ defaultWeapon.Properties.DefAmmo,
+ defaultWeapon
+ );
+
+ // Replace existing magazine with above array
+ items.Remove(magazine);
+ items.AddRange(magazineWithCartridges);
+ }
+
+ var size = _itemHelper.GetItemSize(items, rootItem.Id);
+ width = size.Width;
+ height = size.Height;
+ }
+ // No spawnpoint to fall back on, generate manually
+ else if (_itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.AMMO_BOX))
+ {
+ _itemHelper.AddCartridgesToAmmoBox(items, itemTemplate);
+ }
+ else if (_itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.MAGAZINE))
+ {
+ if (_randomUtil.GetChance100(_locationConfig.MagazineLootHasAmmoChancePercent))
+ {
+ // Create array with just magazine
+ List
- magazineWithCartridges = [rootItem];
+ _itemHelper.FillMagazineWithRandomCartridge(
+ magazineWithCartridges,
+ itemTemplate,
+ staticAmmoDist,
+ null,
+ _locationConfig.MinFillStaticMagazinePercent / 100
+ );
+
+ // Replace existing magazine with above array
+ items.Remove(rootItem);
+ items.AddRange(magazineWithCartridges);
+ }
+ }
+ else if (_itemHelper.ArmorItemCanHoldMods(chosenTpl))
+ {
+ var defaultPreset = _presetHelper.GetDefaultPreset(chosenTpl);
+ if (defaultPreset is not null)
+ {
+ List
- presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(defaultPreset.Items));
+ _itemHelper.RemapRootItemId(presetAndMods);
+
+ // Use original items parentId otherwise item doesnt get added to container correctly
+ presetAndMods[0].ParentId = rootItem.ParentId;
+ items = presetAndMods;
+ }
+ else
+ {
+ // We make base item above, at start of function, no need to do it here
+ if ((itemTemplate.Properties.Slots?.Count ?? 0) > 0)
+ {
+ items = _itemHelper.AddChildSlotItems(
+ items,
+ itemTemplate,
+ _locationConfig.EquipmentLootSettings.ModSpawnChancePercent
+ );
+ }
+ }
+ }
+
+ return new ContainerItem { Items = items, Width = width, Height = height };
}
}
public class ContainerGroupCount
{
[JsonPropertyName("containerIdsWithProbability")]
- public Dictionary ContainerIdsWithProbability { get; set; }
+ public Dictionary? ContainerIdsWithProbability { get; set; }
[JsonPropertyName("chosenCount")]
- public int ChosenCount { get; set; }
+ public double? ChosenCount { get; set; }
}
public class ContainerItem
{
[JsonPropertyName("items")]
- public List
- Items { get; set; }
+ public List
- ? Items { get; set; }
[JsonPropertyName("width")]
- public int Width { get; set; }
+ public double? Width { get; set; }
[JsonPropertyName("height")]
- public int Height { get; set; }
+ public double? Height { get; set; }
}
diff --git a/Libraries/Core/Generators/LootGenerator.cs b/Libraries/Core/Generators/LootGenerator.cs
index 516c63a3..411a4b58 100644
--- a/Libraries/Core/Generators/LootGenerator.cs
+++ b/Libraries/Core/Generators/LootGenerator.cs
@@ -1,15 +1,32 @@
-using System.Text.Json.Serialization;
+using System.Text.Json.Serialization;
+using Core.Helpers;
using SptCommon.Annotations;
using Core.Models.Common;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
+using Core.Models.Enums;
using Core.Models.Spt.Config;
using Core.Models.Spt.Services;
+using Core.Models.Utils;
+using Core.Services;
+using Core.Utils;
+using Core.Utils.Cloners;
namespace Core.Generators;
[Injectable]
public class LootGenerator(
+ ISptLogger _logger,
+ RandomUtil _randomUtil,
+ HashUtil _hashUtil,
+ ItemHelper _itemHelper,
+ PresetHelper _presetHelper,
+ DatabaseService _databaseService,
+ ItemFilterService _itemFilterService,
+ LocalisationService _localisationService,
+ WeightedRandomHelper _weightedRandomHelper,
+ RagfairLinkedItemService _ragfairLinkedItemService,
+ ICloner _cloner
)
{
@@ -20,7 +37,105 @@ public class LootGenerator(
/// An array of loot items
public List
- CreateRandomLoot(LootRequest options)
{
- throw new NotImplementedException();
+ var result = new List
- ();
+ var itemTypeCounts = InitItemLimitCounter(options.ItemLimits);
+
+ // Handle sealed weapon containers
+ var sealedWeaponCrateCount = _randomUtil.GetDouble(
+ options.WeaponCrateCount.Min.Value,
+ options.WeaponCrateCount.Max.Value);
+ if (sealedWeaponCrateCount > 0) {
+ // Get list of all sealed containers from db - they're all the same, just for flavor
+ var itemsDb = _itemHelper.GetItems();
+ var sealedWeaponContainerPool = (itemsDb).Where((item) =>
+ item.Name.Contains("event_container_airdrop"));
+
+ for (var index = 0; index < sealedWeaponCrateCount; index++) {
+ // Choose one at random + add to results array
+ var chosenSealedContainer = _randomUtil.GetArrayValue(sealedWeaponContainerPool);
+ result.Add( new Item{
+ Id = _hashUtil.Generate(),
+ Template = chosenSealedContainer.Id,
+ Upd = new Upd{
+ StackObjectsCount = 1,
+ SpawnedInSession = true
+ },
+ });
+ }
+ }
+
+ // Get items from items.json that have a type of item + not in global blacklist + base type is in whitelist
+ var rewardPoolResults = GetItemRewardPool(
+ options.ItemBlacklist,
+ options.ItemTypeWhitelist,
+ options.UseRewardItemBlacklist.GetValueOrDefault(false),
+ options.AllowBossItems.GetValueOrDefault(false));
+
+ // Pool has items we could add as loot, proceed
+ if (rewardPoolResults.ItemPool.Count > 0) {
+ var randomisedItemCount = _randomUtil.GetDouble(options.ItemCount.Min.Value, options.ItemCount.Max.Value);
+ for (var index = 0; index < randomisedItemCount; index++) {
+ if (!FindAndAddRandomItemToLoot(rewardPoolResults.ItemPool, itemTypeCounts, options, result)) {
+ // Failed to add, reduce index so we get another attempt
+ index--;
+ }
+ }
+ }
+
+ var globalDefaultPresets = _presetHelper.GetDefaultPresets().Values;
+
+ // Filter default presets to just weapons
+ var randomisedWeaponPresetCount = _randomUtil.GetDouble(
+ options.WeaponPresetCount.Min.Value,
+ options.WeaponPresetCount.Max.Value);
+ if (randomisedWeaponPresetCount > 0) {
+ var weaponDefaultPresets = globalDefaultPresets.Where((preset) =>
+ _itemHelper.IsOfBaseclass(preset.Encyclopedia, BaseClasses.WEAPON)).ToList();
+
+ if (weaponDefaultPresets.Any()) {
+ for (var index = 0; index < randomisedWeaponPresetCount; index++) {
+ if (
+ !FindAndAddRandomPresetToLoot(
+ weaponDefaultPresets,
+ itemTypeCounts,
+ rewardPoolResults.Blacklist,
+ result)
+ ) {
+ // Failed to add, reduce index so we get another attempt
+ index--;
+ }
+ }
+ }
+ }
+
+ // Filter default presets to just armors and then filter again by protection level
+ var randomisedArmorPresetCount = _randomUtil.GetDouble(
+ options.ArmorPresetCount.Min.Value,
+ options.ArmorPresetCount.Max.Value);
+ if (randomisedArmorPresetCount > 0) {
+ var armorDefaultPresets = globalDefaultPresets.Where((preset) =>
+ _itemHelper.ArmorItemCanHoldMods(preset.Encyclopedia));
+ var levelFilteredArmorPresets = armorDefaultPresets.Where((armor) =>
+ IsArmorOfDesiredProtectionLevel(armor, options)).ToList();
+
+ // Add some armors to rewards
+ if (levelFilteredArmorPresets.Any()) {
+ for (var index = 0; index < randomisedArmorPresetCount; index++) {
+ if (
+ !FindAndAddRandomPresetToLoot(
+ levelFilteredArmorPresets,
+ itemTypeCounts,
+ rewardPoolResults.Blacklist,
+ result)
+ ) {
+ // Failed to add, reduce index so we get another attempt
+ index--;
+ }
+ }
+ }
+ }
+
+ return result;
}
///
@@ -31,7 +146,29 @@ public class LootGenerator(
/// Array of Item
public List
- CreateForcedLoot(Dictionary forcedLootDict)
{
- throw new NotImplementedException();
+ var result = new List
- ();
+
+ var forcedItems = forcedLootDict;
+
+ foreach (var forcedItemKvP in forcedItems) {
+ var details = forcedLootDict[forcedItemKvP.Key];
+ var randomisedItemCount = _randomUtil.GetDouble(details.Min.Value, details.Max.Value);
+
+ // Add forced loot item to result
+ var newLootItem = new Item{
+ Id = _hashUtil.Generate(),
+ Template = forcedItemKvP.Key,
+ Upd = new Upd{
+ StackObjectsCount = randomisedItemCount,
+ SpawnedInSession = true,
+ },
+ };
+
+ var splitResults = _itemHelper.SplitStack(newLootItem);
+ result.AddRange(splitResults);
+ }
+
+ return result;
}
///
@@ -42,22 +179,78 @@ public class LootGenerator(
/// Should item.json reward item config be used
/// Should boss items be allowed in result
/// results of filtering + blacklist used
- protected object GetItemRewardPool(List itemTplBlacklist, List itemTypeWhitelist,
- bool useRewardItemBlacklist, // TODO: type fuckery, return type was { itemPool: [string, ITemplateItem][]; blacklist: Set }
+ protected ItemRewardPoolResults GetItemRewardPool(List itemTplBlacklist, List itemTypeWhitelist,
+ bool useRewardItemBlacklist,
bool allowBossItems)
{
- throw new NotImplementedException();
+ var itemsDb = _databaseService.GetItems().Values;
+ var itemBlacklist = new HashSet();
+ itemBlacklist.UnionWith(_itemFilterService.GetBlacklistedItems());
+ itemBlacklist.UnionWith(itemTplBlacklist);
+
+ if (useRewardItemBlacklist)
+ {
+ var itemsToAdd = _itemFilterService.GetItemRewardBlacklist();
+
+ // Get all items that match the blacklisted types and fold into item blacklist
+ var itemTypeBlacklist = _itemFilterService.GetItemRewardBaseTypeBlacklist();
+ var itemsMatchingTypeBlacklist = (itemsDb)
+ .Where((templateItem) => _itemHelper.IsOfBaseclasses(templateItem.Parent, itemTypeBlacklist))
+ .Select((templateItem) => templateItem.Id);
+
+ // Clear out blacklist
+ itemBlacklist = [];
+ itemBlacklist.UnionWith(itemBlacklist);
+ itemBlacklist.UnionWith(itemsToAdd);
+ itemBlacklist.UnionWith(itemsMatchingTypeBlacklist);
+ }
+
+ if (!allowBossItems)
+ {
+ foreach (var bossItem in _itemFilterService.GetBossItems()) {
+ itemBlacklist.Add(bossItem);
+ }
+ }
+
+ var items = itemsDb.Where(
+ (item) =>
+ !itemBlacklist.Contains(item.Id) &&
+ item.Type.ToLower() == "item" &&
+ !item.Properties.QuestItem.GetValueOrDefault(false) &&
+ itemTypeWhitelist.Contains(item.Parent)).ToList();
+
+ return new ItemRewardPoolResults{ ItemPool = items, Blacklist = itemBlacklist };
+ }
+
+ public record ItemRewardPoolResults
+ {
+ public List ItemPool { get; set; }
+ public HashSet Blacklist { get; set; }
}
///
- /// Filter armor items by their front plates protection level - top if its a helmet
+ /// Filter armor items by their front plates protection level - top if it's a helmet
///
/// Armor preset to check
/// Loot request options - armor level etc
/// True if item has desired armor level
- protected bool ArmorOfDesiredProtectionLevel(Preset armor, LootRequest options)
+ protected bool IsArmorOfDesiredProtectionLevel(Preset armor, LootRequest options)
{
- throw new NotImplementedException();
+ string[] relevantSlots = ["front_plate", "helmet_top", "soft_armor_front"];
+ foreach (var slotId in relevantSlots) {
+ var armorItem = armor.Items.FirstOrDefault((item) => item?.SlotId?.ToLower() == slotId);
+ if (armorItem is null)
+ {
+ continue;
+ }
+
+ var armorDetails = _itemHelper.GetItem(armorItem.Template).Value;
+ var armorClass = armorDetails.Properties.ArmorClass;
+
+ return options.ArmorLevelWhitelist.Contains((int)armorClass.Value);
+ }
+
+ return false;
}
///
@@ -65,9 +258,14 @@ public class LootGenerator(
///
/// limits as defined in config
/// record, key: item tplId, value: current/max item count allowed
- protected Dictionary InitItemLimitCounter(Dictionary limits)
+ private Dictionary InitItemLimitCounter(Dictionary limits)
{
- throw new NotImplementedException();
+ var itemTypeCounts = new Dictionary();
+ foreach (var itemTypeId in limits) {
+ itemTypeCounts[itemTypeId.Key] = new ItemLimit() { Current = 0, Max = limits[itemTypeId.Key] };
+ }
+
+ return itemTypeCounts;
}
///
@@ -78,11 +276,46 @@ public class LootGenerator(
/// item filters
/// array to add found item to
/// true if item was valid and added to pool
- protected bool FindAndAddRandomItemToLoot(object items, object itemTypeCounts,
- LootRequest options, // TODO: items type was [string, ITemplateItem][], itemTypeCounts was Record
+ protected bool FindAndAddRandomItemToLoot(List items, Dictionary itemTypeCounts,
+ LootRequest options,
List
- result)
{
- throw new NotImplementedException();
+ var randomItem = _randomUtil.GetArrayValue(items);
+
+ var itemLimitCount = itemTypeCounts.TryGetValue(randomItem.Parent, out var randomItemLimitCount);
+ if (!itemLimitCount && randomItemLimitCount?.Current > randomItemLimitCount?.Max) {
+ return false;
+ }
+
+ // Skip armors as they need to come from presets
+ if (_itemHelper.ArmorItemCanHoldMods(randomItem.Id)) {
+ return false;
+ }
+
+ var newLootItem = new Item {
+ Id = _hashUtil.Generate(),
+ Template = randomItem.Id,
+ Upd = new Upd {
+ StackObjectsCount = 1,
+ SpawnedInSession = true,
+ },
+ };
+
+ // Special case - handle items that need a stackcount > 1
+ if (randomItem.Properties.StackMaxSize > 1) {
+ newLootItem.Upd.StackObjectsCount = GetRandomisedStackCount(randomItem, options);
+ }
+
+ newLootItem.Template = randomItem.Id;
+ result.Add(newLootItem);
+
+ if (randomItemLimitCount is not null) {
+ // Increment item count as it's in limit array
+ randomItemLimitCount.Current++;
+ }
+
+ // Item added okay
+ return true;
}
///
@@ -93,7 +326,15 @@ public class LootGenerator(
/// stack count
protected int GetRandomisedStackCount(TemplateItem item, LootRequest options)
{
- throw new NotImplementedException();
+ var min = item.Properties.StackMinRandom;
+ var max = item.Properties.StackMaxSize;
+
+ if (options.ItemStackLimits.TryGetValue(item.Id, out var itemLimits)) {
+ min = itemLimits.Min;
+ max = (int?)itemLimits.Max;
+ }
+
+ return _randomUtil.GetInt((int)(min ?? 1), max ?? 1);
}
///
@@ -104,11 +345,66 @@ public class LootGenerator(
/// Items to skip
/// List to add chosen preset to
/// true if preset was valid and added to pool
- protected bool FindAndAddRandomPresetToLoot(List presetPool, object itemTypeCounts,
- List itemBlacklist, // TODO: type fuckery, itemTypeCounts was Record
+ protected bool FindAndAddRandomPresetToLoot(List presetPool,
+ Dictionary itemTypeCounts,
+ HashSet itemBlacklist,
List
- result)
{
- throw new NotImplementedException();
+ // Choose random preset and get details from item db using encyclopedia value (encyclopedia === tplId)
+ var chosenPreset = _randomUtil.GetArrayValue(presetPool);
+ if (chosenPreset is null ) {
+ _logger.Warning("Unable to find random preset in given presets, skipping");
+
+ return false;
+ }
+
+ // No `_encyclopedia` property, not possible to reliably get root item tpl
+ if (chosenPreset.Encyclopedia is null) {
+ _logger.Debug("$Preset with id: {chosenPreset?.Id} lacks encyclopedia property, skipping");
+
+ return false;
+ }
+
+ // Get preset root item db details via its `_encyclopedia` property
+ var itemDbDetails = _itemHelper.GetItem(chosenPreset.Encyclopedia);
+ if (!itemDbDetails.Key) {
+ _logger.Debug($"$Unable to find preset with tpl: {chosenPreset.Encyclopedia}, skipping");
+
+ return false;
+ }
+
+ // Skip preset if root item is blacklisted
+ if (itemBlacklist.Contains(chosenPreset.Items[0].Template)) {
+ return false;
+ }
+
+ // Some custom mod items lack a parent property
+ if (itemDbDetails.Value.Parent is null) {
+ _logger.Error(_localisationService.GetText("loot-item_missing_parentid", itemDbDetails.Value?.Name));
+
+ return false;
+ }
+
+ // Check chosen preset hasn't exceeded spawn limit
+ var hasItemLimitCount = itemTypeCounts.TryGetValue(itemDbDetails.Value.Parent, out var itemLimitCount);
+ if (!hasItemLimitCount && itemLimitCount?.Current > itemLimitCount?.Max) {
+ return false;
+ }
+
+ var presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(chosenPreset.Items));
+ _itemHelper.RemapRootItemId(presetAndMods);
+ // Add chosen preset tpl to result array
+ foreach (var item in presetAndMods) {
+ result.Add(item);
+ }
+
+ if (itemLimitCount is not null) {
+ // Increment item count as item has been chosen and its inside itemLimitCount dictionary
+ itemLimitCount.Current++;
+ }
+
+ // Item added okay
+ return true;
}
///
@@ -118,7 +414,52 @@ public class LootGenerator(
/// List of items with children lists
public List
> GetSealedWeaponCaseLoot(SealedAirdropContainerSettings containerSettings)
{
- throw new NotImplementedException();
+ List> itemsToReturn = [];
+
+ // Choose a weapon to give to the player (weighted)
+ var chosenWeaponTpl = _weightedRandomHelper.GetWeightedValue(
+ containerSettings.WeaponRewardWeight
+ );
+
+ // Get itemDb details of weapon
+ var weaponDetailsDb = _itemHelper.GetItem(chosenWeaponTpl);
+ if (!weaponDetailsDb.Key) {
+ _logger.Error(
+ _localisationService.GetText("loot-non_item_picked_as_sealed_weapon_crate_reward", chosenWeaponTpl)
+ );
+
+ return itemsToReturn;
+ }
+
+ // Get weapon preset - default or choose a random one from globals.json preset pool
+ var chosenWeaponPreset = containerSettings.DefaultPresetsOnly
+ ? _presetHelper.GetDefaultPreset(chosenWeaponTpl)
+ : _randomUtil.GetArrayValue(_presetHelper.GetPresets(chosenWeaponTpl));
+
+ // No default preset found for weapon, choose a random one
+ if (chosenWeaponPreset is null) {
+ _logger.Warning(
+ _localisationService.GetText("loot-default_preset_not_found_using_random", chosenWeaponTpl)
+ );
+ chosenWeaponPreset = _randomUtil.GetArrayValue(_presetHelper.GetPresets(chosenWeaponTpl));
+ }
+
+ // Clean up Ids to ensure they're all unique and prevent collisions
+ var presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(chosenWeaponPreset.Items));
+ _itemHelper.RemapRootItemId(presetAndMods);
+
+ // Add preset to return object
+ itemsToReturn.Add(presetAndMods);
+
+ // Get a random collection of weapon mods related to chosen weawpon and add them to result array
+ var linkedItemsToWeapon = _ragfairLinkedItemService.GetLinkedDbItems(chosenWeaponTpl);
+ itemsToReturn.AddRange(GetSealedContainerWeaponModRewards(containerSettings, linkedItemsToWeapon, chosenWeaponPreset)
+ );
+
+ // Handle non-weapon mod reward types
+ itemsToReturn.AddRange((GetSealedContainerNonWeaponModRewards(containerSettings, weaponDetailsDb.Value)));
+
+ return itemsToReturn;
}
///
@@ -130,7 +471,69 @@ public class LootGenerator(
protected List> GetSealedContainerNonWeaponModRewards(SealedAirdropContainerSettings containerSettings,
TemplateItem weaponDetailsDb)
{
- throw new NotImplementedException();
+ List> rewards = [];
+
+ foreach (var (rewardKey,settings) in containerSettings.RewardTypeLimits) {
+ var rewardCount = _randomUtil.GetDouble(settings.Min.Value, settings.Max.Value);
+
+ if (rewardCount == 0) {
+ continue;
+ }
+
+ // Edge case - ammo boxes
+ if (rewardKey == BaseClasses.AMMO_BOX) {
+ // Get ammoboxes from db
+ var ammoBoxesDetails = containerSettings.AmmoBoxWhitelist.Select((tpl) => {
+ var itemDetails = _itemHelper.GetItem(tpl);
+ return itemDetails.Value;
+ });
+
+ // Need to find boxes that matches weapons caliber
+ var weaponCaliber = weaponDetailsDb.Properties.AmmoCaliber;
+ var ammoBoxesMatchingCaliber = ammoBoxesDetails.Where((x) =>
+ x.Properties.AmmoCaliber == weaponCaliber);
+ if (!ammoBoxesMatchingCaliber.Any()) {
+ _logger.Debug($"No ammo box with caliber {weaponCaliber} found, skipping");
+
+ continue;
+ }
+
+ for (var index = 0; index < rewardCount; index++) {
+ var chosenAmmoBox = _randomUtil.GetArrayValue(ammoBoxesMatchingCaliber);
+ var ammoBoxReward = new List- { new() { Id = _hashUtil.Generate(), Template = chosenAmmoBox.Id } };
+ _itemHelper.AddCartridgesToAmmoBox(ammoBoxReward, chosenAmmoBox);
+ rewards.Add(ammoBoxReward);
+ }
+
+ continue;
+ }
+
+ // Get all items of the desired type + not quest items + not globally blacklisted
+ var rewardItemPool = _databaseService.GetItems().Values.Where(
+ (item) =>
+ item.Parent == rewardKey &&
+ item.Type.ToLower() == "item" &&
+ _itemFilterService.IsItemBlacklisted(item.Id) &&
+ !(containerSettings.AllowBossItems || _itemFilterService.IsBossItem(item.Id)) &&
+ item.Properties.QuestItem is null
+ );
+
+ if (rewardItemPool.Count() == 0) {
+ _logger.Debug($"No items with base type of {rewardKey} found, skipping");
+
+ continue;
+ }
+
+ for (var index = 0; index < rewardCount; index++) {
+ // Choose a random item from pool
+ var chosenRewardItem = _randomUtil.GetArrayValue(rewardItemPool);
+ var rewardItem = new List
- { new() { Id = _hashUtil.Generate(), Template = chosenRewardItem.Id } };
+
+ rewards.Add(rewardItem);
+ }
+ }
+
+ return rewards;
}
///
@@ -143,7 +546,37 @@ public class LootGenerator(
protected List
> GetSealedContainerWeaponModRewards(SealedAirdropContainerSettings containerSettings, List linkedItemsToWeapon,
Preset chosenWeaponPreset)
{
- throw new NotImplementedException();
+ List> modRewards = [];
+
+ foreach (var (rewardKey,settings) in containerSettings.WeaponModRewardLimits) {
+ var rewardCount = _randomUtil.GetDouble(settings.Min.Value, settings.Max.Value);
+
+ // Nothing to add, skip reward type
+ if (rewardCount == 0) {
+ continue;
+ }
+
+ // Get items that fulfil reward type criteria from items that fit on gun
+ var relatedItems = linkedItemsToWeapon?.Where(
+ (item) => item?.Parent == rewardKey && !_itemFilterService.IsItemBlacklisted(item.Id)
+ );
+ if (relatedItems is null || relatedItems.Count() == 0) {
+ _logger.Debug(
+ $"No items found to fulfil reward type: {rewardKey} for weapon: {chosenWeaponPreset.Name}, skipping type"
+ );
+ continue;
+ }
+
+ // Find a random item of the desired type and add as reward
+ for (var index = 0; index < rewardCount; index++) {
+ var chosenItem = _randomUtil.DrawRandomFromList(relatedItems.ToList());
+ var reward = new List- { new Item() { Id = _hashUtil.Generate(), Template = chosenItem[0].Id } };
+
+ modRewards.Add(reward);
+ }
+ }
+
+ return modRewards;
}
///
diff --git a/Libraries/Core/Generators/PlayerScavGenerator.cs b/Libraries/Core/Generators/PlayerScavGenerator.cs
index e4645ac7..d109a035 100644
--- a/Libraries/Core/Generators/PlayerScavGenerator.cs
+++ b/Libraries/Core/Generators/PlayerScavGenerator.cs
@@ -51,11 +51,12 @@ public class PlayerScavGenerator(
var scavKarmaLevel = GetScavKarmaLevel(pmcDataClone);
// use karma level to get correct karmaSettings
- var playerScavKarmaSettings = _playerScavConfig.KarmaLevel[scavKarmaLevel.ToString()];
- if (playerScavKarmaSettings == null)
+ if (!_playerScavConfig.KarmaLevel.TryGetValue(scavKarmaLevel.ToString(), out var playerScavKarmaSettings))
+ {
_logger.Error(_localisationService.GetText("scav-missing_karma_settings", scavKarmaLevel));
+ }
- _logger.Debug($"generated player scav loadout with karma level {scavKarmaLevel}");
+ _logger.Debug($"Generated player scav loadout with karma level {scavKarmaLevel}");
// Edit baseBotNode values
var baseBotNode = ConstructBotBaseTemplate(playerScavKarmaSettings.BotTypeForLoot);
@@ -80,7 +81,7 @@ public class PlayerScavGenerator(
scavData.Info.Bans = [];
scavData.Info.RegistrationDate = pmcDataClone.Info.RegistrationDate;
scavData.Info.GameVersion = pmcDataClone.Info.GameVersion;
- scavData.Info.MemberCategory = MemberCategory.UNIQUE_ID;
+ scavData.Info.MemberCategory = MemberCategory.UniqueId;
scavData.Info.LockedMoveCommands = true;
scavData.RagfairInfo = pmcDataClone.RagfairInfo;
scavData.UnlockedInfo = pmcDataClone.UnlockedInfo;
diff --git a/Libraries/Core/Generators/RagfairAssortGenerator.cs b/Libraries/Core/Generators/RagfairAssortGenerator.cs
index 30da3261..ceb019e8 100644
--- a/Libraries/Core/Generators/RagfairAssortGenerator.cs
+++ b/Libraries/Core/Generators/RagfairAssortGenerator.cs
@@ -7,6 +7,7 @@ using Core.Models.Spt.Config;
using Core.Servers;
using Core.Services;
using Core.Utils;
+using Core.Utils.Cloners;
namespace Core.Generators;
@@ -16,7 +17,8 @@ public class RagfairAssortGenerator(
ItemHelper itemHelper,
PresetHelper presetHelper,
SeasonalEventService seasonalEventService,
- ConfigServer configServer
+ ConfigServer configServer,
+ ICloner _cloner
)
{
protected List
> generatedAssortItems = [];
@@ -77,7 +79,7 @@ public class RagfairAssortGenerator(
foreach (var preset in presets)
{
// Update Ids and clone
- var presetAndMods = itemHelper.ReplaceIDs(preset.Items);
+ var presetAndMods = itemHelper.ReplaceIDs(_cloner.Clone(preset.Items));
itemHelper.RemapRootItemId(presetAndMods);
// Add presets base item tpl to the processed list so its skipped later on when processing items
diff --git a/Libraries/Core/Generators/RagfairOfferGenerator.cs b/Libraries/Core/Generators/RagfairOfferGenerator.cs
index 00ab6b28..7a8c7760 100644
--- a/Libraries/Core/Generators/RagfairOfferGenerator.cs
+++ b/Libraries/Core/Generators/RagfairOfferGenerator.cs
@@ -157,7 +157,7 @@ public class RagfairOfferGenerator(
if (isTrader) {
return new RagfairOfferUser(){
Id = userID,
- MemberType = MemberCategory.TRADER
+ MemberType = MemberCategory.Trader
};
}
diff --git a/Libraries/Core/Generators/ScavCaseRewardGenerator.cs b/Libraries/Core/Generators/ScavCaseRewardGenerator.cs
index 624f1062..9cc71f1a 100644
--- a/Libraries/Core/Generators/ScavCaseRewardGenerator.cs
+++ b/Libraries/Core/Generators/ScavCaseRewardGenerator.cs
@@ -11,6 +11,7 @@ using Core.Models.Utils;
using Core.Servers;
using Core.Services;
using Core.Utils;
+using Core.Utils.Cloners;
using SptCommon.Extensions;
namespace Core.Generators;
@@ -26,7 +27,8 @@ public class ScavCaseRewardGenerator(
RagfairPriceService _ragfairPriceService,
SeasonalEventService _seasonalEventService,
ItemFilterService _itemFilterService,
- ConfigServer _configServer
+ ConfigServer _configServer,
+ ICloner _cloner
)
{
protected ScavCaseConfig _scavCaseConfig = _configServer.GetConfig();
@@ -312,7 +314,7 @@ public class ScavCaseRewardGenerator(
}
// Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory
- List- presetAndMods = _itemHelper.ReplaceIDs(preset.Items);
+ List
- presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(preset.Items));
_itemHelper.RemapRootItemId(presetAndMods);
resultItem = presetAndMods;
diff --git a/Libraries/Core/Helpers/BotGeneratorHelper.cs b/Libraries/Core/Helpers/BotGeneratorHelper.cs
index 4631c44b..2da6c971 100644
--- a/Libraries/Core/Helpers/BotGeneratorHelper.cs
+++ b/Libraries/Core/Helpers/BotGeneratorHelper.cs
@@ -45,29 +45,34 @@ public class BotGeneratorHelper(
_botConfig.LootItemResourceRandomization.TryGetValue(botRole, out var randomisationSettings);
Upd itemProperties = new();
+ var hasProperties = false;
- if (itemTemplate?.Properties?.MaxDurability is not null)
+ if (itemTemplate?.Properties?.MaxDurability is not null && itemTemplate.Properties.MaxDurability > 0)
{
if (itemTemplate.Properties.WeapClass is not null)
{
// Is weapon
itemProperties.Repairable = GenerateWeaponRepairableProperties(itemTemplate, botRole);
+ hasProperties = true;
}
else if (itemTemplate.Properties.ArmorClass is not null)
{
// Is armor
itemProperties.Repairable = GenerateArmorRepairableProperties(itemTemplate, botRole);
+ hasProperties = true;
}
}
if (itemTemplate?.Properties?.HasHinge ?? false)
{
itemProperties.Togglable = new UpdTogglable { On = true };
+ hasProperties = true;
}
if (itemTemplate?.Properties?.Foldable ?? false)
{
itemProperties.Foldable = new UpdFoldable { Folded = false };
+ hasProperties = true;
}
if (itemTemplate?.Properties?.WeapFireType?.Count == 0)
@@ -75,6 +80,7 @@ public class BotGeneratorHelper(
itemProperties.FireMode = itemTemplate.Properties.WeapFireType.Contains("fullauto")
? new UpdFireMode { FireMode = "fullauto" }
: new UpdFireMode { FireMode = _randomUtil.GetArrayValue(itemTemplate.Properties.WeapFireType) };
+ hasProperties = true;
}
if (itemTemplate?.Properties?.MaxHpResource is not null)
@@ -86,6 +92,7 @@ public class BotGeneratorHelper(
randomisationSettings?.Meds
)
};
+ hasProperties = true;
}
if (itemTemplate?.Properties?.MaxResource is not null && itemTemplate.Properties?.FoodUseTime is not null)
@@ -97,6 +104,7 @@ public class BotGeneratorHelper(
randomisationSettings?.Food
),
};
+ hasProperties = true;
}
if (itemTemplate?.Parent == BaseClasses.FLASHLIGHT)
@@ -106,6 +114,7 @@ public class BotGeneratorHelper(
? GetBotEquipmentSettingFromConfig(botRole, "lightIsActiveNightChancePercent", 50)
: GetBotEquipmentSettingFromConfig(botRole, "lightIsActiveDayChancePercent", 25);
itemProperties.Light = new UpdLight { IsActive = _randomUtil.GetChance100(lightLaserActiveChance), SelectedMode = 0, };
+ hasProperties = true;
}
else if (itemTemplate?.Parent == BaseClasses.TACTICAL_COMBO)
{
@@ -120,6 +129,7 @@ public class BotGeneratorHelper(
IsActive = _randomUtil.GetChance100(lightLaserActiveChance),
SelectedMode = 0,
};
+ hasProperties = true;
}
if (itemTemplate?.Parent == BaseClasses.NIGHTVISION)
@@ -129,20 +139,23 @@ public class BotGeneratorHelper(
? GetBotEquipmentSettingFromConfig(botRole, "nvgIsActiveChanceNightPercent", 90)
: GetBotEquipmentSettingFromConfig(botRole, "nvgIsActiveChanceDayPercent", 15);
itemProperties.Togglable = new UpdTogglable { On = _randomUtil.GetChance100(nvgActiveChance) };
+ hasProperties = true;
}
// Togglable face shield
- if (!(itemTemplate?.Properties?.HasHinge ?? false) || !(itemTemplate.Properties.FaceShieldComponent ?? false)) return itemProperties;
+ if ((itemTemplate?.Properties?.HasHinge ?? false) && (itemTemplate.Properties.FaceShieldComponent ?? false))
+ {
+ var faceShieldActiveChance = GetBotEquipmentSettingFromConfig(
+ botRole,
+ "faceShieldIsActiveChancePercent",
+ 75
+ );
+ itemProperties.Togglable = new UpdTogglable { On = _randomUtil.GetChance100(faceShieldActiveChance) };
+ hasProperties = true;
+ }
// Get chance from botconfig for bot type, use 75% if no value found
- var faceShieldActiveChance = GetBotEquipmentSettingFromConfig(
- botRole,
- "faceShieldIsActiveChancePercent",
- 75
- );
- itemProperties.Togglable = new UpdTogglable { On = _randomUtil.GetChance100(faceShieldActiveChance) };
-
- return itemProperties;
+ return hasProperties ? itemProperties : null;
}
///
@@ -517,13 +530,17 @@ public class BotGeneratorHelper(
foreach (var slotGrid in value?.Properties?.Grids ?? [])
{
// Grid is empty, skip or item size is bigger than grid
- if (slotGrid.Props?.CellsH == 0 || slotGrid.Props?.CellsV == 0 || itemSize[0] * itemSize[1] > slotGrid.Props?.CellsV * slotGrid.Props?.CellsH)
+ if (slotGrid.Props?.CellsH == 0 ||
+ slotGrid.Props?.CellsV == 0 ||
+ itemSize[0] * itemSize[1] > slotGrid.Props?.CellsV * slotGrid.Props?.CellsH)
+ {
continue;
+ }
// Can't put item type in grid, skip all grids as we're assuming they have the same rules
if (!ItemAllowedInContainer(slotGrid, rootItemTplId))
{
- // Multiple containers, maybe next one allows item, only break out of loop for this containers grids
+ // Multiple containers, maybe next one allows item, only break out of loop for the containers grids
break;
}
@@ -535,44 +552,22 @@ public class BotGeneratorHelper(
// Get root items in container we can iterate over to find out what space is free
var containerItemsToCheck = existingContainerItems.Where(x => x.SlotId == slotGrid.Name);
- var itemsToRemove = new List
- ();
- var itemsToAdd = new List
- ();
- foreach (var item in containerItemsToCheck)
- {
- // Check item in contain for children, store for later insertion into `containerItemsToCheck`
- // (used later when figuring out how much space weapon takes up)
- var itemWithChildItems = _itemHelper.FindAndReturnChildrenAsItems(inventory.Items, item.Id);
- if (itemWithChildItems.Count <= 1) continue;
+ var containerItemsWithChildren = GetContainerItemsWithChildren(containerItemsToCheck, inventory.Items);
-
- // Store replaced item + new Child items to add later as we can't modify a collecting while looking over it
- itemsToRemove.Add(item);
- itemsToAdd.AddRange(itemsToAdd);
- }
-
- // Remove the base items flagged above
- foreach (var item in itemsToRemove)
- {
- existingContainerItems.Remove(item);
- }
-
- // Add item back with its child items
- existingContainerItems.AddRange(itemsToAdd);
-
- // Get rid of items free/used spots in current grid
if (slotGrid.Props is not null)
{
+ // Get rid of an items free/used spots in current grid
var slotGridMap = _inventoryHelper.GetContainerMap(
slotGrid.Props.CellsH.GetValueOrDefault(),
slotGrid.Props.CellsV.GetValueOrDefault(),
- existingContainerItems,
+ containerItemsWithChildren,
container.Id
);
// Try to fit item into grid
var findSlotResult = _containerHelper.FindSlotForItem(slotGridMap, itemSize[0], itemSize[1]);
- // Open slot found, add item to inventory
+ // Free slot found, add item
if (findSlotResult.Success ?? false)
{
var parentItem = itemWithChildren.FirstOrDefault((i) => i.Id == rootItemId);
@@ -620,6 +615,28 @@ public class BotGeneratorHelper(
return ItemAddedResult.NO_SPACE;
}
+ ///
+ /// Take a list of items and check if they need children + add them
+ ///
+ ///
+ ///
+ ///
+ protected List
- GetContainerItemsWithChildren(IEnumerable
- containerItems, List
- inventoryItems)
+ {
+ var result = new List
- ();
+ foreach (var item in containerItems)
+ {
+ // Check item in container for children, store for later insertion into `containerItemsToCheck`
+ // (used later when figuring out how much space weapon takes up)
+ var itemWithChildItems = _itemHelper.FindAndReturnChildrenAsItems(inventoryItems, item.Id);
+
+ // Item had children, replace existing data with item + its children
+ result.AddRange(itemWithChildItems);
+ }
+
+ return result;
+ }
+
///
/// Is the provided item allowed inside a container
///
diff --git a/Libraries/Core/Helpers/Dialogue/AbstractDialogChatBot.cs b/Libraries/Core/Helpers/Dialogue/AbstractDialogChatBot.cs
index bd45ec80..3b5abf35 100644
--- a/Libraries/Core/Helpers/Dialogue/AbstractDialogChatBot.cs
+++ b/Libraries/Core/Helpers/Dialogue/AbstractDialogChatBot.cs
@@ -16,14 +16,80 @@ public abstract class AbstractDialogChatBot(
public abstract UserDialogInfo GetChatBot();
- public string HandleMessage(string sessionId, SendMessageRequest request)
+ public string? HandleMessage(string sessionId, SendMessageRequest request)
{
- throw new NotImplementedException();
+ if ((request.Text ?? "").Length == 0) {
+ _logger.Error("Command came in as empty text! Invalid data!");
+ return request.DialogId;
+ }
+
+ var splitCommand = request.Text.Split(" ");
+
+ var commandos = _chatCommands.Where((c) => c.GetCommandPrefix() == splitCommand.FirstOrDefault());
+ if (commandos.FirstOrDefault()?.GetCommands().Contains(splitCommand[1]) ?? false) {
+ return commandos.FirstOrDefault().Handle(splitCommand[1], GetChatBot(), sessionId, request);
+ }
+
+ if (splitCommand.FirstOrDefault().ToLower() == "help") {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ GetChatBot(),
+ "The available commands will be listed below:",
+ [],
+ null
+ );
+ // due to BSG being dumb with messages we need a mandatory timeout between messages so they get out on the right order
+ // TODO: there must be a better way of doing this
+ _ = new Timer(
+ __ =>
+ {
+ foreach (var chatCommand in _chatCommands)
+ {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ GetChatBot(),
+ $"Commands available for \"{chatCommand.GetCommandPrefix()}\" prefix:", [], null
+ );
+
+ _ = new Timer(
+ ___ =>
+ {
+ foreach (var subCommand in chatCommand.GetCommands())
+ {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ GetChatBot(),
+ $"Subcommand {subCommand}:\\n{chatCommand.GetCommandHelp(subCommand)}",
+ [],
+ null
+ );
+ }
+ }, null, TimeSpan.FromMicroseconds(1000), Timeout.InfiniteTimeSpan
+ );
+ }
+ }, null, TimeSpan.FromMicroseconds(1000), Timeout.InfiniteTimeSpan
+ );
+
+ return request.DialogId;
+ }
+
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ GetChatBot(),
+ GetUnrecognizedCommandMessage(),
+ [],
+ null
+ );
+
+ return null;
}
public void RegisterChatCommand(IChatCommand chatCommand)
{
- throw new NotImplementedException();
+ if (_chatCommands.Any((cc) => cc.GetCommandPrefix() == chatCommand.GetCommandPrefix())) {
+ throw new Exception($"The command \"{chatCommand.GetCommandPrefix()}\" attempting to be registered already exists.");
+ }
+ _chatCommands.Add(chatCommand);
}
protected string GetUnrecognizedCommandMessage()
diff --git a/Libraries/Core/Helpers/Dialogue/Commando/SptCommandoCommands.cs b/Libraries/Core/Helpers/Dialogue/Commando/SptCommandoCommands.cs
index 2f15ef44..0f725d37 100644
--- a/Libraries/Core/Helpers/Dialogue/Commando/SptCommandoCommands.cs
+++ b/Libraries/Core/Helpers/Dialogue/Commando/SptCommandoCommands.cs
@@ -2,34 +2,67 @@
using Core.Helpers.Dialog.Commando.SptCommands;
using Core.Models.Eft.Dialog;
using Core.Models.Eft.Profile;
+using Core.Models.Spt.Config;
+using Core.Servers;
+using Core.Services;
namespace Core.Helpers.Dialog.Commando;
[Injectable]
public class SptCommandoCommands : IChatCommand
{
+ protected List _sptCommands;
+ protected LocalisationService _localisationService;
+ public SptCommandoCommands(
+ ConfigServer configServer,
+ LocalisationService localisationService,
+ IEnumerable sptCommands
+ )
+ {
+ _sptCommands = sptCommands.ToList();
+ _localisationService = localisationService;
+ var coreConfigs = configServer.GetConfig();
+ var commandoId = coreConfigs.Features?.ChatbotFeatures.Ids.GetValueOrDefault("commando");
+ if (!(coreConfigs.Features.ChatbotFeatures.CommandoFeatures.GiveCommandEnabled &&
+ coreConfigs.Features.ChatbotFeatures.EnabledBots.ContainsKey(commandoId)))
+ {
+ var giveCommand = _sptCommands.FirstOrDefault(x => x.GetCommand().ToLower() == "give");
+ _sptCommands.Remove(giveCommand);
+ }
+ }
+
public void RegisterSptCommandoCommand(ISptCommand command)
{
- throw new NotImplementedException();
+ if (_sptCommands.Any((c) => c.GetCommand() == command.GetCommand())) {
+ throw new Exception(
+ _localisationService.GetText(
+ "chat-unable_to_register_command_already_registered",
+ command.GetCommand()
+ )
+ );
+ }
+ _sptCommands.Add(command);
}
-
+
public string GetCommandPrefix()
{
- throw new NotImplementedException();
+ return "spt";
}
public string GetCommandHelp(string command)
{
- throw new NotImplementedException();
+ return _sptCommands.FirstOrDefault(c => c.GetCommand() == command)?.GetCommandHelp();
}
public List GetCommands()
{
- throw new NotImplementedException();
+ return _sptCommands.Select(c => c.GetCommand()).ToList();
}
- public string Handle(string command, UserDialogInfo commandHandler, string sesssionId, SendMessageRequest request)
+ public string Handle(string command, UserDialogInfo commandHandler, string sessionId, SendMessageRequest request)
{
- throw new NotImplementedException();
+ return _sptCommands
+ .Find((c) => c.GetCommand() == command)
+ .PerformAction(commandHandler, sessionId, request);
}
}
diff --git a/Libraries/Core/Helpers/Dialogue/CommandoDialogChatBot.cs b/Libraries/Core/Helpers/Dialogue/CommandoDialogChatBot.cs
index 7d9e0128..243e44d5 100644
--- a/Libraries/Core/Helpers/Dialogue/CommandoDialogChatBot.cs
+++ b/Libraries/Core/Helpers/Dialogue/CommandoDialogChatBot.cs
@@ -28,8 +28,8 @@ public class CommandoDialogChatBot(
Info = new UserDialogDetails
{
Level = 1,
- MemberCategory = MemberCategory.DEVELOPER,
- SelectedMemberCategory = MemberCategory.DEVELOPER,
+ MemberCategory = MemberCategory.Developer,
+ SelectedMemberCategory = MemberCategory.Developer,
Nickname = "Commando",
Side = "Usec"
}
@@ -38,6 +38,6 @@ public class CommandoDialogChatBot(
protected string GetUnrecognizedCommandMessage()
{
- throw new NotImplementedException();
+ return "I'm sorry soldier, I don't recognize the command you are trying to use! Type \"help\" to see available commands.";
}
}
diff --git a/Libraries/Core/Helpers/Dialogue/IDialogueChatBot.cs b/Libraries/Core/Helpers/Dialogue/IDialogueChatBot.cs
index d50269ef..b4f221db 100644
--- a/Libraries/Core/Helpers/Dialogue/IDialogueChatBot.cs
+++ b/Libraries/Core/Helpers/Dialogue/IDialogueChatBot.cs
@@ -6,5 +6,5 @@ namespace Core.Helpers.Dialogue;
public interface IDialogueChatBot
{
public UserDialogInfo GetChatBot();
- public string HandleMessage(string sessionId, SendMessageRequest request);
+ public string? HandleMessage(string sessionId, SendMessageRequest request);
}
diff --git a/Libraries/Core/Helpers/Dialogue/SptDialogueChatBot.cs b/Libraries/Core/Helpers/Dialogue/SptDialogueChatBot.cs
index 9c5c107e..dab7acc3 100644
--- a/Libraries/Core/Helpers/Dialogue/SptDialogueChatBot.cs
+++ b/Libraries/Core/Helpers/Dialogue/SptDialogueChatBot.cs
@@ -7,18 +7,26 @@ using Core.Models.Spt.Config;
using Core.Models.Utils;
using Core.Servers;
using Core.Services;
+using Core.Utils;
namespace Core.Helpers.Dialogue;
[Injectable]
public class SptDialogueChatBot(
ISptLogger _logger,
- MailSendService mailSendService,
- IEnumerable chatCommands,
- ConfigServer configServer
-) : AbstractDialogChatBot(_logger, mailSendService, chatCommands)
+ MailSendService _mailSendService,
+ IEnumerable _chatCommands,
+ ConfigServer _configServer,
+ ProfileHelper _profileHelper,
+ RandomUtil _randomUtil,
+ SeasonalEventService _seasonalEventService,
+ GiftService _giftService,
+ LocalisationService _localisationService
+) : AbstractDialogChatBot(_logger, _mailSendService, _chatCommands)
{
- protected CoreConfig _coreConfig = configServer.GetConfig();
+ protected CoreConfig _coreConfig = _configServer.GetConfig();
+ protected WeatherConfig _weatherConfig = _configServer.GetConfig();
+ protected List _listOfMessages = ["hello", "hi", "sup", "yo", "hey"];
public override UserDialogInfo GetChatBot()
{
@@ -29,16 +37,202 @@ public class SptDialogueChatBot(
Info = new UserDialogDetails
{
Level = 1,
- MemberCategory = MemberCategory.DEVELOPER,
- SelectedMemberCategory = MemberCategory.DEVELOPER,
+ MemberCategory = MemberCategory.Developer,
+ SelectedMemberCategory = MemberCategory.Developer,
Nickname = _coreConfig.SptFriendNickname,
Side = "Usec"
}
};
}
- public string HandleMessage(string sessionId, SendMessageRequest request)
+ public string? HandleMessage(string sessionId, SendMessageRequest request)
{
- throw new NotImplementedException();
+ var sender = _profileHelper.GetPmcProfile(sessionId);
+
+ var sptFriendUser = GetChatBot();
+ var requestInput = request.Text.ToLower();
+
+ // only check if entered text is gift code when feature enabled
+ if (_coreConfig.Features.ChatbotFeatures.SptFriendGiftsEnabled) {
+ var giftSent = _giftService.SendGiftToPlayer(sessionId, request.Text);
+ if (giftSent == GiftSentResult.SUCCESS) {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([
+ "Hey! you got the right code!",
+ "A secret code, how exciting!",
+ "You found a gift code!",
+ "A gift code! incredible",
+ "A gift! what could it be!",
+ ]),
+ [],
+ null
+ );
+
+ return null;
+ }
+
+ if (giftSent == GiftSentResult.FAILED_GIFT_ALREADY_RECEIVED) {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue(["Looks like you already used that code", "You already have that!!"]),
+ [],
+ null
+ );
+
+ return null;
+ }
+ }
+
+ if (requestInput.Contains("love you")) {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([
+ "That's quite forward but i love you too in a purely chatbot-human way",
+ "I love you too buddy :3!",
+ "uwu",
+ $"love you too {sender?.Info?.Nickname}",
+ ]),
+ [],
+ null
+ );
+ }
+
+ if (requestInput == "spt") {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue(["Its me!!", "spt? i've heard of that project"]),
+ [],
+ null
+ );
+ }
+
+ if (requestInput == "fish") {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue(["blub"]),
+ [],
+ null
+ );
+ }
+
+ if (_listOfMessages.Contains(requestInput)) {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([
+ "Howdy",
+ "Hi",
+ "Greetings",
+ "Hello",
+ "bonjor",
+ "Yo",
+ "Sup",
+ "Heyyyyy",
+ "Hey there",
+ $"Hello {sender?.Info?.Nickname}",
+ ]),
+ [], null
+ );
+ }
+
+ if (requestInput == "nikita") {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([
+ "I know that guy!",
+ "Cool guy, he made EFT!",
+ "Legend",
+ "Remember when he said webel-webel-webel-webel, classic Nikita moment",
+ ]), [], null
+ );
+ }
+
+ if (requestInput == "are you a bot") {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue(["beep boop", "**sad boop**", "probably", "sometimes", "yeah lol"]),
+ [], null
+ );
+ }
+
+ if (requestInput == "itsonlysnowalan") {
+ _weatherConfig.OverrideSeason = Season.WINTER;
+
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([_localisationService.GetText("chatbot-snow_enabled")]), [], null
+ );
+ }
+
+ if (requestInput == "givemesunshine") {
+ _weatherConfig.OverrideSeason = Season.SUMMER;
+
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([_localisationService.GetText("chatbot-summer_enabled")]), [], null
+ );
+ }
+
+ if (requestInput == "veryspooky") {
+ var enableEventResult = _seasonalEventService.ForceSeasonalEvent(SeasonalEventType.Halloween);
+ if (enableEventResult) {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([
+ _localisationService.GetText("chatbot-forced_event_enabled", SeasonalEventType.Halloween)
+ ]), [], null
+ );
+ }
+ }
+
+ if (requestInput == "hohoho") {
+ var enableEventResult = _seasonalEventService.ForceSeasonalEvent(SeasonalEventType.Christmas);
+ if (enableEventResult) {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([
+ _localisationService.GetText("chatbot-forced_event_enabled", SeasonalEventType.Christmas)
+ ]), [], null
+ );
+ }
+ }
+
+ if (requestInput == "givemespace") {
+ var stashRowGiftId = "StashRows";
+ var maxGiftsToSendCount = _coreConfig.Features.ChatbotFeatures.CommandUseLimits[stashRowGiftId] ?? 5;
+ if (_profileHelper.PlayerHasRecievedMaxNumberOfGift(sessionId, stashRowGiftId, maxGiftsToSendCount)) {
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _localisationService.GetText("chatbot-cannot_accept_any_more_of_gift"), [], null
+ );
+ } else {
+ _profileHelper.AddStashRowsBonusToProfile(sessionId, 2);
+
+ _mailSendService.SendUserMessageToPlayer(
+ sessionId,
+ sptFriendUser,
+ _randomUtil.GetArrayValue([
+ _localisationService.GetText("chatbot-added_stash_rows_please_restart"),
+ ]), [], null
+ );
+
+ _profileHelper.FlagGiftReceivedInProfile(sessionId, stashRowGiftId, maxGiftsToSendCount);
+ }
+ }
+
+ return request.DialogId;
}
}
diff --git a/Libraries/Core/Helpers/DurabilityLimitsHelper.cs b/Libraries/Core/Helpers/DurabilityLimitsHelper.cs
index b81934cc..c75f0bfc 100644
--- a/Libraries/Core/Helpers/DurabilityLimitsHelper.cs
+++ b/Libraries/Core/Helpers/DurabilityLimitsHelper.cs
@@ -144,7 +144,7 @@ public class DurabilityLimitsHelper(
{
var lowestMaxPercent = _botConfig.Durability.Pmc.Armor.LowestMaxPercent;
var highestMaxPercent = _botConfig.Durability.Pmc.Armor.HighestMaxPercent;
- var multiplier = _randomUtil.GetInt(lowestMaxPercent, highestMaxPercent);
+ var multiplier = _randomUtil.GetDouble(lowestMaxPercent, highestMaxPercent);
return itemMaxDurability * (multiplier / 100);
}
diff --git a/Libraries/Core/Helpers/HealthHelper.cs b/Libraries/Core/Helpers/HealthHelper.cs
index cce83f4c..9be8bcd6 100644
--- a/Libraries/Core/Helpers/HealthHelper.cs
+++ b/Libraries/Core/Helpers/HealthHelper.cs
@@ -3,14 +3,32 @@ using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Eft.Health;
using Core.Models.Eft.Profile;
+using Core.Models.Spt.Config;
+using Core.Models.Utils;
+using Core.Servers;
+using Core.Services;
+using Core.Utils;
+using Core.Utils.Cloners;
+using SptCommon.Extensions;
using BodyPartHealth = Core.Models.Eft.Common.Tables.BodyPartHealth;
using Effects = Core.Models.Eft.Profile.Effects;
+using Health = Core.Models.Eft.Profile.Health;
+using Vitality = Core.Models.Eft.Profile.Vitality;
namespace Core.Helpers;
[Injectable]
-public class HealthHelper
+public class HealthHelper(
+ ISptLogger _logger,
+ TimeUtil _timeUtil,
+ SaveServer _saveServer,
+ DatabaseService _databaseService,
+ ConfigServer _configServer,
+ ICloner _cloner
+)
{
+ protected HealthConfig _healthConfig = _configServer.GetConfig();
+
///
/// Resets the profiles vitality/health and vitality/effects properties to their defaults
///
@@ -18,7 +36,34 @@ public class HealthHelper
/// Updated profile
public SptProfile ResetVitality(string sessionID)
{
- throw new NotImplementedException();
+ var profile = _saveServer.GetProfile(sessionID);
+
+ profile.VitalityData ??= new Vitality { Health = null, Effects = null };
+
+ profile.VitalityData.Health = new Health {
+ Hydration = 0,
+ Energy = 0,
+ Temperature = 0,
+ Head = 0,
+ Chest = 0,
+ Stomach = 0,
+ LeftArm = 0,
+ RightArm = 0,
+ LeftLeg = 0,
+ RightLeg = 0,
+ };
+
+ profile.VitalityData.Effects = new Effects {
+ Head = new Head(),
+ Chest = new Chest(),
+ Stomach = new Stomach(),
+ LeftArm = new LeftArm(),
+ RightArm = new RightArm(),
+ LeftLeg = new LeftLeg(),
+ RightLeg = new RightLeg(),
+ };
+
+ return profile;
}
///
@@ -36,7 +81,53 @@ public class HealthHelper
string sessionID,
bool isDead)
{
- throw new NotImplementedException();
+ var fullProfile = _saveServer.GetProfile(sessionID);
+ var profileEdition = fullProfile.ProfileInfo.Edition;
+ var profileSide = fullProfile.CharacterData.PmcData.Info.Side;
+
+ var defaultTemperature =
+ _databaseService.GetProfiles()
+ .GetByJsonProp(profileEdition)
+ .GetByJsonProp(profileSide.ToLower())
+ ?.Character?.Health?.Temperature ?? new CurrentMinMax { Current = 36.6 };
+
+ StoreHydrationEnergyTempInProfile(
+ fullProfile,
+ postRaidHealth.Hydration.Current ?? 0,
+ postRaidHealth.Energy.Current ?? 0,
+ defaultTemperature.Current ?? 0 // Reset profile temp to the default to prevent very cold/hot temps persisting into next raid
+ );
+
+ // Store limb effects from post-raid in profile
+ foreach (var bodyPart in postRaidHealth.BodyParts) {
+ // Effects
+ if (postRaidHealth.BodyParts[bodyPart.Key].Effects is not null) {
+ // fullProfile.VitalityData.Effects[bodyPart.Key] = postRaidHealth.BodyParts[bodyPart.Key].Effects;
+ // TODO: this will need to change, typing is all fucked up
+ }
+
+ // Limb hp
+ if (!isDead)
+ {
+ // Player alive, not is limb alive
+ var byJsonProp = fullProfile.VitalityData.Health.GetByJsonProp(bodyPart.Key);
+ byJsonProp = postRaidHealth.BodyParts[bodyPart.Key].Health.Current ?? 0;
+ } else {
+ var byJsonProp = fullProfile.VitalityData.Health.GetByJsonProp(bodyPart.Key);
+ byJsonProp = (pmcData.Health.BodyParts[bodyPart.Key].Health.Maximum * _healthConfig.HealthMultipliers.Death) ?? 0;
+ }
+ }
+
+ TransferPostRaidLimbEffectsToProfile(postRaidHealth.BodyParts, pmcData);
+
+ // Adjust hydration/energy/temp and limb hp using temp storage hydated above
+ SaveHealth(pmcData, sessionID);
+
+ // Reset temp storage
+ ResetVitality(sessionID);
+
+ // Update last edited timestamp
+ pmcData.Health.UpdateTime = _timeUtil.GetTimeStamp();
}
protected void StoreHydrationEnergyTempInProfile(
@@ -45,7 +136,9 @@ public class HealthHelper
double energy,
double temprature)
{
- throw new NotImplementedException();
+ fullProfile.VitalityData.Health.Hydration = hydration;
+ fullProfile.VitalityData.Health.Energy = energy;
+ fullProfile.VitalityData.Health.Temperature = temprature;
}
///
@@ -55,7 +148,37 @@ public class HealthHelper
/// Player profile on server
protected void TransferPostRaidLimbEffectsToProfile(Dictionary postRaidBodyParts, PmcData profileData)
{
- throw new NotImplementedException();
+ // Iterate over each body part
+ List effectsToIgnore = ["Dehydration", "Exhaustion"];
+ foreach (var bodyPartId in postRaidBodyParts) {
+ // Get effects on body part from profile
+ var bodyPartEffects = postRaidBodyParts[bodyPartId.Key].Effects;
+ foreach (var effect in bodyPartEffects) {
+ var effectDetails = bodyPartEffects[effect.Key];
+
+ // Null guard
+ profileData.Health.BodyParts[bodyPartId.Key].Effects ??= new Dictionary();
+
+ // Effect already exists on limb in server profile, skip
+ var profileBodyPartEffects = profileData.Health.BodyParts[bodyPartId.Key].Effects;
+ if (profileBodyPartEffects[effect.Key] is not null) {
+ if (effectsToIgnore.Contains(effect.Key)) {
+ // Get rid of certain effects we dont want to persist out of raid
+ profileBodyPartEffects[effect.Key] = null;
+ }
+
+ continue;
+ }
+
+ if (effectsToIgnore.Contains(effect.Key)) {
+ // Do not pass some effects to out of raid profile
+ continue;
+ }
+
+ // Add effect to server profile
+ profileBodyPartEffects[effect.Key] = new BodyPartEffectProperties { Time = effectDetails.Time ?? -1 };
+ }
+ }
}
///
@@ -73,7 +196,45 @@ public class HealthHelper
bool addEffects = true,
bool deleteExistingEffects = true)
{
- throw new NotImplementedException();
+ var postRaidBodyParts = request.Health; // post raid health settings
+ var fullProfile = _saveServer.GetProfile(sessionID);
+ var profileEffects = fullProfile.VitalityData.Effects;
+
+ StoreHydrationEnergyTempInProfile(fullProfile, request.Hydration ?? 0, request.Energy ?? 0, request.Temperature ?? 0);
+
+ // Process request data into profile
+ foreach (var bodyPart in postRaidBodyParts) {
+ // Transfer effects from request to profile
+ if (bodyPart.Effects is not null) {
+ // profileEffects[bodyPart] = postRaidBodyParts[bodyPart].Effects;
+ }
+
+ if (request.IsAlive ?? false) {
+ // Player alive, not is limb alive
+ // fullProfile.VitalityData.Health[bodyPart] = postRaidBodyParts[bodyPart].Current;
+ } else {
+ // fullProfile.VitalityData.Health[bodyPart] =
+ // pmcData.Health.BodyParts[bodyPart].Health.Maximum * _healthConfig.HealthMultipliers.Death;
+ }// TODO: this will need to change, typing is all fucked up
+ }
+
+ // Add effects to body parts if enabled
+ if (addEffects) {
+ SaveEffects(
+ pmcData,
+ sessionID,
+ _cloner.Clone(_saveServer.GetProfile(sessionID).VitalityData.Effects),
+ deleteExistingEffects
+ );
+ }
+
+ // Adjust hydration/energy/temp and limb hp
+ SaveHealth(pmcData, sessionID);
+
+ ResetVitality(sessionID);
+
+ // Update last edited timestamp
+ pmcData.Health.UpdateTime = _timeUtil.GetTimeStamp();
}
///
@@ -83,7 +244,40 @@ public class HealthHelper
/// Session id
protected void SaveHealth(PmcData pmcData, string sessionID)
{
- throw new NotImplementedException();
+ // TODO: this will need to change, typing is all fucked up
+ // if (!_healthConfig.Save.Health) {
+ // return;
+ // }
+ //
+ // var profileHealth = _saveServer.GetProfile(sessionID).VitalityData.Health;
+ // foreach (var healthModifier in profileHealth) {
+ // let target = profileHealth[healthModifier];
+ //
+ // if (["Hydration", "Energy", "Temperature"].includes(healthModifier)) {
+ // // Set resources
+ // if (target > pmcData.Health[healthModifier].Maximum) {
+ // target = pmcData.Health[healthModifier].Maximum;
+ // }
+ //
+ // pmcData.Health[healthModifier].Current = Math.round(target);
+ // } else {
+ // // Over max, limit
+ // if (target > pmcData.Health.BodyParts[healthModifier].Health.Maximum) {
+ // target = pmcData.Health.BodyParts[healthModifier].Health.Maximum;
+ // }
+ //
+ // // Part was zeroed out in raid
+ // if (target === 0) {
+ // // Blacked body part
+ // target = Math.round(
+ // pmcData.Health.BodyParts[healthModifier].Health.Maximum *
+ // this.healthConfig.healthMultipliers.blacked,
+ // );
+ // }
+ //
+ // pmcData.Health.BodyParts[healthModifier].Health.Current = Math.round(target);
+ // }
+ // }
}
///
@@ -101,7 +295,42 @@ public class HealthHelper
Effects bodyPartsWithEffects,
bool deleteExistingEffects = true)
{
- throw new NotImplementedException();
+ // TODO: this will need to change, typing is all fucked up
+ // if (!this.healthConfig.save.effects) {
+ // return;
+ // }
+ //
+ // for (const bodyPart in bodyPartsWithEffects) {
+ // // clear effects from profile bodyPart
+ // if (deleteExistingEffects) {
+ // // biome-ignore lint/performance/noDelete: Delete is fine here as we entirely want to get rid of the effect.
+ // delete pmcData.Health.BodyParts[bodyPart].Effects;
+ // }
+ //
+ // for (const effectType in bodyPartsWithEffects[bodyPart]) {
+ // if (typeof effectType !== "string") {
+ // this.logger.warning(`Effect ${effectType} on body part ${bodyPart} not a string, report this`);
+ // }
+ //
+ // // // data can be index or the effect string (e.g. "Fracture") itself
+ // // const effect = /^-?\d+$/.test(effectValue) // is an int
+ // // ? nodeEffects[bodyPart][effectValue]
+ // // : effectValue;
+ // let time = bodyPartsWithEffects[bodyPart][effectType];
+ // if (time) {
+ // // Sometimes the value can be Infinity instead of -1, blame HealthListener.cs in modules
+ // if (time === "Infinity") {
+ // this.logger.warning(
+ // `Effect ${effectType} found with value of Infinity, changed to -1, this is an issue with HealthListener.cs`,
+ // );
+ // time = -1;
+ // }
+ // this.addEffect(pmcData, bodyPart, effectType, time);
+ // } else {
+ // this.addEffect(pmcData, bodyPart, effectType);
+ // }
+ // }
+ // }
}
///
@@ -113,6 +342,18 @@ public class HealthHelper
/// How long the effect has left in seconds (-1 by default, no duration).
protected void AddEffect(PmcData pmcData, string effectBodyPart, string effectType, int duration = -1)
{
- throw new NotImplementedException();
+ // TODO: this will need to change, typing is all fucked up
+ // const profileBodyPart = pmcData.Health.BodyParts[effectBodyPart];
+ // if (!profileBodyPart.Effects) {
+ // profileBodyPart.Effects = {};
+ // }
+ //
+ // profileBodyPart.Effects[effectType] = { Time: duration };
+ //
+ // // Delete empty property to prevent client bugs
+ // if (this.isEmpty(profileBodyPart.Effects)) {
+ // // biome-ignore lint/performance/noDelete: Delete is fine here, we're removing an empty property to prevent game bugs.
+ // delete profileBodyPart.Effects;
+ // }
}
}
diff --git a/Libraries/Core/Helpers/HideoutHelper.cs b/Libraries/Core/Helpers/HideoutHelper.cs
index 5737ede2..f65ef00f 100644
--- a/Libraries/Core/Helpers/HideoutHelper.cs
+++ b/Libraries/Core/Helpers/HideoutHelper.cs
@@ -2,11 +2,16 @@ using SptCommon.Annotations;
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.Spt.Config;
using Core.Models.Utils;
+using Core.Routers;
+using Core.Servers;
using Core.Services;
using Core.Utils;
+using Core.Utils.Cloners;
namespace Core.Helpers;
@@ -14,15 +19,28 @@ namespace Core.Helpers;
public class HideoutHelper(
ISptLogger _logger,
TimeUtil _timeUtil,
- LocalisationService _localisationService
+ LocalisationService _localisationService,
+ HashUtil _hashUtil,
+ DatabaseService _databaseService,
+ EventOutputHolder _eventOutputHolder,
+ HttpResponseUtil _httpResponseUtil,
+ ProfileHelper _profileHelper,
+ InventoryHelper _inventoryHelper,
+ PlayerService _playerService,
+ ItemHelper _itemHelper,
+ ConfigServer _configServer,
+ ICloner _cloner
)
{
+ protected HideoutConfig hideoutConfig = _configServer.GetConfig();
+
public const string BitcoinFarm = "5d5c205bd582a50d042a3c0e";
public const string CultistCircleCraftId = "66827062405f392b203a44cf";
public const string BitcoinProductionId = "5d5c205bd582a50d042a3c0e";
public const string WaterCollector = "5d5589c1f934db045e6c5492";
public const int MaxSkillPoint = 5000;
-
+ protected List _idCheck = [HideoutHelper.BitcoinFarm, HideoutHelper.CultistCircleCraftId];
+
///
/// Add production to profiles' Hideout.Production array
///
@@ -31,13 +49,64 @@ public class HideoutHelper(
/// Session id
/// client response
public void RegisterProduction(
- PmcData profileData,
- HideoutSingleProductionStartRequestData productionRequest,
- string sessionId)
+ PmcData pmcData,
+ HideoutSingleProductionStartRequestData body,
+ string sessionID)
{
- throw new NotImplementedException();
+ var recipe = _databaseService
+ .GetHideout()
+ .Production.Recipes.FirstOrDefault((production) => production.Id == body.RecipeId);
+ if (recipe is null)
+ {
+ _logger.Error(_localisationService.GetText("hideout-missing_recipe_in_db", body.RecipeId));
+
+ _httpResponseUtil.AppendErrorToOutput(_eventOutputHolder.GetOutput(sessionID));
+ }
+
+ // @Important: Here we need to be very exact:
+ // - normal recipe: Production time value is stored in attribute "productionType" with small "p"
+ // - scav case recipe: Production time value is stored in attribute "ProductionType" with capital "P"
+ if (pmcData.Hideout?.Production is null)
+ {
+ pmcData.Hideout.Production = new Dictionary();
+ }
+
+ var modifiedProductionTime = GetAdjustedCraftTimeWithSkills(pmcData, body.RecipeId);
+
+ var production = InitProduction(
+ body.RecipeId,
+ modifiedProductionTime ?? 0,
+ recipe.NeedFuelForAllProductionTime
+ );
+
+ // Store the tools used for this production, so we can return them later
+ if (body is not null && body.Tools?.Count > 0)
+ {
+ production.SptRequiredTools = [];
+
+ foreach (var tool in body.Tools)
+ {
+ var toolItem = _cloner.Clone(pmcData.Inventory.Items.FirstOrDefault((x) => x.Id == tool.Id));
+
+ // Make sure we only return as many as we took
+ _itemHelper.AddUpdObjectToItem(toolItem);
+
+ toolItem.Upd.StackObjectsCount = tool.Count;
+
+ production.SptRequiredTools.Add(
+ new Item
+ {
+ Id = _hashUtil.Generate(),
+ Template = toolItem.Template,
+ Upd = toolItem.Upd,
+ }
+ );
+ }
+ }
+
+ pmcData.Hideout.Production[body.RecipeId] = production;
}
-
+
///
/// Add production to profiles' Hideout.Production array
///
@@ -46,11 +115,37 @@ public class HideoutHelper(
/// Session id
/// client response
public void RegisterProduction(
- PmcData profileData,
- HideoutContinuousProductionStartRequestData productionRequest,
- string sessionId)
+ PmcData pmcData,
+ HideoutContinuousProductionStartRequestData body,
+ string sessionID)
{
- throw new NotImplementedException();
+ var recipe = _databaseService
+ .GetHideout()
+ .Production.Recipes.FirstOrDefault((production) => production.Id == body.RecipeId);
+ if (recipe is null)
+ {
+ _logger.Error(_localisationService.GetText("hideout-missing_recipe_in_db", body.RecipeId));
+
+ _httpResponseUtil.AppendErrorToOutput(_eventOutputHolder.GetOutput(sessionID));
+ }
+
+ // @Important: Here we need to be very exact:
+ // - normal recipe: Production time value is stored in attribute "productionType" with small "p"
+ // - scav case recipe: Production time value is stored in attribute "ProductionType" with capital "P"
+ if (pmcData.Hideout?.Production is null)
+ {
+ pmcData.Hideout.Production = new Dictionary();
+ }
+
+ var modifiedProductionTime = GetAdjustedCraftTimeWithSkills(pmcData, body.RecipeId);
+
+ var production = InitProduction(
+ body.RecipeId,
+ modifiedProductionTime ?? 0,
+ recipe.NeedFuelForAllProductionTime
+ );
+
+ pmcData.Hideout.Production[body.RecipeId] = production;
}
///
@@ -59,20 +154,23 @@ public class HideoutHelper(
///
public Production InitProduction(
string recipeId,
- int productionTime,
- bool needFuelForAllProductionTime)
+ double productionTime,
+ bool? needFuelForAllProductionTime)
{
- throw new NotImplementedException();
- }
-
- ///
- /// Is the provided object a Production type
- ///
- ///
- ///
- public bool IsProductionType(Production Production)
- {
- throw new NotImplementedException();
+ return new Production
+ {
+ Progress = 0,
+ InProgress = true,
+ RecipeId = recipeId,
+ StartTimestamp = _timeUtil.GetTimeStamp(),
+ ProductionTime = productionTime,
+ Products = [],
+ GivenItemsInStart = [],
+ Interrupted = false,
+ NeedFuelForAllProductionTime = needFuelForAllProductionTime, // Used when sending to client
+ needFuelForAllProductionTime = needFuelForAllProductionTime, // used when stored in production.json
+ SkipTime = 0,
+ };
}
///
@@ -83,7 +181,6 @@ public class HideoutHelper(
public void ApplyPlayerUpgradesBonuses(PmcData profileData, Bonus bonus)
{
// Handle additional changes some bonuses need before being added
- var bonusToAdd = new Bonus();
switch (bonus.Type)
{
case BonusType.StashSize:
@@ -121,9 +218,14 @@ public class HideoutHelper(
/// Process a players hideout, update areas that use resources + increment production timers
///
/// Session id
- public void UpdatePlayerHideout(string sessionId)
+ public void UpdatePlayerHideout(string sessionID)
{
- throw new NotImplementedException();
+ var pmcData = _profileHelper.GetPmcProfile(sessionID);
+ var hideoutProperties = GetHideoutProperties(pmcData);
+
+ UpdateAreasWithResources(sessionID, pmcData, hideoutProperties);
+ UpdateProductionTimers(pmcData, hideoutProperties);
+ pmcData.Hideout.SptUpdateLastRunTimestamp = _timeUtil.GetTimeStamp();
}
///
@@ -131,14 +233,34 @@ public class HideoutHelper(
///
/// Player profile
/// Properties
- protected (int btcFarmCGs, bool isGeneratorOn, bool waterCollectorHasFilter) GetHideoutProperties(PmcData profileData)
+ protected HideoutProperties GetHideoutProperties(PmcData pmcData)
{
- throw new NotImplementedException();
+ var bitcoinFarm = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.BITCOIN_FARM);
+ var bitcoinCount = bitcoinFarm?.Slots.Where((slot) => slot.Items is not null).Count(); // Get slots with an item property
+
+ var hideoutProperties = new HideoutProperties
+ {
+ BtcFarmGcs = bitcoinCount,
+ IsGeneratorOn = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.GENERATOR)?.Active ?? false,
+ WaterCollectorHasFilter = DoesWaterCollectorHaveFilter(
+ pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.WATER_COLLECTOR)
+ ),
+ };
+
+ return hideoutProperties;
}
protected bool DoesWaterCollectorHaveFilter(BotHideoutArea waterCollector)
{
- throw new NotImplementedException();
+ // Can put filters in from L3
+ if (waterCollector.Level == 3)
+ {
+ // Has filter in at least one slot
+ return waterCollector.Slots.Any(slot => slot.Items is not null);
+ }
+
+ // No Filter
+ return false;
}
///
@@ -147,10 +269,84 @@ public class HideoutHelper(
/// Profile to check for productions and update
/// Hideout properties
protected void UpdateProductionTimers(
- PmcData profileData,
- (int btcFarmCGs, bool isGeneratorOn, bool waterCollectorHasFilter) hideoutProperties)
+ PmcData pmcData,
+ HideoutProperties hideoutProperties)
{
- throw new NotImplementedException();
+ var recipes = _databaseService.GetHideout().Production;
+
+ // Check each production and handle edge cases if necessary
+ foreach (var prodId in pmcData.Hideout?.Production)
+ {
+ var craft = pmcData.Hideout.Production[prodId.Key];
+ if (craft is null)
+ {
+ // Craft value is undefined, get rid of it (could be from cancelling craft that needs cleaning up)
+ pmcData.Hideout.Production.Remove(prodId.Key);
+
+ continue;
+ }
+
+ if (craft.Progress == null)
+ {
+ _logger.Warning(
+ _localisationService.GetText("hideout-craft_has_undefined_progress_value_defaulting", prodId)
+ );
+ craft.Progress = 0;
+ }
+
+ // Skip processing (Don't skip continious crafts like bitcoin farm or cultist circle)
+ if (IsCraftComplete(craft))
+ {
+ continue;
+ }
+
+ // Special handling required
+ if (IsCraftOfType(craft, HideoutAreas.SCAV_CASE))
+ {
+ UpdateScavCaseProductionTimer(pmcData, prodId.Key);
+
+ continue;
+ }
+
+ if (IsCraftOfType(craft, HideoutAreas.WATER_COLLECTOR))
+ {
+ UpdateWaterCollectorProductionTimer(pmcData, prodId.Key, hideoutProperties);
+
+ continue;
+ }
+
+ // Continious craft
+ if (IsCraftOfType(craft, HideoutAreas.BITCOIN_FARM))
+ {
+ UpdateBitcoinFarm(
+ pmcData,
+ pmcData.Hideout.Production[prodId.Key],
+ hideoutProperties.BtcFarmGcs,
+ hideoutProperties.IsGeneratorOn
+ );
+
+ continue;
+ }
+
+ // No recipe, needs special handling
+ if (IsCraftOfType(craft, HideoutAreas.CIRCLE_OF_CULTISTS))
+ {
+ UpdateCultistCircleCraftProgress(pmcData, prodId.Key);
+
+ continue;
+ }
+
+ // Ensure recipe exists before using it in updateProductionProgress()
+ var recipe = recipes.Recipes.FirstOrDefault((r) => r.Id == prodId.Key);
+ if (recipe is null)
+ {
+ _logger.Error(_localisationService.GetText("hideout-missing_recipe_for_area", prodId));
+
+ continue;
+ }
+
+ UpdateProductionProgress(pmcData, prodId.Key, recipe, hideoutProperties);
+ }
}
///
@@ -161,7 +357,20 @@ public class HideoutHelper(
/// True if it is from that area
protected bool IsCraftOfType(Production craft, HideoutAreas hideoutType)
{
- throw new NotImplementedException();
+ switch (hideoutType)
+ {
+ case HideoutAreas.WATER_COLLECTOR:
+ return craft.RecipeId == HideoutHelper.WaterCollector;
+ case HideoutAreas.BITCOIN_FARM:
+ return craft.RecipeId == HideoutHelper.BitcoinFarm;
+ case HideoutAreas.SCAV_CASE:
+ return craft.SptIsScavCase ?? false;
+ case HideoutAreas.CIRCLE_OF_CULTISTS:
+ return craft.SptIsCultistCircle ?? false;
+ default:
+ _logger.Error($"Unhandled hideout area: {hideoutType}, assuming craft: {craft.RecipeId} is not of this type");
+ return false;
+ }
}
///
@@ -172,7 +381,10 @@ public class HideoutHelper(
/// True when craft is complete
protected bool IsCraftComplete(Production craft)
{
- throw new NotImplementedException();
+ return (
+ craft.Progress >= craft.ProductionTime &&
+ !_idCheck.Contains(craft.RecipeId)
+ );
}
///
@@ -184,9 +396,13 @@ public class HideoutHelper(
protected void UpdateWaterCollectorProductionTimer(
PmcData pmcData,
string productionId,
- Dictionary hideoutProperties)
+ HideoutProperties hideoutProperties)
{
- throw new NotImplementedException();
+ var timeElapsed = GetTimeElapsedSinceLastServerTick(pmcData, hideoutProperties.IsGeneratorOn);
+ if (hideoutProperties.WaterCollectorHasFilter)
+ {
+ pmcData.Hideout.Production[productionId].Progress += timeElapsed;
+ }
}
///
@@ -200,19 +416,68 @@ public class HideoutHelper(
PmcData pmcData,
string prodId,
HideoutProduction recipe,
- Dictionary hideoutProperties)
+ HideoutProperties hideoutProperties)
{
- throw new NotImplementedException();
+ // Production is complete, no need to do any calculations
+ if (DoesProgressMatchProductionTime(pmcData, prodId))
+ {
+ return;
+ }
+
+ // Get seconds since last hideout update + now
+ var timeElapsed = GetTimeElapsedSinceLastServerTick(pmcData, hideoutProperties.IsGeneratorOn, recipe);
+
+ // Increment progress by time passed
+ var production = pmcData.Hideout.Production[prodId];
+ // Some items NEED power to craft (e.g. DSP)
+ production.Progress += (production.needFuelForAllProductionTime ?? false) && !hideoutProperties.IsGeneratorOn ? 0 : timeElapsed;
+
+ // Limit progress to total production time if progress is over (dont run for continious crafts))
+ if (!(recipe.Continuous ?? false))
+ {
+ // If progress is larger than prod time, return ProductionTime, hard cap the vaue
+ production.Progress = Math.Min(production.Progress ?? 0, production.ProductionTime ?? 0);
+ }
}
protected void UpdateCultistCircleCraftProgress(PmcData pmcData, string prodId)
{
- throw new NotImplementedException();
+ var production = pmcData.Hideout.Production[prodId];
+
+ // Check if we're already complete, skip
+ if (production.AvailableForFinish ?? false)
+ {
+ return;
+ }
+
+ // Get seconds since last hideout update
+ var timeElapsedSeconds = _timeUtil.GetTimeStamp() - pmcData.Hideout.SptUpdateLastRunTimestamp;
+
+ // Increment progress by time passed if progress is less than time needed
+ if (production.Progress < production.ProductionTime)
+ {
+ production.Progress += timeElapsedSeconds;
+
+ // Check if craft is complete
+ if (production.Progress >= production.ProductionTime)
+ {
+ FlagCultistCircleCraftAsComplete(production);
+ }
+
+ return;
+ }
+
+ // Craft in complete
+ FlagCultistCircleCraftAsComplete(production);
}
- protected void FlagCultistCircleCraftAsComplete(Production Production)
+ protected void FlagCultistCircleCraftAsComplete(Production production)
{
- throw new NotImplementedException();
+ // Craft is complete, flas as such
+ production.AvailableForFinish = true;
+
+ // Reset progress so its not over production time
+ production.Progress = production.ProductionTime;
}
///
@@ -224,7 +489,7 @@ public class HideoutHelper(
/// progress matches productionTime from recipe
protected bool DoesProgressMatchProductionTime(PmcData pmcData, string prodId)
{
- throw new NotImplementedException();
+ return pmcData.Hideout.Production[prodId].Progress == pmcData.Hideout.Production[prodId].ProductionTime;
}
///
@@ -234,7 +499,12 @@ public class HideoutHelper(
/// Id of scav case production to update
protected void UpdateScavCaseProductionTimer(PmcData pmcData, string productionId)
{
- throw new NotImplementedException();
+ var timeElapsed =
+ _timeUtil.GetTimeStamp() -
+ pmcData.Hideout.Production[productionId].StartTimestamp -
+ pmcData.Hideout.Production[productionId].Progress;
+
+ pmcData.Hideout.Production[productionId].Progress += timeElapsed;
}
///
@@ -246,9 +516,32 @@ public class HideoutHelper(
protected void UpdateAreasWithResources(
string sessionID,
PmcData pmcData,
- Dictionary hideoutProperties)
+ HideoutProperties hideoutProperties)
{
- throw new NotImplementedException();
+ foreach (var area in pmcData.Hideout.Areas)
+ {
+ switch (area.Type)
+ {
+ case HideoutAreas.GENERATOR:
+ if (hideoutProperties.IsGeneratorOn)
+ {
+ UpdateFuel(area, pmcData, hideoutProperties.IsGeneratorOn);
+ }
+
+ break;
+ case HideoutAreas.WATER_COLLECTOR:
+ UpdateWaterCollector(sessionID, pmcData, area, hideoutProperties);
+ break;
+
+ case HideoutAreas.AIR_FILTERING:
+ if (hideoutProperties.IsGeneratorOn)
+ {
+ UpdateAirFilters(area, pmcData, hideoutProperties.IsGeneratorOn);
+ }
+
+ break;
+ }
+ }
}
///
@@ -259,16 +552,155 @@ public class HideoutHelper(
/// Is the generator turned on since last update
protected void UpdateFuel(BotHideoutArea generatorArea, PmcData pmcData, bool isGeneratorOn)
{
- throw new NotImplementedException();
+ // 1 resource last 14 min 27 sec, 1/14.45/60 = 0.00115
+ // 10-10-2021 From wiki, 1 resource last 12 minutes 38 seconds, 1/12.63333/60 = 0.00131
+ var fuelUsedSinceLastTick =
+ _databaseService.GetHideout().Settings.GeneratorFuelFlowRate *
+ GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
+
+ // Get all fuel consumption bonuses, returns an empty array if none found
+ var profileFuelConsomptionBonusSum = _profileHelper.GetBonusValueFromProfile(
+ pmcData,
+ BonusType.FuelConsumption
+ );
+
+ // An increase in "bonus" consumption is actually an increase in consumption, so invert this for later use
+ var fuelConsumptionBonusRate = -(profileFuelConsomptionBonusSum / 100);
+
+ // An increase in hideout management bonus is a decrease in consumption
+ var hideoutManagementConsumptionBonusRate = GetHideoutManagementConsumptionBonus(pmcData);
+
+ var combinedBonus = 1.0 - (fuelConsumptionBonusRate + hideoutManagementConsumptionBonusRate);
+
+ // Sanity check, never let fuel consumption go negative, otherwise it returns fuel to the player
+ if (combinedBonus < 0)
+ {
+ combinedBonus = 0;
+ }
+
+ fuelUsedSinceLastTick *= combinedBonus;
+
+ var hasFuelRemaining = false;
+ var pointsConsumed = 0D;
+ for (var i = 0; i < generatorArea.Slots.Count; i++)
+ {
+ var generatorSlot = generatorArea.Slots[i];
+ if (generatorSlot?.Items is null)
+ {
+ // No item in slot, skip
+ continue;
+ }
+
+ var fuelItemInSlot = generatorSlot?.Items[0];
+ if (fuelItemInSlot is null)
+ {
+ // No item in slot, skip
+ continue;
+ }
+
+ var fuelRemaining = fuelItemInSlot.Upd?.Resource?.Value;
+ if (fuelRemaining == 0)
+ {
+ // No fuel left, skip
+ continue;
+ }
+
+ // Undefined fuel, fresh fuel item and needs its max fuel amount looked up
+ if (fuelRemaining is null)
+ {
+ var fuelItemTemplate = _itemHelper.GetItem(fuelItemInSlot.Template).Value;
+ pointsConsumed = fuelUsedSinceLastTick ?? 0;
+ fuelRemaining = fuelItemTemplate.Properties.MaxResource - fuelUsedSinceLastTick;
+ }
+ else
+ {
+ // Fuel exists already, deduct fuel from item remaining value
+ pointsConsumed = (double)((fuelItemInSlot.Upd.Resource.UnitsConsumed ?? 0) + fuelUsedSinceLastTick);
+ fuelRemaining -= fuelUsedSinceLastTick;
+ }
+
+ // Round values to keep accuracy
+ fuelRemaining = Math.Round((fuelRemaining * 10000) ?? 0) / 10000;
+ pointsConsumed = Math.Round(pointsConsumed * 10000) / 10000;
+
+ // Fuel consumed / 10 is over 1, add hideout management skill point
+ if (pmcData is not null && Math.Floor(pointsConsumed / 10) >= 1)
+ {
+ _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.HideoutManagement, 1);
+ pointsConsumed -= 10;
+ }
+
+ var isFuelItemFoundInRaid = fuelItemInSlot.Upd?.SpawnedInSession ?? false;
+ if (fuelRemaining > 0)
+ {
+ // Deducted all used fuel from this container, clean up and exit loop
+ fuelItemInSlot.Upd = GetAreaUpdObject(1, fuelRemaining, pointsConsumed, isFuelItemFoundInRaid);
+
+ _logger.Debug($"Profile: {pmcData.Id} Generator has: {fuelRemaining} fuel left in slot {i + 1}");
+ hasFuelRemaining = true;
+
+ break; // Break to avoid updating all the fuel tanks
+ }
+
+ fuelItemInSlot.Upd = GetAreaUpdObject(1, 0, 0, isFuelItemFoundInRaid);
+
+ // Ran out of fuel items to deduct fuel from
+ fuelUsedSinceLastTick = Math.Abs(fuelRemaining ?? 0);
+ _logger.Debug($"Profile: {pmcData.Id} Generator ran out of fuel");
+ }
+
+ // Out of fuel, flag generator as offline
+ if (!hasFuelRemaining)
+ {
+ generatorArea.Active = false;
+ }
}
protected void UpdateWaterCollector(
string sessionId,
PmcData pmcData,
BotHideoutArea area,
- Dictionary hideoutProperties)
+ HideoutProperties hideoutProperties)
{
- throw new NotImplementedException();
+ // Skip water collector when not level 3 (cant collect until 3)
+ if (area.Level != 3)
+ {
+ return;
+ }
+
+ if (!hideoutProperties.WaterCollectorHasFilter)
+ {
+ return;
+ }
+
+ // Canister with purified water craft exists
+ var purifiedWaterCraft = pmcData.Hideout.Production[HideoutHelper.WaterCollector];
+ if (purifiedWaterCraft is not null && purifiedWaterCraft.GetType() == typeof(Production))
+ {
+ // Update craft time to account for increases in players craft time skill
+ purifiedWaterCraft.ProductionTime = GetAdjustedCraftTimeWithSkills(
+ pmcData,
+ purifiedWaterCraft.RecipeId,
+ true
+ );
+
+ UpdateWaterFilters(area, purifiedWaterCraft, hideoutProperties.IsGeneratorOn, pmcData);
+ }
+ else
+ {
+ // continuousProductionStart()
+ // seem to not trigger consistently
+ HideoutSingleProductionStartRequestData recipe = new HideoutSingleProductionStartRequestData
+ {
+ RecipeId = HideoutHelper.WaterCollector,
+ Action = "HideoutSingleProductionStart",
+ Items = [],
+ Tools = [],
+ Timestamp = _timeUtil.GetTimeStamp(),
+ };
+
+ RegisterProduction(pmcData, recipe, sessionId);
+ }
}
///
@@ -278,12 +710,61 @@ public class HideoutHelper(
/// Recipe being crafted
/// Should the hideout management bonus be applied to the calculation
/// Items craft time with bonuses subtracted
- public double GetAdjustedCraftTimeWithSkills(
- PmcData playerProfile,
+ public double? GetAdjustedCraftTimeWithSkills(
+ PmcData pmcData,
string recipeId,
bool applyHideoutManagementBonus = false)
{
- throw new NotImplementedException();
+ var globalSkillsDb = _databaseService.GetGlobals().Configuration.SkillsSettings;
+
+ var recipe = _databaseService
+ .GetHideout()
+ .Production.Recipes.FirstOrDefault((production) => production.Id == recipeId);
+ if (recipe is null)
+ {
+ _logger.Error(_localisationService.GetText("hideout-missing_recipe_in_db", recipeId));
+
+ return null;
+ }
+
+ var timeReductionSeconds = 0D;
+
+ // Bitcoin farm is excluded from crafting skill cooldown reduction
+ if (recipeId != HideoutHelper.BitcoinFarm)
+ {
+ // Seconds to deduct from crafts total time
+ timeReductionSeconds += GetSkillProductionTimeReduction(
+ pmcData,
+ recipe.ProductionTime ?? 0,
+ SkillTypes.Crafting,
+ globalSkillsDb.Crafting.ProductionTimeReductionPerLevel ?? 0
+ );
+ }
+
+ // Some crafts take into account hideout management, e.g. fuel, water/air filters
+ if (applyHideoutManagementBonus)
+ {
+ timeReductionSeconds += GetSkillProductionTimeReduction(
+ pmcData,
+ recipe.ProductionTime ?? 0,
+ SkillTypes.HideoutManagement,
+ globalSkillsDb.HideoutManagement.ConsumptionReductionPerLevel ?? 0
+ );
+ }
+
+ var modifiedProductionTime = recipe.ProductionTime - timeReductionSeconds;
+ if (modifiedProductionTime > 0 && _profileHelper.IsDeveloperAccount(pmcData.Id))
+ {
+ modifiedProductionTime = 40;
+ }
+
+ // Sanity check, don't let anything craft in less than 5 seconds
+ if (modifiedProductionTime < 5)
+ {
+ modifiedProductionTime = 5;
+ }
+
+ return modifiedProductionTime;
}
///
@@ -297,9 +778,90 @@ public class HideoutHelper(
BotHideoutArea waterFilterArea,
Production production,
bool isGeneratorOn,
- PmcData playerProfile)
+ PmcData pmcData)
{
- throw new NotImplementedException();
+ var filterDrainRate = GetWaterFilterDrainRate(pmcData);
+ var craftProductionTime = GetTotalProductionTimeSeconds(HideoutHelper.WaterCollector);
+ var secondsSinceServerTick = GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
+
+ filterDrainRate = GetTimeAdjustedWaterFilterDrainRate(
+ secondsSinceServerTick ?? 0,
+ craftProductionTime,
+ production.Progress ?? 0,
+ filterDrainRate
+ );
+
+ // Production hasn't completed
+ var pointsConsumed = 0D;
+
+ // Check progress against the productions craft time (dont use base time as it doesnt include any time bonuses profile has)
+ if (production.Progress > production.ProductionTime)
+ {
+ // Craft is complete nothing to do
+ return;
+ }
+
+ // Check all slots that take water filters until we find one with filter in it
+ for (var i = 0; i < waterFilterArea.Slots.Count; i++)
+ {
+ // No water filter in slot, skip
+ if (waterFilterArea.Slots[i].Items is null)
+ {
+ continue;
+ }
+
+ var waterFilterItemInSlot = waterFilterArea.Slots[i].Items[0];
+
+ // How many units of filter are left
+ var resourceValue = waterFilterItemInSlot.Upd?.Resource is not null
+ ? waterFilterItemInSlot.Upd.Resource.Value
+ : null;
+ if (resourceValue is null)
+ {
+ // Missing, is new filter, add default and subtract usage
+ resourceValue = 100 - filterDrainRate;
+ pointsConsumed = filterDrainRate;
+ }
+ else
+ {
+ pointsConsumed = (waterFilterItemInSlot.Upd.Resource.UnitsConsumed ?? 0) + filterDrainRate;
+ resourceValue -= filterDrainRate;
+ }
+
+ // Round to get values to 3dp
+ resourceValue = Math.Round((resourceValue * 1000) ?? 0) / 1000;
+ pointsConsumed = Math.Round(pointsConsumed * 1000) / 1000;
+
+ // Check units consumed for possible increment of hideout mgmt skill point
+ if (pmcData is not null && Math.Floor(pointsConsumed / 10) >= 1)
+ {
+ _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.HideoutManagement, 1);
+ pointsConsumed -= 10;
+ }
+
+ // Filter has some fuel left in it after our adjustment
+ if (resourceValue > 0)
+ {
+ var isWaterFilterFoundInRaid = waterFilterItemInSlot.Upd.SpawnedInSession ?? false;
+
+ // Set filters consumed amount
+ waterFilterItemInSlot.Upd = GetAreaUpdObject(
+ 1,
+ resourceValue,
+ pointsConsumed,
+ isWaterFilterFoundInRaid
+ );
+ _logger.Debug($"Water filter has: {resourceValue} units left in slot {i + 1}");
+
+ break; // Break here to avoid iterating other filters now w're done
+ }
+
+ // Filter ran out / used up
+ // biome-ignore lint/performance/noDelete: Delete is fine here, as we're seeking to entirely delete the water filter.
+ waterFilterArea.Slots[i].Items = null;
+ // Update remaining resources to be subtracted
+ filterDrainRate = Math.Abs(resourceValue ?? 0);
+ }
}
///
@@ -312,12 +874,18 @@ public class HideoutHelper(
/// Base drain rate
/// Drain rate (adjusted)
protected double GetTimeAdjustedWaterFilterDrainRate(
- double secondsSinceServerTick,
+ long secondsSinceServerTick,
double totalProductionTime,
double productionProgress,
double baseFilterDrainRate)
{
- throw new NotImplementedException();
+ var drainTimeSeconds =
+ secondsSinceServerTick > totalProductionTime
+ ? totalProductionTime - productionProgress // More time passed than prod time, get total minus the current progress
+ : secondsSinceServerTick;
+
+ // Multiply base drain rate by time passed
+ return baseFilterDrainRate * drainTimeSeconds;
}
///
@@ -325,9 +893,31 @@ public class HideoutHelper(
///
/// Player profile
/// Drain rate
- protected double GetWaterFilterDrainRate(PmcData playerProfile)
+ protected double GetWaterFilterDrainRate(PmcData pmcData)
{
- throw new NotImplementedException();
+ var globalSkillsDb = _databaseService.GetGlobals().Configuration.SkillsSettings;
+
+ // 100 resources last 8 hrs 20 min, 100/8.33/60/60 = 0.00333
+ var filterDrainRate = 0.00333;
+
+ var hideoutManagementConsumptionBonus = GetSkillBonusMultipliedBySkillLevel(
+ pmcData,
+ SkillTypes.HideoutManagement,
+ globalSkillsDb.HideoutManagement.ConsumptionReductionPerLevel ?? 0
+ );
+ var craftSkillTimeReductionMultipler = GetSkillBonusMultipliedBySkillLevel(
+ pmcData,
+ SkillTypes.Crafting,
+ globalSkillsDb.Crafting.CraftTimeReductionPerLevel ?? 0
+ );
+
+ // Never let bonus become 0
+ var reductionBonus =
+ hideoutManagementConsumptionBonus + craftSkillTimeReductionMultipler == 0
+ ? 1
+ : 1 - (hideoutManagementConsumptionBonus + craftSkillTimeReductionMultipler);
+
+ return filterDrainRate * reductionBonus;
}
///
@@ -337,7 +927,15 @@ public class HideoutHelper(
/// Seconds to produce item
protected double GetTotalProductionTimeSeconds(string prodId)
{
- throw new NotImplementedException();
+ return (
+ _databaseService.GetHideout()
+ .Production.Recipes.FirstOrDefault(
+ (prod) =>
+ prod.Id == prodId
+ )
+ ?.ProductionTime ??
+ 0
+ );
}
///
@@ -348,26 +946,175 @@ public class HideoutHelper(
///
/// Upd
protected Upd GetAreaUpdObject(
- int stackCount,
- double resourceValue,
- int resourceUnitsConsumed,
+ double stackCount,
+ double? resourceValue,
+ double resourceUnitsConsumed,
bool isFoundInRaid)
{
- throw new NotImplementedException();
+ return new Upd
+ {
+ StackObjectsCount = stackCount,
+ Resource = new UpdResource { Value = resourceValue, UnitsConsumed = resourceUnitsConsumed },
+ SpawnedInSession = isFoundInRaid,
+ };
}
- protected void UpdateAirFilters(BotHideoutArea airFilterArea, PmcData playerProfile, bool isGeneratorOn)
+ protected void UpdateAirFilters(BotHideoutArea airFilterArea, PmcData pmcData, bool isGeneratorOn)
{
- throw new NotImplementedException();
+ // 300 resources last 20 hrs, 300/20/60/60 = 0.00416
+ /* 10-10-2021 from WIKI (https://escapefromtarkov.fandom.com/wiki/FP-100_filter_absorber)
+ Lasts for 17 hours 38 minutes and 49 seconds (23 hours 31 minutes and 45 seconds with elite hideout management skill),
+ 300/17.64694/60/60 = 0.004722
+ */
+ var filterDrainRate =
+ _databaseService.GetHideout().Settings.AirFilterUnitFlowRate *
+ GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
+
+ // Hideout management resource consumption bonus:
+ var hideoutManagementConsumptionBonus = 1.0 - GetHideoutManagementConsumptionBonus(pmcData);
+ filterDrainRate *= hideoutManagementConsumptionBonus;
+ var pointsConsumed = 0D;
+
+ for (var i = 0; i < airFilterArea.Slots.Count; i++)
+ {
+ if (airFilterArea.Slots[i].Items is not null)
+ {
+ var resourceValue = airFilterArea.Slots[i].Items[0].Upd?.Resource is not null
+ ? airFilterArea.Slots[i].Items[0].Upd.Resource.Value
+ : null;
+
+ if (resourceValue is null)
+ {
+ resourceValue = 300 - filterDrainRate;
+ pointsConsumed = filterDrainRate ?? 0;
+ }
+ else
+ {
+ pointsConsumed = ((airFilterArea.Slots[i].Items[0].Upd.Resource.UnitsConsumed ?? 0) + filterDrainRate) ?? 0;
+ resourceValue -= filterDrainRate;
+ }
+
+ resourceValue = Math.Round((resourceValue * 10000) ?? 0) / 10000;
+ pointsConsumed = Math.Round(pointsConsumed * 10000) / 10000;
+
+ // check unit consumed for increment skill point
+ if (pmcData is not null && Math.Floor(pointsConsumed / 10) >= 1)
+ {
+ _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.HideoutManagement, 1);
+ pointsConsumed -= 10;
+ }
+
+ if (resourceValue > 0)
+ {
+ airFilterArea.Slots[i].Items[0].Upd = new Upd
+ {
+ StackObjectsCount = 1,
+ Resource = new UpdResource { Value = resourceValue, UnitsConsumed = pointsConsumed },
+ };
+ _logger.Debug($"Air filter: {resourceValue} filter left on slot {i + 1}");
+ break; // Break here to avoid updating all filters
+ }
+
+ airFilterArea.Slots[i].Items = null;
+ // Update remaining resources to be subtracted
+ filterDrainRate = Math.Abs(resourceValue ?? 0);
+ }
+ }
}
protected void UpdateBitcoinFarm(
- PmcData playerProfile,
+ PmcData pmcData,
Production btcProduction,
- int btcFarmCGs,
+ int? btcFarmCGs,
bool isGeneratorOn)
{
- throw new NotImplementedException();
+ var isBtcProd = btcProduction.GetType() == typeof(Production);
+ if (!isBtcProd)
+ {
+ return;
+ }
+
+ // The wiki has a wrong formula!
+ // Do not change unless you validate it with the Client code files!
+ // This formula was found on the client files:
+ // *******************************************************
+ /*
+ public override int InstalledSuppliesCount
+ {
+ get
+ {
+ return this.int_1;
+ }
+ protected set
+ {
+ if (this.int_1 === value)
+ {
+ return;
+ }
+ this.int_1 = value;
+ base.Single_0 = ((this.int_1 === 0) ? 0f : (1f + (float)(this.int_1 - 1) * this.float_4));
+ }
+ }
+ */
+ // **********************************************************
+ // At the time of writing this comment, this was GClass1667
+ // To find it in case of weird results, use DNSpy and look for usages on class AreaData
+ // Look for a GClassXXXX that has a method called "InitDetails" and the only parameter is the AreaData
+ // That should be the bitcoin farm production. To validate, try to find the snippet below:
+ /*
+ protected override void InitDetails(AreaData data)
+ {
+ base.InitDetails(data);
+ this.gclass1678_1.Type = EDetailsType.Farming;
+ }
+ */
+ // Needs power to function
+ if (!isGeneratorOn)
+ {
+ // Return with no changes
+ return;
+ }
+
+ var coinSlotCount = GetBTCSlots(pmcData);
+
+ // Full of bitcoins, halt progress
+ if (btcProduction.Products.Count >= coinSlotCount)
+ {
+ // Set progress to 0
+ btcProduction.Progress = 0;
+
+ return;
+ }
+
+ var bitcoinProdData = _databaseService
+ .GetHideout()
+ .Production.Recipes.FirstOrDefault((production) => production.Id == HideoutHelper.BitcoinProductionId);
+
+ // BSG finally fixed their settings, they now get loaded from the settings and used in the client
+ var adjustedCraftTime =
+ (_profileHelper.IsDeveloperAccount(pmcData.SessionId) ? 40 : bitcoinProdData.ProductionTime) /
+ (1 + (btcFarmCGs - 1) * _databaseService.GetHideout().Settings.GpuBoostRate);
+
+ // The progress should be adjusted based on the GPU boost rate, but the target is still the base productionTime
+ var timeMultiplier = bitcoinProdData.ProductionTime / adjustedCraftTime;
+ var timeElapsedSeconds = GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
+ btcProduction.Progress += Math.Floor((timeElapsedSeconds * timeMultiplier) ?? 0);
+
+ while (btcProduction.Progress >= bitcoinProdData.ProductionTime)
+ {
+ if (btcProduction.Products.Count < coinSlotCount)
+ {
+ // Has space to add a coin to production rewards
+ AddBtcToProduction(btcProduction, bitcoinProdData.ProductionTime ?? 0);
+ }
+ else
+ {
+ // Filled up bitcoin storage
+ btcProduction.Progress = 0;
+ }
+ }
+
+ btcProduction.StartTimestamp = _timeUtil.GetTimeStamp();
}
///
@@ -377,7 +1124,17 @@ public class HideoutHelper(
/// Time to craft a bitcoin
protected void AddBtcToProduction(Production btcProd, double coinCraftTimeSeconds)
{
- throw new NotImplementedException();
+ btcProd.Products.Add(
+ new Item
+ {
+ Id = _hashUtil.Generate(),
+ Template = ItemTpl.BARTER_PHYSICAL_BITCOIN,
+ Upd = new Upd { StackObjectsCount = 1 },
+ }
+ );
+
+ // Deduct time spent crafting from progress
+ btcProd.Progress -= coinCraftTimeSeconds;
}
///
@@ -387,12 +1144,27 @@ public class HideoutHelper(
/// Is the generator on for the duration of elapsed time
/// Hideout production recipe being crafted we need the ticks for
/// Amount of time elapsed in seconds
- protected double GetTimeElapsedSinceLastServerTick(
- PmcData playerProfile,
+ protected long? GetTimeElapsedSinceLastServerTick(
+ PmcData pmcData,
bool isGeneratorOn,
HideoutProduction recipe = null)
{
- throw new NotImplementedException();
+ // Reduce time elapsed (and progress) when generator is off
+ var timeElapsed = _timeUtil.GetTimeStamp() - pmcData.Hideout.SptUpdateLastRunTimestamp;
+
+ if (recipe is not null) {
+ var hideoutArea = _databaseService.GetHideout().Areas.FirstOrDefault((area) => area.Type == recipe.AreaType);
+ if (!(hideoutArea.NeedsFuel ?? false)) {
+ // e.g. Lavatory works at 100% when power is on / off
+ return timeElapsed;
+ }
+ }
+
+ if (!isGeneratorOn) {
+ timeElapsed *= (long)_databaseService.GetHideout().Settings.GeneratorSpeedWithoutFuel;
+ }
+
+ return timeElapsed;
}
///
@@ -400,17 +1172,25 @@ public class HideoutHelper(
///
/// Profile to look up
/// Coin slot count
- protected int GetBTCSlots(PmcData profileData)
+ protected double GetBTCSlots(PmcData pmcData)
{
- throw new NotImplementedException();
+ var bitcoinProductions = _databaseService
+ .GetHideout()
+ .Production.Recipes.FirstOrDefault((production) => production.Id == HideoutHelper.BitcoinFarm);
+ var productionSlots = bitcoinProductions?.ProductionLimitCount ?? 3; // Default to 3 if none found
+ var hasManagementSkillSlots = _profileHelper.HasEliteSkillLevel(SkillTypes.HideoutManagement, pmcData);
+ var managementSlotsCount = GetEliteSkillAdditionalBitcoinSlotCount() ?? 2;
+
+ return productionSlots + (hasManagementSkillSlots ? managementSlotsCount : 0);
}
///
/// Get a count of how many additional bitcoins player hideout can hold with elite skill
///
- protected int GetEliteSkillAdditionalBitcoinSlotCount()
+ protected double? GetEliteSkillAdditionalBitcoinSlotCount()
{
- throw new NotImplementedException();
+ return _databaseService.GetGlobals().Configuration.SkillsSettings.HideoutManagement.EliteSlots.BitcoinFarm
+ .Container;
}
///
@@ -419,9 +1199,25 @@ public class HideoutHelper(
///
/// Profile to get hideout consumption level from
/// Consumption bonus
- protected double GetHideoutManagementConsumptionBonus(PmcData profileData)
+ protected double? GetHideoutManagementConsumptionBonus(PmcData pmcData)
{
- throw new NotImplementedException();
+ var hideoutManagementSkill = _profileHelper.GetSkillFromProfile(pmcData, SkillTypes.HideoutManagement);
+ if (hideoutManagementSkill is null || hideoutManagementSkill.Progress == 0) {
+ return 0;
+ }
+
+ // If the level is 51 we need to round it at 50 so on elite you dont get 25.5%
+ // at level 1 you already get 0.5%, so it goes up until level 50. For some reason the wiki
+ // says that it caps at level 51 with 25% but as per dump data that is incorrect apparently
+ var roundedLevel = Math.Floor((hideoutManagementSkill.Progress / 100) ?? 0D);
+ roundedLevel = roundedLevel == 51 ? roundedLevel - 1 : roundedLevel;
+
+ return (
+ (roundedLevel *
+ _databaseService.GetGlobals().Configuration.SkillsSettings.HideoutManagement
+ .ConsumptionReductionPerLevel) /
+ 100
+ );
}
///
@@ -431,9 +1227,20 @@ public class HideoutHelper(
/// Player skill from profile
/// Value from globals.config.SkillsSettings - `PerLevel`
/// Multiplier from 0 to 1
- protected double GetSkillBonusMultipliedBySkillLevel(PmcData profileData, SkillTypes skill, double valuePerLevel)
+ protected double GetSkillBonusMultipliedBySkillLevel(PmcData pmcData, SkillTypes skill, double valuePerLevel)
{
- throw new NotImplementedException();
+ var profileSkill = _profileHelper.GetSkillFromProfile(pmcData, skill);
+ if (profileSkill is null || profileSkill.Progress == 0) {
+ return 0;
+ }
+
+ // If the level is 51 we need to round it at 50 so on elite you dont get 25.5%
+ // at level 1 you already get 0.5%, so it goes up until level 50. For some reason the wiki
+ // says that it caps at level 51 with 25% but as per dump data that is incorrect apparently
+ var roundedLevel = Math.Floor((profileSkill.Progress / 100) ?? 0D);
+ roundedLevel = roundedLevel == 51 ? roundedLevel - 1 : roundedLevel;
+
+ return (roundedLevel * valuePerLevel) / 100;
}
///
@@ -444,17 +1251,14 @@ public class HideoutHelper(
/// Skill bonus amount to apply
/// Seconds to reduce craft time by
public double GetSkillProductionTimeReduction(
- PmcData profileData,
+ PmcData pmcData,
double productionTime,
SkillTypes skill,
double amountPerLevel)
{
- throw new NotImplementedException();
- }
+ var skillTimeReductionMultipler = GetSkillBonusMultipliedBySkillLevel(pmcData, skill, amountPerLevel);
- public bool IsProduction(Production Production)
- {
- throw new NotImplementedException();
+ return productionTime * skillTimeReductionMultipler;
}
///
@@ -466,12 +1270,58 @@ public class HideoutHelper(
/// Session id
/// Output object to update
public void GetBTC(
- PmcData profileData,
+ PmcData pmcData,
HideoutTakeProductionRequestData request,
string sessionId,
ItemEventRouterResponse output)
{
- throw new NotImplementedException();
+ // Get how many coins were crafted and ready to pick up
+ var craftedCoinCount = pmcData.Hideout.Production[HideoutHelper.BitcoinFarm]?.Products?.Count;
+ if (craftedCoinCount is null) {
+ var errorMsg = _localisationService.GetText("hideout-no_bitcoins_to_collect");
+ _logger.Error(errorMsg);
+
+ _httpResponseUtil.AppendErrorToOutput(output, errorMsg);
+
+ return;
+ }
+
+ List
> itemsToAdd = [];
+ for (var index = 0; index < craftedCoinCount; index++) {
+ itemsToAdd.Add([new Item
+ {
+ Id = _hashUtil.Generate(),
+ Template = ItemTpl.BARTER_PHYSICAL_BITCOIN,
+ Upd = new Upd { StackObjectsCount = 1 },
+ },
+ ]);
+ }
+
+ // Create request for what we want to add to stash
+ AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest {
+ ItemsWithModsToAdd = itemsToAdd,
+ FoundInRaid = true,
+ UseSortingTable = false,
+ Callback = null,
+ };
+
+ // Add FiR coins to player inventory
+ _inventoryHelper.AddItemsToStash(sessionId, addItemsRequest, pmcData, output);
+ if (output.Warnings?.Count > 0) {
+ return;
+ }
+
+ // Is at max capacity + we collected all coins - reset production start time
+ var coinSlotCount = GetBTCSlots(pmcData);
+ if (pmcData.Hideout.Production[HideoutHelper.BitcoinFarm].Products.Count >= coinSlotCount) {
+ // Set start to now
+ pmcData.Hideout.Production[HideoutHelper.BitcoinFarm].StartTimestamp = _timeUtil
+ .GetTimeStamp();
+ }
+
+ // Remove crafted coins from production in profile now they've been collected
+ // Can only collect all coins, not individially
+ pmcData.Hideout.Production[HideoutHelper.BitcoinFarm].Products = [];
}
///
@@ -505,7 +1355,7 @@ public class HideoutHelper(
/// true if complete
protected bool HideoutImprovementIsComplete(HideoutImprovement improvement)
{
- throw new NotImplementedException();
+ return improvement?.Completed ?? false;
}
///
@@ -533,9 +1383,34 @@ public class HideoutHelper(
/// Add/remove bonus combat skill based on number of dogtags in place of fame hideout area
///
/// Player profile
- public void ApplyPlaceOfFameDogtagBonus(PmcData profileData)
+ public void ApplyPlaceOfFameDogtagBonus(PmcData pmcData)
{
- throw new NotImplementedException();
+ var fameAreaProfile = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.PLACE_OF_FAME);
+
+ // Get hideout area 16 bonus array
+ var fameAreaDb = _databaseService
+ .GetHideout()
+ .Areas.FirstOrDefault((area) => area.Type == HideoutAreas.PLACE_OF_FAME);
+
+ // Get SkillGroupLevelingBoost object
+ var combatBoostBonusDb = fameAreaDb.Stages[fameAreaProfile.Level.ToString()].Bonuses.FirstOrDefault(
+ (bonus) => bonus.Type.ToString() == "SkillGroupLevelingBoost"
+ );
+
+ // Get SkillGroupLevelingBoost object in profile
+ var combatBonusProfile = pmcData.Bonuses.FirstOrDefault((bonus) => bonus.Id == combatBoostBonusDb.Id);
+
+ // Get all slotted dogtag items
+ var activeDogtags = pmcData.Inventory.Items.Where((item) => item?.SlotId?.StartsWith("dogtag") ?? false).ToList();
+
+ // Calculate bonus percent (apply hideoutManagement bonus)
+ var hideoutManagementSkill = _profileHelper.GetSkillFromProfile(pmcData, SkillTypes.HideoutManagement);
+ var hideoutManagementSkillBonusPercent = 1 + hideoutManagementSkill.Progress / 10000; // 5100 becomes 0.51, add 1 to it, 1.51
+ var bonus =
+ GetDogtagCombatSkillBonusPercent(pmcData, activeDogtags) * hideoutManagementSkillBonusPercent;
+
+ // Update bonus value to above calcualted value
+ combatBonusProfile.Value = Math.Round((bonus ?? 0), 2);
}
///
@@ -545,9 +1420,24 @@ public class HideoutHelper(
/// Player profile
/// Active dogtags in place of fame dogtag slots
/// Combat bonus
- protected double GetDogtagCombatSkillBonusPercent(PmcData profileData, List- activeDogtags)
+ protected double GetDogtagCombatSkillBonusPercent(PmcData pmcData, List
- activeDogtags)
{
- throw new NotImplementedException();
+ // Not own dogtag
+ // Side = opposite of player
+ var result = 0D;
+ foreach (var dogtag in activeDogtags) {
+ if (dogtag.Upd.Dogtag is null) {
+ continue;
+ }
+
+ if (int.Parse(dogtag.Upd.Dogtag?.AccountId) == pmcData.Aid) {
+ continue;
+ }
+
+ result += (0.01 * dogtag.Upd.Dogtag.Level) ?? 0;
+ }
+
+ return result;
}
///
@@ -556,8 +1446,20 @@ public class HideoutHelper(
///
/// Hideout area data
/// Player profile
- public void RemoveHideoutWallBuffsAndDebuffs(HideoutArea wallAreaData, PmcData profileData)
+ public void RemoveHideoutWallBuffsAndDebuffs(HideoutArea wallAreaDb, PmcData pmcData)
{
- throw new NotImplementedException();
+ // Smush all stage bonuses into one array for easy iteration
+ var wallBonuses = wallAreaDb.Stages.SelectMany((stage) => stage.Value.Bonuses);
+
+ // Get all bonus Ids that the wall adds
+ List bonusIdsToRemove = [];
+ foreach (var bonus in wallBonuses) {
+ bonusIdsToRemove.Add(bonus.Id);
+ }
+
+ _logger.Debug($"Removing: {bonusIdsToRemove.Count} bonuses from profile");
+
+ // Remove the wall bonuses from profile by id
+ pmcData.Bonuses = pmcData.Bonuses.Where((bonus) => !bonusIdsToRemove.Contains(bonus.Id)).ToList();
}
}
diff --git a/Libraries/Core/Helpers/InventoryHelper.cs b/Libraries/Core/Helpers/InventoryHelper.cs
index f08e5d0b..fc83a786 100644
--- a/Libraries/Core/Helpers/InventoryHelper.cs
+++ b/Libraries/Core/Helpers/InventoryHelper.cs
@@ -95,13 +95,12 @@ public class InventoryHelper(
ItemEventRouterResponse output)
{
var itemWithModsToAddClone = _cloner.Clone(request.ItemWithModsToAdd);
- var rootItemToAdd = itemWithModsToAddClone.FirstOrDefault();
// Get stash layouts ready for use
var stashFS2D = GetStashSlotMap(pmcData, sessionId);
if (stashFS2D is null)
{
- _logger.Error("Unable to get stash map for players: { sessionId} stash");
+ _logger.Error($"Unable to get stash map for players: {sessionId} stash");
return;
}
@@ -125,13 +124,13 @@ public class InventoryHelper(
SetFindInRaidStatusForItem(itemWithModsToAddClone, request.FoundInRaid.GetValueOrDefault(false));
// Remove trader properties from root item
- RemoveTraderRagfairRelatedUpdProperties(rootItemToAdd.Upd);
+ RemoveTraderRagfairRelatedUpdProperties(itemWithModsToAddClone[0].Upd);
// Run callback
try
{
if (request.Callback is not null)
- request.Callback((int) (rootItemToAdd.Upd.StackObjectsCount ?? 0));
+ request.Callback((int)(itemWithModsToAddClone[0].Upd.StackObjectsCount ?? 0));
}
catch (Exception ex)
{
@@ -144,12 +143,13 @@ public class InventoryHelper(
}
// Add item + mods to output and profile inventory
+
output.ProfileChanges[sessionId]
- .Items.NewItems.AddRange(itemWithModsToAddClone.Select(x => x));
+ .Items.NewItems.AddRange(itemWithModsToAddClone);
pmcData.Inventory.Items.AddRange(itemWithModsToAddClone);
_logger.Debug(
- $"Added {rootItemToAdd.Upd?.StackObjectsCount ?? 1} item: {rootItemToAdd.Template} with: {itemWithModsToAddClone.Count - 1} mods to inventory"
+ $"Added {itemWithModsToAddClone[0].Upd?.StackObjectsCount ?? 1} item: {itemWithModsToAddClone[0].Template} with: {itemWithModsToAddClone.Count - 1} mods to inventory"
);
}
@@ -918,7 +918,7 @@ public class InventoryHelper(
/// Player profile
/// session id
/// 2-dimensional array
- protected int[][] GetStashSlotMap(PmcData pmcData, string sessionID)
+ protected int[][]? GetStashSlotMap(PmcData pmcData, string sessionID)
{
var playerStashSize = GetPlayerStashSize(sessionID);
return GetContainerMap(
diff --git a/Libraries/Core/Helpers/ItemHelper.cs b/Libraries/Core/Helpers/ItemHelper.cs
index dc011626..e283f731 100644
--- a/Libraries/Core/Helpers/ItemHelper.cs
+++ b/Libraries/Core/Helpers/ItemHelper.cs
@@ -1,16 +1,13 @@
-using System.Text.Json;
using System.Text.Json.Serialization;
using SptCommon.Annotations;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
-using Core.Models.Eft.ItemEvent;
using Core.Models.Enums;
using Core.Models.Utils;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;
using Core.Utils.Collections;
-using SptCommon.Extensions;
namespace Core.Helpers;
@@ -933,8 +930,92 @@ public class ItemHelper(
}
}
+ public void ReplaceProfileInventoryIds(BotBaseInventory inventory, List? insuredItems = null)
+ {
+ // Blacklist
+ var itemIdBlacklist = new HashSet();
+ itemIdBlacklist.UnionWith(
+ new List{
+ inventory.Equipment,
+ inventory.QuestRaidItems,
+ inventory.QuestStashItems,
+ inventory.SortingTable,
+ inventory.Stash,
+ inventory.HideoutCustomizationStashId
+ });
+ itemIdBlacklist.UnionWith(inventory.HideoutAreaStashes.Keys);
+
+ // Add insured items ids to blacklist
+ if (insuredItems is not null)
+ {
+ itemIdBlacklist.UnionWith(insuredItems.Select(x => x.ItemId));
+ }
+
+
+ foreach (var item in inventory.Items)
+ {
+ if (itemIdBlacklist.Contains(item.Id))
+ {
+ continue;
+ }
+
+ // Generate new id
+ var newId = _hashUtil.Generate();
+
+ // Keep copy of original id
+ var originalId = item.Id;
+
+ // Update items id to new one we generated
+ item.Id = newId;
+
+ // Find all children of item and update their parent ids to match
+ var childItems = inventory.Items.Where(x => x.ParentId == originalId);
+ foreach (var childItem in childItems)
+ {
+ childItem.ParentId = newId;
+ }
+
+ // Also replace in quick slot if the old ID exists.
+ if (inventory.FastPanel is null)
+ {
+ continue;
+ }
+
+ // Update quickslot id
+ if (inventory.FastPanel.ContainsKey(originalId))
+ {
+ inventory.FastPanel[originalId] = newId;
+ }
+ }
+ }
+
+ public List
- ReplaceIDs(List
- items)
+ {
+ foreach (var item in items)
+ {
+
+ // Generate new id
+ var newId = _hashUtil.Generate();
+
+ // Keep copy of original id
+ var originalId = item.Id;
+
+ // Update items id to new one we generated
+ item.Id = newId;
+
+ // Find all children of item and update their parent ids to match
+ var childItems = items.Where(x => x.ParentId == originalId);
+ foreach (var childItem in childItems)
+ {
+ childItem.ParentId = newId;
+ }
+ }
+
+ return items;
+ }
+
///
- /// Regenerate all GUIDs with new IDs, for the exception of special item types (e.g. quest, sorting table, etc.) This
+ /// Regenerate all GUIDs with new IDs, with the exception of special item types (e.g. quest, sorting table, etc.) This
/// function will not mutate the original items list, but will return a new list with new GUIDs.
///
/// Items to adjust the IDs of
@@ -942,127 +1023,74 @@ public class ItemHelper(
/// Insured items that should not have their IDs replaced
/// Quick slot panel
/// List
- public List- ReplaceIDs(List
- originalItems, PmcData pmcData = null, List insuredItems = null,
- Dictionary fastPanel = null)
+ public List
- ReplaceIDs(
+ List
- originalItems,
+ PmcData? pmcData = null,
+ List? insuredItems = null,
+ Dictionary? fastPanel = null)
{
- var serialisedInventory = _jsonUtil.Serialize(originalItems);
- var hideoutAreaStashes = pmcData?.Inventory?.HideoutAreaStashes ?? new();
+ // Blacklist
+ var itemIdBlacklist = new HashSet();
+
+ if (pmcData != null)
+ {
+ itemIdBlacklist.UnionWith(
+ new List{
+ pmcData.Inventory.Equipment,
+ pmcData.Inventory.QuestRaidItems,
+ pmcData.Inventory.QuestStashItems,
+ pmcData.Inventory.SortingTable,
+ pmcData.Inventory.Stash,
+ pmcData.Inventory.HideoutCustomizationStashId
+ });
+ itemIdBlacklist.UnionWith(pmcData.Inventory.HideoutAreaStashes.Keys);
+ }
+
+
+ // Add insured items ids to blacklist
+ if (insuredItems is not null)
+ {
+ itemIdBlacklist.UnionWith(insuredItems.Select(x => x.ItemId));
+ }
+
foreach (var item in originalItems)
{
- if (pmcData != null)
- {
- // Insured items should not be renamed. Only works for PMCs.
- if (insuredItems?.FirstOrDefault(i => i.ItemId == item.Id) != null)
- continue;
-
- // Do not replace the IDs of specific types of items.
- if (item.Id == pmcData?.Inventory?.Equipment ||
- item.Id == pmcData?.Inventory?.QuestRaidItems ||
- item.Id == pmcData?.Inventory?.QuestStashItems ||
- item.Id == pmcData?.Inventory?.SortingTable ||
- item.Id == pmcData?.Inventory?.Stash ||
- item.Id == pmcData?.Inventory?.HideoutCustomizationStashId ||
- (hideoutAreaStashes?.ContainsKey(item.Id) ?? false))
- {
- continue;
- }
- }
-
- // Replace the ID of the item in the serialised inventory using a regular expression.
- var oldId = item.Id;
- var newId = _hashUtil.Generate();
- serialisedInventory = serialisedInventory.Replace(oldId, newId); // Node uses regex with "g" flag to replace all instances
-
- // Also replace in quick slot if the old ID exists.
- if (fastPanel != null)
- {
- foreach (var itemSlot in fastPanel)
- {
- if (fastPanel[itemSlot.Key] == oldId)
- fastPanel[itemSlot.Key] = fastPanel[itemSlot.Key].Replace(oldId, newId); // Node uses regex with "g" flag to replace all instances
- }
- }
- }
-
- var items = _jsonUtil.Deserialize
>(serialisedInventory);
-
- // fix dupe id's
- var dupes = new Dictionary();
- var newParents = new Dictionary>();
- var childrenMapping = new Dictionary>();
- var oldToNewIds = new Dictionary>();
-
- // Finding duplicate IDs involves scanning the item three times.
- // First scan - Check which ids are duplicated.
- // Second scan - Map parents to items.
- // Third scan - Resolve IDs.
- foreach (var item in items)
- {
- if (!dupes.TryAdd(item.Id, 0))
- {
- dupes[item.Id] += 1;
- }
- }
-
- foreach (var item in items)
- {
- if (!(dupes[item.Id] > 1))
+ if (itemIdBlacklist.Contains(item.Id))
{
continue;
}
+ // Generate new id
var newId = _hashUtil.Generate();
- if (!newParents.ContainsKey(item.ParentId))
+
+ // Keep copy of original id
+ var originalId = item.Id;
+
+ // Update items id to new one we generated
+ item.Id = newId;
+
+ // Find all children of item and update their parent ids to match
+ var childItems = originalItems.Where(x => x.ParentId == originalId);
+ foreach (var childItem in childItems)
{
- newParents.Add(item.ParentId, []);
+ childItem.ParentId = newId;
}
- var newParentsItems = newParents.GetValueOrDefault(item.ParentId);
- newParentsItems.Add(item);
-
- if (!oldToNewIds.ContainsKey(item.Id))
+ // Also replace in quick slot if the old ID exists.
+ if (pmcData.Inventory.FastPanel is null)
{
- oldToNewIds.Add(item.Id, []);
+ continue;
}
- var oldToNewIdsItems = oldToNewIds.GetValueOrDefault(item.Id);
- oldToNewIdsItems.Add(newId);
- }
-
- foreach (var item in items)
- {
- if (dupes[item.Id] > 1)
+ // Update quickslot id
+ if (pmcData.Inventory.FastPanel.ContainsKey(originalId))
{
- var oldId = item.Id;
- var newId = oldToNewIds[oldId][0];
- oldToNewIds[oldId].RemoveAt(0);
- item.Id = newId;
-
- // Extract one of the children that's also duplicated.
- if (newParents.ContainsKey(oldId) && newParents[oldId].Count > 0)
- {
- childrenMapping[newId] = new();
- for (int i = 0; i < newParents[oldId].Count; i++)
- {
- // Make sure we haven't already assigned another duplicate child of
- // same slot and location to this parent.
- var childId = GetChildId(newParents[oldId][i]);
-
- if (!childrenMapping.ContainsKey(childId))
- {
- childrenMapping[newId][childId] = 1;
- newParents[oldId][i].ParentId = newId;
- // Some very fucking sketchy stuff on this childIndex
- // No clue wth was that childIndex supposed to be, but its not
- newParents[oldId].RemoveAt(i);
- }
- }
- }
+ pmcData.Inventory.FastPanel[originalId] = newId;
}
}
- return items;
+ return originalItems;
}
///
@@ -1431,11 +1459,11 @@ public class ItemHelper(
chosenCaliber,
staticAmmoDist,
defaultCartridgeTpl,
- weapon?.Properties?.Chambers[0]?.Props?.Filters[0]?.Filter
+ (weapon?.Properties?.Chambers?.FirstOrDefault()?.Props?.Filters?.FirstOrDefault()?.Filter) ?? null
);
if (cartridgeTpl is null)
{
- _logger.Debug($"Unable to fill item: {magazine[0].Id} {magTemplate.Name} with cartridges, none found.");
+ _logger.Debug($"Unable to fill item: {magazine.FirstOrDefault().Id} {magTemplate.Name} with cartridges, none found.");
return;
}
@@ -1851,12 +1879,9 @@ public class ItemHelper(
// Optional: new id to use
// Returns New root id
- public string RemapRootItemId(List- itemWithChildren, string newId = null)
+ public string RemapRootItemId(List
- itemWithChildren, string? newId = null)
{
- if (newId is null)
- {
- newId = _hashUtil.Generate();
- }
+ newId ??= _hashUtil.Generate();
var rootItemExistingId = itemWithChildren[0].Id;
diff --git a/Libraries/Core/Helpers/NotifierHelper.cs b/Libraries/Core/Helpers/NotifierHelper.cs
index fe14af5f..fdd3f72b 100644
--- a/Libraries/Core/Helpers/NotifierHelper.cs
+++ b/Libraries/Core/Helpers/NotifierHelper.cs
@@ -25,7 +25,7 @@ public class NotifierHelper(HttpServerHelper _httpServerHelper)
EventIdentifier = dialogueMessage.Id,
OfferId = ragfairData.OfferId,
HandbookId = ragfairData.HandbookId,
- Count = ragfairData.Count
+ Count = (int)ragfairData.Count
};
}
diff --git a/Libraries/Core/Helpers/ProfileHelper.cs b/Libraries/Core/Helpers/ProfileHelper.cs
index e507e2b4..47b5a085 100644
--- a/Libraries/Core/Helpers/ProfileHelper.cs
+++ b/Libraries/Core/Helpers/ProfileHelper.cs
@@ -510,7 +510,7 @@ public class ProfileHelper(
/// True if account is developer
public bool IsDeveloperAccount(string sessionID)
{
- return GetFullProfile(sessionID)?.ProfileInfo?.Edition?.ToLower().StartsWith("spt developer") == false;
+ return GetFullProfile(sessionID)?.ProfileInfo?.Edition?.ToLower().StartsWith("spt developer") ?? false;
}
///
diff --git a/Libraries/Core/Helpers/QuestHelper.cs b/Libraries/Core/Helpers/QuestHelper.cs
index 6f6e7469..6054253d 100644
--- a/Libraries/Core/Helpers/QuestHelper.cs
+++ b/Libraries/Core/Helpers/QuestHelper.cs
@@ -96,7 +96,18 @@ public class QuestHelper(
/// Reduction of cartesian product between two quest lists
public List GetDeltaQuests(List before, List after)
{
- throw new System.NotImplementedException();
+ List knownQuestsIds = [];
+ foreach (var quest in before) {
+ knownQuestsIds.Add(quest.Id);
+ }
+
+ if (knownQuestsIds.Count != 0) {
+ return after.Where((q) => {
+ return knownQuestsIds.IndexOf(q.Id) == -1;
+ }).ToList();
+ }
+
+ return after;
}
///
@@ -107,7 +118,43 @@ public class QuestHelper(
/// the adjusted skill progress gain
public int AdjustSkillExpForLowLevels(Common profileSkill, int progressAmount)
{
- throw new System.NotImplementedException();
+ var currentLevel = Math.Floor((double)(profileSkill.Progress / 100));
+
+ // Only run this if the current level is under 9
+ if (currentLevel >= 9) {
+ return progressAmount;
+ }
+
+ // This calculates how much progress we have in the skill's starting level
+ var startingLevelProgress = (profileSkill.Progress % 100) * ((currentLevel + 1) / 10);
+
+ // The code below assumes a 1/10th progress skill amount
+ var remainingProgress = progressAmount / 10;
+
+ // We have to do this loop to handle edge cases where the provided XP bumps your level up
+ // See "CalculateExpOnFirstLevels" in client for original logic
+ var adjustedSkillProgress = 0;
+ while (remainingProgress > 0 && currentLevel < 9) {
+ // Calculate how much progress to add, limiting it to the current level max progress
+ var currentLevelRemainingProgress = (currentLevel + 1) * 10 - startingLevelProgress;
+ _logger.Debug($"currentLevelRemainingProgress: {currentLevelRemainingProgress}");
+ var progressToAdd = Math.Min(remainingProgress, currentLevelRemainingProgress ?? 0);
+ var adjustedProgressToAdd = (10 / (currentLevel + 1)) * progressToAdd;
+ _logger.Debug($"Progress To Add: {progressToAdd} Adjusted for level: {adjustedProgressToAdd}");
+
+ // Add the progress amount adjusted by level
+ adjustedSkillProgress += (int)adjustedProgressToAdd;
+ remainingProgress -= (int)progressToAdd;
+ startingLevelProgress = 0;
+ currentLevel++;
+ }
+
+ // If there's any remaining progress, add it. This handles if you go from level 8 -> 9
+ if (remainingProgress > 0) {
+ adjustedSkillProgress += remainingProgress;
+ }
+
+ return adjustedSkillProgress;
}
///
@@ -289,7 +336,7 @@ public class QuestHelper(
{
return (
condition.ConditionType == "Quest" &&
- (condition.Target?.Item?.Contains(startedQuestId) ?? false) &&
+ ((condition.Target?.Item?.Contains(startedQuestId) ?? false) || (condition.Target?.List?.Contains(startedQuestId) ?? false))&&
(condition.Status?.Contains(QuestStatusEnum.Started) ?? false)
);
}
@@ -318,7 +365,7 @@ public class QuestHelper(
return false;
}
- if (!QuestIsProfileWhitelisted(profile.Info.GameVersion, quest.Id))
+ if (QuestIsProfileWhitelisted(profile.Info.GameVersion, quest.Id))
{
return false;
}
@@ -419,7 +466,7 @@ public class QuestHelper(
*/
protected bool QuestIsProfileBlacklisted(string gameVersion, string questId)
{
- var questBlacklist = _questConfig.ProfileBlacklist[gameVersion];
+ var questBlacklist = _questConfig.ProfileBlacklist?.GetValueOrDefault(gameVersion);
if (questBlacklist is null)
{
// Not blacklisted
@@ -902,8 +949,7 @@ public class QuestHelper(
public ItemEventRouterResponse CompleteQuest(PmcData pmcData, CompleteQuestRequestData body, string sessionID)
{
var completeQuestResponse = _eventOutputHolder.GetOutput(sessionID);
-
- var completedQuest = GetQuestFromDb(body.QuestId, pmcData);
+
var preCompleteProfileQuests = _cloner.Clone(pmcData.Quests);
var completedQuestId = body.QuestId;
@@ -961,10 +1007,7 @@ public class QuestHelper(
{
completeQuestResponse.ProfileChanges[sessionID].QuestsStatus.AddRange(questStatusChanges);
}
-
- // Recalculate level in event player leveled up
- pmcData.Info.Level = _playerService.CalculateLevel(pmcData);
-
+
return completeQuestResponse;
}
@@ -1128,7 +1171,6 @@ public class QuestHelper(
*/
protected List UpdateQuestsForGameEdition(List quests, string gameVersion)
{
- _logger.Debug("[UpdateQuestsForGameEdition] If you are hitting this method, please confirm the return is comparable to Node");
var modifiedQuests = _cloner.Clone(quests);
foreach (var quest in modifiedQuests)
{
@@ -1174,7 +1216,7 @@ public class QuestHelper(
return false;
}
- return quest.Conditions.Fail.Any(condition => (condition.Target.List?.Contains(completedQuestId) ?? false));
+ return quest.Conditions.Fail.Any(condition => (condition.Target?.List?.Contains(completedQuestId) ?? false));
}
)
.ToList();
@@ -1279,9 +1321,9 @@ public class QuestHelper(
foreach (var quest in quests)
{
// If quest has prereq of completed quest + availableAfter value > 0 (quest has wait time)
- var nextQuestWaitCondition = quest.Conditions.AvailableForStart.FirstOrDefault(
- x => (x.Target?.List.Contains(completedQuestId) ?? false) && x.AvailableAfter > 0
- );
+ var nextQuestWaitCondition = quest.Conditions?.AvailableForStart?.FirstOrDefault(
+ x => ((x.Target?.List?.Contains(completedQuestId) ?? false) || (x.Target?.Item?.Contains(completedQuestId) ?? false)) && x.AvailableAfter > 0
+ ); // as we have to use the ListOrT type now, check both List and Item for the above checks
if (nextQuestWaitCondition is not null)
{
diff --git a/Libraries/Core/Helpers/RagfairOfferHelper.cs b/Libraries/Core/Helpers/RagfairOfferHelper.cs
index 957a5bd4..39b2c5f3 100644
--- a/Libraries/Core/Helpers/RagfairOfferHelper.cs
+++ b/Libraries/Core/Helpers/RagfairOfferHelper.cs
@@ -1,8 +1,9 @@
-using System.Runtime.InteropServices.JavaScript;
-using SptCommon.Annotations;
+using System.Text.RegularExpressions;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
+using Core.Models.Eft.Hideout;
using Core.Models.Eft.ItemEvent;
+using Core.Models.Eft.Player;
using Core.Models.Eft.Profile;
using Core.Models.Eft.Ragfair;
using Core.Models.Enums;
@@ -12,113 +13,125 @@ using Core.Routers;
using Core.Servers;
using Core.Services;
using Core.Utils;
+using SptCommon.Annotations;
using SptCommon.Extensions;
namespace Core.Helpers;
[Injectable]
public class RagfairOfferHelper(
- ISptLogger logger,
- TimeUtil timeUtil,
- HashUtil hashUtil,
- EventOutputHolder eventOutputHolder,
- DatabaseService databaseService,
- TraderHelper traderHelper,
- SaveServer saveServer,
- ItemHelper itemHelper,
- BotHelper botHelper,
- PaymentHelper paymentHelper,
- PresetHelper presetHelper,
- ProfileHelper profileHelper,
- QuestHelper questHelper,
- RagfairServerHelper ragfairServerHelper,
- RagfairSortHelper ragfairSortHelper,
- RagfairHelper ragfairHelper,
- RagfairOfferService ragfairOfferService,
- RagfairRequiredItemsService ragfairRequiredItemsService,
- LocaleService localeService,
- LocalisationService localisationService,
- MailSendService mailSendService,
- ConfigServer configServer
-)
+ ISptLogger _logger,
+ TimeUtil _timeUtil,
+ HashUtil _hashUtil,
+ BotHelper _botHelper,
+ RagfairSortHelper _ragfairSortHelper,
+ PresetHelper _presetHelper,
+ RagfairHelper _ragfairHelper,
+ PaymentHelper _paymentHelper,
+ TraderHelper _traderHelper,
+ QuestHelper _questHelper,
+ RagfairServerHelper _ragfairServerHelper,
+ ItemHelper _itemHelper,
+ DatabaseService _databaseService,
+ RagfairOfferService _ragfairOfferService,
+ LocaleService _localeService,
+ LocalisationService _localisationService,
+ MailSendService _mailSendService,
+ RagfairRequiredItemsService _ragfairRequiredItemsService,
+ ProfileHelper _profileHelper,
+ EventOutputHolder _eventOutputHolder,
+ ConfigServer _configServer)
{
- protected static string goodSoldTemplate = "5bdabfb886f7743e152e867e 0"; // Your {soldItem} {itemCount} items were bought by {buyerNickname}.
- protected RagfairConfig ragfairConfig = configServer.GetConfig();
- protected QuestConfig questConfig = configServer.GetConfig();
- protected BotConfig botConfig = configServer.GetConfig();
+ protected RagfairConfig _ragfairConfig = _configServer.GetConfig();
+ protected BotConfig _botConfig = _configServer.GetConfig();
+ protected static string _goodSoldTemplate = "5bdabfb886f7743e152e867e 0"; // Your {soldItem} {itemCount} items were bought by {buyerNickname}.
- /**
- * Passthrough to ragfairOfferService.getOffers(), get flea offers a player should see
- * @param searchRequest Data from client
- * @param itemsToAdd ragfairHelper.filterCategories()
- * @param traderAssorts Trader assorts
- * @param pmcData Player profile
- * @returns Offers the player should see
- */
+ ///
+ /// Passthrough to ragfairOfferService.getOffers(), get flea offers a player should see
+ ///
+ /// Data from client
+ /// ragfairHelper.filterCategories()
+ /// Trader assorts
+ /// Player profile
+ /// Offers the player should see
public List GetValidOffers(
SearchRequestData searchRequest,
List itemsToAdd,
Dictionary traderAssorts,
- PmcData pmcData
- )
+ PmcData pmcData)
{
- var playerIsFleaBanned = profileHelper.PlayerIsFleaBanned(pmcData);
- var tieredFlea = ragfairConfig.TieredFlea;
- var tieredFleaLimitTypes = tieredFlea.UnlocksType.Keys;
- return ragfairOfferService.GetOffers().Where((offer) => {
- if (!PassesSearchFilterCriteria(searchRequest, offer, pmcData)) {
- return false;
- }
+ var playerIsFleaBanned = _profileHelper.PlayerIsFleaBanned(pmcData);
+ var tieredFlea = _ragfairConfig.TieredFlea;
+ var tieredFleaLimitTypes = tieredFlea.UnlocksType;
+ return _ragfairOfferService.GetOffers()
+ .Where(
+ offer =>
+ {
+ if (!PassesSearchFilterCriteria(searchRequest, offer, pmcData))
+ {
+ return false;
+ }
- var isDisplayable = IsDisplayableOffer(
- searchRequest,
- itemsToAdd,
- traderAssorts,
- offer,
- pmcData,
- playerIsFleaBanned
- );
+ var isDisplayable = IsDisplayableOffer(
+ searchRequest,
+ itemsToAdd,
+ traderAssorts,
+ offer,
+ pmcData,
+ playerIsFleaBanned
+ );
- if (!isDisplayable) {
- return false;
- }
+ if (!isDisplayable)
+ {
+ return false;
+ }
- // Not trader offer + tiered flea enabled
- if (tieredFlea.Enabled && !OfferIsFromTrader(offer)) {
- CheckAndLockOfferFromPlayerTieredFlea(tieredFlea, offer, tieredFleaLimitTypes, pmcData.Info.Level);
- }
+ // Not trader offer + tiered flea enabled
+ if (tieredFlea.Enabled && !OfferIsFromTrader(offer))
+ {
+ CheckAndLockOfferFromPlayerTieredFlea(
+ tieredFlea,
+ offer,
+ tieredFleaLimitTypes.Keys.ToList(),
+ pmcData.Info.Level.Value
+ );
+ }
- return true;
- });
+ return true;
+ }
+ )
+ .ToList();
}
- /**
- * Disable offer if item is flagged by tiered flea config
- * @param tieredFlea Tiered flea settings from ragfair config
- * @param offer Ragfair offer to check
- * @param tieredFleaLimitTypes Dict of item types with player level to be viewable
- * @param playerLevel Level of player viewing offer
- */
+ ///
+ /// Disable offer if item is flagged by tiered flea config
+ ///
+ /// Tiered flea settings from ragfair config
+ /// Ragfair offer to check
+ /// Dict of item types with player level to be viewable
+ /// Level of player viewing offer
protected void CheckAndLockOfferFromPlayerTieredFlea(
TieredFlea tieredFlea,
RagfairOffer offer,
List tieredFleaLimitTypes,
- int playerLevel
- )
+ int playerLevel)
{
- var offerItemTpl = offer.Items[0].Template;
- if (tieredFlea?.AmmoTplUnlocks != null && itemHelper.IsOfBaseclass(offerItemTpl, BaseClasses.AMMO)) {
- var unlockLevel = tieredFlea.AmmoTplUnlocks[offerItemTpl];
- if (unlockLevel != null && playerLevel < unlockLevel) {
+ var offerItemTpl = offer.Items.FirstOrDefault().Template;
+ if (tieredFlea.AmmoTplUnlocks is not null && _itemHelper.IsOfBaseclass(offerItemTpl, BaseClasses.AMMO))
+ {
+ if (tieredFlea.AmmoTplUnlocks.TryGetValue(offerItemTpl, out var unlockLevel) && playerLevel < unlockLevel)
+ {
offer.Locked = true;
+
return;
}
}
// Check for a direct level requirement for the offer item
- var itemLevelRequirement = tieredFlea.UnlocksTpl[offerItemTpl];
- if (itemLevelRequirement != null) {
- if (playerLevel < itemLevelRequirement) {
+ if (tieredFlea.UnlocksTpl.TryGetValue(offerItemTpl, out var itemLevelRequirement))
+ {
+ if (playerLevel < itemLevelRequirement)
+ {
offer.Locked = true;
return;
@@ -126,128 +139,158 @@ public class RagfairOfferHelper(
}
// Optimisation - Ensure the item has at least one of the limited base types
- if (itemHelper.IsOfBaseclasses(offerItemTpl, tieredFleaLimitTypes)) {
- // Loop over all flea types to find the matching one
- foreach (var tieredItemType in tieredFleaLimitTypes) {
- if (itemHelper.IsOfBaseclass(offerItemTpl, tieredItemType)) {
- if (playerLevel < tieredFlea.UnlocksType[tieredItemType]) {
- offer.Locked = true;
- }
- break;
+ if (_itemHelper.IsOfBaseclasses(offerItemTpl, tieredFleaLimitTypes))
+ {
+ // Loop over flea types
+ foreach (var tieredItemType in tieredFleaLimitTypes
+ .Where(tieredItemType => _itemHelper.IsOfBaseclass(offerItemTpl, tieredItemType)))
+ {
+ if (playerLevel < tieredFlea.UnlocksType[tieredItemType])
+ {
+ offer.Locked = true;
}
+
+ break;
}
}
}
- /**
- * Get matching offers that require the desired item and filter out offers from non traders if player is below ragfair unlock level
- * @param searchRequest Search request from client
- * @param pmcDataPlayer profile
- * @returns Matching IRagfairOffer objects
- */
+ ///
+ /// Get matching offers that require the desired item and filter out offers from non traders if player is below ragfair
+ /// unlock level
+ ///
+ /// Search request from client
+ /// Player profile
+ /// Matching RagfairOffer objects
public List GetOffersThatRequireItem(SearchRequestData searchRequest, PmcData pmcData)
{
- // Get all offers that requre the desired item and filter out offers from non traders if player below ragifar unlock
- var requiredOffers = ragfairRequiredItemsService.GetRequiredItemsById(searchRequest.NeededSearchId);
- var tieredFlea = ragfairConfig.TieredFlea;
- var tieredFleaLimitTypes = tieredFlea.UnlocksType.Keys.ToList();
+ // Get all offers that require the desired item and filter out offers from non traders if player below ragifar unlock
+ var requiredOffers = _ragfairRequiredItemsService.GetRequiredItemsById(searchRequest.NeededSearchId);
+ var tieredFlea = _ragfairConfig.TieredFlea;
+ var tieredFleaLimitTypes = tieredFlea.UnlocksType;
+ return requiredOffers.Where(
+ offer =>
+ {
+ if (!PassesSearchFilterCriteria(searchRequest, offer, pmcData))
+ {
+ return false;
+ }
- return requiredOffers.Where(offer => {
- if (!PassesSearchFilterCriteria(searchRequest, offer, pmcData)) {
- return false;
- }
+ if (tieredFlea.Enabled && !OfferIsFromTrader(offer))
+ {
+ CheckAndLockOfferFromPlayerTieredFlea(
+ tieredFlea,
+ offer,
+ tieredFleaLimitTypes.Keys.ToList(),
+ pmcData.Info.Level.Value
+ );
+ }
- if (tieredFlea.Enabled && !OfferIsFromTrader(offer)) {
- CheckAndLockOfferFromPlayerTieredFlea(tieredFlea, offer, tieredFleaLimitTypes, pmcData.Info.Level.Value);
- }
-
- return true;
- });
+ return true;
+ }
+ )
+ .ToList();
}
- /**
- * Get offers from flea/traders specifically when building weapon preset
- * @param searchRequest Search request data
- * @param itemsToAdd string array of item tpls to search for
- * @param traderAssorts All trader assorts player can access/buy
- * @param pmcData Player profile
- * @returns IRagfairOffer array
- */
+ ///
+ /// Get offers from flea/traders specifically when building weapon preset
+ ///
+ /// Search request data
+ /// string array of item tpls to search for
+ /// All trader assorts player can access/buy
+ /// Player profile
+ /// RagfairOffer array
public List GetOffersForBuild(
SearchRequestData searchRequest,
List itemsToAdd,
Dictionary traderAssorts,
- PmcData pmcData
- )
+ PmcData pmcData)
{
var offersMap = new Dictionary>();
var offersToReturn = new List();
- var playerIsFleaBanned = profileHelper.PlayerIsFleaBanned(pmcData);
- var tieredFlea = ragfairConfig.TieredFlea;
- var tieredFleaLimitTypes = tieredFlea.UnlocksType.Keys.ToList();
+ var playerIsFleaBanned = _profileHelper.PlayerIsFleaBanned(pmcData);
+ var tieredFlea = _ragfairConfig.TieredFlea;
+ var tieredFleaLimitTypes = tieredFlea.UnlocksType;
- foreach (var desiredItemTpl in searchRequest.BuildItems.Keys) {
- var matchingOffers = ragfairOfferService.GetOffersOfType(desiredItemTpl);
- if (matchingOffers == null) {
+ foreach (var desiredItemTpl in searchRequest.BuildItems)
+ {
+ var matchingOffers = _ragfairOfferService.GetOffersOfType(desiredItemTpl.Key);
+ if (matchingOffers is null)
+ {
// No offers found for this item, skip
continue;
}
- foreach (var offer in matchingOffers) {
- // Dont show pack offers
- if (offer.SellInOnePiece ?? false) {
+
+ foreach (var offer in matchingOffers)
+ {
+ // Don't show pack offers
+ if (offer.SellInOnePiece.GetValueOrDefault(false))
+ {
continue;
}
- if (!PassesSearchFilterCriteria(searchRequest, offer, pmcData)) {
+ if (!PassesSearchFilterCriteria(searchRequest, offer, pmcData))
+ {
continue;
}
- if (!IsDisplayableOffer(
+ if (
+ !IsDisplayableOffer(
searchRequest,
itemsToAdd,
traderAssorts,
offer,
pmcData,
- playerIsFleaBanned)
- ) {
+ playerIsFleaBanned
+ )
+ )
+ {
continue;
}
- if (OfferIsFromTrader(offer)) {
- if (TraderBuyRestrictionReached(offer)) {
+ if (OfferIsFromTrader(offer))
+ {
+ if (TraderBuyRestrictionReached(offer))
+ {
continue;
}
- if (TraderOutOfStock(offer)) {
+ if (TraderOutOfStock(offer))
+ {
continue;
}
- if (TraderOfferItemQuestLocked(offer, traderAssorts)) {
+ if (TraderOfferItemQuestLocked(offer, traderAssorts))
+ {
continue;
}
- if (TraderOfferLockedBehindLoyaltyLevel(offer, pmcData)) {
+ if (TraderOfferLockedBehindLoyaltyLevel(offer, pmcData))
+ {
continue;
}
}
// Tiered flea and not trader offer
- if (tieredFlea.Enabled && !OfferIsFromTrader(offer)) {
+ if (tieredFlea.Enabled && !OfferIsFromTrader(offer))
+ {
CheckAndLockOfferFromPlayerTieredFlea(
tieredFlea,
offer,
- tieredFleaLimitTypes,
+ tieredFleaLimitTypes.Keys.ToList(),
pmcData.Info.Level.Value
);
// Do not add offer to build if user does not have access to it
- if (offer.Locked ?? false) {
+ if (offer.Locked.GetValueOrDefault(false))
+ {
continue;
}
}
var key = offer.Items[0].Template;
- if (!offersMap.ContainsKey(key)) {
+ if (!offersMap.ContainsKey(key))
+ {
offersMap.Add(key, []);
}
@@ -256,187 +299,327 @@ public class RagfairOfferHelper(
}
// Get best offer for each item to show on screen
- foreach (var possibleOffers in offersMap.Values) {
+ var offersToSort = new List();
+ foreach (var possibleOffers in offersMap.Values)
+ {
+ // prepare temp list for offers
+ offersToSort.Clear();
+
// Remove offers with locked = true (quest locked) when > 1 possible offers
// single trader item = shows greyed out
// multiple offers for item = is greyed out
- if (possibleOffers.Count > 1) {
+ if (possibleOffers.Count > 1)
+ {
var lockedOffers = GetLoyaltyLockedOffers(possibleOffers, pmcData);
// Exclude locked offers + above loyalty locked offers if at least 1 was found
- possibleOffers = possibleOffers.Where((offer) => !(offer.Locked || lockedOffers.includes(offer._id)));
+ offersToSort = possibleOffers.Where(
+ offer => !(offer.Locked.GetValueOrDefault(false) || lockedOffers.Contains(offer.Id))
+ )
+ .ToList();
// Exclude trader offers over their buy restriction limit
- possibleOffers = getOffersInsideBuyRestrictionLimits(possibleOffers);
+ offersToSort = GetOffersInsideBuyRestrictionLimits(possibleOffers);
}
// Sort offers by price and pick the best
- var offer = ragfairSortHelper.sortOffers(possibleOffers, RagfairSort.PRICE, 0)[0];
- offersToReturn.push(offer);
+ var offer = _ragfairSortHelper.SortOffers(offersToSort, RagfairSort.PRICE)[0];
+ offersToReturn.Add(offer);
}
return offersToReturn;
}
/**
- * Get offers that have not exceeded buy limits
- * @param possibleOffers offers to process
- * @returns Offers
+ * Should a ragfair offer be visible to the player
+ * @param searchRequest Search request
+ * @param itemsToAdd ?
+ * @param traderAssorts Trader assort items - used for filtering out locked trader items
+ * @param offer The flea offer
+ * @param pmcProfile Player profile
+ * @returns True = should be shown to player
*/
- protected List GetOffersInsideBuyRestrictionLimits(List possibleOffers) {
- // Check offer has buy limit + is from trader + current buy count is at or over max
- return possibleOffers.Where((offer) => {
- if (
- offer.BuyRestrictionMax != null &&
- OfferIsFromTrader(offer) &&
- offer.BuyRestrictionCurrent >= offer.BuyRestrictionMax
- ) {
- if (offer.BuyRestrictionCurrent >= offer.BuyRestrictionMax) {
- return false;
- }
+ private bool IsDisplayableOffer(SearchRequestData searchRequest, List itemsToAdd,
+ Dictionary