Merge tag '4.0.9'

This commit is contained in:
Archangel
2025-12-24 19:30:56 +01:00
21 changed files with 272 additions and 103 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<!-- SPT specific -->
<SptVersion Condition="'$(SptVersion)' == ''">4.0.8</SptVersion>
<SptVersion Condition="'$(SptVersion)' == ''">4.0.9</SptVersion>
<SptCommit Condition="'$(SptCommit)' == ''">a12b34</SptCommit>
<SptBuildTime Condition="'$(SptBuildTime)' == ''">0000000000</SptBuildTime>
<SptBuildType Condition="'$(SptBuildType)' == ''">LOCAL</SptBuildType>
@@ -9930,7 +9930,8 @@
"adjustBotAppearances": true,
"enableChristmasHideout": true,
"enableSanta": true,
"enableRundansEvent": true
"enableRundansEvent": true,
"enableKhorvodEvent": true
},
"startDay": "13",
"startMonth": "12",
@@ -9944,6 +9945,7 @@
"settings": {
"adjustBotAppearances": true,
"enableChristmasHideout": true,
"enableKhorvodEvent": true,
"enableSanta": true
},
"startDay": "1",
@@ -13997,5 +13999,23 @@
}
]
}
},
"khorovodEventTransitWhitelist": {
"shoreline": [
24
],
"lighthouse": [
22
],
"rezervbase": [
19
],
"woods": [
41
],
"bigmap": [
11
],
"interchange": []
}
}
@@ -35191,6 +35191,7 @@
],
"active": false,
"activePVE": false,
"initialFrozenDelaySec": 60,
"applyFrozenEverySec": 1,
"consumables": [
"67586bee39b1b82b0d0f9d06"
@@ -396,7 +396,8 @@ public class DialogueController(
var checkTime = message.DateTime + (message.MaxStorageTime ?? 0);
return timeNow < checkTime;
})
.ToList() ?? [];
.ToList()
?? [];
}
/// <summary>
@@ -781,23 +781,23 @@ public class HideoutController(
ItemEventRouterResponse output
)
{
// Validate that we have a matching production
var productionDict = pmcData.Hideout.Production;
// Find craft/production in player profile
MongoId? prodId = null;
foreach (var (productionId, production) in productionDict)
foreach (var (productionId, productionInProfile) in pmcData.Hideout.Production)
{
// Skip undefined production objects
if (production is null)
// Skip undefined production objects caused by continious crafts
if (productionInProfile is null)
{
continue;
}
if (production.RecipeId != request.RecipeId)
// Not craft we're looking for
if (productionInProfile.RecipeId != request.RecipeId)
{
continue;
}
// Production or ScavCase
// Could be Production or ScavCase
prodId = productionId; // Set to objects key
break;
}
@@ -817,7 +817,6 @@ public class HideoutController(
// Variables for management of skill
var craftingExpAmount = 0;
var counterHoursCrafting = GetCustomSptHoursCraftingTaskConditionCounter(pmcData, recipe);
var totalCraftingHours = counterHoursCrafting.Value;
@@ -886,6 +885,21 @@ public class HideoutController(
serverLocalisationService.GetText("inventory-no_stash_space"),
BackendErrorCodes.NotEnoughSpace
);
return;
}
// Add the crafting result to the stash, marked as FiR
var addItemsRequest = new AddItemsDirectRequest
{
ItemsWithModsToAdd = itemAndChildrenToSendToPlayer,
FoundInRaid = true,
UseSortingTable = false,
Callback = null,
};
inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output);
if (output.Warnings?.Count > 0)
{
return;
}
@@ -908,24 +922,10 @@ public class HideoutController(
}
}
// Add the crafting result to the stash, marked as FiR
var addItemsRequest = new AddItemsDirectRequest
{
ItemsWithModsToAdd = itemAndChildrenToSendToPlayer,
FoundInRaid = true,
UseSortingTable = false,
Callback = null,
};
inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output);
if (output.Warnings?.Count > 0)
{
return;
}
// - increment skill point for crafting
// - delete the production in profile Hideout.Production
// - Increment skill point for crafting
// - Delete the production in profile Hideout.Production
// Hideout Management skill
// ? use a configuration variable for the value?
// ? Use a configuration variable for the value?
var globals = databaseService.GetGlobals();
profileHelper.AddSkillPointsToPlayer(
pmcData,
@@ -607,7 +607,7 @@ public class RepeatableQuestController(
fullProfile.SptData.FreeRepeatableRefreshUsedCount[repeatableTypeLower] = 0;
// Create stupid redundant change requirements from quest data
generatedRepeatables.ChangeRequirement = new Dictionary<MongoId, ChangeRequirement?>();
generatedRepeatables.ChangeRequirement = [];
foreach (var quest in generatedRepeatables.ActiveQuests)
{
generatedRepeatables.ChangeRequirement.TryAdd(
@@ -707,6 +707,7 @@ public class RepeatableQuestController(
EndTime = 0,
FreeChanges = hasAccess ? repeatableConfig.FreeChanges : 0,
FreeChangesAvailable = hasAccess ? repeatableConfig.FreeChangesAvailable : 0,
ChangeRequirement = [],
};
// Add base object that holds repeatable data to profile
@@ -770,6 +771,14 @@ public class RepeatableQuestController(
/// <returns>True if unlocked</returns>
protected bool PlayerHasDailyScavQuestsUnlocked(PmcData pmcData)
{
if (pmcData.TradersInfo.TryGetValue(Traders.FENCE, out var fence))
{
if (fence.Unlocked is not null && !fence.Unlocked.Value)
{
return false;
}
}
return pmcData.Hideout?.Areas?.FirstOrDefault(hideoutArea => hideoutArea.Type == HideoutAreas.IntelligenceCenter)?.Level >= 1;
}
@@ -1820,9 +1820,14 @@ public class BotEquipmentModGenerator(
scopeSlot?.All(slot =>
slot.Properties.Filters.FirstOrDefault()
.Filter.All(tpl =>
itemHelper.IsOfBaseclasses(tpl, whitelistedSightTypes) || itemHelper.IsOfBaseclass(tpl, BaseClasses.MOUNT)
itemHelper.IsItemInDb(tpl)
&& (
itemHelper.IsOfBaseclasses(tpl, whitelistedSightTypes)
|| itemHelper.IsOfBaseclass(tpl, BaseClasses.MOUNT)
)
) ?? false
)
)
?? false
)
// Add mod to allowed list
{
@@ -716,10 +716,8 @@ public class BotWeaponGenerator(
// Try to get cartridges from slots array first, if none found, try Cartridges array
var cartridges =
magazineTemplate.Value.Properties.Slots.FirstOrDefault()?.Properties?.Filters?.FirstOrDefault()?.Filter ?? magazineTemplate
.Value.Properties.Cartridges.FirstOrDefault()
?.Properties?.Filters?.FirstOrDefault()
?.Filter;
magazineTemplate.Value.Properties.Slots.FirstOrDefault()?.Properties?.Filters?.FirstOrDefault()?.Filter
?? magazineTemplate.Value.Properties.Cartridges.FirstOrDefault()?.Properties?.Filters?.FirstOrDefault()?.Filter;
return cartridges ?? [];
}
@@ -0,0 +1,70 @@
using System.Text.Json.Nodes;
using SPTarkov.DI.Annotations;
namespace SPTarkov.Server.Core.Migration.Migrations.Fixes;
[Injectable]
public sealed class InvalidRepeatableQuestFix : AbstractProfileMigration
{
public override string FromVersion
{
get { return "~4.0"; }
}
public override string ToVersion
{
get { return "~4.0"; }
}
public override string MigrationName
{
get { return "InvalidRepeatableQuestFix"; }
}
public override bool CanMigrate(JsonObject profile, IEnumerable<IProfileMigration> previouslyRanMigrations)
{
if (profile["characters"]?["pmc"]?["RepeatableQuests"] is JsonArray repeatables)
{
foreach (var node in repeatables)
{
if (node is not JsonObject quest)
{
continue;
}
var endTimeNode = quest["endTime"];
var endTime = endTimeNode?.GetValue<int>() ?? 0;
if (endTime != 0 && quest["changeRequirement"] is null)
{
return true;
}
}
}
return false;
}
public override JsonObject? Migrate(JsonObject profile)
{
if (profile["characters"]?["pmc"]?["RepeatableQuests"] is JsonArray repeatables)
{
foreach (var node in repeatables)
{
if (node is not JsonObject quest)
{
continue;
}
var endTime = quest["endTime"]?.GetValue<int>() ?? 0;
if (endTime != 0 && quest["changeRequirement"] is null)
{
quest["endTime"] = 0;
}
}
}
return base.Migrate(profile);
}
}
@@ -648,6 +648,9 @@ public record RunddansSettings
[JsonPropertyName("applyFrozenEverySec")]
public double ApplyFrozenEverySec { get; set; }
[JsonPropertyName("initialFrozenDelaySec")]
public double InitialFrozenDelaySec { get; set; }
[JsonPropertyName("consumables")]
public IEnumerable<string> Consumables { get; set; }
@@ -6,7 +6,7 @@ namespace SPTarkov.Server.Core.Models.Eft.Common.Tables;
public record RepeatableQuest : Quest
{
[JsonPropertyName("changeCost")]
public List<ChangeCost?>? ChangeCost { get; set; }
public required List<ChangeCost> ChangeCost { get; set; }
[JsonPropertyName("changeStandingCost")]
public int? ChangeStandingCost { get; set; }
@@ -94,7 +94,7 @@ public record PmcDataRepeatableQuest
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
[JsonPropertyName("changeRequirement")]
public Dictionary<MongoId, ChangeRequirement?>? ChangeRequirement { get; set; }
public required Dictionary<MongoId, ChangeRequirement> ChangeRequirement { get; set; } = [];
[JsonPropertyName("freeChanges")]
public int? FreeChanges { get; set; }
@@ -106,10 +106,10 @@ public record PmcDataRepeatableQuest
public record ChangeRequirement
{
[JsonPropertyName("changeCost")]
public List<ChangeCost?>? ChangeCost { get; set; }
public required List<ChangeCost> ChangeCost { get; set; } = [];
[JsonPropertyName("changeStandingCost")]
public double? ChangeStandingCost { get; set; }
public required double ChangeStandingCost { get; set; }
}
public record ChangeCost
@@ -1,5 +1,6 @@
namespace SPTarkov.Server.Core.Models.Enums;
[Flags]
public enum TransitionType
{
NONE = 0,
@@ -65,10 +65,10 @@ public record ArmorDurability
public int MinLimitPercent { get; set; }
[JsonPropertyName("lowestMaxPercent")]
public int LowestMaxPercent { get; set; }
public int? LowestMaxPercent { get; set; }
[JsonPropertyName("highestMaxPercent")]
public int HighestMaxPercent { get; set; }
public int? HighestMaxPercent { get; set; }
}
public record WeaponDurability
@@ -47,6 +47,9 @@ public record SeasonalEventConfig : BaseConfig
[JsonPropertyName("hostilitySettingsForEvent")]
public required Dictionary<string, Dictionary<string, List<AdditionalHostilitySettings>>> HostilitySettingsForEvent { get; set; }
[JsonPropertyName("khorovodEventTransitWhitelist")]
public required Dictionary<string, List<int>> KhorovodEventTransitWhitelist { get; set; }
/// <summary>
/// Ids of containers on locations that only have Christmas loot
/// </summary>
@@ -143,6 +146,9 @@ public record SeasonalEventSettings
[JsonPropertyName("enableRundansEvent")]
public bool? EnableRundansEvent { get; set; }
[JsonPropertyName("enableKhorvodEvent")]
public bool? EnableKhorvodEvent { get; set; }
}
public record ZombieSettings
@@ -16,12 +16,11 @@ public class ItemBaseClassService(
ServerLocalisationService serverLocalisationService
)
{
private bool _cacheGenerated;
/// <summary>
/// Key = Item tpl, values = Ids of its parents
/// </summary>
private Dictionary<MongoId, HashSet<MongoId>> _itemBaseClassesCache = [];
private readonly Lock _itemBaseClassesLock = new();
private readonly HashSet<MongoId> _rootNodeIds = [];
/// <summary>
@@ -36,23 +35,32 @@ public class ItemBaseClassService(
var items = databaseService.GetItems();
foreach (var item in items)
{
if (string.Equals(item.Value.Type, "Item", StringComparison.OrdinalIgnoreCase))
{
var itemIdToUpdate = item.Value.Id;
if (!_itemBaseClassesCache.ContainsKey(item.Value.Id))
{
_itemBaseClassesCache[item.Value.Id] = [];
AddItemToCache(item.Key);
}
}
AddBaseItems(itemIdToUpdate, item.Value);
public void AddItemToCache(MongoId itemTpl)
{
var itemDb = databaseService.GetItems();
if (!itemDb.TryGetValue(itemTpl, out var item))
{
logger.Error($"Could not add {itemTpl} to cache, it does not exist in the item database!");
return;
}
lock (_itemBaseClassesLock)
{
if (string.Equals(item.Type, "Item", StringComparison.OrdinalIgnoreCase))
{
_itemBaseClassesCache.TryAdd(item.Id, []);
AddBaseItems(item.Id, item);
}
else
{
_rootNodeIds.Add(item.Key);
_rootNodeIds.Add(item.Id);
}
}
_cacheGenerated = true;
}
/// <summary>
@@ -79,11 +87,6 @@ public class ItemBaseClassService(
/// <returns> true if item inherits from base class passed in </returns>
public bool ItemHasBaseClass(MongoId itemTpl, IEnumerable<MongoId> baseClasses)
{
if (!_cacheGenerated)
{
HydrateItemBaseClassCache();
}
if (itemTpl.IsEmpty)
{
logger.Warning("Unable to check itemTpl base class as value passed is null");
@@ -101,14 +104,8 @@ public class ItemBaseClassService(
var existsInCache = _itemBaseClassesCache.TryGetValue(itemTpl, out var baseClassList);
if (!existsInCache)
{
// Not found
if (logger.IsLogEnabled(LogLevel.Debug))
{
logger.Debug(serverLocalisationService.GetText("baseclass-item_not_found", itemTpl.ToString()));
}
// Not found in cache, Hydrate again - some mods add items late in server startup lifecycle
HydrateItemBaseClassCache();
// Not found in cache, attempt to add first
AddItemToCache(itemTpl);
existsInCache = _itemBaseClassesCache.TryGetValue(itemTpl, out baseClassList);
}
@@ -131,11 +128,6 @@ public class ItemBaseClassService(
/// <returns> true if item inherits from base class passed in </returns>
public bool ItemHasBaseClass(MongoId itemTpl, MongoId baseClasses)
{
if (!_cacheGenerated)
{
HydrateItemBaseClassCache();
}
if (itemTpl.IsEmpty)
{
logger.Warning("Unable to check itemTpl base class as value passed is null");
@@ -153,14 +145,8 @@ public class ItemBaseClassService(
var existsInCache = _itemBaseClassesCache.TryGetValue(itemTpl, out var baseClassList);
if (!existsInCache)
{
// Not found
if (logger.IsLogEnabled(LogLevel.Debug))
{
logger.Debug(serverLocalisationService.GetText("baseclass-item_not_found", itemTpl.ToString()));
}
// Not found in cache, Hydrate again - some mods add items late in server startup lifecycle
HydrateItemBaseClassCache();
// Not found in cache, attempt to add first
AddItemToCache(itemTpl);
existsInCache = _itemBaseClassesCache.TryGetValue(itemTpl, out baseClassList);
}
@@ -182,11 +168,6 @@ public class ItemBaseClassService(
/// <returns> array of base classes </returns>
public HashSet<MongoId> GetItemBaseClasses(MongoId itemTpl)
{
if (!_cacheGenerated)
{
HydrateItemBaseClassCache();
}
if (!_itemBaseClassesCache.TryGetValue(itemTpl, out var value))
{
return [];
@@ -58,6 +58,7 @@ public class LocationLifecycleService(
protected readonly PmcConfig PMCConfig = configServer.GetConfig<PmcConfig>();
protected readonly BotConfig BotConfig = configServer.GetConfig<BotConfig>();
protected readonly LostOnDeathConfig LostOnDeathConfig = configServer.GetConfig<LostOnDeathConfig>();
protected readonly SeasonalEventConfig SeasonalEventConfig = configServer.GetConfig<SeasonalEventConfig>();
protected const string Pmc = "pmc";
protected const string Savage = "savage";
@@ -93,21 +94,73 @@ public class LocationLifecycleService(
: playerProfile.CharacterData.ScavData.Skills.Common
);
var transitionType = TransitionType.NONE;
if (request.TransitionType is TransitionType flags)
{
if (flags.HasFlag(TransitionType.COMMON))
{
transitionType = TransitionType.COMMON;
}
if (flags.HasFlag(TransitionType.EVENT))
{
transitionType = TransitionType.EVENT;
}
}
// Raid is starting, adjust run times to reduce server load while player is in raid
RagfairConfig.RunIntervalSeconds = RagfairConfig.RunIntervalValues.InRaid;
HideoutConfig.RunIntervalSeconds = HideoutConfig.RunIntervalValues.InRaid;
var location = GenerateLocationAndLoot(sessionId, request.Location, !request.ShouldSkipLootGeneration ?? true);
var isRundansActive = databaseService.GetGlobals().Configuration.RunddansSettings.Active;
if (transitionType == TransitionType.EVENT)
{
// Handle Runddans / Khorovod event
if (isRundansActive && location.Transits is not null)
{
// Get whitelist for maps transits, event should have 1 only
var matchingTransitWhitelist = SeasonalEventConfig.KhorovodEventTransitWhitelist.GetValueOrDefault(
location.Id.ToLowerInvariant(),
[]
);
foreach (var transits in location.Transits)
{
if (transits.Id is null)
{
continue;
}
// ActivateAfterSeconds sets the timer on the generator, events is needed because it is checked again in the client
// To enable certain stuff for the Khorovod event
if (matchingTransitWhitelist.Contains(transits.Id.Value))
{
transits.ActivateAfterSeconds = 300;
transits.Events = true;
}
else
{
// Disable the other transits in this event, people are only allowed to transit to certain points
transits.IsActive = false;
}
}
}
}
var result = new StartLocalRaidResponseData
{
// PVE_OFFLINE_xxxxxxxx_27_06_2025_20_20_44
ServerId = $"{request.Location}.{request.PlayerSide} {timeUtil.GetTimeStamp()}", // Only used for metrics in client
ServerSettings = databaseService.GetLocationServices(), // TODO - is this per map or global?
Profile = new ProfileInsuredItems { InsuredItems = playerProfile.CharacterData.PmcData.InsuredItems },
LocationLoot = GenerateLocationAndLoot(sessionId, request.Location, !request.ShouldSkipLootGeneration ?? true),
TransitionType = TransitionType.NONE,
LocationLoot = location,
TransitionType = transitionType,
Transition = new Transition
{
TransitionType = TransitionType.NONE,
TransitionType = transitionType,
TransitionRaidId = new MongoId(),
TransitionCount = 0,
VisitedLocations = [],
@@ -128,7 +181,7 @@ public class LocationLifecycleService(
if (transitionData is not null)
{
logger.Success($"Player: {sessionId} is in transit to {request.Location}");
result.Transition.TransitionType = TransitionType.COMMON;
result.Transition.TransitionType = transitionType;
result.Transition.TransitionRaidId = transitionData.TransitionRaidId;
result.Transition.TransitionCount += 1;
@@ -66,7 +66,7 @@ public class CustomItemService(
AddToFleaPriceDb(newItemId, newItemDetails.FleaPriceRoubles);
itemBaseClassService.HydrateItemBaseClassCache();
itemBaseClassService.AddItemToCache(newItemId);
if (itemHelper.IsOfBaseclass(itemClone.Id, BaseClasses.WEAPON))
{
@@ -112,7 +112,7 @@ public class CustomItemService(
AddToFleaPriceDb(newItem.Id, newItemDetails.FleaPriceRoubles);
itemBaseClassService.HydrateItemBaseClassCache();
itemBaseClassService.AddItemToCache(newItem.Id);
if (itemHelper.IsOfBaseclass(newItem.Id, BaseClasses.WEAPON))
{
@@ -539,33 +539,39 @@ public class ProfileFixerService(
foreach (var profileArea in pmcProfile.Hideout?.Areas ?? [])
{
var areaType = profileArea.Type;
var level = profileArea.Level;
var currentLevel = profileArea.Level;
if (level.GetValueOrDefault(0) == 0)
if (currentLevel.GetValueOrDefault(0) == 0)
{
continue;
}
// Get array of hideout area upgrade levels to check for bonuses
// Create array of hideout area upgrade levels player has installed
// Zero indexed
var areaLevelsToCheck = new List<string>();
for (var index = 0; index < level + 1; index++)
// Stage key is saved as string in db
for (var index = 0; index < currentLevel + 1; index++)
{
areaLevelsToCheck.Add(index.ToString());
areaLevelsToCheck.Add(index.ToString()); // Convert to string as hideout stage key is saved as string in db
}
// Iterate over area levels, check for bonuses, add if needed
// Get hideout area data from db
var dbArea = dbHideoutAreas?.FirstOrDefault(area => area.Type == areaType);
if (dbArea is null)
if (dbArea is null || dbArea.Stages is null)
{
continue;
}
// Check if profile is missing any bonuses from each area level
foreach (var areaLevel in areaLevelsToCheck)
{
// Get areas level bonuses from db
var levelBonuses = dbArea.Stages?[areaLevel].Bonuses;
// Get areas level from db
if (!dbArea.Stages.TryGetValue(areaLevel, out var stage))
{
continue;
}
// Get the bonuses for this upgrade stage
var levelBonuses = stage.Bonuses;
if (levelBonuses is null || levelBonuses.Count == 0)
{
continue;
@@ -578,8 +584,10 @@ public class ProfileFixerService(
var profileBonus = GetBonusFromProfile(pmcProfile.Bonuses, bonus);
if (profileBonus is null)
{
// no bonus, add to profile
logger.Debug($"Profile has level {level} area {profileArea.Type} but no bonus found, adding {bonus.Type}");
// No bonus in profile, add it
logger.Debug(
$"Profile has level: {currentLevel} area: {profileArea.Type} but no bonus found, adding: {bonus.Type}"
);
hideoutHelper.ApplyPlayerUpgradesBonus(pmcProfile, bonus);
}
}
@@ -41,6 +41,11 @@ public class App(
logger.Debug($"OS: {Environment.OSVersion.Version} | {Environment.OSVersion.Platform}");
logger.Debug($"Pagefile: {pageFileGb:F2} GB");
logger.Debug($"RAM: {totalMemoryGb:F2} GB");
if (totalMemoryGb < 30)
{
logger.Warning($"Detected RAM ({totalMemoryGb:F2}GB) is smaller than recommended (32GB) you may experience crashes or reduced FPS on large maps");
}
logger.Debug($"Ran as admin: {Environment.IsPrivilegedProcess}");
logger.Debug($"CPU cores: {Environment.ProcessorCount}");
logger.Debug($"PATH: {(Environment.ProcessPath ?? "null returned").Encode(EncodeType.BASE64)}");
@@ -9,6 +9,11 @@
"commandName": "Project",
"hotReloadEnabled": false,
"workingDirectory": "bin/$(Configuration)/$(TargetFramework)"
},
"Spt Server (Mac)": {
"commandName": "Project",
"hotReloadEnabled": false,
"workingDirectory": "bin/$(Configuration)/$(TargetFramework)/osx-arm64"
}
}
}
+3
View File
@@ -25,6 +25,9 @@
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
<AssemblyName>SPT.Server.Linux</AssemblyName>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">
<AssemblyName>SPT.Server.Mac</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Libraries\SPTarkov.Reflection\SPTarkov.Reflection.csproj" />
<ProjectReference Include="..\Libraries\SPTarkov.Server.Core\SPTarkov.Server.Core.csproj" />