Added ability to track loot items and prevent more than a pre-defined amount from spawning

Replaced `location.json` `forcedLootSingleSpawnById` with `lootMaxSpawnLimits`

Added 5 item limit to `Labrys research notes` #290
This commit is contained in:
Chomp
2025-06-21 14:45:08 +01:00
parent 29db232820
commit 21bd868abe
6 changed files with 207 additions and 125 deletions
@@ -46,94 +46,100 @@
"normal": {}
},
"openZones": {},
"forcedLootSingleSpawnById": {
"bigmap": [
"5ac620eb86f7743a8e6e0da0",
"5939e5a786f77461f11c0098",
"64e74a3d4d49d23b2c39d319",
"6614230055afee107f05e998",
"66b22630a6b4e5ec7c02cdb7",
"675f80d4fe1b59cf490d3527",
"67499d0eeca8acb2d2061639",
"675f7acc4076a741a3061566",
"675f80d4fe1b59cf490d3527",
"675f7f224076a741a3061568",
"675f7b168d28a25ec7007dbb"
],
"interchange": ["64e74a5ac2b4f829615ec336", "667a8ef464eea5fdef0db135"],
"lighthouse": [
"6331bb0d1aa9f42b804997a6",
"6398a0861c712b1e1d4dadf1",
"6399f54b0a36db13c823ad21",
"64e74a64aac4cd0a7264ecdf",
"661666458c2aa9cb1602503b"
],
"rezervbase": [
"64e74a4baac4cd0a7264ecdd",
"6398a072e301557ae24cec92",
"67499b3eeca8acb2d2061636",
"67499b9b909d2013670a5029"
],
"shoreline": [
"64e74a534d49d23b2c39d31b",
"661421c7c1f2f548c50ee649",
"6614217b6d9d5abcad0ff098",
"661423200d240a5f5d0f679b",
"6707d1f9571b50abc703b651",
"66760b3deb51b08bd40c2b08",
"67499adbeca8acb2d2061634",
"6614238e0d240a5f5d0f679d",
"666073159916667083033cb9"
],
"tarkovstreets": [
"638df4cc7b560b03794a18d2",
"638cbc68a63f1b49be6a3010",
"638e0057ab150a5f56238960",
"63927b29c115f907b14700b9",
"638dfc803083a019d447768e",
"638e9d5536b3b72c944e2fc7",
"6393262086e646067c176aa2",
"63989ced706b793c7d60cfef",
"63a39e1d234195315d4020bd",
"64e74a35aac4cd0a7264ecdb",
"64e74a186393886f74114a96",
"64e74a1faac4cd0a7264ecd9",
"64e73909cd54ef0580746af3",
"64e74a2fc2b4f829615ec332",
"64e74a274d49d23b2c39d317",
"64f09c02b63b74469b6c149f",
"64f07f7726cfa02c506f8ac0",
"64f69b4267e11a7c6206e010",
"64f5b4f71a5f313cb144c06c",
"657acb2ac900be5902191ac9",
"6582dbf0b8d7830efc45016f",
"66687bc89111279d600b5062"
],
"laboratory": [
"6398a4cfb5992f573c6562b3",
"64e74a44c2b4f829615ec334",
"6711039f9e648049e50b3307",
"6707cef3571b50abc703b64f",
"6707cd70aab679420007e018",
"6707cc67cc1667e49e0f7232",
"6707cf827d279daad80fa95f"
],
"sandbox": ["6575a6ca8778e96ded05a802", "6582bd252b50c61c565828e2"],
"factory4_day": [
"591093bb86f7747caa7bb2ee",
"66c0b39ca1f68fcc1d0c0cc3",
"66a0e523e749756c920d02d0",
"593a87af86f774122f54a951"
],
"factory4_night": [
"591093bb86f7747caa7bb2ee",
"66c0b39ca1f68fcc1d0c0cc3",
"66a0e523e749756c920d02d0",
"593a87af86f774122f54a951"
],
"labyrinth": [
"679b992329acd1f2f60985a5"
]
"lootMaxSpawnLimits": {
"bigmap": {
"5ac620eb86f7743a8e6e0da0": 1,
"5939e5a786f77461f11c0098": 1,
"64e74a3d4d49d23b2c39d319": 1,
"6614230055afee107f05e998": 1,
"66b22630a6b4e5ec7c02cdb7": 1,
"675f80d4fe1b59cf490d3527": 1,
"67499d0eeca8acb2d2061639": 1,
"675f7acc4076a741a3061566": 1,
"675f80d4fe1b59cf490d3527": 1,
"675f7f224076a741a3061568": 1,
"675f7b168d28a25ec7007dbb": 1
},
"interchange": {
"64e74a5ac2b4f829615ec336": 1,
"667a8ef464eea5fdef0db135": 1
},
"lighthouse": {
"6331bb0d1aa9f42b804997a6": 1,
"6398a0861c712b1e1d4dadf1": 1,
"6399f54b0a36db13c823ad21": 1,
"64e74a64aac4cd0a7264ecdf": 1,
"661666458c2aa9cb1602503b": 1
},
"rezervbase": {
"64e74a4baac4cd0a7264ecdd": 1,
"6398a072e301557ae24cec92": 1,
"67499b3eeca8acb2d2061636": 1,
"67499b9b909d2013670a5029": 1
},
"shoreline": {
"64e74a534d49d23b2c39d31b": 1,
"661421c7c1f2f548c50ee649": 1,
"6614217b6d9d5abcad0ff098": 1,
"661423200d240a5f5d0f679b": 1,
"6707d1f9571b50abc703b651": 1,
"66760b3deb51b08bd40c2b08": 1,
"67499adbeca8acb2d2061634": 1,
"6614238e0d240a5f5d0f679d": 1,
"666073159916667083033cb9": 1
},
"tarkovstreets": {
"638df4cc7b560b03794a18d2": 1,
"638cbc68a63f1b49be6a3010": 1,
"638e0057ab150a5f56238960": 1,
"63927b29c115f907b14700b9": 1,
"638dfc803083a019d447768e": 1,
"638e9d5536b3b72c944e2fc7": 1,
"6393262086e646067c176aa2": 1,
"63989ced706b793c7d60cfef": 1,
"63a39e1d234195315d4020bd": 1,
"64e74a35aac4cd0a7264ecdb": 1,
"64e74a186393886f74114a96": 1,
"64e74a1faac4cd0a7264ecd9": 1,
"64e73909cd54ef0580746af3": 1,
"64e74a2fc2b4f829615ec332": 1,
"64e74a274d49d23b2c39d317": 1,
"64f09c02b63b74469b6c149f": 1,
"64f07f7726cfa02c506f8ac0": 1,
"64f69b4267e11a7c6206e010": 1,
"64f5b4f71a5f313cb144c06c": 1,
"657acb2ac900be5902191ac9": 1,
"6582dbf0b8d7830efc45016f": 1,
"66687bc89111279d600b5062": 1
},
"laboratory": {
"6398a4cfb5992f573c6562b3": 1,
"64e74a44c2b4f829615ec334": 1,
"6711039f9e648049e50b3307": 1,
"6707cef3571b50abc703b64f": 1,
"6707cd70aab679420007e018": 1,
"6707cc67cc1667e49e0f7232": 1,
"6707cf827d279daad80fa95f": 1
},
"sandbox": {
"6575a6ca8778e96ded05a802": 1,
"6582bd252b50c61c565828e2": 1
},
"factory4_day": {
"591093bb86f7747caa7bb2ee": 1,
"66c0b39ca1f68fcc1d0c0cc3": 1,
"66a0e523e749756c920d02d0": 1,
"593a87af86f774122f54a951": 1
},
"factory4_night": {
"591093bb86f7747caa7bb2ee": 1,
"66c0b39ca1f68fcc1d0c0cc3": 1,
"66a0e523e749756c920d02d0": 1,
"593a87af86f774122f54a951": 1
},
"labyrinth": {
"679b992329acd1f2f60985a5": 5
}
},
"rogueLighthouseSpawnTimeSettings": {
"enabled": false,
@@ -29,6 +29,7 @@ public class LocationLootGenerator(
SeasonalEventService _seasonalEventService,
ItemFilterService _itemFilterService,
ConfigServer _configServer,
CounterTrackerHelper counterTrackerHelper,
ICloner _cloner
)
{
@@ -349,7 +350,7 @@ public class LocationLootGenerator(
containerDistribution.Add(new ProbabilityObject<string, double>(x, value, value));
}
chosenContainerIds.AddRange(containerDistribution.Draw((int)containerData.ChosenCount));
chosenContainerIds.AddRange(containerDistribution.Draw((int) containerData.ChosenCount));
return chosenContainerIds;
}
@@ -509,7 +510,8 @@ public class LocationLootGenerator(
// Filter out items picked that are already in the above `tplsForced` array
var chosenTpls = containerLootPool
.Draw(itemCountToAdd, _locationConfig.AllowDuplicateItemsInStaticContainers, lockList)
.Where(tpl => !tplsForced.Contains(tpl));
.Where(tpl => !tplsForced.Contains(tpl))
.Where(tpl => !counterTrackerHelper.IncrementCount(tpl));
// Add forced loot to chosen item pool
var tplsToAddToContainer = tplsForced.Concat(chosenTpls);
@@ -695,12 +697,13 @@ public class LocationLootGenerator(
/// <param name="dynamicLootDist"></param>
/// <param name="staticAmmoDist"></param>
/// <param name="locationName">Location to generate loot for</param>
/// <param name="spawnLimitedLoot"></param>
/// <returns>Array of spawn points with loot in them</returns>
public List<SpawnpointTemplate> GenerateDynamicLoot(
LooseLoot dynamicLootDist,
Dictionary<string, List<StaticAmmoDetails>> staticAmmoDist,
string locationName
)
string locationName,
Dictionary<string, int> spawnLimitedLoot)
{
List<SpawnpointTemplate> loot = [];
List<Spawnpoint> dynamicForcedSpawnPoints = [];
@@ -726,28 +729,29 @@ public class LocationLootGenerator(
dynamicLootDist.Spawnpoints.Where(point => point.Template.IsAlwaysSpawn ?? false)
);
// Add forced loot
AddForcedLoot(loot, dynamicForcedSpawnPoints, locationName, staticAmmoDist);
var allDynamicSpawnPoints = dynamicLootDist.Spawnpoints;
// Add forced loot to results
AddForcedLoot(loot, dynamicForcedSpawnPoints, locationName, staticAmmoDist, spawnLimitedLoot);
// Draw from random distribution
var desiredSpawnPointCount = Math.Round(
GetLooseLootMultiplierForLocation(locationName)
* _randomUtil.GetNormallyDistributedRandomNumber(
(double)dynamicLootDist.SpawnpointCount.Mean,
(double)dynamicLootDist.SpawnpointCount.Std
dynamicLootDist.SpawnpointCount.Mean,
dynamicLootDist.SpawnpointCount.Std
)
);
// Positions not in forced but have 100% chance to spawn
List<Spawnpoint> guaranteedLoosePoints = [];
var blacklistedSpawnPoints = _locationConfig.LooseLootBlacklist.GetValueOrDefault(
locationName
);
// Init empty array to hold spawn points, letting us pick them pseudo-randomly
var spawnPointArray = new ProbabilityObjectArray<string, Spawnpoint>(_mathUtil, _cloner);
// Positions not in forced but have 100% chance to spawn
List<Spawnpoint> guaranteedLoosePoints = [];
var allDynamicSpawnPoints = dynamicLootDist.Spawnpoints;
foreach (var spawnPoint in allDynamicSpawnPoints)
{
// Point is blacklisted, skip
@@ -793,7 +797,7 @@ public class LocationLootGenerator(
if (randomSpawnPointCount > 0 && spawnPointArray.Count > 0)
// Add randomly chosen spawn points
{
foreach (var si in spawnPointArray.Draw((int)randomSpawnPointCount, false))
foreach (var si in spawnPointArray.Draw((int) randomSpawnPointCount, false))
{
chosenSpawnPoints.Add(spawnPointArray.Data(si));
}
@@ -915,6 +919,12 @@ public class LocationLootGenerator(
staticAmmoDist
);
// If count reaches max, skip adding item to loot
if (counterTrackerHelper.IncrementCount(createItemResult.Items.FirstOrDefault().Template))
{
continue;
}
// Root id can change when generating a weapon, ensure ids match
spawnPoint.Template.Root = createItemResult.Items.FirstOrDefault().Id;
@@ -922,6 +932,7 @@ public class LocationLootGenerator(
spawnPoint.Template.Items = createItemResult.Items;
loot.Add(spawnPoint.Template);
}
return loot;
@@ -933,19 +944,20 @@ public class LocationLootGenerator(
/// <param name="lootLocationTemplates">List to add forced loot spawn locations to</param>
/// <param name="forcedSpawnPoints">Forced loot locations that must be added</param>
/// <param name="locationName">Name of map currently having force loot created for</param>
/// <param name="staticAmmoDist"></param>
/// <param name="spawnLimitedLoot"></param>
protected void AddForcedLoot(
List<SpawnpointTemplate> lootLocationTemplates,
List<Spawnpoint> forcedSpawnPoints,
string locationName,
Dictionary<string, List<StaticAmmoDetails>> staticAmmoDist
)
Dictionary<string, List<StaticAmmoDetails>> staticAmmoDist,
Dictionary<string, int> spawnLimitedLoot)
{
var lootToForceSingleAmountOnMap =
_locationConfig.ForcedLootSingleSpawnById.GetValueOrDefault(locationName);
if (lootToForceSingleAmountOnMap is not null)
if (spawnLimitedLoot 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)
foreach (var (itemTpl, itemSpawnCountMax) in spawnLimitedLoot)
{
// Get all spawn positions for item tpl in forced loot array
var items = forcedSpawnPoints.Where(forcedSpawnPoint =>
@@ -969,7 +981,7 @@ public class LocationLootGenerator(
_cloner
);
foreach (var si in items)
// use locationId as template.Id is the same across all items
// Use locationId as template.Id is the same across all items
{
spawnPointArray.Add(
new ProbabilityObject<string, Spawnpoint>(
@@ -980,8 +992,8 @@ public class LocationLootGenerator(
);
}
// Choose 1 out of all found spawn positions for spawn id and add to loot array
foreach (var spawnPointLocationId in spawnPointArray.Draw(1, false))
// Choose count from config of spawn positions for spawn id and add to loot array
foreach (var spawnPointLocationId in spawnPointArray.Draw(itemSpawnCountMax, false))
{
var itemToAdd = items.FirstOrDefault(item =>
item.LocationId == spawnPointLocationId
@@ -1001,6 +1013,12 @@ public class LocationLootGenerator(
staticAmmoDist
);
// If count reaches max, skip adding item to loot
if (counterTrackerHelper.IncrementCount(itemTpl))
{
continue;
}
// Update root ID with the dynamically generated ID
lootItem.Root = createItemResult.Items.FirstOrDefault().Id;
lootItem.Items = createItemResult.Items;
@@ -1018,7 +1036,7 @@ public class LocationLootGenerator(
var firstLootItemTpl = forcedLootLocation.Template.Items.FirstOrDefault().Template;
// Skip spawn positions processed already
if (lootToForceSingleAmountOnMap?.Contains(firstLootItemTpl) ?? false)
if (spawnLimitedLoot?.ContainsKey(firstLootItemTpl) ?? false)
{
continue;
}
@@ -0,0 +1,46 @@
using SPTarkov.DI.Annotations;
namespace SPTarkov.Server.Core.Helpers
{
[Injectable]
public class CounterTrackerHelper
{
private Dictionary<string, int> _maxCounts = new();
private readonly Dictionary<string, int> _trackedCounts = new();
/// <summary>
/// Add dictionary of keys and their matching limits to track
/// </summary>
/// <param name="maxCounts">Values to store</param>
public void AddDataToTrack(Dictionary<string, int> maxCounts)
{
_maxCounts = maxCounts;
}
/// <summary>
/// Increment the counter for passed in key, get back value determining if max value passed
/// </summary>
/// <param name="key"></param>
/// <param name="countToIncrementBy"></param>
/// <returns>True = above max count</returns>
public bool IncrementCount(string key, int countToIncrementBy = 1)
{
// Not tracked, skip
if (!_maxCounts.ContainsKey(key))
{
return false;
}
_trackedCounts.TryAdd(key, 0);
_trackedCounts[key] += countToIncrementBy;
return _trackedCounts[key] > _maxCounts[key];
}
public void Clear()
{
_trackedCounts.Clear();
_maxCounts.Clear();
}
}
}
@@ -24,10 +24,10 @@ public record SpawnpointCount
public Dictionary<string, object> ExtensionData { get; set; }
[JsonPropertyName("mean")]
public double? Mean { get; set; }
public required double Mean { get; set; }
[JsonPropertyName("std")]
public double? Std { get; set; }
public required double Std { get; set; }
}
public record SpawnpointTemplate
@@ -34,10 +34,10 @@ public record LocationConfig : BaseConfig
public required Dictionary<string, HashSet<string>> OpenZones { get; set; }
/// <summary>
/// Key = map id, value = item tpls that should only have one forced loot spawn position
/// Key = map id, value = dict of item tpls that should only have x forced loot spawn position
/// </summary>
[JsonPropertyName("forcedLootSingleSpawnById")]
public required Dictionary<string, HashSet<string>> ForcedLootSingleSpawnById { get; set; }
[JsonPropertyName("lootMaxSpawnLimits")]
public required Dictionary<string, Dictionary<string, int>> LootMaxSpawnLimits { get; set; }
/// <summary>
/// How many attempts should be taken to fit an item into a container before giving up
@@ -54,6 +54,7 @@ public class LocationLifecycleService
protected TraderConfig _traderConfig;
protected TraderHelper _traderHelper;
protected BtrDeliveryService _btrDeliveryService;
private readonly CounterTrackerHelper _counterTrackerHelper;
public LocationLifecycleService(
ISptLogger<LocationLifecycleService> logger,
@@ -84,7 +85,8 @@ public class LocationLifecycleService
QuestHelper questHelper,
InsuranceService insuranceService,
MatchBotDetailsCacheService matchBotDetailsCacheService,
BtrDeliveryService btrDeliveryService
BtrDeliveryService btrDeliveryService,
CounterTrackerHelper counterTrackerHelper
)
{
_logger = logger;
@@ -116,6 +118,7 @@ public class LocationLifecycleService
_insuranceService = insuranceService;
_matchBotDetailsCacheService = matchBotDetailsCacheService;
_btrDeliveryService = btrDeliveryService;
_counterTrackerHelper = counterTrackerHelper;
_locationConfig = _configServer.GetConfig<LocationConfig>();
_inRaidConfig = _configServer.GetConfig<InRaidConfig>();
@@ -390,13 +393,13 @@ public class LocationLifecycleService
return locationBaseClone;
}
// Add cusom pmcs to map every time its run
// Add custom pmcs to map every time its run
_pmcWaveGenerator.ApplyWaveChangesToMap(locationBaseClone);
// Adjust raid based on whether this is a scav run
LocationConfig? locationConfigClone = null;
var raidAdjustments = _profileActivityService
.GetProfileActivityRaidData(sessionId)
.GetProfileActivityRaidData(sessionId)?
.RaidAdjustments;
if (raidAdjustments is not null)
{
@@ -406,6 +409,11 @@ public class LocationLifecycleService
var staticAmmoDist = _cloner.Clone(location.StaticAmmo);
var itemsWithSpawnCountLimits = _cloner.Clone(_locationConfig.LootMaxSpawnLimits.GetValueOrDefault(name.ToLower()));
// Store items with spawn count limits inside so they can be accessed later inside static/dynamic loot spawn methods
_counterTrackerHelper.AddDataToTrack(itemsWithSpawnCountLimits);
// Create containers and add loot to them
var staticLoot = _locationLootGenerator.GenerateStaticContainers(
locationBaseClone,
@@ -418,7 +426,8 @@ public class LocationLifecycleService
var dynamicSpawnPoints = _locationLootGenerator.GenerateDynamicLoot(
dynamicLootDistClone,
staticAmmoDist,
name.ToLower()
name.ToLower(),
itemsWithSpawnCountLimits
);
// Push chosen spawn points into returned object
@@ -446,6 +455,9 @@ public class LocationLifecycleService
_profileActivityService.GetProfileActivityRaidData(sessionId).RaidAdjustments = null;
}
// Clean up tracker
_counterTrackerHelper.Clear();
return locationBaseClone;
}
@@ -614,7 +626,7 @@ public class LocationLifecycleService
// Check if new standing has leveled up trader
_traderHelper.LevelUp(fenceId, pmcData);
pmcData.TradersInfo[fenceId].LoyaltyLevel = Math.Max(
(int)pmcData.TradersInfo[fenceId].LoyaltyLevel,
(int) pmcData.TradersInfo[fenceId].LoyaltyLevel,
1
);
@@ -652,7 +664,7 @@ public class LocationLifecycleService
// Check if new standing has leveled up trader
_traderHelper.LevelUp(fenceId, pmcData);
pmcData.TradersInfo[fenceId].LoyaltyLevel = Math.Max(
(int)pmcData.TradersInfo[fenceId].LoyaltyLevel,
(int) pmcData.TradersInfo[fenceId].LoyaltyLevel,
1
);
@@ -684,7 +696,7 @@ public class LocationLifecycleService
fenceStanding += Math.Max(baseGain / extractCount, 0.01);
// Ensure fence loyalty level is not above/below the range -7 to 15
var newFenceStanding = Math.Min(Math.Max((double)fenceStanding, -7), 15);
var newFenceStanding = Math.Min(Math.Max((double) fenceStanding, -7), 15);
_logger.Debug(
$"Old vs new fence standing: {pmcData.TradersInfo[fenceId].Standing}, {newFenceStanding}"
);
@@ -988,7 +1000,7 @@ public class LocationLifecycleService
// Clamp fence standing
var currentFenceStanding = postRaidProfile.TradersInfo[fenceId].Standing;
pmcProfile.TradersInfo[fenceId].Standing = Math.Min(
Math.Max((double)currentFenceStanding, -7),
Math.Max((double) currentFenceStanding, -7),
15
); // Ensure it stays between -7 and 15