Merge pull request #372 from hulkhan22/feat/configurable-btr-delivery-time

feat: Configurable BTR delivery time
This commit is contained in:
Chomp
2025-06-07 23:09:14 +01:00
committed by GitHub
9 changed files with 362 additions and 80 deletions
@@ -0,0 +1,4 @@
{
"returnTimeOverrideSeconds": 0,
"runIntervalSeconds": 30
}
@@ -0,0 +1,121 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.DI;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Models.Eft.Profile;
using SPTarkov.Server.Core.Models.Spt.Config;
using SPTarkov.Server.Core.Models.Utils;
using SPTarkov.Server.Core.Servers;
using SPTarkov.Server.Core.Services;
using SPTarkov.Server.Core.Utils;
using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel;
namespace SPTarkov.Server.Core.Callbacks;
[Injectable(TypePriority = OnUpdateOrder.BtrDeliveryCallbacks)]
public class BtrDeliveryCallbacks(
ISptLogger<BtrDeliveryCallbacks> _logger,
BtrDeliveryService _btrDeliveryService,
TimeUtil _timeUtil,
ConfigServer _configServer,
SaveServer _saveServer,
HashUtil _hashUtil,
ItemHelper _itemHelper
)
: IOnUpdate
{
private readonly BtrDeliveryConfig _btrDeliveryConfig = _configServer.GetConfig<BtrDeliveryConfig>();
public Task<bool> OnUpdate(long secondsSinceLastRun)
{
if (secondsSinceLastRun < _btrDeliveryConfig.RunIntervalSeconds)
{
return Task.FromResult(false);
}
ProcessDeliveries();
return Task.FromResult(true);
}
/// <summary>
/// Process BTR delivery items of all profiles prior to being given back to the player through the mail service
/// </summary>
protected void ProcessDeliveries()
{
// Process each installed profile.
foreach (var sessionId in _saveServer.GetProfiles())
{
ProcessDeliveryByProfile(sessionId.Key);
}
}
/// <summary>
/// Process delivery items of a single profile prior to being given back to the player through the mail service
/// </summary>
/// <param name="sessionId">Player id</param>
public void ProcessDeliveryByProfile(string sessionId)
{
// Filter out items that don't need to be processed yet.
var toBeProcessed = FilterDeliveryItems(sessionId);
// Do nothing if no items to process
if (toBeProcessed.Count == 0)
{
return;
}
ProcessDeliveryItems(toBeProcessed, sessionId);
}
/// <summary>
/// Get all delivery items that are ready to be processed in a specific profile
/// </summary>
/// <param name="sessionId">Session/Player id</param>
/// <returns>All delivery items that are ready to be processed</returns>
protected List<BtrDelivery> FilterDeliveryItems(string sessionId)
{
var currentTime = _timeUtil.GetTimeStamp();
var deliveryList = _saveServer.GetProfile(sessionId).BtrDeliveryList;
if (deliveryList != null && deliveryList!.Count > 0)
{
if (_logger.IsLogEnabled(LogLevel.Debug))
{
_logger.Debug($"Found {deliveryList.Count} BTR delivery package(s) in profile {sessionId}");
}
return deliveryList.Where(toBeDelivered => currentTime >= toBeDelivered.ScheduledTime).ToList();
}
return [];
}
/// <summary>
/// This method orchestrates the processing of delivery items in a profile
/// </summary>
/// <param name="packagesToBeDelivered">The delivery items to process</param>
/// <param name="sessionId">session ID that should receive the processed items</param>
protected void ProcessDeliveryItems(List<BtrDelivery> packagesToBeDelivered, string sessionId)
{
if (_logger.IsLogEnabled(LogLevel.Debug))
{
_logger.Debug(
$"Processing {packagesToBeDelivered.Count} BTR delivery package(s), which include a total of: {packagesToBeDelivered.Select(items => items.Items).Count()} items, in profile: {sessionId}"
);
}
// Iterate over each of the insurance packages.
foreach (var package in packagesToBeDelivered)
{
// Create a new root parent ID for the message we'll be sending the player
var rootItemParentId = _hashUtil.Generate();
// Update the delivery items to have the new root parent ID for root/orphaned items
package.Items = _itemHelper.AdoptOrphanedItems(rootItemParentId, package.Items);
_btrDeliveryService.SendBTRDelivery(sessionId, package.Items);
// Remove the fully processed BTR delivery package from the profile.
_btrDeliveryService.RemoveBTRDeliveryPackageFromProfile(sessionId, package);
}
}
}
@@ -5,4 +5,5 @@ public static class OnUpdateOrder
public const int DialogueCallbacks = 1000;
public const int HideoutCallbacks = 2000;
public const int InsuranceCallbacks = 3000;
public const int BtrDeliveryCallbacks = 4000;
}
@@ -79,6 +79,13 @@ public record SptProfile
set;
}
[JsonPropertyName("btrDelivery")]
public List<BtrDelivery>? BtrDeliveryList
{
get;
set;
}
/// <summary>
/// Assort purchases made by player since last trader refresh
/// </summary>
@@ -1056,3 +1063,30 @@ public record Insurance
set;
}
}
public record BtrDelivery
{
[JsonExtensionData]
public Dictionary<string, object> ExtensionData { get; set; }
[JsonPropertyName("_id")]
public string? Id
{
get;
set;
}
[JsonPropertyName("scheduledTime")]
public int? ScheduledTime
{
get;
set;
}
[JsonPropertyName("items")]
public List<Item>? Items
{
get;
set;
}
}
@@ -11,6 +11,7 @@ public static class ConfigTypesExtension
ConfigTypes.AIRDROP => "spt-airdrop",
ConfigTypes.BACKUP => "spt-backup",
ConfigTypes.BOT => "spt-bot",
ConfigTypes.BTR_DELIVERY => "spt-btrdelivery",
ConfigTypes.PMC => "spt-pmc",
ConfigTypes.CORE => "spt-core",
ConfigTypes.HEALTH => "spt-health",
@@ -46,6 +47,7 @@ public static class ConfigTypesExtension
ConfigTypes.AIRDROP => typeof(AirdropConfig),
ConfigTypes.BACKUP => typeof(BackupConfig),
ConfigTypes.BOT => typeof(BotConfig),
ConfigTypes.BTR_DELIVERY => typeof(BtrDeliveryConfig),
ConfigTypes.PMC => typeof(PmcConfig),
ConfigTypes.CORE => typeof(CoreConfig),
ConfigTypes.HEALTH => typeof(HealthConfig),
@@ -80,6 +82,7 @@ public enum ConfigTypes
AIRDROP,
BACKUP,
BOT,
BTR_DELIVERY,
PMC,
CORE,
HEALTH,
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace SPTarkov.Server.Core.Models.Spt.Config;
public record BtrDeliveryConfig : BaseConfig
{
[JsonPropertyName("kind")]
public override string Kind
{
get;
set;
} = "spt-btrdelivery";
/// <summary>
/// Override to control how quickly delivery is processed/returned in seconds
/// </summary>
[JsonPropertyName("returnTimeOverrideSeconds")]
public double ReturnTimeOverrideSeconds
{
get;
set;
}
/// <summary>
/// How often server should process BTR delivery in seconds
/// </summary>
[JsonPropertyName("runIntervalSeconds")]
public double RunIntervalSeconds
{
get;
set;
}
}
@@ -0,0 +1,159 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
using SPTarkov.Server.Core.Models.Eft.Match;
using SPTarkov.Server.Core.Models.Eft.Profile;
using SPTarkov.Server.Core.Models.Enums;
using SPTarkov.Server.Core.Models.Spt.Config;
using SPTarkov.Server.Core.Models.Utils;
using SPTarkov.Server.Core.Servers;
using SPTarkov.Server.Core.Utils;
using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel;
namespace SPTarkov.Server.Core.Services;
[Injectable(InjectionType.Singleton)]
public class BtrDeliveryService(
ISptLogger<BtrDeliveryService> _logger,
DatabaseService _databaseService,
RandomUtil _randomUtil,
HashUtil _hashUtil,
TimeUtil _timeUtil,
SaveServer _saveServer,
MailSendService _mailSendService,
ConfigServer _configServer,
LocalisationService _localisationService
)
{
protected BtrDeliveryConfig _btrDeliveryConfig = _configServer.GetConfig<BtrDeliveryConfig>();
protected TraderConfig _traderConfig = _configServer.GetConfig<TraderConfig>();
protected static List<string> _transferTypes = new()
{
"btr",
"transit"
};
/// <summary>
/// Check if player used BTR or transit item sending service and send items to player via mail if found
/// </summary>
/// <param name="sessionId"> Session ID </param>
/// <param name="request"> End raid request from client </param>
public void HandleItemTransferEvent(string sessionId, EndLocalRaidRequestData request)
{
foreach (var transferType in _transferTypes)
{
var rootId = $"{Traders.BTR}_{transferType}";
List<Item>? itemsToSend = null;
// if rootId doesnt exist in TransferItems, skip
if (!request?.TransferItems?.TryGetValue(rootId, out itemsToSend) ?? false)
{
continue;
}
// Filter out the btr container item from transferred items before delivering
itemsToSend = itemsToSend?.Where(item => item.Id != Traders.BTR).ToList();
if (itemsToSend?.Count == 0)
{
continue;
}
HandleTransferItemDelivery(sessionId, itemsToSend);
}
}
protected void HandleTransferItemDelivery(string sessionId, List<Item> items)
{
var serverProfile = _saveServer.GetProfile(sessionId);
var pmcData = serverProfile.CharacterData.PmcData;
// Remove any items that were returned by the item delivery, but also insured, from the player's insurance list
// This is to stop items being duplicated by being returned from both item delivery and insurance
var deliveredItemIds = items.Select(item => item.Id);
pmcData.InsuredItems = pmcData.InsuredItems
.Where(insuredItem => !deliveredItemIds.Contains(insuredItem.ItemId))
.ToList();
if (_saveServer.GetProfile(sessionId).BtrDeliveryList == null)
{
_saveServer.GetProfile(sessionId).BtrDeliveryList = new List<BtrDelivery>();
}
// Store delivery to send to player later in profile
_saveServer.GetProfile(sessionId).BtrDeliveryList.Add(
new BtrDelivery
{
Id = _hashUtil.Generate(),
ScheduledTime = (int) GetBTRDeliveryReturnTimestamp(),
Items = items
}
);
}
public void SendBTRDelivery(string sessionId, List<Item> items)
{
var dialogueTemplates = _databaseService.GetTrader(Traders.BTR).Dialogue;
if (dialogueTemplates is null)
{
_logger.Error(_localisationService.GetText("inraid-unable_to_deliver_item_no_trader_found", Traders.BTR));
return;
}
if (!dialogueTemplates.TryGetValue("itemsDelivered", out var itemsDelivered))
{
_logger.Error("dialogueTemplates doesn't contain itemsDelivered");
return;
}
var messageId = _randomUtil.GetArrayValue(itemsDelivered);
var messageStoreTime = _timeUtil.GetHoursAsSeconds(_traderConfig.Fence.BtrDeliveryExpireHours);
// Send the items to the player
_mailSendService.SendLocalisedNpcMessageToPlayer(
sessionId,
Traders.BTR,
MessageType.BtrItemsDelivery,
messageId,
items,
messageStoreTime
);
}
/// <summary>
/// Remove a BTR delivery package from a profile using the package's ID.
/// </summary>
/// <param name="sessionId">The session ID of the profile to remove the package from.</param>
/// <param name="delivery">The BTR delivery package to remove.</param>
public void RemoveBTRDeliveryPackageFromProfile(string sessionId, BtrDelivery delivery)
{
var profile = _saveServer.GetProfile(sessionId);
profile.BtrDeliveryList = profile.BtrDeliveryList
.Where(package => package.Id != delivery.Id)
.ToList();
if (_logger.IsLogEnabled(LogLevel.Debug))
{
_logger.Debug($"Removed processed BTR delivery package. Remaining packages: {profile.BtrDeliveryList.Count}");
}
}
/// <summary>
/// Get a timestamp of when items given to the BTR driver should be sent to player.
/// </summary>
/// <returns>Timestamp to return items to player in seconds</returns>
protected double GetBTRDeliveryReturnTimestamp()
{
// If override in config is non-zero, use that
if (_btrDeliveryConfig.ReturnTimeOverrideSeconds > 0)
{
if (_logger.IsLogEnabled(LogLevel.Debug))
{
_logger.Debug($"BTR delivery override used: returning in {_btrDeliveryConfig.ReturnTimeOverrideSeconds} seconds");
}
return _timeUtil.GetTimeStamp() + _btrDeliveryConfig.ReturnTimeOverrideSeconds;
}
return _timeUtil.GetTimeStamp();
}
}
@@ -118,6 +118,7 @@ public class CreateProfileService(
VitalityData = new Vitality(),
InraidData = new Inraid(),
InsuranceList = [],
BtrDeliveryList = [],
TraderPurchases = new Dictionary<string, Dictionary<string, TraderPurchaseData>?>(),
FriendProfileIds = [],
CustomisationUnlocks = []
@@ -8,7 +8,6 @@ using SPTarkov.Server.Core.Models.Eft.Profile;
using SPTarkov.Server.Core.Models.Eft.Quests;
using SPTarkov.Server.Core.Models.Enums;
using SPTarkov.Server.Core.Models.Spt.Config;
using SPTarkov.Server.Core.Models.Spt.Location;
using SPTarkov.Server.Core.Models.Utils;
using SPTarkov.Server.Core.Servers;
using SPTarkov.Server.Core.Utils;
@@ -54,6 +53,7 @@ public class LocationLifecycleService
protected TimeUtil _timeUtil;
protected TraderConfig _traderConfig;
protected TraderHelper _traderHelper;
protected BtrDeliveryService _btrDeliveryService;
public LocationLifecycleService(
ISptLogger<LocationLifecycleService> logger,
@@ -83,7 +83,8 @@ public class LocationLifecycleService
PmcWaveGenerator pmcWaveGenerator,
QuestHelper questHelper,
InsuranceService insuranceService,
MatchBotDetailsCacheService matchBotDetailsCacheService
MatchBotDetailsCacheService matchBotDetailsCacheService,
BtrDeliveryService btrDeliveryService
)
{
_logger = logger;
@@ -114,6 +115,7 @@ public class LocationLifecycleService
_questHelper = questHelper;
_insuranceService = insuranceService;
_matchBotDetailsCacheService = matchBotDetailsCacheService;
_btrDeliveryService = btrDeliveryService;
_locationConfig = _configServer.GetConfig<LocationConfig>();
_inRaidConfig = _configServer.GetConfig<InRaidConfig>();
@@ -442,7 +444,7 @@ public class LocationLifecycleService
var isSurvived = IsPlayerSurvived(request.Results);
// Handle items transferred via BTR or transit to player mailbox
HandleItemTransferEvent(sessionId, request);
_btrDeliveryService.HandleItemTransferEvent(sessionId, request);
// Player is moving between maps
if (isTransfer && request.LocationTransit is not null)
@@ -458,7 +460,6 @@ public class LocationLifecycleService
if (!isPmc)
{
HandlePostRaidPlayerScav(sessionId, pmcProfile, scavProfile, isDead, isTransfer, isSurvived, request);
return;
}
@@ -695,7 +696,7 @@ public class LocationLifecycleService
{
_logger.Error($"post raid fence data not found for: {sessionId}");
}
scavProfile.TradersInfo[Traders.FENCE].Standing = Math.Min(Math.Max(postRaidFenceData.Standing.Value, fenceMin), fenceMax);
// Successful extract as scav, give some rep
@@ -1076,81 +1077,6 @@ public class LocationLifecycleService
}
}
/// <summary>
/// Check if player used BTR or transit item sending service and send items to player via mail if found
/// </summary>
/// <param name="sessionId"> Session ID </param>
/// <param name="request"> End raid request from client </param>
protected void HandleItemTransferEvent(string sessionId, EndLocalRaidRequestData request)
{
var transferTypes = new List<string>
{
"btr",
"transit"
};
foreach (var trasferType in transferTypes)
{
var rootId = $"{Traders.BTR}_{trasferType}";
List<Item>? itemsToSend = null;
// if rootId doesnt exist in TransferItems, skip
if (!request?.TransferItems?.TryGetValue(rootId, out itemsToSend) ?? false)
{
continue;
}
// Filter out the btr container item from transferred items before delivering
itemsToSend = itemsToSend?.Where(item => item.Id != Traders.BTR).ToList();
if (itemsToSend?.Count == 0)
{
continue;
}
TransferItemDelivery(sessionId, Traders.BTR, itemsToSend);
}
}
protected void TransferItemDelivery(string sessionId, string traderId, List<Item> items)
{
var serverProfile = _saveServer.GetProfile(sessionId);
var pmcData = serverProfile.CharacterData.PmcData;
var dialogueTemplates = _databaseService.GetTrader(traderId).Dialogue;
if (dialogueTemplates is null)
{
_logger.Error(_localisationService.GetText("inraid-unable_to_deliver_item_no_trader_found", traderId));
return;
}
if (!dialogueTemplates.TryGetValue("itemsDelivered", out var itemsDelivered))
{
_logger.Error("dialogueTemplates doesn't contain itemsDelivered");
return;
}
var messageId = _randomUtil.GetArrayValue(itemsDelivered);
var messageStoreTime = _timeUtil.GetHoursAsSeconds(_traderConfig.Fence.BtrDeliveryExpireHours);
// Remove any items that were returned by the item delivery, but also insured, from the player's insurance list
// This is to stop items being duplicated by being returned from both item delivery and insurance
var deliveredItemIds = items.Select(item => item.Id);
pmcData.InsuredItems = pmcData.InsuredItems.Where(insuredItem => !deliveredItemIds.Contains(insuredItem.ItemId)
)
.ToList();
// Send the items to the player
_mailSendService.SendLocalisedNpcMessageToPlayer(
sessionId,
traderId,
MessageType.BtrItemsDelivery,
messageId,
items,
messageStoreTime
);
}
protected void HandleInsuredItemLostEvent(
string sessionId,
PmcData preRaidPmcProfile,