Merge tag '4.0.12'

This commit is contained in:
Archangel
2026-02-05 22:58:27 +01:00
21 changed files with 146 additions and 44 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<!-- SPT specific -->
<SptVersion Condition="'$(SptVersion)' == ''">4.0.10</SptVersion>
<SptVersion Condition="'$(SptVersion)' == ''">4.0.12</SptVersion>
<SptCommit Condition="'$(SptCommit)' == ''">a12b34</SptCommit>
<SptBuildTime Condition="'$(SptBuildTime)' == ''">0000000000</SptBuildTime>
<SptBuildType Condition="'$(SptBuildType)' == ''">LOCAL</SptBuildType>
@@ -4,7 +4,7 @@
<PackageId>SPTarkov.Common</PackageId>
<Authors>Single Player Tarkov</Authors>
<Description>Common shared library for the Single Player Tarkov projects.</Description>
<Copyright>Copyright (c) Single Player Tarkov 2025</Copyright>
<Copyright>Copyright (c) Single Player Tarkov 2026</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://sp-tarkov.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/sp-tarkov/server-csharp</RepositoryUrl>
+1 -1
View File
@@ -4,7 +4,7 @@
<PackageId>SPTarkov.DI</PackageId>
<Authors>Single Player Tarkov</Authors>
<Description>Dependency injection shared library for the Single Player Tarkov projects.</Description>
<Copyright>Copyright (c) Single Player Tarkov 2025</Copyright>
<Copyright>Copyright (c) Single Player Tarkov 2026</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://sp-tarkov.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/sp-tarkov/server-csharp</RepositoryUrl>
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\Build.props" />
<PropertyGroup>
<PackageId>SPTarkov.Reflection</PackageId>
<Authors>Single Player Tarkov</Authors>
<Description>Reflection library for the Single Player Tarkov server.</Description>
<Copyright>Copyright (c) Single Player Tarkov 2025</Copyright>
<Copyright>Copyright (c) Single Player Tarkov 2026</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://sp-tarkov.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/sp-tarkov/server-csharp</RepositoryUrl>
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\Build.props" />
<PropertyGroup>
<PackageId>SPTarkov.Server.Assets</PackageId>
<Authors>Single Player Tarkov</Authors>
<Description>Asset library for the Single Player Tarkov server.</Description>
<Copyright>Copyright (c) Single Player Tarkov 2025</Copyright>
<Copyright>Copyright (c) Single Player Tarkov 2026</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://sp-tarkov.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/sp-tarkov/server-csharp</RepositoryUrl>
@@ -248,7 +248,7 @@ public class DialogueController(
var fullProfile = saveServer.GetProfile(sessionId);
var dialogue = GetDialogByIdFromProfile(fullProfile, request);
if (dialogue.Messages?.Count == 0)
if (dialogue.Messages == null || dialogue.Messages.Count == 0)
{
return new GetMailDialogViewResponseData
{
@@ -266,9 +266,9 @@ public class DialogueController(
return new GetMailDialogViewResponseData
{
Messages = dialogue.Messages,
Messages = GetLimitedMessages(dialogue.Messages, request.Limit, request.Time),
Profiles = GetProfilesForMail(fullProfile, dialogue.Users),
HasMessagesWithRewards = MessagesHaveUncollectedRewards(dialogue.Messages!),
HasMessagesWithRewards = MessagesHaveUncollectedRewards(dialogue.Messages),
};
}
@@ -410,6 +410,49 @@ public class DialogueController(
return messages.Any(message => (message.Items?.Data?.Count ?? 0) > 0);
}
/// <summary>
/// Gets a subset of messages from before a certain time
/// </summary>
/// <param name="allMessages">The superset of messages</param>
/// <param name="limit">The maximum number of messages to return, null/0 means all</param>
/// <param name="time">Limit to messages before this Unix time (seconds since epoch), null/0 means all</param>
/// <returns>List of matching messages</returns>
protected List<Message> GetLimitedMessages(List<Message> allMessages, int? limit, decimal? time)
{
if ((time == null || time == 0) && (limit == null || limit == 0 || limit >= allMessages.Count))
{
return allMessages;
}
if (time == null || time == 0)
{
time = timeUtil.GetTimeStamp();
}
if (limit == null || limit == 0)
{
limit = int.MaxValue;
}
List<Message> results = [];
for (var i = allMessages.Count - 1; i >= 0; i--)
{
var message = allMessages[i];
if (message.DateTime <= time)
{
results.Add(message);
if (results.Count >= limit)
{
break;
}
}
}
results.Reverse(); // Since we iterated from newest to oldest, reverse so the result is in order
return results;
}
/// <summary>
/// Handle client/mail/dialog/remove
/// Remove an entire dialog with an entity (trader/user)
@@ -162,10 +162,7 @@ public class HideoutController(
return;
}
// Upgrade profile values
profileHideoutArea.Level++;
profileHideoutArea.CompleteTime = 0;
profileHideoutArea.Constructing = false;
var nextLevel = profileHideoutArea.Level + 1;
var hideoutData = hideout.Areas.FirstOrDefault(area => area.Type == profileHideoutArea.Type);
if (hideoutData is null)
@@ -177,12 +174,18 @@ public class HideoutController(
}
// Apply bonuses
if (!hideoutData.Stages.TryGetValue(profileHideoutArea.Level.ToString(), out var hideoutStage))
if (!hideoutData.Stages.TryGetValue(nextLevel.ToString(), out var hideoutStage))
{
logger.Error($"Stage level: {profileHideoutArea.Level} not found for area: {request.AreaType}");
logger.Error($"Stage level: {nextLevel} not found for area: {request.AreaType}");
return;
}
// Upgrade profile values
profileHideoutArea.Level = nextLevel;
profileHideoutArea.CompleteTime = 0;
profileHideoutArea.Constructing = false;
var bonuses = hideoutStage.Bonuses;
if (bonuses?.Count > 0)
{
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Models.Common;
@@ -67,6 +68,33 @@ public class PrestigeController(ProfileHelper profileHelper, DatabaseService dat
profile.SptData.PendingPrestige = pendingPrestige;
profile.ProfileInfo.IsWiped = true;
var prestigeLevels = databaseService.GetTemplates().Prestige?.Elements ?? [];
var prestigeRewards = prestigeLevels
.Slice(0, pendingPrestige.PrestigeLevel.Value)
.SelectMany(prestigeInner => prestigeInner.Rewards);
var customisationTemplateDb = databaseService.GetTemplates().Customization;
foreach (var reward in prestigeRewards)
{
if (!MongoId.IsValidMongoId(reward.Target))
{
continue;
}
if (!customisationTemplateDb.TryGetValue(reward.Target, out var template))
{
continue;
}
// This has to be done before the profile is wiped, as the user can only select a new head during the wipe
if (template.Parent == CustomisationTypeId.HEAD)
{
profileHelper.AddHideoutCustomisationUnlock(profile, reward, CustomisationSource.PRESTIGE);
}
}
await saveServer.SaveProfileAsync(sessionId);
}
}
@@ -1025,15 +1025,24 @@ public class RagfairController(
}
// Only reduce time to end if time remaining is greater than what we would set it to
var differenceInSeconds = playerOffer.EndTime - timeUtil.GetTimeStamp();
if (differenceInSeconds > RagfairConfig.Sell.ExpireSeconds)
var now = timeUtil.GetTimeStamp();
var configExpireSeconds = RagfairConfig.Sell.ExpireSeconds;
var differenceInSeconds = playerOffer.EndTime - now;
if (differenceInSeconds > configExpireSeconds)
{
// `expireSeconds` Default is 71 seconds
var newEndTime = RagfairConfig.Sell.ExpireSeconds + timeUtil.GetTimeStamp();
// TODO: RagfairConfig.Sell.ExpireSeconds should not exist as it should use
// Globals.Configuration.RagFair.OfferDurationTimeInHourAfterRemove (the value actually used by client)
var newEndTime = configExpireSeconds + now;
playerOffer.EndTime = (long?)Math.Round((double)newEndTime);
differenceInSeconds = configExpireSeconds;
}
logger.Debug($"Flagged player offer: {offerId} for expiry in: {TimeSpan.FromTicks(playerOffer.EndTime.Value).ToString()}");
if (logger.IsLogEnabled(LogLevel.Debug) && differenceInSeconds is { } remaining)
{
logger.Debug($"Flagged player: {sessionId} offer: {offerId} for expiry in: {TimeSpan.FromSeconds(remaining).ToString()}");
}
return output;
}
@@ -684,6 +684,9 @@ public class ProfileHelper(
case CustomisationTypeId.UPPER:
rewardToStore.Type = CustomisationType.UPPER;
break;
case CustomisationTypeId.HEAD:
rewardToStore.Type = CustomisationType.HEAD;
break;
default:
logger.Error($"Unhandled customisation unlock type: {template.Parent} not added to profile");
return;
@@ -33,7 +33,7 @@ public sealed class InvalidRepeatableQuestFix : AbstractProfileMigration
}
var endTimeNode = quest["endTime"];
var endTime = endTimeNode?.GetValue<int>() ?? 0;
var endTime = endTimeNode?.GetValue<long>() ?? 0;
if (endTime != 0 && quest["changeRequirement"] is null)
{
@@ -56,7 +56,7 @@ public sealed class InvalidRepeatableQuestFix : AbstractProfileMigration
continue;
}
var endTime = quest["endTime"]?.GetValue<int>() ?? 0;
var endTime = quest["endTime"]?.GetValue<long>() ?? 0;
if (endTime != 0 && quest["changeRequirement"] is null)
{
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\Build.props" />
<PropertyGroup>
<PackageId>SPTarkov.Server.Core</PackageId>
<Authors>Single Player Tarkov</Authors>
<Description>Core library for the Single Player Tarkov server.</Description>
<Copyright>Copyright (c) Single Player Tarkov 2025</Copyright>
<Copyright>Copyright (c) Single Player Tarkov 2026</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://sp-tarkov.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/sp-tarkov/server-csharp</RepositoryUrl>
@@ -113,7 +113,13 @@ public class BotWeaponModLimitService(ISptLogger<BotWeaponModLimitService> logge
var modIsLightOrLaser = itemHelper.IsOfBaseclasses(modTemplate.Id, modLimits.FlashlightLaserBaseTypes);
if (modIsLightOrLaser)
{
return WeaponModLimitReached(modTemplate.Id, modLimits.FlashlightLaser, modLimits.FlashlightLaserMax ?? 0, botRole);
return WeaponModLimitReached(
modTemplate.Id,
modLimits.FlashlightLaser,
modLimits.FlashlightLaserMax ?? 0,
botRole,
"light/laser"
);
}
// Mod is a mount that can hold only flashlights ad limit is reached (don't want to add empty mounts if limit is reached)
@@ -137,10 +143,11 @@ public class BotWeaponModLimitService(ISptLogger<BotWeaponModLimitService> logge
/// <param name="currentCount">current number of this item on gun</param>
/// <param name="maxLimit">mod limit allowed</param>
/// <param name="botRole">role of bot we're checking weapon of</param>
/// <param name="modType">OPTIONAL: Type of mod, scope or lightlaser</param>
/// <returns>true if limit reached</returns>
protected bool WeaponModLimitReached(MongoId modTpl, ItemCount currentCount, int? maxLimit, string botRole)
protected bool WeaponModLimitReached(MongoId modTpl, ItemCount currentCount, int? maxLimit, string botRole, string modType = "scope")
{
// No limit
// No limit, ignore
if (maxLimit is null or 0)
{
return false;
@@ -151,13 +158,13 @@ public class BotWeaponModLimitService(ISptLogger<BotWeaponModLimitService> logge
{
if (logger.IsLogEnabled(LogLevel.Debug))
{
logger.Debug($"[{botRole}] scope limit reached! tried to add {modTpl} but scope count is {currentCount.Count}");
logger.Debug($"[{botRole}] {modType} limit reached! tried to add: {modTpl} but {modType} count is: {currentCount.Count}");
}
return true;
}
// Increment scope count
// Increment mod count limit
currentCount.Count++;
return false;
@@ -683,14 +683,19 @@ public class CircleOfCultistService(
/// <param name="rewardPool">Pool to add items to</param>
protected void AddTaskItemRequirementsToRewardPool(PmcData pmcData, HashSet<MongoId> itemRewardBlacklist, HashSet<MongoId> rewardPool)
{
var activeTasks = pmcData.Quests.Where(quest => quest.Status == QuestStatusEnum.Started);
foreach (var task in activeTasks)
var activeTasks = pmcData.Quests?.Where(quest => quest.Status == QuestStatusEnum.Started);
foreach (var task in activeTasks ?? [])
{
var questData = questHelper.GetQuestFromDb(task.QId, pmcData);
var handoverConditions = questData.Conditions.AvailableForFinish.Where(condition => condition.ConditionType == "HandoverItem");
foreach (var condition in handoverConditions)
if (questData is null)
{
foreach (var neededItem in condition.Target.List)
logger.Warning($"Could not get quest data for QId {task.QId}.");
continue;
}
var handoverConditions = questData.Conditions.AvailableForFinish?.Where(condition => condition.ConditionType == "HandoverItem");
foreach (var condition in handoverConditions ?? [])
{
foreach (var neededItem in condition?.Target?.List ?? [])
{
if (itemRewardBlacklist.Contains(neededItem) || !itemHelper.IsValidItem(neededItem))
{
@@ -505,7 +505,11 @@ public class LocationLifecycleService(
// TODO - Persist each players last visited location history over multiple transits, e.g. using InMemoryCacheService, need to take care to not let data get stored forever
// Store transfer data for later use in `startLocalRaid()` when next raid starts
request.LocationTransit.SptExitName = request.Results.ExitName;
profileActivityService.GetProfileActivityRaidData(sessionId).LocationTransit = request.LocationTransit;
// Update raid data with new location data
var profileActivityRaidData = profileActivityService.GetProfileActivityRaidData(sessionId);
profileActivityRaidData.LocationTransit = request.LocationTransit;
profileActivityRaidData.RaidConfiguration?.Location = locationName;
}
if (!isPmc)
@@ -24,6 +24,7 @@ public class JsonUtil
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
#endif
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
NewLine = "\n",
};
foreach (var registrator in registrators)
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<Import Project="..\..\Build.props" />
<PropertyGroup>
<PackageId>SPTarkov.Server.Web</PackageId>
<Authors>Single Player Tarkov</Authors>
<Description>Common shared library for the Single Player Tarkov projects.</Description>
<Copyright>Copyright (c) Single Player Tarkov 2025</Copyright>
<Copyright>Copyright (c) Single Player Tarkov 2026</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://sp-tarkov.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/sp-tarkov/server-csharp</RepositoryUrl>
+1 -1
View File
@@ -199,7 +199,7 @@ public class ModValidator(ISptLogger<ModValidator> logger, ServerLocalisationSer
new
{
name = mod.ModMetadata.Name,
version = $"{mod.ModMetadata.Version} (targets SPT: {mod.ModMetadata.SptVersion})",
version = $"{mod.ModMetadata.Version} (GUID: {mod.ModMetadata.ModGuid} | targets SPT: {mod.ModMetadata.SptVersion})",
author = mod.ModMetadata.Author,
}
)
+2 -3
View File
@@ -71,7 +71,7 @@ public static class Program
ShowRedConsoleMessage(
e,
"The server has unexpectedly stopped, reach out to #spt-support in our Discord server. Include a screenshot of this message + the below error"
"The server has unexpectedly stopped, reach out to #mod-questions-4-0 in our Discord server. Include a screenshot of this message and the surrounding error(s) above and below"
);
Console.WriteLine("Press any key to exit...");
Console.ReadLine();
@@ -93,6 +93,7 @@ public static class Program
public static async Task StartServer(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
SetConsoleOutputMode();
// Some users don't know how to create a shortcut...
if (!IsRunFromInstallationFolder())
@@ -157,8 +158,6 @@ public static class Program
forwardedHeadersOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
SetConsoleOutputMode();
await app.Services.GetRequiredService<SptServerStartupService>().Startup();
await app.RunAsync();
+1 -1
View File
@@ -4,7 +4,7 @@
<PackageId>SPTarkov.Server</PackageId>
<Authors>Single Player Tarkov</Authors>
<Description>Single Player Tarkov server launcher.</Description>
<Copyright>Copyright (c) Single Player Tarkov 2025</Copyright>
<Copyright>Copyright (c) Single Player Tarkov 2026</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://sp-tarkov.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/sp-tarkov/server-csharp</RepositoryUrl>
+2 -2
View File
@@ -2,7 +2,7 @@
"loggers": [
{
"type": "File",
"logLevel": "Trace",
"logLevel": "Info",
"format": "[%date% %time%][%level%][%logger%] %message%",
"filePath": "./user/logs/spt/",
"filePattern": "spt%DATE%.log",
@@ -23,7 +23,7 @@
},
{
"type": "File",
"logLevel": "Trace",
"logLevel": "Info",
"format": "[%date% %time%][%level%][%logger%] %message%",
"filePath": "./user/logs/requests/",
"filePattern": "requests%DATE%.log",