using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.ItemEvent; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; namespace SPTarkov.Server.Core.Routers; [Injectable] public class EventOutputHolder(ProfileHelper profileHelper, TimeUtil timeUtil, ICloner cloner) { protected readonly Dictionary> _clientActiveSessionStorage = new(); protected readonly Dictionary _outputStore = new(); /// /// Get a fresh/empty response to send to the client /// /// Player id /// ItemEventRouterResponse public ItemEventRouterResponse GetOutput(MongoId sessionId) { if (_outputStore.TryGetValue(sessionId, out var result)) { return result; } // Nothing found, Create new empty output response ResetOutput(sessionId); _outputStore.TryGetValue(sessionId, out result!); return result; } public void ResetOutput(MongoId sessionId) { var pmcProfile = profileHelper.GetPmcProfile(sessionId); _outputStore.Remove(sessionId); // Create fresh output object _outputStore.Add( sessionId, new ItemEventRouterResponse { ProfileChanges = new Dictionary { { sessionId, new ProfileChange { Id = sessionId, Experience = pmcProfile.Info.Experience, Quests = [], RagFairOffers = [], WeaponBuilds = [], EquipmentBuilds = [], Items = new ItemChanges { NewItems = [], ChangedItems = [], DeletedItems = [], }, Production = [], Improvements = [], Skills = new Skills { Common = [], Mastering = [], Points = 0, }, Health = cloner.Clone(pmcProfile.Health), TraderRelations = [], QuestsStatus = [], } }, }, Warnings = [], } ); } /// /// Update output object with most recent values from player profile /// /// Session id public void UpdateOutputProperties(MongoId sessionId) { var pmcData = profileHelper.GetPmcProfile(sessionId); var profileChanges = _outputStore[sessionId].ProfileChanges[sessionId]; profileChanges.Experience = pmcData.Info.Experience; profileChanges.Health = cloner.Clone(pmcData.Health); profileChanges.Skills.Common = cloner.Clone(pmcData.Skills.Common); // Always send skills for Item event route response profileChanges.Skills.Mastering = cloner.Clone(pmcData.Skills.Mastering); // Clone productions to ensure we preserve the profile jsons data var hideoutProductionNull = pmcData.Hideout.Production == null; if (!hideoutProductionNull) { profileChanges.Production = GetProductionsFromProfileAndFlagComplete(cloner.Clone(pmcData.Hideout.Production), sessionId); } if (pmcData.Hideout.Improvements != null) { profileChanges.Improvements = cloner.Clone(GetImprovementsFromProfileAndFlagComplete(pmcData)); } profileChanges.TraderRelations = ConstructTraderRelations(pmcData.TradersInfo); ResetMoneyTransferLimit(pmcData.MoneyTransferLimitData); profileChanges.MoneyTransferLimitData = pmcData.MoneyTransferLimitData; // Fixes container craft from water collector not resetting after collection + removed completed normal crafts if (!hideoutProductionNull && pmcData.Hideout.Production.Any()) { CleanUpCompleteCraftsInProfile(pmcData.Hideout.Production); } } /// /// Required as continuous productions don't reset and stay at 100% completion but client thinks it hasn't started /// /// Productions in a profile protected void CleanUpCompleteCraftsInProfile(Dictionary? productions) { foreach (var production in productions) { if (production.Value == null) { // cultist circle // remove production in case client already issued a HideoutDeleteProductionCommand and the item is moved to stash productions.Remove(production.Key); } else if ((production.Value.SptIsComplete ?? false) && (production.Value.SptIsContinuous ?? false)) { // Water collector / Bitcoin etc production.Value.SptIsComplete = false; production.Value.Progress = 0; production.Value.StartTimestamp = timeUtil.GetTimeStamp(); } else if (!production.Value.InProgress ?? false) { // Normal completed craft, delete productions.Remove(production.Key); } } } /// /// Return all hideout Improvements from player profile, adjust completed Improvements' completed property to be true /// /// Player profile /// Dictionary of hideout improvements protected Dictionary GetImprovementsFromProfileAndFlagComplete(PmcData pmcData) { foreach (var (key, improvement) in pmcData.Hideout.Improvements) { // Skip completed if (improvement.Completed ?? false) { continue; } if (improvement.ImproveCompleteTimestamp < timeUtil.GetTimeStamp()) { improvement.Completed = true; } } return pmcData.Hideout.Improvements; } /// /// Return productions from player profile except those completed crafts the client has already seen /// /// Productions from player profile /// Player session ID /// Dictionary of hideout productions protected Dictionary GetProductionsFromProfileAndFlagComplete( Dictionary? productions, MongoId sessionId ) { foreach (var production in productions) { if (production.Value is null) // Could be cancelled production, skip item to save processing { continue; } // Complete and is Continuous e.g. water collector if ((production.Value.SptIsComplete ?? false) && (production.Value.SptIsContinuous ?? false)) { continue; } // Skip completed if (!production.Value.InProgress ?? false) { continue; } // Client informed of craft, remove from data returned if (!_clientActiveSessionStorage.TryGetValue(sessionId, out var storageForSessionId)) { _clientActiveSessionStorage.Add(sessionId, []); storageForSessionId = _clientActiveSessionStorage[sessionId]; } // Ensure we don't inform client of production again if (storageForSessionId.ContainsKey(production.Key)) { productions.Remove(production.Key); continue; } // Flag started craft as having been seen by client so it won't happen subsequent times if (production.Value.Progress > 0 && !storageForSessionId.ContainsKey(production.Key)) { storageForSessionId.TryAdd(production.Key, true); } } // Return undefined if there's no crafts to send to client to match live behaviour return productions.Keys.Count > 0 ? productions : null; } protected void ResetMoneyTransferLimit(MoneyTransferLimits limit) { if (limit.NextResetTime < timeUtil.GetTimeStamp()) { limit.NextResetTime += limit.ResetInterval; limit.RemainingLimit = limit.TotalLimit; } } /// /// Convert the internal trader data object into an object we can send to the client /// /// Server data for traders /// Dict of trader id + TraderData protected Dictionary ConstructTraderRelations(Dictionary traderData) { return traderData.ToDictionary( trader => trader.Key, trader => new TraderData { SalesSum = trader.Value.SalesSum, Disabled = trader.Value.Disabled, Loyalty = trader.Value.LoyaltyLevel, Standing = trader.Value.Standing, Unlocked = trader.Value.Unlocked, } ); } }