using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Game; 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.Spt.Location; using SPTarkov.Server.Core.Models.Spt.Mod; 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.Controllers; [Injectable] public class GameController( ISptLogger logger, IReadOnlyList loadedMods, ConfigServer configServer, DatabaseService databaseService, TimeUtil timeUtil, HttpServerHelper httpServerHelper, HideoutHelper hideoutHelper, ProfileHelper profileHelper, ProfileFixerService profileFixerService, ServerLocalisationService serverLocalisationService, PostDbLoadService postDbLoadService, SeasonalEventService seasonalEventService, GiftService giftService, RaidTimeAdjustmentService raidTimeAdjustmentService, ProfileActivityService profileActivityService ) { protected readonly BotConfig BotConfig = configServer.GetConfig(); protected readonly CoreConfig CoreConfig = configServer.GetConfig(); protected const double Deviation = 0.0001; protected readonly HideoutConfig HideoutConfig = configServer.GetConfig(); protected readonly HttpConfig HttpConfig = configServer.GetConfig(); /// /// Handle client/game/start /// /// /// Session/Player id /// public void GameStart(string url, MongoId sessionId, long startTimeStampMs) { profileActivityService.AddActiveProfile(sessionId, startTimeStampMs); if (sessionId.IsEmpty) { logger.Error($"{nameof(sessionId)} is empty on GameController.GameStart"); return; } // repeatableQuests are stored by in profile.Quests due to the responses of the client (e.g. Quests in // offraidData). Since we don't want to clutter the Quests list, we need to remove all completed (failed or // successful) repeatable quests. We also have to remove the Counters from the repeatableQuests var fullProfile = profileHelper.GetFullProfile(sessionId); if (fullProfile is null) { logger.Error($"{nameof(fullProfile)} is null on GameController.GameStart"); return; } fullProfile.FriendProfileIds ??= []; if (fullProfile.ProfileInfo?.IsWiped is not null && fullProfile.ProfileInfo.IsWiped.Value) { return; } if (fullProfile.ProfileInfo?.InvalidOrUnloadableProfile is not null && fullProfile.ProfileInfo.InvalidOrUnloadableProfile.Value) { return; } fullProfile.CharacterData!.PmcData!.WishList ??= new(); fullProfile.CharacterData.ScavData!.WishList ??= new(); if (fullProfile.DialogueRecords is not null) { profileFixerService.CheckForAndFixDialogueAttachments(fullProfile); } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Started game with session {sessionId} {fullProfile.ProfileInfo?.Username}"); } var pmcProfile = fullProfile.CharacterData.PmcData; if (CoreConfig.Fixes.FixProfileBreakingInventoryItemIssues) { profileFixerService.FixProfileBreakingInventoryItemIssues(pmcProfile); } if (pmcProfile.Health is not null) { UpdateProfileHealthValues(pmcProfile); } if (pmcProfile.Inventory is not null) { SendPraporGiftsToNewProfiles(pmcProfile); SendMechanicGiftsToNewProfile(pmcProfile); } profileFixerService.CheckForAndRemoveInvalidTraders(fullProfile); profileFixerService.CheckForAndFixPmcProfileIssues(pmcProfile); if (pmcProfile.Hideout is not null) { profileFixerService.AddMissingHideoutBonusesToProfile(pmcProfile, databaseService.GetHideout().Areas); hideoutHelper.SetHideoutImprovementsToCompleted(pmcProfile); pmcProfile.UnlockHideoutWallInProfile(); // Handle if player has been inactive for a long time, catch up on hideout update before the user goes to his hideout if (!profileActivityService.ActiveWithinLastMinutes(sessionId, HideoutConfig.UpdateProfileHideoutWhenActiveWithinMinutes)) { hideoutHelper.UpdatePlayerHideout(sessionId); } } LogProfileDetails(fullProfile); SaveActiveModsToProfile(fullProfile); if (pmcProfile.Info is not null) { AddPlayerToPmcNames(pmcProfile); } if (pmcProfile.Skills?.Common is not null) { WarnOnActiveBotReloadSkill(pmcProfile); } seasonalEventService.GivePlayerSeasonalGifts(sessionId); // Set activity timestamp at the end of the method, so that code that checks for an older timestamp (Updating hideout) can still run profileActivityService.SetActivityTimestamp(sessionId); } /// /// Handle client/game/config /// /// Session/Player id /// GameConfigResponse public GameConfigResponse GetGameConfig(MongoId sessionId) { var profile = profileHelper.GetPmcProfile(sessionId); var gameTime = profile?.Stats?.Eft?.OverallCounters?.Items?.FirstOrDefault(c => c.Key!.Contains("LifeTime") && c.Key.Contains("Pmc"))?.Value ?? 0D; var config = new GameConfigResponse { Languages = databaseService.GetLocales().Languages, IsNdaFree = false, IsReportAvailable = false, IsTwitchEventMember = false, Language = "en", Aid = profile?.Aid, Taxonomy = 6, ActiveProfileId = sessionId, Backend = new Backend { Lobby = httpServerHelper.GetBackendUrl(), Trading = httpServerHelper.GetBackendUrl(), Messaging = httpServerHelper.GetBackendUrl(), Main = httpServerHelper.GetBackendUrl(), RagFair = httpServerHelper.GetBackendUrl(), }, UseProtobuf = false, UtcTime = timeUtil.GetTimeStamp(), TotalInGame = gameTime, SessionMode = "pve", PurchasedGames = new PurchasedGames { IsEftPurchased = true, IsArenaPurchased = false }, IsGameSynced = true, }; return config; } /// /// Handle client/game/mode /// /// Session/Player id /// /// public GameModeResponse GetGameMode(MongoId sessionId, GameModeRequestData requestData) { return new GameModeResponse { GameMode = "pve", BackendUrl = httpServerHelper.GetBackendUrl() }; } /// /// Handle client/server/list /// /// Session/Player id /// public List GetServer(MongoId sessionId) { return [new ServerDetails { Ip = HttpConfig.BackendIp, Port = HttpConfig.BackendPort }]; } /// /// Handle client/match/group/current /// /// Session/Player id /// public CurrentGroupResponse GetCurrentGroup(MongoId sessionId) { return new CurrentGroupResponse { Squad = [] }; } /// /// Handle client/checkVersion /// /// Session/Player id /// public CheckVersionResponse GetValidGameVersion(MongoId sessionId) { return new CheckVersionResponse { IsValid = true, LatestVersion = CoreConfig.CompatibleTarkovVersion }; } /// /// Handle client/game/keepalive /// /// Session/Player id /// public GameKeepAliveResponse GetKeepAlive(MongoId sessionId) { profileActivityService.SetActivityTimestamp(sessionId); return new GameKeepAliveResponse { Message = "OK", UtcTime = timeUtil.GetTimeStamp() }; } /// /// Handle singleplayer/settings/getRaidTime /// /// Session/Player id /// /// public RaidChanges GetRaidTime(MongoId sessionId, GetRaidTimeRequest request) { return raidTimeAdjustmentService.GetRaidAdjustments(sessionId, request); } /// /// /// Session/Player id /// public SurveyResponseData GetSurvey(MongoId sessionId) { return CoreConfig.Survey; } /// /// Players set botReload to a high value and don't expect the crazy fast reload speeds, give them a warn about it /// /// Player profile protected void WarnOnActiveBotReloadSkill(PmcData pmcProfile) { var botReloadSkill = pmcProfile.GetSkillFromProfile(SkillTypes.BotReload); if (botReloadSkill?.Progress > 0) { logger.Warning(serverLocalisationService.GetText("server_start_player_active_botreload_skill")); } } /// /// When player logs in, iterate over all active effects and reduce timer /// /// Profile to adjust values for protected void UpdateProfileHealthValues(PmcData pmcProfile) { var healthLastUpdated = pmcProfile.Health?.UpdateTime; var currentTimeStamp = timeUtil.GetTimeStamp(); var diffSeconds = currentTimeStamp - healthLastUpdated; // Update just occurred if (healthLastUpdated >= currentTimeStamp) { return; } // Base values double energyRegenPerHour = 60; double hydrationRegenPerHour = 60; var hpRegenPerHour = 456.6; // Set new values, whatever is smallest energyRegenPerHour += pmcProfile .Bonuses!.Where(bonus => bonus.Type == BonusType.EnergyRegeneration) .Aggregate(0d, (sum, bonus) => sum + bonus.Value!.Value); hydrationRegenPerHour += pmcProfile .Bonuses!.Where(bonus => bonus.Type == BonusType.HydrationRegeneration) .Aggregate(0d, (sum, bonus) => sum + bonus.Value!.Value); hpRegenPerHour += pmcProfile .Bonuses!.Where(bonus => bonus.Type == BonusType.HealthRegeneration) .Aggregate(0d, (sum, bonus) => sum + bonus.Value!.Value); // Player has energy deficit if (pmcProfile.Health?.Energy?.Current - pmcProfile.Health?.Energy?.Maximum <= Deviation) { // Set new value, whatever is smallest pmcProfile.Health!.Energy!.Current += Math.Round(energyRegenPerHour * (diffSeconds!.Value / 3600)); if (pmcProfile.Health.Energy.Current > pmcProfile.Health.Energy.Maximum) { pmcProfile.Health.Energy.Current = pmcProfile.Health.Energy.Maximum; } } // Player has hydration deficit if (pmcProfile.Health?.Hydration?.Current - pmcProfile.Health?.Hydration?.Maximum <= Deviation) { pmcProfile.Health!.Hydration!.Current += Math.Round(hydrationRegenPerHour * (diffSeconds!.Value / 3600)); if (pmcProfile.Health.Hydration.Current > pmcProfile.Health.Hydration.Maximum) { pmcProfile.Health.Hydration.Current = pmcProfile.Health.Hydration.Maximum; } } // Check all body parts DecreaseBodyPartEffectTimes(pmcProfile, hpRegenPerHour, diffSeconds.Value); // Update both values as they've both been updated pmcProfile.Health.UpdateTime = currentTimeStamp; } /// /// Check for and update any timers on effect found on body parts /// /// Player /// /// protected void DecreaseBodyPartEffectTimes(PmcData pmcProfile, double hpRegenPerHour, double diffSeconds) { foreach (var bodyPart in pmcProfile.Health!.BodyParts!.Select(bodyPartKvP => bodyPartKvP.Value)) { // Check part hp if (bodyPart.Health!.Current < bodyPart.Health.Maximum) { bodyPart.Health.Current += Math.Round(hpRegenPerHour * (diffSeconds / 3600)); } if (bodyPart.Health.Current > bodyPart.Health.Maximum) { bodyPart.Health.Current = bodyPart.Health.Maximum; } if (bodyPart.Effects is null || bodyPart.Effects.Count == 0) { continue; } // Look for effects foreach (var (effectId, effect) in bodyPart.Effects) { // remove effects below 1, .e.g. bleeds at -1 if (effect.Time < 1) { // More than 30 minutes has passed if (diffSeconds > timeUtil.GetMinutesAsSeconds(30)) { bodyPart.Effects.Remove(effectId); } continue; } // Decrement effect time value by difference between current time and time health was last updated effect.Time -= diffSeconds; if (effect.Time < 1) // Effect time was sub 1, set floor it can be { effect.Time = 1; } } } } /// /// Send starting gifts to profile after x days /// /// Profile to add gifts to protected void SendPraporGiftsToNewProfiles(PmcData pmcProfile) { var timeStampProfileCreated = pmcProfile.Info?.RegistrationDate; var oneDaySeconds = timeUtil.GetHoursAsSeconds(24); var currentTimeStamp = timeUtil.GetTimeStamp(); // One day post-profile creation if (currentTimeStamp > timeStampProfileCreated + oneDaySeconds) { giftService.SendPraporStartingGift(pmcProfile.SessionId.Value, 1); } // Two day post-profile creation if (currentTimeStamp > timeStampProfileCreated + oneDaySeconds * 2) { giftService.SendPraporStartingGift(pmcProfile.SessionId.Value, 2); } } /// /// Mechanic sends players a measuring tape on profile start for some reason /// /// protected void SendMechanicGiftsToNewProfile(PmcData pmcProfile) { giftService.SendGiftWithSilentReceivedCheck("MechanicGiftDay1", pmcProfile.SessionId.Value, 1); } /// /// Get a list of installed mods and save their details to the profile being used /// /// Profile to add mod details to protected void SaveActiveModsToProfile(SptProfile fullProfile) { fullProfile.SptData!.Mods ??= []; foreach (var mod in loadedMods) { if ( fullProfile.SptData.Mods.Any(m => m.Author == mod.ModMetadata.Author && m.Version == mod.ModMetadata.Version.ToString() && m.Name == mod.ModMetadata.Name ) ) { // exists already, skip continue; } fullProfile.SptData.Mods.Add( new ModDetails { Author = mod.ModMetadata.Author, Version = mod.ModMetadata.Version.ToString(), Name = mod.ModMetadata.Name, Url = mod.ModMetadata.Url, DateAdded = timeUtil.GetTimeStamp(), } ); } } /// /// Add the logged in players name to PMC name pool /// /// Profile of player to get name from protected void AddPlayerToPmcNames(PmcData pmcProfile) { var playerName = pmcProfile.Info?.Nickname; if (playerName is not null) { var bots = databaseService.GetBots().Types; // Official names can only be 15 chars in length if (playerName.Length > BotConfig.BotNameLengthLimit) { return; } // Skip if player name exists already if (bots!.TryGetValue("bear", out var bearBot)) { if (bearBot is not null && bearBot.FirstNames!.Any(x => x == playerName)) { bearBot.FirstNames!.Add(playerName); } } if (bots.TryGetValue("bear", out var usecBot)) { if (usecBot is not null && usecBot.FirstNames!.Any(x => x == playerName)) { usecBot.FirstNames!.Add(playerName); } } } } /// /// /// protected void LogProfileDetails(SptProfile fullProfile) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Profile made with: {fullProfile.SptData?.Version}"); logger.Debug($"Server version: {ProgramStatics.SPT_VERSION()} {ProgramStatics.COMMIT()}"); logger.Debug($"Debug enabled: {ProgramStatics.DEBUG()}"); logger.Debug($"Mods enabled: {ProgramStatics.MODS()}"); } } public void Load() { postDbLoadService.PerformPostDbLoadActions(); } }