#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; using Microsoft.Xna.Framework; namespace Barotrauma { internal class PurchasedUpgrade { public readonly UpgradeCategory Category; public readonly UpgradePrefab Prefab; public int Level; public PurchasedUpgrade(UpgradePrefab upgradePrefab, UpgradeCategory category, int level = 1) { Category = category; Prefab = upgradePrefab; Level = level; } public void Deconstruct(out UpgradePrefab prefab, out UpgradeCategory category, out int level) { prefab = Prefab; category = Category; level = Level; } } /// /// This class handles all upgrade logic. /// Storing, applying, checking and validation of upgrades. /// /// /// Upgrades are applied per item basis meaning each item has their own set of slots for upgrades. /// The store applies upgrades globally to categories of items so the purpose of this class is to keep those individual "upgrade slots" in sync. /// The target level of an upgrade is stored in the metadata and is what the store displays and modifies while this class will make sure that /// the upgrades on the items match the values stored in the metadata. /// partial class UpgradeManager { /// /// This one toggles whether or not connected submarines get upgraded too. /// Could probably be removed, I just didn't like magic numbers. /// public const bool UpgradeAlsoConnectedSubs = true; /// /// Prevents the player from upgrading the submarine when we are switching to a new one. /// /// /// In singleplayer we check if CampaignMode.PendingSubmarineSwitch is not null indicating we are switching submarines /// but in multiplayer that value is not synced so we use this variable instead by setting it to false in /// and then set it back to true when the round ends in /// public bool CanUpgrade = true; /// /// This is used by the client in multiplayer, acts like a secondary PendingUpgrades list /// but is not affected by server messages. /// /// /// Not used in singleplayer. /// private List? loadedUpgrades; /// /// This is used by the client to notify the server which upgrades are yet to be paid for. /// /// /// In singleplayer this does nothing. /// public readonly List PurchasedUpgrades = new List(); public readonly List PendingUpgrades = new List(); private CampaignMetadata Metadata => Campaign.CampaignMetadata; private readonly CampaignMode Campaign; private int spentMoney; public event Action? OnUpgradesChanged; public UpgradeManager(CampaignMode campaign) { DebugConsole.Log("Created brand new upgrade manager."); Campaign = campaign; } public UpgradeManager(CampaignMode campaign, XElement element, bool isSingleplayer) : this(campaign) { DebugConsole.Log($"Restored upgrade manager from save file, ({element.Elements().Count()} pending upgrades)."); LoadPendingUpgrades(element, isSingleplayer); } private DateTime lastUpgradeSpeak, lastErrorSpeak; /// /// Purchases an upgrade and handles logic for deducting the credit. /// /// /// Purchased upgrades are temporarily stored in and they are applied /// after the next round starts similarly how items are spawned in the stowage room after the round starts. /// /// /// public void PurchaseUpgrade(UpgradePrefab prefab, UpgradeCategory category) { if (!CanUpgradeSub()) { DebugConsole.ThrowError("Cannot upgrade when switching to another submarine."); return; } int price = prefab.Price.GetBuyprice(GetUpgradeLevel(prefab, category), Campaign.Map?.CurrentLocation); int currentLevel = GetUpgradeLevel(prefab, category); if (currentLevel + 1 > prefab.MaxLevel) { DebugConsole.ThrowError($"Tried to purchase \"{prefab.Name}\" over the max level! ({currentLevel + 1} > {prefab.MaxLevel}). The transaction has been cancelled."); return; } if (price < 0) { Location? location = Campaign.Map?.CurrentLocation; LogError($"Upgrade price is less than 0! ({price})", new Dictionary { { "Level", currentLevel }, { "Saved Level", GetRealUpgradeLevel(prefab, category) }, { "Upgrade", $"{category.Identifier}.{prefab.Identifier}" }, { "Location", location?.Type }, { "Reputation", $"{location?.Reputation?.Value} / {location?.Reputation?.MaxReputation}" }, { "Base Price", prefab.Price.BasePrice } }); } if (Campaign.Money > price) { if (GameMain.NetworkMember == null || GameMain.NetworkMember.IsServer) { // only make the NPC speak if more than 5 minutes have passed since the last purchased service if (lastUpgradeSpeak == DateTime.MinValue || lastUpgradeSpeak.AddMinutes(5) < DateTime.Now) { UpgradeNPCSpeak(TextManager.Get("Dialog.UpgradePurchased"), Campaign.IsSinglePlayer); lastUpgradeSpeak = DateTime.Now; } } Campaign.Money -= price; spentMoney += price; PurchasedUpgrade? upgrade = FindMatchingUpgrade(prefab, category); #if CLIENT DebugLog($"CLIENT: Purchased level {GetUpgradeLevel(prefab, category) + 1} {category.Name}.{prefab.Name} for ${price}", GUI.Style.Orange); #endif if (upgrade == null) { PendingUpgrades.Add(new PurchasedUpgrade(prefab, category)); } else { upgrade.Level++; } #if CLIENT // tell the server that this item is yet to be paid for server side PurchasedUpgrades.Add(new PurchasedUpgrade(prefab, category)); #endif OnUpgradesChanged?.Invoke(); } else { DebugConsole.ThrowError("Tried to purchase an upgrade with insufficient funds, the transaction has not been completed.\n" + $"Upgrade: {prefab.Name}, Cost: {price}, Have: {Campaign.Money}"); } } /// /// Applies all our pending upgrades to the submarine. /// /// /// Upgrades are applied similarly to how items on the submarine are spawned at the start of the round. /// Upgrades should be applied at the start of the round and after the round ends they are written into /// the submarine save and saved there. /// Because of the difficulty of accessing the actual Submarine object from and outpost or when the campaign UI is created /// we modify levels that are shown on the store interface using campaign metadata. /// /// This method should be called by both the client and the server during level generation. /// /// /// public void ApplyUpgrades() { PurchasedUpgrades.Clear(); if (Submarine.MainSub == null) { return; } List pendingUpgrades = PendingUpgrades; if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient) { if (Level.Loaded?.Type != LevelData.LevelType.Outpost) { if (loadedUpgrades != null) { // client receives pending upgrades from the save file pendingUpgrades = loadedUpgrades; } } else { // prevent the client from applying pending upgrades at an outpost when joining mid round return; } } DebugConsole.Log("Applying upgrades..."); foreach (var (prefab, category, level) in pendingUpgrades) { int newLevel = BuyUpgrade(prefab, category, Submarine.MainSub, level); DebugConsole.Log($" - {category.Identifier}.{prefab.Identifier} lvl. {level}, new: ({newLevel})"); if (newLevel > 0) { SetUpgradeLevel(prefab, category, Math.Clamp(newLevel, 0, prefab.MaxLevel)); } } PendingUpgrades.Clear(); loadedUpgrades?.Clear(); loadedUpgrades = null; spentMoney = 0; } /// /// Cancels the pending upgrades and refunds the money spent /// private void RefundUpgrades() { DebugConsole.Log($"Refunded {spentMoney} marks in pending upgrades."); if (spentMoney > 0) { #if CLIENT GUIMessageBox msgBox = new GUIMessageBox(TextManager.Get("UpgradeRefundTitle"), TextManager.Get("UpgradeRefundBody"), new[] { TextManager.Get("Ok") }); msgBox.Buttons[0].OnClicked += msgBox.Close; #endif } Campaign.Money += spentMoney; spentMoney = 0; PendingUpgrades.Clear(); PurchasedUpgrades.Clear(); } public void CreateUpgradeErrorMessage(string text, bool isSinglePlayer, Character character) { // 10 second cooldown on the error message but not the UI sound if (lastErrorSpeak == DateTime.MinValue || lastErrorSpeak.AddSeconds(10) < DateTime.Now) { UpgradeNPCSpeak(text, isSinglePlayer, character); lastErrorSpeak = DateTime.Now; } #if CLIENT SoundPlayer.PlayUISound(GUISoundType.PickItemFail); #endif } /// /// Makes the NPC talk or if no NPC has been specified find the upgrade NPC and make it talk. /// /// /// /// Optional NPC to make talk, if null tries to find one at the outpost. /// /// This might seem a bit spaghetti but it's the only way I could figure out how to do this and make it work /// in both multiplayer and singleplayer because in multiplayer the client doesn't have access to SubmarineInfo.OutpostNPCs list /// so we cannot find the upgrade NPC using that and the client cannot use Character.Speak anyways in multiplayer so the alternative /// is to send network packages when interacting with the NPC. /// partial void UpgradeNPCSpeak(string text, bool isSinglePlayer, Character? character = null); /// /// Validates that upgrade values stored in CampaignMetadata matches the values on the submarine and fixes any inconsistencies. /// Should be called after every round start right after /// /// public void SanityCheckUpgrades(Submarine submarine) { // check walls foreach (Structure wall in submarine.GetWalls(UpgradeAlsoConnectedSubs)) { foreach (UpgradeCategory category in UpgradeCategory.Categories) { foreach (UpgradePrefab prefab in UpgradePrefab.Prefabs) { int level = GetRealUpgradeLevel(prefab, category); if (level == 0 || !prefab.IsWallUpgrade) { continue; } Upgrade? upgrade = wall.GetUpgrade(prefab.Identifier); bool isOverMax = IsOverMaxLevel(level, prefab); if (isOverMax) { SetUpgradeLevel(prefab, category, prefab.MaxLevel); level = prefab.MaxLevel; } if (upgrade == null || upgrade.Level != level || isOverMax) { DebugConsole.AddWarning($"{wall.prefab.Name} has incorrect \"{prefab.Name}\" level! Expected {level} but got {upgrade?.Level ?? 0}. Fixing..."); FixUpgradeOnItem(wall, prefab, level); } } } } // Check items foreach (Item item in submarine.GetItems(UpgradeAlsoConnectedSubs)) { foreach (UpgradeCategory category in UpgradeCategory.Categories) { foreach (UpgradePrefab prefab in UpgradePrefab.Prefabs) { if (!category.CanBeApplied(item, prefab)) { continue; } int level = GetRealUpgradeLevel(prefab, category); if (level == 0) { continue; } Upgrade? upgrade = item.GetUpgrade(prefab.Identifier); bool isOverMax = IsOverMaxLevel(level, prefab); if (isOverMax) { SetUpgradeLevel(prefab, category, prefab.MaxLevel); level = prefab.MaxLevel; } if (upgrade == null || upgrade.Level != level || isOverMax) { DebugConsole.AddWarning($"{item.prefab.Name} has incorrect \"{prefab.Name}\" level! Expected {level} but got {upgrade?.Level ?? 0}{(isOverMax ? " (Over max level!)" : string.Empty)}. Fixing..."); FixUpgradeOnItem(item, prefab, level); } } } } static bool IsOverMaxLevel(int level, UpgradePrefab prefab) => level > prefab.MaxLevel; } private static void FixUpgradeOnItem(ISerializableEntity target, UpgradePrefab prefab, int level) { if (target is MapEntity mapEntity) { mapEntity.SetUpgrade(new Upgrade(target, prefab, level), false); } } /// /// Applies an upgrade on the submarine, should be called by when the round starts. /// /// /// /// /// /// New level that was applied, -1 if no upgrades were applied. private static int BuyUpgrade(UpgradePrefab prefab, UpgradeCategory category, Submarine submarine, int level = 1) { int? newLevel = null; if (category.IsWallUpgrade) { foreach (Structure structure in submarine.GetWalls(UpgradeAlsoConnectedSubs)) { Upgrade upgrade = new Upgrade(structure, prefab, level); structure.AddUpgrade(upgrade, createNetworkEvent: false); Upgrade? newUpgrade = structure.GetUpgrade(prefab.Identifier); if (newUpgrade != null) { SanityCheck(newUpgrade, structure); newLevel ??= newUpgrade.Level; } } } else { foreach (Item item in submarine.GetItems(UpgradeAlsoConnectedSubs)) { if (category.CanBeApplied(item, prefab)) { Upgrade upgrade = new Upgrade(item, prefab, level); item.AddUpgrade(upgrade, createNetworkEvent: false); Upgrade? newUpgrade = item.GetUpgrade(prefab.Identifier); if (newUpgrade != null) { SanityCheck(newUpgrade, item); newLevel ??= newUpgrade.Level; } } } } foreach (Submarine loadedSub in Submarine.Loaded.Where(sub => sub != submarine)) { XElement? root = loadedSub.Info?.SubmarineElement; if (root == null) { continue; } if (root.Name.ToString().Equals("LinkedSubmarine", StringComparison.OrdinalIgnoreCase)) { if (root.Attribute("location") == null) { continue; } // Check if this is our linked submarine ushort dockingPortID = (ushort) root.GetAttributeInt("originallinkedto", 0); if (dockingPortID > 0 && submarine.GetItems(true).Any(item => item.ID == dockingPortID)) { BuyUpgrade(prefab, category, loadedSub, level); } } } return newLevel ?? -1; void SanityCheck(Upgrade newUpgrade, MapEntity target) { if (newLevel != null && newLevel != newUpgrade.Level) { // automatically fix this if it ever happens? DebugConsole.AddWarning($"The upgrade {newUpgrade.Prefab.Name} in {target.Name} has a different level compared to other items! \n" + $"Expected level was ${newLevel} but got {newUpgrade.Level} instead."); } } } /// /// Gets the progress that is shown on the store interface. /// Includes values stored in the metadata and /// /// /// /// public int GetUpgradeLevel(UpgradePrefab prefab, UpgradeCategory category) { if (!Metadata.HasKey(FormatIdentifier(prefab, category))) { return GetPendingLevel(); } return GetRealUpgradeLevel(prefab, category) + GetPendingLevel(); int GetPendingLevel() { PurchasedUpgrade? upgrade = FindMatchingUpgrade(prefab, category); return upgrade?.Level ?? 0; } } /// /// Gets the level of the upgrade that is stored in the metadata. /// /// /// /// public int GetRealUpgradeLevel(UpgradePrefab prefab, UpgradeCategory category) { return !Metadata.HasKey(FormatIdentifier(prefab, category)) ? 0 : Metadata.GetInt(FormatIdentifier(prefab, category), 0); } /// /// Stores the target upgrade level in the campaign metadata. /// /// /// /// private void SetUpgradeLevel(UpgradePrefab prefab, UpgradeCategory category, int level) { Metadata.SetValue(FormatIdentifier(prefab, category), level); } public bool CanUpgradeSub() { if (GameMain.NetworkMember != null && GameMain.NetworkMember.IsClient) { return CanUpgrade; } return Campaign.PendingSubmarineSwitch == null; } public void RefundResetAndReload(SubmarineInfo newSubmarine, bool notifyClients = false) { RefundUpgrades(); ResetUpgrades(); Dictionary newUpgrades = ReloadUpgradeValues(newSubmarine); #if SERVER if (notifyClients) { SendUpgradeResetMessage(newUpgrades); } #endif } /// /// Parses a SubmarineInfo and sets the store values accordingly. /// Used when reloading a previously saved submarine. /// /// private Dictionary ReloadUpgradeValues(SubmarineInfo info) { Dictionary newValues = new Dictionary(); IEnumerable linkedSubElements = info.SubmarineElement.Elements().Where(element => element.Name.ToString().Equals("LinkedSubmarine", StringComparison.OrdinalIgnoreCase)).SelectMany(element => element.Elements()); IEnumerable mainSubElements = info.SubmarineElement.Elements().Where(Predicate); List elements = mainSubElements.Concat(linkedSubElements.Where(Predicate)).ToList(); foreach (UpgradeCategory category in UpgradeCategory.Categories) { foreach (UpgradePrefab prefab in UpgradePrefab.Prefabs) { if (!prefab.UpgradeCategories.Contains(category)) { continue; } List levels = GetUpgradeFromXML(elements, category, prefab); if (levels.Any()) { int level = (int) levels.Average(i => i); newValues.Add(FormatIdentifier(prefab, category), level); } } } foreach (var (dataIdentifier, level) in newValues) { Campaign.CampaignMetadata.SetValue(dataIdentifier, level); } return newValues; static List GetUpgradeFromXML(List elements, UpgradeCategory category, UpgradePrefab prefab) { List levels = new List(); foreach (XElement subElement in elements) { if (!category.CanBeApplied(subElement, prefab)) { continue; } foreach (XElement component in subElement.Elements()) { if (string.Equals(component.Name.ToString(), "upgrade", StringComparison.OrdinalIgnoreCase)) { string identifier = component.GetAttributeString("identifier", string.Empty); int level = component.GetAttributeInt("level", -1); if (string.IsNullOrWhiteSpace(identifier) || level <= 0) { continue; } UpgradePrefab? matchingPrefab = UpgradePrefab.Find(identifier); if (matchingPrefab == null || matchingPrefab != prefab) { continue; } if (matchingPrefab.UpgradeCategories.Contains(category)) { levels.Add(level); } } } } return levels; } static bool Predicate(XElement element) => element.HasElements && element.Elements().Any(e => e.Name.ToString().Equals("upgrade", StringComparison.OrdinalIgnoreCase)); } /// /// Resets our upgrade progress and prices. /// This does not actually remove the upgrades from the submarine but resets the store interface. /// /// /// This method works by iterating thru all upgrade categories and prefabs and checking if they have a /// valid key stored in the metadata, if they do set it to 0, upgrades without a key stored are always /// assumed to be 0 so they don't need to be reset. /// /// Should initially be called server side as we can't trust clients with such a simple notification. /// private void ResetUpgrades() { foreach (UpgradeCategory category in UpgradeCategory.Categories) { foreach (UpgradePrefab prefab in UpgradePrefab.Prefabs) { if (!prefab.UpgradeCategories.Contains(category)) { continue; } string dataIdentifier = FormatIdentifier(prefab, category); if (Metadata.HasKey(dataIdentifier)) { Metadata.SetValue(dataIdentifier, 0); } } } OnUpgradesChanged?.Invoke(); } public void SavePendingUpgrades(XElement? parent, List upgrades) { if (parent == null) { return; } DebugConsole.Log("Saving pending upgrades to save file..."); XElement upgradeElement = new XElement("PendingUpgrades"); foreach (var (prefab, category, level) in upgrades) { upgradeElement.Add(new XElement("PendingUpgrade", new XAttribute("category", category.Identifier), new XAttribute("prefab", prefab.Identifier), new XAttribute("level", level))); } DebugConsole.Log($"Saved {upgradeElement.Elements().Count()} pending upgrades."); parent.Add(upgradeElement); } private void LoadPendingUpgrades(XElement? element, bool isSingleplayer = true) { if (element == null || !element.HasElements) { return; } List pendingUpgrades = new List(); // ReSharper disable once LoopCanBeConvertedToQuery foreach (XElement upgrade in element.Elements()) { string? categoryIdentifier = upgrade.GetAttributeString("category", null); UpgradeCategory? category = UpgradeCategory.Find(categoryIdentifier); if (string.IsNullOrWhiteSpace(categoryIdentifier) || category == null) { continue; } string? prefabIdentifier = upgrade.GetAttributeString("prefab", null); UpgradePrefab? prefab = UpgradePrefab.Find(prefabIdentifier); if (string.IsNullOrWhiteSpace(prefabIdentifier) || prefab == null) { continue; } int level = upgrade.GetAttributeInt("level", -1); if (level < 0) { continue; } pendingUpgrades.Add(new PurchasedUpgrade(prefab, category, level)); } #if CLIENT if (isSingleplayer) { SetPendingUpgrades(pendingUpgrades); } else { loadedUpgrades = pendingUpgrades; } #else SetPendingUpgrades(pendingUpgrades); #endif } public static void LogError(string text, Dictionary data, Exception e = null) { string error = $"{text}\n"; foreach (var (label, value) in data) { error += $" - {label}: {value ?? "NULL"}\n"; } DebugConsole.ThrowError(error.TrimEnd('\n'), e); } public static Dictionary GetMetadataLevels(CampaignMetadata? metadata) { Dictionary values = new Dictionary(); if (metadata == null) { return values; } foreach (UpgradeCategory category in UpgradeCategory.Categories) { foreach (UpgradePrefab prefab in UpgradePrefab.Prefabs) { string identifier = FormatIdentifier(prefab, category); if (metadata.HasKey(identifier) && !values.ContainsKey(identifier)) { values.Add(identifier, metadata.GetInt(identifier)); } } } return values; } /// /// Used to sync the pending upgrades list in multiplayer. /// /// /// /// In singleplayer this is not used and should not be. /// public void SetPendingUpgrades(List upgrades) { PendingUpgrades.Clear(); PendingUpgrades.AddRange(upgrades); OnUpgradesChanged?.Invoke(); } public static void DebugLog(string msg, Color? color = null) { #if UNSTABLE || DEBUG DebugConsole.NewMessage(msg, color ?? Color.GreenYellow); #else DebugConsole.Log(msg); #endif } private PurchasedUpgrade? FindMatchingUpgrade(UpgradePrefab prefab, UpgradeCategory category) => PendingUpgrades.Find(u => u.Prefab == prefab && u.Category == category); private static string FormatIdentifier(UpgradePrefab prefab, UpgradeCategory category) => $"upgrade.{category.Identifier}.{prefab.Identifier}"; } }